Chat: queue prompts with Cmd/Ctrl+Enter and drag to reorder the queue - #8952
Conversation
Cmd/Ctrl+Enter now queues the composer text whether or not a response is running, so prompts can be stacked up front. Plain Enter is unchanged. Queued rows are draggable, with ArrowUp/ArrowDown on the row handle as the keyboard equivalent. Only pending rows move, so a drag cannot race the dispatcher.
Rows preventDefault'd every drop, including file drops, and the page dropzone skips events already prevented, so a file dropped on the queue was silently lost. Rows now claim only their own private drag type. Cmd/Ctrl+Shift+Enter queued instead of inserting a newline, since the chord was matched before the plain-Enter branch that excludes Shift. Shift now disqualifies it.
Starting a queue awaits settings hydration before the queue store learns about it, and nothing else marked the thread as queueing during that gap. Cmd/Ctrl+Enter on an idle thread therefore left a window where a plain Enter or a Send click took the normal send path with the same text still in the composer. The pending queue then started, saw that response running, and dispatched its own copy, so the prompt went twice. The reservation now records its thread, and a live one counts towards livePromptQueueActive. A second submit in that window joins the queue instead: an identical prompt dedupes on the existing reservation key, and an edited one queues behind the first.
The plain-text path now marks its queue start synchronously, but the pasted-text path still called startHydratedPromptQueue only after File.text() resolved. During that read nothing marked the thread as queueing, so a plain Enter or a Send click took the normal send path and the read then queued the same text again. Record the intent before the read and drop it when the read settles, so a submit during it joins the queue instead.
|
Correct, and it is the same window the previous fix closed on the plain-text path, left open on this one. Fixed in 8a415a0. The intent is recorded in a small pending list before the read begins and dropped when it settles, in a The entry is pushed after the early returns, so a composer with no pasted-text attachment never registers anything. Tracking is per thread for the same reason as the reservation map: the composer survives thread switches, so another thread's in-flight read must not make this one look busy. Tests cover concurrent reads on two threads and one finishing while the other stays counted. Suite is at 2726, |
A file read that precedes a queue start sat outside every invalidation path: Clear all cancelled the reservations and advanced the history boundary, but the read carried on and queued its prompt afterwards, recreating the chat. The read now registers with the same cancel and boundary mechanism the reservations use. Recording the read as an active queue also routed a second submit into the queue branch, which started another read of the same attachment and queued a duplicate once the first reservation had been released. Reads are now keyed by thread, text and attachments, so the second submit joins the read already in flight.
|
Both new points are right, and both are fixed in 2a220ce. Cancel pending paste reads when clearing queues. Correct, and the gap was wider than the The read now registers the same way a reservation does. It carries Avoid starting another read for an already-pending paste. Correct, and it is the cost of the previous fix. Recording the read as an active queue is what stops a second submit taking the send path, but it routes that submit into the queue branch, which called Reads are now keyed and held in a map, so a submit during a read joins the one already in flight and reports handled rather than starting another. One deliberate choice there: the wait mode is not part of the key. It is recomputed per submit, and if a run starts or ends mid-read the two submits compute different values, which would split one prompt across two keys and bring the duplicate straight back. What identifies the prompt is the thread, the text and the attachments it is assembled from, so those are the key and the first read's wait mode wins.
Scope worth stating: the key is unit tested, but the cancel wiring is verified by typecheck and inspection rather than by driving the real composer, since reproducing the window needs the full chat UI with a Clear all landing inside a |
The reservation took its settings epoch and incognito flag after the file read, so a setting changed during the read became the baseline instead of aborting the queue, unlike every other pending start. The read now captures all of them up front and the reservation uses those.
|
Right, and it is a gap I introduced with the previous commit rather than one that was already there. That commit captured the history clear boundary and the local model boundary before the read and threaded the latter into the reservation, but left the settings epoch and the incognito flag to be taken afterwards. So a queue-relevant setting changed during the read became the reservation's own baseline, and Fixed in 05ba402. The read now captures every baseline the reservation would otherwise take: the local model boundary generation, the settings epoch, the incognito flag and the history clear generation. The optional argument on Suite is at 2730, |
A read whose captured settings had already gone stale still absorbed the next submit as a duplicate, and then aborted itself, so neither gesture queued anything. The dedupe now only absorbs a read that is still going to start; a stale one is replaced, and it drops out because it no longer owns its key. An abort also says so rather than being silent.
|
Right, and it is the cost of the dedupe from the previous commit: the read absorbed the retry as a duplicate and then aborted itself, so the two gestures cancelled each other out and nothing queued. Fixed in 90fc433. The dedupe now only absorbs a read that is still going to start. The abort path also no longer passes Suite is at 2730, |
The pasted-text read had this fixed; the reservation itself did not. A retry with the same prompt hit the duplicate check, the original then aborted on its stale settings epoch, and neither gesture queued. The staleness predicate now covers both, and a reservation that is already invalid is replaced rather than turning the retry away. A superseded start no longer reports an abort, since the one that replaced it is still going.
|
Right, and it is the same defect one level up: the previous commit fixed it for the pasted-text read and left the reservation itself turning retries away. Fixed in ba230a0. The model boundary check deliberately stays out of the predicate. It needs One thing that fell out of this: a superseded reservation used to run Suite is at 2730, |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ba230a0838
ℹ️ 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".
| const chatState = useChatRuntimeStore.getState(); | ||
| return shouldAbortPendingQueueForSettingsChange({ | ||
| capturedEpoch: pending.queuedSettingsEpoch, | ||
| currentEpoch: chatState.queuedSettingsEpoch, | ||
| capturedTemporary: pending.temporary, | ||
| currentTemporary: chatState.incognito, | ||
| }); |
There was a problem hiding this comment.
Permit retries after a local-model boundary change
When a model load or unload advances localPromptQueueModelBoundary while createPromptQueueTarget() or a pasted-text read is pending, this predicate can still classify the reservation as fresh because it checks only the settings/history state. A subsequent Enter is therefore consumed as a duplicate, while the original reservation later fails modelBoundaryInvalidated, leaving the prompt unsent. This occurs when the boundary advances before queuedSettingsEpoch changes; the retry path needs to recognize that invalidated local reservation or otherwise avoid absorbing the newer submission.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
The analysis is right and the fix as asked is not, so this one stays as it is. Adding the model boundary to pendingQueueStartIsStale would make that predicate stricter than the reservation's own check: the predicate cannot know usesLocalModel, which is only known once createPromptQueueTarget resolves, so on an external-model target it would call a reservation stale that is still going to start, and the retry would open a second queue for the same prompt. Trading a transient no-op for a duplicate send is the wrong direction. The residual behaviour is bounded: the composer is only cleared in onStarted, so the text is still there and the next press queues normally once the reservation has resolved and left the map.
|
Shot this from two isolated installs, BEFORE at the merge base One flow covers both halves of the PR. Four prompts are submitted into an idle thread with Ctrl+Enter, then the bottom queued row is walked to the top with ArrowUp on its handle. Model is Both halves are holding the same three rows before anything moves, alpha / beta / gamma, which is the control. After the same two ArrowUp presses the base still reads alpha, beta, gamma and the head reads gamma, alpha, beta.
The header is where the chord shows up, and it is worth saying why it is a number rather than a picture. Two things I got wrong first, in case they are useful for the next one of these. Photographing the chord on its own does not work. My first plan gave it a pair of its own with the model unloaded, so the thread would stay idle throughout. A send with nothing loaded makes Studio auto-load the 270M default, and that load itself reads as a run, so the two sides queue identically. The queue drains at the speed of the model, and the first run photographed the base holding three rows against the head holding two, purely because the head's opening prompt finished sooner. Two different queues, and it reads as a difference this PR made. The scene now asserts the settled rows are the three expected prompts and retries the flow otherwise. With the 270M default the whole stack is gone before any shot lands, on either side. |
One conflict, in the composer's hook block: main added the pastedTextMinChars preference read (#8963) and this branch added the Cmd/Ctrl+Enter ref and its handler at the same spot. Both are kept. Nothing else in thread.tsx conflicted, and the paste threshold does not change what the queue sees: a long paste still arrives as an attachment that isPastedTextFile recognises, which is what canQueuePastedTextPrompt gates on.
`npm test` runs under node --experimental-strip-types, whose ESM loader does not resolve an extensionless specifier, so every test that reaches the preferences store died on ERR_MODULE_NOT_FOUND. It fails the same way on main at 2cf7a28, so it is not something this branch introduced, but it takes the branch's own suite down with it. Vite resolves it either way; the rest of the files on the test path already carry the extension.
Two hardening fixes, both found by testing the chord across engines
rather than by a failure in the wild.
Windows reports AltGr as Ctrl+Alt on any layout the browser does not
recognise as carrying a real AltGraph modifier, which the UI Events spec
allows for explicitly ("some operating systems simulate the AltGraph
modifier key with the combination of the Alt and Control modifier keys").
On those layouts AltGr is held for everyday characters, so the chord
would fire on a keypress meant as something else. Alt now disqualifies
it, the way Shift already does. There is no Ctrl+Alt+Enter binding here
worth keeping.
The chord also armed its ref before knowing there was a submit to reach.
`event.currentTarget.form?.requestSubmit()` is a no-op when the textarea
sits outside a form, and requestSubmit is missing entirely on Safari
before 16, so in both cases the ref stayed armed and the NEXT submit --
an ordinary Enter, possibly minutes later -- queued instead of sending.
It is now armed only when the call is actually going to happen, and
disarmed if it throws.
Tests: an edge file for the predicates (numpad Enter, every platform's
modifier combination, AltGr, DOMStringList types, a Map's one-shot
values iterator, JSON-lookalike prompt text, NFC against NFD), and an
exhaustive sweep of the reorder math over every queue length, from/to
pair and active slot up to seven items -- permutation preserved, the
spent head never moves, the range check and the reorder never disagree,
a move and its reverse restore the queue, the input is never mutated,
and the dispatch-changed flag agrees with the slot it reports on.
Suite 2900 passing, tsc -b clean, and eslint on thread.tsx reports the
same 50 pre-existing errors as it did before the change.
|
Went through this one properly before merging: what changes for a user, whether anything else moves, and how it behaves outside Chrome on Linux. Three commits pushed to the branch, Before and afterBefore, queueing was only ever a side effect. After, Cmd/Ctrl+Enter queues whether or not something is running, and a queued row moves by drag or by ArrowUp/ArrowDown on its handle. The gap is real rather than cosmetic, and the measurement that shows it is the queue's own header, since it counts the item already dispatched. Four prompts submitted in quick succession into an idle thread:
Does it break anythingNo, and the parts that must not move were measured against the merge base rather than asserted. Same three engines, same script:
There is no hardware path to worry about here. The diff is five files, all under Old installs are fine for the same reason, and I checked rather than reasoned: I installed this branch in place over an existing merge-base install carrying real chats. The old password still logs in, so Cross-browserDriven against a real built Studio in Chromium 151, Firefox 153 and WebKit 26.5, ten checks each, thirty of thirty passing and no difference between the engines: both chords, the two newline combinations, plain Enter, auto-repeat, a drag driven through a real Two of those are worth calling out. The custom drag type survives everywhere. All three report The keyboard reorder keeps focus. This is the one I expected to fail: the HTML spec blanks the document's focused area when a focused node is removed, and moving a keyed row is a remove plus an insert. Measured in all three engines, in both directions, the handle stays focused and the second ArrowUp lands. Worth knowing it holds by React's choice of which row to move rather than by guarantee, so it is a thing to re-check if the stack's markup changes. What I changedAltGr no longer fires the chord. Windows reports AltGr as Ctrl+Alt on any layout the browser does not recognise as carrying a real AltGraph modifier, which the UI Events spec allows for explicitly. On those layouts AltGr is held for everyday characters, so the chord could fire on a keypress meant as something else. Alt now disqualifies it, the way Shift already did. The control row above is that fix: Ctrl+Alt+Enter now behaves exactly as it does on the merge base. The chord no longer arms itself when there is nothing to submit. The branch is merged with main. One conflict, in the composer's hook block, where main added the One thing that was not this branch's fault. Tests addedAn edge file for the predicates and an exhaustive sweep of the reorder math. The sweep runs every queue length up to seven against every from/to pair and every active slot, and asserts the invariants a queue engine depends on: the result is always a permutation with nothing lost or duplicated, nothing at or before the dispatching slot ever moves, the range check and the reorder never disagree, a move and its reverse restore the queue, the caller's array is never mutated, a frozen array works, and the dispatch-changed flag agrees with the slot it reports on. The reorder math came through all of it unchanged, which is the main thing I wanted to know before trusting a drag to retarget a pending send. The edge file covers what the DOM hands us on platforms this box cannot run: numpad Enter, every platform's modifier combination, the AltGr shape, a Suite 2900 passing, Not coveredReal Safari, real Windows and real macOS. WebKit 26.5 under Playwright is the closest stand-in for Safari that a Linux box can run, and the Cmd and AltGr chords were synthesised as key events rather than typed on the hardware. The drags are real |
A chord pressed while a stored chat's own settings are still loading was read and cleared at the top of handleSubmit, then parked by the threadScopedSettingsPending branch as an ordinary pending send. The release effect calls sendReservedComposer directly rather than returning through handleSubmit, so the prompt went out as a plain send and the one shortcut whose whole point is stacking silently did nothing. The intent now rides with the parked send and the release queues it. Three details worth stating. queueComposerText is hoisted out of handleSubmit, because the release effect cannot reach into that closure. It reads the live composer rather than the rendered text: at release it runs from an effect, where the rendered copy can be a commit behind. The wait mode is recomputed at release rather than carried from the parked submit. A run can start while the settings load, and a queue that ignored it would dispatch on top of the response already streaming. The parked intent is cleared by cancelQueuedSend and on unmount, next to pendingSendRef, so a cancelled or abandoned park cannot leave it armed for a later send. Suite 2900 passing, tsc -b clean, and eslint on thread.tsx reports the same 50 pre-existing errors as before the change.
Two from the same round, both on the pasted-text branch. A read that resolves after the composer moved to another chat could queue into that chat. The queue target is anchored where createPromptQueueTarget is CALLED, and on this path that is after File.text() rather than before it, so a switch mid-read leaves the target aiming at the chat now on screen: initialRunningThreadIds is built from the live thread list item, the post-await identity check passes against it, and thread A's prompt dispatches into thread B. The read now records which chat the composer was showing when it began and drops itself if that has changed, the same composerIdentityRef check the dictation send already uses. Releasing a parked chord always queued the composer text, so a pasted attachment was left behind: an attachment-only prompt queued nothing at all, and one with both queued only the typed half. The release now takes the same two branches handleSubmit takes, in the same order, and falls through to an ordinary send when neither can queue, which is what this path did before the intent was carried at all. Suite 2900 passing, tsc -b clean, eslint on thread.tsx back to the same 50 pre-existing errors.
|
Codex Review: Didn't find any major issues. 🎉 Reviewed commit: ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
Post-convergence comment pass over the files this PR touches. Comments only: the reasoning each one carries is kept, the words around it are not. Nothing pre-existing in thread.tsx was rewritten, so the diff stays readable as this PR's own work. comment_tools.py check reports 4/4 code-unchanged, suite 2900 passing.
The body is recorded beside the File when the paste is attached, so the queue can stack it in the gesture itself instead of a task later. A gesture that awaited landed behind any later one that did not, putting the two prompts in the queue the wrong way round.
|
Codex Review: Didn't find any major issues. Delightful! Reviewed commit: ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
main's composer send guard (#8849) and this branch both edit the composer keydown path and every site that clears the composer after a queue. Both kept: the chord and the guard are passed to the same input hook, and the two hoisted queue helpers arm the guard where main armed it inline.
Both new modules were reached by path, which the import rule refuses and which was the only lint finding this branch added on top of main.
One keydown handler now asks both predicates, so no key may be claimed by both, and the two AltGr rules have to agree.
|
Codex Review: Didn't find any major issues. Delightful! Reviewed commit: ℹ️ About Codex in GitHubCodex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback". |
Comment-only.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
The literal NUL in this template string causes Git to classify the entire TypeScript test as binary (the commit is rendered as a GIT binary patch), so future changes cannot be reviewed or merged as normal line diffs. Use an escaped source representation such as \0 while preserving the same runtime test value.
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…9026) * Repair the prompt-queue contract test against the queueing refactor Repo tests (CPU) is red on main. test_composer_only_queues_behind_the _current_chat greps thread.tsx for code that #8952 moved or replaced, so it fails on any branch regardless of what that branch changes. It is currently red on #8935, #8964, #8980 and #8983 for this reason alone. The behaviour it guards is intact, and in two of the three cases the code that replaced it is stronger than what the test still asserted, so the assertions are repointed rather than dropped: - Queueing moved out of handleSubmit into an extracted queueComposerText, so the Cmd/Ctrl+Enter path could share it. Assert the delegation in handleSubmit and the queueing inside queueComposerText, which keeps this a contract on behaviour rather than on where the code sits. - promptQueueStartPendingRef.current.has(reservationKey) became a .get(reservationKey) === identity comparison. A reservation can be replaced between the start and the callback, and acting on the successor would dispatch the wrong prompt; presence alone never caught that. - temporary: useChatRuntimeStore.getState().incognito became temporary: incognitoAtQueueStart, captured when the queue starts instead of read live at dispatch. A chat toggled out of temporary mid-queue must not have its queued prompts persisted. Each rewritten assertion was checked against a deliberately broken tree rather than assumed to discriminate. Removing the delegation, swapping the identity check back to presence, reading temporary live again, and stopping queueComposerText from queueing each fail the file. tests/studio 11 passed for this file, whole directory green. * Read the identity check out of the dispatch guard, not the whole file Reported on this PR and correct. The assertion searched thread.tsx for promptQueueStartPendingRef.current.get(reservationKey) === anywhere, and the abort and cleanup branches beside the dispatch carry the same comparison. The dispatch guard could regress to .has(reservationKey) on its own, which is exactly the bug this assertion exists to catch, and the test would still pass on its neighbours. It now slices the if condition that guards the startPromptQueue call and asserts the identity comparison there, with .has excluded from that guard. All three comparisons are still required by count, because the other two are load-bearing too: abort without it reports the successor's start as this one's failure, and cleanup without it deletes the successor's entry. 11 passed. Rewriting the dispatch guard to .has fails it; it passed before. --------- Co-authored-by: danielhanchen <unslothshared@gmail.com>


The problem
Two gaps in the prompt queue.
You cannot queue on purpose. Queueing only happens as a side effect of submitting while something is already running:
handleSubmitchecksliveThreadIsRunning || livePromptQueueActive || livePreStreamRunActiveand queues in that branch alone. With an idle thread there is no way to stack prompts up front, and no key that means "queue this".The queue order is fixed. Rows can be edited and removed, but not moved, so a prompt lined up in the wrong order has to be deleted and retyped.
The change
Cmd/Ctrl+Enter queues. It queues whether or not a response is running. With nothing running the queue dispatches it immediately, so it reads like a send, and the next Cmd/Ctrl+Enter lands behind it. Plain Enter is untouched, and so is the existing behaviour of Enter queueing while a run is active.
The chord is claimed in the IME key handler ahead of the plain-Enter submit, since Cmd+Enter carries no Shift and would otherwise fall through to a normal send. It sets a ref that
handleSubmitreads once per submit, so a rejected send cannot leave it armed for the next one. Long pasted text that the composer parked in a chip queues through the same path it already used.Queued rows drag to reorder. The row is the drag source, the handle carries the affordance, and dropping on a row moves the dragged prompt into that slot. ArrowUp and ArrowDown on the handle do the same thing for the keyboard, since HTML5 drag events never fire for keys.
Decisions worth flagging
Only pending rows move. A dispatched item is already on its way out, and a run mid-dispatch is refused outright, so a drag can never race the pump. Nothing may move to or from a slot before the one about to send.
A move across the active slot retargets the pending send.
run.indexis positional, so reordering can change which item sits in the active slot. That case clears the retry timer and reschedules, exactly asremovePromptQueueItemalready does for the same reason. A stale scheduled dispatch is harmless on its own, sinceisActivePromptQueueItemre-checks identity, but leaving the reschedule out would stall the queue when the new occupant has no timer of its own.The drag payload is
text/plain, deliberately notFiles. The page-wide dropzone inThreadgates every handler onhasFiles(e), so a row drag never lights up the file-drop affordance.The index math lives in
features/chat/utils/prompt-queue-reorder.ts, next to the other pure queue helpers, rather than inside the 6k-linethread.tsxwhere nothing is exported and nothing can be tested.Testing
New
tests/prompt-queue-reorder.test.ts, 11 cases: downward drags landing after the target and upward drags landing before it, adjacent swaps in both directions, no items lost and the input left unmutated, self-moves refused, the active-slot boundary enforced from both sides, out-of-range and non-integer indices, and the dispatch-target-changed signal in the three cases that matter including a run that has not dispatched yet.One case sweeps every from/to pair and asserts the range check agrees with the reorder it guards, so the two cannot drift apart.
Full frontend suite passes at 2713, typecheck is clean, and the production build succeeds.
Not covered
Reordering across two different queue runs is refused. Runs are per target chat, so a cross-run drag would mean moving a prompt to another chat, which the stack has no affordance for.