Restyle the chat transcript and render tool calls as inline chips - #656
Merged
Merged
Conversation
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>
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.
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
--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.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.
renderAssistantRunmerges 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.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:
action_noteand is the chip's first choice.read ToolChipGroup.ts,searched "font-size" in views), asaction_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: valuelines, 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.pynow expands a chip.Note for anyone pulling this: the merge of main here changes the shell, so
npm run buildis needed before the e2e suite means anything — the stale build fails all 18 with[data-desktop-id="home"] not found.🤖 Generated with Claude Code