Repository Public
1'use client';2import { Suspense, useState } from 'react';3import dynamic from 'next/dynamic';4import { useRouter, useSearchParams } from 'next/navigation';5import { useQueryClient } from '@tanstack/react-query';6import type { AgentTask } from '@cr/shared';7import { ArrowLeft, Download, Plus } from 'lucide-react';8import { api, API_BASE, errorMessage } from '@/lib/api';9import { qk, upsertTask, useProjects, useTask, useTasks } from '@/lib/queries';10import { useBreakpoint, useNow } from '@/hooks/use-viewport';11import { DASH, fmtBytes, fmtDateTime, fmtDuration, fmtNum } from '@/lib/format';12import { TASK_ACTIVE } from '@/lib/states';13import { toast } from '@/stores/toasts';14import { Button, IconButton } from '@/components/ui/button';15import { ConfirmDialog } from '@/components/ui/dialog';16import { EmptyState, ErrorState, PageHeader, Skeleton, Spinner } from '@/components/ui/misc';17import { TaskStateBadge } from '@/components/sessions/state-badge';18import { CreateTaskDialog } from '@/components/tasks/create-task-dialog';19import { MobileTaskList } from '@/components/tasks/mobile-task-list';20import { IllusTasks } from '@/components/ui/illustrations';21import { cn } from '@/lib/cn';2223const TaskLog = dynamic(() => import('@/components/tasks/task-log'), { ssr: false, loading: () => <div className="flex h-full items-center justify-center"><Spinner /></div> });2425const durationSec = (t: AgentTask, now: number): number | null => (t.startedAt ? ((t.endedAt ? new Date(t.endedAt).getTime() : now) - new Date(t.startedAt).getTime()) / 1000 : null);2627function TasksInner() {28 const params = useSearchParams();29 const router = useRouter();30 const bp = useBreakpoint();31 const q = useTasks();32 const now = useNow(1000);33 const [creating, setCreating] = useState(false);34 const selected = params.get('t');35 const select = (id: string | null) => router.replace(id ? `/tasks/?t=${id}` : '/tasks/');36 const mobile = bp === 'mobile';37 const list = q.data ?? [];3839 const listPane = (40 <div className="flex h-full min-w-0 flex-col">41 <PageHeader title="Tasks" description="Background commands with captured logs. Nothing is retried automatically." actions={<Button variant="primary" onClick={() => setCreating(true)} data-testid="new-task"><Plus className="size-3.5" /> New task</Button>} />42 <div className="min-h-0 flex-1 overflow-y-auto" data-testid="task-list">43 {q.isPending ? <div className="flex flex-col gap-2 p-4">{[0, 1, 2].map((i) => <Skeleton key={i} className="h-12" />)}</div>44 : q.isError ? <ErrorState error={q.error} onRetry={() => q.refetch()} />45 : list.length === 0 ? <EmptyState illustration={<IllusTasks />} title="No tasks yet" description="Run a build, a test suite or any long command in the background." />46 : (47 <ul className="divide-y divide-line">48 {list.map((t) => (49 <li key={t.id}>50 <button onClick={() => select(t.id)} aria-current={t.id === selected ? 'true' : undefined} data-testid="task-row" className={cn('flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-surface-2 md:px-6', t.id === selected && 'bg-surface-2')}>51 <span className="min-w-0 flex-1">52 <span className="block truncate text-[13px] font-medium text-text">{t.title}</span>53 <span className="tnum block truncate text-[11px] text-muted">{t.host} · {fmtDuration(durationSec(t, now))}{t.exitCode != null ? ` · exit ${t.exitCode}` : ''}</span>54 </span>55 <TaskStateBadge state={t.state} />56 </button>57 </li>58 ))}59 </ul>60 )}61 </div>62 </div>63 );64 return (65 <>66 {mobile ? (selected ? <TaskDetail id={selected} onBack={() => select(null)} /> : <MobileTaskList onSelect={select} onNew={() => setCreating(true)} />) : (67 <div className="grid h-full grid-cols-[minmax(320px,2fr)_3fr]">68 <div className="min-w-0 border-r border-line">{listPane}</div>69 {selected ? <TaskDetail id={selected} /> : <EmptyState title="Select a task" description="Its live log, resource usage and artifacts appear here." className="h-full" />}70 </div>71 )}72 <CreateTaskDialog open={creating} onClose={() => setCreating(false)} onCreated={(t) => select(t.id)} />73 </>74 );75}7677function TaskDetail({ id, onBack }: { id: string; onBack?: () => void }) {78 const qc = useQueryClient();79 const router = useRouter();80 const q = useTask(id);81 const projects = useProjects().data;82 const now = useNow(1000);83 const [confirmCancel, setConfirmCancel] = useState(false);84 const [busy, setBusy] = useState(false);85 if (q.isPending) return <div className="flex h-full items-center justify-center"><Spinner /></div>;86 if (q.isError) return <ErrorState error={q.error} onRetry={() => q.refetch()} className="h-full" />;87 const t = q.data;88 const active = TASK_ACTIVE.includes(t.state);89 const retry = async () => {90 setBusy(true);91 try {92 const n = await api.post<AgentTask>(`/tasks/${t.id}/retry`);93 upsertTask(qc, n);94 router.replace(`/tasks/?t=${n.id}`);95 } catch (e) {96 toast.error('Retry failed', errorMessage(e));97 } finally {98 setBusy(false);99 }100 };101 return (102 <div className="flex h-full min-w-0 flex-col" data-testid="task-detail">103 <div className="flex flex-wrap items-center gap-2 border-b border-line px-3 py-2.5 md:px-4">104 {onBack && <IconButton label="Back to tasks" noTooltip onClick={onBack}><ArrowLeft className="size-4" /></IconButton>}105 <h2 className="min-w-0 flex-1 truncate text-sm font-semibold">{t.title}</h2>106 <TaskStateBadge state={t.state} />107 {active && <Button size="sm" variant="danger" disabled={t.state === 'CANCELLING'} onClick={() => setConfirmCancel(true)} data-testid="cancel-task">Cancel</Button>}108 {!active && t.idempotent && <Button size="sm" loading={busy} onClick={retry} data-testid="retry-task">Retry</Button>}109 </div>110 <div className="border-b border-line px-3 py-2.5 md:px-4">111 <pre className="max-h-20 overflow-auto whitespace-pre-wrap break-all rounded-sm bg-surface-2 px-2.5 py-2 font-mono text-[11.5px] text-text-2">{t.command}</pre>112 <dl className="tnum mt-2.5 grid grid-cols-2 gap-x-6 gap-y-1 text-xs sm:grid-cols-4">113 {([114 ['Host', t.host],115 ['Project', projects?.find((p) => p.id === t.projectId)?.name ?? (t.projectId ? DASH : 'None')],116 ['Duration', fmtDuration(durationSec(t, now))],117 ['Exit code', t.exitCode ?? DASH],118 ['Started', fmtDateTime(t.startedAt)],119 ['Timeout', t.timeoutSec == null ? 'None' : fmtDuration(t.timeoutSec)],120 ['CPU time', t.cpuSec == null ? 'not measured' : `${fmtNum(t.cpuSec, 1)} s`],121 ['Peak memory', t.maxRssMb == null ? 'not measured' : `${fmtNum(t.maxRssMb, 0)} MB`],122 ] as const).map(([k, v]) => (123 <div key={k} className="min-w-0"><dt className="text-[11px] text-muted">{k}</dt><dd className="truncate text-text-2">{v}</dd></div>124 ))}125 </dl>126 {t.state === 'LOST' && <p className="mt-2 text-xs text-warning">The runner disappeared (host restart). The outcome is unknown; the task was not retried.</p>}127 {!t.idempotent && !active && <p className="mt-2 text-[11px] text-muted">Not marked idempotent: retry is not offered.</p>}128 {t.retryOf && <p className="mt-1 text-[11px] text-muted">Retry of <button className="text-accent hover:underline" onClick={() => router.replace(`/tasks/?t=${t.retryOf}`)}>a previous run</button>.</p>}129 {t.artifacts.length > 0 && (130 <ul className="mt-2.5 flex flex-wrap gap-2" aria-label="Artifacts">131 {t.artifacts.map((a) => (132 <li key={a.id}>133 <a href={`${API_BASE}/tasks/${t.id}/artifacts/${a.id}`} download={a.name} data-testid="artifact" className="inline-flex h-7 items-center gap-1.5 rounded-sm border border-line-2 bg-surface-2 px-2 text-xs hover:border-line-3 pointer-coarse:h-11">134 <Download className="size-3" /> {a.name} <span className="tnum text-muted">{fmtBytes(a.size)}</span>135 </a>136 </li>137 ))}138 </ul>139 )}140 </div>141 <div className="min-h-0 flex-1"><TaskLog key={t.id} taskId={t.id} active={active} /></div>142 <ConfirmDialog143 open={confirmCancel}144 onOpenChange={setConfirmCancel}145 title="Cancel task?"146 description="The process group receives SIGTERM, then SIGKILL after 10 seconds. Partial output stays in the log."147 confirmLabel="Cancel task"148 danger149 onConfirm={async () => {150 try {151 await api.post(`/tasks/${t.id}/cancel`);152 // the final state may already have been pushed: refetch rather than trust the response order153 await Promise.all([qc.invalidateQueries({ queryKey: qk.tasks }), qc.invalidateQueries({ queryKey: qk.task(t.id) })]);154 } catch (e) {155 toast.error('Could not cancel', errorMessage(e));156 }157 }}158 />159 </div>160 );161}162export default function TasksPage() {163 return <Suspense><TasksInner /></Suspense>;164}165