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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user