Studio: stop a sent prompt staying in the chat composer by shimmyshimmer · Pull Request #8849 · unslothai/unsloth · GitHub
Skip to content

Studio: stop a sent prompt staying in the chat composer - #8849

Merged
danielhanchen merged 20 commits into
mainfrom
fix/composer-keeps-sent-prompt
Aug 16, 2026
Merged

Studio: stop a sent prompt staying in the chat composer#8849
danielhanchen merged 20 commits into
mainfrom
fix/composer-keeps-sent-prompt

Conversation

@shimmyshimmer

@shimmyshimmer shimmyshimmer commented Aug 14, 2026

Copy link
Copy Markdown
Member

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, and composer.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 / onCompositionEnd in useImeComposerInputHandlers apply 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.
  • The draft restore effect writes 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

applySentTextGuard refuses 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 (inputType of insertReplacementText) 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:

  • Refusing in the input handlers also calls preventDefault, which stops ComposerPrimitive.Input's own handler applying the same value (composeEventHandlers skips it once defaultPrevented).
  • Empty texts are dropped when arming, so an attachment-only send still clears.
  • The image-edit send replaces the visible text with a wrapper before sending, so both the wrapper and what the user typed are armed.
  • The three queue paths clear the composer without going through send(), so they arm the guard as well, on both the trimmed prompt and the untrimmed text a late write would carry.
  • Draft suppression is keyed on the draft the send cleared, so another thread's identical draft still restores. When it does suppress, it clears the composer and drops the raced draft rather than returning early, which would leave the previous thread's text on screen under this one.

Verification

Reproduced against the real ComposerPrimitive and useLocalRuntime on a vite entry with no backend, by sending and then dispatching the write:

write after send before after
stale identical, next macrotask text restored composer empty
stale identical, 1.5s later text restored composer empty
autocorrect commit, mutated value text restored composer empty
autocorrect, 1.5s later text restored composer empty
deliberate undo text restored prompt restored, as asked
deliberate re-paste text restored prompt restored, as asked

The prompt sent in every case, and typing straight after a send is never swallowed.

tests/composer-send-guard.test.ts covers 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 sanity fails on tests/llama-extra-args-diagnostics.test.ts for a missing parallelSlotsClamped on LlamaFlagCatalog, and Backend CI fails on AttributeError: '_Backend' object has no attribute 'context_length'. Both reproduce on a clean main checkout and are unrelated to this branch, which changes no Python.

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.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

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.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

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 inputType is insertReplacementText or historyUndo when the composer is empty. A replacement cannot originate from an empty composer, so on one it is stale whatever its value, and typing and pasting never report those input types.

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 sendReservedComposer, so only the wrapper was guarded. It now passes the pre-wrap text too, and the guard holds a list.

Stamp text when a prompt is queued. Correct. All three queue paths cleared the composer without going through send(). They now arm the guard, on the untrimmed text as well as the trimmed prompt, since a late write carries the untrimmed value.

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: Frontend build + bundle sanity is red on tests/llama-extra-args-diagnostics.test.ts for a missing parallelSlotsClamped on LlamaFlagCatalog. That reproduces on main with the same four errors and has nothing to do with this branch, so I have left it out rather than fold an unrelated fix into this PR.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@shimmyshimmer

Copy link
Copy Markdown
Member Author

Checked the rest of the red checks that came in after the last push. All of them are pre-existing on main and none are related to this branch, which changes three TypeScript files and no Python.

  • Backend CI (Python 3.11, Python 3.12, Repo tests (CPU)): 19 failures, all AttributeError: '_Backend' object has no attribute 'context_length' out of routes/inference.py. Reproduced on a clean main checkout locally, same error, so it predates this branch.
  • Security audit (pip scan-packages :: hf-stack): scans installed pip packages, and the evidence lines are from scipy and tokenizer sources. This branch adds no dependencies.
  • Frontend build + bundle sanity: the parallelSlotsClamped typecheck error covered in the description, also reproduced on main.

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:

late write guard off guard on
identical value text restored composer empty
autocorrect commit, mutated value text restored composer empty
undo text restored composer empty

Ordinary typing straight after a send is kept, not swallowed. The message sends in every case.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: 96a61cfb2a

ℹ️ 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".

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.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

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:

late write before after
identical value, 2.0s after send text restored composer empty
identical value, 3.0s after send text restored composer empty
autocorrect commit, 2.0s after send text restored composer empty

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 sentTextGuardBlocksDraft tests and verified by inspection, but not exercised through a real thread switch, since that needs the full chat UI.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

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.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

Right, and worse than described: undo was refused twice over. The value equals what was sent, so the equality check rejects it, and historyUndo also counted as a replacement into an empty composer. Repeated undo could not get the prompt back at all.

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: insertReplacementText is the engine, and it still cannot originate from an empty composer. Fixed in 987a36f.

Measured on the vite entry, guard on throughout:

write after send result
undo, immediately prompt restored
undo, 800ms later prompt restored
deliberate re-paste prompt restored
stale identical write composer empty
stale identical, 1.5s later composer empty
autocorrect commit composer empty
autocorrect, 1.5s later composer empty

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.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

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.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

Right, and the reasoning holds: the wrapper is built from the trimmed text, so trimmed was the only extra value armed, while a late DOM write carries whatever the textarea actually held. An instruction with leading or trailing whitespace matched neither and came straight back.

Fixed in 79ce6c8. The raw pre-send value is armed too, read live from the composer rather than from composerText, since that is render-time state and can lag what the textarea holds at the moment of the send.

Reproduced on the vite entry with " make it brighter ", swapping the visible text for the wrapper and then firing the late write with the raw value:

armed composer after send
wrapper and trimmed only " make it brighter " restored
wrapper, trimmed and raw empty

The send went through in both. Suite is at 2719, with a case covering the raw and trimmed forms of a whitespace-padded instruction.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

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.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

@shimmyshimmer

Copy link
Copy Markdown
Member Author

Right, and it is worse than a swallowed keystroke: the refusal keeps the guard, so every retry is swallowed too. Sending ? and then typing ? again could never be undone by typing.

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 insertText write carrying the full pre-send value into a composer that send() has already cleared. The two are indistinguishable by value, by inputType and by composer state.

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 ? and then producing an identical ? write:

write after send result
stale write, no keydown behind it composer empty
real trusted keydown, then the write ? restored

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.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

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 compositionstart marks the guard the same way. The mark is no longer keystroke-specific, so it is now markSentTextGuardUserInput and userInputSince.

Measured on the vite entry with a one-emoji prompt, guard on throughout:

write after sending the emoji result
committed write, no composition or keydown behind it composer empty
composition started after the send, then its commit emoji restored
keydown, then its write emoji restored

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:

  • User types ?, an input is queued with an empty pre-value and ? as both data and result. Enter sends. That queued event fires against a composer send() has already cleared.
  • User sends ?, then inserts ? into the now-empty composer.

Both are an insertion of ? into an empty field, and both are trusted. Same value, same inputType, same composer state, same isTrusted. Nothing in the event separates them, which is why the boundary has to come from ordering rather than from the write itself. Accepting trusted insertions into an empty composer would restore the original bug directly, since the queued stale write is one.

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. tsc -p tsconfig.app.json clean, and the one biome format complaint in that file is on a line this branch does not touch.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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 };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

Right, and the chain holds all the way through: the saved-prompt menu writes with aui.composer().setText(...), which never passes the guard, so nothing retires it. Autosave then stores a perfectly legitimate draft, and the next visit to the thread reads it as the raced save and deletes it along with the composer text.

Fixed in 418f0be, though not at the write sites. ComposerToolsMenu and PromptStorageDialog do not hold justSentRef, so retiring there means drilling the ref into both, and any future component that fills the composer has to remember to do the same. The check belongs where the harm is.

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.
@shimmyshimmer

Copy link
Copy Markdown
Member Author

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 justSentRef drilled into them.

Walking the case that broke it:

  • A sends the prompt, guard armed under A. Composer is empty, so nothing retires.
  • Switch to B, whose draft is the same text. The restore runs under B's key, the guard's key is A, so it does not match and the guard stays armed.
  • Switch back to A. The restore suppresses A's raced draft and clears the composer, then the retire check reads an empty composer and leaves the guard alone. The prompt is not restored.

And the saved-prompt case it is meant to fix:

  • A sends the prompt, guard armed under A.
  • The menu writes the same text while A is on screen. The key matches and the composer is non-empty, so the guard retires.
  • Autosave stores the draft, and the next visit has no guard to misread it.

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 react-hooks/exhaustive-deps errors on this branch from armJustSent; those are fixed here, and thread.tsx now matches main on every rule except the one added import.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +109 to +111
}
if (write.replacesText && write.composerIsEmpty) {
return { accept: false, guard };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

@shimmyshimmer

Copy link
Copy Markdown
Member Author

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. insertReplacementText means the engine replaced text that was already in the field. Autocorrect needs a word to correct, a spell-check suggestion needs a misspelling to replace, and an inline prediction needs a prefix to complete. After a send the field is empty, so there is nothing for any of them to act on. That is the whole basis for the rule, and it is the rule that catches the mutated autocorrect commit that the first round of this review called the significant finding. Accepting replacements into an empty composer restores that bug directly.

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 teh cat and then firing a mutated replacement carrying the cat with no keydown and no composition behind it:

after composer state textarea
send empty empty
stale replacement refused empty empty
keystroke, then input x x

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 insertReplacementText into a genuinely empty field, I will take that as the counterexample and rework the rule around it. Without one I am not going to widen it on the possibility, because the cost is a bug that was reproduced rather than a hypothetical one.

…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.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

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.
danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Aug 16, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

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.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

…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.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

…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.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 16, 2026
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

1 similar comment
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

danielhanchen added a commit to danielhanchen/unsloth-staging-2 that referenced this pull request Aug 16, 2026
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 44f6de0f1e

ℹ️ 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".

Final comment-only pass. Verified with comment_tools.py check.
@danielhanchen

Copy link
Copy Markdown
Member

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +121 to +122
// Anything else is the user, so stop guarding rather than judge later writes.
return { accept: true, guard: null };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +2736 to +2737

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@danielhanchen
danielhanchen merged commit 127f2a3 into main Aug 16, 2026
35 of 38 checks passed
@danielhanchen
danielhanchen deleted the fix/composer-keeps-sent-prompt branch August 16, 2026 13:14
danielhanchen added a commit that referenced this pull request Aug 16, 2026
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.
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