Skip to content

feat(desktop): collapse consecutive tool steps into one tool-chain card - #6536

Open
baxen wants to merge 4 commits into
mainfrom
ss-dev-02/tool-chain-cards
Open

feat(desktop): collapse consecutive tool steps into one tool-chain card#6536
baxen wants to merge 4 commits into
mainfrom
ss-dev-02/tool-chain-cards

Conversation

@baxen

@baxen baxen commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Slice C — tool-chain cards

Runs of consecutive tool items within a turn now collapse into a single tool-chain card that mutates in place as steps stream in, instead of leaving one row per tool call. A turn that read eight files and ran four commands was twelve rows of transcript; it is now one.

Behaviour

  • Collapsed header — verb/object/outcome headline derived from the classifier's own per-step semantics (Read 4 files, Edited 3 files, Ran 6 commands, Sent 2 messages), an aggregate status glyph (spinner while any step executes, check when all clean, error mark if any failed), and elapsed/finished timing.
  • Live run — reads as active with its position (Reviewing files · step 3) and is expanded by default so the reader watches work happen.
  • On completion — the card collapses itself and hands the space back to the conversation, unless it contains an error, in which case it stays open with the failing step highlighted. A reader's own toggle overrides both rules from then on.
  • Expanded body — one row per step reusing the existing AgentSessionToolItem/* rendering, so shell blocks, diffs, sent-message previews, and image previews all keep working. A lone tool item not in a run renders exactly as today.
  • compactPreview keeps today's rendering: the plain, self-managed, collapsed Ran N tool calls row — no derived headline, no aggregate glyph, no timing, no disclosure policy. The variant decision lives in one boundary component (AgentSessionToolRunSegment: variant in, presentation out); the card itself is unconditionally controlled, so no piece of chrome has to remember to opt out of the preview.

One mechanism, not two

Per the brief, this replaces the existing "same-kind summary" grouping rather than layering a second mechanism on it. The old two-pass 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 callsRan 12 commands). Grouping is now a single pass over maximal runs of eligible steps: one card, one level of steps, headline adapting to whether the run is homogeneous.

Removed: TranscriptToolRunSummary, TranscriptToolRunChildSegment, groupSameKindSegments, groupMixedToolRuns, isBurstParticipant, isGroupingEligible, sameKindKey, sameKindLabel, minimumSummaryRunLength, and the SameKindSummaryItem component (which recursed into nested summaries). The parallel bucket taxonomy added in the first revision (ToolRunBucket, dominantToolRunBucket, BUCKET_PHRASES, BUCKET_SALIENCE) is also gone — see below. Added: TranscriptToolRun + single-pass groupToolRunSegments, and a pure agentSessionToolRunSummary.ts driven by render classes rather than tool-name matching.

Headlines read the classifier instead of restating it

The first revision of this card carried its own bucket taxonomy (ToolRunBucket + BUCKET_PHRASES/BUCKET_SALIENCE) that re-derived, in parallel, vocabulary the activity classifier already produces for every step — two places to keep in sync and two places to be wrong. That taxonomy is gone. A run's kind is now the classifier's own {renderClass, tone}, verbs come from the step descriptors' descriptor.action.verb, and the summary module supplies only what a per-step descriptor structurally cannot: the collective noun (4 files), a per-render-class verb floor, and a tone fallback. Both maps are satisfies Record<...> so a new render class or tone cannot be added without deciding its phrasing. Live runs re-tense the classifier's closed past-tense vocabulary (ReadReviewing, RanRunning).

A failed step no longer erases what it was doing. classifyTool flattens failures to render class error, so one failed read used to drag an entire run's headline down to generic tool work. The summary re-classifies a failed step as if it had succeeded to recover its class, tone, and verb for headline purposes — and honestly keeps error if that is genuinely all the step was. The failure still surfaces through the aggregate glyph and the highlighted step row, which is where it belongs.

Two deliberate behaviour changes

  1. Failed steps stay inside their run. The old grouping broke runs on isError, turning one stretch of work into three rows and stripping the failure of its context. A failure belongs to the run it happened in; the card surfaces it by staying open and highlighting it. This intentionally inverts a few existing test expectations, which are updated with the reasoning recorded at each site.
  2. Runs are keyed on their FIRST step id (tool-run:<firstStepId>), so appending a streaming step never changes the id. A changed key remounts the card and drops the reader's disclosure state — this is the append-stability contract scroll anchoring depends on, and it has its own test.

Ineligible-and-run-breaking: raw-rail, suppressed, status, permission, thought. Those rows stay visible and split the run, so the ambient safety net and every intervention point keep their own row.

Eligibility is decided on a step's recovered class, not on the class its descriptor reports. classifyTool flattens every failed step to render class error while keeping its original groupKey, so reading the reported class let a failure launder an excluded class into an eligible one — a failed stop hook (error + suppressed:stop-hook) was admitted and the safety row vanished into a card. Both bug reviewers found this independently; fixed by routing eligibility through the same toolRunKind recovery the headline already used, so there is one recovery path rather than two that can disagree.

Grouping threshold changed 3 → 2. The old scheme had a per-class minimum run length (minimumSummaryRunLength: 2 for file-edit, 3 for everything else); TOOL_RUN_MINIMUM_STEPS is now a flat 2. Two consecutive reads that previously stayed as two separate rows now collapse into one card. That is intentional — the per-class split existed because the old summary row was a weak affordance that was not worth spending on a pair, and the card is not — but it is a visible change to how short runs read, so it should be reviewed as a decision rather than a detail.

The <details> echo trap — now a shared hook

<details> fires toggle for programmatic open changes as well as clicks, and the event carries no way to tell the two apart. So the card's own auto-expand echoes back an event that looks exactly like a reader opening it, and recording that as a reader choice pins the row to its first policy state forever — auto-collapse silently stops working. The discriminator is agreement: an echo always reports the state we just rendered, so only a toggle that disagrees can have come from the reader.

Quality found the same trap in Slice B's thought disclosure, so the guard is now a generic hook at src/shared/hooks/useControlledDisclosure.ts (matching where the repo already keeps shared UI hooks) rather than something private to this card. useToolRunDisclosure is a one-liner over it. Slice B should consume this hook instead of reinventing the guard — which is why C merges first.

jsdom does not emit the echo, so a test for this passes vacuously unless it injects the agreeing toggle itself. The hook's test drives a real controlled <details> and does exactly that; I confirmed it is non-vacuous by removing the guard (the echo test fails, the other four still pass) and restoring it.

ActivityRow's controlled disclosure is typed all-or-nothing — a discriminated union of {open, onOpenChange} or neither, not two independent optionals. A half-controlled <details> is always a bug: an open with no handler freezes the row, and a handler with no open never applies what it recorded. Verified the compiler rejects both half-controlled shapes.

Scope

Touches the transcript list, its grouping helper, ActivityRow, and one new shared hook (src/shared/hooks/useControlledDisclosure.ts, which Slice B will consume). No changes to the drawer/shell (Slice A) or message/thought/plan styling (Slice B).

Streaming cost

ToolRunStepRow is memoized on the step's identity. A run re-renders on every append while it streams and on every live-clock tick, and each step's presenter rebuilds compact tool summaries, parses diffs, and renders markdown/images — so an unmemoized row made a long run cost O(n) of that work per appended step (the transcript's own TranscriptItemView avoids it the same way). Transcript items are replaced rather than mutated, so reference equality on the item is a sound change test. Covered by a render-count test that counts at the presenter boundary rather than reaching into React internals, paired with a test that a step which really did change still re-renders, so the memo cannot be made vacuously strict.

Validation

At 74b90152 (full validation rerun after the latest revision):

  • Desktop suite 5414/5414 pass — full pnpm test, not a scoped run
  • tsc --noEmit clean; biome check clean (the four remaining repo findings are pre-existing on main, confirmed by stashing); file-size and px-text gates clean; all pre-push hooks green
  • Tests: 32 unit tests for headline derivation / eligibility / aggregate status / timing, now built through the real classifyTool so tone and action values are production values rather than hand-written fixtures — including mixed relay-op runs (read vs write vs admin), an evenly-split run resolved by tone salience, and a failed step not dragging the headline to generic tool work
  • 17 render tests for disclosure, header, and body behaviour, including 5 that render through the variant boundary under compactPreview to prove which presentation is picked
  • 5 tests for the shared useControlledDisclosure hook, verified non-vacuous by removing the guard

🤖 Generated with Buzz

Bug-pass fixes (post-review)

Two bugs found by the independent bug pass, both fixed in 84eaf287:

  1. [P1] Failed suppressed/status steps leaked into chains — described under "One mechanism, not two" above. Regression tests build the failed stop/postcompact items through the real classifier (new mkClassifiedTool helper) and assert the flattened class and preserved groupKey before asserting the row stays a standalone breaker, so the test still describes the trap if the classifier's behaviour changes. Confirmed non-vacuous: reverting the fix fails all three new tests.

  2. [P2] Appending a step re-rendered every prior step — described under "Streaming cost" above. Confirmed non-vacuous: reverting the memo fails both new tests.

  3. [P2] A live heterogeneous admin run read in the past tense — fixed in 74b90152. A run whose defining steps disagree on a verb falls back to its tone's verb, and the admin fallback "Changed" had no progressive form, so buzz channels create followed by an executing buzz channels delete headlined Changed Buzz relay ops · step 2 while still running.

    The root cause was the table's type, not the missing row: PROGRESSIVE_VERBS was Record<string, string>, so an absent verb was not a compile error but a silent fall-through to the past-tense form. The set of verbs this module can choose for a headline is now named RunVerb — the classifier's closed vocabulary plus the tone and render-class fallbacks — and the table is closed with satisfies Record<RunVerb, string>, so adding a verb or a new tone fallback fails to compile until its progressive form is decided. The remaining lookup goes through a named progressiveVerb helper documenting why the cast is safe the other way: a verb taken straight from a descriptor is only typed string, so an unrecognised one degrades to itself rather than to something wrong.

    Two tests: the reported mixed-admin live case, and one covering every tone's fallback. Note for reviewers — this fallback is only reached when the steps sharing the dominant kind disagree on a verb, which makes the fixtures easy to get wrong: a pair differing in render class instead narrows the dominant kind to a single step whose verb then trivially agrees, quietly testing nothing (my first write-tone pair had exactly that flaw and passed for the wrong reason). Each case now asserts its precondition — same kind, differing verbs — before asserting the headline. Confirmed non-vacuous both ways: removing the Changed entry fails tsc and both new tests.

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 <details>.

Note for reviewers: <details> 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 <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
@baxen
baxen requested a review from a team as a code owner August 22, 2026 01:23

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 643b310690

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +198 to +200
function isToolStepRunning(item: ToolItem): boolean {
return item.status === "executing" || item.status === "pending";
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate running state on live session ownership

When an agent crashes or disconnects after emitting a tool start but before its terminal update, the archived item permanently retains executing or pending. This status-only check therefore marks the card as running whenever that history is reopened, even though AgentSessionTranscriptList already knows there is no matching active turn/live session; the card displays a spinner and ToolRunLiveElapsed keeps increasing from the original timestamp indefinitely. Gate this phase on current turn/session liveness, or terminalize orphaned steps so dead work is shown as stopped or timed out rather than actively running.

AGENTS.md reference: AGENTS.md:L13-L18

Useful? React with 👍 / 👎.

…re the details echo guard

Review pass on the tool-chain card. Four changes, none of which alter what
a reader sees in the default transcript.

Headlines now consume classifier semantics instead of restating them. The
card carried its own bucket taxonomy (BUCKET_PHRASES/BUCKET_SALIENCE) that
re-derived, in parallel, vocabulary the activity classifier already
produces per step — two places to keep in sync and two places to be wrong.
A run's kind is now the classifier's own {renderClass, tone}, and the verb
comes from the step descriptors' action.verb, with the summary module
supplying only what a per-step descriptor cannot: the collective noun ("4
files"), a verb floor per render class, and a tone fallback. Live runs
re-tense the classifier's closed past-tense vocabulary (Read → Reviewing).

A failed step no longer erases what it was doing. classifyTool flattens
failures to renderClass "error", so one failed read used to drag a whole
run's headline down to generic tool work. The summary re-classifies a
failed step as if it had succeeded to recover its class, tone, and verb —
and honestly keeps "error" if that is genuinely all the step was. The
failure still surfaces through the aggregate glyph and the highlighted step
row, which is where it belongs.

The compactPreview decision moved to one boundary component. The card was
branching on variant internally, which meant every piece of chrome had to
remember to opt out of the preview. AgentSessionToolRunSegment now takes
the variant and returns a presentation: the full card, or a plain
self-managed row carrying the legacy "Ran N tool calls" sentence with no
glyph, no timing, and no disclosure policy. The card itself is
unconditionally controlled. Tests render through the boundary, so they
prove which presentation is picked rather than assuming it.

ActivityRow's controlled disclosure is now all-or-nothing. `open` and
`onOpenChange` were independent optionals; a half-controlled <details> is
always a bug (an `open` with no handler freezes, a handler with no `open`
never applies), so the pair is a union. Verified the compiler rejects both
half-controlled shapes.

The echo guard is now a shared hook. src/shared/hooks/useControlledDisclosure
holds the <details> programmatic-toggle guard — the trap where a
policy-driven open echoes back a toggle indistinguishable from a click, and
recording it pins the row forever. Slice B's thought disclosure has the
same trap and will consume this hook rather than reinvent it, which is why
this lands first. Its test drives a real controlled <details> and injects
the echo jsdom omits; confirmed non-vacuous by removing the guard (the echo
test fails, the other four pass).

Validated: desktop suite 5407/5407, tsc --noEmit clean, biome check clean
(the four remaining findings are pre-existing on main, confirmed by
stashing), file-size and px-text gates clean.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
ss-dev-02 and others added 2 commits August 21, 2026 20:52
… steps

Two bugs from the Slice C bug pass, both reported independently by reviewers.

A failed suppressed or status step could join a tool-chain run. `classifyTool`
flattens every failed step to render class `error` while keeping its original
groupKey, so a failed `stop` hook reported as `error` with groupKey
`suppressed:stop-hook` — and run eligibility, which read the reported class,
admitted it. The ambient safety net and the status spine then vanished into a
collapsed card, which is exactly what the chain exclusions exist to prevent.
Eligibility now decides on the step's RECOVERED class via `toolRunKind`, the
same undoing of that flattening the headline already relied on, so there is one
recovery path rather than two that can disagree. A step whose recovered class is
genuinely chainable still chains, and a step that is nothing but a failure still
reports as an error.

Appending a step to a live run re-rendered every step already in it. The
per-step row was unmemoized, so each streamed append re-ran compact-summary
building, diff parsing, and markdown/image rendering for every prior step —
O(n) of that work per append, which the transcript's own `TranscriptItemView`
avoids by being memoized. `ToolRunStepRow` is now `React.memo`; transcript
items are replaced rather than mutated, so reference equality on the item is a
sound test for "this step did not change".

Tests. Eligibility and grouping regressions build their failed `stop` and
`postcompact` items through the REAL classifier via a new `mkClassifiedTool`
helper, because the bug lived precisely in how the classifier flattens failures
— a hand-written descriptor would not reproduce it. They assert the flattened
class and preserved groupKey first, so the test still describes the trap if the
classifier's behaviour ever changes, then assert the failed row stays a
standalone breaker between two shell steps. The render-count test counts at the
presenter boundary (`ACTIVITY_RENDER_CLASS_PRESENTERS` is consulted on every
render) rather than reaching into React internals, and is paired with a test
that a step which really did change still re-renders, so the memo cannot be
made vacuously strict.

Validated: desktop suite 5412/5412, tsc --noEmit clean, biome check clean
(the four remaining findings are pre-existing on main), file-size and px-text
gates clean.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
A live tool-chain run of mixed admin relay ops read in the past tense. A run
whose defining steps disagree on a verb falls back to its tone's verb, and the
admin fallback is "Changed" — which had no entry in the progressive table, so a
`buzz channels create` followed by an executing `buzz channels delete` headlined
"Changed Buzz relay ops · step 2" while the work was still running. The live
header is supposed to be the settled header in another tense.

The table was typed `Record<string, string>`, so a verb it lacked was not a
compile error, just a silent fall-through to the past-tense form. Naming the set
of verbs this module can CHOOSE for a headline as `RunVerb` — the classifier's
closed vocabulary plus the tone and render-class fallbacks — and closing the
table with `satisfies Record<RunVerb, string>` makes the omission impossible:
adding a verb or a tone fallback now fails to compile until its progressive form
is decided. "Changed" → "Changing" is the entry that was missing.

The one remaining lookup goes through a named `progressiveVerb` helper, which
documents why the cast is safe in the other direction: a verb taken straight
from a descriptor is only typed `string`, so an unrecognised one degrades to
itself rather than to something wrong.

Tests. One case for the reported bug (mixed admin, live, reads "Changing"), and
one covering every tone's fallback. Both build their steps through the real
classifier so the tone and the verb disagreement are production behaviour rather
than fixture assumptions. The fallback is only reached when the steps sharing
the DOMINANT kind disagree on a verb, which makes the fixtures easy to get
wrong: a pair differing in render class instead narrows the dominant kind to a
single step whose verb then trivially agrees, quietly testing nothing. My first
write-tone pair had exactly that flaw. Each case now asserts its precondition —
same kind, differing verbs — before asserting the headline, so a fixture that
stops reaching the fallback fails loudly instead of passing for the wrong
reason.

Validated: desktop suite 5414/5414, tsc --noEmit clean, biome check clean (the
four remaining findings are pre-existing on main), file-size and px-text gates
clean.

Co-authored-by: Bradley Axen <baxen@squareup.com>
Signed-off-by: Bradley Axen <baxen@squareup.com>
@baxen

baxen commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Latest revision and validation:

  • Pushed 74b901527a8b8d8b1740693f55f64758237e89bb (origin matches).
  • Full desktop pnpm test: 5414/5414 pass.
  • pnpm typecheck: clean.
  • pnpm check: clean for this change; the four reported Biome findings are pre-existing on main.
  • pnpm check:file-sizes: clean; pre-push hooks green.
  • PR CI for 74b901527: all applicable checks pass, including Desktop Core, Desktop Smoke E2E (1–4), Desktop Build, and Desktop E2E Integration. Inapplicable lanes are skipped.

The PR description now explicitly records the intentional flat threshold change from 3 to 2 and that compactPreview uses the plain legacy Ran N tool calls rendering without chain-card chrome or disclosure policy. One independent P2 review thread remains open about orphaned executing/pending history; it needs a liveness ownership decision rather than a headline-table change.

@ss-core-02

@baxen

baxen commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Validation complete at 74b901527a8b8d8b1740693f55f64758237e89bb:

  • Full desktop pnpm test: 5414/5414 pass.
  • pnpm typecheck: clean.
  • pnpm check: clean for the changed files; the four repository findings are pre-existing on main.
  • pnpm check:file-sizes: clean; pre-push hooks green.
  • PR CI: all applicable checks pass, including Desktop Core, Desktop Smoke E2E (1–4), Desktop Build, Desktop E2E Relay, and Desktop E2E Integration. Inapplicable lanes are skipped.

The PR description now explicitly documents the intentional flat grouping threshold change from 3 to 2 and compactPreview's plain legacy Ran N tool calls presentation without chain-card chrome or disclosure policy.

One independent P2 review thread remains open about orphaned executing/pending history after a crashed or disconnected session. That is a separate liveness-ownership policy question; I have left it visible for review rather than silently changing the scope of the card implementation.

@ss-core-02

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant