feat(web): collapse consecutive same-tool calls into one event row

Successful tool calls of the same tool that run back-to-back (e.g., five
Bash curls to the same endpoint) now collapse into a single Tool group row
labelled "Bash x5", summing durations and using the first call's start
time. Errored calls and tool-name boundaries break the run, so distinct
work is never hidden. Clicking the group opens a panel that lists each
child with its input preview and per-call duration; an inline accordion
reveals the full Tool use / Tool result for one child at a time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-07 17:05:13 -07:00
parent ebf9bc7ed0
commit de26face0f
No known key found for this signature in database
2 changed files with 468 additions and 15 deletions

View file

@ -1,7 +1,12 @@
import { describe, expect, test } from "bun:test";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import { eventsToActivity, extractStageModel, turnsToStageKind } from "./run-stages";
import {
eventsToActivity,
extractStageModel,
groupConsecutiveTools,
turnsToStageKind,
} from "./run-stages";
function envelope(seq: number, partial: Partial<EventEnvelope>): EventEnvelope {
return {
@ -256,6 +261,153 @@ describe("eventsToActivity", () => {
});
});
describe("groupConsecutiveTools", () => {
type Filtered = Parameters<typeof groupConsecutiveTools>[0];
function tool(opts: {
ts: string;
toolName: string;
durationMs?: number;
isError?: boolean;
input?: string;
result?: string;
}) {
return {
kind: "tool" as const,
ts: opts.ts,
toolName: opts.toolName,
input: opts.input ?? "",
result: opts.result ?? "",
isError: opts.isError ?? false,
durationMs: opts.durationMs ?? 0,
};
}
function entry(turn: ReturnType<typeof tool> | { kind: "system"; ts: string; content: string } | { kind: "assistant"; ts: string; content: string; inputTokens: number; outputTokens: number }, index: number): Filtered[number] {
return { turn, index };
}
test("empty input returns empty output", () => {
expect(groupConsecutiveTools([])).toEqual([]);
});
test("single tool turn becomes a single, not a group", () => {
const t = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell", durationMs: 100 });
expect(groupConsecutiveTools([entry(t, 0)])).toEqual([
{ kind: "single", turn: t, turnIndex: 0 },
]);
});
test("two consecutive same-tool successes form a group of 2", () => {
const a = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell", durationMs: 1000 });
const b = tool({ ts: "2026-04-09T12:00:01Z", toolName: "shell", durationMs: 2000 });
const result = groupConsecutiveTools([entry(a, 0), entry(b, 1)]);
expect(result).toEqual([
{
kind: "group",
toolName: "shell",
ts: "2026-04-09T12:00:00Z",
durationMs: 3000,
children: [
{ turn: a, turnIndex: 0 },
{ turn: b, turnIndex: 1 },
],
},
]);
});
test("five consecutive same-tool successes form one group; durations summed; ts is first", () => {
const turns = [0, 1, 2, 3, 4].map((i) =>
tool({
ts: `2026-04-09T12:00:0${i}Z`,
toolName: "shell",
durationMs: (i + 1) * 1000,
}),
);
const filtered = turns.map((t, i) => entry(t, i));
const result = groupConsecutiveTools(filtered);
expect(result).toHaveLength(1);
const item = result[0];
expect(item.kind).toBe("group");
if (item.kind === "group") {
expect(item.ts).toBe("2026-04-09T12:00:00Z");
expect(item.durationMs).toBe(15000);
expect(item.children.map((c) => c.turnIndex)).toEqual([0, 1, 2, 3, 4]);
}
});
test("a different tool between same-tool calls breaks the group boundary", () => {
const a = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell", durationMs: 1 });
const b = tool({ ts: "2026-04-09T12:00:01Z", toolName: "shell", durationMs: 1 });
const c = tool({ ts: "2026-04-09T12:00:02Z", toolName: "read_file", durationMs: 1 });
const d = tool({ ts: "2026-04-09T12:00:03Z", toolName: "shell", durationMs: 1 });
const e = tool({ ts: "2026-04-09T12:00:04Z", toolName: "shell", durationMs: 1 });
const result = groupConsecutiveTools([
entry(a, 0),
entry(b, 1),
entry(c, 2),
entry(d, 3),
entry(e, 4),
]);
expect(result.map((r) => r.kind)).toEqual(["group", "single", "group"]);
if (result[0].kind === "group") {
expect(result[0].children.map((c) => c.turnIndex)).toEqual([0, 1]);
}
if (result[1].kind === "single") {
expect(result[1].turnIndex).toBe(2);
}
if (result[2].kind === "group") {
expect(result[2].children.map((c) => c.turnIndex)).toEqual([3, 4]);
}
});
test("an errored tool call is never grouped and breaks the run", () => {
const a = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell" });
const errored = tool({ ts: "2026-04-09T12:00:01Z", toolName: "shell", isError: true });
const c = tool({ ts: "2026-04-09T12:00:02Z", toolName: "shell" });
const d = tool({ ts: "2026-04-09T12:00:03Z", toolName: "shell" });
const result = groupConsecutiveTools([
entry(a, 0),
entry(errored, 1),
entry(c, 2),
entry(d, 3),
]);
expect(result.map((r) => r.kind)).toEqual(["single", "single", "group"]);
if (result[1].kind === "single") {
expect(result[1].turn).toBe(errored);
}
if (result[2].kind === "group") {
expect(result[2].children.map((c) => c.turnIndex)).toEqual([2, 3]);
}
});
test("non-tool turns flush the buffer correctly", () => {
const a = tool({ ts: "2026-04-09T12:00:00Z", toolName: "shell" });
const b = tool({ ts: "2026-04-09T12:00:01Z", toolName: "shell" });
const msg = {
kind: "assistant" as const,
ts: "2026-04-09T12:00:02Z",
content: "thinking",
inputTokens: 0,
outputTokens: 0,
};
const c = tool({ ts: "2026-04-09T12:00:03Z", toolName: "shell" });
const result = groupConsecutiveTools([
entry(a, 0),
entry(b, 1),
entry(msg, 2),
entry(c, 3),
]);
expect(result.map((r) => r.kind)).toEqual(["group", "single", "single"]);
if (result[0].kind === "group") {
expect(result[0].children.map((c) => c.turnIndex)).toEqual([0, 1]);
}
if (result[2].kind === "single") {
expect(result[2].turnIndex).toBe(3);
}
});
});
describe("turnsToStageKind", () => {
test("classifies a stage with only command events as command", () => {
const events: EventEnvelope[] = [

View file

@ -9,6 +9,8 @@ import {
import { XMarkIcon } from "@heroicons/react/24/outline";
import {
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
ChevronUpDownIcon,
CpuChipIcon,
FunnelIcon,
@ -53,6 +55,10 @@ type TurnType =
type CommandTurn = Extract<TurnType, { kind: "command" }>;
type StageKind = "agent" | "command";
type PanelSelection =
| { kind: "single"; turnIndex: number }
| { kind: "group"; childTurnIndices: number[] };
const STAGE_ACTIVITY_EVENT_SET = new Set<string>(STAGE_ACTIVITY_EVENT_TYPES);
const EVENT_KINDS = ["system", "assistant", "tool", "command"] as const;
@ -229,6 +235,59 @@ export function turnsToStageKind(turns: TurnType[]): StageKind {
return hasCommand ? "command" : "agent";
}
type ToolTurn = Extract<TurnType, { kind: "tool" }>;
export type DisplayItem =
| { kind: "single"; turn: TurnType; turnIndex: number }
| {
kind: "group";
toolName: string;
ts: string;
durationMs: number;
children: { turn: ToolTurn; turnIndex: number }[];
};
export function groupConsecutiveTools(
filtered: { turn: TurnType; index: number }[],
): DisplayItem[] {
const out: DisplayItem[] = [];
let buf: { turn: ToolTurn; turnIndex: number }[] = [];
function flush() {
if (buf.length === 0) return;
if (buf.length === 1) {
out.push({ kind: "single", turn: buf[0].turn, turnIndex: buf[0].turnIndex });
} else {
const first = buf[0].turn;
const totalMs = buf.reduce((sum, b) => sum + b.turn.durationMs, 0);
out.push({
kind: "group",
toolName: first.toolName,
ts: first.ts,
durationMs: totalMs,
children: buf,
});
}
buf = [];
}
for (const { turn, index } of filtered) {
const groupable = turn.kind === "tool" && !turn.isError;
if (groupable && (buf.length === 0 || buf[0].turn.toolName === turn.toolName)) {
buf.push({ turn, turnIndex: index });
continue;
}
flush();
if (groupable) {
buf.push({ turn, turnIndex: index });
} else {
out.push({ kind: "single", turn, turnIndex: index });
}
}
flush();
return out;
}
const STAGE_MODEL_EVENT_NAMES = new Set([
"stage.prompt",
"agent.session.activated",
@ -467,6 +526,50 @@ function EventRow({
);
}
const TOOL_GROUP_TONE = "bg-mint/15 text-mint";
function ToolGroupRow({
group,
runStart,
selected,
onSelect,
}: {
group: Extract<DisplayItem, { kind: "group" }>;
runStart: string | undefined;
selected: boolean;
onSelect: () => void;
}) {
const metric = group.durationMs > 0 ? formatDurationMs(group.durationMs) : null;
return (
<button
type="button"
onClick={onSelect}
aria-pressed={selected}
className={`grid w-full grid-cols-[5rem_1fr_auto_auto] items-center gap-4 px-5 py-2.5 text-left transition-colors hover:bg-overlay focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-teal-500 ${
selected ? "bg-overlay" : ""
}`}
>
<span
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${TOOL_GROUP_TONE}`}
>
Tool
</span>
<span className="min-w-0 truncate text-sm text-fg-3">
{humanizeToolName(group.toolName)} x{group.children.length}
</span>
<span className="inline-flex items-center justify-end gap-1.5 font-mono text-xs tabular-nums text-fg-muted">
{metric && <ClockIcon className="size-3" aria-hidden="true" />}
{metric ?? ""}
</span>
<Tooltip label={formatAbsoluteTs(group.ts)}>
<span className="pl-3 font-mono text-xs tabular-nums text-fg-muted">
{formatElapsed(group.ts, runStart)}
</span>
</Tooltip>
</button>
);
}
function DebugRow({
event,
runStart,
@ -868,6 +971,152 @@ function EventDetailsPanel({
);
}
const TOOL_INPUT_PREVIEW_KEYS = ["command", "path", "pattern", "url", "query", "script"];
function toolInputPreview(turn: ToolTurn): string {
const raw = turn.input;
if (!raw) return "";
try {
const parsed = JSON.parse(raw);
if (typeof parsed === "string") return oneLine(parsed);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
for (const k of TOOL_INPUT_PREVIEW_KEYS) {
const v = obj[k];
if (typeof v === "string" && v) return oneLine(v);
}
}
} catch {
// input wasn't valid JSON; fall through to oneLine of the raw string
}
return oneLine(raw);
}
function ToolGroupChildRow({
child,
runStart,
expanded,
onToggle,
}: {
child: { turn: ToolTurn; turnIndex: number };
runStart: string | undefined;
expanded: boolean;
onToggle: () => void;
}) {
const { turn } = child;
const metric = turn.durationMs > 0 ? formatDurationMs(turn.durationMs) : null;
const elapsed = formatElapsed(turn.ts, runStart);
const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon;
return (
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className={`grid w-full grid-cols-[auto_1fr_auto_auto] items-center gap-3 px-5 py-2.5 text-left transition-colors hover:bg-overlay focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-teal-500 ${
expanded ? "bg-overlay" : ""
}`}
>
<span
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${TOOL_GROUP_TONE}`}
>
{humanizeToolName(turn.toolName)}
</span>
<span className="min-w-0 truncate font-mono text-xs text-fg-3">
{toolInputPreview(turn)}
</span>
<span className="inline-flex items-center justify-end gap-1.5 font-mono text-xs tabular-nums text-fg-muted">
{metric && <ClockIcon className="size-3" aria-hidden="true" />}
{metric ?? ""}
<Tooltip label={formatAbsoluteTs(turn.ts)}>
<span className="pl-3 tabular-nums">{elapsed}</span>
</Tooltip>
</span>
<Chevron className="size-4 text-fg-muted" aria-hidden="true" />
</button>
);
}
function ToolGroupDetails({
group,
runStart,
}: {
group: Extract<DisplayItem, { kind: "group" }>;
runStart: string | undefined;
}) {
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
useEffect(() => {
setExpandedIndex(null);
}, [group]);
const elapsed = formatElapsed(group.ts, runStart);
const totalDuration = group.durationMs > 0 ? formatDurationMs(group.durationMs) : null;
return (
<div className="-mx-5 -mt-4">
<div className="flex items-baseline gap-3 border-b border-line px-5 py-3">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${TOOL_GROUP_TONE}`}
>
Tool
</span>
<span className="text-sm font-medium text-fg">
{humanizeToolName(group.toolName)}{" "}
<span className="text-fg-muted">x{group.children.length}</span>
</span>
<span className="ml-auto inline-flex items-center gap-1.5 font-mono text-xs tabular-nums text-fg-muted">
{elapsed}
{totalDuration && (
<>
<span aria-hidden="true">·</span>
<ClockIcon className="size-3" aria-hidden="true" />
{totalDuration}
</>
)}
</span>
</div>
<ul className="divide-y divide-line">
{group.children.map((child, i) => (
<li key={`group-child-${child.turnIndex}`}>
<ToolGroupChildRow
child={child}
runStart={runStart}
expanded={expandedIndex === i}
onToggle={() =>
setExpandedIndex((current) => (current === i ? null : i))
}
/>
{expandedIndex === i && (
<div className="bg-overlay/50 px-5 py-4">
<EventDetails turn={child.turn} runStart={runStart} />
</div>
)}
</li>
))}
</ul>
</div>
);
}
function ToolGroupDetailsPanel({
group,
runStart,
onClose,
}: {
group: Extract<DisplayItem, { kind: "group" }> | null;
runStart: string | undefined;
onClose: () => void;
}) {
return (
<DetailsPanel
title={group ? "Tool group" : ""}
isOpen={group != null}
onClose={onClose}
>
{group ? <ToolGroupDetails group={group} runStart={runStart} /> : null}
</DetailsPanel>
);
}
function DebugEventDetails({ event }: { event: EventEnvelope }) {
const text = useMemo(() => JSON.stringify(event, null, 2), [event]);
const tokens = useMemo(() => highlightJson(text), [text]);
@ -1149,13 +1398,12 @@ export default function RunStages() {
return null;
}, [turns]);
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [panelSelection, setPanelSelection] = useState<PanelSelection | null>(null);
const [openDebugSeq, setOpenDebugSeq] = useState<number | null>(null);
useEffect(() => {
setOpenIndex(null);
setPanelSelection(null);
setOpenDebugSeq(null);
}, [selectedStageId]);
const openTurn = openIndex != null ? turns[openIndex] ?? null : null;
const [tab, setTab] = useState<EventsTab>("transcript");
const [selectedKinds, setSelectedKinds] = useState<EventKind[]>([
@ -1174,6 +1422,24 @@ export default function RunStages() {
});
return out;
}, [turns, selectedKinds, search]);
const displayItems = useMemo(
() => groupConsecutiveTools(filteredTurns),
[filteredTurns],
);
const openTurn =
panelSelection?.kind === "single" ? turns[panelSelection.turnIndex] ?? null : null;
const openGroup = useMemo<Extract<DisplayItem, { kind: "group" }> | null>(() => {
if (panelSelection?.kind !== "group") return null;
const wanted = panelSelection.childTurnIndices;
for (const item of displayItems) {
if (item.kind !== "group") continue;
if (item.children.length !== wanted.length) continue;
const matches = item.children.every((c, i) => c.turnIndex === wanted[i]);
if (matches) return item;
}
return null;
}, [displayItems, panelSelection]);
const debugEvents = useMemo<EventEnvelope[]>(() => {
if (!selectedStageId) return [];
@ -1277,15 +1543,44 @@ export default function RunStages() {
No events match these filters.
</div>
) : (
filteredTurns.map(({ turn, index }) => (
<EventRow
key={`turn-${index}`}
turn={turn}
runStart={runStart}
selected={openIndex === index}
onSelect={() => setOpenIndex(index)}
/>
))
displayItems.map((item) => {
if (item.kind === "single") {
return (
<EventRow
key={`turn-${item.turnIndex}`}
turn={item.turn}
runStart={runStart}
selected={
panelSelection?.kind === "single" &&
panelSelection.turnIndex === item.turnIndex
}
onSelect={() =>
setPanelSelection({ kind: "single", turnIndex: item.turnIndex })
}
/>
);
}
const childIndices = item.children.map((c) => c.turnIndex);
const groupKey = `group-${childIndices.join("-")}`;
const isSelected =
panelSelection?.kind === "group" &&
panelSelection.childTurnIndices.length === childIndices.length &&
panelSelection.childTurnIndices.every((v, i) => v === childIndices[i]);
return (
<ToolGroupRow
key={groupKey}
group={item}
runStart={runStart}
selected={isSelected}
onSelect={() =>
setPanelSelection({
kind: "group",
childTurnIndices: childIndices,
})
}
/>
);
})
)
) : debugEvents.length > 0 && filteredDebugEvents.length === 0 ? (
<div className="px-2 py-6 text-sm text-fg-muted">
@ -1306,11 +1601,17 @@ export default function RunStages() {
</div>
{tab === "transcript" ? (
stageKind === "command" ? null : (
stageKind === "command" ? null : panelSelection?.kind === "group" ? (
<ToolGroupDetailsPanel
group={openGroup}
runStart={runStart}
onClose={() => setPanelSelection(null)}
/>
) : (
<EventDetailsPanel
turn={openTurn}
runStart={runStart}
onClose={() => setOpenIndex(null)}
onClose={() => setPanelSelection(null)}
/>
)
) : (