Collapse a turn's tool activity into one grouped, live-ticking line - #72893
Merged
Conversation
Contributor
૮ >ﻌ< ა ci reviewran on bc89330 all good! |
OutThisLife
enabled auto-merge
July 27, 2026 21:14
OutThisLife
disabled auto-merge
July 27, 2026 21:15
Adds the grammar behind "Edited wiring.tsx, explored 3 files, ran 5 commands": one clause per category of work, a name when the category holds a single thing and a count otherwise, and the present tense for whichever category is still running. The continuity test is the load-bearing part. Tool grouping was reverted once because it reshuffled the moment a turn settled, so this replays the same turn twice — as the gateway event stream the live view builds bubbles from, and as the rows toChatMessages rehydrates on resume — and asserts both produce the same runs.
A run of two or more tool calls now renders behind its summary once it has finished, so a long transcript reads as what the agent did rather than as a wall of rows. The run is keyed by its first tool call instead of its part index: live and rehydrated turns agree on which calls belong together but not on the indices they land at, and keying by index is what made the previous attempt reshuffle on settle. A run holding anything still pending always renders its rows, which is what keeps a clarify question or an approval bar out from behind a chevron. The approval-group tests move to tool-group and grow coverage for both halves of that rule.
A settled reasoning block reads "Thought for 5s" instead of staying "Thinking" forever. Nothing in the persisted turn records the duration, so the number is frozen when the block finishes on screen and simply omitted on a rehydrated turn, rather than reporting whatever a timer that never ran would say.
The list gates two behaviours since the settled-run summary landed, but the comment still justified it only in terms of the scroll window's height cap. Record the pending-row rule that keeps approvals visible, and why file edits stay off the list.
A run inferred "still working" from a missing result alone, so a call
left unresolved — by an interrupted turn, or an agent that moved on —
pinned its run as live forever. That stranded the summary in the present
tense ("Exploring 2 files" on a finished turn) and, because a live run
withholds its toggle so approvals can't hide, left the run permanently
expanded with no way to collapse it.
Qualify liveness the way ToolEntry already qualifies a row's: a missing
result only means pending while the run is the tail of a running
message. Liveness is now passed into summarizeToolRun rather than read
off the calls, since it isn't a property of the calls.
The elapsed timer counts whole seconds, so reasoning that finished inside one rendered as "Thought for 0s" — accurate and useless, and on a turn with several short blocks it repeats down the transcript. Drop the number below a second and say it was brief instead.
OutThisLife
force-pushed
the
bb/desktop-activity-grouping
branch
from
July 27, 2026 23:11
da78f04 to
6ba2f3e
Compare
A live run was the settled view under a CSS max-height: the real rows, capped at ~6.75rem, with an escape hatch that let an open diff lift the cap entirely. So it was neither a single line nor an honest expanded block, and a run could balloon mid-turn. Split the two presentations instead. Live, a run is its summary plus a one-line reel that slides each finished action up and out, so a turn touching thirty files reads as one line ticking over in place. Settled, the summary is the whole of it until opened. Cards — file edits, clarify, image_generate — leave the run entirely and stay on screen where they happened, since the diff or the question IS the point of the turn. Drops useToolWindow, the bounded-window threshold, the scroll container, the fade mask and the :has([data-tool-open]) escape hatch, all of which existed to make "the real rows, but shorter" work. Also unifies transcript scaffolding on one colour: thinking headers painted --ui-text-secondary and tool summaries --ui-text-tertiary under a shared opacity, which is two greys for one kind of line. Both now render through ScaffoldRow at a token pitched between them.
The timer registry hands every caller of a key the same origin, and every reasoning block in a turn was keyed `reasoning:<messageId>`. So the second and third blocks measured from the first one's start and each reported the running total as its own duration — the "6s, 6s, 16s" down a single turn. Key per block, and move the measurement into `useMeasuredDuration`, which keeps the number beside the origin that produced it. The thread virtualizes, so the component that watched a block finish is usually gone by the time anyone scrolls back to read it; component state forgot the duration on unmount and the row fell back to having none. A block that genuinely was never watched running — history from an earlier app session, or reasoning that arrived already complete — still has no duration to report, and now says "Thought" rather than sitting in the present tense at a turn that ended. Also drops the run summary's aggregate +N/−M: a run can no longer contain a file edit, so it was always zero. Each edit carries its own count on its card.
The drafting/stall status row kept its own type and colour — text-sm at muted-foreground/70, with the hint at /55 — so "Editing" while the model drafts a call rendered a full step larger than the "Explored 3 files" line it turns into a moment later, in a different grey. Route it through the scaffold label token so the whole left column reads as one kind of line. The timer keeps its midground tint: that belongs to the live-signal cluster with the dither block, not to the scaffolding.
…hare TOOL_HEADER_TITLE_CLASS was byte-identical to SCAFFOLD_LABEL_CLASS apart from the colour — secondary (74%) against the scaffold's 64% — so a "Ran wc -l" row sat visibly brighter than the "Thought for 1s" line above it. Same for the trailing duration: tertiary against scaffold meta. The primitive existed but only the thinking header and run summary were routed through it. Point the tool row at it too and delete the duplicates, so there is one place left to change. Search hit titles keep the brighter grey under their own name: they are result content, not scaffolding.
`tool.generating` names the tool whose arguments are streaming, and nothing ever closed that claim: there is no stop-drafting event, and a draft can be abandoned without reaching `tool.start` when a mid-stream retry drops a partial call or a guardrail blocks the tool. Enumerating the ways a draft ends left those holes open, so "Editing" sat under the transcript for the rest of a multi-iteration turn. Invert the rule — the claim only covers what the model is emitting right now, so any other output from that session retires it. Stopping the turn clears it too, and a `tool.generating` that arrives after the stop is ignored on the same condition `mutateStream` already drops late tool rows.
A run counted as live only while one of its calls was unresolved, which is false for the instant between one sequential call finishing and the next arriving — and for a string of commands that instant is most of the run. It settled and re-opened between every call, so the ticker unmounted and came back at the top of its reel instead of scrolling, and the summary flipped to past tense while work was still going. Live is now "the turn is working and nothing follows this run", with the tail bound still settling a run the agent has moved past. The summary takes its present-tense clause from the most recent call when none is pending — the same call the ticker is showing. The stall spinner stays out of the way while a run narrates, rather than stacking a second timer under it.
The scaffold rows shared a colour but not a weight: tool summaries and the ticker rendered medium against the reply's normal-weight prose, which read as emphasis on the quietest lines in the column. Spacing had the matching problem. The block-gap rule listed which *pairs* of blocks qualified, so the live status line — neither tool, thinking, nor prose — fell through every branch onto its own half-size margin. And because a streaming turn is sealed into several bubbles as it goes but rehydrates into fewer, two blocks are siblings inside one bubble or split across two depending on when you look; the flex gap between bubbles is half the block gap, so the rhythm tightened and relaxed as a turn settled. Cover every top-level block with one rule, keep prose-to-prose on paragraph rhythm, and top the between-bubble gap up to match.
…sheet Two identical tool rows, one above the other, rendered at two different opacities — and no amount of hovering would even them out. The enter animation fills forwards, so its final keyframe is held in the animation origin of the cascade for as long as the element lives, above the author stylesheet. Naming `opacity: 1` there didn't just end the fade, it permanently overruled the resting opacity of every element the sheet dims. Transcript scaffolding is dimmed exactly that way, so a row kept whichever opacity it happened to mount with: full if it animated in during the turn, faded if it was rehydrated or remounted past its one-shot key. Same for thinking headers, which is why "Thought" never matched the rows near it. Leave the end opacity out of the keyframe. It animates up to whatever CSS asks for and keeps answering to it, hover included. Then close the way the surfaces drifted in the first place: the fade named each one in its own selector, so the live status line — added later, and neither tool nor thinking nor prose — matched none of them and sat a shade brighter than the rows either side. One `data-conversation-scaffold` mark now carries it, and every surface opts in.
randlee
pushed a commit
to randlee/hermes-agent
that referenced
this pull request
Aug 11, 2026
…tivity-grouping Collapse a turn's tool activity into one grouped, live-ticking line
33hodl
pushed a commit
to 33hodl/hermes-agent
that referenced
this pull request
Aug 12, 2026
…tivity-grouping Collapse a turn's tool activity into one grouped, live-ticking line
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Desktop's transcript renders every tool call as its own row, so a turn that read six files and ran five commands costs eleven rows before you reach the reply. This gives a turn's activity one line instead: while the run is going, a ticker scrolls the current action through a single row; when it settles, that row becomes the summary that stands in for the whole run — "Explored 3 files, ran 5 commands" — and expands back to the rows on click.
Grouping was tried once before and reverted because the group reshuffled the instant a turn settled. The cause is that the live view spreads a turn across several bubbles while rehydration folds it into one, so the same tool call lands at a different part index depending on which path built it. A run is therefore keyed by its first tool call, never by its position.
tool-run-continuity.test.tsreplays one turn both ways — as the gateway event stream and as the rowstoChatMessagesrehydrates — and asserts the two agree on which calls belong to which run.Only ephemeral activity — reads, searches, commands — goes into a run. Anything you would want to come back to leaves it: a file edit keeps its own diff card with its own +N/−M, and so do
clarifyandimage_generate. An approval bar can't be buried behind a chevron, because the tool carrying it never gets collapsed into a summary in the first place.The rest is the scaffolding around that line finally agreeing with itself. Thinking headers, tool summaries, the ticker, and the live status row are one kind of line — the quiet grey that says what the agent did rather than what it said — so they now render through one primitive at one colour, one weight, and one vertical rhythm. A settled reasoning block reads "Thought for 5s" (or "Thought briefly"), measured per block and kept across virtualization rather than restarting with the turn. And the "Editing" status that names a tool mid-draft now retires as soon as the model emits anything else, instead of hanging for the rest of the turn when a draft is abandoned.
Test plan
npm run typecheckandnpm run lintinapps/desktopnpm run test:ui— themarkdown-blocksproperty fuzz can time out under parallel load; it passes alone in ~20s of its 30s budget and is untouched here