From 643b310690b607dfde3697ff350d419dbdc7326d Mon Sep 17 00:00:00 2001 From: ss-dev-02 <9e916f802f7932c38e630ac6b4726f5db7ca326c0d98e9bab495309eef13fe5a@buzz.block.builderlab.xyz> Date: Fri, 21 Aug 2026 18:20:43 -0700 Subject: [PATCH 1/4] feat(desktop): collapse consecutive tool steps into one tool-chain card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stretch of tool work rendered as one row per call, so a turn that read eight files and ran four commands cost twelve rows of transcript and read as noise. Runs of consecutive tool steps within a turn now collapse into a single card that mutates in place as steps stream in. The card headlines the run as verb/object/outcome ("Read 4 files", "Reviewing files · step 3"), carries an aggregate status glyph (spinner while any step executes, check when clean, error mark when any failed) and elapsed/finished timing. A live run is expanded so the reader watches work happen; once it settles the card collapses and hands the space back — unless it failed, in which case it stays open with the failing step highlighted. A reader's own toggle overrides both rules from then on. The body reuses the ordinary tool item rendering, so shell blocks, diffs, sent-message previews, and image previews all keep working. This REPLACES the previous two-pass grouping rather than layering on it. That scheme collapsed same-kind runs, then wrapped leftovers in a "mixed burst" summary that could nest the same-kind ones, producing stacked redundant headlines ("Ran 16 tool calls" → "Ran 12 commands"). Grouping is now a single pass over maximal runs of eligible steps: one card, one level of steps, with the headline adapting to whether the run is homogeneous. Two deliberate behaviour changes: - Failed steps now stay INSIDE their run. The old grouping broke runs on isError, which turned one stretch of work into three rows and stripped the failure of its context. A failure belongs to the run it happened in, and the card surfaces it by staying open and highlighting it. - Runs are keyed on their FIRST step id, so appending a streaming step never changes the id. A changed key would remount the card and drop the reader's disclosure choice; this is the append-stability contract that scroll anchoring relies on. Raw-rail, suppressed, status, permission, and thought rows are ineligible, so they stay visible AND break runs — the safety net and every intervention point keep their own row. compactPreview keeps today's rendering: an uncontrolled, collapsed summary row with no live clock. ActivityRow gains optional controlled disclosure so the card reuses the existing row chrome instead of hand-rolling a second
. Note for reviewers:
fires `toggle` for programmatic open changes as well as clicks, so the card's own auto-expand echoes back as an event. Treating that echo as a reader choice pins the card open and defeats auto-collapse entirely; useToolRunDisclosure ignores toggles that agree with the state it just rendered. The render test for this was confirmed to fail without the guard (jsdom omits the echo, so the test injects it). Validated: desktop suite 5390/5390, tsc --noEmit clean, biome check clean, file-size gate clean. Co-authored-by: Bradley Axen Signed-off-by: Bradley Axen --- .../ui/AgentSessionToolRunCard.test.mjs | 260 ++++++++++ .../agents/ui/AgentSessionToolRunCard.tsx | 356 ++++++++++++++ .../agents/ui/AgentSessionTranscriptList.tsx | 194 +------- .../ui/activityRenderClasses/ActivityRow.tsx | 15 + .../ui/agentSessionToolRunSummary.test.mjs | 445 ++++++++++++++++++ .../agents/ui/agentSessionToolRunSummary.ts | 372 +++++++++++++++ .../agentSessionTranscriptGrouping.test.mjs | 177 ++++--- .../ui/agentSessionTranscriptGrouping.ts | 245 ++-------- 8 files changed, 1618 insertions(+), 446 deletions(-) create mode 100644 desktop/src/features/agents/ui/AgentSessionToolRunCard.test.mjs create mode 100644 desktop/src/features/agents/ui/AgentSessionToolRunCard.tsx create mode 100644 desktop/src/features/agents/ui/agentSessionToolRunSummary.test.mjs create mode 100644 desktop/src/features/agents/ui/agentSessionToolRunSummary.ts 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..4bc75926924 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolRunCard.test.mjs @@ -0,0 +1,260 @@ +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)), + }; +} + +// ── 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", + ); +}); diff --git a/desktop/src/features/agents/ui/AgentSessionToolRunCard.tsx b/desktop/src/features/agents/ui/AgentSessionToolRunCard.tsx new file mode 100644 index 00000000000..66498790514 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolRunCard.tsx @@ -0,0 +1,356 @@ +import * as React from "react"; +import { Check, CircleAlert, Loader2 } from "lucide-react"; + +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +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; + +/** + * 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. + */ +export function AgentSessionToolRunCard({ + agentAvatarUrl, + agentName, + agentPubkey, + profiles, + run, +}: AgentTranscriptIdentityProps & { + profiles?: UserProfileLookup; + run: TranscriptToolRun; +}) { + const variant = useAgentSessionTranscriptVariant(); + const timestampsEnabled = useTranscriptTimestampsEnabled(); + const isCompactPreview = variant === "compactPreview"; + const aggregate = summarizeToolRunStatus(run.items); + const headline = summarizeToolRunHeadline(run.items, aggregate); + const stats = useToolRunEditStats(run.items); + const { expanded, setExpanded } = useToolRunDisclosure(aggregate.phase); + + // The compact preview is a non-interactive activity thumbnail, so a run reads + // there exactly as it did before cards existed: an uncontrolled, collapsed + // summary row with no live clock and no per-row timestamp. Everywhere else + // the card drives its own disclosure. + const disclosure = isCompactPreview + ? {} + : { onOpenChange: setExpanded, open: expanded }; + + return ( +
+ + + + {isCompactPreview ? null : ( + + )} + + {run.items.map((item) => ( + + ))} + + + {timestampsEnabled && !isCompactPreview ? ( +
+ +
+ ) : null} +
+ ); +} + +/** + * Disclosure state 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). Once the reader has touched the card their choice wins over both + * rules for the rest of the run's life. + */ +function useToolRunDisclosure(phase: ToolRunPhase) { + const [userChoice, setUserChoice] = React.useState(null); + const expanded = userChoice ?? phase !== "done"; + + // `
` fires `toggle` for programmatic `open` changes as well as + // clicks. Without this guard the card's own auto-expand would be recorded as + // a reader choice and would then pin the card open forever, so a completed + // run would never collapse. Only a toggle that DISAGREES with the state we + // last rendered can have come from the reader. + const renderedRef = React.useRef(expanded); + React.useLayoutEffect(() => { + renderedRef.current = expanded; + }, [expanded]); + + const setExpanded = React.useCallback((open: boolean) => { + if (open === renderedRef.current) return; + setUserChoice(open); + }, []); + + return { expanded, setExpanded }; +} + +/** + * 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 ( + <> +