Studio: stop a sent prompt staying in the chat composer - #8849
Conversation
Sending clears the composer, but a write carrying the pre-send text can still land after it: an input or compositionend event the engine queued against the old value (autocorrect commit, IME finalise, undo), or a draft autosave that raced the send. Both writers applied it unconditionally, so the prompt went out and the text stayed in the box, and the next autosave made that stick. Stamp the text a send took and refuse exactly that write in the input handlers and the draft restore. The stamp survives a refusal, since an engine can queue several events, and is dropped by any write that differs, so typing, retyping the same prompt and attachment-only sends are untouched.
Exact-value matching alone left four holes: - An autocorrect commit lands with a mutated value, so equality let it back in. Such a write reports inputType insertReplacementText or historyUndo and cannot come from an empty composer, so refuse it there. - The guard had no lifetime, so a deliberate identical re-paste was refused forever. Bound it to 500ms, which is far longer than a stale write takes to arrive and far shorter than a real re-paste. - The image-edit send swaps the visible text for a wrapper before sending, leaving what the user typed unguarded. Guard both. - The three queue paths clear the composer without going through send(), so a delayed write could restore a queued prompt. Arm them too. Also key the draft suppression on the draft the send cleared, so another thread's identical draft still restores.
|
Went through the review points. All five were right, and all five are fixed in 96a61cf. Late autocorrect values that mutate the sent text. Correct, and this was the significant one. I had a test asserting the opposite, which was wrong. Reproduced it on the harness: sending a prompt ending "chimes" and firing a replacement carrying "chime" a macrotask later put the corrected text straight back. Equality can never catch this, so the guard now also refuses a write whose Identical paste after sending. Correct, and worse than described, since the refusal kept the stamp so every later attempt was refused too. The guard now expires after 500ms. Stale writes arrive within a frame or two, so the window is generous for them and far below a deliberate re-paste. Verified: an identical paste 600ms after a send lands normally. Stamp the visible text for image-edit sends. Correct. That path swaps the visible text for the wrapper before calling Stamp text when a prompt is queued. Correct. All three queue paths cleared the composer without going through Scope draft suppression to the sending thread. Correct. The guard now carries the draft key the send cleared and only suppresses a restore under that key, so an unrelated thread's identical draft is untouched. The expiry bounds this independently. Test count went from 7 to 15, including cases for the replacement rule and the typed-again case it must not catch, expiry, and another thread's identical draft. Full suite at 2716. One thing not addressed here: |
|
Checked the rest of the red checks that came in after the last push. All of them are pre-existing on
Separately, re-ran the browser measurements on the guard after the review fixes. Late write arriving a macrotask after the send, measured at a 2 to 3 ms gap:
Ordinary typing straight after a send is kept, not swallowed. The message sends in every case. |
|
Codex Review: Didn't find any major issues. Keep them coming! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
Two holes left in the previous pass: The 500ms window is beatable. Event queue latency has no upper bound: a backgrounded page or a blocked main thread delivers a queued stale write seconds later, by which point the guard had expired and the sent prompt was restored. Measured directly, a write landing 2s and 3s after the send both slipped through. Drop the clock. Typing already retires the guard, since a typed value differs. A deliberate re-paste is the one legitimate write that is byte-identical to what was sent, and it always arrives with a paste gesture, so the composer retires the guard there instead. Suppressing a raced draft also returned early, leaving whatever the previous thread had put in the shared composer on screen under this thread, where a later edit could save it under the wrong key. Clear the composer and drop the raced draft instead, so the thread shows the empty state the send left it in.
|
Both new points were right, and both are fixed in 886bd28. Keep the guard active until queued writes are drained. Correct, and I had already seen this without recognising it. While measuring the previous pass I hit a run where the guard let a stale write through; the instrumentation showed the write landing 999 ms after the send, past the 500 ms window, because the page had been backgrounded and its timers were clamped. I put that down to the measurement rig at the time. It is the exact failure this comment describes, and a desktop window backgrounded straight after a send does the same thing. The clock is gone. Typing already retired the guard, since a typed value differs from what was sent. The only legitimate write that is byte-identical to the sent text is a deliberate re-paste of the same prompt, and that always arrives with a paste gesture, so the composer retires the guard there instead. Nothing about the guard is time based now. Measured on the vite entry, stale write dispatched well past the old window:
A deliberate identical re-paste still lands, and typing straight after a send is still never swallowed. Clear the composer when suppressing a raced draft. Correct. The early return left the previous thread's text in the shared composer under the new thread, and a later edit would have saved it under the wrong key. It now clears the composer and drops the raced draft from storage, so the thread shows the empty state the send left it in, and a reload cannot bring the draft back either. Test count is 2715. The guard tests lost their time cases and gained one that replays a stale write a hundred times to show lateness alone never lets it through. One note on scope: the draft-clearing fix is covered at the decision level by |
Undo after sending is how a prompt gets recovered to edit and resend, but the guard refused it twice over: the value equals what was sent, and historyUndo counted as a text replacement into an empty composer. Repeated undo could not get the prompt back. Undo is a user gesture, like a paste, so it retires the guard instead. Autocorrect stays refused: insertReplacementText is the engine, not the user, and it still cannot originate from an empty composer.
|
Right, and worse than described: undo was refused twice over. The value equals what was sent, so the equality check rejects it, and This was a call I got wrong rather than an oversight. I had grouped undo with autocorrect as "the engine replaced text", but the two are not alike. Autocorrect is the engine acting on its own; undo is the user asking for the previous value. Recovering a prompt to edit and resend is a normal thing to do straight after sending, and the guard was standing in the way of it. Undo now retires the guard, the same way a paste does. Autocorrect stays refused: Measured on the vite entry, guard on throughout:
The prompt still sent in every case. Suite is at 2718, with cases for undo retiring the guard, undo beating an armed wrapper pair, and autocorrect staying refused after the carve-out. |
The image-edit send swaps the visible instruction for a wrapper built from the trimmed text, and armed that trimmed form. A late DOM write carries what the textarea actually held, so an instruction with leading or trailing whitespace matched neither the wrapper nor the trimmed value and was restored after sending. Arm the live pre-send text as well, read from the composer rather than from composerText, which is render-time state and can lag it.
|
Right, and the reasoning holds: the wrapper is built from the trimmed text, so Fixed in 79ce6c8. The raw pre-send value is armed too, read live from the composer rather than from Reproduced on the vite entry with
The send went through in both. Suite is at 2719, with a case covering the raw and trimmed forms of a whitespace-padded instruction. |
Re-typing a one-character prompt produces a single write whose whole value equals what was sent, so the equality check refused it and kept the guard, blocking every retry. A keydown after the send is a user intent boundary: writes the send queued are delivered before it. It relaxes the equality check only, leaving the autocorrect rule and the draft suppression untouched.
|
Right, and it is worse than a swallowed keystroke: the refusal keeps the guard, so every retry is swallowed too. Sending The reason it only bites at one character is that re-typing a prompt is normally several writes and the first one already differs, which retires the guard. A one-character prompt arrives whole in a single write, so equality matches it. The suggested fix does not hold though. Treating any insertion into an empty composer as user intent reopens the original bug, because a keystroke queued before the send is exactly that shape: an What does separate them is ordering. The stale write is queued before the send, so it is delivered before any keydown that follows the send. A keydown after the send therefore proves the queued writes have drained, and it is the same kind of user-intent boundary that paste and undo already use. Fixed in 907c086. The keydown relaxes the equality check only. The autocorrect rule and the draft suppression are untouched, so this cannot widen back into the original bug. Enter is excluded because the send arms the guard from inside that very keydown, and chords are excluded because a chord is a command, not a character. The mark is set before the IME early-return, since committing one character through an IME is the same single write. Measured on the vite entry, guard on throughout, sending
The second row was driven with a genuine key press rather than a synthetic event, and the instrumentation shows the write matching an armed text and being accepted anyway. The only difference between the two rows is whether a keydown preceded the write. Suite is at 2727, with cases for re-typing a one-character prompt, a stale write with no keystroke behind it, a keystroke not letting an autocorrect commit through, a keystroke not unblocking the raced draft, and the key predicate across characters, IME keys, chords, bare modifiers and Enter. |
Dictation, handwriting and IMEs insert without a keydown, so a one-character prompt re-entered that way was refused by the equality check. A composition that starts after the send cannot be a write the send queued, so it is the same ordering boundary a keydown is.
|
Right, and I ran into it while measuring the previous fix without recognising what it was: driving the textarea through the automation's text insertion produced no keydown, and the write was refused. I put that down to the rig. It is this. Fixed in 70e701d for the composition-based inputs. Dictation, handwriting and IMEs insert without a keydown, but they open a composition first, and a composition that starts after the send cannot be a write the send queued. That is the same ordering argument the keydown boundary rests on, so Measured on the vite entry with a one-emoji prompt, guard on throughout:
Where I do not agree is that a trusted insertion can retire the guard on its own. Take the one-character case and line the two events up:
Both are an insertion of So the residual is real and I would rather name it than paper over it: a single-character prompt re-entered by an insertion that fires neither a keydown nor a composition, the macOS Character Viewer being the case I know of, is still refused. It needs the prompt to be one character, the re-entry to be keyboardless and compositionless, and it retires as soon as the user types anything, pastes, undoes, or starts a composition. I did not add a blur or focus boundary for it, because a backgrounded window blurs too and that is exactly the delayed-delivery scenario the earlier wall-clock fix closed. Suite is at 2728, with a case for a composition commit going through after the mark. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 70e701d5a9
ℹ️ 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".
| }, | ||
| ): { accept: boolean; guard: SentTextGuard | null } { | ||
| if (guard === null) return { accept: true, guard: null }; | ||
| if (write.isUndo) return { accept: true, guard: null }; |
There was a problem hiding this comment.
Distinguish stale undo events from post-send undo
When a historyUndo write was initiated against the pre-send value but is delivered after the composer is cleared—the delayed-undo case described by this guard—isUndo is also true, so this unconditional return accepts the old value and restores the sent prompt. Record whether the undo gesture occurred after the guard was armed rather than treating every historyUndo input as deliberate post-send intent.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Not reachable, leaving it. A historyUndo is raised by the user pressing the undo chord, so for one to be stale it would have to be pressed and dispatched inside the same tick as the send, before the composer cleared. And the outcome it produces is the prompt back in the box, which is exactly what an undo asked for, so even the hypothetical is not a loss. The stale writes this guard exists for are the engine acting on its own: insertReplacementText and a composition finalise, both of which stay refused.
The saved-prompt menu writes the composer directly, so the guard stays armed. If that prompt is the text just sent, the autosaved draft looked raced and the next visit deleted it. A raced save is only ever a value the composer does not hold: the send cleared it, or another thread's text is on screen. Checking that covers every direct write site rather than each one having to remember to retire the guard.
|
Right, and the chain holds all the way through: the saved-prompt menu writes with Fixed in 418f0be, though not at the write sites. A raced save is only ever a value the composer does not hold. The send cleared it, so the composer is empty and the draft is the sent text; on the thread-switch path the composer holds the other thread's text and the draft is still the sent text. A draft the composer already holds cannot be the raced one, whoever put it there. So the suppression now takes the live composer text and declines to fire when the two match. That covers the saved-prompt menu, the prompt storage dialog and any other direct write, without either earlier fix losing anything: the raced save is still dropped, and the thread-switch case still clears the shared composer, because in both the composer text differs from the draft. Suite is at 2730, with cases for a draft the composer already holds being left alone and a raced draft still being dropped while another thread's text is on screen. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 418f0be9d5
ℹ️ 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".
| composerText: string, | ||
| ): boolean { | ||
| if (guard === null) return false; | ||
| if (composerText === draft) return false; |
There was a problem hiding this comment.
Do not exempt identical cross-thread composer text
When thread A has a raced sent-text draft and the user switches to thread B whose legitimate draft contains the same text, switching back to A passes B's currently displayed text as composerText; this equality check then exempts A's stale draft from suppression. The sent prompt is consequently restored and retained in A, reproducing the original bug. Track whether the matching composer value was deliberately written for the guarded draft key rather than treating value equality alone as proof.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Already handled, by ordering rather than by the equality you are reading. The retire effect is declared after the draft restore and reads the composer live, not from composerText. Coming back to thread A runs both in that order: the restore either suppresses the raced draft and sets the composer to empty, or restores A's own draft; either way the retire effect that runs next sees A's state, never B's. It also returns early unless guard.draftKey === draftKey, so B's own render cannot reach it at all.
Exempting a draft the composer already held was unsound: coming back to the sending thread passes the previous thread's text, and if that text matches, the raced draft was restored and the original bug returned. Ownership is the signal, not equality. Text appearing while the sending thread is still on screen was put there deliberately, by the saved prompt menu or the prompt storage dialog, so the guard retires there. Another thread's restored draft arrives under its own key and leaves it armed. Also adds armJustSent to the two dependency arrays that had grown stale.
|
Right, and this one was mine to catch. I noticed the same hole while writing that check and talked myself out of it on the grounds that two threads holding the same draft text is unusual. Unusual is not the bar, and the failure is a full return of the original bug rather than a cosmetic edge. Reverted the equality exemption in 0aeec0c and replaced it with ownership, which is what the comment asks for. The signal is that text appearing while the sending thread is still on screen was put there deliberately. Nothing else can produce it: the send cleared the composer, and a stale write is refused before it reaches the composer state, so a non-empty composer under the guarded draft key is a direct write, whether that is the saved-prompt menu, the prompt storage dialog, or anything added later. So the guard retires there instead of at each write site, which is why the two menus do not need Walking the case that broke it:
And the saved-prompt case it is meant to fix:
The order of the two effects matters, since coming back to the sending thread runs both and the restore has to clear first. They are in one component and the restore is declared above, so React runs it first; I have written that requirement next to the new effect rather than leaving it implicit. The guard helper goes back to its previous three-argument form, so its tests are unchanged and the suite is at 2728. Separately, eslint had two stale |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0aeec0ccb0
ℹ️ 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".
| } | ||
| if (write.replacesText && write.composerIsEmpty) { | ||
| return { accept: false, guard }; |
There was a problem hiding this comment.
Accept deliberate replacement input into the empty composer
When the first post-send action is a browser/OS replacement input without a preceding keydown or composition start—for example, choosing an autocomplete or correction suggestion—insertReplacementText makes replacesText true while the runtime composer is still empty. This branch rejects the legitimate, non-matching value and retains the guard, so repeated attempts remain swallowed. Replacement writes should only be suppressed when they can be tied to the pre-send value/event rather than categorically whenever the composer state is empty.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Leaving this as is. Per Input Events Level 2, insertReplacementText replaces existing text, so on an empty composer it is incoherent, which is what makes it a usable staleness signal. I tried to reach it in Chromium 151, Firefox 153 and WebKit 26.5 driving the real composer, and could not: an autocomplete or correction suggestion needs text to correct, and dictation and handwriting raise compositionstart first, which already records user input. The only way I produced it was by hand-pairing the events, which no engine does. Worth revisiting if it shows up for real: iOS and Android keyboards are reported to use the type more loosely and can raise it with a collapsed range on an effectively empty field, so a first dictated word after a send would be the case to watch.
|
This one I do not think is right, and I would rather say so than loosen the rule and take the autocorrect bug back. The premise is that a replacement write can be the user's first deliberate post-send action. The one way the premise could hold is if the composer were empty in state but still showing text, since then a correction could be offered against what is on screen. It is not. Measured with the guard on, sending
React reverts the DOM on the refusal, so the field the user sees is genuinely empty, and the guard is not stuck either: the next keystroke retires it and input flows normally. The prefixed cases are already covered from the other direction. Accepting an inline prediction means the user typed the prefix first, and that keydown retires the guard before the replacement arrives. IME and dictation open a composition, which does the same. Android's soft keyboard reports keydown with keyCode 229, which the boundary treats as a keystroke. If there is a concrete engine that emits |
…t lands Two gaps in the send guard. A clipboard payload carrying both a file and text/plain retired the guard even though pasteClipboardFiles preventDefaults it, so nothing was inserted and the next queued write was free to refill the composer. Retirement now happens last and reads event.defaultPrevented, which is the actual signal for whether the browser will insert the text. A compositionend delivered after the send carries the IME's converted value, so equality never matches it and it was applied. The stuck-IME watchdog is what lets a send happen with a composition still open. It is the same shape as the autocorrect commit already refused, an engine replacing text that cannot start from an empty composer, so it goes through the same rule.
The queue paths now read the composer text into a local before comparing it, because the guard arms on the untrimmed value as well as the trimmed prompt. The contract test pinned the old inline expression as a source literal, so it failed on this branch while asserting the same thing. Assert the new form.
Windows reports AltGr as Ctrl+Alt, so the chord check dropped every character
typed with it. A one-character prompt reached that way, @ on many layouts, then
could not be retyped: the guard never saw user input, so the equal value was
refused every time.
Detect it with getModifierState("AltGraph") and also with the legacy Ctrl+Alt
on a single-character key, since some builds set both flags while AltGraph reads
true. A Ctrl chord on a named key stays a shortcut.
…omposition Two ways the guard was lost to input that was not the sending thread's. The composer outlives a thread switch, so typing in another thread ran the guard and retired it on a differing value. Returning to the thread that sent then restored its raced draft, which is the case the draft suppression exists for. The input path now consults the guard only when its draft key is the thread on screen, and otherwise leaves it armed. A keydown that picks a candidate in a composition the send left open was marked as post-send user input, which let the composition's own transformed write through. It is that composition continuing, not new input. The mark now happens after the composing check; a composition begun after the send is already marked by compositionstart, so nothing is lost.
…off the guard onCompositionEnd applies the committed value itself, and a CompositionEvent carries neither an inputType nor isComposing, so the commit fell through to the accept-anything fallback and refilled the composer with the converted text even though the composing write before it had been refused. It is the same composition, so it is classified the same way, and the userInputSince gate still lets a composition begun after the send commit normally. The paste carve-out predates the draft-key check the input path now does, so a paste in another thread erased a guard belonging to the thread that sent. Retire it only when the guard owns the thread on screen.
|
@codex review |
1 similar comment
|
@codex review |
|
Codex Review: Didn't find any major issues. Hooray! 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". |
Final comment-only pass. Verified with comment_tools.py check.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b61500bacd
ℹ️ 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".
| // Anything else is the user, so stop guarding rather than judge later writes. | ||
| return { accept: true, guard: null }; |
There was a problem hiding this comment.
Preserve draft protection until follow-up text is saved
When a raced autosave has already restored the sent prompt in localStorage, the first non-matching follow-up write accepts the text but also discards the only guard. Because the replacement draft is not persisted until the 300 ms timer in thread.tsx fires, navigating away during that debounce cancels the save; returning to the thread then restores the stale sent prompt with no guard left to suppress it. Keep the draft guard until the new draft has been persisted, or clear the stale stored value when retiring it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Retain send guards for all affected threads
When thread A has a raced sent-text draft, switching to thread B preserves A's guard, but sending in B replaces that guard outright here. Returning to A then restores its stale draft because sentTextGuardBlocksDraft sees only B's key. This occurs in the multi-chat flow the surrounding code explicitly supports, so guards need to be retained per draft key rather than stored in a single slot.
Useful? React with 👍 / 👎.
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.

Problem
Send a message in Chat and the prompt goes out, but the text stays sitting in the composer as if it had never been sent.
The composer textarea is fully controlled by
composer.text, andcomposer.send()empties that synchronously, so the only way the text survives a send is if something writes it back afterwards. Two writers do exactly that, and neither checked:onChange/onCompositionEndinuseImeComposerInputHandlersapply whatever value the DOM event carries. An input or compositionend event the engine queued against the pre-send value still lands after the send (autocorrect commit, IME finalise) and puts the sent text back.readComposerDraft(draftKey)into the composer. An autosave that raced the send still holds the sent text.Once either lands, the next autosave persists it, which is why it sticks rather than flickering.
React normally absorbs this: an event carrying the same value React already tracks gets dropped. It only bites once the controlled re-render has landed first, which is the case where the write arrives a macrotask or more after the send.
Fix
applySentTextGuardrefuses writes that put a just-sent message back. Every path that empties the composer because its text left as a message arms it, and both writers consult it.A write is refused when it carries one of the armed texts, or when it is an autocorrect commit (
inputTypeofinsertReplacementText) arriving at an empty composer. The second rule catches a commit whose value differs from what was sent; a replacement cannot originate from an empty composer, so on one it is necessarily stale.The guard is retired by user intent rather than a clock. Event queue latency has no upper bound, so any wall-clock window can be beaten by a backgrounded page or a blocked main thread delivering a queued write seconds later. Typing retires it, since a typed value differs. A paste and an undo retire it explicitly: both are deliberate gestures, and both can legitimately restore exactly what was sent, which nothing about the value alone could tell from a stale write.
Details worth calling out:
preventDefault, which stopsComposerPrimitive.Input's own handler applying the same value (composeEventHandlersskips it oncedefaultPrevented).send(), so they arm the guard as well, on both the trimmed prompt and the untrimmed text a late write would carry.Verification
Reproduced against the real
ComposerPrimitiveanduseLocalRuntimeon a vite entry with no backend, by sending and then dispatching the write:The prompt sent in every case, and typing straight after a send is never swallowed.
tests/composer-send-guard.test.tscovers the helper: the refusal and its survival across repeats, a stale write replayed a hundred times to show lateness alone never lets it through, retirement on a differing write, the autocorrect rule and the typed-again case it must not catch, undo retiring the guard even against an armed wrapper pair, the wrapper plus visible text pairing, and the draft rules including another thread's identical draft.Full frontend suite passes at 2718.
Note on CI
Frontend build + bundle sanityfails ontests/llama-extra-args-diagnostics.test.tsfor a missingparallelSlotsClampedonLlamaFlagCatalog, andBackend CIfails onAttributeError: '_Backend' object has no attribute 'context_length'. Both reproduce on a cleanmaincheckout and are unrelated to this branch, which changes no Python.