SPB Git

Repository Public

mobile-task-list.tsx 5.9 KB 98 lines tsx

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
Files main
Menu

Theme