From 1e33d57b4eb8c4b55855db75111e3d378455ef44 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 17 Jun 2026 00:42:36 +0330 Subject: [PATCH] Live run progress: watch the agent work in real time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking Run on a task now keeps the drawer open and streams the agent's progress instead of just closing. A new RunProgress panel polls the run (GET /api/assembler/runs/{id}) and shows a live timeline — Queued -> Thinking -> Delivered — with elapsed time, any MCP tool calls, the action produced and its risk tag, and an expandable output. When the run settles it refreshes the board and pulses the review badge. The panel resets when the drawer switches tasks. Co-Authored-By: Claude Opus 4.8 --- client/src/components/RunProgress.tsx | 196 ++++++++++++++++++++++++++ client/src/pages/BoardPage.tsx | 36 +++-- 2 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 client/src/components/RunProgress.tsx 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() + }} + /> + )} )}