197 lines
6.2 KiB
TypeScript
197 lines
6.2 KiB
TypeScript
|
|
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>
|
||
|
|
)
|
||
|
|
}
|