diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index e07030c154..4aca072ec1 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -80,6 +80,7 @@ import { type CompositionEvent, type FC, type FormEvent, + type KeyboardEvent, useCallback, useEffect, useRef, @@ -442,12 +443,28 @@ function useImeComposerInputHandlers() { [setComposerText, setCompositionState], ); + // If the watchdog cleared the composing flags during a long candidate-window + // pause, a subsequent IME keypress (browser-side isComposing=true / IME + // keyCode 229) would otherwise reach handleSubmit with composingRef=false + // and submit the preedit text. Re-arm composingRef synchronously from the + // native event so the form-submit gate keeps blocking until compositionend. + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.nativeEvent.isComposing || e.keyCode === 229) { + composingRef.current = true; + clearStuckTimer(); + } + }, + [clearStuckTimer], + ); + return { inputProps: { onCompositionStart, onCompositionUpdate, onCompositionEnd, onChange, + onKeyDown, }, isComposing, isComposingRef: composingRef, diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index cbc1a7c409..915797972c 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -717,8 +717,14 @@ export function SharedComposer({ function onKeyDown(e: KeyboardEvent) { // IME composition (Japanese/Chinese/Korean): Enter commits the candidate. - // Don't hijack it. See issue #5318. - if (e.nativeEvent.isComposing || e.keyCode === 229) return; + // Don't hijack it. See issue #5318. Re-pin composingRef in case the stuck + // watchdog (#5546) cleared it during a long candidate-window pause; this + // keeps a follow-up click-Send from submitting preedit text. + if (e.nativeEvent.isComposing || e.keyCode === 229) { + composingRef.current = true; + clearStuckImeTimer(); + return; + } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (!busy) { diff --git a/tests/studio/playwright_chat_ime_i18n.py b/tests/studio/playwright_chat_ime_i18n.py index 7e843ac58f..543a20a987 100644 --- a/tests/studio/playwright_chat_ime_i18n.py +++ b/tests/studio/playwright_chat_ime_i18n.py @@ -477,6 +477,65 @@ with sync_playwright() as p: info("compositionend watchdog recovery PASS") clear() + # 6c. Watchdog-race repro: after the watchdog clears composingRef during a + # long candidate pause, a subsequent IME keydown (browser still sees + # isComposing=true / keyCode 229) must not slip preedit text through + # the form submit. The onKeyDown gate re-pins composingRef so the + # handleSubmit / blockSend guards keep refusing. The Send button stays + # visually enabled (watchdog has already cleared the React state); the + # refusal happens at form.requestSubmit() time, not at the button. + step("BUG REPRO: keydown re-pin after watchdog cleared composing (issue #5546 follow-up)") + clear() + composer.click() + composer.evaluate( + """(el) => { + el.focus(); + el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''})); + el.dispatchEvent(new CompositionEvent('compositionupdate', {bubbles:true, data:'半'})); + const setter = Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, 'value' + ).set; + setter.call(el, el.value + '半角'); + el.dispatchEvent(new InputEvent('input', { + bubbles:true, inputType:'insertCompositionText', + data:'半角', isComposing:true, + })); + }""" + ) + send_btn_keydown = page.locator('button[aria-label="Send message"]') + # Wait past the watchdog so composingRef has cleared. + try: + expect(send_btn_keydown).not_to_be_disabled(timeout = 8_000) + except Exception: + soft_fail("watchdog did not clear before keydown re-pin test") + # Fire the IME-confirm Enter (keyCode 229, isComposing=true) then trigger + # the form submit synchronously. With the keydown gate, composingRef is + # re-pinned before handleSubmit runs and the submit is prevented; the + # textarea must still hold the preedit text. + submit_probe = composer.evaluate( + """(el) => { + el.focus(); + el.dispatchEvent(new KeyboardEvent('keydown', { + bubbles:true, key:'Enter', code:'Enter', keyCode:229, + isComposing:true, + })); + const form = el.closest('form'); + const before = el.value; + try { form && form.requestSubmit(); } catch (e) {} + return {before, after: el.value, cleared: before !== '' && el.value === ''}; + }""" + ) + if submit_probe.get("cleared"): + shoot("06c-keydown-repin-FAIL") + fail( + "Form submitted after an IME keydown -- preedit text leaked " + "through the watchdog gap (#5546 follow-up regression)." + ) + info(f"Form submit refused after IME keydown; textarea retained {submit_probe.get('after')!r}") + shoot("06c-keydown-repin") + info("keydown re-pin gate PASS") + clear() + # 7. Final state. The change-password redirect emits benign 401 noise, # so we filter via is_benign_* and only fail on real errors. shoot("07-final") @@ -505,7 +564,7 @@ with sync_playwright() as p: info( f"DONE: ascii=OK paste={len(I18N_SAMPLES)}/{len(I18N_SAMPLES)} " f"normal_composition=OK stuck_recovery=OK " - f"compositionend_watchdog=OK" + f"compositionend_watchdog=OK keydown_repin=OK" ) _watchdog.cancel() browser.close() diff --git a/tests/studio/test_composer_rtl_bidi_attribute.py b/tests/studio/test_composer_rtl_bidi_attribute.py index 591e731b0f..61ad90a2a3 100644 --- a/tests/studio/test_composer_rtl_bidi_attribute.py +++ b/tests/studio/test_composer_rtl_bidi_attribute.py @@ -96,3 +96,26 @@ def test_compare_composer_has_stuck_compositionend_watchdog(): assert ( "onCompositionUpdate" in src ), "compare composer is missing onCompositionUpdate wiring" + + +def test_main_composer_keydown_repins_composing_during_ime(): + """Issue #5546 watchdog can clear composingRef during a long candidate + pause; the IME keydown gate must re-pin it so a follow-up Enter does not + submit preedit text.""" + src = THREAD_TSX.read_text() + assert "onKeyDown" in src, "main composer is missing onKeyDown IME gate" + assert "e.nativeEvent.isComposing" in src and "keyCode === 229" in src, ( + "main composer keydown gate must check both nativeEvent.isComposing " + "and the IME keyCode 229 sentinel" + ) + + +def test_compare_composer_keydown_repins_composing_during_ime(): + """Compare composer onKeyDown re-pins composingRef on IME keypress so a + follow-up click-Send during the watchdog window does not slip preedit + text through.""" + src = SHARED_TSX.read_text() + assert "composingRef.current = true" in src, ( + "compare composer keydown gate must re-pin composingRef when the " + "browser still considers the IME active" + )