diff --git a/client/src/components/RunProgress.tsx b/client/src/components/RunProgress.tsx new file mode 100644 index 0000000..11ea225 --- /dev/null +++ b/client/src/components/RunProgress.tsx @@ -0,0 +1,196 @@ +import { useEffect, useRef, useState } from 'react' +import { AlertTriangle, BrainCircuit, Check, Inbox, Loader2, Wrench } from 'lucide-react' +import { api } from '@/lib/api' + +/** The live run record as returned by GET /api/assembler/runs/{id}. */ +interface Run { + id: string + status: 'Queued' | 'Running' | 'Completed' | 'Failed' | string + actionType: string | null + actionRisk: string | null + output: string | null + error: string | null + resultJson: string | null + latencyMs: number | null + createdAtUtc: string + completedAtUtc: string | null +} + +interface ToolCall { + name?: string + tool?: string + server?: string + ok?: boolean +} + +const TERMINAL = new Set(['Completed', 'Failed']) + +/** Pulls the tool-call list out of the run's result JSON, if the agent used any MCP tools. */ +function readToolCalls(run: Run | null): ToolCall[] { + if (!run?.resultJson) return [] + try { + const parsed = JSON.parse(run.resultJson) as { toolCalls?: ToolCall[] } + return Array.isArray(parsed.toolCalls) ? parsed.toolCalls : [] + } catch { + return [] + } +} + +/** + * Watches one agent run live: polls the run until it reaches a terminal state and renders a + * step-by-step timeline (Queued → Thinking → Done) with elapsed time, the action the agent took, + * its risk tag, any tool calls, and where the result landed. + */ +export function RunProgress({ + runId, + onSettled, +}: { + runId: string + /** Fired once when the run reaches Completed/Failed — lets the parent refresh the board + badge. */ + onSettled?: (run: Run) => void +}) { + const [run, setRun] = useState(null) + const [elapsed, setElapsed] = useState(0) + const settledRef = useRef(false) + const onSettledRef = useRef(onSettled) + onSettledRef.current = onSettled + + useEffect(() => { + let active = true + let pollTimer = 0 + const start = Date.now() + + const tick = window.setInterval(() => { + if (active && !settledRef.current) setElapsed(Math.round((Date.now() - start) / 1000)) + }, 1000) + + const poll = async () => { + try { + const r = await api.get(`/api/assembler/runs/${runId}`) + if (!active) return + setRun(r) + if (TERMINAL.has(r.status)) { + settledRef.current = true + onSettledRef.current?.(r) + return + } + } catch { + /* transient — keep polling */ + } + if (active) pollTimer = window.setTimeout(poll, 1200) + } + void poll() + + return () => { + active = false + window.clearTimeout(pollTimer) + window.clearInterval(tick) + } + }, [runId]) + + const status = run?.status ?? 'Queued' + const failed = status === 'Failed' + const done = status === 'Completed' + const running = status === 'Running' + const tools = readToolCalls(run) + const seconds = run?.latencyMs != null ? (run.latencyMs / 1000).toFixed(1) : elapsed.toString() + + return ( +
+
+ Agent at work + {seconds}s +
+ +
    + + + {tools.length > 0 && ( + t.name ?? t.tool).filter(Boolean).join(', ')} + /> + )} + +
+ + {done && run?.actionType && ( +
+ {run.actionType} + {run.actionRisk && ( + + {run.actionRisk} risk + + )} +
+ )} + + {done && run?.output && ( +
+ Show output +
+ {run.output} +
+
+ )} +
+ ) +} + +type StepState = 'pending' | 'active' | 'done' | 'error' + +function Step({ + state, + icon: Icon, + title, + detail, +}: { + state: StepState + icon: typeof Check + title: string + detail?: string +}) { + const ring = + state === 'done' + ? 'border-approved bg-approved/15 text-approved' + : state === 'active' + ? 'border-primary bg-primary/15 text-primary' + : state === 'error' + ? 'border-destructive bg-destructive/15 text-destructive' + : 'border-border bg-muted/40 text-muted-foreground' + + return ( +
  • + + {state === 'active' ? : } + +
    +

    {title}

    + {detail &&

    {detail}

    } +
    +
  • + ) +} diff --git a/client/src/pages/BoardPage.tsx b/client/src/pages/BoardPage.tsx index ff3a44f..30c3eee 100644 --- a/client/src/pages/BoardPage.tsx +++ b/client/src/pages/BoardPage.tsx @@ -12,6 +12,7 @@ import { Bot, Play, Plus, Trash2 } from 'lucide-react' import { toast } from 'sonner' import { AppShell, REVIEWS_CHANGED } from '@/components/AppShell' import { LivePreview } from '@/components/LivePreview' +import { RunProgress } from '@/components/RunProgress' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { @@ -407,8 +408,15 @@ function TaskDrawer({ const [busy, setBusy] = useState(false) const [seatId, setSeatId] = useState('') const [preview, setPreview] = useState(false) + const [runId, setRunId] = useState(null) const aiSeats = seats.filter((s) => s.state === 'Ai') + // Switching to a different task clears the live run panel so it never shows a stale run. + const taskId = task?.id + useEffect(() => { + setRunId(null) + }, [taskId]) + if (!task) { return null } @@ -513,26 +521,38 @@ function TaskDrawer({ + + {/* Live process: watch the agent move through Queued → Thinking → Delivered in real time. */} + {runId && ( + { + // Result is in the review inbox and the board has moved — refresh both. + window.dispatchEvent(new Event(REVIEWS_CHANGED)) + onChanged() + }} + /> + )} )}