Repository navigation
design live sender
The user's ask (2026-09-22): while a turn runs, the Sender should not be
read-only — model / permission mode / thinking level stay writable, and
sending new input mid-turn QUEUES it for the next turn. Ground truth that
gated this design: research-live-sender.md —
every target ACCEPTS mid-turn config writes (server+daemon already forwarded
them; only the web locked the selects), while prompt-while-busy is
heterogeneous (claude queues natively, opencode joins the run, dsh rejects,
our pi façade rejects) — so the queue lives in OUR stack.
- Queue UI: a CHIP floating ABOVE the composer card — outside it, right- aligned (2026-09-23 refinement: inside the card it read as an attachment preview; the user asked for it to hover above the Sender instead), never a transcript row (it is a sender affordance, not history).
- Depth 1 — a second parked send bounces
QUEUE_FULL. - The parked entry is cancellable (chip
×) and editable — edit is TAKE-BACK-TO-DRAFT: click returns the content to the composer and drops the slot; re-send re-queues. One daemon-free op for both. - Turn cancel DROPS the parked entry (does not auto-send).
- The queue lives on the SERVER, not the daemon (refined from the initial
proposal while sizing): zero daemon changes,
queue.cancelis atomic at the server, the slot rides the live-session record (channel close drops it for free), and the flush hook is the busy→idle transition the server already observes.
-
shared/realtime.ts:ChatStreamEventgains{kind:'queue_state', prompt: PromptBlock[] | null, flushed: boolean}— SERVER-emitted only (never daemon);prompt: nullclears the slot andflusheddistinguishes clear-because-it-RUNS from clear-because-cancelled. New browser→server opchat:queue.cancel {sessionId}(ack{accepted:true}, idempotent).chat:message.send's ack gainsqueued: boolean. - Server
realtime/chat.ts:LiveSession.queuedPrompt(the slot).onMessageSend: busy → park (emitqueue_state, ackqueued:true), second park →QUEUE_FULL; the per-machine ACTIVE budget still guards IDLE sends (a queued send serializes behind the running turn, adds no concurrency).onStream's busy→idle transition is the flush hook: emitqueue_state(null, flushed:true)then forward the parkedchat:message.sendto the daemon.onTurnCancelandonQueueCanceldrop the slot (queue_state(null, flushed:false)).reattachreplays a non-empty slot so a re-joining viewer restores the chip. - Daemon: UNCHANGED — its
session-busyreject stays as the race backstop it always was; the parked entry only reaches the daemon (and its history ring) when it actually runs, so the transcript stays honest.
-
fold.ts:queuedslice; aqueue_stateevent sets/clears it. The FLUSH conversion lives in the reducer:queue_state(null, flushed:true)while a chip exists folds the parked blocks into a USER row first — every viewer saw the chip (live or via reattach replay), so every viewer can materialize the row; late joiners get it from the daemon ring. -
composer.tsx:controlsDisabled = disabled(selects + attach stay writable mid-turn; the trigger tooltip shows "applies from the next turn" while a turn runs). WhileturnActivewith a non-empty draft, the primary Send (title "Queue message") renders ALONGSIDE Stop; empty draft is Stop-only. The chip (rendered byAgentSession.tsx, floating above the card): clock glyph + label + mono preview + take-back (pencil) + cancel (×). -
AgentSession.tsx:send()no longer bounces onturnActive— mid-turn it skips the optimisticuser_messagedispatch (the chip is the projection; the fold adds the row at flush), acksqueued:trueas a toast,QUEUE_FULLas an error (draft restored).queueEditprefills the draft from the parked blocks (text → value,resource_link→ file refs, image → attachments) thenqueue.cancels.
| Moment | Behavior |
|---|---|
| Turn running, change a select | forwarded immediately; the running turn keeps its pinned config — applies from the next turn (claude-code may apply sooner; dsh/pi have no permission modes at all) |
| Turn running, send | parks (chip + toast); QUEUE_FULL if the slot is taken |
| Turn running, chip × / pencil | slot dropped atomically server-side (pencil also refills the draft) |
| Turn ends (idle) |
queue_state(null, flushed:true) → user_message echo → forwarded prompt runs |
| Stop pressed | turn cancelled AND the parked entry dropped (flushed:false) |
| Page refresh / second tab |
queue_state replayed on reattach — the chip restores |
Two gaps surfaced when two windows of the same account watched one session:
-
Prompt echo. The daemon's live stream never carries the user's own
message (ACP
session/updateis agent-side), so the user row existed only on the sending tab's optimistic dispatch — every other viewer saw just the agent's rows. The SERVER now emits auser_messagestream event ({kind:'user_message', blocks}) to the room on BOTH send paths (direct and queue flush); the web dropped the optimistic dispatch and the fold's flush-row workaround, so the echo is the single source of truth. No adapter can double-render it: no live user-item kind exists. -
Turn state after refresh. The fold's history ingestion rebuilds
from scratch and force-closes running rows, so a viewer (re)joining
mid-turn landed with
turnActive=false(Send where Stop belongs) even thoughLiveSession.busywas true. When the server relays a history batch for a busy session it follows it with a syntheticsession_status {state:'active'}— same socket, emitted after the history event, so the rebuild-then-set order is guaranteed.
Verified with two simultaneous rig viewers: live echo on the passive tab (no refresh), mid-turn join AND mid-turn refresh both restore the user row and the Stop button.
-
server/test/chat.test.ts: the busy-gate case became the queue case (ackqueued:true, chip event,chat:queue.canceldrop); a new lifecycle test covers park →QUEUE_FULL→ rejoin replay → idle flush (forwarded prompt +flushed:true) → turn-cancel drop with a no-flush counter assertion. -
apps/webfold.test.ts: queue slice set/clear, flush clear adds no row (theuser_messageecho paints it), echo starts the turn for a non-sending viewer, cancel-clear adds no row. - Rig E2E (agent-browser, live claude-code session): selects enabled
mid-turn; 排队发送+停止生成 coexist; chip + toast on queue;
QUEUE_FULLon a second; the sleep-45 turn's end auto-flushed 排队消息A (fold made the user row, the turn ran and answered); a cancelled entry produced NO flush after the turn ended. (Take-back was exercised only partially in the UI — the flush raced the click — but it is the cancel op plus local draft prefill.)