feat(desktop): collapse consecutive tool steps into one tool-chain card - #6536
feat(desktop): collapse consecutive tool steps into one tool-chain card#6536baxen wants to merge 4 commits into
Conversation
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>
There was a problem hiding this comment.
💡 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".
| function isToolStepRunning(item: ToolItem): boolean { | ||
| return item.status === "executing" || item.status === "pending"; | ||
| } |
There was a problem hiding this comment.
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>
… 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>
|
Latest revision and validation:
The PR description now explicitly records the intentional flat threshold change from 3 to 2 and that @ss-core-02 |
|
Validation complete at
The PR description now explicitly documents the intentional flat grouping threshold change from 3 to 2 and 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 |
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
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.Reviewing files · step 3) and is expanded by default so the reader watches work happen.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.compactPreviewkeeps today's rendering: the plain, self-managed, collapsedRan N tool callsrow — 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 calls→Ran 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 theSameKindSummaryItemcomponent (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-passgroupToolRunSegments, and a pureagentSessionToolRunSummary.tsdriven 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 aresatisfies 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 (Read→Reviewing,Ran→Running).A failed step no longer erases what it was doing.
classifyToolflattens failures to render classerror, 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 keepserrorif 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
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.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.
classifyToolflattens every failed step to render classerrorwhile keeping its original groupKey, so reading the reported class let a failure launder an excluded class into an eligible one — a failedstophook (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 sametoolRunKindrecovery 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 forfile-edit, 3 for everything else);TOOL_RUN_MINIMUM_STEPSis 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>firestogglefor programmaticopenchanges 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 atogglethat 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.useToolRunDisclosureis 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
toggleitself. 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: anopenwith no handler freezes the row, and a handler with noopennever 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
ToolRunStepRowis 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 ownTranscriptItemViewavoids 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):pnpm test, not a scoped runtsc --noEmitclean;biome checkclean (the four remaining repo findings are pre-existing onmain, confirmed by stashing); file-size and px-text gates clean; all pre-push hooks greenclassifyToolso 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 workcompactPreviewto prove which presentation is pickeduseControlledDisclosurehook, 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:[P1] Failed suppressed/status steps leaked into chains — described under "One mechanism, not two" above. Regression tests build the failed
stop/postcompactitems through the real classifier (newmkClassifiedToolhelper) 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.[P2] Appending a step re-rendered every prior step — described under "Streaming cost" above. Confirmed non-vacuous: reverting the memo fails both new tests.
[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, sobuzz channels createfollowed by an executingbuzz channels deleteheadlinedChanged Buzz relay ops · step 2while still running.The root cause was the table's type, not the missing row:
PROGRESSIVE_VERBSwasRecord<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 namedRunVerb— the classifier's closed vocabulary plus the tone and render-class fallbacks — and the table is closed withsatisfies 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 namedprogressiveVerbhelper documenting why the cast is safe the other way: a verb taken straight from a descriptor is only typedstring, 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
Changedentry failstscand both new tests.