Skip to content

Restyle the chat transcript and render tool calls as inline chips - #656

Merged
weishi-imbue merged 20 commits into
mainfrom
dandelion-crab
Sep 22, 2026
Merged

weishi-imbue merged 20 commits into
mainfrom
dandelion-crab

Conversation

@gdenisov

Copy link
Copy Markdown
Contributor

Restyles the chat transcript, and replaces the stack of tool-call blocks with a row of inline chips.

The styling half started from the prototype at mind-sketches/prototypes/minds-chat, whose transcript CSS began as a copy of this app's — so its deltas are deliberate rather than incidental. The chips half then went further than the prototype, after the labels turned out to be the weak point.

The transcript

  • The agent's side stops short of the column's right edge (--width-agent-gutter): its prose, its wrap-up reply, and the timeline's task rows. The user's bubbles already stopped short at 80%, so the agent's full-width text was the one thing running edge to edge.
  • An opened-up task drops its left rule and indent. The timeline's own thread already runs down the left of every node; a second vertical line inside a node read as a nested timeline that wasn't one.
  • Task titles take their tone from their state instead of all reading alike: only a closed one greys out, and the live one shimmers together with its narration, so the current task pulses as a unit.
  • Softer status bullets: a done step's check is the accent pulled toward grey rather than flat grey, a pending one sits below faint, and the live spinner runs a thinner ring.
  • Type back on the scale. The prototype invented 13.5px titles and 13px captions; titles are body, captions and tool headers are helper.

The bullet alignment

The bullet sat a pixel off its title because the two were positioned by different boxes — the bullet by the node's line box, the title by its own. They agreed only by coincidence, and drifted as soon as the type changed. The bullet is now a flex box exactly one title line tall, the title is block-level so it starts at the body's top edge, and the chevron takes leading-none (at 18px its line box was taller than the title's, so expandable titles sat lower than plain ones).

It then centres on the x-height, not the em box. The em box is the one candidate the eye doesn't read; the full-ink centre moves with whichever glyphs a title happens to contain, so a title with no descenders would sit 1.3px off one that has them. All three states now land within 0.1px of the x-height regardless of the string.

Tool calls

A turn's actions were a stack of full-width bordered blocks, one per call, each as loud as the prose beside it. They are now one wrapping row of small ghost chips, and only the chip a reader picks opens its detail.

  • Runs merge across assistant events. A harness emits one event per model response, so three tool calls in a row are usually three events; grouping only within an event would have left them as three separate rows. renderAssistantRun merges wherever a caller holds a whole event list; top-level rows stay per-event, since each is measured and windowed on its own by the virtualized list.
  • The panel opens under its own chip, as a full-width flex item that breaks the wrap where it sits. Hung below the whole row, a chip on the first of three lines had its detail two lines and 56px away.
  • Sub-agent calls and permission requests stay as cards — one is a whole conversation, the other is something to act on.

What a chip says

A chip said the tool's name, which tells three calls apart and nothing at twenty ("Read Read Grep Read Bash Read"). The harness already knew better and was discarding it:

  1. The agent's own words. Claude's Bash and Agent tools require a short description of what the command is for, and the agent writes one every time. It was reaching the frontend already — but only as the live activity strip's caption, which shows for a second while the call runs, and as a field in the raw JSON two clicks deep. It is now stamped as action_note and is the chip's first choice.
  2. Otherwise, what the call did: a past-tense verb and the thing it acted on (read ToolChipGroup.ts, searched "font-size" in views), as action_verb / action_target. Never inferred for a tool that states no reason.

Measured over three real transcripts on disk (388 calls): Bash is 71% of them, every shell call had a description, median 23 chars, max 43. A file is named rather than pathed — the whole path is a click away in the panel.

The past-tense verbs live in one shared table keyed by the caption participle the harnesses already share, rather than a new table per harness.

The panel

Shows the input as text rather than the JSON object it arrives as: several fields as key: value lines, a lone remaining field as its bare value — which for a shell call is exactly the command. The field the chip already said is dropped (matched by value, so only the one the note came from). The verb leads that line where the chip is showing the note instead.

Not done

Only claude's parser stamps the action fields. Codex, pi and antigravity fall back to their caption_label — the same phrase in the present tense, which reads fine — so nothing shows a bare tool name, but their chips say "Reading" where claude's say "read".

Tests

1,169 frontend tests and 1,713 chat Python tests (coverage 92%), including 18 e2e against a real browser. The tool-call e2e was rewritten for the chips; verify_scroll.py now expands a chip.

Note for anyone pulling this: the merge of main here changes the shell, so npm run build is needed before the e2e suite means anything — the stale build fails all 18 with [data-desktop-id="home"] not found.

🤖 Generated with Claude Code

gdenisov and others added 20 commits September 17, 2026 13:12
The prototype at mind-sketches/prototypes/minds-chat started from a copy of
this app's transcript styles, so its deltas are deliberate rather than
incidental. This lifts the styling ones (the inline tool chips are a separate
change):

- The agent's side of the column stops --width-agent-gutter short of the right
  edge: its prose, its wrap-up reply, and the timeline's task rows. The user's
  bubbles already stopped short at 80%, so the agent's full-width text was the
  one thing running edge to edge.
- An opened-up task drops its left rule and indent. The timeline's own thread
  already runs down the left of every node; a second vertical line inside a
  node read as a nested timeline that wasn't one.
- The bullets carry the status more softly: a done step's check is the accent
  pulled toward grey rather than flat grey, a pending one sits below faint, and
  the live spinner runs a thinner ring (--spinner-width).
- Task titles take their tone from their state instead of all reading alike:
  only a closed one greys out, and the live one shimmers together with its
  narration so the current task pulses as a unit.
- Type and spacing: prose leads at 1.4, code blocks and task captions drop to
  the row size, and the user bubble's padding tightens to 14/8.

The node row, its bullet, the title and the chevron were copied between
ProgressBlock and handoff-node; they move to views/timeline-node.ts so the
title's new per-state tone has one definition rather than two.

Co-authored-by: Sculptor <sculptor@imbue.com>
…ts title

The sizes the previous commit brought over were the prototype's own inventions
(13.5px titles, 13px captions) rather than anything this workspace defines. A
task title is the row's subject, so it is body text like the prose around it --
what sets it apart is the weight and the tone. Its captions, the tool-block
headers and the code blocks drop to helper instead of inventing a step between
body and helper.

The bullet sat a pixel low against its title because the two were positioned by
different boxes: the bullet by the node's line box (the block's 21px strut, with
the icon baseline-aligned inside it), the title by its own 19.6px one. They only
ever agreed by coincidence, which is why they drifted apart as soon as the title
changed size. The bullet is now a flex box exactly one title line tall with the
icon centred in it, and the title is block-level (`flex w-fit`) so it starts at
the body's top edge instead of on the body's baseline. The chevron takes
`leading-none` for the same reason: at 18px its line box was taller than the
title's, so an expandable title sat lower than a plain one.

`-top-px` on the bullet is the optical correction left over: centring on the
line box centres on the em box, but text carries its mass above that, so a
geometrically centred bullet still reads low.

Also drops the inline-code border inside a <pre>. Only fences the tool-call
wrapper claims get `display: block`; a plain fenced block in agent prose stays
an inline <code>, so that border was drawn once per wrapped line and a code
block came out as a stack of outlined strips.

Co-authored-by: Sculptor <sculptor@imbue.com>
The previous pass aimed the bullet at the title's em box and then nudged it a
pixel further up, which put it visibly ABOVE the text rather than on it. Two
mistakes: the em box is the one candidate the eye does not read, and the nudge
went the wrong way.

Measured against the rendered font (14px/500, 19.6px leading), the bullet was
1.27px above the cap band and 2.49px above the x-height. It now centres on the
title's line box and shifts DOWN by 0.1em -- about half a descender, which is
how far a line's ink centre sits below its geometric centre, since the
ascenders use most of the space above the baseline and the descenders only part
of the space below.

Aiming at the x-height is also what makes the bullets agree ACROSS rows: the
full-ink centre moves with whichever glyphs a title happens to contain, so
"Check the chat still works end to end" (no descenders) would sit 1.3px off
from a title that has them. All three states now land within 0.1px of the
x-height centre regardless of the string.

Co-authored-by: Sculptor <sculptor@imbue.com>
A turn's actions were a stack of full-width bordered blocks, one per call, each
carrying as much weight as the prose beside it -- so a turn that read three
files looked like three paragraphs of content, and the shape of the turn was
lost in its own chrome. A run of calls now collapses into one wrapping row of
small ghost chips (glyph + name) that reads as a single line of what the agent
did, and only the chip a reader picks opens its detail below the row. One open
at a time: the row is a list to scan, not a set of boxes to leave lying open.

Runs merge ACROSS assistant events, which is what makes this work at all: a
harness emits one event per model response, so three tool calls in a row are
usually three events, and grouping only within an event would have left them as
three separate rows. renderAssistantRun does the merging wherever a caller holds
a whole event list (a step's revealed work, an ungrouped run, a handoff body);
the top-level rows stay per-event, since each is measured and windowed on its
own by the virtualized list. Each chip carries the id of the event that issued
it, since that is the handle its input is fetched by.

What does NOT become a chip: a sub-agent call, which is a whole conversation
rather than an action, and a permission request, which is something to act on
and so must not be one click away.

Three things the chips had to keep from the blocks they replace. Payloads are
still fetched on demand, so the panel carries the loading and "no longer
available" states. A failed call keeps its resident error snippet, now leading
the panel so it is readable before the real output arrives -- and the chip
itself goes red, so the failure is visible without opening anything at all.
And the open state lives in the shared expansion store, so virtualization
evicting a row does not collapse what the reader opened.

That last one needed a new signal: the older blocks survive a memoized redraw by
toggling a DOM class on a body that is always present, but which detail panel
EXISTS changes here, so it has to be built at render time. expansion-state now
counts its changes and StableAssistantMessage compares that count.

Also splits the payload state machine out of the block renderer (tool-payloads)
so both surfaces read it from one place.

Co-authored-by: Sculptor <sculptor@imbue.com>
…e row

A long run wraps: two dozen calls fill three lines. The panel hung below the
whole row, so opening a chip on the first line put its detail 56px and two lines
of unrelated chips away from it -- far enough that a reader loses track of which
call they opened, which is the one thing the panel exists to tell them.

The panel is now a full-width flex item sitting immediately after its own chip
in the row. An item that wants the whole width cannot share a line, so it breaks
the wrap exactly where it sits: the chip that opened it ends its line, the panel
spans the width 8px underneath, and the rest of the run resumes below. No
measuring and no DOM bookkeeping -- the same flex wrap that laid the chips out
places the panel.

Its own `ml-2` and matching narrower basis undo the row's `-ml-2` for this one
child, so the panel's left edge lands on the prose's rather than 8px left of it.

Co-authored-by: Sculptor <sculptor@imbue.com>
A chip said the tool's name -- "Read", "Bash", "Grep". That tells three calls
apart and identifies nothing at twenty, where a row reads "Read Read Grep Read
Bash Read" and a reader cannot tell which Read was which file.

The harness already knew better, on two levels, and was discarding both.

Claude's Bash and Agent tools require a short description of what the command is
for, and the agent writes one on every call ("Read the transcript container
markup"). It was reaching the frontend already, but only as the live activity
strip's caption -- which shows for a second while the call runs and is gone --
and as a field in the raw JSON two clicks deep. It is now stamped as
`action_note` and is the chip's first choice, because nothing beats the agent
saying what it was doing.

Every other tool records no reason anywhere, so those chips say what the call
DID instead: a past-tense verb and the thing it acted on ("read ChatPanel.ts",
`searched "font-size" in src/views`). The two ride separately as `action_verb`
and `action_target` because the chip sets them in different type -- the verb is
prose, the target is the machine's own text -- and a joined label could not be
split back without guessing where a multi-word verb like "loaded skill" ends.
A reason is never inferred for a tool that states none.

The past-tense verbs live in ONE shared table keyed by the caption participle
the harnesses already share, rather than a new table per harness: they are
written to read alike, so "Reading" -> "read" holds for all of them. A path
target trims to its last three segments from the LEFT, since clipping a path
from the right leaves the directory and no filename.

Only claude's parser stamps the action fields so far. The others fall back to
their `caption_label` -- the same phrase in the present tense ("Reading foo.py"),
which reads fine on a chip -- so no harness is left showing a bare tool name.

Co-authored-by: Sculptor <sculptor@imbue.com>
An open shell chip printed a four-line JSON blob whose only real content was one
command -- and whose other field was the description, which the chip beside it
was already showing. Braces and quoting are the wire's business, not the
reader's.

The pane now lays the input out as text: several fields as `key: value` lines,
with a multi-line value dropped below its key, and a LONE remaining field as its
bare value -- which for a shell call is exactly the command. An input that is not
a JSON object at all (codex's code-mode program) is shown verbatim, since there
is nothing to unpack.

What makes the shell case come out as one clean line is dropping the field the
chip already said. Matched by value against the note rather than by key name, so
it only ever drops the field the note actually came from.

The verb leads that line -- "ran" + the command -- but only where the chip is
showing the note instead, since there it is the missing half. When the chip
already reads "edited <file>", repeating the verb in the pane would be the same
doubling this change removes.

Co-authored-by: Sculptor <sculptor@imbue.com>
A chip is a phrase a person scans, and half of it was dressed as a terminal: the
target sat in monospace at reduced opacity, so "read .../src/views/ToolChipGroup.ts"
read as a line of console output wearing a pill rather than as a sentence.

Both halves now take the same face, size and colour, separated by an ordinary
word space. They stay separate elements because they are separate facts the
tests locate individually, but the wrapping label owns the layout now -- as flex
children of the chip they sat a 6px gap apart, which at this size read as a
double space.

And a file is NAMED rather than pathed: `read ToolChipGroup.ts`, not three
segments of directory ahead of it. The path was there to tell two same-named
files apart, which is worth less than the phrase reading as a phrase -- and the
whole path is one click away in the chip's own panel, which shows the input.
`shorten_path` existed only for that trim and goes with it.

Also adds the Python tests the action labels never had: the verb/target table,
the shell call's deliberate divergence from its caption, and the cases where a
note is absent rather than invented.

Co-authored-by: Sculptor <sculptor@imbue.com>
"ran" was prose in the reading face beside monospace output, which read as a
label stuck to a value rather than as one line. The pane now carries the face
and size, so the verb, the command, and the space between them are all set
alike.

The space is the reason the face moved up to the pane rather than onto the verb
alone: it is an ordinary text node, and a text node takes the face of whatever
contains it. Set on the verb only, the gap stayed a sans space beside monospace
text -- narrower than half a mono space, which read as no separation at all.
It now measures exactly one.

Co-authored-by: Sculptor <sculptor@imbue.com>
Trying the chips closer to running text: 8px of side padding down to 6, and the
gap between a chip's glyph and its first word from 6px down to 2.

Two other numbers track the padding rather than standing on their own. The row's
negative left margin cancels the FIRST chip's padding so the row's ink starts
where the prose above it does, and the open panel's margin undoes that negative
margin for itself -- both move to 6px with it, and the glyph still lands on the
prose's left edge exactly.

Co-authored-by: Sculptor <sculptor@imbue.com>
Another step tighter, with the row's negative left margin and the open panel's
own margin following it as before -- the glyph still lands on the prose's left
edge, and the panel still spans from there to the agent's gutter.

Co-authored-by: Sculptor <sculptor@imbue.com>
A pass over every comment the branch added or changed, taking out what reads as
a changelog rather than as help.

Gone: the before-picture ("a turn's actions used to be a stack of blocks", "a
chip used to say the tool's name", "before this it was a block with an inline
icon"), and the arguing that went with it -- "measured, not guessed", the
131.39-vs-132.73 figures that pinned a comment to one type size, "the doubling
this pane exists to avoid". Past-tense accounts of a bug being fixed became
present-tense statements of the mechanism, which is what a reader needs.

What a comment explains stays. The reason the bullet shifts 0.1em, the trap in
emptying the pending array by splice rather than reassignment, the wrap break
the panel's basis-full buys, why a note is never inferred: all kept.

No banners or commented-out code to sweep -- the touched files had none.

Co-authored-by: Sculptor <sculptor@imbue.com>
A second pass over the whole PR's added comments, not only this session's:
"no longer names its tool", "still badges", "chips still use" carried a "then
vs now" that a reader coming to the code fresh has no then for. Reworded to
plain present-tense statements of what the code does.

Co-authored-by: Sculptor <sculptor@imbue.com>
Problem: In system/apps/chat/frontend/src/views/ToolChipGroup.ts,
formatToolInput dropped the field the chip already showed by an exact
value match (value === omit). The omit is chip.call.action_note, which
the backend derives via stated_note -> shorten(description, 80): shorten
collapses whitespace and clips to 79 chars + an ellipsis past 80. So a
Bash/delegation description with a newline, a doubled space, or over 80
characters no longer equalled its note, the filter failed to drop it, and
the description was rendered both on the chip and again in the panel --
violating the function's own stated invariant.

Fix: Match the note against the same normalization the backend applied.
A new noteEchoesValue helper collapses the value's whitespace before
comparing, and for a clipped note (one ending in the ellipsis) matches its
leading run, so the originating field is dropped regardless of shortening.
Added regression tests for the clipped and whitespace-collapsed cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Problem: The preceding fix commit left the new noteEchoesValue filter
line and its regression tests unwrapped, which the frontend's
lint-and-format check (prettier) rejects.
Fix: Ran prettier --write over the two changed files so the filter
wraps and the formatting check passes; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two files conflicted, both where the chip work meets changes main made since
the last merge.

message-renderers.ts: main added a reauth-note repaint trigger to
StableAssistantMessage's onbeforeupdate at the same spot the chip work added an
expansion-version trigger -- kept both. And main's rewrite of the reauth
affordance (it now offers a switch link as well as "sign in again") landed
beside the renderToolCallBlock the chips deleted -- took main's reauth doc
comment and dropped the function, which no longer has callers.

message-renderers.test.ts: main re-added the renderToolCallBlock payload tests
(gone with the function) and a new describe for the switch link -- dropped the
first, kept the second.

test_e2e.py auto-merged (main's tab->window renames, this branch's chip
assertions).

Co-authored-by: Sculptor <sculptor@imbue.com>
The chat and dev changelog entries the check-changelog gate requires for this
branch. The gate also flagged an 'agents' project touched only by a tracked
.agents/.DS_Store that rode in from an old base and is not on main; that macOS
junk file is removed and .DS_Store is gitignored, so 'agents' is no longer
touched and needs no entry.

Co-authored-by: Sculptor <sculptor@imbue.com>
The repo requires every .gitignore pattern to start with **/ or contain a path
separator, so .dockerignore (a symlink to .gitignore) reads each pattern the
same way git does -- checked by test_meta_ratchets.py. The bare `.DS_Store`
added with the earlier untrack tripped it; `**/.DS_Store` still matches the
file at any depth.

Co-authored-by: Sculptor <sculptor@imbue.com>
@weishi-imbue
weishi-imbue merged commit 0130625 into main Sep 22, 2026
3 of 4 checks passed

@joshalbrecht joshalbrecht left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

lgtm

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.

3 participants