Agent pane: a guest's message waits for the owner (Approve / Decline)
HOLD: stacked on #26747 (acpmux held prompts, lane/mp-hold at 208b5a766d45). It lands after #26747; rebase onto feat-cmux-next once that merges, then drop this line.
What
A guest's message waits for the owner in the agent pane. Leo (2026-10-28), on Theo's T3 Code "Share with guest": "we want to be having, be able to do this kind of flow for CMux multiplayer ... we can work on the agent chat multiplayer." In Theo's flow "each message they send waits for you"; the owner approves each message. Approve sends it to the agent, Decline drops it.
Spec: leo-workspace design/chat-multiplayer.md, section 2 "Held message card" and "Presence"; plan and inventory manaflow-ai/cmuxterm-hq#4766. This is PR 3 of that plan (owner UI).
- A queued prompt with
awaitingOwnerrenders at its place in the transcript as an incoming message from that person: their face and name chip ("Cy Park (guest)" for aguest:id), the text, and in the line's action area Approve (primary text button) and Decline (quiet), then the quiet delivery label "Waiting for you". Return approves while the card has the focus. No motion, no dot, no toast, no focus steal. - Approve sends
_acpmux/queue_approve {sessionId, promptId, revision}with the revision the queue lists. Decline sends_acpmux/queue_decline {sessionId, promptId}. Both buttons wait while one answer is in flight. - After Approve (
queue_approved) the row is the normal queued card ("Read after this turn"). After Decline (queue_declined) it collapses to one quiet line "Declined: " that opens to the whole text on a click. queue.stale(a guest edit since the owner looked) reads quietly in the row's label as "Edited since you looked", with the new text shown and the buttons live again.queue.not_foundreads "Already answered". There is no auto-retry and no toast.- The buttons show only where acpmux's
initializenames thequeueApprovalfeature. An older daemon draws the guest's message with no actions. - Needs you: each held prompt is a "needs you" item (the header badge and the transcript pill), like a permission ask. Its action is Review, which goes to the held card. It is computed in one place,
needsYou/model.ts. - A guest's prompt (held or approved) leaves the composer queue for the transcript. A tailnet teammate's queued prompt and the owner's own are unchanged.
- Swift:
_acpmux/queue_approveand_acpmux/queue_declineadded to the pane relay's allowlists (requests, session-scoped, known params). Approve needs a user gesture (.always) because it runs a prompt. Decline needs none. Nothing else changes in Swift. - The reuse covers
IncomingMessageLine(a newpersonprop: face in the icon column, name chip, no chat lookup), the received line's action area, andQueuedMessageCard's ⋯ menu shape. The action row is not forked.
Variants (gallery agent-pane.guest-approval)
- A, inline (the pick, default): Approve and Decline sit in the line's action area, before "Waiting for you".
- B, bar: a compact bar of the two buttons sits under the message (
look-bar,look-bar-three). - C, menu: the label sits with a ⋯ menu beside it, Approve first (
look-menu).
I picked A. The spec puts the actions "in the row's action area", and chat-transcript.md says all message kinds share one action row. A keeps both answers one click away and visible without hover. It adds no height, so three held messages stack as compactly as three queued ones. B adds a row per message, which costs a lot of height in the three-held stack. C hides the decision behind a menu, which is wrong for the one thing the owner must act on. B and C ship only as gallery variants through <html data-held-card-look>, which the app never sets.
Before / after (gallery captures, fixture content, Chromium on the fast-checks runner)
Before (feat-cmux-next at 5f87f20e5813, the same fixture): a guest's prompt is just a composer-queue row under their name, with no way to hold or answer it.
| Before, dark | Before, light |
|---|---|
![]() | ![]() |
After, A (pick):
| One held, dark | One held, light |
|---|---|
![]() | ![]() |
| Three held from two guests while the agent works, dark | Light |
|---|---|
![]() | ![]() |
Before for the three-held state: 
| Approved | Declined (opened by a click), dark | Declined, light |
|---|---|---|
![]() | ![]() | ![]() |
| B, bar | C, menu (open) | Unchanged: a teammate's queued prompt |
|---|---|---|
![]() | ![]() | ![]() |
The header's Needs you badge counts the held messages (1, then 3).
Tests
webviews/src/agent-session/acpmux/conversation/heldMessage.test.tsx covers these cases:
- A held prompt renders with Approve, Decline, "Waiting for you" and "Cy Park (guest)".
- Approve and Decline answer by prompt id, with one answer in flight.
staleis said in the row and the buttons come back.- Return on the focused card approves it.
- Approved, it is a queued message with no actions.
- A teammate's and the owner's queued prompts stay composer-queue rows.
- Through the real
AcpmuxDirectClientand the mock daemon:queued {awaitingOwner, revision}and then a guest edit (queue_updatedat revision 1). Approve sends_acpmux/queue_approve {sessionId, promptId, revision: 1}, Decline sends_acpmux/queue_decline {sessionId, promptId}, and the declined row appears. - The declined line reads "Declined: " and opens on a click.
Review fixes (Astra, BLOCK at 53c392bb). Each fix has a regression test, and each test fails on the 53c392bb sources and passes at head:
- The relay's param allowlist now allows
revisionon_acpmux/queue_approve, so a native Approve is no longer refused. Decline stays{sessionId, promptId}. Tests:AgentPaneQueueApprovalRelayTests.swift(shapes, scope, gesture), and a webview test that checks the requests the pane sends againstAcpmuxPaneMethods+Params.swift. - An owner's prompt that acpmux queues settles its sending bubble,
queuedSendsand the composer's acceptance on itsqueuedrecord again. Only a guest's held prompt waits forqueue_approved. Test: a send to an idle chat that acpmux queues leaves no pending bubble, and the composer is released. - Approve names the revision the card (or the Needs you item) was drawn with, not the client's newest one, so a guest's edit since then gets
queue.stale. Test: a card drawn at revision 3 sends 3. - Needs you offers held prompts only when
queueApprovalis served, and its Approve also names the item's revision. Test included.
Second review (re-review at e0be7f0a, BLOCK on two more):
5. A listed revision never joins text read from an older record. Before this fix, an edit past the queue list's 200-character preview left the old full text paired with the new revision. A held row now always carries the revision of the text it shows. When acpmux lists a newer revision whose full text hasn't arrived, the card reads "Updating…" and both Approve and Decline are disabled until the edit's own queue_updated brings the text. Needs you offers no answers for such an item. Test: an edit beyond 200 characters, with the list arriving before the edit record.
6. Rendered-revision proof: the test draws the card at revision r, sends the client a queue_updated to r+1 without re-rendering the card, and clicks Approve on the old card. The outgoing queue_approve carries r. On the 53c392bb sources the same test sends r+1 (run 40661617401).
Third review (at bc837515, two P2s where the card could stay stuck in "Updating…"):
7. A queue list that names a revision lower than the text the pane already holds is stale and ignored. This happens when an attach replays live records and then merges its older snapshot. The row shows "Updating…" only when acpmux lists a newer revision than its text. Test: an older snapshot merged after revision 1's record (no Updating), plus a newer listing (Updating).
8. A held prompt whose queued or queue_updated record is older than the opened page has only its list preview. The pane now reads that prompt's newest record with an existing call, _acpmux/events (kinds: ["queued", "queue_updated"], newest first, 200 records a page, at most 10 pages). It takes text and revision from that one record and never goes back to an older revision; a record that arrives live meanwhile wins. Test: a cold attach whose page starts after the held record. The card opens updating, reads the record, and becomes approvable. acpmux needs no change.
Fourth review (at 7caa97cc, two P2s in the backfill):
9. Held-record reads are now keyed by selection generation and prompt id, the set of reads in flight is cleared when the session resets, and a read for a selection the pane has left stops at its next page. Before, leaving and reopening a chat in the middle of a read (A, then B, then A) suppressed the reopened card's read and left it stuck in "Updating…". Test: the card is left mid-read and reopened; the late result from the first read is discarded, and the card becomes approvable.
10. A held row's text, images and revision always come from one record. The backfill installs the record's own images. Kept text keeps its own images when a stale or same-revision listing arrives. A live held queued record names only its own images. Test: the images change between the list and the record, and the card shows the record's images with its revision; a stale, same or newer listing never swaps the images under kept text.
Fifth review (at 472a7e53): one rule replaces the preview heuristics. A held card is approvable only when the pane holds that prompt's authoritative record (a queued / queue_updated record, or a read of one) for its current revision.
- In
direct.ts(Hold),revisionis set only from a record, together with that record's text, images and parts. A listing (a 200-character preview) never sets it, whatever its preview looks like.queuedMessageRowsmarks every held row without a record revision asupdating, so the card has one place to learn whether it may offer Approve. Test: a short preview with no "…" is not approvable. - Held cards show the record's whole content: the text as sent (no cut, no Minimal Mode fold, no signature or reference rewriting), its images, and every other attachment. Resource links use the sent prompt's renderer (
recordedFiles, from UserBubble), and documents and audio use the same file chips.queuedMessageRowskeeps the held row'sinlineParts. Test: a 14-line message with a PDF, an audio note and a link. - Needs you never approves a held prompt. Its only action is Review, which scrolls to the held card and focuses it, so Return approves it once read. Tests cover the item and the scroll and focus.
- Targeted lookup: acpmux has none.
_acpmux/eventsfilters only by kind and seq, and_acpmux/composerslists the 200-character preview, so a lookup needs a new_acpmux/queue_get(on #26747; not added here). Until it exists, the existing bounded read stays as the interim, with no new paging. A held prompt whose record is not found stays "Updating…" and cannot be approved.
Receipts (cmux-ci fast checks on this PR):
- Head cc7ccf080dad ran 106 tests across
heldMessage,queuedMessages,queueAuthor,promptAccepted,promptDelivery,needsYou/*,multiplayer/*,test/agent-pane-needs-you.test.tsxandtest/queued-user-footer.test.ts. All pass,vp fmt --checkis clean,vp checkreports no type errors, andvp lintfinds 0 errors (run 40678885462). - Red then green: the first 4 regression tests fail on the 53c392bb sources (run 40658448200). The two 'Updating…' tests fail on the e0be7f0a sources (run 40660628826). The two third-round tests fail on the bc837515 sources (run 40669142415). The two fourth-round tests fail on the 7caa97cc sources (run 40674591374). The four fifth-round tests fail on the 472a7e53 sources (run 40678244477). All pass at head.
test/agent-pane-queue-collapse.test.tsandtest/agent-pane-queue-row-click.test.ts: 4 fail on head and the same 4 (of 6) fail on base 458d9bf2fdf7, so they are base reds and not from this PR.- The gallery Playwright captures above come from the fast-checks runner (
gallery:build, thenscripts/gallery-matrix/runner.ts, Chromium). The review fixes change no pixels, so I did not recapture.
Observed / Not run
- Observed: the tests and captures above.
- Not run: a Swift compile and the new
AgentPaneQueueApprovalRelayTests(macOS only; they need a fleet Mac run). There is also no live daemon end to end; that is plan PR 8.
Risk
risk:normal. The change is UI plus two relay allowlist entries for owner-only acpmux methods. acpmux refuses both for any authored caller (queue.owner_only), and Approve needs a fresh user gesture in the relay. Nothing changes for chats without guests. Localization: 9 new keys in the agent pane catalog in all 21 locales (multiplayer.guestName, queue.held.waiting|approve|decline|declined|stale|gone|updating|review).
Size: about 1230 changed lines, of which about 740 are tests (webview and Swift) and gallery fixtures.
Changelog
Added: in a shared chat, a guest's message waits for the owner with Approve and Decline.
Agent session: agent session
🤖 Generated with Claude Code











