Skip to content

feat(chat): send a queued message now, without discarding the rest - #85

Open
dnviti wants to merge 1 commit into
chore/version-bump-5.3.2from
feat/issue-70-send-queued-now
Open

feat(chat): send a queued message now, without discarding the rest#85
dnviti wants to merge 1 commit into
chore/version-bump-5.3.2from
feat/issue-70-send-queued-now

Conversation

@dnviti

@dnviti dnviti commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Closes #70. Targets chore/version-bump-5.3.2, not main.

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. Its
body now lives in cancelTurnInFlight(), shared with the promotion path, which
does 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 marker event records the stop and names the message that caused it. It rides
on 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

  • Offered while the agent waits on a person? Yes. "No, not that file" is
    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.
  • Every waiting message, or only the newest? Every one. The row is where the
    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 exec declares interrupt: false. Cutting in there would not have
    ended 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.
  • On a phone the two controls sat 7px apart, under the 8px floor the rest of
    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 typecheck clean, 1608 unit tests, 382 browser checks and 0
FAIL (was 366). The new coverage runs through the real ChatSession, the real
MessageProcessor, and a real ChatView on screen at both desktop and phone
size.

Control-tested both ways: with src/ stashed, 14 of the new unit tests fail and
the browser suite goes red on exactly the nine lines about the control and the
marker.

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings July 27, 2026 16:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_now websocket message wired through client controller → server message processor → chat manager → ChatSession.sendQueuedNow().
  • Adds an interrupted transcript 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;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants