Files
Teamup/client/src/components/RunProgress.tsx
T

197 lines
6.2 KiB
TypeScript
Raw Normal View History

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>
)
}