Repository Public
1'use client';2import { useState } from 'react';3import { useQueryClient } from '@tanstack/react-query';4import { motion } from 'motion/react';5import type { AgentTask } from '@cr/shared';6import { Ban, Plus, X } from 'lucide-react';7import { api, errorMessage } from '@/lib/api';8import { qk, useTasks } from '@/lib/queries';9import { useNow } from '@/hooks/use-viewport';10import { fmtDuration } from '@/lib/format';11import { TASK_ACTIVE, TASK_STATE } from '@/lib/states';12import { toast } from '@/stores/toasts';13import { PullToRefresh } from '@/components/ui/pull-to-refresh';14import { SwipeRow } from '@/components/ui/swipe-row';15import { FilterChips } from '@/components/ui/chips';16import { ConfirmDialog } from '@/components/ui/dialog';17import { EmptyState, ErrorState, Skeleton } from '@/components/ui/misc';18import { IllusTasks } from '@/components/ui/illustrations';19import { TaskStateBadge } from '@/components/sessions/state-badge';20import { stagger } from '@/components/home/sections';21import { cn } from '@/lib/cn';2223type Filter = 'all' | 'running' | 'done';24const secs = (t: AgentTask, now: number) => (t.startedAt ? ((t.endedAt ? new Date(t.endedAt).getTime() : now) - new Date(t.startedAt).getTime()) / 1000 : null);2526/** Phone task list: cards, live elapsed time, indeterminate shimmer while running, swipe to cancel. */27export function MobileTaskList({ onSelect, onNew }: { onSelect: (id: string) => void; onNew: () => void }) {28 const qc = useQueryClient();29 const q = useTasks();30 const now = useNow(1000);31 const [filter, setFilter] = useState<Filter>('all');32 const [cancelling, setCancelling] = useState<AgentTask | null>(null);33 const all = q.data ?? [];34 const running = all.filter((t) => TASK_ACTIVE.includes(t.state));35 const list = filter === 'running' ? running : filter === 'done' ? all.filter((t) => !TASK_ACTIVE.includes(t.state)) : all;36 return (37 <div className="flex h-full flex-col">38 <div className="flex shrink-0 items-center gap-2 px-4 pb-3">39 <div className="min-w-0 flex-1"><FilterChips<Filter> label="Filter tasks" value={filter} onChange={setFilter} options={[{ id: 'all', label: 'All' }, { id: 'running', label: 'Running', count: running.length }, { id: 'done', label: 'Finished' }]} /></div>40 <button aria-label="New task" data-testid="new-task" onClick={onNew} className="bg-brand pressable flex size-11 shrink-0 items-center justify-center rounded-full shadow-float"><Plus className="size-5" strokeWidth={2.4} /></button>41 </div>42 <PullToRefresh onRefresh={() => q.refetch()} className="min-h-0 flex-1" testId="task-list">43 <ul className="flex flex-col gap-2.5 px-4 pb-10 pt-1">44 {q.isPending ? (45 [0, 1, 2].map((i) => <Skeleton key={i} className="h-[72px] rounded-md" />)46 ) : q.isError ? (47 <ErrorState error={q.error} onRetry={() => q.refetch()} />48 ) : list.length === 0 ? (49 <EmptyState illustration={<IllusTasks />} title={filter === 'running' ? 'Nothing is running' : 'No tasks yet'} description="Run a build, a test suite or any long command in the background and watch its log live." />50 ) : (51 list.map((t, i) => {52 const active = TASK_ACTIVE.includes(t.state);53 return (54 <motion.li key={t.id} {...stagger(i)}>55 <SwipeRow className="card" actions={active ? [{ label: 'Cancel', icon: <Ban className="size-5" />, tone: 'danger', onAction: () => setCancelling(t) }] : []}>56 <button onClick={() => onSelect(t.id)} data-testid="task-row" className="block w-full p-3.5 text-left">57 <span className="flex items-center gap-3">58 <span className={cn('flex size-9 shrink-0 items-center justify-center rounded-full', active ? 'bg-accent/12 text-accent' : t.state === 'SUCCEEDED' ? 'bg-success-vivid/15 text-success' : 'bg-surface-2 text-muted')}>59 {active ? <span className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent" aria-hidden /> : t.state === 'SUCCEEDED' ? (60 <svg viewBox="0 0 24 24" className="size-5" aria-hidden><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" /></svg>61 ) : <X className="size-4" aria-hidden />}62 </span>63 <span className="min-w-0 flex-1">64 <span className="block truncate text-[15px] font-semibold text-text">{t.title}</span>65 <span className="tnum block truncate text-[13px] text-muted">{t.host} · {fmtDuration(secs(t, now))}{t.exitCode != null ? ` · exit ${t.exitCode}` : ''}</span>66 </span>67 <TaskStateBadge state={t.state} />68 </span>69 {active && <span className="mt-3 block h-1.5 overflow-hidden rounded-full bg-surface-2" role="progressbar" aria-label={`${t.title}: ${TASK_STATE[t.state].label}`}><span className="shimmer-accent block h-full w-full rounded-full" /></span>}70 </button>71 </SwipeRow>72 </motion.li>73 );74 })75 )}76 </ul>77 </PullToRefresh>78 <ConfirmDialog79 open={!!cancelling}80 onOpenChange={(o) => !o && setCancelling(null)}81 title="Cancel task?"82 description={<>“{cancelling?.title}” receives SIGTERM, then SIGKILL after 10 seconds. Partial output stays in the log.</>}83 confirmLabel="Cancel task"84 danger85 onConfirm={async () => {86 if (!cancelling) return;87 try {88 await api.post(`/tasks/${cancelling.id}/cancel`);89 await qc.invalidateQueries({ queryKey: qk.tasks });90 } catch (e) {91 toast.error('Could not cancel', errorMessage(e));92 }93 }}94 />95 </div>96 );97}98