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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-09 21:10:53 -04:00
parent 4f202303bc
commit 74862bdbaf
No known key found for this signature in database
3 changed files with 547 additions and 9 deletions

View file

@ -487,3 +487,203 @@ function DnaPopover({
document.body,
);
}
export type ThreadCategory = "system" | "agent" | "tool" | "user" | "interrupt";
const THREAD_CATEGORY_LABEL: Record<ThreadCategory, string> = {
system: "System",
agent: "Agent",
tool: "Tool",
user: "User",
interrupt: "Interrupt",
};
const THREAD_CATEGORY_COLOR: Record<ThreadCategory, string> = {
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 (
<div
className="rounded-md bg-overlay"
style={{ height: STRIP_HEIGHT }}
aria-hidden="true"
/>
);
}
const hoveredItem =
hover != null
? items.find((it) => selectionKey(it.selection) === hover.key) ?? null
: null;
return (
<div
className="relative rounded-md bg-overlay px-1.5 py-[5px]"
style={{ height: STRIP_HEIGHT }}
>
<div className="relative h-full">
{items.map((item) => {
const key = selectionKey(item.selection);
const isInstant = item.durationMs <= 0;
const isSelected = selectionsEqual(item.selection, selection);
const isHovered = hover?.key === key;
const leftPct = (item.startMs / totalMs) * 100;
const baseColor = THREAD_CATEGORY_COLOR[item.category];
const style: React.CSSProperties = isInstant
? {
left: `calc(${leftPct}% - ${INSTANT_MARKER_PX / 2}px)`,
width: INSTANT_MARKER_PX,
top: 0,
bottom: 0,
background: baseColor,
opacity: 1,
boxShadow: isSelected
? "0 0 0 1px rgba(255,255,255,0.55)"
: "none",
}
: {
left: `${leftPct}%`,
width: `max(${MIN_DURATION_PX}px, ${(item.durationMs / totalMs) * 100}%)`,
top: 0,
bottom: 0,
background: baseColor,
opacity: isSelected || isHovered ? 1 : 0.9,
boxShadow: isSelected
? "0 0 0 1px rgba(255,255,255,0.55)"
: "none",
};
return (
<div
key={key}
role="button"
tabIndex={-1}
aria-label={`${THREAD_CATEGORY_LABEL[item.category]} · ${item.label}`}
aria-pressed={isSelected}
onMouseEnter={(e) =>
setHover({
key,
rect: e.currentTarget.getBoundingClientRect(),
})
}
onMouseLeave={() =>
setHover((cur) => (cur?.key === key ? null : cur))
}
onClick={() => onSelect(item.selection)}
className="absolute cursor-pointer rounded-[2px] transition-all duration-100 ease-out"
style={style}
/>
);
})}
</div>
{hoveredItem != null && hover != null && (
<ThreadDnaPopover item={hoveredItem} anchorRect={hover.rect} />
)}
</div>
);
}
function ThreadDnaPopover({
item,
anchorRect,
}: {
item: ThreadDnaItem;
anchorRect: DOMRect;
}) {
if (typeof document === "undefined") return null;
const left = anchorRect.left + anchorRect.width / 2;
const top = anchorRect.top;
const elapsed = formatThreadElapsed(item.startMs);
const duration =
item.durationMs > 0 ? formatThreadDuration(item.durationMs) : "instant";
return createPortal(
<div
role="tooltip"
style={{ left, top }}
className="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-[calc(100%+8px)] whitespace-nowrap rounded-md bg-panel-alt px-2.5 py-1 text-xs text-fg shadow-lg outline-1 -outline-offset-1 outline-line-strong"
>
{`${THREAD_CATEGORY_LABEL[item.category]} · ${item.label} · ${elapsed} · ${duration}`}
</div>,
document.body,
);
}

View file

@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import {
buildThreadDnaItems,
eventsTabLabel,
eventsToActivity,
extractStageModel,
@ -621,3 +622,184 @@ describe("eventsTabLabel", () => {
expect(eventsTabLabel("primary", "summary")).toBe("Summary");
});
});
describe("buildThreadDnaItems", () => {
const RUN_START = "2026-04-09T12:00:00Z";
function singleSystem(turnIndex: number, ts: string, content = "prompt") {
return {
kind: "single" as const,
turnIndex,
turn: { kind: "system" as const, ts, content },
};
}
function singleAssistant(turnIndex: number, ts: string) {
return {
kind: "single" as const,
turnIndex,
turn: {
kind: "assistant" as const,
ts,
content: "hi",
inputTokens: 0,
outputTokens: 0,
},
};
}
function singleTool(
turnIndex: number,
ts: string,
toolName: string,
durationMs: number,
) {
return {
kind: "single" as const,
turnIndex,
turn: {
kind: "tool" as const,
ts,
toolName,
input: "",
result: "",
isError: false,
durationMs,
},
};
}
function singleSteer(turnIndex: number, ts: string) {
return {
kind: "single" as const,
turnIndex,
turn: { kind: "steer" as const, ts, content: "do this" },
};
}
test("empty input returns empty output", () => {
expect(buildThreadDnaItems([], RUN_START)).toEqual([]);
});
test("system prompt at runStart is an instant marker at startMs=0", () => {
const items = buildThreadDnaItems([singleSystem(0, RUN_START)], RUN_START);
expect(items).toEqual([
{
category: "system",
label: "stage.prompt",
startMs: 0,
durationMs: 0,
selection: { kind: "single", turnIndex: 0 },
},
]);
});
test("assistant turn duration is gap from previous activity end to its ts", () => {
// system at 0s, assistant at 8s → bar starts at 0, lasts 8s.
const items = buildThreadDnaItems(
[
singleSystem(0, "2026-04-09T12:00:00Z"),
singleAssistant(1, "2026-04-09T12:00:08Z"),
],
RUN_START,
);
expect(items[1]).toEqual({
category: "agent",
label: "agent.message",
startMs: 0,
durationMs: 8000,
selection: { kind: "single", turnIndex: 1 },
});
});
test("tool uses explicit durationMs and advances prevEnd by that duration", () => {
// assistant at 8s, tool starting at 8.5s for 30s → next assistant at 39s
// should be a 500ms agent bar starting at 38500ms.
const items = buildThreadDnaItems(
[
singleAssistant(0, "2026-04-09T12:00:08Z"),
singleTool(1, "2026-04-09T12:00:08.500Z", "shell", 30_000),
singleAssistant(2, "2026-04-09T12:00:39Z"),
],
RUN_START,
);
expect(items[1]).toMatchObject({
category: "tool",
startMs: 8500,
durationMs: 30_000,
});
expect(items[2]).toMatchObject({
category: "agent",
startMs: 38_500,
durationMs: 500,
});
});
test("user steer is an instant marker categorised as user", () => {
const items = buildThreadDnaItems(
[singleSteer(0, "2026-04-09T12:00:30Z")],
RUN_START,
);
expect(items[0]).toEqual({
category: "user",
label: "user.steer",
startMs: 30_000,
durationMs: 0,
selection: { kind: "single", turnIndex: 0 },
});
});
test("tool group spans first child's start to last child's end", () => {
const child1 = {
turnIndex: 0,
turn: {
kind: "tool" as const,
ts: "2026-04-09T12:00:10Z",
toolName: "shell",
input: "",
result: "",
isError: false,
durationMs: 1000,
},
};
const child2 = {
turnIndex: 1,
turn: {
kind: "tool" as const,
ts: "2026-04-09T12:00:12Z",
toolName: "shell",
input: "",
result: "",
isError: false,
durationMs: 2000,
},
};
const group = {
kind: "group" as const,
toolName: "shell",
ts: "2026-04-09T12:00:10Z",
durationMs: 3000,
children: [child1, child2],
};
const items = buildThreadDnaItems([group], RUN_START);
// span = 12s + 2s − 10s = 4s, not the summed 3s.
expect(items[0]).toMatchObject({
category: "tool",
startMs: 10_000,
durationMs: 4000,
selection: { kind: "group", childTurnIndices: [0, 1] },
});
});
test("falls back to first item's ts when runStart is missing", () => {
const items = buildThreadDnaItems(
[
singleSystem(0, "2026-04-09T12:00:05Z"),
singleAssistant(1, "2026-04-09T12:00:10Z"),
],
undefined,
);
expect(items[0]).toMatchObject({ startMs: 0 });
expect(items[1]).toMatchObject({ startMs: 0, durationMs: 5000 });
});
});

View file

@ -14,11 +14,16 @@ import {
DetailsPanel,
EventSearchInput,
MultiSelectFilter,
ThreadDnaStrip,
debugCategory,
debugCategoryLabel,
formatElapsed,
} from "../components/event-debug";
import type { DebugCategory } from "../components/event-debug";
import type {
DebugCategory,
ThreadDnaItem,
ThreadDnaSelection,
} from "../components/event-debug";
import { StageSidebar } from "../components/stage-sidebar";
import type { Stage } from "../components/stage-sidebar";
import { EmptyState } from "../components/state";
@ -81,9 +86,7 @@ export type StageRenderer =
| "wait"
| "summary";
type PanelSelection =
| { kind: "single"; turnIndex: number }
| { kind: "group"; childTurnIndices: number[] };
type PanelSelection = ThreadDnaSelection;
const STAGE_ACTIVITY_EVENT_SET = new Set<string>(STAGE_ACTIVITY_EVENT_TYPES);
@ -343,6 +346,147 @@ export function groupConsecutiveTools(
return out;
}
// Convert the event list / grouped tool view into bars for the Thread DNA
// strip. Each bar carries the same selection identifier the event list uses,
// so clicking a bar opens the same side-panel entry as clicking its row.
//
// Duration semantics:
// - tool / command turns use their explicit durationMs
// - tool groups span from the first child's start to the last child's end
// - assistant turns have no native duration; we treat the time from the
// previous activity's end to this message's ts as "thinking" time
// - system / steer / interrupt are instants (durationMs = 0)
export function buildThreadDnaItems(
items: DisplayItem[],
runStart: string | undefined,
): ThreadDnaItem[] {
if (items.length === 0) return [];
const anchorMs = (() => {
if (runStart) {
const parsed = Date.parse(runStart);
if (!Number.isNaN(parsed)) return parsed;
}
const firstTs =
items[0].kind === "single" ? items[0].turn.ts : items[0].ts;
const parsedFirst = Date.parse(firstTs);
return Number.isNaN(parsedFirst) ? null : parsedFirst;
})();
if (anchorMs == null) return [];
const out: ThreadDnaItem[] = [];
let prevEndMs: number | null = null;
for (const item of items) {
if (item.kind === "single") {
const turn = item.turn;
const tsMs = Date.parse(turn.ts);
if (Number.isNaN(tsMs)) continue;
const selection: ThreadDnaSelection = {
kind: "single",
turnIndex: item.turnIndex,
};
switch (turn.kind) {
case "system":
out.push({
category: "system",
label: "stage.prompt",
startMs: Math.max(0, tsMs - anchorMs),
durationMs: 0,
selection,
});
prevEndMs = tsMs;
break;
case "steer":
out.push({
category: "user",
label: "user.steer",
startMs: Math.max(0, tsMs - anchorMs),
durationMs: 0,
selection,
});
prevEndMs = tsMs;
break;
case "interrupt":
out.push({
category: "interrupt",
label: "interrupt",
startMs: Math.max(0, tsMs - anchorMs),
durationMs: 0,
selection,
});
prevEndMs = tsMs;
break;
case "assistant": {
// turn.ts is the moment the assistant message arrived (end of
// generation). Its bar represents the gap from the last activity
// to that moment, so the visual width approximates "thinking".
const startSourceMs = prevEndMs ?? tsMs;
const startMs = Math.max(0, startSourceMs - anchorMs);
const durationMs = Math.max(0, tsMs - startSourceMs);
out.push({
category: "agent",
label: "agent.message",
startMs,
durationMs,
selection,
});
prevEndMs = tsMs;
break;
}
case "tool": {
const startMs = Math.max(0, tsMs - anchorMs);
const durationMs = Math.max(0, turn.durationMs);
out.push({
category: "tool",
label: humanizeToolName(turn.toolName),
startMs,
durationMs,
selection,
});
prevEndMs = tsMs + durationMs;
break;
}
case "command": {
const startMs = Math.max(0, tsMs - anchorMs);
const durationMs = Math.max(0, turn.durationMs);
out.push({
category: "tool",
label: "command",
startMs,
durationMs,
selection,
});
prevEndMs = tsMs + durationMs;
break;
}
}
} else {
const firstStart = Date.parse(item.ts);
const lastChild = item.children[item.children.length - 1].turn;
const lastEnd = Date.parse(lastChild.ts) + lastChild.durationMs;
if (Number.isNaN(firstStart) || Number.isNaN(lastEnd)) continue;
const startMs = Math.max(0, firstStart - anchorMs);
const durationMs = Math.max(0, lastEnd - firstStart);
out.push({
category: "tool",
label: `${humanizeToolName(item.toolName)} ×${item.children.length}`,
startMs,
durationMs,
selection: {
kind: "group",
childTurnIndices: item.children.map((c) => c.turnIndex),
},
});
prevEndMs = lastEnd;
}
}
return out;
}
const STAGE_MODEL_EVENT_NAMES = new Set([
"stage.prompt",
"agent.session.activated",
@ -1127,6 +1271,10 @@ export default function RunStages() {
const selectedStage = stages.find((s: Stage) => s.id === stageId) ?? stages[0];
const selectedStageId = selectedStage?.id;
const runStart =
selectedStage?.startedAt ??
runQuery.data?.start_time ??
runQuery.data?.created_at;
const stageEventsQuery = useRunStageEvents(id, selectedStageId);
const turns = useMemo(
() =>
@ -1180,6 +1328,10 @@ export default function RunStages() {
() => groupConsecutiveTools(filteredTurns),
[filteredTurns],
);
const threadDnaItems = useMemo(
() => buildThreadDnaItems(displayItems, runStart),
[displayItems, runStart],
);
const openTurn =
panelSelection?.kind === "single" ? turns[panelSelection.turnIndex] ?? null : null;
@ -1249,11 +1401,6 @@ export default function RunStages() {
);
}
const runStart =
selectedStage.startedAt ??
runQuery.data?.start_time ??
runQuery.data?.created_at;
return (
<div className="-mr-4 -mt-6 flex min-h-0 flex-1 sm:-mr-6 lg:-mr-8">
<div className="shrink-0 pb-6 pr-3 pt-6">
@ -1296,6 +1443,15 @@ export default function RunStages() {
/>
</div>
)}
{effectiveTab === "primary" && renderer === "agent" && (
<div className="pb-3">
<ThreadDnaStrip
items={threadDnaItems}
selection={panelSelection}
onSelect={setPanelSelection}
/>
</div>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pt-6 pb-[calc(1.5rem+var(--fabro-interview-dock-clearance,0px))]">