feat(chat): send a queued message now, without discarding the rest - #85
Open
dnviti wants to merge 1 commit into
Open
feat(chat): send a queued message now, without discarding the rest#85dnviti wants to merge 1 commit into
dnviti wants to merge 1 commit into
Conversation
The composer never refuses a message while the agent works — it queues it — but queuing was the only thing that could happen to it. The only way to get in front of a working agent was the stop button, and stopping discards the whole line, so correcting one thing cost you the two messages already lined up. Each waiting message now carries a control that sends it immediately: the turn in flight is cut short, that message is delivered as a real turn of its own, and the rest of the queue stays exactly as it was, in order, to be delivered after. The default is untouched — a message sent while the agent is busy still queues. `interrupt()` keeps its meaning; its body is now shared with the promotion path through `cancelTurnInFlight()`, which does everything except discard the queue — including resolving any approval or question the cancelled turn left on screen. A `marker` records the stop and names the message that caused it, so the transcript does not read as an agent that gave up; it rides on the messages, so it survives a snapshot and a reopen. Two things found while checking, neither in the report. `codex exec` declares `interrupt: false`, and cutting in there would have handed the process a second turn rather than replacing the first — refused in the session, and not offered in the browser. And on a phone the two controls sat 7px apart, under the 8px floor the rest of the suite enforces, with one of them being the one that throws the message away; the existing phone sweep never saw it because its fixture has no queued message. Closes #70 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a “send this queued message now” control so a user can interrupt a turn in flight with a specific queued message without discarding the rest of the queue, and records the interruption in the transcript.
Changes:
- Introduces a new
chat_queue_send_nowwebsocket message wired through client controller → server message processor → chat manager →ChatSession.sendQueuedNow(). - Adds an
interruptedtranscript marker type and renders it as a notice rule in the conversation. - Extends unit and browser coverage to verify ordering, double-click safety, mobile target sizing, and correct offering/withholding of the control.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/chat-wiring.test.js | Verifies websocket wiring forwards chat_queue_send_now and rejects unauthorized/no-id cases. |
| test/chat-queue-now.test.js | Adds end-to-end unit coverage for promotion semantics, ordering, markers, and clearing pending approvals/questions. |
| test/browser/checks.ts | Adds on-screen checks for accessibility labels, double-click behavior, and mobile hit-target/gap constraints. |
| src/shared/chat-reducer.ts | Renders marker.kind === 'interrupted' as a transcript notice without clearing history. |
| src/shared/chat-events.ts | Extends event/block unions to include interrupted marker/notice. |
| src/server/websocket/messages.ts | Wires new inbound message to chat manager sendQueuedNow. |
| src/server/chat/session.ts | Implements sendQueuedNow() and refactors interrupt logic into cancelTurnInFlight(). |
| src/server/chat/manager.ts | Exposes sendQueuedNow() to sessions. |
| src/client/shell/chat/MessageBubble.tsx | Renders interruption marker with distinct icon. |
| src/client/shell/chat/Composer.tsx | Adds per-queued-row “send now” control with client-side double-click suppression + mobile spacing. |
| src/client/shell/chat/ChatView.tsx | Offers “send now” only when the session is interruptible and a turn is actually in flight. |
| src/client/chat/controller.ts | Sends chat_queue_send_now messages to the server. |
| docs/runtimes.md | Documents queue behavior and when “send now” is/isn’t offered. |
| CHANGELOG.md | Notes the new queued-message “send now” feature and transcript marker behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+791
to
+815
| const index = this.queue.findIndex((turn) => turn.id === id); | ||
| if (index < 0) return false; | ||
| const [turn] = this.queue.splice(index, 1); | ||
| this.publishQueue(); | ||
|
|
||
| // Held across the interrupt *and* the delivery, because going idle is what | ||
| // releases the queue: `interrupt` ends in `setState('idle')`, `ingest` runs | ||
| // `drainQueue` after every event, and without this the first message still | ||
| // waiting would overtake the one the user actually chose. | ||
| this.draining = true; | ||
| try { | ||
| if (inFlight) { | ||
| await this.cancelTurnInFlight(); | ||
| // The record has to say the turn stopped because of this message, not | ||
| // that the agent simply gave up. `marker` rather than an error: being | ||
| // corrected mid-turn is not a failure, and it belongs to the | ||
| // conversation rather than to the turn that was stopped. | ||
| this.ingest({ t: 'marker', kind: 'interrupted', detail: quoteTurn(turn.text) }); | ||
| } | ||
| await this.deliver({ text: turn.text, attachments: turn.attachments }); | ||
| return true; | ||
| } catch (error: unknown) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| this.ingest({ t: 'error', message: `could not send that message: ${message}` }); | ||
| return false; |
This was referenced Jul 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #70. Targets
chore/version-bump-5.3.2, notmain.The problem
Sending a message while the agent works queues it — the right default, and the
reason the composer never refuses one. But queuing was the only thing that
could happen to it. Some messages are worth waiting their turn; others are the
reason you are typing at all. Getting one of those in front of a working agent
meant pressing stop, and stopping discards the whole line: correcting one thing
cost you the two messages you had already lined up.
What this does
Every waiting message gets a second control beside the one that removes it. It
cuts the turn in flight short, hands that message over as a real turn of its
own, and leaves the rest of the line intact — still waiting, still in order,
delivered afterwards as usual. The default is untouched.
interrupt()keeps its meaning, including that it still discards the queue. Itsbody now lives in
cancelTurnInFlight(), shared with the promotion path, whichdoes everything except the discarding — most importantly resolving any approval
or question the cancelled turn left on screen, so nothing sits there waiting for
an answer that can no longer reach anything.
A
markerevent records the stop and names the message that caused it. It rideson the messages, so it survives a snapshot, a page and a reopen, and it renders
through the rule the transcript already draws for compaction.
The two open questions, decided
exactly what gets typed while an approval is on screen, and the acceptance
criteria already require the card to be cleared when the turn is cut short.
decision is being made; "only the newest" means reading three rows and being
allowed to act on one.
Two defects found while checking, neither in the report
codex execdeclaresinterrupt: false. Cutting in there would not haveended the turn in flight — it would have handed the process a second one,
which is the one thing the queue exists to prevent. Refused in the session and
not offered in the browser; the session check is the one that matters, because
the browser is not the only thing that can ask.
the suite enforces — two 44px targets with an invisible seam, one of which
throws the message away. The existing phone sweep never saw it because its
fixture has no queued message in it.
Verification
npm run typecheckclean, 1608 unit tests, 382 browser checks and 0FAIL (was 366). The new coverage runs through the real
ChatSession, the realMessageProcessor, and a realChatViewon screen at both desktop and phonesize.
Control-tested both ways: with
src/stashed, 14 of the new unit tests fail andthe browser suite goes red on exactly the nine lines about the control and the
marker.
🤖 Generated with Claude Code