From 337f52883a7b06e6db52aa6da80327f92fa8c719 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 7 May 2026 13:15:49 -0700 Subject: [PATCH] feat(web): show duration and token counts on stage event rows Adds a metric column on the run stages Transcript view: token totals for Agent messages (from agent.message billing.input_tokens + output_tokens), and elapsed time for Tool calls (computed from the paired started/completed timestamps) and Command runs (from command.completed.duration_ms). Also tightens the column top padding so the toolbar reads with balanced breathing on each side, and lets the toolbar underline extend across the row in line with the run-tab underline pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/fabro-web/app/routes/run-stages.test.ts | 16 +++++- apps/fabro-web/app/routes/run-stages.tsx | 59 ++++++++++++++++++-- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/apps/fabro-web/app/routes/run-stages.test.ts b/apps/fabro-web/app/routes/run-stages.test.ts index 339a8e559..742910b94 100644 --- a/apps/fabro-web/app/routes/run-stages.test.ts +++ b/apps/fabro-web/app/routes/run-stages.test.ts @@ -46,13 +46,25 @@ describe("eventsToActivity", () => { const firstVisit = eventsToActivity(events, "verify@1"); expect(firstVisit).toEqual([ { kind: "system", ts: "2026-04-09T12:00:00Z", content: "first visit prompt" }, - { kind: "assistant", ts: "2026-04-09T12:00:00Z", content: "first visit reply" }, + { + kind: "assistant", + ts: "2026-04-09T12:00:00Z", + content: "first visit reply", + inputTokens: 0, + outputTokens: 0, + }, ]); const secondVisit = eventsToActivity(events, "verify@2"); expect(secondVisit).toEqual([ { kind: "system", ts: "2026-04-09T12:00:00Z", content: "second visit prompt" }, - { kind: "assistant", ts: "2026-04-09T12:00:00Z", content: "second visit reply" }, + { + kind: "assistant", + ts: "2026-04-09T12:00:00Z", + content: "second visit reply", + inputTokens: 0, + outputTokens: 0, + }, ]); }); diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 08ba72f2a..67cd61a3e 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -28,8 +28,8 @@ export const handle = { wide: true, fullHeight: true }; type TurnType = | { kind: "system"; ts: string; content: string } - | { kind: "assistant"; ts: string; content: string } - | { kind: "tool"; ts: string; toolName: string; input: string; result: string; isError: boolean } + | { kind: "assistant"; ts: string; content: string; inputTokens: number; outputTokens: number } + | { kind: "tool"; ts: string; toolName: string; input: string; result: string; isError: boolean; durationMs: number } | { kind: "command"; ts: string; script: string; running: boolean; exitCode: number | null; durationMs: number }; const STAGE_ACTIVITY_EVENT_SET = new Set(STAGE_ACTIVITY_EVENT_TYPES); @@ -95,7 +95,16 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn break; case "agent.message": { const msg = getString(props, "text") ?? e.text ?? ""; - if (msg) turns.push({ kind: "assistant", ts: e.ts, content: msg }); + if (msg) { + const billing = (props.billing ?? {}) as UnknownRecord; + turns.push({ + kind: "assistant", + ts: e.ts, + content: msg, + inputTokens: getNumber(billing, "input_tokens") ?? 0, + outputTokens: getNumber(billing, "output_tokens") ?? 0, + }); + } break; } case "agent.tool.started": { @@ -121,6 +130,7 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn input: started?.input ?? "", result, isError: (props.is_error ?? e.is_error) === true, + durationMs: durationBetween(started?.ts, e.ts), }); break; } @@ -279,6 +289,39 @@ export function turnSummary(turn: TurnType): string { } } +function durationBetween(startTs: string | undefined, endTs: string): number { + if (!startTs) return 0; + const startMs = Date.parse(startTs); + const endMs = Date.parse(endTs); + if (Number.isNaN(startMs) || Number.isNaN(endMs)) return 0; + return Math.max(0, endMs - startMs); +} + +function formatDurationMs(ms: number): string { + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function formatTokenCount(n: number): string { + if (n < 1000) return `${n}`; + if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; + return `${(n / 1_000_000).toFixed(1)}M`; +} + +export function turnMetric(turn: TurnType): string | null { + switch (turn.kind) { + case "assistant": { + const total = turn.inputTokens + turn.outputTokens; + return total > 0 ? `${formatTokenCount(total)} tok` : null; + } + case "tool": + case "command": + return turn.durationMs > 0 ? formatDurationMs(turn.durationMs) : null; + case "system": + return null; + } +} + export function searchableText(turn: TurnType): string { switch (turn.kind) { case "system": @@ -314,12 +357,13 @@ function EventRow({ selected: boolean; onSelect: () => void; }) { + const metric = turnMetric(turn); return (