Live run progress: watch the agent work in real time

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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-17 00:42:36 +03:30
parent 861efa4e20
commit 1e33d57b4e
2 changed files with 224 additions and 8 deletions
+196
View File
@@ -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<Run | null>(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<Run>(`/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 (
<div className="flex flex-col gap-3 rounded-xl border bg-card/60 p-4 backdrop-blur-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">Agent at work</span>
<span className="text-xs tabular-nums text-muted-foreground">{seconds}s</span>
</div>
<ol className="flex flex-col gap-2.5">
<Step
state={status === 'Queued' ? 'active' : 'done'}
icon={Inbox}
title="Queued"
detail="Task accepted — waiting for a worker."
/>
<Step
state={failed && !done ? (run?.output ? 'done' : 'error') : running ? 'active' : status === 'Queued' ? 'pending' : 'done'}
icon={BrainCircuit}
title="Thinking"
detail="Assembling the prompt and calling the model."
/>
{tools.length > 0 && (
<Step
state="done"
icon={Wrench}
title={`Used ${tools.length} tool${tools.length === 1 ? '' : 's'}`}
detail={tools.map((t) => t.name ?? t.tool).filter(Boolean).join(', ')}
/>
)}
<Step
state={failed ? 'error' : done ? 'done' : 'pending'}
icon={done ? Check : AlertTriangle}
title={failed ? 'Failed' : 'Delivered'}
detail={
failed
? run?.error ?? 'The run failed.'
: done
? `${run?.actionType ?? 'Result'} produced — sent to the review inbox.`
: 'Result will land in the review inbox.'
}
/>
</ol>
{done && run?.actionType && (
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
<span className="rounded-md bg-muted px-2 py-0.5 text-xs font-medium">{run.actionType}</span>
{run.actionRisk && (
<span className="rounded-md bg-amber-500/15 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-400">
{run.actionRisk} risk
</span>
)}
</div>
)}
{done && run?.output && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">Show output</summary>
<div className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap rounded-lg bg-muted p-3 leading-relaxed">
{run.output}
</div>
</details>
)}
</div>
)
}
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 (
<li className="flex items-start gap-3">
<span className={`mt-0.5 grid size-7 shrink-0 place-items-center rounded-full border ${ring}`}>
{state === 'active' ? <Loader2 className="size-3.5 animate-spin" /> : <Icon className="size-3.5" />}
</span>
<div className="min-w-0">
<p className={`text-sm font-medium ${state === 'pending' ? 'text-muted-foreground' : ''}`}>{title}</p>
{detail && <p className="text-xs text-muted-foreground">{detail}</p>}
</div>
</li>
)
}
+28 -8
View File
@@ -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<string>('')
const [preview, setPreview] = useState(false)
const [runId, setRunId] = useState<string | null>(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({
</Select>
<Button
disabled={busy || !seatId}
onClick={() =>
onClick={() => {
let started: string | null = null
act(async () => {
await api.post('/api/assembler/runs', { seatId, workItemId: task.id })
const run = await api.post<{ id: string }>('/api/assembler/runs', { seatId, workItemId: task.id })
started = run?.id ?? null
// Visible feedback: the task leaves Backlog for In Progress while the agent works.
if (task.status === 'Backlog') {
await api.patch(`/api/orgboard/tasks/${task.id}/move`, { status: 'InProgress' })
}
}, 'Sent to the agent — moved to In Progress; its result will land in the review inbox.').then(() => {
// The proposal lands a few seconds later; nudge the review badge, then close.
}, 'Agent started — watch it work below.').then(() => {
if (started) setRunId(started)
window.dispatchEvent(new Event(REVIEWS_CHANGED))
setTimeout(() => window.dispatchEvent(new Event(REVIEWS_CHANGED)), 4000)
setTimeout(() => window.dispatchEvent(new Event(REVIEWS_CHANGED)), 9000)
onClose()
})
}
}}
>
<Bot data-icon="inline-start" />
Run
</Button>
</div>
{/* Live process: watch the agent move through Queued → Thinking → Delivered in real time. */}
{runId && (
<RunProgress
key={runId}
runId={runId}
onSettled={() => {
// Result is in the review inbox and the board has moved — refresh both.
window.dispatchEvent(new Event(REVIEWS_CHANGED))
onChanged()
}}
/>
)}
</div>
)}