diff --git a/desktop/src/features/agents/ui/AgentSessionToolRunCard.test.mjs b/desktop/src/features/agents/ui/AgentSessionToolRunCard.test.mjs
new file mode 100644
index 00000000000..2527b06f744
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentSessionToolRunCard.test.mjs
@@ -0,0 +1,473 @@
+import assert from "node:assert/strict";
+import { after, afterEach, before, test } from "node:test";
+
+import { JSDOM } from "jsdom";
+
+const dom = new JSDOM("
", {
+ url: "http://localhost",
+});
+
+before(() => {
+ Object.assign(globalThis, {
+ document: dom.window.document,
+ HTMLElement: dom.window.HTMLElement,
+ Element: dom.window.Element,
+ Node: dom.window.Node,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ window: dom.window,
+ });
+ dom.window.matchMedia = () => ({
+ matches: false,
+ addEventListener() {},
+ removeEventListener() {},
+ });
+});
+
+afterEach(async () => {
+ const { cleanup } = await import("@testing-library/react");
+ cleanup();
+});
+
+after(() => dom.window.close());
+
+const START = "2026-06-18T00:00:00.000Z";
+
+function step(id, overrides = {}) {
+ return {
+ id,
+ type: "tool",
+ renderClass: "shell",
+ descriptor: {
+ renderClass: "shell",
+ label: "Ran command",
+ preview: id,
+ source: "shell",
+ groupKey: "shell:command",
+ },
+ title: id,
+ toolName: "shell",
+ buzzToolName: null,
+ status: "completed",
+ args: {},
+ result: "ok",
+ isError: false,
+ timestamp: START,
+ startedAt: START,
+ completedAt: "2026-06-18T00:00:01.000Z",
+ turnId: "turn-1",
+ sessionId: "sess-1",
+ channelId: "chan-1",
+ ...overrides,
+ };
+}
+
+async function renderCard(items) {
+ const { createElement } = await import("react");
+ const { render } = await import("@testing-library/react");
+ const { AgentSessionToolRunCard } = await import(
+ "./AgentSessionToolRunCard.tsx"
+ );
+
+ const element = (runItems) =>
+ createElement(AgentSessionToolRunCard, {
+ agentAvatarUrl: null,
+ agentName: "Agent",
+ agentPubkey: "pk",
+ run: {
+ id: `tool-run:${runItems[0].id}`,
+ items: runItems,
+ timestamp: runItems[0].timestamp,
+ },
+ });
+
+ const view = render(element(items));
+ return {
+ ...view,
+ card: () => view.container.querySelector("details"),
+ // Re-render the SAME card id with more/updated steps, exactly as the
+ // transcript does while a run streams.
+ stream: (nextItems) => view.rerender(element(nextItems)),
+ };
+}
+
+/**
+ * Renders a run through the variant boundary itself (`AgentSessionToolRunSegment`)
+ * under the compact-preview variant, so the test proves which presentation the
+ * boundary actually picks rather than assuming it.
+ */
+async function renderCompactPreviewRun(items) {
+ const { createElement } = await import("react");
+ const { render } = await import("@testing-library/react");
+ const { AgentSessionTranscriptVariantProvider } = await import(
+ "./agentSessionTranscriptContext.ts"
+ );
+ const { AgentSessionToolRunSegment } = await import(
+ "./AgentSessionToolRunCard.tsx"
+ );
+
+ const view = render(
+ createElement(
+ AgentSessionTranscriptVariantProvider,
+ { value: "compactPreview" },
+ createElement(AgentSessionToolRunSegment, {
+ agentAvatarUrl: null,
+ agentName: "Agent",
+ agentPubkey: "pk",
+ run: {
+ id: `tool-run:${items[0].id}`,
+ items,
+ timestamp: items[0].timestamp,
+ },
+ }),
+ ),
+ );
+ return { ...view, row: () => view.container.querySelector("details") };
+}
+
+// ── Disclosure ───────────────────────────────────────────────────────────────
+
+test("a live run is expanded so the reader watches work happen", async () => {
+ const { card } = await renderCard([
+ step("a"),
+ step("b", { status: "executing", completedAt: null }),
+ ]);
+ assert.equal(card().open, true);
+});
+
+// The regression this guards: `` fires `toggle` for programmatic
+// `open` changes too. If the card's own auto-expand were mistaken for a reader
+// choice, the completed run would stay pinned open forever.
+test("a run that completes collapses itself without the reader touching it", async () => {
+ const { card, stream } = await renderCard([
+ step("a"),
+ step("b", { status: "executing", completedAt: null }),
+ ]);
+ assert.equal(card().open, true);
+
+ stream([step("a"), step("b")]);
+ assert.equal(card().open, false);
+});
+
+// Real browsers fire `toggle` for programmatic `open` changes too, so the
+// card's own auto-expand arrives back as an event that AGREES with the state we
+// just rendered. jsdom does not emit that echo, so it is injected here: without
+// the guard the echo is recorded as a reader choice and pins the card open, and
+// the completed run never collapses.
+test("a browser toggle echo of the card's own auto-expand is not a reader choice", async () => {
+ const { act, fireEvent } = await import("@testing-library/react");
+ const { card, stream } = await renderCard([
+ step("a"),
+ step("b", { status: "executing", completedAt: null }),
+ ]);
+ assert.equal(card().open, true);
+
+ // The echo: open state already matches what we rendered.
+ await act(async () => {
+ fireEvent(card(), new dom.window.Event("toggle"));
+ });
+
+ stream([step("a"), step("b")]);
+ assert.equal(card().open, false);
+});
+
+test("a completed run that contains a failure stays open", async () => {
+ const { card, stream } = await renderCard([
+ step("a"),
+ step("b", { status: "executing", completedAt: null }),
+ ]);
+
+ stream([step("a"), step("b", { isError: true, status: "failed" })]);
+ assert.equal(card().open, true);
+});
+
+test("a settled clean run renders collapsed from the start", async () => {
+ const { card } = await renderCard([step("a"), step("b")]);
+ assert.equal(card().open, false);
+});
+
+test("the reader's collapse survives later streaming updates", async () => {
+ const { card, stream } = await renderCard([
+ step("a"),
+ step("b", { status: "executing", completedAt: null }),
+ ]);
+ assert.equal(card().open, true);
+
+ const { act, fireEvent } = await import("@testing-library/react");
+ // Reader collapses a live run: emulate the click plus the browser's own
+ // open-state mutation, which is what actually fires `toggle`.
+ await act(async () => {
+ card().open = false;
+ fireEvent(card(), new dom.window.Event("toggle"));
+ });
+ assert.equal(card().open, false);
+
+ // A new step arrives; the reader's choice still wins over "live means open".
+ stream([
+ step("a"),
+ step("b"),
+ step("c", { status: "executing", completedAt: null }),
+ ]);
+ assert.equal(card().open, false);
+});
+
+test("the reader can open a failed run's card and keep it open", async () => {
+ const { card, stream } = await renderCard([
+ step("a", { isError: true, status: "failed" }),
+ step("b"),
+ ]);
+ assert.equal(card().open, true);
+
+ const { act, fireEvent } = await import("@testing-library/react");
+ await act(async () => {
+ card().open = false;
+ fireEvent(card(), new dom.window.Event("toggle"));
+ });
+ assert.equal(card().open, false);
+
+ stream([step("a", { isError: true, status: "failed" }), step("b")]);
+ assert.equal(card().open, false);
+});
+
+// ── Header ───────────────────────────────────────────────────────────────────
+
+test("the header reads as an outcome once settled and names the failing count", async () => {
+ const { container, getByText } = await renderCard([
+ step("a"),
+ step("b", { isError: true, status: "failed" }),
+ ]);
+
+ assert.equal(
+ container.querySelector("[data-run-phase]").dataset.runPhase,
+ "error",
+ );
+ // Aggregate glyph is announced, not just drawn.
+ getByText("1 step failed");
+});
+
+test("the header reads as active with the step position while live", async () => {
+ const { container } = await renderCard([
+ step("a"),
+ step("b", { status: "executing", completedAt: null }),
+ ]);
+
+ // Scope to the card's own summary: the executing step row inside the body
+ // announces its own status too.
+ const header = container.querySelector("details > summary");
+ assert.match(header.textContent, /Running/);
+ assert.match(header.textContent, /step 2/);
+});
+
+test("a clean settled run announces done", async () => {
+ const { container, getByText } = await renderCard([step("a"), step("b")]);
+ assert.equal(
+ container.querySelector("[data-run-phase]").dataset.runPhase,
+ "done",
+ );
+ getByText("Done");
+});
+
+// ── Body ─────────────────────────────────────────────────────────────────────
+
+test("the body renders one row per step and highlights the failing one", async () => {
+ const { container } = await renderCard([
+ step("a"),
+ step("b", { isError: true, status: "failed" }),
+ step("c"),
+ ]);
+
+ const rows = container.querySelectorAll(
+ '[data-testid="transcript-tool-run-step"]',
+ );
+ assert.equal(rows.length, 3);
+ assert.deepEqual(
+ [...rows].map((row) => row.dataset.stepFailed),
+ [undefined, "true", undefined],
+ );
+});
+
+test("the card carries the run id so streaming steps never remount it", async () => {
+ const { container } = await renderCard([step("a"), step("b")]);
+ assert.equal(
+ container.querySelector("[data-tool-run-id]").dataset.toolRunId,
+ "tool-run:a",
+ );
+});
+
+// ── Compact preview ──────────────────────────────────────────────────────────
+//
+// The compact activity preview is a passive thumbnail, so a run must read there
+// exactly as it did before chain cards existed: the generic `Ran N tool calls`
+// sentence, plain and self-managed. These tests lock that contract — the chain
+// card's chrome and policy leaking into the preview is a regression.
+
+test("compact preview renders a run as the plain legacy summary row", async () => {
+ const { container, queryByTestId, row } = await renderCompactPreviewRun([
+ step("a"),
+ step("b"),
+ ]);
+
+ // The full chain card is not what the boundary picked.
+ assert.equal(queryByTestId("transcript-tool-run-card"), null);
+ assert.ok(queryByTestId("transcript-tool-run-compact-row"));
+ // Scoped to the run's own summary, because the step rows in the body carry
+ // their own "Ran " labels. The count animates through
+ // AnimatedCount, so the sentence is asserted as text rather than as one node.
+ const summary = container.querySelector("details > summary");
+ // The generic sentence, not a derived verb/object headline ("Ran 2 commands").
+ assert.match(summary.textContent, /^Ran/);
+ assert.match(summary.textContent, /2 tool calls$/);
+ assert.equal(row().open, false);
+});
+
+test("compact preview draws no aggregate status glyph or timing", async () => {
+ const { container, queryByText } = await renderCompactPreviewRun([
+ step("a"),
+ step("b"),
+ ]);
+
+ // The glyphs announce themselves for screen readers, so their absence is
+ // assertable rather than a matter of inspecting class names.
+ assert.equal(queryByText("Done"), null);
+ assert.equal(queryByText("Running"), null);
+ assert.equal(queryByText("1 step failed"), null);
+ // No run-level phase/timing chrome at all.
+ assert.equal(container.querySelector("[data-run-phase]"), null);
+ assert.equal(
+ container.querySelector("[data-testid='transcript-row-timestamp']"),
+ null,
+ );
+});
+
+// No auto-open while live and no auto-collapse on settle: disclosure in the
+// preview is the `` element's own business.
+test("compact preview applies no auto-open or auto-collapse policy", async () => {
+ const live = await renderCompactPreviewRun([
+ step("a"),
+ step("b", { status: "executing", completedAt: null }),
+ ]);
+ assert.equal(live.row().open, false);
+
+ const failed = await renderCompactPreviewRun([
+ step("a"),
+ step("b", { isError: true, status: "failed" }),
+ ]);
+ assert.equal(failed.row().open, false);
+});
+
+test("compact preview still expands to one row per step", async () => {
+ const { container } = await renderCompactPreviewRun([
+ step("a"),
+ step("b"),
+ step("c"),
+ ]);
+ assert.equal(
+ container.querySelectorAll('[data-testid="transcript-tool-run-step"]')
+ .length,
+ 3,
+ );
+});
+
+test("the default variant gets the full chain card, not the plain row", async () => {
+ const { createElement } = await import("react");
+ const { render } = await import("@testing-library/react");
+ const { AgentSessionTranscriptVariantProvider } = await import(
+ "./agentSessionTranscriptContext.ts"
+ );
+ const { AgentSessionToolRunSegment } = await import(
+ "./AgentSessionToolRunCard.tsx"
+ );
+
+ const items = [step("a"), step("b")];
+ const { queryByTestId } = render(
+ createElement(
+ AgentSessionTranscriptVariantProvider,
+ { value: "default" },
+ createElement(AgentSessionToolRunSegment, {
+ agentAvatarUrl: null,
+ agentName: "Agent",
+ agentPubkey: "pk",
+ run: { id: "tool-run:a", items, timestamp: items[0].timestamp },
+ }),
+ ),
+ );
+
+ assert.ok(queryByTestId("transcript-tool-run-card"));
+ assert.equal(queryByTestId("transcript-tool-run-compact-row"), null);
+});
+
+// ── Streaming cost ───────────────────────────────────────────────────────────
+
+/**
+ * A run is re-rendered on every append while it streams (and on every
+ * live-clock tick). Unchanged steps must not re-render with it: each step's
+ * presenter rebuilds compact tool summaries, parses diffs, and renders
+ * markdown/images, so an unmemoized step row makes a long run cost O(n) of that
+ * work per appended step.
+ *
+ * Counted at the presenter boundary — `TranscriptActivityItem` looks its
+ * presenter up in `ACTIVITY_RENDER_CLASS_PRESENTERS` on every render, so
+ * swapping in a counting presenter observes exactly the work a step row
+ * triggers, without reaching into React internals.
+ */
+async function countStepRenders(initialItems, nextItems) {
+ const { createElement } = await import("react");
+ const { render } = await import("@testing-library/react");
+ const { ACTIVITY_RENDER_CLASS_PRESENTERS } = await import(
+ "./activityRenderClasses/TranscriptActivityItem.tsx"
+ );
+ const { AgentSessionToolRunCard } = await import(
+ "./AgentSessionToolRunCard.tsx"
+ );
+
+ const renders = [];
+ const original = ACTIVITY_RENDER_CLASS_PRESENTERS.shell;
+ ACTIVITY_RENDER_CLASS_PRESENTERS.shell = function CountingPresenter(props) {
+ renders.push(props.item.id);
+ return createElement("div", null, props.item.id);
+ };
+
+ try {
+ const element = (items) =>
+ createElement(AgentSessionToolRunCard, {
+ agentAvatarUrl: null,
+ agentName: "Agent",
+ agentPubkey: "pk",
+ run: { id: "tool-run:a", items, timestamp: items[0].timestamp },
+ });
+
+ const view = render(element(initialItems));
+ renders.length = 0;
+ view.rerender(element(nextItems));
+ return renders;
+ } finally {
+ ACTIVITY_RENDER_CLASS_PRESENTERS.shell = original;
+ }
+}
+
+test("appending a step does not re-render the steps already in the run", async () => {
+ // Five settled steps, then a sixth arrives. The five are the SAME objects
+ // across both renders, as the transcript store replaces items rather than
+ // mutating them.
+ const settled = ["a", "b", "c", "d", "e"].map((id) => step(id));
+ const appended = [
+ ...settled,
+ step("f", { status: "executing", completedAt: null }),
+ ];
+
+ const rendered = await countStepRenders(settled, appended);
+
+ // Only the newly appended step renders; the five unchanged ones are skipped.
+ assert.deepEqual(rendered, ["f"]);
+});
+
+test("a step that actually changed does re-render", async () => {
+ // Guards the memo from being too aggressive: an executing step settling is a
+ // new object for that id, and it must re-render to drop its spinner.
+ const a = step("a");
+ const executing = step("b", { status: "executing", completedAt: null });
+ const settled = step("b");
+
+ const rendered = await countStepRenders([a, executing], [a, settled]);
+
+ assert.deepEqual(rendered, ["b"]);
+});
diff --git a/desktop/src/features/agents/ui/AgentSessionToolRunCard.tsx b/desktop/src/features/agents/ui/AgentSessionToolRunCard.tsx
new file mode 100644
index 00000000000..06023b4bd4d
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentSessionToolRunCard.tsx
@@ -0,0 +1,427 @@
+import * as React from "react";
+import { Check, CircleAlert, Loader2 } from "lucide-react";
+
+import type { UserProfileLookup } from "@/features/profile/lib/identity";
+import { useControlledDisclosure } from "@/shared/hooks/useControlledDisclosure";
+import { cn } from "@/shared/lib/cn";
+import { useNow } from "@/shared/lib/useNow";
+import { AnimatedCount } from "@/shared/ui/AnimatedCount";
+import {
+ ActivityRow,
+ ActivityRowContent,
+ ActivityRowLabel,
+ splitActivityRowCountedObject,
+ type ActivityRowStats,
+} from "./activityRenderClasses/ActivityRow";
+import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
+import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp";
+import type { AgentTranscriptIdentityProps } from "./activityRenderClasses/types";
+import { useAgentSessionTranscriptVariant } from "./agentSessionTranscriptContext";
+import type { TranscriptToolRun } from "./agentSessionTranscriptGrouping";
+import { buildCompactToolSummary } from "./agentSessionToolSummary";
+import {
+ summarizeToolRunHeadline,
+ summarizeToolRunStatus,
+ toolRunCompletedAtMs,
+ toolRunElapsedMs,
+ toolRunStartedAtMs,
+ type ToolRunPhase,
+} from "./agentSessionToolRunSummary";
+import {
+ formatDurationMs,
+ formatTranscriptTimestampTitle,
+} from "./agentSessionUtils";
+import { hasFileEditLineDiff } from "./FileEditDiffView";
+import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference";
+import { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference";
+
+type ToolRunStep = TranscriptToolRun["items"][number];
+
+/**
+ * Cadence of the live elapsed clock. Only mounted while a run is executing, so
+ * a settled transcript never ticks.
+ */
+const LIVE_ELAPSED_TICK_MS = 1000;
+
+/**
+ * A run of consecutive tool steps, in whichever presentation the current
+ * transcript variant calls for.
+ *
+ * This is the ONE place the variant is consulted: variant in, presentation out.
+ * Grouping stays variant-agnostic — a run is a run everywhere — and keeping the
+ * branch here rather than inside the card is what stops a second variant-aware
+ * grouping pipeline from growing back.
+ */
+export function AgentSessionToolRunSegment({
+ agentAvatarUrl,
+ agentName,
+ agentPubkey,
+ profiles,
+ run,
+}: AgentTranscriptIdentityProps & {
+ profiles?: UserProfileLookup;
+ run: TranscriptToolRun;
+}) {
+ const variant = useAgentSessionTranscriptVariant();
+ const Presentation =
+ variant === "compactPreview"
+ ? AgentSessionToolRunCompactRow
+ : AgentSessionToolRunCard;
+
+ return (
+
+ );
+}
+
+/**
+ * One card for one run of consecutive tool steps.
+ *
+ * The card is the run's single row: it mutates in place as steps stream in
+ * (VISION_ACTIVITY "mutate in place") rather than leaving a trail of status
+ * rows. Its header is the run's verb/object/outcome sentence plus an aggregate
+ * status glyph and timing; its body is one row per step, each reusing the
+ * ordinary tool item rendering so shell blocks, diffs, sent-message previews,
+ * and image previews all keep working.
+ *
+ * This is the full-fidelity presentation. The compact activity preview renders
+ * a run as a plain summary row instead; that choice is made once, at the
+ * transcript list boundary, so nothing here is variant-aware.
+ */
+export function AgentSessionToolRunCard({
+ agentAvatarUrl,
+ agentName,
+ agentPubkey,
+ profiles,
+ run,
+}: AgentTranscriptIdentityProps & {
+ profiles?: UserProfileLookup;
+ run: TranscriptToolRun;
+}) {
+ const timestampsEnabled = useTranscriptTimestampsEnabled();
+ const aggregate = summarizeToolRunStatus(run.items);
+ const headline = summarizeToolRunHeadline(run.items, aggregate);
+ const stats = useToolRunEditStats(run.items);
+ const { onOpenChange, open } = useToolRunDisclosure(aggregate.phase);
+
+ return (
+
+
+
+
+
+
+ {run.items.map((item) => (
+
+ ))}
+
+
+ {timestampsEnabled ? (
+
+
+
+ ) : null}
+
+ );
+}
+
+/**
+ * A run in the compact activity preview: one plain, collapsed, self-managed
+ * row.
+ *
+ * The preview is a non-interactive thumbnail of what an agent is doing, not a
+ * place to supervise it — so a run reads there exactly as it did before chain
+ * cards existed: the generic `Ran N tool calls` sentence the legacy mixed-burst
+ * fallthrough used, with no aggregate status glyph, no derived verb/object
+ * headline ("Read 2 files"), no live clock, no timing, and no
+ * auto-open/auto-collapse policy. Disclosure is the `` element's own
+ * business, so expanding stays possible and stays entirely the reader's choice.
+ */
+export function AgentSessionToolRunCompactRow({
+ agentAvatarUrl,
+ agentName,
+ agentPubkey,
+ profiles,
+ run,
+}: AgentTranscriptIdentityProps & {
+ profiles?: UserProfileLookup;
+ run: TranscriptToolRun;
+}) {
+ return (
+
+
+
+ {run.items.map((item) => (
+
+ ))}
+
+
+ );
+}
+
+/**
+ * Disclosure policy for a run card.
+ *
+ * A live run is open so the reader watches work happen; when it settles the
+ * card collapses itself and hands the space back to the conversation — unless
+ * the run failed, in which case it stays open (a buried error is a broken
+ * feed). `useControlledDisclosure` layers the reader's own toggle over that
+ * policy and guards the `` echo trap.
+ */
+function useToolRunDisclosure(phase: ToolRunPhase) {
+ return useControlledDisclosure(phase !== "done");
+}
+
+/**
+ * Aggregate +/- across the run's real line diffs. Runs that edited nothing
+ * report no stats rather than a misleading "+0 -0".
+ */
+function useToolRunEditStats(items: ToolRunStep[]): ActivityRowStats | null {
+ return React.useMemo(() => {
+ let additions = 0;
+ let deletions = 0;
+ let sawDiff = false;
+
+ for (const item of items) {
+ if (item.isError) continue;
+ const diff = buildCompactToolSummary(item).fileEditDiff;
+ if (!diff || !hasFileEditLineDiff(diff)) continue;
+ sawDiff = true;
+ additions += diff.additions;
+ deletions += diff.deletions;
+ }
+
+ return sawDiff ? { additions, deletions } : null;
+ }, [items]);
+}
+
+/**
+ * Aggregate outcome glyph: spinner while any step executes, check when the run
+ * finished clean, error mark when any step failed.
+ */
+function ToolRunStatusGlyph({
+ errorCount,
+ phase,
+}: {
+ errorCount: number;
+ phase: ToolRunPhase;
+}) {
+ if (phase === "running") {
+ return (
+ <>
+
+ Running
+ >
+ );
+ }
+
+ if (phase === "error") {
+ return (
+ <>
+
+
+ {errorCount === 1 ? "1 step failed" : `${errorCount} steps failed`}
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+ Done
+ >
+ );
+}
+
+/**
+ * The run's headline sentence. A leading count in the object phrase rolls
+ * through AnimatedCount so a run growing from "Read 3 files" to "Read 4 files"
+ * reads as an increment rather than a silent swap.
+ */
+function ToolRunHeadlineLabel({
+ detail,
+ object,
+ stats,
+ verb,
+}: {
+ detail: string | null;
+ object: string | null;
+ stats: ActivityRowStats | null;
+ verb: string;
+}) {
+ const animationsEnabled = useTranscriptAnimationEnabled();
+ const counted =
+ animationsEnabled && object ? splitActivityRowCountedObject(object) : null;
+
+ const objectNode = counted ? (
+ <>
+
+ {counted.rest}
+ >
+ ) : (
+ object
+ );
+
+ return (
+ <>
+
+ {detail ? (
+
+ {detail}
+
+ ) : null}
+ >
+ );
+}
+
+/**
+ * Run timing: a live counter while the run executes, the finished span once it
+ * settles. Split so only the live branch mounts the ticking clock.
+ */
+function ToolRunTiming({
+ isRunning,
+ items,
+}: {
+ isRunning: boolean;
+ items: ToolRunStep[];
+}) {
+ if (isRunning) {
+ return ;
+ }
+
+ const startedAt = toolRunStartedAtMs(items);
+ const completedAt = toolRunCompletedAtMs(items);
+ // Nothing is executing but a step never reported a completion instant: the
+ // span is unknowable, so report nothing rather than a number frozen against
+ // whenever this happened to render.
+ if (startedAt === null || completedAt === null) return null;
+
+ return ;
+}
+
+function ToolRunLiveElapsed({ items }: { items: ToolRunStep[] }) {
+ const now = useNow(LIVE_ELAPSED_TICK_MS);
+ return ;
+}
+
+function ToolRunTimingText({ ms }: { ms: number | null }) {
+ const formatted = ms === null ? null : formatDurationMs(ms);
+ if (!formatted) return null;
+
+ return (
+
+ {formatted}
+
+ );
+}
+
+/**
+ * One step inside an expanded run. Reuses the ordinary tool item rendering so
+ * every render class keeps its own presentation; a failed step additionally
+ * carries a left rule so the eye lands on it without hunting.
+ *
+ * Memoized on the step's identity, because a run is re-rendered on **every**
+ * append while it streams and on every live-clock tick. Without this, appending
+ * one step to a run of twenty re-runs compact-summary building, diff parsing,
+ * and markdown/image rendering for all twenty unchanged steps — work the
+ * transcript's own `TranscriptItemView` avoids the same way. Transcript items
+ * are replaced rather than mutated, so reference equality on `item` is a sound
+ * test for "this step did not change".
+ */
+const ToolRunStepRow = React.memo(function ToolRunStepRow({
+ agentAvatarUrl,
+ agentName,
+ agentPubkey,
+ item,
+ profiles,
+}: AgentTranscriptIdentityProps & {
+ item: ToolRunStep;
+ profiles?: UserProfileLookup;
+}) {
+ const failed = item.isError || item.status === "failed";
+
+ return (
+
+
+
+ );
+});
diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
index d24c8fab79f..6323334cf91 100644
--- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
+++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx
@@ -22,7 +22,6 @@ import {
DialogTitle,
} from "@/shared/ui/dialog";
import { Toggle } from "@/shared/ui/toggle";
-import { AnimatedCount } from "@/shared/ui/AnimatedCount";
import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
import type { PromptSection, TranscriptItem } from "./agentSessionTypes";
import { TurnLivenessIndicator } from "./TurnLivenessIndicator";
@@ -34,18 +33,10 @@ import {
} from "./agentSessionTranscriptContext";
import { useTranscriptAnimationEnabled } from "./transcriptAnimationPreference";
import { useTranscriptTimestampsEnabled } from "./transcriptTimestampPreference";
+import { AgentSessionToolRunSegment } from "./AgentSessionToolRunCard";
import { TranscriptActivityItem } from "./activityRenderClasses/TranscriptActivityItem";
-import {
- ActivityRow,
- ActivityRowContent,
- ActivityRowLabel,
- type ActivityRowStats,
- splitActivityRowCountedObject,
- splitActivityRowLabel,
-} from "./activityRenderClasses/ActivityRow";
import { TranscriptTimestamp } from "./activityRenderClasses/TranscriptTimestamp";
import type { AgentTranscriptIdentityProps } from "./activityRenderClasses/types";
-import type { FileEditDiff } from "./agentSessionFileEditDiff";
import {
buildTranscriptDisplayBlocks,
formatTurnSetupLabel,
@@ -55,10 +46,8 @@ import {
type TranscriptDisplayBlock,
type TranscriptTurnSegment,
} from "./agentSessionTranscriptGrouping";
-import { buildCompactToolSummary } from "./agentSessionToolSummary";
import { shouldShowTranscriptRowTimestamp } from "./agentSessionTranscriptPresentation";
import { formatTranscriptTimestampTitle } from "./agentSessionUtils";
-import { hasFileEditLineDiff } from "./FileEditDiffView";
import { UserMessageBubble } from "./activityRenderClasses/UserMessageBubble";
const TRANSCRIPT_ACP_SOURCE_STORAGE_KEY = "buzz:show-transcript-acp-source";
@@ -326,8 +315,8 @@ function hasRenderableCompactBlock(block: TranscriptDisplayBlock) {
if (segment.kind === "prompt") {
return true;
}
- if (segment.kind === "summary") {
- return segment.summary.items.some(isRenderableCompactItem);
+ if (segment.kind === "tool-run") {
+ return segment.run.items.some(isRenderableCompactItem);
}
return false;
});
@@ -431,8 +420,8 @@ function getTurnSegmentKey(turnId: string, segment: TranscriptTurnSegment) {
// steers), so key on the user message id rather than the bare turn id.
return `turn:${turnId}:prompt:${segment.user.id}`;
}
- if (segment.kind === "summary") {
- return segment.summary.id;
+ if (segment.kind === "tool-run") {
+ return segment.run.id;
}
return segment.item.id;
}
@@ -462,14 +451,14 @@ function TranscriptTurnSegmentView({
return ;
}
- if (segment.kind === "summary") {
+ if (segment.kind === "tool-run") {
return (
-
);
}
@@ -485,173 +474,6 @@ function TranscriptTurnSegmentView({
);
}
-function SameKindSummaryItem({
- agentAvatarUrl,
- agentName,
- agentPubkey,
- profiles,
- summary,
-}: AgentTranscriptIdentityProps & {
- profiles?: UserProfileLookup;
- summary: Extract["summary"];
-}) {
- const groupedFileEditDiffs = React.useMemo(
- () =>
- summary.renderClass === "file-edit" || summary.variant === "mixed"
- ? getGroupedFileEditDiffs(summary.items)
- : [],
- [summary.items, summary.renderClass, summary.variant],
- );
- const groupedFileEditStats = summarizeFileEditDiffs(groupedFileEditDiffs);
- const expandsToToolItems = summary.items.every(
- (item) => item.type === "tool",
- );
- const variant = useAgentSessionTranscriptVariant();
- const timestampsEnabled = useTranscriptTimestampsEnabled();
- const showTimestamp = timestampsEnabled && variant !== "compactPreview";
- // Mixed bursts expand to their child segments in original order: raw tool
- // rows plus nested same-kind summaries that joined the burst (which stay
- // expandable to their own child rows).
- const childSegments = summary.segments ?? null;
-
- return (
- <>
-
-
-
- {childSegments
- ? childSegments.map((child) =>
- child.kind === "summary" ? (
-
- ) : (
-
- ),
- )
- : expandsToToolItems
- ? summary.items.map((item) => (
-
- ))
- : summary.items.map((item) => (
-
- {item.type === "tool"
- ? item.descriptor.preview || item.descriptor.label
- : item.title}
-
- ))}
-
-
- {showTimestamp ? (
-
- ) : null}
- >
- );
-}
-
-function getGroupedFileEditDiffs(items: TranscriptItem[]): FileEditDiff[] {
- return items.flatMap((item) => {
- if (item.type !== "tool" || item.isError) {
- return [];
- }
-
- const diff = buildCompactToolSummary(item).fileEditDiff;
- return diff && hasFileEditLineDiff(diff) ? [diff] : [];
- });
-}
-
-function summarizeFileEditDiffs(
- diffs: FileEditDiff[],
-): ActivityRowStats | null {
- if (diffs.length === 0) {
- return null;
- }
-
- return diffs.reduce(
- (stats, diff) => ({
- additions: stats.additions + diff.additions,
- deletions: stats.deletions + diff.deletions,
- }),
- { additions: 0, deletions: 0 },
- );
-}
-
-function ToolRunSummaryLabel({
- label,
- stats,
-}: {
- label: string;
- stats?: ActivityRowStats | null;
-}) {
- const animationPreferenceEnabled = useTranscriptAnimationEnabled();
- const parts = splitActivityRowLabel(label);
-
- if (!parts) {
- return {label};
- }
-
- // Streaming bursts grow their count in place ("Ran 16 tool calls" →
- // "Ran 17 tool calls"); rolling the digits odometer-style makes the
- // increment legible. AnimatedCount keeps an sr-only static value and
- // falls back to static text under prefers-reduced-motion.
- const countedObject =
- animationPreferenceEnabled && typeof parts.object === "string"
- ? splitActivityRowCountedObject(parts.object)
- : null;
- const object = countedObject ? (
- <>
-
- {countedObject.rest}
- >
- ) : (
- parts.object
- );
-
- return (
-
- );
-}
-
function TurnPromptBlock({
context,
profiles,
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx
index 8c82bb0d6cd..7ea47b4f8b6 100644
--- a/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx
+++ b/desktop/src/features/agents/ui/activityRenderClasses/ActivityRow.tsx
@@ -16,7 +16,19 @@ export type ActivityRowStats = {
export type ActivityRowToneScope = "none" | "tool" | "summary";
-type ActivityRowProps = {
+/**
+ * Controlled disclosure, all or nothing. Either the caller drives open state
+ * (used by tool-run cards, which open themselves while a run is live) or the
+ * `` manages itself. A half-controlled row is always a bug — an
+ * `open` with no change handler freezes the row, and a handler with no `open`
+ * never applies what it recorded — so the pair is typed as a union rather than
+ * two independent optional props.
+ */
+type ActivityRowDisclosureProps =
+ | { onOpenChange: (open: boolean) => void; open: boolean }
+ | { onOpenChange?: never; open?: never };
+
+type ActivityRowProps = ActivityRowDisclosureProps & {
children: React.ReactNode;
className?: string;
openToneScope?: Exclude;
@@ -38,6 +50,8 @@ type ActivityRowContentComponent = React.FC & {
export function ActivityRow({
children,
className,
+ onOpenChange,
+ open,
openToneScope = "tool",
testId,
title,
@@ -68,6 +82,12 @@ export function ActivityRow({
className,
)}
data-testid={testId}
+ onToggle={
+ onOpenChange
+ ? (event) => onOpenChange(event.currentTarget.open)
+ : undefined
+ }
+ open={open}
title={title}
>
{
+ for (const renderClass of [
+ "file-read",
+ "file-edit",
+ "relay-op",
+ "skill-read",
+ "message",
+ "generic",
+ "image",
+ "shell",
+ "plan",
+ ]) {
+ assert.equal(isToolRunEligible(tool("t", renderClass)), true, renderClass);
+ }
+});
+
+// The safety net and every intervention point must stay visible as its own row.
+test("isToolRunEligible refuses raw-rail, suppressed, status, permission, thought", () => {
+ for (const renderClass of [
+ "raw-rail",
+ "suppressed",
+ "status",
+ "permission",
+ "thought",
+ ]) {
+ assert.equal(isToolRunEligible(tool("t", renderClass)), false, renderClass);
+ }
+});
+
+test("isToolRunEligible admits a failed step so it stays in its own run", () => {
+ assert.equal(
+ isToolRunEligible(tool("t", "error", { isError: true, status: "failed" })),
+ true,
+ );
+ assert.equal(
+ isToolRunEligible(tool("t", "shell", { isError: true, status: "failed" })),
+ true,
+ );
+});
+
+// A failure must not launder an excluded class into an eligible one. The
+// classifier flattens every failed step to render class `error` while keeping
+// its original groupKey, so deciding eligibility on the reported class alone
+// pulled failed safety/status rows into chains. Built through the real
+// classifier, because the bug lived in exactly that flattening.
+test("a failed suppressed or status step is still refused", () => {
+ const failedStopHook = classified("s", { isError: true, toolName: "stop" });
+ assert.equal(failedStopHook.descriptor.renderClass, "error");
+ assert.equal(failedStopHook.descriptor.groupKey, "suppressed:stop-hook");
+ assert.equal(isToolRunEligible(failedStopHook), false);
+
+ const failedCompact = classified("c", {
+ isError: true,
+ toolName: "postcompact",
+ });
+ assert.equal(failedCompact.descriptor.renderClass, "error");
+ assert.equal(failedCompact.descriptor.groupKey, "status:post-compact");
+ assert.equal(isToolRunEligible(failedCompact), false);
+});
+
+test("isToolRunEligible refuses non-tool items", () => {
+ assert.equal(
+ isToolRunEligible({
+ id: "m",
+ type: "message",
+ renderClass: "message",
+ role: "assistant",
+ title: "Assistant",
+ text: "hi",
+ timestamp: START,
+ }),
+ false,
+ );
+});
+
+test("isToolRunEligible falls back to the descriptor render class", () => {
+ const item = tool("t", undefined);
+ item.renderClass = undefined;
+ item.descriptor.renderClass = "shell";
+ assert.equal(isToolRunEligible(item), true);
+
+ const suppressed = tool("t2", undefined);
+ suppressed.renderClass = undefined;
+ suppressed.descriptor.renderClass = "suppressed";
+ assert.equal(isToolRunEligible(suppressed), false);
+});
+
+test("toolRunGroupKey prefers the descriptor groupKey, falling back to the class", () => {
+ assert.equal(
+ toolRunGroupKey(
+ tool("t", "file-read", {
+ descriptor: {
+ renderClass: "file-read",
+ label: "Read file",
+ preview: null,
+ groupKey: "read_file",
+ },
+ }),
+ ),
+ "read_file",
+ );
+ assert.equal(
+ toolRunGroupKey(
+ tool("t", "file-read", {
+ descriptor: {
+ renderClass: "file-read",
+ label: "Read file",
+ preview: null,
+ },
+ }),
+ ),
+ "file-read",
+ );
+});
+
+test("TOOL_RUN_MINIMUM_STEPS keeps a lone step out of a card", () => {
+ assert.equal(TOOL_RUN_MINIMUM_STEPS, 2);
+});
+
+// ── Aggregate status ─────────────────────────────────────────────────────────
+
+test("summarizeToolRunStatus reports done for an all-settled clean run", () => {
+ const aggregate = summarizeToolRunStatus([
+ tool("a", "shell"),
+ tool("b", "shell"),
+ ]);
+ assert.deepEqual(aggregate, {
+ phase: "done",
+ hasError: false,
+ count: 2,
+ activeStep: null,
+ errorCount: 0,
+ });
+});
+
+test("summarizeToolRunStatus reports the FIRST unsettled step as active", () => {
+ const aggregate = summarizeToolRunStatus([
+ tool("a", "shell"),
+ tool("b", "shell", { status: "executing", completedAt: null }),
+ tool("c", "shell", { status: "pending", completedAt: null }),
+ ]);
+ assert.equal(aggregate.phase, "running");
+ assert.equal(aggregate.activeStep, 2);
+});
+
+test("summarizeToolRunStatus counts failures from isError or failed status", () => {
+ const aggregate = summarizeToolRunStatus([
+ tool("a", "shell"),
+ tool("b", "error", { isError: true }),
+ tool("c", "shell", { status: "failed" }),
+ ]);
+ assert.equal(aggregate.phase, "error");
+ assert.equal(aggregate.hasError, true);
+ assert.equal(aggregate.errorCount, 2);
+});
+
+// A live run keeps reporting that work is ongoing; the failure is not masked —
+// hasError stays true so the card stays open and highlights the failing step.
+test("summarizeToolRunStatus keeps running phase while a failed step is followed by live work", () => {
+ const aggregate = summarizeToolRunStatus([
+ tool("a", "error", { isError: true }),
+ tool("b", "shell", { status: "executing", completedAt: null }),
+ ]);
+ assert.equal(aggregate.phase, "running");
+ assert.equal(aggregate.hasError, true);
+});
+
+// ── Kind (render class + tone) ────────────────────────────────────────────────
+
+test("toolRunKind carries the classifier's tone, not just the render class", () => {
+ assert.deepEqual(
+ toolRunKind(buzzCli("r", "buzz messages get --channel abc")),
+ {
+ renderClass: "relay-op",
+ tone: "read",
+ },
+ );
+ assert.deepEqual(toolRunKind(buzzCli("w", "buzz canvas set --channel abc")), {
+ renderClass: "relay-op",
+ tone: "write",
+ });
+ assert.deepEqual(toolRunKind(buzzCli("a", "buzz channels create --name x")), {
+ renderClass: "relay-op",
+ tone: "admin",
+ });
+});
+
+// The classifier flattens a failed step to renderClass "error" with a
+// "… failed" label, which would otherwise erase what the step was doing and
+// make any run containing one headline as anonymous tool work.
+test("toolRunKind recovers a failed step's original class and tone", () => {
+ const failedRead = buzzCli("f", "buzz messages get --channel abc", {
+ isError: true,
+ });
+ assert.equal(failedRead.descriptor.renderClass, "error");
+ assert.deepEqual(toolRunKind(failedRead), {
+ renderClass: "relay-op",
+ tone: "read",
+ });
+
+ const failedFileRead = classified("f2", {
+ args: { path: "a.ts" },
+ isError: true,
+ toolName: "read_file",
+ });
+ assert.equal(toolRunKind(failedFileRead).renderClass, "file-read");
+});
+
+// ── Headlines ────────────────────────────────────────────────────────────────
+
+test("summarizeToolRunHeadline keeps the specific countable sentence for a homogeneous run", () => {
+ const read = (id) =>
+ tool(id, "file-read", {
+ descriptor: {
+ renderClass: "file-read",
+ label: "Read file",
+ preview: null,
+ action: { verb: "Read", object: "a.ts" },
+ groupKey: "read_file",
+ },
+ });
+
+ assert.deepEqual(headlineOf([read("a"), read("b"), read("c")]), {
+ verb: "Read",
+ object: "3 files",
+ detail: null,
+ });
+});
+
+test("summarizeToolRunHeadline names each homogeneous run class", () => {
+ const homogeneous = (renderClass, groupKey, verb) =>
+ [1, 2].map((n) =>
+ tool(`${renderClass}-${n}`, renderClass, {
+ descriptor: {
+ renderClass,
+ label: "Label",
+ preview: null,
+ action: { verb, object: null },
+ groupKey,
+ },
+ }),
+ );
+
+ assert.deepEqual(headlineOf(homogeneous("file-edit", "edit", "Edited")), {
+ verb: "Edited",
+ object: "2 files",
+ detail: null,
+ });
+ assert.deepEqual(headlineOf(homogeneous("skill-read", "skill", "Read")), {
+ verb: "Read",
+ object: "2 skills",
+ detail: null,
+ });
+ assert.deepEqual(headlineOf(homogeneous("shell", "cmd", "Ran")), {
+ verb: "Ran",
+ object: "2 commands",
+ detail: null,
+ });
+ assert.deepEqual(headlineOf(homogeneous("message", "msg", "Sent")), {
+ verb: "Sent",
+ object: "2 messages",
+ detail: null,
+ });
+ assert.deepEqual(headlineOf(homogeneous("image", "img", "Viewed")), {
+ verb: "Viewed",
+ object: "2 images",
+ detail: null,
+ });
+ assert.deepEqual(headlineOf(homogeneous("plan", "todo", "Updated")), {
+ verb: "Updated",
+ object: "2 todos",
+ detail: null,
+ });
+});
+
+// The verb comes from the descriptors the classifier already produced, so two
+// runs of the same render class read differently when their tone differs.
+test("summarizeToolRunHeadline takes a homogeneous run's verb from the classifier", () => {
+ assert.deepEqual(
+ headlineOf([
+ buzzCli("r1", "buzz messages get --channel abc"),
+ buzzCli("r2", "buzz messages get --channel def"),
+ ]),
+ { verb: "Read", object: "2 Buzz relay ops", detail: null },
+ );
+ assert.deepEqual(
+ headlineOf([
+ buzzCli("w1", "buzz canvas set --channel abc"),
+ buzzCli("w2", "buzz canvas set --channel def"),
+ ]),
+ { verb: "Updated", object: "2 Buzz relay ops", detail: null },
+ );
+ assert.deepEqual(
+ headlineOf([
+ buzzCli("a1", "buzz channels create --name x"),
+ buzzCli("a2", "buzz channels create --name y"),
+ ]),
+ { verb: "Created", object: "2 Buzz relay ops", detail: null },
+ );
+});
+
+test("summarizeToolRunHeadline falls back to the descriptor label for an unnamed homogeneous run", () => {
+ const generic = (id) =>
+ tool(id, "generic", {
+ descriptor: {
+ renderClass: "generic",
+ label: "Queried registry",
+ preview: null,
+ action: { verb: "Ran", object: "registry" },
+ groupKey: "registry",
+ },
+ });
+
+ assert.deepEqual(headlineOf([generic("a"), generic("b")]), {
+ verb: "Queried registry",
+ object: "×2",
+ detail: null,
+ });
+});
+
+// A heterogeneous run must not pretend to be one thing: it names the dominant
+// kind of work and carries the honest step count.
+test("summarizeToolRunHeadline names the dominant kind and step count for a mixed run", () => {
+ const headline = headlineOf([
+ classified("read-1", { args: { path: "a.ts" }, toolName: "read_file" }),
+ classified("read-2", { args: { path: "b.ts" }, toolName: "read_file" }),
+ buzzCli("shell-1", "ls -la"),
+ ]);
+
+ assert.deepEqual(headline, {
+ verb: "Read",
+ object: "files",
+ detail: "3 steps",
+ });
+});
+
+// The regression this guards: keying the headline on render class alone
+// flattened every heterogeneous relay-op run to one generic phrase, so a sweep
+// of reads and a sweep of admin changes were indistinguishable.
+test("a mixed relay-op run reflects read vs write vs admin tone", () => {
+ const reads = headlineOf([
+ buzzCli("r1", "buzz messages get --channel abc"),
+ buzzCli("r2", "buzz channels list"),
+ buzzCli("r3", "buzz feed get"),
+ ]);
+ assert.deepEqual(reads, {
+ verb: "Read",
+ object: "Buzz relay ops",
+ detail: "3 steps",
+ });
+
+ const writes = headlineOf([
+ buzzCli("w1", "buzz canvas set --channel abc"),
+ buzzCli("w2", "buzz reactions add --event e1"),
+ buzzCli("w3", "buzz messages get --channel abc"),
+ ]);
+ assert.deepEqual(writes, {
+ // Two write-toned ops outnumber the one read; their verbs disagree
+ // ("Updated"/"Added"), so the run falls back to the write tone's verb.
+ verb: "Updated",
+ object: "Buzz relay ops",
+ detail: "3 steps",
+ });
+
+ const admin = headlineOf([
+ buzzCli("a1", "buzz channels create --name x"),
+ buzzCli("a2", "buzz channels delete --id y"),
+ buzzCli("a3", "buzz messages get --channel abc"),
+ ]);
+ assert.deepEqual(admin, {
+ verb: "Changed",
+ object: "Buzz relay ops",
+ detail: "3 steps",
+ });
+});
+
+// Equal counts of read- and admin-toned relay work: the more consequential tone
+// headlines the run.
+test("an evenly split relay run headlines with the more salient tone", () => {
+ assert.deepEqual(
+ headlineOf([
+ buzzCli("r1", "buzz messages get --channel abc"),
+ buzzCli("a1", "buzz channels create --name x"),
+ ]),
+ { verb: "Created", object: "Buzz relay ops", detail: "2 steps" },
+ );
+});
+
+// A failed step keeps its class, so one failed read inside a run of reads still
+// reads as a run of reads rather than as anonymous tool work.
+test("a failed step does not drag a run's headline to generic tool work", () => {
+ assert.deepEqual(
+ headlineOf([
+ classified("read-1", { args: { path: "a.ts" }, toolName: "read_file" }),
+ classified("read-2", {
+ args: { path: "b.ts" },
+ isError: true,
+ toolName: "read_file",
+ }),
+ classified("read-3", { args: { path: "c.ts" }, toolName: "read_file" }),
+ ]),
+ { verb: "Read", object: "3 files", detail: null },
+ );
+});
+
+test("summarizeToolRunHeadline reads as active with the step position while live", () => {
+ const read = (id, overrides = {}) =>
+ tool(id, "file-read", {
+ descriptor: {
+ renderClass: "file-read",
+ label: "Read file",
+ preview: null,
+ action: { verb: "Read", object: "a.ts" },
+ groupKey: "read_file",
+ },
+ ...overrides,
+ });
+
+ assert.deepEqual(
+ headlineOf([
+ read("read-1"),
+ read("read-2"),
+ read("read-3", { status: "executing", completedAt: null }),
+ ]),
+ { verb: "Reviewing", object: "files", detail: "step 3" },
+ );
+});
+
+// The live header is the settled header in another tense, off the same verb —
+// not a second vocabulary.
+test("a live run re-tenses the classifier's verb", () => {
+ const live = (item) => ({ ...item, status: "executing", completedAt: null });
+
+ assert.equal(
+ headlineOf([
+ buzzCli("w1", "buzz canvas set --channel abc"),
+ live(buzzCli("w2", "buzz canvas set --channel def")),
+ ]).verb,
+ "Updating",
+ );
+ assert.equal(
+ headlineOf([buzzCli("c1", "ls -la"), live(buzzCli("c2", "pwd"))]).verb,
+ "Running",
+ );
+ assert.equal(
+ headlineOf([
+ buzzCli("a1", "buzz channels create --name x"),
+ live(buzzCli("a2", "buzz channels create --name y")),
+ ]).verb,
+ "Creating",
+ );
+});
+
+// The bug this guards: mixed admin relay ops disagree on a verb, so the run
+// falls back to the admin tone's verb ("Changed") — which had no progressive
+// form, leaving a LIVE card reading in the past tense. Built through the real
+// classifier so the tone and the disagreement are production behaviour.
+test("a live run of mixed admin relay ops still reads present-tense", () => {
+ const live = (item) => ({ ...item, status: "executing", completedAt: null });
+
+ const created = buzzCli("a1", "buzz channels create --name x");
+ const deleted = live(buzzCli("a2", "buzz channels delete --channel y"));
+
+ // Precondition: genuinely admin-toned, and the two steps really do disagree
+ // on a verb — otherwise the single agreed verb would be used and the tone
+ // fallback (the thing under test) would never be reached.
+ assert.equal(toolRunKind(created).tone, "admin");
+ assert.equal(toolRunKind(deleted).tone, "admin");
+ assert.notEqual(
+ created.descriptor.action.verb,
+ deleted.descriptor.action.verb,
+ );
+
+ const headline = headlineOf([created, deleted]);
+ assert.equal(headline.verb, "Changing");
+ assert.equal(headline.detail, "step 2");
+ // The regression, stated directly: no live header reads in the past tense.
+ assert.notEqual(headline.verb, "Changed");
+});
+
+// Each tone's fallback verb, exercised through the public headline rather than
+// by reaching for the module-private tables.
+//
+// A run only falls back to its tone's verb when the steps that DEFINE the
+// headline disagree on one — and those are just the steps sharing the dominant
+// render class and tone. So each pair below shares a class and a tone and
+// differs only in verb; a pair that differed in class instead would narrow the
+// dominant kind to a single step, whose verb would trivially agree, and the
+// fallback under test would never be reached.
+test("every tone's fallback verb re-tenses for a live run", () => {
+ const live = (item) => ({ ...item, status: "executing", completedAt: null });
+ const verbOf = (a, b) => {
+ // Precondition for every case: same kind, disagreeing verbs. Asserted so a
+ // fixture that stops reaching the fallback fails loudly instead of passing
+ // for the wrong reason.
+ assert.deepEqual(toolRunKind(a), toolRunKind(b));
+ assert.notEqual(a.descriptor.action.verb, b.descriptor.action.verb);
+ return headlineOf([a, live(b)]).verb;
+ };
+
+ // read: get ("Read") vs search ("Searched") → TONE_VERB.read, re-tensed.
+ assert.equal(
+ verbOf(
+ buzzCli("r1", "buzz messages get --channel abc"),
+ buzzCli("r2", "buzz messages search --query hi"),
+ ),
+ "Reviewing",
+ );
+ // write: canvas set ("Updated") vs reactions add ("Added").
+ assert.equal(
+ verbOf(
+ buzzCli("w1", "buzz canvas set --channel abc --content x"),
+ buzzCli("w2", "buzz reactions add --event e --emoji tada"),
+ ),
+ "Updating",
+ );
+ // admin: create ("Created") vs delete ("Deleted") → "Changed" → "Changing".
+ // This is the case that shipped a past-tense verb on a live card.
+ assert.equal(
+ verbOf(
+ buzzCli("a1", "buzz channels create --name x"),
+ buzzCli("a2", "buzz channels delete --channel y"),
+ ),
+ "Changing",
+ );
+ // neutral has no tone verb at all, so it falls through to the render class's
+ // floor. The classifier gives each neutral-toned class one verb, so a
+ // disagreeing pair has to be built by hand to reach the floor.
+ const imageStep = (id, verb, groupKey) =>
+ tool(id, "image", {
+ descriptor: {
+ renderClass: "image",
+ label: "Viewed image",
+ preview: null,
+ action: { verb, object: null },
+ groupKey,
+ },
+ });
+ const viewed = imageStep("i1", "Viewed", "view_image");
+ assert.equal(toolRunKind(viewed).tone, "neutral");
+ assert.equal(
+ verbOf(viewed, imageStep("i2", "Captured", "screenshot")),
+ "Viewing",
+ );
+});
+
+test("summarizeToolRunHeadline tolerates an empty run", () => {
+ assert.deepEqual(headlineOf([]), {
+ verb: "Working…",
+ object: null,
+ detail: null,
+ });
+});
+
+// Writes and outbound speech outrank reads when equally common, so a run that
+// edited as much as it read headlines as editing.
+test("dominantToolRunKind breaks ties toward the more salient render class", () => {
+ assert.equal(
+ dominantToolRunKind([
+ tool("read-1", "file-read"),
+ tool("edit-1", "file-edit"),
+ ]).renderClass,
+ "file-edit",
+ );
+ assert.equal(
+ dominantToolRunKind([
+ tool("read-1", "file-read"),
+ tool("read-2", "file-read"),
+ tool("edit-1", "file-edit"),
+ ]).renderClass,
+ "file-read",
+ );
+});
+
+test("dominantToolRunKind keeps a failed step's recovered class in the count", () => {
+ assert.deepEqual(
+ dominantToolRunKind([
+ buzzCli("f", "buzz messages get --channel abc", { isError: true }),
+ ]),
+ { renderClass: "relay-op", tone: "read" },
+ );
+});
+
+// ── Timing ───────────────────────────────────────────────────────────────────
+
+test("toolRunStartedAtMs takes the earliest start across the run", () => {
+ const items = [
+ tool("a", "shell", { startedAt: "2026-06-18T00:00:05.000Z" }),
+ tool("b", "shell", { startedAt: START }),
+ ];
+ assert.equal(toolRunStartedAtMs(items), START_MS);
+});
+
+test("toolRunStartedAtMs falls back to the timestamp when startedAt is absent", () => {
+ const item = tool("a", "shell", { startedAt: "" });
+ assert.equal(toolRunStartedAtMs([item]), START_MS);
+});
+
+test("toolRunCompletedAtMs returns null until every step has settled", () => {
+ const settled = [
+ tool("a", "shell", { completedAt: "2026-06-18T00:00:01.000Z" }),
+ tool("b", "shell", { completedAt: "2026-06-18T00:00:04.000Z" }),
+ ];
+ assert.equal(
+ toolRunCompletedAtMs(settled),
+ Date.parse("2026-06-18T00:00:04.000Z"),
+ );
+
+ const partial = [...settled, tool("c", "shell", { completedAt: null })];
+ assert.equal(toolRunCompletedAtMs(partial), null);
+});
+
+test("toolRunElapsedMs spans first start to last completion once settled", () => {
+ const items = [
+ tool("a", "shell", { completedAt: "2026-06-18T00:00:01.000Z" }),
+ tool("b", "shell", { completedAt: "2026-06-18T00:00:04.500Z" }),
+ ];
+ assert.equal(toolRunElapsedMs(items, START_MS + 999_999), 4500);
+});
+
+test("toolRunElapsedMs runs to now while a step is unsettled", () => {
+ const items = [
+ tool("a", "shell", { completedAt: "2026-06-18T00:00:01.000Z" }),
+ tool("b", "shell", { status: "executing", completedAt: null }),
+ ];
+ assert.equal(toolRunElapsedMs(items, START_MS + 2500), 2500);
+});
+
+test("toolRunElapsedMs returns null for unmeasurable or negative spans", () => {
+ const unparseable = tool("a", "shell", {
+ startedAt: "nope",
+ timestamp: "nope",
+ });
+ assert.equal(toolRunElapsedMs([unparseable], START_MS), null);
+ assert.equal(toolRunStartedAtMs([unparseable]), null);
+
+ const settled = [
+ tool("a", "shell", { completedAt: "2026-06-17T23:59:59.000Z" }),
+ ];
+ assert.equal(toolRunElapsedMs(settled, START_MS), null);
+});
diff --git a/desktop/src/features/agents/ui/agentSessionToolRunSummary.ts b/desktop/src/features/agents/ui/agentSessionToolRunSummary.ts
new file mode 100644
index 00000000000..4ea2eceba8b
--- /dev/null
+++ b/desktop/src/features/agents/ui/agentSessionToolRunSummary.ts
@@ -0,0 +1,556 @@
+import type {
+ AgentActivityDescriptor,
+ AgentActivityRenderClass,
+ AgentActivityTone,
+ TranscriptItem,
+} from "./agentSessionTypes";
+import { classifyTool, classifyToolItem } from "./agentSessionToolClassifier";
+
+type ToolItem = Extract;
+
+/**
+ * Which render classes may join a tool-chain run.
+ *
+ * Exhaustive by construction: adding a render class to
+ * `AgentActivityRenderClass` forces a decision here rather than silently
+ * opting the new class into (or out of) chaining.
+ *
+ * The exclusions are deliberate, not incidental:
+ * - `raw-rail` / `suppressed` — the ambient safety net. Collapsing ground
+ * truth or deliberately-unrendered noise into a chain would hide the two
+ * classes that exist precisely to be a floor.
+ * - `status` — turn/tool heartbeat (e.g. "Context compacted"). Spine content
+ * that changes how every later step should be read; it must not disappear
+ * into a collapsed card.
+ * - `permission` / `thought` — intervention points and reasoning. A control
+ * gate the reader may need to act on never collapses, and thoughts are read
+ * as prose rather than as steps.
+ *
+ * Everything else chains, including `message` (so sent-message previews keep
+ * rendering inside a chain body) and `error` (so a failed step is a *member*
+ * of the run that failed rather than a row that shatters it — the card then
+ * stays open and highlights it).
+ */
+const CHAIN_ELIGIBLE_RENDER_CLASSES = {
+ "file-read": true,
+ "file-edit": true,
+ "relay-op": true,
+ "skill-read": true,
+ message: true,
+ generic: true,
+ image: true,
+ shell: true,
+ plan: true,
+ error: true,
+ permission: false,
+ "raw-rail": false,
+ suppressed: false,
+ thought: false,
+ status: false,
+} satisfies Record;
+
+/** Runs shorter than this render as ordinary standalone rows. */
+export const TOOL_RUN_MINIMUM_STEPS = 2;
+
+/**
+ * Per-render-class phrasing floor: the past-tense verb to use when a
+ * descriptor carries no `action`, and the singular noun for the run's object
+ * phrase ("3 files", "2 Buzz relay ops").
+ *
+ * The classifier is the source of truth for *vocabulary* — every descriptor
+ * already carries an `action.verb` derived from the tool and its tone
+ * (`agentSessionToolClassifier`), and that verb is what a run headlines with.
+ * This table only supplies what a per-step descriptor cannot: the collective
+ * noun for a run of N steps, plus a verb floor for items whose descriptor
+ * predates `action`. Exhaustive by construction, so a new render class forces a
+ * decision here instead of silently headlining as generic tool work.
+ */
+const RENDER_CLASS_PHRASE = {
+ "file-read": { countable: true, noun: "file", verb: "Read" },
+ "file-edit": { countable: true, noun: "file", verb: "Edited" },
+ "skill-read": { countable: true, noun: "skill", verb: "Read" },
+ "relay-op": { countable: true, noun: "Buzz relay op", verb: "Ran" },
+ message: { countable: true, noun: "message", verb: "Sent" },
+ image: { countable: true, noun: "image", verb: "Viewed" },
+ shell: { countable: true, noun: "command", verb: "Ran" },
+ plan: { countable: true, noun: "todo", verb: "Updated" },
+ // Not countable: a homogeneous run of unnamed work says what the classifier
+ // called it ("Queried registry ×2") rather than counting anonymous "tool
+ // calls"; the noun is only reached when such work dominates a mixed run.
+ generic: { countable: false, noun: "tool call", verb: "Ran" },
+ // Reached only by a failed step whose original classification could not be
+ // recovered, so it reports honestly rather than guessing.
+ error: { countable: false, noun: "tool call", verb: "Ran" },
+ // Never chain (see CHAIN_ELIGIBLE_RENDER_CLASSES), so these are unreachable
+ // here; they exist to keep the table exhaustive.
+ permission: { countable: false, noun: "step", verb: "Ran" },
+ "raw-rail": { countable: false, noun: "step", verb: "Ran" },
+ suppressed: { countable: false, noun: "step", verb: "Ran" },
+ thought: { countable: false, noun: "step", verb: "Ran" },
+ status: { countable: false, noun: "step", verb: "Ran" },
+} satisfies Record<
+ AgentActivityRenderClass,
+ { countable: boolean; noun: string; verb: RunVerb }
+>;
+
+/**
+ * Every past-tense verb a run can headline with *of this module's own choosing*:
+ * the classifier's closed vocabulary plus the tone and render-class fallbacks
+ * below. Naming the set is what forces `PROGRESSIVE_VERBS` to stay complete —
+ * `TONE_VERB.admin` ("Changed") once had no progressive form, so a live run of
+ * mixed admin relay ops read "Changed Buzz relay ops · step 2" in the
+ * present tense's place.
+ */
+type RunVerb =
+ | "Added"
+ | "Archived"
+ | "Captured"
+ | "Changed"
+ | "Checked"
+ | "Compacted"
+ | "Created"
+ | "Deleted"
+ | "Edited"
+ | "Ran"
+ | "Read"
+ | "Removed"
+ | "Searched"
+ | "Sent"
+ | "Unarchived"
+ | "Updated"
+ | "Viewed";
+
+/**
+ * Present-participle form of every verb in `RunVerb`. A live run reads as work
+ * in progress ("Reviewing files") rather than as an outcome, so the verb is
+ * re-tensed here instead of being looked up in a second vocabulary.
+ *
+ * Exhaustive by construction: adding a verb to `RunVerb` — or a new tone
+ * fallback — fails to compile until its progressive form is decided here, which
+ * is the guard the missing "Changed" entry slipped through.
+ */
+const PROGRESSIVE_VERBS = {
+ Added: "Adding",
+ Archived: "Archiving",
+ Captured: "Capturing",
+ // Reached by a live run of mixed admin relay ops, whose steps disagree on a
+ // verb and so fall back to the admin tone's.
+ Changed: "Changing",
+ Checked: "Checking",
+ Compacted: "Compacting",
+ Created: "Creating",
+ Deleted: "Deleting",
+ Edited: "Editing",
+ Ran: "Running",
+ // "Reading files" would read as one open file; "Reviewing" is how the
+ // transcript has always narrated a live sweep of reads.
+ Read: "Reviewing",
+ Removed: "Removing",
+ Searched: "Searching",
+ Sent: "Sending",
+ Unarchived: "Unarchiving",
+ Updated: "Updating",
+ Viewed: "Viewing",
+} satisfies Record;
+
+/** Render classes in descending salience: writes and speech before reads. */
+const RENDER_CLASS_SALIENCE = {
+ "file-edit": 0,
+ message: 1,
+ "relay-op": 2,
+ shell: 3,
+ plan: 4,
+ image: 5,
+ generic: 6,
+ error: 7,
+ "file-read": 8,
+ "skill-read": 9,
+ // Ineligible for chaining; ranked last so a recovered odd class never
+ // outranks real work.
+ permission: 10,
+ "raw-rail": 11,
+ suppressed: 12,
+ thought: 13,
+ status: 14,
+} satisfies Record;
+
+/** Tones in descending salience, so admin/write work outranks reads. */
+const TONE_SALIENCE = {
+ admin: 0,
+ write: 1,
+ neutral: 2,
+ read: 3,
+} satisfies Record;
+
+/**
+ * The kind of work a step represents: its render class plus the classifier's
+ * tone. Tone is part of the identity so a mixed run of Buzz relay ops
+ * headlines as reading, writing, or administering rather than flattening every
+ * relay op into one generic phrase.
+ */
+export type ToolRunKind = {
+ renderClass: AgentActivityRenderClass;
+ tone: AgentActivityTone;
+};
+
+/**
+ * Verb floor per tone, used when the steps that define a run's headline do not
+ * agree on one verb (a mixed sweep of relay writes, say). The classifier's own
+ * tone→verb fallback uses the same "Read"/"Updated" split; "Changed" is the
+ * admin equivalent, which only a run needs because a single admin step always
+ * has a specific verb ("Created", "Removed") of its own.
+ */
+const TONE_VERB: Record = {
+ admin: "Changed",
+ write: "Updated",
+ read: "Read",
+ neutral: null,
+};
+
+/**
+ * Aggregate lifecycle of a run.
+ *
+ * `running` deliberately wins over `error` for the *phase* (the spec's
+ * "running spinner while any step executes"). A failure is never masked by
+ * that: `hasError` is tracked separately, a live run is expanded by default,
+ * and the failing step carries its own highlight — so an error inside a
+ * still-running chain is visible in the body while the header keeps reporting
+ * that work is ongoing.
+ */
+export type ToolRunPhase = "running" | "done" | "error";
+
+/** Verb/object/outcome headline for a run, pre-split for `ActivityRowLabel`. */
+export type ToolRunHeadline = {
+ verb: string;
+ /** Object phrase; carries the count for a homogeneous run ("3 files"). */
+ object: string | null;
+ /** Trailing clause — "4 steps" while collapsed, "step 3" while live. */
+ detail: string | null;
+};
+
+export type ToolRunAggregate = {
+ phase: ToolRunPhase;
+ hasError: boolean;
+ /** Number of steps in the run. */
+ count: number;
+ /** 1-based position of the step currently executing, if any. */
+ activeStep: number | null;
+ /** Count of steps that failed. */
+ errorCount: number;
+};
+
+/**
+ * Whether a transcript item may join a tool-chain run.
+ *
+ * Eligibility is decided on the step's *recovered* class, not on whatever class
+ * its descriptor happens to carry. `classifyTool` flattens every failed step to
+ * render class `error`, which would otherwise let a failure launder an excluded
+ * class into an eligible one: a failed `stop` hook still carries `groupKey`
+ * `suppressed:stop-hook` but reports as `error`, and admitting that would fold
+ * the ambient safety net into a collapsed card — precisely what the exclusions
+ * above exist to prevent. `toolRunKind` already undoes that flattening for the
+ * headline, so deciding eligibility through it keeps one recovery path rather
+ * than two that can disagree.
+ */
+export function isToolRunEligible(item: TranscriptItem): boolean {
+ if (item.type !== "tool") return false;
+ return CHAIN_ELIGIBLE_RENDER_CLASSES[toolRunKind(item).renderClass] === true;
+}
+
+/** Effective render class for a tool item (descriptor fallback included). */
+export function toolRunRenderClass(item: ToolItem): AgentActivityRenderClass {
+ const descriptor = item.descriptor ?? classifyToolItem(item);
+ return item.renderClass ?? descriptor.renderClass;
+}
+
+/**
+ * Semantic identity used to decide whether a run is homogeneous. Falls back to
+ * the render class when the classifier supplied no `groupKey`.
+ */
+export function toolRunGroupKey(item: ToolItem): string {
+ const descriptor = item.descriptor ?? classifyToolItem(item);
+ return descriptor.groupKey ?? toolRunRenderClass(item);
+}
+
+/**
+ * The descriptor a run reads a step's *semantics* from.
+ *
+ * `classifyTool` rewrites a failed step to render class `error` with a "…
+ * failed" label. That is right for the step's own row, but it erases what the
+ * step was doing — so a run containing one failed read would headline as
+ * anonymous tool work. When a descriptor arrives already flattened this
+ * re-classifies the step as if it had succeeded, recovering its original class,
+ * tone, and verb. Nothing is hidden by that: the failure is carried by the
+ * aggregate status glyph and by the failing step's own highlighted row.
+ */
+function toolRunDescriptor(item: ToolItem): AgentActivityDescriptor {
+ const descriptor = item.descriptor ?? classifyToolItem(item);
+ if (descriptor.renderClass !== "error") return descriptor;
+
+ const recovered = classifyTool({
+ title: item.title,
+ toolName: item.toolName,
+ buzzToolName: item.buzzToolName,
+ args: item.args,
+ result: item.result,
+ isError: false,
+ });
+ // Some steps are genuinely nothing but a failure (no recoverable tool
+ // identity); those keep reporting as an error rather than being guessed at.
+ return recovered.renderClass === "error" ? descriptor : recovered;
+}
+
+/** Render class plus tone: the kind of work one step represents. */
+export function toolRunKind(item: ToolItem): ToolRunKind {
+ const descriptor = toolRunDescriptor(item);
+ // `item.renderClass` is canonical except when it too was flattened to
+ // "error", in which case the recovered descriptor's class is the honest one.
+ const renderClass =
+ item.renderClass && item.renderClass !== "error"
+ ? item.renderClass
+ : descriptor.renderClass;
+ return { renderClass, tone: descriptor.tone ?? "neutral" };
+}
+
+function sameToolRunKind(left: ToolRunKind, right: ToolRunKind): boolean {
+ return left.renderClass === right.renderClass && left.tone === right.tone;
+}
+
+function isToolStepRunning(item: ToolItem): boolean {
+ return item.status === "executing" || item.status === "pending";
+}
+
+function isToolStepFailed(item: ToolItem): boolean {
+ return item.isError || item.status === "failed";
+}
+
+/** Aggregate status across a run's steps. */
+export function summarizeToolRunStatus(items: ToolItem[]): ToolRunAggregate {
+ let activeStep: number | null = null;
+ let errorCount = 0;
+
+ items.forEach((item, index) => {
+ if (activeStep === null && isToolStepRunning(item)) {
+ activeStep = index + 1;
+ }
+ if (isToolStepFailed(item)) {
+ errorCount += 1;
+ }
+ });
+
+ const hasError = errorCount > 0;
+ const phase: ToolRunPhase =
+ activeStep !== null ? "running" : hasError ? "error" : "done";
+
+ return { phase, hasError, count: items.length, activeStep, errorCount };
+}
+
+/**
+ * Past-tense label for a run whose steps all share one semantic group key.
+ * These are the specific, countable sentences ("Read 3 files") the transcript
+ * has always used; keep them verbatim so a homogeneous run reads no differently
+ * than it did before chains existed.
+ */
+function homogeneousHeadline(items: ToolItem[]): ToolRunHeadline {
+ const count = items.length;
+ const kind = toolRunKind(items[0]);
+ const phrase = RENDER_CLASS_PHRASE[kind.renderClass];
+
+ // Work the classifier could only name generically has no honest collective
+ // noun, so it keeps the classifier's own label ("Queried registry ×2")
+ // instead of counting anonymous "tool calls".
+ if (!phrase.countable) {
+ return {
+ verb: toolRunDescriptor(items[0]).label,
+ object: `×${count}`,
+ detail: null,
+ };
+ }
+
+ return {
+ verb: runVerb(items, kind),
+ object: `${count} ${pluralize(phrase.noun, count)}`,
+ detail: null,
+ };
+}
+
+function pluralize(noun: string, count: number): string {
+ return count === 1 ? noun : `${noun}s`;
+}
+
+/**
+ * Present-participle form of a run's verb, for a live header.
+ *
+ * Verbs this module chooses are `RunVerb` and always have an entry. A verb that
+ * came straight from a descriptor is only typed `string` — the classifier could
+ * grow one this table has not seen — so an unknown verb degrades to itself
+ * rather than to something wrong.
+ */
+function progressiveVerb(verb: string): string {
+ return PROGRESSIVE_VERBS[verb as RunVerb] ?? verb;
+}
+
+/**
+ * The verb a run headlines with.
+ *
+ * Vocabulary comes from the classifier: each step's descriptor already carries
+ * an `action.verb` chosen from the tool and its tone, so a run of relay reads
+ * says "Read" and a run of relay writes says "Updated" without this module
+ * re-deriving either. When the steps that define the headline disagree on a
+ * verb (a mixed sweep of writes, say) the run falls back to the tone's verb,
+ * and finally to the render class's floor.
+ */
+function runVerb(items: ToolItem[], kind: ToolRunKind): string {
+ const verbs = new Set();
+ for (const item of items) {
+ if (!sameToolRunKind(toolRunKind(item), kind)) continue;
+ const verb = toolRunDescriptor(item).action?.verb;
+ if (verb) verbs.add(verb);
+ }
+
+ if (verbs.size === 1) {
+ const [only] = verbs;
+ return only;
+ }
+
+ return TONE_VERB[kind.tone] ?? RENDER_CLASS_PHRASE[kind.renderClass].verb;
+}
+
+/**
+ * The kind of work that dominates a run. Ties break toward the more salient
+ * render class, then the more salient tone — so a run that wrote as much as it
+ * read headlines as writing ("failures rise; reads recede").
+ */
+export function dominantToolRunKind(items: ToolItem[]): ToolRunKind {
+ const counts = new Map();
+ for (const item of items) {
+ const kind = toolRunKind(item);
+ const key = `${kind.renderClass}:${kind.tone}`;
+ const entry = counts.get(key);
+ if (entry) {
+ entry.count += 1;
+ } else {
+ counts.set(key, { count: 1, kind });
+ }
+ }
+
+ let dominant: { count: number; kind: ToolRunKind } | null = null;
+ for (const entry of counts.values()) {
+ if (dominant === null || outranks(entry, dominant)) {
+ dominant = entry;
+ }
+ }
+
+ return dominant?.kind ?? { renderClass: "generic", tone: "neutral" };
+}
+
+function outranks(
+ candidate: { count: number; kind: ToolRunKind },
+ incumbent: { count: number; kind: ToolRunKind },
+): boolean {
+ if (candidate.count !== incumbent.count) {
+ return candidate.count > incumbent.count;
+ }
+ const candidateClass = RENDER_CLASS_SALIENCE[candidate.kind.renderClass];
+ const incumbentClass = RENDER_CLASS_SALIENCE[incumbent.kind.renderClass];
+ if (candidateClass !== incumbentClass) {
+ return candidateClass < incumbentClass;
+ }
+ return (
+ TONE_SALIENCE[candidate.kind.tone] < TONE_SALIENCE[incumbent.kind.tone]
+ );
+}
+
+/**
+ * Headline for a run: verb, object, and an optional trailing clause.
+ *
+ * This is the run's ONE headline derivation — the collapsed header and the
+ * live header are the same sentence in two tenses, and both read their
+ * vocabulary from the classifier's descriptors rather than from a parallel
+ * taxonomy.
+ *
+ * A live run reads as active and reports how far it has got
+ * ("Reviewing files · step 3"). A settled run reads as an outcome: homogeneous
+ * runs keep their specific countable sentence ("Read 4 files"), heterogeneous
+ * runs name the dominant kind of work and carry the step count in the detail
+ * clause ("Read files · 6 steps") rather than pretending to be one thing.
+ */
+export function summarizeToolRunHeadline(
+ items: ToolItem[],
+ aggregate: ToolRunAggregate,
+): ToolRunHeadline {
+ if (items.length === 0) {
+ return { verb: "Working…", object: null, detail: null };
+ }
+
+ const groupKey = toolRunGroupKey(items[0]);
+ const homogeneous = items.every((item) => toolRunGroupKey(item) === groupKey);
+
+ if (aggregate.phase === "running") {
+ const kind = homogeneous
+ ? toolRunKind(items[0])
+ : dominantToolRunKind(items);
+ const verb = runVerb(items, kind);
+ return {
+ verb: progressiveVerb(verb),
+ // A live run has no final count to report, so it names the kind of work
+ // and lets the detail clause carry progress.
+ object: pluralize(RENDER_CLASS_PHRASE[kind.renderClass].noun, 2),
+ detail:
+ aggregate.activeStep === null ? null : `step ${aggregate.activeStep}`,
+ };
+ }
+
+ if (homogeneous) {
+ return homogeneousHeadline(items);
+ }
+
+ const kind = dominantToolRunKind(items);
+ return {
+ verb: runVerb(items, kind),
+ object: pluralize(RENDER_CLASS_PHRASE[kind.renderClass].noun, 2),
+ detail: `${items.length} steps`,
+ };
+}
+
+/** Earliest start instant across a run, in epoch ms. */
+export function toolRunStartedAtMs(items: ToolItem[]): number | null {
+ let earliest: number | null = null;
+ for (const item of items) {
+ const parsed = Date.parse(item.startedAt || item.timestamp);
+ if (Number.isNaN(parsed)) continue;
+ if (earliest === null || parsed < earliest) earliest = parsed;
+ }
+ return earliest;
+}
+
+/**
+ * Latest completion instant across a run, in epoch ms — null unless every step
+ * has settled, so a partially-finished run never reports a final duration.
+ */
+export function toolRunCompletedAtMs(items: ToolItem[]): number | null {
+ let latest: number | null = null;
+ for (const item of items) {
+ if (!item.completedAt) return null;
+ const parsed = Date.parse(item.completedAt);
+ if (Number.isNaN(parsed)) return null;
+ if (latest === null || parsed > latest) latest = parsed;
+ }
+ return latest;
+}
+
+/**
+ * Wall-clock span of a run in ms: first start to last completion once settled,
+ * or first start to `now` while still executing. Null when unmeasurable.
+ */
+export function toolRunElapsedMs(
+ items: ToolItem[],
+ now: number,
+): number | null {
+ const startedAt = toolRunStartedAtMs(items);
+ if (startedAt === null) return null;
+ const completedAt = toolRunCompletedAtMs(items);
+ const end = completedAt ?? now;
+ const elapsed = end - startedAt;
+ return elapsed < 0 ? null : elapsed;
+}
diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs
index 7fddb8a7f1b..4a13624138d 100644
--- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs
+++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.test.mjs
@@ -8,6 +8,7 @@ import {
getDisplayBlockKey,
} from "./agentSessionTranscriptGrouping.ts";
import { isObserverEventAfter } from "../observerRelayStore.ts";
+import { classifyTool } from "./agentSessionToolClassifier.ts";
const baseTimestamp = "2026-06-14T22:20:23.000Z";
@@ -244,8 +245,12 @@ test("buildTranscriptDisplayBlocks groups same-kind tool runs within a turn", ()
assert.equal(block.kind, "turn");
assert.equal(block.segments.length, 1);
- assert.equal(block.segments[0].kind, "summary");
- assert.equal(block.segments[0].summary.label, "Read 3 files");
+ assert.equal(block.segments[0].kind, "tool-run");
+ assert.equal(block.segments[0].run.id, "tool-run:tool:1");
+ assert.deepEqual(
+ block.segments[0].run.items.map((item) => item.id),
+ ["tool:1", "tool:2", "tool:3"],
+ );
});
test("buildTranscriptDisplayBlocks groups consecutive file edit tool runs", () => {
@@ -278,11 +283,9 @@ test("buildTranscriptDisplayBlocks groups consecutive file edit tool runs", () =
assert.equal(block.kind, "turn");
assert.equal(block.segments.length, 1);
- assert.equal(block.segments[0].kind, "summary");
- assert.equal(block.segments[0].summary.label, "Edited 2 files");
- assert.equal(block.segments[0].summary.renderClass, "file-edit");
+ assert.equal(block.segments[0].kind, "tool-run");
assert.deepEqual(
- block.segments[0].summary.items.map((item) => item.id),
+ block.segments[0].run.items.map((item) => item.id),
["edit:1", "edit:2"],
);
});
@@ -297,16 +300,14 @@ test("buildTranscriptDisplayBlocks groups mixed consecutive eligible tool runs",
assert.equal(block.kind, "turn");
assert.equal(block.segments.length, 1);
- assert.equal(block.segments[0].kind, "summary");
- assert.equal(block.segments[0].summary.variant, "mixed");
- assert.equal(block.segments[0].summary.label, "Ran 4 tool calls");
+ assert.equal(block.segments[0].kind, "tool-run");
assert.deepEqual(
- block.segments[0].summary.items.map((item) => item.id),
+ block.segments[0].run.items.map((item) => item.id),
["read-1", "shell-1", "read-2", "read-3"],
);
});
-test("buildTranscriptDisplayBlocks groups tool bursts at threshold 2", () => {
+test("buildTranscriptDisplayBlocks groups tool runs at threshold 2", () => {
const [block] = buildTranscriptDisplayBlocks([
mkTool("read-1", "Read file", "file-read", "read_file"),
mkTool("shell-1", "Ran command", "shell", "shell:command"),
@@ -314,9 +315,8 @@ test("buildTranscriptDisplayBlocks groups tool bursts at threshold 2", () => {
assert.equal(block.kind, "turn");
assert.equal(block.segments.length, 1);
- assert.equal(block.segments[0].kind, "summary");
- assert.equal(block.segments[0].summary.variant, "mixed");
- assert.equal(block.segments[0].summary.label, "Ran 2 tool calls");
+ assert.equal(block.segments[0].kind, "tool-run");
+ assert.equal(block.segments[0].run.items.length, 2);
});
test("buildTranscriptDisplayBlocks keeps a lone eligible tool row expanded", () => {
@@ -331,7 +331,7 @@ test("buildTranscriptDisplayBlocks keeps a lone eligible tool row expanded", ()
);
});
-test("buildTranscriptDisplayBlocks nests same-kind summaries inside tool bursts", () => {
+test("buildTranscriptDisplayBlocks flattens heterogeneous tool work into ONE run", () => {
const [block] = buildTranscriptDisplayBlocks([
mkTool("read-1", "Read file", "file-read", "read_file"),
mkTool("read-2", "Read file", "file-read", "read_file"),
@@ -341,27 +341,17 @@ test("buildTranscriptDisplayBlocks nests same-kind summaries inside tool bursts"
]);
assert.equal(block.kind, "turn");
+ // One card, one level of steps. The old two-pass scheme nested a same-kind
+ // summary inside a mixed burst here, producing two stacked headlines.
assert.equal(block.segments.length, 1);
- assert.equal(block.segments[0].kind, "summary");
- assert.equal(block.segments[0].summary.variant, "mixed");
- assert.equal(block.segments[0].summary.label, "Ran 5 tool calls");
- // Mixed summaries are the only visible burst summary; nested same-kind
- // summaries flatten back to leaf rows to avoid redundant rows such as
- // "Ran 16 tool calls" → "Ran 12 commands".
- assert.deepEqual(
- block.segments[0].summary.segments.map((child) =>
- child.kind === "item" ? child.item.id : child.summary.label,
- ),
- ["read-1", "read-2", "read-3", "shell-1", "skill-1"],
- );
- // Flat leaf items preserve original order.
+ assert.equal(block.segments[0].kind, "tool-run");
assert.deepEqual(
- block.segments[0].summary.items.map((item) => item.id),
+ block.segments[0].run.items.map((item) => item.id),
["read-1", "read-2", "read-3", "shell-1", "skill-1"],
);
});
-test("buildTranscriptDisplayBlocks collapses alternating search/read bursts into one summary", () => {
+test("buildTranscriptDisplayBlocks collapses alternating command/read work into one run", () => {
const [block] = buildTranscriptDisplayBlocks([
mkTool("shell-1", "Ran command", "shell", "shell:command"),
mkTool("read-1", "Read file", "file-read", "read_file"),
@@ -375,13 +365,9 @@ test("buildTranscriptDisplayBlocks collapses alternating search/read bursts into
assert.equal(block.kind, "turn");
assert.equal(block.segments.length, 1);
- assert.equal(block.segments[0].kind, "summary");
- assert.equal(block.segments[0].summary.variant, "mixed");
- assert.equal(block.segments[0].summary.label, "Ran 8 tool calls");
+ assert.equal(block.segments[0].kind, "tool-run");
assert.deepEqual(
- block.segments[0].summary.segments.map((child) =>
- child.kind === "summary" ? child.summary.label : child.item.id,
- ),
+ block.segments[0].run.items.map((item) => item.id),
[
"shell-1",
"read-1",
@@ -395,6 +381,26 @@ test("buildTranscriptDisplayBlocks collapses alternating search/read bursts into
);
});
+// Appending a step must not change the run id: the card is keyed on it, and a
+// changed key remounts the card and drops the reader's disclosure choice.
+test("buildTranscriptDisplayBlocks keeps the run id stable as steps stream in", () => {
+ const step = (id) => mkTool(id, "Read file", "file-read", "read_file");
+ const runId = (items) =>
+ buildTranscriptDisplayBlocks(items)[0].segments[0].run.id;
+
+ const two = [step("read-1"), step("read-2")];
+ assert.equal(runId(two), "tool-run:read-1");
+ assert.equal(runId([...two, step("read-3")]), "tool-run:read-1");
+ assert.equal(
+ runId([
+ ...two,
+ step("read-3"),
+ mkTool("shell-1", "Ran command", "shell", "shell:command"),
+ ]),
+ "tool-run:read-1",
+ );
+});
+
test("buildTranscriptDisplayBlocks keeps messages out of mixed tool runs", () => {
const [block] = buildTranscriptDisplayBlocks([
mkTool("read-1", "Read file", "file-read", "read_file"),
@@ -407,20 +413,24 @@ test("buildTranscriptDisplayBlocks keeps messages out of mixed tool runs", () =>
assert.equal(block.kind, "turn");
assert.deepEqual(
block.segments.map((segment) => segment.kind),
- ["summary", "item", "summary"],
+ ["tool-run", "item", "tool-run"],
);
assert.equal(block.segments[1].item.id, "assistant");
assert.deepEqual(
- block.segments[0].summary.items.map((item) => item.id),
+ block.segments[0].run.items.map((item) => item.id),
["read-1", "shell-1"],
);
assert.deepEqual(
- block.segments[2].summary.items.map((item) => item.id),
+ block.segments[2].run.items.map((item) => item.id),
["read-2", "shell-2"],
);
});
-test("buildTranscriptDisplayBlocks breaks failed tools out of mixed tool runs", () => {
+// A failure belongs to the run it happened in. Shattering the run around it
+// (the old behaviour) produced three rows for one stretch of work and hid the
+// failure's context; the card instead keeps the failing step as a member,
+// stays open, and highlights it.
+test("buildTranscriptDisplayBlocks keeps failed tools inside their run", () => {
const failed = {
...mkTool("shell-fail", "Ran command failed", "error", "shell:command"),
isError: true,
@@ -439,19 +449,23 @@ test("buildTranscriptDisplayBlocks breaks failed tools out of mixed tool runs",
assert.equal(block.kind, "turn");
assert.deepEqual(
block.segments.map((segment) => segment.kind),
- ["summary", "item", "summary"],
+ ["tool-run"],
);
- assert.equal(block.segments[0].summary.variant, "mixed");
- assert.equal(block.segments[0].summary.label, "Ran 3 tool calls");
- assert.equal(block.segments[1].item.id, "shell-fail");
- assert.equal(block.segments[2].summary.variant, "mixed");
assert.deepEqual(
- block.segments[2].summary.items.map((item) => item.id),
- ["read-2", "shell-2", "image-1"],
+ block.segments[0].run.items.map((item) => item.id),
+ [
+ "read-1",
+ "shell-1",
+ "skill-1",
+ "shell-fail",
+ "read-2",
+ "shell-2",
+ "image-1",
+ ],
);
});
-test("flattenDisplayBlocks preserves child order through mixed summaries", () => {
+test("flattenDisplayBlocks preserves step order through tool runs", () => {
const blocks = buildTranscriptDisplayBlocks([
mkTool("read-1", "Read file", "file-read", "read_file"),
mkTool("shell-1", "Ran command", "shell", "shell:command"),
@@ -464,7 +478,7 @@ test("flattenDisplayBlocks preserves child order through mixed summaries", () =>
);
});
-test("buildTranscriptDisplayBlocks never same-kind groups failed tools", () => {
+test("buildTranscriptDisplayBlocks groups an all-failed run into one card", () => {
const mkFailed = (id) => ({
...mkTool(id, "Ran command failed", "error", "shell:command"),
isError: true,
@@ -479,11 +493,15 @@ test("buildTranscriptDisplayBlocks never same-kind groups failed tools", () => {
assert.equal(block.kind, "turn");
assert.deepEqual(
block.segments.map((segment) => segment.kind),
- ["item", "item", "item"],
+ ["tool-run"],
+ );
+ assert.deepEqual(
+ block.segments[0].run.items.map((item) => item.id),
+ ["fail-1", "fail-2", "fail-3"],
);
});
-test("buildTranscriptDisplayBlocks never same-kind groups status tool rows", () => {
+test("buildTranscriptDisplayBlocks never runs status tool rows into a card", () => {
const [block] = buildTranscriptDisplayBlocks([
mkTool("status-1", "Context compacted", "status", "status:post-compact"),
mkTool("status-2", "Context compacted", "status", "status:post-compact"),
@@ -497,7 +515,7 @@ test("buildTranscriptDisplayBlocks never same-kind groups status tool rows", ()
);
});
-test("buildTranscriptDisplayBlocks never same-kind groups suppressed tool rows", () => {
+test("buildTranscriptDisplayBlocks never runs suppressed tool rows into a card", () => {
const [block] = buildTranscriptDisplayBlocks([
mkTool("stop-1", "Checked todos", "suppressed", "suppressed:stop-hook"),
mkTool("stop-2", "Checked todos", "suppressed", "suppressed:stop-hook"),
@@ -511,16 +529,11 @@ test("buildTranscriptDisplayBlocks never same-kind groups suppressed tool rows",
);
});
-test("buildTranscriptDisplayBlocks breaks same-kind runs on an ineligible row", () => {
- const failed = {
- ...mkTool("fail-1", "Read file failed", "error", "read_file"),
- isError: true,
- };
-
+test("buildTranscriptDisplayBlocks breaks runs on an ineligible row", () => {
const [block] = buildTranscriptDisplayBlocks([
mkTool("read-1", "Read file", "file-read", "read_file"),
mkTool("read-2", "Read file", "file-read", "read_file"),
- failed,
+ mkTool("status-1", "Context compacted", "status", "status:post-compact"),
mkTool("read-3", "Read file", "file-read", "read_file"),
mkTool("read-4", "Read file", "file-read", "read_file"),
]);
@@ -528,19 +541,114 @@ test("buildTranscriptDisplayBlocks breaks same-kind runs on an ineligible row",
assert.equal(block.kind, "turn");
assert.deepEqual(
block.segments.map((segment) => segment.kind),
- ["summary", "item", "summary"],
+ ["tool-run", "item", "tool-run"],
);
- assert.equal(block.segments[1].item.id, "fail-1");
+ assert.equal(block.segments[1].item.id, "status-1");
assert.deepEqual(
- block.segments[0].summary.items.map((item) => item.id),
+ block.segments[0].run.items.map((item) => item.id),
["read-1", "read-2"],
);
assert.deepEqual(
- block.segments[2].summary.items.map((item) => item.id),
+ block.segments[2].run.items.map((item) => item.id),
["read-3", "read-4"],
);
});
+// Thoughts and plans are read as prose, not as steps, and permission gates are
+// intervention points — each stays visible and splits the run around it.
+test("buildTranscriptDisplayBlocks breaks runs on thoughts and permission gates", () => {
+ const thought = {
+ id: "thought-1",
+ type: "thought",
+ renderClass: "thought",
+ title: "Thinking",
+ text: "considering options",
+ timestamp: "2026-06-18T00:00:00Z",
+ turnId: "turn-1",
+ sessionId: "sess-1",
+ channelId: "chan-1",
+ };
+ const permission = {
+ ...mkTool("perm-1", "Permission requested", "permission", "permission:ask"),
+ };
+
+ const [block] = buildTranscriptDisplayBlocks([
+ mkTool("read-1", "Read file", "file-read", "read_file"),
+ mkTool("read-2", "Read file", "file-read", "read_file"),
+ thought,
+ mkTool("read-3", "Read file", "file-read", "read_file"),
+ permission,
+ mkTool("shell-1", "Ran command", "shell", "shell:command"),
+ mkTool("shell-2", "Ran command", "shell", "shell:command"),
+ ]);
+
+ assert.equal(block.kind, "turn");
+ assert.deepEqual(
+ block.segments.map((segment) => segment.kind),
+ ["tool-run", "item", "item", "item", "tool-run"],
+ );
+ assert.equal(block.segments[1].item.id, "thought-1");
+ // A lone eligible step between two breakers renders as today, not as a card.
+ assert.equal(block.segments[2].item.id, "read-3");
+ assert.equal(block.segments[3].item.id, "perm-1");
+});
+
+// A failed safety/status row must still break the run. The classifier flattens
+// any failed step to render class `error` while keeping its original groupKey,
+// so eligibility decided on the reported class alone folded a failed stop hook
+// into the surrounding chain. Built through the real classifier, because the
+// bug lived in exactly that flattening — a hand-written descriptor would not
+// reproduce it.
+test("buildTranscriptDisplayBlocks breaks runs on a FAILED suppressed row", () => {
+ const failedStopHook = mkClassifiedTool("stop-1", "stop", { isError: true });
+ assert.equal(failedStopHook.descriptor.renderClass, "error");
+ assert.equal(failedStopHook.descriptor.groupKey, "suppressed:stop-hook");
+
+ const [block] = buildTranscriptDisplayBlocks([
+ mkTool("shell-1", "Ran command", "shell", "shell:command"),
+ mkTool("shell-2", "Ran command", "shell", "shell:command"),
+ failedStopHook,
+ mkTool("shell-3", "Ran command", "shell", "shell:command"),
+ mkTool("shell-4", "Ran command", "shell", "shell:command"),
+ ]);
+
+ assert.equal(block.kind, "turn");
+ assert.deepEqual(
+ block.segments.map((segment) => segment.kind),
+ ["tool-run", "item", "tool-run"],
+ );
+ assert.equal(block.segments[1].item.id, "stop-1");
+ assert.deepEqual(
+ block.segments[0].run.items.map((item) => item.id),
+ ["shell-1", "shell-2"],
+ );
+ assert.deepEqual(
+ block.segments[2].run.items.map((item) => item.id),
+ ["shell-3", "shell-4"],
+ );
+});
+
+test("buildTranscriptDisplayBlocks breaks runs on a FAILED status row", () => {
+ const failedCompact = mkClassifiedTool("compact-1", "postcompact", {
+ isError: true,
+ });
+ assert.equal(failedCompact.descriptor.renderClass, "error");
+ assert.equal(failedCompact.descriptor.groupKey, "status:post-compact");
+
+ const [block] = buildTranscriptDisplayBlocks([
+ mkTool("read-1", "Read file", "file-read", "read_file"),
+ mkTool("read-2", "Read file", "file-read", "read_file"),
+ failedCompact,
+ ]);
+
+ assert.equal(block.kind, "turn");
+ assert.deepEqual(
+ block.segments.map((segment) => segment.kind),
+ ["tool-run", "item"],
+ );
+ assert.equal(block.segments[1].item.id, "compact-1");
+});
+
test("buildTranscriptDisplayBlocks bundles steer message with steer context behind the prompt segment", () => {
const steerMessage = {
id: "steer:chan-1:turn-1",
@@ -641,6 +749,32 @@ function mkTool(id, label, renderClass = "generic", groupKey = label) {
};
}
+/**
+ * A tool item whose descriptor comes from the REAL classifier, so tests that
+ * depend on how it flattens failures (failed steps become render class `error`
+ * while keeping their original groupKey) exercise production behaviour rather
+ * than a fixture's guess at it.
+ */
+function mkClassifiedTool(id, toolName, { args = {}, isError = false } = {}) {
+ const descriptor = classifyTool({
+ title: toolName,
+ toolName,
+ buzzToolName: null,
+ args,
+ result: "",
+ isError,
+ });
+
+ return {
+ ...mkTool(id, toolName, descriptor.renderClass, descriptor.groupKey),
+ args,
+ descriptor,
+ isError,
+ status: isError ? "failed" : "completed",
+ toolName,
+ };
+}
+
// ── Session-run splitting and session-boundary blocks ──────────────────────────
/**
diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts
index 795f1fcd164..8a7f54772ea 100644
--- a/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts
+++ b/desktop/src/features/agents/ui/agentSessionTranscriptGrouping.ts
@@ -1,10 +1,13 @@
import { buildTranscriptState } from "./agentSessionTranscript";
import type { ObserverEvent, TranscriptItem } from "./agentSessionTypes";
-import { classifyToolItem } from "./agentSessionToolClassifier";
+import {
+ isToolRunEligible,
+ TOOL_RUN_MINIMUM_STEPS,
+} from "./agentSessionToolRunSummary";
export type TranscriptTurnSegment =
| { kind: "item"; item: TranscriptItem }
- | { kind: "summary"; summary: TranscriptToolRunSummary }
+ | { kind: "tool-run"; run: TranscriptToolRun }
| { kind: "setup"; items: Extract[] }
| {
kind: "prompt";
@@ -58,30 +61,16 @@ export type TranscriptDisplayBlock =
firstItemId: string;
};
-export type TranscriptToolRunChildSegment =
- | { kind: "item"; item: TranscriptItem }
- | { kind: "summary"; summary: TranscriptToolRunSummary };
-
-export type TranscriptToolRunSummary = {
- id: string;
- label: string;
- count: number;
- /** Flat leaf tool items in original order (nested summaries expanded). */
- items: TranscriptItem[];
- renderClass: TranscriptItem["renderClass"] | null;
+export type TranscriptToolRun = {
/**
- * "same-kind" summaries collapse runs sharing one semantic groupKey and get
- * specific labels ("Read 3 files"). "mixed" summaries collapse broader
- * bursts of routine tool work ("Ran 9 tool calls") and may contain nested
- * same-kind summaries as children.
+ * Stable identity for the run, derived from its FIRST step's item id. A run
+ * grows in place as later steps stream in — appending a step must not change
+ * this id, or the card would remount and lose its disclosure state.
*/
- variant: "same-kind" | "mixed";
- /**
- * Child segments in original order for mixed bursts — raw tool rows plus
- * any same-kind summaries that joined the burst. Absent on same-kind
- * summaries, whose children are just `items`.
- */
- segments?: TranscriptToolRunChildSegment[];
+ id: string;
+ /** Tool steps in original order. */
+ items: Extract[];
+ /** Timestamp of the run's first step. */
timestamp: string;
};
@@ -176,7 +165,7 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] {
}
if (!userPrompt) {
- return groupToolSegments(activitySegments);
+ return groupToolRunSegments(activitySegments);
}
const segments: TranscriptTurnSegment[] = [
@@ -189,204 +178,66 @@ function classifyTurnItems(items: TranscriptItem[]): TranscriptTurnSegment[] {
...activitySegments,
];
- return groupToolSegments(segments);
+ return groupToolRunSegments(segments);
}
/**
- * Two-pass tool grouping:
- * 1. Same-kind runs collapse into summaries with specific labels
- * ("Read 3 files", "Edited 2 files").
- * 2. Leftover adjacent eligible tool rows of differing kinds collapse into a
- * mixed fallback summary ("Ran 5 tool calls").
+ * Collapse each maximal run of consecutive chain-eligible tool steps into one
+ * `tool-run` segment.
*
- * Messages, errors, permissions, and status/lifecycle rows never join either
- * pass, so intervention points stay visible.
+ * This is the transcript's ONE tool-grouping mechanism. It replaced an earlier
+ * two-pass scheme (same-kind summaries, then a "mixed burst" summary that could
+ * nest them) whose nesting produced redundant headlines like "Ran 16 tool calls"
+ * → "Ran 12 commands". A run is now flat: one card, one level of steps, and the
+ * headline adapts to whether the run is homogeneous
+ * (`summarizeToolRunHeadline`).
+ *
+ * Messages, thoughts, plans, permissions, status/lifecycle, raw-rail, and
+ * suppressed rows are not eligible, so they both stay visible and break runs —
+ * intervention points never disappear into a card. Failed tool steps are
+ * deliberately eligible: a failure belongs to the run it happened in, and the
+ * card keeps itself open and highlights the failing step.
*/
-function groupToolSegments(
- segments: TranscriptTurnSegment[],
-): TranscriptTurnSegment[] {
- return groupMixedToolRuns(groupSameKindSegments(segments));
-}
-
-function groupSameKindSegments(
+function groupToolRunSegments(
segments: TranscriptTurnSegment[],
): TranscriptTurnSegment[] {
const grouped: TranscriptTurnSegment[] = [];
+
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
- if (segment.kind !== "item") {
+ if (segment.kind !== "item" || !isToolRunEligible(segment.item)) {
grouped.push(segment);
continue;
}
- const key = sameKindKey(segment.item);
- if (!key) {
- grouped.push(segment);
- continue;
- }
- const run = [segment.item];
- let j = i + 1;
+
+ const run: Extract[] = [];
+ let j = i;
while (j < segments.length) {
const next = segments[j];
- if (next.kind !== "item" || sameKindKey(next.item) !== key) break;
- run.push(next.item);
+ if (next.kind !== "item" || !isToolRunEligible(next.item)) break;
+ // isToolRunEligible only admits tool items.
+ run.push(next.item as Extract);
j += 1;
}
- if (run.length >= minimumSummaryRunLength(run[0])) {
+
+ if (run.length >= TOOL_RUN_MINIMUM_STEPS) {
grouped.push({
- kind: "summary",
- summary: {
- id: `summary:${key}:${run[0].id}`,
- label: sameKindLabel(run[0], run.length),
- count: run.length,
+ kind: "tool-run",
+ run: {
+ // Keyed on the first step so the id is append-stable while the run
+ // streams; see TranscriptToolRun.id.
+ id: `tool-run:${run[0].id}`,
items: run,
- renderClass: getRenderClass(run[0]),
- variant: "same-kind",
timestamp: run[0].timestamp,
},
});
- i = j - 1;
} else {
grouped.push(...run.map((item) => ({ kind: "item" as const, item })));
- i = j - 1;
- }
- }
- return grouped;
-}
-
-const MIXED_RUN_MINIMUM_SEGMENTS = 2;
-
-/**
- * Burst pass: collapse an interleave-tolerant run of routine tool work into
- * one "Ran N tool calls" summary. Both leftover raw eligible tool rows and
- * same-kind summaries produced by the first pass participate, so alternating
- * patterns like search → read-summary → search → read-summary collapse into a
- * single supervision row whose children are the original segments in order.
- * Messages, permissions, errors/failed tools, and status/suppressed rows
- * break bursts, so intervention points stay visible.
- */
-function groupMixedToolRuns(
- segments: TranscriptTurnSegment[],
-): TranscriptTurnSegment[] {
- const grouped: TranscriptTurnSegment[] = [];
- for (let i = 0; i < segments.length; i++) {
- const segment = segments[i];
- if (!isBurstParticipant(segment)) {
- grouped.push(segment);
- continue;
- }
- const run: TranscriptToolRunChildSegment[] = [segment];
- let j = i + 1;
- while (j < segments.length) {
- const next = segments[j];
- if (!isBurstParticipant(next)) break;
- run.push(next);
- j += 1;
- }
- if (run.length >= MIXED_RUN_MINIMUM_SEGMENTS) {
- const items = run.flatMap((child) =>
- child.kind === "item" ? [child.item] : child.summary.items,
- );
- // Mixed bursts are already the visual summary. Expanding nested
- // same-kind summaries here creates redundant rows like
- // "Ran 16 tool calls" → "Ran 12 commands". Keep same-kind summaries as
- // grouping inputs, but flatten the mixed summary's visible children back
- // to leaf tool rows.
- const childSegments = items.map((item) => ({
- kind: "item" as const,
- item,
- }));
- grouped.push({
- kind: "summary",
- summary: {
- id: `summary:mixed:${items[0].id}`,
- label: `Ran ${items.length} tool calls`,
- count: items.length,
- items,
- renderClass: null,
- variant: "mixed",
- segments: childSegments,
- timestamp: items[0].timestamp,
- },
- });
- } else {
- grouped.push(...run);
}
i = j - 1;
}
- return grouped;
-}
-
-/**
- * Burst participants are raw eligible tool rows and same-kind summaries
- * (already-collapsed routine tool work). Mixed summaries never re-enter.
- */
-function isBurstParticipant(
- segment: TranscriptTurnSegment,
-): segment is TranscriptToolRunChildSegment {
- if (segment.kind === "item") {
- return isGroupingEligible(segment.item);
- }
- return segment.kind === "summary" && segment.summary.variant === "same-kind";
-}
-
-const GROUPING_ELIGIBLE_RENDER_CLASSES = new Set<
- NonNullable
->([
- "file-read",
- "skill-read",
- "shell",
- "relay-op",
- "file-edit",
- "image",
- "plan",
- "generic",
-]);
-
-/**
- * Shared eligibility for both grouping passes. Failed tools (isError or
- * reclassified renderClass "error"), messages, permissions, status, and
- * suppressed rows are never grouped and break runs, so intervention points
- * stay visible.
- */
-function isGroupingEligible(item: TranscriptItem): boolean {
- if (item.type !== "tool" || item.isError) return false;
- const renderClass = getRenderClass(item);
- return (
- renderClass != null && GROUPING_ELIGIBLE_RENDER_CLASSES.has(renderClass)
- );
-}
-
-function sameKindKey(item: TranscriptItem): string | null {
- if (!isGroupingEligible(item) || item.type !== "tool") return null;
- const descriptor = item.descriptor ?? classifyToolItem(item);
- return descriptor.groupKey ?? getRenderClass(item);
-}
-
-function sameKindLabel(item: TranscriptItem, count: number): string {
- if (item.type !== "tool") return `${count} items`;
- const descriptor = item.descriptor ?? classifyToolItem(item);
- const renderClass = getRenderClass(item);
- const label = descriptor.label;
- if (renderClass === "file-edit") {
- return `Edited ${count} file${count === 1 ? "" : "s"}`;
- }
- if (renderClass === "file-read") return `Read ${count} files`;
- if (renderClass === "skill-read") {
- return `Read ${count} skill${count === 1 ? "" : "s"}`;
- }
- if (renderClass === "shell") return `Ran ${count} commands`;
- if (renderClass === "relay-op") return `Ran ${count} Buzz relay ops`;
- return `${label} ×${count}`;
-}
-
-function minimumSummaryRunLength(item: TranscriptItem): number {
- return getRenderClass(item) === "file-edit" ? 2 : 3;
-}
-function getRenderClass(item: TranscriptItem) {
- if (item.type !== "tool") return item.renderClass;
- const descriptor = item.descriptor ?? classifyToolItem(item);
- return item.renderClass ?? descriptor.renderClass;
+ return grouped;
}
/**
@@ -732,8 +583,8 @@ export function flattenDisplayBlocks(
if (segment.context) {
result.push(segment.context);
}
- } else if (segment.kind === "summary") {
- result.push(...segment.summary.items);
+ } else if (segment.kind === "tool-run") {
+ result.push(...segment.run.items);
} else {
result.push(...segment.items);
}
diff --git a/desktop/src/shared/hooks/useControlledDisclosure.test.mjs b/desktop/src/shared/hooks/useControlledDisclosure.test.mjs
new file mode 100644
index 00000000000..143ad8b47d2
--- /dev/null
+++ b/desktop/src/shared/hooks/useControlledDisclosure.test.mjs
@@ -0,0 +1,150 @@
+import assert from "node:assert/strict";
+import { after, afterEach, before, test } from "node:test";
+
+import { JSDOM } from "jsdom";
+
+const dom = new JSDOM("", {
+ url: "http://localhost",
+});
+
+before(() => {
+ Object.assign(globalThis, {
+ document: dom.window.document,
+ HTMLElement: dom.window.HTMLElement,
+ Element: dom.window.Element,
+ Node: dom.window.Node,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ window: dom.window,
+ });
+});
+
+afterEach(async () => {
+ const { cleanup } = await import("@testing-library/react");
+ cleanup();
+});
+
+after(() => dom.window.close());
+
+/**
+ * Drives a real controlled `` through the hook, since the trap the
+ * hook exists for lives in how `` reports `toggle` — a unit test
+ * against the returned callback alone could not observe it.
+ */
+async function renderDisclosure(initialPolicyOpen) {
+ const { createElement } = await import("react");
+ const { render } = await import("@testing-library/react");
+ const { useControlledDisclosure } = await import(
+ "./useControlledDisclosure.ts"
+ );
+
+ function Probe({ policyOpen }) {
+ const { onOpenChange, open } = useControlledDisclosure(policyOpen);
+ return createElement(
+ "details",
+ {
+ onToggle: (event) => onOpenChange(event.currentTarget.open),
+ open,
+ },
+ createElement("summary", null, "header"),
+ createElement("p", null, "body"),
+ );
+ }
+
+ const view = render(createElement(Probe, { policyOpen: initialPolicyOpen }));
+ const details = () => view.container.querySelector("details");
+ return {
+ details,
+ // Re-run policy with a new answer, as streaming data does.
+ setPolicy: (policyOpen) =>
+ view.rerender(createElement(Probe, { policyOpen })),
+ // What the reader does: the browser mutates open, THEN fires toggle.
+ readerToggle: async () => {
+ const { act, fireEvent } = await import("@testing-library/react");
+ const element = details();
+ await act(async () => {
+ element.open = !element.open;
+ fireEvent(element, new dom.window.Event("toggle"));
+ });
+ },
+ // What a real browser additionally does after a PROGRAMMATIC open change:
+ // echo a toggle that agrees with the state just rendered. jsdom does not
+ // emit this, so it is injected — otherwise every guard test here would
+ // pass vacuously.
+ echoToggle: async () => {
+ const { act, fireEvent } = await import("@testing-library/react");
+ const element = details();
+ await act(async () => {
+ fireEvent(element, new dom.window.Event("toggle"));
+ });
+ },
+ };
+}
+
+test("policy drives open state until the reader intervenes", async () => {
+ const { details, setPolicy } = await renderDisclosure(true);
+ assert.equal(details().open, true);
+
+ setPolicy(false);
+ assert.equal(details().open, false);
+
+ setPolicy(true);
+ assert.equal(details().open, true);
+});
+
+// The regression guard. Without it the echo is recorded as a reader choice and
+// pins the row to its first policy answer, disabling every later transition.
+test("a browser toggle echo of a policy-driven open is not a reader choice", async () => {
+ const { details, echoToggle, setPolicy } = await renderDisclosure(true);
+ assert.equal(details().open, true);
+
+ await echoToggle();
+
+ setPolicy(false);
+ assert.equal(details().open, false);
+});
+
+test("the reader's choice outlives later policy changes", async () => {
+ const { details, readerToggle, setPolicy } = await renderDisclosure(true);
+
+ await readerToggle();
+ assert.equal(details().open, false);
+
+ // Policy still says open; the reader said otherwise and keeps winning.
+ setPolicy(true);
+ assert.equal(details().open, false);
+
+ setPolicy(false);
+ assert.equal(details().open, false);
+});
+
+test("the reader can also override policy in the opening direction", async () => {
+ const { details, readerToggle, setPolicy } = await renderDisclosure(false);
+ assert.equal(details().open, false);
+
+ await readerToggle();
+ assert.equal(details().open, true);
+
+ setPolicy(false);
+ assert.equal(details().open, true);
+});
+
+test("onOpenChange keeps a stable identity across renders", async () => {
+ const { createElement } = await import("react");
+ const { render } = await import("@testing-library/react");
+ const { useControlledDisclosure } = await import(
+ "./useControlledDisclosure.ts"
+ );
+
+ const seen = [];
+ function Probe({ policyOpen }) {
+ const { onOpenChange } = useControlledDisclosure(policyOpen);
+ seen.push(onOpenChange);
+ return null;
+ }
+
+ const view = render(createElement(Probe, { policyOpen: true }));
+ view.rerender(createElement(Probe, { policyOpen: false }));
+
+ assert.ok(seen.length >= 2);
+ assert.equal(seen[0], seen[seen.length - 1]);
+});
diff --git a/desktop/src/shared/hooks/useControlledDisclosure.ts b/desktop/src/shared/hooks/useControlledDisclosure.ts
new file mode 100644
index 00000000000..42d228b5954
--- /dev/null
+++ b/desktop/src/shared/hooks/useControlledDisclosure.ts
@@ -0,0 +1,51 @@
+import * as React from "react";
+
+export type ControlledDisclosure = {
+ /** Whether the disclosure should currently render open. */
+ open: boolean;
+ /** Pass straight to a controlled `` / `ActivityRow` change handler. */
+ onOpenChange: (open: boolean) => void;
+};
+
+/**
+ * Disclosure state for a `` whose open state has a *policy* — some
+ * rule that opens or closes it as data changes (a tool run opens while it is
+ * live and closes when it settles; a thought opens while the agent is still
+ * thinking) — but where the reader's own click must win from then on.
+ *
+ * Pass the policy's current answer as `policyOpen`. Until the reader touches
+ * the row, that answer is what renders; afterwards their choice does, for as
+ * long as the component stays mounted.
+ *
+ * ## The echo trap this exists to guard
+ *
+ * `` fires `toggle` for programmatic `open` changes as well as for
+ * clicks, and the event carries no way to tell the two apart. So when policy
+ * opens the row, the DOM echoes a `toggle` back that looks exactly like a
+ * reader opening it — and naively recording that as a reader choice pins the
+ * row to its first policy state forever, silently disabling every later policy
+ * transition (a completed tool run would never collapse again).
+ *
+ * The discriminator is agreement: an echo always reports the state we just
+ * rendered, so only a `toggle` that DISAGREES with the last rendered state can
+ * have come from the reader. jsdom does not emit the echo, so a test for this
+ * must inject the agreeing `toggle` itself or it passes vacuously.
+ */
+export function useControlledDisclosure(
+ policyOpen: boolean,
+): ControlledDisclosure {
+ const [readerChoice, setReaderChoice] = React.useState(null);
+ const open = readerChoice ?? policyOpen;
+
+ const renderedRef = React.useRef(open);
+ React.useLayoutEffect(() => {
+ renderedRef.current = open;
+ }, [open]);
+
+ const onOpenChange = React.useCallback((next: boolean) => {
+ if (next === renderedRef.current) return;
+ setReaderChoice(next);
+ }, []);
+
+ return { onOpenChange, open };
+}