feat(chat): steer a queued follow-up into the running turn - #386
Conversation
A message sent while a turn is streaming lands in the queue and waits for that turn to finish. Each queued chip now also offers a one-click action that promotes the item into the response that is already running, so the user does not have to interrupt the turn or wait it out. - store: `submitIntent: "steer"` is now the one dispatch of a queued item allowed during a live turn; on success only that item leaves the queue and the composer draft is left untouched. Every failure path returns before mutating, so the item stays queued. - store: extract the post-steer draft resolution into `applySteeredPromptDraft` so `app.store.ts` stays under its ratchet. - ui: `onSteerQueuedTurn` / `canSteerQueuedTurn` on `PromptInput`, gated on the mid-turn steering setting, provider support, a live (non-stalled) turn, and a store-backed item with no attachments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c9d16078c8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| }), | ||
| } | ||
| : promptDraftByTask; | ||
| const nextPromptDraftByTask = applySteeredPromptDraft({ |
There was a problem hiding this comment.
Reserve queued prompts before awaiting steer delivery
When the steer acknowledgment is delayed until after the active turn completes, the queued item remains visible to createQueuedTaskTurnDispatcher, which automatically starts it as a fresh turn; this later mutation only removes it after the steer reports success, so an accepted steer can execute the same prompt twice and append its transcript entry after the new turn has begun. The same duplication risk is unavoidable when delivery returns unknown, because the provider may have accepted the steer but the item is never removed before automatic dispatch. Mark the item in flight or otherwise exclude it from queue draining before awaiting delivery.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid — fixed in 00204c9.
Confirmed the window is real and wide: submitSteerWithDeadline waits up to RENDERER_STEER_ACK_TIMEOUT_MS (32s), and turn completion calls dispatchNextQueuedTaskTurn, which took the queue head unconditionally.
Added createSteerQueueReservations (src/store/steer-queue-reservations.ts). The item is reserved before the await and the reservation is what resolves it:
- in-flight — no path may dispatch it (the dispatcher skips it, and
sendUserMessagereturnsblockedfor a manual send-now or a second steer of the same item). - accepted — released; the caller drops it from the queue in the same tick as before.
- rejected — released, so it goes back to waiting for its normal turn.
- unknown — kept out of automatic dispatch only. A duplicate run is worse than a prompt the user re-sends deliberately, and they already got the "delivery unconfirmed" toast, so manual send/re-steer stays open.
Holds are in-memory and keyed by the item UUID, so a reload drops them with the turn they were ambiguous about and a stale key can never match a different item.
Covered by tests/steer-queue-reservations.test.ts, a dispatcher case in tests/queued-task-turn-dispatch.test.ts (reserved head skipped, next unreserved item dispatched), and a store-level case in tests/bridge-persistence-regression.test.ts.
| const canSteerQueuedTurns = | ||
| midTurnSteeringEnabled && | ||
| providerSupportsMidTurnSteering({ providerId: args.activeProvider }) && | ||
| args.isTurnActive && | ||
| providerTurnDisplayState !== "stalled"; |
There was a problem hiding this comment.
Check the running provider before offering queue steering
When the user changes the enabled model/provider selector while a turn is running, args.activeProvider becomes the newly selected task provider rather than the provider serving the active turn. Consequently, switching away from a steer-capable running provider hides these new actions, while switching toward one exposes actions that resolveMidTurnSteeringContext rejects after the click. Derive this capability from the matching providerTurnActivity.providerId (with the same history fallback used by the store) instead.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Right in principle — fixed in 00204c9, though it is latent today: both descriptors set supportsMidTurnSteering: true, so no provider switch can currently make the UI and resolveMidTurnSteeringContext disagree. It would start biting silently the moment a provider turns the capability off, so worth closing now.
Extracted the store's own resolution into resolveActiveTurnProviderId (activity snapshot matching activeTurnId, else the getRespondingProviderId history fallback) and used it in both places, so the chip affordance and the dispatch gate cannot drift.
Left canSteerActiveTurn (composer Enter/Tab) on the selector: that is pre-existing behaviour and this PR deliberately does not touch the Enter/Tab affordances. Happy to follow up separately if you want them unified.
|
| Commit | Scanned at | New | Resolved | Net |
|---|---|---|---|---|
c9d1607 |
2026-08-21 11:36 UTC | 0 | 0 | 0 |
00204c9 < |
2026-08-21 12:24 UTC | 0 | 0 | 0 |
Last scanned: 00204c9 · 2026-08-21 12:24 UTC
|
| Commit | Scanned at | New | Resolved | Net |
|---|---|---|---|---|
c9d1607 |
2026-08-21 11:36 UTC | 0 | 0 | 0 |
00204c9 < |
2026-08-21 12:24 UTC | 0 | 0 | 0 |
Last scanned: 00204c9 · 2026-08-21 12:24 UTC
Steering a staged queue item awaits the provider acknowledgement for up to RENDERER_STEER_ACK_TIMEOUT_MS. The running turn can finish inside that window, and turn completion drains the queue — so the very item being steered could be started as a fresh turn while the provider was still accepting it into the old one, running the prompt twice. Reserve the item before the await: nothing may dispatch it while delivery is undecided, an accepted steer drops it as before, a rejected steer returns it to the queue, and an unconfirmed one stays queued but out of automatic dispatch so the user decides whether to send it again. Also derive queue-steer eligibility from the provider serving the running turn instead of the model selector, which the user can switch mid-turn without retargeting that turn. Extracted for the app.store.ts max-lines ratchet: buildFailedSteerResult, plus resolveActiveTurnProviderId now shared by the store gate and the chip affordance so the two cannot drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
A message sent while a turn is streaming lands in the queue and waits for that turn to finish. Each queued chip now also offers a one-click action that promotes the item into the response that is already running, so the user no longer has to interrupt the turn or wait it out.
Changes
Features
submitIntent: "steer"is now the single exception to the "a queued item cannot be dispatched during a live turn" rule. On success only that item leaves the queue and the composer draft is left exactly as typed; every failure path (steer-unavailable,steer-delivery-unknown,blocked) returns before mutating, so the item stays queued and still auto-dispatches when the turn ends.Refactors / Chores
applySteeredPromptDraftinprompt-draft-send.ts, keepingapp.store.tsunder its max-lines ratchet.Test Evidence
bun run typecheck— cleanbun run check:max-lines-ratchet/check:doc-paths/check:switch-exhaustiveness/check:reliability-gates— all passbun test tests/custom-theme.test.ts tests/prompt-input-queue-mode.test.tsx tests/prompt-draft-send.test.ts tests/bridge-persistence-regression.test.ts tests/provider-steer-delivery.test.ts tests/chat-state-helpers.test.ts tests/queued-task-turn-dispatch.test.ts— 145 pass, 0 failbun test: 3849 pass / 12 fail, byte-identical to the same 12 pre-existing failures onorigin/mainin this environment (provider executable resolution, secret env, untracked-diff timeout — all cross-file pollution unrelated to this change; each passes in isolation).New coverage:
tests/prompt-draft-send.test.ts—applySteeredPromptDraftdrops only the steered item, preserves the composer, and never clobbers a newer draft written while the steer was in flight.tests/bridge-persistence-regression.test.ts— end-to-end store flow: a queued dispatch without the steer intent is stillblocked; with it the item is steered, leaves the queue alone apart from itself, and a rejected steer puts nothing at risk.tests/prompt-input-queue-mode.test.tsx— the chip action renders per eligible item and stays hidden for an item with attachments or a non-steerable turn.Notes
UI change uses existing semantic tokens only (
text-muted-foreground,hover:text-primary); no new theme token or built-in theme update was required.