agent chat: several waiting messages from other sessions draw as one card

Merged
TE
8d ago
+873-26

Leo, 2026-10-27: "i also feel like there should be better batching for messages from other sessions so you can get to things faster/more efficiently"

What changes

Two or more waiting messages from other sessions now draw as one collapsible card in the agent chat instead of one card each:

  • Read after this step: messages steered into the running turn that the agent has not read yet.
  • Read after this turn: messages queued behind the running turn.

The two kinds are never mixed in one card. The card head is one line: a chevron, "N messages from M sessions", the senders' names (hidden in a narrow pane; the line's tooltip still lists them), and the existing delivery note. Opened, it lists each message as its existing received card, oldest first, with that card's own actions (Send now, or the queued card's Steer / Edit message / Move / Delete). Nothing is dropped. A single waiting message draws exactly as before.

The card is built from the transcript's rows, so it looks the same whether or not acpmux delivers the messages together (#26707 batches the steers on the daemon side). Find opens the card when a match is inside it, through the card row's joined ids. Whether a card is open is remembered per page (incomingOpened.ts), so it survives the virtualized list, and the row estimator (model.ts) reads that state. Nothing animates in; only the chevron turns.

How

  • conversation/waitingMessages.ts:
    • groupWaitingTurn makes the queued card with the queued rows, once per queue change (AcpmuxPane).
    • groupWaitingSteps finds the steers' runs in the running turn. conversation/turns.ts turnViewFrom draws each run's card in its rows' place, in the loop that already pushes the running turn's rows.
    • Card rows keep their identity while they hold the same messages. Each card is named by its first message, so a later batch never inherits an earlier card's open state.
  • direct.ts: the snapshot gains waitingSteers (the rows of steers that wait). It is present only while 2+ wait, as one stable array per change.
  • Find opens the card that holds a match, then opens the message that holds it (FindReveal.member). The active highlight counts within that message.
  • Cost per snapshot: none while fewer than two steers wait and fewer than two messages are queued. Otherwise one lookup per waiting steer, at the index it had last time. A new or moved steer is looked for once. There is no pass over or copy of the transcript's rows, and no new timer, observer or animation loop.
  • QueuedMessageCard.tsx, IncomingMessageCard.tsx and conversation.css are untouched; open PRs change them.

Before / after (gallery agent-pane.waiting-messages-card, fixture content)

Four messages from three sessions steered into the running turn:

DarkLight
Beforebefore steered darkbefore steered light
Afterafter steered darkafter steered light
After, openedafter steered opened darkafter steered opened light

Three messages queued behind the running turn:

DarkLight
Beforebefore queued darkbefore queued light
Afterafter queued darkafter queued light

Narrow pane (420px): the count and the note stay whole.

after narrow light

Gallery variants of the head

  • A (ships): count plus names, as above.
  • B: count alone.
  • C: each sender as a hairline chip.

B and C are drawn in the gallery only. A ships because the names tell you who wrote without opening the card. C's chips compete with the count, and B hides the senders even when there is room.

B: count onlyC: chips
variant count onlyvariant chips

Tests

webviews/test/waiting-messages-card.test.tsx covers:

  • the transcript, with rows from AcpmuxDirectClient over a fake daemon, drawn through turnViewFrom;

  • the queued card;

  • Find opening a closed card and the message inside it;

  • groupWaitingSteps and groupWaitingTurn, including that a streamed snapshot reads only the steers' own rows.

  • Red: the test-only commit c71ad26 on base (run 40642230443): 3 fail, 1 pass. The final test file on base (run 40655225154): 4 fail, 1 pass. On base each waiting message drew its own card and there was no grouping. The case that passes is "one message alone draws as before", as it should.

  • Green at 18e1da0 (run 40657938639): format check, typecheck, lint and check:catalog-keys are clean. 103 pass and 0 fail across waiting-messages-card, queue-agent-message, agent-pane-pending-steer, agent-pane-queue, agent-pane-steer-take-back, transcript-steer-continuous, steer-no-caption, queue-row-actions, chat-find, chat-find-matches, transcript-find-follow, tool-failure-find, agent-pane-steer, agent-pane-turn-footer and agent-pane-turn-opener.

Review

An independent Codex (gpt-6-astra, high) review went four rounds:

  1. BLOCK. Find into a closed card, the open-card estimate, per-token cost, and open state shared between batches. All fixed.
  2. BLOCK. A row copy per snapshot, the member reveal, the occurrence offset, and the reveal key. All fixed.
  3. BLOCK. An extra pass over the running turn. Fixed.
  4. SHIP at 18e1da0. One nit: sorting the steers' indices is O(steers log steers).

Performance

No perf numbers: "faster" is Leo's word for getting to other sessions' messages in fewer clicks, not a speed change. On cost: no work while fewer than two steers wait and fewer than two messages are queued. Otherwise one lookup per waiting steer, at the index it had last time; a new or moved steer is looked for once. Card rows keep their identity, so memoized rows do not redraw. No per-token O(rows) work, and no new timer, observer or animation loop.

Risk

Low to medium. The change is contained in the agent chat transcript. With fewer than two waiting messages from other sessions, the row list is passed through unchanged. Overlap: #26373 and #25683 (same owner) change model.ts finish for received-message heights; this PR adds one separate branch there. Size: 914 changed lines: 348 test, 133 gallery, 433 product code.

Agent session: agent session

šŸ¤– Generated with Claude Code