From 74862bdbafdd5988c52d58f87da3a2d5f3912a34 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 9 May 2026 21:10:53 -0400 Subject: [PATCH] feat(web): add Thread DNA strip to agent stage tabs Renders a Gantt-style strip below the Thread toolbar on agent and prompt stages only. Bar position encodes start time and width encodes duration, so empty stretches in the strip are real dead time (idle waits, sandbox spin-up, time waiting on a human steer). Categories collapse to five colors: system/agent/tool/user/interrupt. Tool/command bars use their explicit durationMs; tool groups span first child's start to last child's end so dead time between grouped tools is honored; assistant bars span the gap from the previous activity's end to the message ts as an approximation of "thinking" time; system/steer/interrupt are 4 px instant markers. Hover shows kind/label/elapsed/duration in a portaled popover; click selects the matching turn or tool group and shares state with the existing event list and side panel. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/fabro-web/app/components/event-debug.tsx | 200 ++++++++++++++++++ apps/fabro-web/app/routes/run-stages.test.ts | 182 ++++++++++++++++ apps/fabro-web/app/routes/run-stages.tsx | 174 ++++++++++++++- 3 files changed, 547 insertions(+), 9 deletions(-) diff --git a/apps/fabro-web/app/components/event-debug.tsx b/apps/fabro-web/app/components/event-debug.tsx index 47a2bbd34..d197192ba 100644 --- a/apps/fabro-web/app/components/event-debug.tsx +++ b/apps/fabro-web/app/components/event-debug.tsx @@ -487,3 +487,203 @@ function DnaPopover({ document.body, ); } + +export type ThreadCategory = "system" | "agent" | "tool" | "user" | "interrupt"; + +const THREAD_CATEGORY_LABEL: Record = { + system: "System", + agent: "Agent", + tool: "Tool", + user: "User", + interrupt: "Interrupt", +}; + +const THREAD_CATEGORY_COLOR: Record = { + system: "var(--color-amber)", + agent: "var(--color-teal-500)", + tool: "var(--color-mint)", + user: "var(--color-ice-300)", + interrupt: "var(--color-coral)", +}; + +export type ThreadDnaSelection = + | { kind: "single"; turnIndex: number } + | { kind: "group"; childTurnIndices: number[] }; + +export interface ThreadDnaItem { + category: ThreadCategory; + label: string; + startMs: number; + durationMs: number; + selection: ThreadDnaSelection; +} + +const INSTANT_MARKER_PX = 4; +const MIN_DURATION_PX = 3; + +function selectionKey(s: ThreadDnaSelection): string { + return s.kind === "single" + ? `s:${s.turnIndex}` + : `g:${s.childTurnIndices.join(",")}`; +} + +function selectionsEqual( + a: ThreadDnaSelection, + b: ThreadDnaSelection | null, +): boolean { + if (b == null) return false; + if (a.kind === "single" && b.kind === "single") { + return a.turnIndex === b.turnIndex; + } + if (a.kind === "group" && b.kind === "group") { + if (a.childTurnIndices.length !== b.childTurnIndices.length) return false; + return a.childTurnIndices.every((v, i) => v === b.childTurnIndices[i]); + } + return false; +} + +function formatThreadElapsed(ms: number): string { + const total = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; +} + +function formatThreadDuration(ms: number): string { + if (ms < 1000) return `${Math.max(0, Math.round(ms))} ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)} s`; + const minutes = Math.floor(ms / 60000); + const seconds = Math.round((ms % 60000) / 1000); + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +export function ThreadDnaStrip({ + items, + selection, + onSelect, +}: { + items: ThreadDnaItem[]; + selection: ThreadDnaSelection | null; + onSelect: (s: ThreadDnaSelection) => void; +}) { + const [hover, setHover] = useState<{ key: string; rect: DOMRect } | null>( + null, + ); + + const totalMs = useMemo(() => { + let max = 0; + for (const item of items) { + const end = item.startMs + Math.max(0, item.durationMs); + if (end > max) max = end; + } + return Math.max(1, max); + }, [items]); + + if (items.length === 0) { + return ( +