studio/chat: release stuck IME flag when compositionend never fires#5551
Conversation
Chrome on Windows talking to a WSL-hosted Studio (issue unslothai#5546) fires compositionstart + compositionupdate but no compositionend after the IME commits. The earlier hardening in unslothai#5327 cleared the stale flag on the next non-composing input event, which never arrives in this sequence, so composingRef stays true forever and the Send button stays disabled even though the committed CJK text is already in the textarea. Add a watchdog in both useImeComposerInputHandlers (main + edit composer) and SharedComposer (compare mode) that runs the same reset the missing compositionend would have done. The timer is rearmed on every compositionupdate and on every non-composing input so it only fires when the IME pipeline has actually gone quiet — normal candidate selection keeps it alive, the WSL stuck case lets it expire. Extends the existing IME Playwright smoke with a stuck-compositionend repro and adds a static guard so the watchdog can't be removed without the regression tests catching it.
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
Code Review
This pull request introduces a watchdog timer to resolve an issue where the compositionend event fails to fire in specific environments, such as Chrome on Windows over WSL, which previously caused the message composer to remain in a "composing" state indefinitely. The implementation adds a 2500ms timeout that resets during compositionupdate events and automatically clears the composition flag if the timer expires. This fix is applied to both the assistant-ui thread and the shared composer components. Furthermore, the PR includes new Playwright regression tests and source-level assertions to ensure the watchdog is correctly wired and functional. I have no feedback to provide.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 357402cf29
ℹ️ 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 (next) { | ||
| stuckTimerRef.current = setTimeout(() => { | ||
| stuckTimerRef.current = null; | ||
| composingRef.current = false; | ||
| setIsComposing(false); | ||
| }, IME_STUCK_TIMEOUT_MS); |
There was a problem hiding this comment.
Avoid clearing IME state solely on silence
In an active IME session where the user pauses for more than 2.5s while choosing a candidate, this timer flips composingRef/isComposing to false even though no compositionend or commit signal has arrived. Because the Send/Edit guards rely on those flags, the UI can re-enable submission during a still-active composition and allow the current preedit/candidate text to be sent or saved; the watchdog should only be armed after a commit-like input or otherwise verify composition has actually ended. The same silence-based timeout is mirrored in SharedComposer.
Useful? React with 👍 / 👎.
…5546 watchdog gap The stuck-compositionend watchdog (PR unslothai#5551) releases composingRef after 2500 ms of IME silence so Send unwedges in the WSL+Chrome case. The same release also fires during a long candidate-window pause in healthy IMEs, which lets a subsequent IME-confirm Enter slip preedit text through handleSubmit (main composer) or click-Send through send() (compare composer). Add a keydown gate to both composers: when the browser still reports nativeEvent.isComposing or keyCode 229, re-pin composingRef and cancel any pending watchdog so the next form-submit / send() guard refuses. The Send button stays visually enabled (avoids re-introducing the stuck-UI bug) but the submit path is blocked until a real compositionend or non-composing input arrives. Mirrors the existing isComposing guard shape in shared-composer.onKeyDown. Tests: - tests/studio/test_composer_rtl_bidi_attribute.py: two new static guards asserting the keydown gate wiring in both composer files. - tests/studio/playwright_chat_ime_i18n.py: new section 6c repro that fires the IME-confirm keydown after the watchdog has cleared, then triggers form.requestSubmit() and asserts the preedit text is not cleared (would indicate a leaked submit). Verified across Chromium / Firefox / WebKit via a side-by-side pre-PR vs post-PR simulation (54 scenarios, zero pageerror or console.error). The unslothai#5546 stuck-end repro still passes (Send re-enables 2.5-3 s after the silent commit) and the new keydown-repin probe confirms the submit gate refuses on all three engines.
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 597af0d0fd
ℹ️ 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".
| if (e.nativeEvent.isComposing || e.keyCode === 229) { | ||
| composingRef.current = true; | ||
| clearStuckTimer(); | ||
| } |
There was a problem hiding this comment.
Re-arm IME watchdog after re-pinning composition
When the watchdog has already cleared composingRef during a long IME pause, this keydown path sets composingRef back to true but only clears the timeout; it never schedules a new one. On the same Windows+WSL Chrome path this patch targets (missing compositionend), if no subsequent compositionupdate/input arrives after that IME keydown, composingRef stays true indefinitely and Send remains blocked again (the same pattern is mirrored in shared-composer.tsx).
Useful? React with 👍 / 👎.
The keydown re-pin added in 2c3c979 closed the watchdog-race for healthy IMEs, but on the same WSL+Chrome no-compositionend path this PR targets it would re-lock Send permanently: setting composingRef=true and only *clearing* the watchdog leaves the flag pinned forever if no follow-up compositionend or non-composing input ever arrives. Swap clearStuckTimer/clearStuckImeTimer for refreshStuckTimer/ refreshStuckImeTimer in both composer keydown gates so the watchdog fires once more after every IME keypress. Same visual contract — Send stays enabled — the submit gate just keeps a 2.5s window before re-releasing instead of staying locked. Extends the playwright IME smoke with section 6d: clears composing via the watchdog, fires an IME keydown, then waits past the re-armed watchdog window and asserts the form submit actually flushes the textarea. Two new static guards in test_composer_rtl_bidi_attribute lock the refresh call into both keydown handlers.
for more information, see https://pre-commit.ci
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8d9b73d96f
ℹ️ 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".
| composingRef.current = false; | ||
| setIsComposing(false); | ||
| }, IME_STUCK_TIMEOUT_MS); |
There was a problem hiding this comment.
Avoid unlocking IME solely on watchdog silence
The watchdog callback clears composingRef/isComposing after a fixed 2.5s gap even if no commit signal (compositionend or non-composing input) has arrived, which can re-enable Send during an active candidate session when users pause to choose text and then click Send/Edit. In that case preedit/candidate text can be submitted as if composition had finished. Fresh evidence in this commit: the new Playwright section 6b codifies timeout-only unlock behavior with no compositionend, so this path is still present (and mirrored in shared-composer.tsx).
Useful? React with 👍 / 👎.
…nslothai#5551) * studio/chat: release stuck IME flag when compositionend never fires Chrome on Windows talking to a WSL-hosted Studio (issue unslothai#5546) fires compositionstart + compositionupdate but no compositionend after the IME commits. The earlier hardening in unslothai#5327 cleared the stale flag on the next non-composing input event, which never arrives in this sequence, so composingRef stays true forever and the Send button stays disabled even though the committed CJK text is already in the textarea. Add a watchdog in both useImeComposerInputHandlers (main + edit composer) and SharedComposer (compare mode) that runs the same reset the missing compositionend would have done. The timer is rearmed on every compositionupdate and on every non-composing input so it only fires when the IME pipeline has actually gone quiet — normal candidate selection keeps it alive, the WSL stuck case lets it expire. Extends the existing IME Playwright smoke with a stuck-compositionend repro and adds a static guard so the watchdog can't be removed without the regression tests catching it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio/chat: re-pin composing flag on IME keydown to close unslothai#5546 watchdog gap The stuck-compositionend watchdog (PR unslothai#5551) releases composingRef after 2500 ms of IME silence so Send unwedges in the WSL+Chrome case. The same release also fires during a long candidate-window pause in healthy IMEs, which lets a subsequent IME-confirm Enter slip preedit text through handleSubmit (main composer) or click-Send through send() (compare composer). Add a keydown gate to both composers: when the browser still reports nativeEvent.isComposing or keyCode 229, re-pin composingRef and cancel any pending watchdog so the next form-submit / send() guard refuses. The Send button stays visually enabled (avoids re-introducing the stuck-UI bug) but the submit path is blocked until a real compositionend or non-composing input arrives. Mirrors the existing isComposing guard shape in shared-composer.onKeyDown. Tests: - tests/studio/test_composer_rtl_bidi_attribute.py: two new static guards asserting the keydown gate wiring in both composer files. - tests/studio/playwright_chat_ime_i18n.py: new section 6c repro that fires the IME-confirm keydown after the watchdog has cleared, then triggers form.requestSubmit() and asserts the preedit text is not cleared (would indicate a leaked submit). Verified across Chromium / Firefox / WebKit via a side-by-side pre-PR vs post-PR simulation (54 scenarios, zero pageerror or console.error). The unslothai#5546 stuck-end repro still passes (Send re-enables 2.5-3 s after the silent commit) and the new keydown-repin probe confirms the submit gate refuses on all three engines. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio/chat: re-arm IME watchdog after keydown re-pin (Codex P1) The keydown re-pin added in 2c3c979 closed the watchdog-race for healthy IMEs, but on the same WSL+Chrome no-compositionend path this PR targets it would re-lock Send permanently: setting composingRef=true and only *clearing* the watchdog leaves the flag pinned forever if no follow-up compositionend or non-composing input ever arrives. Swap clearStuckTimer/clearStuckImeTimer for refreshStuckTimer/ refreshStuckImeTimer in both composer keydown gates so the watchdog fires once more after every IME keypress. Same visual contract — Send stays enabled — the submit gate just keeps a 2.5s window before re-releasing instead of staying locked. Extends the playwright IME smoke with section 6d: clears composing via the watchdog, fires an IME keydown, then waits past the re-armed watchdog window and asserts the form submit actually flushes the textarea. Two new static guards in test_composer_rtl_bidi_attribute lock the refresh call into both keydown handlers. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Summary
compositionendnever arrives, so Send stops being permanently disabled after a CJK commit on Chrome + Windows + WSL.Root cause
The earlier IME hardening (#5327) cleared the stuck-composition flag on the next non-composing input event. On Chrome talking to a WSL-hosted Studio, the OS-level IME pipeline fires
compositionstart+compositionupdatebut never emitscompositionendor a follow-up non-composing input event after the commit.composingRef.currentstaystrue,canSendstaysfalse, and the committed Chinese text just sits in the textarea with no way to send it.Changes
studio/frontend/src/components/assistant-ui/thread.tsx: introduceIME_STUCK_TIMEOUT_MS(2500 ms) and reworkuseImeComposerInputHandlerssosetCompositionState(true)arms a watchdog timer;onCompositionUpdateand the existingonChangerearm it; expiry resets bothcomposingRefandisComposing. Cleans up on unmount.studio/frontend/src/features/chat/shared-composer.tsx: mirror the same watchdog into the compare-mode composer.tests/studio/playwright_chat_ime_i18n.py: new section 6b that dispatches the WSL/Chrome event sequence (start → update → commit input event, nocompositionend) and asserts the Send button comes back enabled while the committed text is still in the textarea.tests/studio/test_composer_rtl_bidi_attribute.py: two new static guards so the watchdog and itsonCompositionUpdatewiring can't be silently dropped in a future refactor.Validation
npm run typecheck(clean)npm run build(clean)npx biome checkon the two changed.tsxfiles — same diagnostic count asmain, no new errors/warnings introducedpython3 -m pytest tests/studio/test_composer_rtl_bidi_attribute.py -v→ 8 passed (6 existing + 2 new guards)