Skip to content

Web UI cannot queue messages while the agent is working #445

Description

@alex-clickhouse

Summary

In the web UI you cannot send a message while the agent is working. The composer blocks the
send button for the whole turn, so a correction or a follow-up has to wait until the agent
goes idle, and the user has to watch for that moment.

Telegram and Slack do not have this problem. The channel router already queues inbound
messages per session and runs them after the current turn. Only the web path is missing the
behaviour.

The request is the Claude Code composer behaviour: type while the agent runs, see the queued
messages, remove one if you change your mind, and have them delivered automatically.

Current behaviour

Channels already queue. ChannelRouter.handle_message (nerve/channels/router.py:103)
appends every inbound message to _pending_batches with a future. If the per-session lock is
held, it returns the future and the running driver coroutine picks the message up. The driver
drains the queue after the current turn and merges the pending messages into a single turn via
_run_batch (nerve/channels/router.py:227), joining the texts with a blank line. A 0.60s
debounce (BATCH_DEBOUNCE, nerve/channels/router.py:101) collects rapid-fire sends.

Web bypasses the router. The WebSocket handler calls _engine.run() directly
(nerve/gateway/server.py:865), so it gets none of the queueing.

The composer blocks the send. canSend = !disabled && !isStreaming && ...
(web/src/components/Chat/ChatInput.tsx:316), and the send button is replaced by the stop
button while the turn is open.

Why removing the isStreaming gate is not enough

engine.run serializes per session on _session_locks (nerve/agent/engine.py:2456), so a
second send would wait rather than fail. But the WebSocket handler calls
_engine.register_task(session_id, task) for each send, and register_task
(nerve/agent/sessions.py:380) replaces the live task with the queued one and logs
"replacing live task for session %s (possible concurrent run)". After that, stop targets the
queued turn instead of the running one, so the stop button stops working.

There is also no way to see or cancel a queued message, and no debounce, so two fast sends
become two separate turns.

Where the queued message can be delivered

Three options, in increasing order of risk.

Option 1: at turn end

receive_turn exits on the first TurnCompleted (nerve/agent/backends/claude.py:1085), the
engine releases the session lock, and the queued message runs as its own turn through
engine.run. It is persisted, titled, broadcast, and metered like any other message.

  • Cost: the message waits for the whole turn. A correction sent two minutes into a six minute
    tool chain lands four minutes late.
  • Risk: none. This is the path channels already use.

Option 2: mid-turn, at the next tool round

ClaudeSDKClient.query() writes a single JSON line to the CLI's stdin
(claude_agent_sdk/client.py:248). Nothing in the SDK prevents calling it while
receive_response() is still iterating. The CLI has an internal mid-turn delivery path: its
bundled strings include "is already waking; message queued for its next tool round (not delivered if the agent turns out to have been stopped)", which is the agent-to-agent
SendMessage case.

Unverified: whether a user message written to stdin in stream-json input mode reaches that
same tool-round path, or is buffered until the turn ends. This needs a spike. Write a second
query() mid-turn against a live session and check whether the injected text reaches the
model before the turn's ResultMessage.

Two things to handle if it does work:

  • Turn accounting. receive_turn returns on the first TurnCompleted. If the CLI closes
    the current turn and opens a new one for the injected message, the second turn's events stay
    in the SDK buffer. _idle_stream_watcher (nerve/agent/engine.py:3175) already drains
    events that arrive with no active run, but the queued message would then render as an
    autonomous turn instead of a user message.
  • Persistence. A direct client.query() skips _run_inner, which is what writes the user
    message to the database and broadcasts the bubble. Without an explicit write, the UI shows
    the agent answering a message that is not in the transcript, and a reload loses it.

Option 3: interrupt and resend

client.interrupt(), then run the queued message. Immediate, but it discards the in-flight
tool call. This is the current stop button plus a resend, not queueing.

Proposal

Build option 1, then spike option 2.

  1. chatStore: add queued: Record<sessionId, QueuedMessage[]> beside the existing
    per-session drafts map (web/src/stores/chatStore.ts:110, already persisted to
    localStorage), with enqueueMessage, dequeueMessage(index), and clearQueue. Queue
    fileIds with the text, because uploads complete before send.
  2. ChatInput: allow send while streaming, keep stop as a separate control, and render the
    queued messages as removable chips above the composer.
  3. Flush from one function, flushQueue(sessionId), called by handleDone
    (web/src/stores/handlers/streamingHandlers.ts:436). Send one message per queued item so
    the order stays visible, rather than merging them the way _run_batch does.
  4. Spike option 2. If stdin messages do reach the next tool round, move the flush trigger to
    the tool_result event and add the database write. The queue data model does not change,
    only the trigger.

A client-side queue is per tab. It does not survive a reload, is invisible to other tabs and to
Telegram and Slack, and is lost if the browser closes. If the queue has to be shared, the web
path should move onto router.handle_message so it inherits the existing batching, or the
queue needs a table and WebSocket events. That is a larger change and should be a separate
issue.

Decisions needed

  • On stop. Should an interrupt discard the queue? Suggested: keep the items as chips but do
    not auto-send them, so handleStopped does not flush.
  • On a mid-turn pause. AskUserQuestion and ExitPlanMode park the turn while
    is_running stays true (nerve/agent/interactive.py). Under option 1 a queued message sits
    until the question is answered. That is probably right, but it is visible behaviour and
    should be chosen rather than inherited.
  • One turn or many. Channels merge a batch into one turn. The proposal above sends queued
    web messages as separate turns. Worth deciding whether the two paths should match.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions