diff --git a/packages/browseros-agent/apps/agent/components/ai-elements/run-result-dialog.tsx b/packages/browseros-agent/apps/agent/components/ai-elements/run-result-dialog.tsx index 9f851317d..fa3ab5843 100644 --- a/packages/browseros-agent/apps/agent/components/ai-elements/run-result-dialog.tsx +++ b/packages/browseros-agent/apps/agent/components/ai-elements/run-result-dialog.tsx @@ -2,15 +2,19 @@ import dayjs from 'dayjs' import duration from 'dayjs/plugin/duration' import { AlertCircle, + Bot, Check, CheckCircle2, + ChevronDown, Copy, Loader2, + MessageSquare, RotateCcw, Square, XCircle, } from 'lucide-react' import { type FC, useState } from 'react' +import { useNavigate } from 'react-router' import { Button } from '@/components/ui/button' import { Dialog, @@ -19,7 +23,15 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { ScrollArea } from '@/components/ui/scroll-area' +import { SCHEDULED_TASK_CONTINUE_IN_CHAT_EVENT } from '@/lib/constants/analyticsEvents' +import { track } from '@/lib/metrics/track' import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes' import { MessageResponse } from './message' @@ -54,6 +66,18 @@ export const RunResultDialog: FC = ({ onRetryRun, }) => { const [copied, setCopied] = useState(false) + const navigate = useNavigate() + + const handleContinueInChat = (mode: 'chat' | 'agent') => { + if (!run?.result) return + onOpenChange(false) + track(SCHEDULED_TASK_CONTINUE_IN_CHAT_EVENT, { mode }) + const key = `scheduled-task-${run.id}` + sessionStorage.setItem(key, run.result) + navigate( + `/home/chat?q=${encodeURIComponent(key)}&mode=${mode}&source=scheduled-task`, + ) + } const handleCopy = async () => { if (!run?.result) return @@ -142,6 +166,26 @@ export const RunResultDialog: FC = ({ )} )} + {run.result && ( + + + + + + handleContinueInChat('chat')}> + + Chat + + handleContinueInChat('agent')}> + + Assistant + + + + )} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTaskResultGroup.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTaskResultGroup.tsx new file mode 100644 index 000000000..295a9a03a --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTaskResultGroup.tsx @@ -0,0 +1,158 @@ +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import { + CheckCircle2, + ChevronDown, + Clock, + Loader2, + RotateCcw, + Square, + XCircle, +} from 'lucide-react' +import type { FC } from 'react' +import { Button } from '@/components/ui/button' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible' +import type { JobRunWithDetails, ScheduledJob, ScheduledJobRun } from './types' + +dayjs.extend(relativeTime) + +interface ScheduledTaskResultGroupProps { + job: ScheduledJob + runs: JobRunWithDetails[] + isOpen: boolean + onOpenChange: (open: boolean) => void + onViewRun: (run: ScheduledJobRun) => void + onCancelRun: (runId: string) => void + onRetryRun: (jobId: string) => void +} + +const getStatusIcon = (status: JobRunWithDetails['status']) => { + switch (status) { + case 'completed': + return + case 'running': + return + case 'failed': + return + } +} + +const formatTimestamp = (dateString: string) => dayjs(dateString).fromNow() + +export const ScheduledTaskResultGroup: FC = ({ + job, + runs, + isOpen, + onOpenChange, + onViewRun, + onCancelRun, + onRetryRun, +}) => { + const latestRun = runs[0] + const latestTime = latestRun ? formatTimestamp(latestRun.startedAt) : '' + const runningCount = runs.filter((r) => r.status === 'running').length + + return ( + + + + + + +
+ {runs.map((run) => ( + + )} + {run.status === 'failed' && ( + + )} +
+ + ))} + +
+
+ ) +} diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTaskResults.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTaskResults.tsx index 8e02ad7ce..5eaa8825d 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTaskResults.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/ScheduledTaskResults.tsx @@ -1,31 +1,13 @@ -import dayjs from 'dayjs' -import relativeTime from 'dayjs/plugin/relativeTime' -import { - Calendar, - CheckCircle2, - Clock, - Loader2, - RotateCcw, - Square, - XCircle, -} from 'lucide-react' +import { Calendar } from 'lucide-react' import type { FC } from 'react' -import { useMemo } from 'react' -import { Button } from '@/components/ui/button' +import { useMemo, useState } from 'react' import { useScheduledJobRuns, useScheduledJobs, } from '@/lib/schedules/scheduleStorage' -import type { - ScheduledJob, - ScheduledJobRun, -} from '@/lib/schedules/scheduleTypes' - -dayjs.extend(relativeTime) - -interface JobRunWithDetails extends ScheduledJobRun { - job: ScheduledJob | undefined -} +import type { ScheduledJobRun } from '@/lib/schedules/scheduleTypes' +import { ScheduledTaskResultGroup } from './ScheduledTaskResultGroup' +import { groupRunsByJob } from './types' interface ScheduledTaskResultsProps { onViewRun: (run: ScheduledJobRun) => void @@ -33,19 +15,6 @@ interface ScheduledTaskResultsProps { onRetryRun: (jobId: string) => void } -const getStatusIcon = (status: JobRunWithDetails['status']) => { - switch (status) { - case 'completed': - return - case 'running': - return - case 'failed': - return - } -} - -const formatTimestamp = (dateString: string) => dayjs(dateString).fromNow() - export const ScheduledTaskResults: FC = ({ onViewRun, onCancelRun, @@ -53,29 +22,14 @@ export const ScheduledTaskResults: FC = ({ }) => { const { jobRuns } = useScheduledJobRuns() const { jobs } = useScheduledJobs() + const [openGroupId, setOpenGroupId] = useState(null) - 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) - - return [...running, ...completedOrFailed] - }, [jobRuns, jobs]) + const groupedRuns = useMemo( + () => groupRunsByJob(jobRuns, jobs), + [jobRuns, jobs], + ) - if (!sortedRuns.length) { + if (!groupedRuns.length) { return (
@@ -86,61 +40,17 @@ export const ScheduledTaskResults: FC = ({ return (
- {sortedRuns.map((run) => ( - - )} - {run.status === 'failed' && ( - - )} -
- + {groupedRuns.map(({ job, runs }) => ( + setOpenGroupId(open ? job.id : null)} + onViewRun={onViewRun} + onCancelRun={onCancelRun} + onRetryRun={onRetryRun} + /> ))}
) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/types.ts b/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/types.ts index 4631a7c65..f4d9c9067 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/types.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/scheduled-tasks/types.ts @@ -11,3 +11,53 @@ export interface ScheduledTasksStorage { loadRuns(): Promise saveRuns(runs: ScheduledJobRun[]): Promise } + +export interface JobRunWithDetails extends ScheduledJobRun { + job: ScheduledJob +} + +export interface JobGroup { + job: ScheduledJob + runs: JobRunWithDetails[] +} + +export function groupRunsByJob( + jobRuns: ScheduledJobRun[], + jobs: ScheduledJob[], +): JobGroup[] { + const jobsById = new Map(jobs.map((j) => [j.id, j])) + const runsByJob = new Map() + + for (const run of jobRuns) { + const job = jobsById.get(run.jobId) ?? { + id: run.jobId, + name: 'Unknown task', + query: '', + scheduleType: 'daily' as const, + enabled: false, + createdAt: '', + updatedAt: '', + } + + const enriched = { ...run, job } + const existing = runsByJob.get(run.jobId) ?? [] + existing.push(enriched) + runsByJob.set(run.jobId, existing) + } + + const groups: JobGroup[] = [] + + for (const [, runs] of runsByJob) { + const sorted = runs.sort( + (a, b) => + new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), + ) + groups.push({ job: sorted[0].job, runs: sorted }) + } + + return groups.sort((a, b) => { + const latestA = a.runs[0]?.startedAt ?? '' + const latestB = b.runs[0]?.startedAt ?? '' + return new Date(latestB).getTime() - new Date(latestA).getTime() + }) +} diff --git a/packages/browseros-agent/apps/agent/entrypoints/newtab/index/NewTabChat.tsx b/packages/browseros-agent/apps/agent/entrypoints/newtab/index/NewTabChat.tsx index 164c3afc4..7d72a6633 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/newtab/index/NewTabChat.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/newtab/index/NewTabChat.tsx @@ -79,10 +79,11 @@ export const NewTabChat: FC = () => { // biome-ignore lint/correctness/useExhaustiveDependencies: must only run once on mount useEffect(() => { if (hasSentInitialRef.current) return - const query = searchParams.get('q') + const queryParam = searchParams.get('q') const chatMode = searchParams.get('mode') const tabIdsParam = searchParams.get('tabs') - if (!query) return + const source = searchParams.get('source') + if (!queryParam) return hasSentInitialRef.current = true if (chatMode === 'chat' || chatMode === 'agent') { @@ -90,6 +91,15 @@ export const NewTabChat: FC = () => { } setSearchParams({}, { replace: true }) + const query = + source === 'scheduled-task' + ? (sessionStorage.getItem(queryParam) ?? queryParam) + : queryParam + + if (source === 'scheduled-task') { + sessionStorage.removeItem(queryParam) + } + const actionType = searchParams.get('actionType') const tabName = searchParams.get('tabName') const tabDescription = searchParams.get('tabDescription') diff --git a/packages/browseros-agent/apps/agent/entrypoints/newtab/index/ScheduleResults.tsx b/packages/browseros-agent/apps/agent/entrypoints/newtab/index/ScheduleResults.tsx index 82ab09df7..f04cb7c1b 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/newtab/index/ScheduleResults.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/newtab/index/ScheduleResults.tsx @@ -31,17 +31,14 @@ import { useScheduledJobRuns, useScheduledJobs, } from '@/lib/schedules/scheduleStorage' -import type { - ScheduledJob, - ScheduledJobRun, -} from '@/lib/schedules/scheduleTypes' +import { + groupRunsByJob, + type JobGroup, + type JobRunWithDetails, +} from '../../app/scheduled-tasks/types' dayjs.extend(relativeTime) -interface JobRunWithDetails extends ScheduledJobRun { - job: ScheduledJob | undefined -} - const MAX_DISPLAY_COUNT = 3 const SCHEDULE_RESULTS_COLLAPSED_KEY = 'schedule-results-collapsed' @@ -63,6 +60,7 @@ export const ScheduleResults: FC = () => { const stored = localStorage.getItem(SCHEDULE_RESULTS_COLLAPSED_KEY) return stored !== 'true' }) + const [openGroupId, setOpenGroupId] = useState(null) const [viewingRun, setViewingRun] = useState(null) const handleOpenChange = (open: boolean) => { @@ -75,30 +73,23 @@ export const ScheduleResults: FC = () => { const runningCount = jobRuns.filter((r) => r.status === 'running').length - const displayedRuns: JobRunWithDetails[] = useMemo(() => { - const enrichWithJob = (run: ScheduledJobRun): JobRunWithDetails => ({ - ...run, - job: jobs.find((j) => j.id === run.jobId), - }) + const groupedRuns: JobGroup[] = useMemo(() => { + const allGroups = groupRunsByJob(jobRuns, jobs) - const runningJobs = jobRuns - .filter((r) => r.status === 'running') - .map(enrichWithJob) + const withRunning = allGroups.filter((g) => + g.runs.some((r) => r.status === 'running'), + ) + const withoutRunning = allGroups.filter( + (g) => !g.runs.some((r) => r.status === 'running'), + ) - if (runningJobs.length >= MAX_DISPLAY_COUNT) { - return runningJobs + const result = [...withRunning] + for (const group of withoutRunning) { + if (result.length >= MAX_DISPLAY_COUNT) break + result.push(group) } - const completedOrFailed = jobRuns - .filter((r) => r.status === 'completed' || r.status === 'failed') - .sort( - (a, b) => - new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), - ) - .slice(0, MAX_DISPLAY_COUNT - runningJobs.length) - .map(enrichWithJob) - - return [...runningJobs, ...completedOrFailed] + return result }, [jobRuns, jobs]) const viewRun = (run: JobRunWithDetails) => { @@ -117,7 +108,7 @@ export const ScheduleResults: FC = () => { track(SCHEDULED_TASK_RETRIED_EVENT) } - if (!displayedRuns.length) return null + if (!groupedRuns.length) return null return ( { - {displayedRuns.map((run) => ( - - )} - {run.status === 'failed' && ( + {groupedRuns.map(({ job, runs }) => { + const latestRun = runs[0] + const latestTime = latestRun + ? formatTimestamp(latestRun.startedAt) + : '' + const groupRunningCount = runs.filter( + (r) => r.status === 'running', + ).length + + return ( + setOpenGroupId(open ? job.id : null)} + > + - )} - - - ))} + + + +
+ {runs.map((run) => ( + + )} + {run.status === 'failed' && ( + + )} +
+ + ))} + +
+
+ ) + })}