Guest page: the guest's composer and its own messages (part 2)

Draft
TE
4d ago
+3619-57

HOLD: stacked on #26779; rebase and drop this line once it lands.

Leo's words (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 that flow each message the guest sends waits for the owner.

Plan: manaflow-ai/cmuxterm-hq#4766. Spec: leo-workspace design/chat-multiplayer.md, section 3 (the guest's composer) and section 4.3 (prompt, typing, prompt_state, prompt_status).

This is part 2 of the guest page. It is stacked on #26779, and the broker only opens PRs onto feat-cmux-next, so until #26779 lands this diff also shows its commits. This PR's own change is the last three commits, from "the guest's composer and its own messages" on: about 900 lines, mostly the new guest files. The guest's own messages and composer draw inside the same GuestActionsBoundary as the transcript, so their author chips ask no avatar host either.

What it does

  • Composer. Text only: no attachments, slash commands or pickers. Return sends and Shift-Return starts a new line. A message over 32 KiB (UTF-8 bytes, the room's bound) can't be sent, and the page says why.
  • prompt {client_id, text}. The client id is 128 random bits, made once when the message is written. It is kept with the message in the tab's sessionStorage (guestPrompts.ts), so every resend is the same prompt: the daemon turns it into one acpmux prompt id, which runs once.
    • A message sent while the room is away stays "Sending…". It goes again, with the same id, after the next snapshot.
    • After a reload, the snapshot's prompts set each state. Held ones it doesn't answer are asked with prompt_status.
  • The guest's own messages.
    • While a message is open it shows the guest's chip (the same AuthorChip as a teammate's prompt), its text in the prompt bubble, then "Sending…" and "Waiting for ".
    • Once decided it folds to one quiet line: "Sent: …", "Declined: …", "Withdrawn: …" or "Not sent: …".
    • share.too_many_held reads "Not sent, too many messages waiting: …".
    • The page keeps every open message and the 16 newest decided ones, and shows the last three decided.
  • Typing (typingSignal.ts). typing {on: true} goes at the first keystroke of a draft. {on: false} goes on send, on clearing the draft, or after 5 s with no keystroke. It sends at most two frames a second: a change inside the gap goes when the gap ends, and only if it still differs. It uses one-shot timers only, none while nothing changes, and a reconnect re-sends the current state.

Captures

These are gallery guest-page.waiting at phone width, rendered with Chromium on cmux-ci before the rebase onto #26779's read-only fixes. Those fixes don't change these states' layout, and the field focus styling a later commit changed doesn't show in an unfocused still. Fixture content only.

DarkLight
held: one message waiting for the owner, the one before it sentheld darkheld light
decided: sent, declined, refused with a reasondecided darkdecided light

Risk

Normal: guest page only, and nothing in the app loads it. The page sends only prompt, typing, older and prompt_status. Every prompt is held by the daemon for the owner, so nothing a guest writes reaches the agent without Approve (spec 4.2). Text fields show focus by their edge, not a ring (check-text-focus-rings).

Tests

Run on cmux-ci at 5cde0f93, run 40679848831: bun run check, 45 tests across 10 files and the bundle build pass. They include #26779's class test (guest-no-host-calls), which here also covers the composer and the guest's own messages.

  • test/guest-composer.test.tsx, against a fake room:
    • a message goes as prompt {client_id, text};
    • typing turns on, then off on send;
    • the chip and "Waiting for Sam Rivera", then "Sent: …";
    • declined, and refused with share.too_many_held;
    • a message sent while the room was away goes again after the reconnect with the same client id;
    • after a reload the snapshot's states win, and unanswered held ones are asked about;
    • over 32 KiB cannot be sent.
  • test/guest-prompts.test.ts: the client id survives a reload; states fold in, and unknown states or ids are ignored; the refusal reason is kept; what is kept; UTF-8 byte size.
  • test/guest-typing.test.ts, with a fake clock:
    • on once at the first keystroke, off after exactly 5 s idle;
    • off on send or clear, and a draft of spaces is not typing;
    • at most two frames a second, with no flapping inside the gap;
    • nothing goes without a connection, and a reconnect sends the current state.
  • #26779's guest tests run unchanged, except the read-only page test now allows the guest's own composer field.

Changelog

none (the guest page ships with the hosted room, not the app)

Agent session: agent session

🤖 Generated with Claude Code