Skip to content

Collapse a turn's tool activity into one grouped, live-ticking line - #72893

Merged
OutThisLife merged 15 commits into
mainfrom
bb/desktop-activity-grouping
Jul 28, 2026
Merged

Collapse a turn's tool activity into one grouped, live-ticking line#72893
OutThisLife merged 15 commits into
mainfrom
bb/desktop-activity-grouping

Conversation

@OutThisLife

@OutThisLife OutThisLife commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

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.ts replays one turn both ways — as the gateway event stream and as the rows toChatMessages rehydrates — 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 clarify and image_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 typecheck and npm run lint in apps/desktop
  • npm run test:ui — the markdown-blocks property fuzz can time out under parallel load; it passes alone in ~20s of its 30s budget and is untouched here
  • Open a session with a long tool-heavy history: runs are collapsed to summary lines, clicking one expands it, and the state survives a reload
  • Send a prompt that runs several commands back to back: the ticker scrolls in place through the whole run without flickering back to a summary between calls, then settles
  • Send a prompt that edits files: each edit stays on screen as its own diff card between the runs around it
  • Trigger a dangerous command approval mid-run and confirm the bar is still in the flow
  • Interrupt a turn while "Editing" is showing and confirm the label clears immediately

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on bc89330

all good!

@alt-glitch alt-glitch added type/feature New feature or request comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have labels Jul 27, 2026
@OutThisLife
OutThisLife enabled auto-merge July 27, 2026 21:14
@OutThisLife
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.
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.
@OutThisLife OutThisLife changed the title Group a run of tool calls behind one summary line in Desktop Collapse a turn's tool activity into one grouped, live-ticking line Jul 28, 2026
…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.
@OutThisLife
OutThisLife merged commit fe71e32 into main Jul 28, 2026
34 checks passed
@OutThisLife
OutThisLife deleted the bb/desktop-activity-grouping branch July 28, 2026 01:11
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants