From 2539e3a66190ad15470fce34e8338a166cf58a38 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 24 Jul 2026 13:17:57 -0400 Subject: [PATCH 001/273] feat(web): add Chat view for agent stages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Chat tab to agent stage pages alongside Thread and Debug, styled after the Ask Fabro sidebar: agent messages render as first-class chat bubbles (the narration between tool batches is the content that matters), the stage prompt is a collapsed user-side card, and each run of consecutive tool calls collapses to a wrench-icon count chip. While the stage is running, in-flight tool calls (agent.tool.started without a completed event) show as a live spinner line with the tool name and input preview — data the Thread view drops today. Thread remains the default tab; Chat becomes the default only after production testing. Also fixes the demo dataset: detect-drift carries the agent-flavored stage events (prompt, agent messages, tool calls) but was labeled a command stage, so its Thread/Chat views were unreachable in demo mode. Co-Authored-By: Claude Fable 5 --- apps/fabro-web/app/routes/run-stages.test.ts | 104 +++++++++ apps/fabro-web/app/routes/run-stages.tsx | 231 ++++++++++++++++++- lib/apps/fabro-server/src/demo/mod.rs | 2 +- 3 files changed, 326 insertions(+), 11 deletions(-) diff --git a/apps/fabro-web/app/routes/run-stages.test.ts b/apps/fabro-web/app/routes/run-stages.test.ts index abe450314..815ecb712 100644 --- a/apps/fabro-web/app/routes/run-stages.test.ts +++ b/apps/fabro-web/app/routes/run-stages.test.ts @@ -2,11 +2,13 @@ import { describe, expect, test } from "bun:test"; import type { EventEnvelope } from "@qltysh/fabro-api-client"; import { + buildChatItems, buildThreadDnaItems, eventsTabLabel, eventsToActivity, formatStageModelUsageLabel, groupConsecutiveTools, + pendingToolCalls, selectStageRenderer, } from "./run-stages"; @@ -652,6 +654,108 @@ describe("eventsTabLabel", () => { expect(eventsTabLabel("primary", "wait")).toBe("Status"); expect(eventsTabLabel("primary", "summary")).toBe("Summary"); }); + + test("uses Chat for the chat tab", () => { + expect(eventsTabLabel("chat", "agent")).toBe("Chat"); + }); +}); + +describe("buildChatItems", () => { + const TS = "2026-04-09T12:00:00Z"; + + function assistant(content: string) { + return { kind: "assistant" as const, ts: TS, content, inputTokens: 0, outputTokens: 0 }; + } + + function tool(toolName: string, isError = false) { + return { + kind: "tool" as const, + ts: TS, + toolName, + input: "{}", + result: "", + isError, + durationMs: 5, + }; + } + + test("merges consecutive tool turns into one count regardless of tool name", () => { + const items = buildChatItems([ + assistant("reading files"), + tool("read_file"), + tool("shell"), + tool("grep"), + assistant("done"), + ]); + expect(items).toEqual([ + { kind: "turn", turn: assistant("reading files"), turnIndex: 0 }, + { kind: "tools", ts: TS, count: 3, errored: 0 }, + { kind: "turn", turn: assistant("done"), turnIndex: 4 }, + ]); + }); + + test("errored calls stay in the batch and are counted", () => { + const items = buildChatItems([tool("shell"), tool("shell", true), tool("read_file")]); + expect(items).toEqual([{ kind: "tools", ts: TS, count: 3, errored: 1 }]); + }); + + test("non-tool turns break tool batches", () => { + const steer = { kind: "steer" as const, ts: TS, content: "focus on the API" }; + const items = buildChatItems([tool("shell"), steer, tool("shell")]); + expect(items).toEqual([ + { kind: "tools", ts: TS, count: 1, errored: 0 }, + { kind: "turn", turn: steer, turnIndex: 1 }, + { kind: "tools", ts: TS, count: 1, errored: 0 }, + ]); + }); +}); + +describe("pendingToolCalls", () => { + test("returns started-but-not-completed calls for the stage", () => { + const events: EventEnvelope[] = [ + envelope(1, { + event: "agent.tool.started", + stage_id: "plan@1", + node_id: "plan", + properties: { + tool_call_id: "call-1", + tool_name: "shell", + arguments: { command: "cargo build" }, + }, + }), + envelope(2, { + event: "agent.tool.started", + stage_id: "plan@1", + node_id: "plan", + properties: { + tool_call_id: "call-2", + tool_name: "read_file", + arguments: { file_path: "/tmp/x" }, + }, + }), + envelope(3, { + event: "agent.tool.completed", + stage_id: "plan@1", + node_id: "plan", + properties: { tool_call_id: "call-1", output: "ok" }, + }), + ]; + expect(pendingToolCalls(events, "plan@1")).toEqual([ + { toolName: "read_file", input: JSON.stringify({ file_path: "/tmp/x" }) }, + ]); + }); + + test("ignores events from other stage visits", () => { + const events: EventEnvelope[] = [ + envelope(1, { + event: "agent.tool.started", + stage_id: "plan@2", + node_id: "plan", + properties: { tool_call_id: "call-1", tool_name: "shell", arguments: {} }, + }), + ]; + expect(pendingToolCalls(events, "plan@1")).toEqual([]); + }); }); describe("buildThreadDnaItems", () => { diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index b39b6bb72..264eff455 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -6,6 +6,7 @@ import { ChevronRightIcon, ClipboardDocumentIcon, CpuChipIcon, + WrenchScrewdriverIcon, } from "@heroicons/react/16/solid"; import { CircleStackIcon, ClockIcon } from "@heroicons/react/20/solid"; @@ -69,7 +70,7 @@ import { useRunState, } from "../lib/queries"; import { STAGE_ACTIVITY_EVENT_TYPES, type StageActivityEventType } from "../lib/run-events"; -import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; +import { ACTIVE_STAGE_STATES, mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; import { getNumber, getString, type UnknownRecord } from "../lib/unknown"; import type { EventEnvelope, StageHandler, StageModelUsage } from "@qltysh/fabro-api-client"; @@ -132,7 +133,7 @@ const EVENT_KIND_LABEL: Record = { command: "Command", }; -const EVENTS_TABS = ["primary", "context", "debug"] as const; +const EVENTS_TABS = ["chat", "primary", "context", "debug"] as const; type EventsTab = (typeof EVENTS_TABS)[number]; interface StageActivityState { @@ -183,6 +184,7 @@ const PRIMARY_TAB_LABEL: Record = { }; export function eventsTabLabel(tab: EventsTab, renderer: StageRenderer): string { + if (tab === "chat") return "Chat"; if (tab === "debug") return "Debug"; if (tab === "context") return "Context"; return PRIMARY_TAB_LABEL[renderer]; @@ -378,6 +380,36 @@ export function eventsToActivity(events: EventEnvelope[], stageId: string): Turn return turns; } +export interface PendingToolCall { + toolName: string; + input: string; +} + +// Tool calls that have started but not completed. The Thread view's +// `eventsToActivity` drops these (a tool turn only exists once its result +// arrives); the Chat view shows them as a live "working" line. +export function pendingToolCalls( + events: EventEnvelope[], + stageId: string, +): PendingToolCall[] { + const pending = new Map(); + for (const e of events) { + if (activityEventStageId(e) !== stageId) continue; + const props: UnknownRecord = e.properties ?? {}; + const callId = getString(props, "tool_call_id") ?? e.tool_call_id ?? ""; + if (e.event === "agent.tool.started") { + const args = props.arguments ?? e.arguments; + pending.set(callId, { + toolName: getString(props, "tool_name") ?? e.tool_name ?? "", + input: typeof args === "string" ? args : JSON.stringify(args ?? ""), + }); + } else if (e.event === "agent.tool.completed") { + pending.delete(callId); + } + } + return Array.from(pending.values()); +} + type ToolTurn = Extract; export type DisplayItem = @@ -390,6 +422,33 @@ export type DisplayItem = children: { turn: ToolTurn; turnIndex: number }[]; }; +// The Chat view's projection: messages stay individual turns while +// consecutive tool/command turns (regardless of tool name) merge into one +// count. Unlike `groupConsecutiveTools`, errored calls stay in the batch — +// the chip reports them as a count instead of breaking the group. +export type ChatItem = + | { kind: "turn"; turn: TurnType; turnIndex: number } + | { kind: "tools"; ts: string; count: number; errored: number }; + +export function buildChatItems(turns: TurnType[]): ChatItem[] { + const out: ChatItem[] = []; + turns.forEach((turn, turnIndex) => { + if (turn.kind === "tool" || turn.kind === "command") { + const errored = turn.kind === "tool" && turn.isError ? 1 : 0; + const last = out[out.length - 1]; + if (last?.kind === "tools") { + last.count += 1; + last.errored += errored; + } else { + out.push({ kind: "tools", ts: turn.ts, count: 1, errored }); + } + return; + } + out.push({ kind: "turn", turn, turnIndex }); + }); + return out; +} + export function groupConsecutiveTools( filtered: { turn: TurnType; index: number }[], ): DisplayItem[] { @@ -1105,8 +1164,7 @@ function EventDetailsPanel({ const TOOL_INPUT_PREVIEW_KEYS = ["command", "path", "pattern", "url", "query", "script"]; -function toolInputPreview(turn: ToolTurn): string { - const raw = turn.input; +function toolInputPreview(raw: string): string { if (!raw) return ""; try { const parsed = JSON.parse(raw); @@ -1149,7 +1207,7 @@ function ToolGroupChildRow({ }`} > - {toolInputPreview(turn)} + {toolInputPreview(turn.input)} {metric &&