import { useEffect, useRef, useState } from 'react'; import { Link, useParams } from 'react-router'; import { useClient } from 'hooks/useClient'; import { Radio, FileText, Activity as ActivityIcon } from 'lucide-react'; type TaskRow = { id: string; source: 'harness'; cwd: string; sizeBytes: number; updatedAt: number; active: boolean }; type DetachedRow = { id: string; source: 'detached'; path: string; announcedAt: number }; type Registry = { tasks: TaskRow[]; detached: DetachedRow[] }; type ProgressLine = { phase?: string; status?: string; pct?: number; detail?: string; cap?: string; job?: string }; const POLL_MS = 3000; const MAX_LINES = 600; // Which run you are following is `/activity/:id`. The two row kinds stream through different query // params (`task=` for a harness file, `path=` for a detached job), so the URL carries the id only and // the screen re-derives the param from the registry row — one address shape for both kinds. export const ActivityScreen = () => { const { token, get } = useClient(); const [reg, setReg] = useState({ tasks: [], detached: [] }); const [regLoaded, setRegLoaded] = useState(false); const selectedId = useParams<{ id: string }>().id ?? null; const [lines, setLines] = useState([]); const [progress, setProgress] = useState(null); const esRef = useRef(null); const scrollRef = useRef(null); // Poll the registry (harness task files + announced detached jobs). useEffect(() => { let alive = true; const tick = () => get('/activity/tasks').then((r) => { if (alive) { setReg(r); setRegLoaded(true); } }).catch(() => {}); tick(); const iv = setInterval(tick, POLL_MS); return () => { alive = false; clearInterval(iv); }; }, []); // The row backing the open id, and the stream query it implies. A string rather than the row object, // so the 3s registry poll — which replaces every row — does not tear down and re-open the stream. const row = selectedId ? (reg.tasks.find((t) => t.id === selectedId) ?? reg.detached.find((d) => d.id === selectedId)) : undefined; const query = !row ? null : row.source === 'harness' ? `task=${encodeURIComponent(row.id)}` : `path=${encodeURIComponent(row.path)}`; // Live-tail the selected task via SSE (EventSource can't set headers → token in the query string). useEffect(() => { esRef.current?.close(); setLines([]); setProgress(null); if (!query) return; const es = new EventSource(`/api/activity/stream?${query}&token=${encodeURIComponent(token ?? '')}`); esRef.current = es; es.onmessage = (ev) => { try { const d = JSON.parse(ev.data) as { kind: string; text?: string; progress?: ProgressLine }; if (d.kind === 'progress' && d.progress) setProgress(d.progress); else if (d.kind === 'line' && typeof d.text === 'string') setLines((prev) => [...prev.slice(-(MAX_LINES - 1)), d.text!]); } catch { /* ignore */ } }; return () => es.close(); }, [query, token]); useEffect(() => { scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight); }, [lines]); const rowCls = (active: boolean) => `flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm ${active ? 'bg-muted text-foreground' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'}`; return (
{selectedId ? ( <>
{selectedId}
{progress && (
{[progress.cap, progress.phase].filter(Boolean).join(' · ')} {progress.status ? ` (${progress.status})` : ''} {progress.detail ?? (typeof progress.pct === 'number' ? `${progress.pct}%` : '')}
{typeof progress.pct === 'number' && (
)}
)}
{lines.length === 0 ? ( {query ? 'waiting for output…' : regLoaded ? ( <> no run called {selectedId} is in the registry — it finished, or it never started.{' '} Back to the list ) : 'loading…'} ) : ( lines.map((l, i) =>
{l}
) )}
) : (
Select a task to follow its live output
)}
); };