Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'bun:test'
import type {
ScheduledJob,
ScheduledJobRun,
} from '@/lib/schedules/scheduleTypes'
import { groupScheduledTaskRuns } from './scheduledTaskResultsUtils'

function makeJob(input: Pick<ScheduledJob, 'id' | 'name'>): ScheduledJob {
return {
...input,
query: `Query for ${input.name}`,
scheduleType: 'daily',
scheduleTime: '09:00',
enabled: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}
}

function makeRun(input: {
id: string
jobId: string
startedAt: string
status?: ScheduledJobRun['status']
}): ScheduledJobRun {
return {
id: input.id,
jobId: input.jobId,
startedAt: input.startedAt,
status: input.status ?? 'completed',
result: `Result for ${input.id}`,
}
}

describe('groupScheduledTaskRuns', () => {
it('groups runs by scheduled task and sorts groups by latest run', () => {
const groups = groupScheduledTaskRuns({
jobs: [makeJob({ id: 'news', name: 'Morning News' })],
runs: [
makeRun({
id: 'news-old',
jobId: 'news',
startedAt: '2026-01-02T09:00:00.000Z',
}),
makeRun({
id: 'prices-new',
jobId: 'prices',
startedAt: '2026-01-03T14:00:00.000Z',
}),
makeRun({
id: 'news-new',
jobId: 'news',
startedAt: '2026-01-04T09:00:00.000Z',
}),
],
})

expect(groups.map((group) => group.id)).toEqual(['news', 'prices'])
expect(groups[0]).toMatchObject({
id: 'news',
name: 'Morning News',
resultCount: 2,
latestRun: { id: 'news-new' },
})
expect(groups[0]?.runs.map((run) => run.id)).toEqual([
'news-new',
'news-old',
])
})

it('keeps missing jobs visible under an unknown task label', () => {
const groups = groupScheduledTaskRuns({
jobs: [],
runs: [
makeRun({
id: 'orphan-run',
jobId: 'deleted-job',
startedAt: '2026-01-02T09:00:00.000Z',
}),
],
})

expect(groups).toHaveLength(1)
expect(groups[0]).toMatchObject({
id: 'deleted-job',
name: 'Unknown scheduled task',
resultCount: 1,
latestRun: { id: 'orphan-run' },
})
})

it('keeps running runs first without changing the latest-run header data', () => {
const groups = groupScheduledTaskRuns({
jobs: [makeJob({ id: 'news', name: 'Morning News' })],
runs: [
makeRun({
id: 'completed-new',
jobId: 'news',
startedAt: '2026-01-04T09:00:00.000Z',
status: 'completed',
}),
makeRun({
id: 'running-old',
jobId: 'news',
startedAt: '2026-01-03T09:00:00.000Z',
status: 'running',
}),
],
})

expect(groups[0]?.latestRun.id).toBe('completed-new')
expect(groups[0]?.runs.map((run) => run.id)).toEqual([
'running-old',
'completed-new',
])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,33 @@ import relativeTime from 'dayjs/plugin/relativeTime'
import {
Calendar,
CheckCircle2,
ChevronDown,
Clock,
Loader2,
RotateCcw,
Square,
XCircle,
} from 'lucide-react'
import type { FC } from 'react'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
useScheduledJobRuns,
useScheduledJobs,
} from '@/lib/schedules/scheduleStorage'
import type {
ScheduledJob,
ScheduledJobRun,
} from '@/lib/schedules/scheduleTypes'
import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes'
import {
groupScheduledTaskRuns,
type JobRunWithDetails,
} from './scheduledTaskResultsUtils'

dayjs.extend(relativeTime)

interface JobRunWithDetails extends ScheduledJobRun {
job: ScheduledJob | undefined
}

interface ScheduledTaskResultsProps {
onViewRun: (run: ScheduledJobRun) => void
onCancelRun: (runId: string) => void
Expand All @@ -46,6 +49,22 @@ const getStatusIcon = (status: JobRunWithDetails['status']) => {

const formatTimestamp = (dateString: string) => dayjs(dateString).fromNow()

const formatRunTimestamp = (dateString: string) => {
const date = dayjs(dateString)

if (date.isSame(dayjs(), 'day')) {
return `Today, ${date.format('h:mm A')}`
}
if (date.isSame(dayjs().subtract(1, 'day'), 'day')) {
return `Yesterday, ${date.format('h:mm A')}`
}

return date.format('MMM D, h:mm A')
}

const getRunPreview = (run: JobRunWithDetails) =>
run.finalResult ?? run.result ?? run.error

export const ScheduledTaskResults: FC<ScheduledTaskResultsProps> = ({
onViewRun,
onCancelRun,
Expand All @@ -54,28 +73,23 @@ export const ScheduledTaskResults: FC<ScheduledTaskResultsProps> = ({
const { jobRuns } = useScheduledJobRuns()
const { jobs } = useScheduledJobs()

const sortedRuns: JobRunWithDetails[] = useMemo(() => {
const enrichWithJob = (run: ScheduledJobRun): JobRunWithDetails => ({
...run,
job: jobs.find((j) => j.id === run.jobId),
})

const running = jobRuns
.filter((r) => r.status === 'running')
.map(enrichWithJob)

const completedOrFailed = jobRuns
.filter((r) => r.status === 'completed' || r.status === 'failed')
.sort(
(a, b) =>
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
)
.map(enrichWithJob)
const taskGroups = useMemo(
() => groupScheduledTaskRuns({ runs: jobRuns, jobs }),
[jobRuns, jobs],
)
const [expandedGroupId, setExpandedGroupId] = useState<
string | null | undefined
>(undefined)

return [...running, ...completedOrFailed]
}, [jobRuns, jobs])
const visibleExpandedGroupId =
expandedGroupId === undefined
? (taskGroups[0]?.id ?? null)
: expandedGroupId !== null &&
!taskGroups.some((group) => group.id === expandedGroupId)
? (taskGroups[0]?.id ?? null)
: expandedGroupId

if (!sortedRuns.length) {
if (!taskGroups.length) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-muted-foreground">
<Calendar className="h-10 w-10 opacity-50" />
Expand All @@ -85,62 +99,107 @@ export const ScheduledTaskResults: FC<ScheduledTaskResultsProps> = ({
}

return (
<div className="space-y-2">
{sortedRuns.map((run) => (
<Button
key={run.id}
variant="ghost"
onClick={() => onViewRun(run)}
className="h-auto w-full justify-start rounded-xl border border-border/50 bg-card p-4 text-left transition-all hover:border-border"
<div className="space-y-3">
{taskGroups.map((group) => (
<Collapsible
key={group.id}
open={visibleExpandedGroupId === group.id}
onOpenChange={(open) => setExpandedGroupId(open ? group.id : null)}
className="rounded-xl border border-border bg-card shadow-sm transition-all hover:border-border"
>
<div className="flex w-full items-start gap-3">
{getStatusIcon(run.status)}
<CollapsibleTrigger className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-accent/40">
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 ${
visibleExpandedGroupId === group.id ? '' : '-rotate-90'
}`}
/>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted">
{getStatusIcon(group.latestRun.status)}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="truncate font-medium text-foreground text-sm">
{run.job?.name}
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium text-foreground">
{group.name}
</span>
<span className="flex items-center gap-1 text-muted-foreground text-xs">
<Clock className="h-3 w-3" />
{formatTimestamp(run.startedAt)}
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-muted-foreground text-xs">
{group.resultCount}{' '}
{group.resultCount === 1 ? 'result' : 'results'}
</span>
</div>
{run.result && (
<p className="line-clamp-2 text-ellipsis text-muted-foreground text-xs">
{run.result}
</p>
)}
<div className="mt-1 flex items-center gap-1 text-muted-foreground text-xs">
<Clock className="h-3 w-3" />
<span>Latest {formatTimestamp(group.latestRun.startedAt)}</span>
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="border-border border-t px-4 pt-3 pb-4">
<div className="space-y-2">
{group.runs.map((run) => {
const preview = getRunPreview(run)

return (
<div
key={run.id}
className="flex items-start gap-3 rounded-lg border border-border bg-background p-3"
>
<div className="pt-0.5">{getStatusIcon(run.status)}</div>
<button
type="button"
onClick={() => onViewRun(run)}
className="min-w-0 flex-1 text-left"
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground text-sm">
{formatRunTimestamp(run.startedAt)}
</span>
<span className="text-muted-foreground text-xs">
{run.status}
</span>
</div>
{preview && (
<p className="mt-1 line-clamp-2 text-muted-foreground text-xs">
{preview}
</p>
)}
</button>
<div className="flex shrink-0 items-center gap-1">
{run.status === 'running' && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => onCancelRun(run.id)}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
aria-label="Cancel run"
>
<Square className="h-3.5 w-3.5" />
</Button>
)}
{run.status === 'failed' && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => onRetryRun(run.jobId)}
className="text-muted-foreground hover:text-foreground"
aria-label="Retry run"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => onViewRun(run)}
className="text-muted-foreground hover:text-foreground"
>
View
</Button>
</div>
</div>
)
})}
Comment on lines +137 to +199
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Running tasks no longer sorted to the top within a group

The previous implementation explicitly floated running runs before completed/failed ones. In the new grouping, runs are sorted purely by startedAt (newest-first), so a currently-running task that started earlier than a recently-completed run will appear below it in the list. In practice this edge case is rare, but it's a behavioral regression worth confirming is intentional.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTaskResults.tsx
Line: 144-206

Comment:
**Running tasks no longer sorted to the top within a group**

The previous implementation explicitly floated `running` runs before `completed`/`failed` ones. In the new grouping, runs are sorted purely by `startedAt` (newest-first), so a currently-running task that started earlier than a recently-completed run will appear below it in the list. In practice this edge case is rare, but it's a behavioral regression worth confirming is intentional.

How can I resolve this? If you propose a fix, please make it concise.

</div>
{run.status === 'running' && (
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation()
onCancelRun(run.id)
}}
className="shrink-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
aria-label="Cancel run"
>
<Square className="h-3.5 w-3.5" />
</Button>
)}
{run.status === 'failed' && (
<Button
variant="ghost"
size="icon-sm"
onClick={(e) => {
e.stopPropagation()
onRetryRun(run.jobId)
}}
className="shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Retry run"
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
</div>
</Button>
</CollapsibleContent>
</Collapsible>
))}
</div>
)
Expand Down
Loading
Loading