Agent chat: a run of messages to agents is one group, "Messaged <target> 3 times"

Merged
TE
6d ago
+826-10

Leo, 2026-10-29, on Claude desktop's transcript: "this would be decent ui for cmux-next... tho i dont mind what we have now. i just think we can do a little bit better"

Gallery: agent-pane.message-groups/running, agent-pane.message-groups/done, agent-pane.message-groups/done-open, agent-pane.message-groups/mixed, agent-pane.message-groups/before-running, agent-pane.message-groups/before-open, agent-pane.message-groups/look-guide-running, agent-pane.message-groups/look-guide-open, agent-pane.message-groups/look-list-running, agent-pane.message-groups/look-list-open

What changes

Two or more messages to other agents in a row (Claude Code's SendMessage, a shell acpmux send, a cmux-subsessions send_message, in any mix) now draw as one group (toolGroups.ts messages, MessageGroupRow.tsx):

  1. One line that sums them up. While a message goes out: the present tense, its target and the time since the first one left ("Messaging Model picker spruce-up 11s"). Once all went: the past tense with a count ("Messaged Model picker spruce-up 3 times", or "Messaged 3 agents" when the targets differ). The verb sits in the turn's verb column (#27386); the target takes the text colour at the row's weight, the rest stays muted.
  2. One quiet bordered card, a row per message (each message's own existing row: verb, target, first line, caret), split by hairlines in the transcript's card-ring colour.
  3. A row opens in place to the whole message as Markdown, not JSON (unchanged). A shell acpmux send / send_message row now has the same hover Copy a SendMessage row already had, so every sent message has exactly one Copy.
  4. The group starts open while a message goes out and closed once all went; the reader's choice persists across the virtualized transcript. Find in Chat opens it.

A lone message still draws as its own row. Other work between two messages splits them. A message that failed or never said it went out keeps its own row with its failure, outside any group, so a closed group never reads "Messaged ..." over a hidden failure. With the Codex-style runs on (the default), the message in flight leaves its run for the live line, which now says "Sending message to " for a Claude SendMessage too (it did for acpmux send and send_message); the running group is what shows with those runs off. Coordination cards stay in sight in a folded run, as the messages did.

Kept: our colours, glass, type scale, and the spinner as the working mark. Not touched: the composer, the rail, the model picker.

Variants (gallery, theme.messageGroupLook)

  • card (picked, the default): the bordered card with hairlines, as the reference.
  • guide: the rows on the tree guide an edit group's rows hang from, no card.
  • list: the hairlines with no ring, flush under the line.
  • off: no group, each message its own row: the "before".

card wins: it reads as one unit at a glance in a long transcript, and its hairlines separate rows whose first lines are similar; guide reads as nested edits and list loses the group's edge when the rows above are also muted.

Home Channels draws these rows too; its pane catalog (gen-strings.mjs home-channels-pane) carries the new messages.group.* keys.

Timer, observers, per-token cost

  • The elapsed counter is useNow(running) (SubagentGroup.tsx): one 1 s interval while a message in the group goes out and the pane is visible. Stop condition: the last message went (running false), the pane hid (visibilitychange), or the row unmounted. Cleanup: clearInterval plus removing the visibilitychange listener in the effect cleanup. MessageGroupRow.test.tsx asserts the interval is live while sending and gone once the last message went.
  • No new observers or animation loops. Expand and collapse are instant on the click (useAnchoredCollapse with animates: () => false, the header still anchored).
  • No new O(rows) work per token: grouping is one pass over the run's own entries inside the existing per-row toolGroups memo; the tick re-renders only the group's line (its rows are the same elements, so React skips them).

UI review checklist (design/ui-review-checklist.md, skills/aiforui)

Applied:

  • Motion gate: a group toggle is seen tens of times a day, so it does not animate; open and close take effect on the click, with no fade-in.
  • Subtraction: the word repeated on every row ("Sent to", the envelope) stays on the rows as their verb; the head names the count once. No narration labels; no second Copy or Open control.
  • Stable layout: the timer uses tabular-nums; the gap before it is one token (--ds-space-2); the chevron stays on the row's trailing column; the target truncates before it.
  • Spacing and surfaces: padding, gaps and radius from tokens (--ds-space-1/2/3, --ds-radius-card); one hairline ring, no card inside a card, no new fill.
  • Type: one weight in the row kind; the target is the text colour, not bold.
  • Every state: running, done (same target), done (mixed targets), opened row, queued rows keep their delivery word inside the group, failed ones stay outside it, both themes in the gallery.
  • Agent working mark: the existing spinner; no other indicator restyled.

Needs a human: VoiceOver reading of the group line; a real trackpad hover on Copy.

Prior art checked

Open: #26591 (show every item when expanding a tool group), #27611 (plain "Ran 2 commands", regular sub-session rows), #27602 (waiting messages from agents, received side), #26629 (fold completed dialogue), #26696 (tool output inline), #27074 (dim background activity). None groups sent agent messages; this PR touches none of their lines beyond TurnRows.tsx's groupNode switch. Merged: #24075 / #24443 (tool calls consolidate; coordination cards kept out of groups), #23738 (sent message row), #24291 (SendMessage names its subagent), #27386 (verb column).

Tests

  • toolGroups.test.ts: consecutive messages of every kind form one group; a lone one does not; other work, a sub-session start and a failed send split runs.
  • messageLiveLine.test.ts: with the Codex-style runs on, a SendMessage in flight leaves its run and the live line names its recipient.
  • MessageGroupRow.test.tsx: the line in each state; a click opens the card in the same commit and closes it; the clock runs only while a message goes out; the off look draws the old rows.

Agent session: agent session

šŸ¤– Generated with Claude Code