diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index ff80099505..e07030c154 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -353,16 +353,58 @@ function isNativeComposing(event: Event) { return "isComposing" in event && (event as InputEvent).isComposing === true; } +// Fallback timeout for stuck IME composition. When Chrome on Windows talks +// to a WSL-hosted Studio (issue #5546), `compositionend` never fires after +// the candidate is committed, so `composingRef` stays true and Send stays +// disabled. Every compositionupdate / non-composing input resets the timer; +// only a true gap-after-commit lets it fire. 2500ms is well above a normal +// candidate-window pause but short enough to recover before the user +// notices the Send button is stuck. +const IME_STUCK_TIMEOUT_MS = 2500; + function useImeComposerInputHandlers() { const aui = useAui(); const composingRef = useRef(false); const [isComposing, setIsComposing] = useState(false); + const stuckTimerRef = useRef | null>(null); - const setCompositionState = useCallback((next: boolean) => { - composingRef.current = next; - setIsComposing(next); + const clearStuckTimer = useCallback(() => { + if (stuckTimerRef.current) { + clearTimeout(stuckTimerRef.current); + stuckTimerRef.current = null; + } }, []); + const setCompositionState = useCallback( + (next: boolean) => { + composingRef.current = next; + setIsComposing(next); + clearStuckTimer(); + if (next) { + stuckTimerRef.current = setTimeout(() => { + stuckTimerRef.current = null; + composingRef.current = false; + setIsComposing(false); + }, IME_STUCK_TIMEOUT_MS); + } + }, + [clearStuckTimer], + ); + + const refreshStuckTimer = useCallback(() => { + if (!composingRef.current) { + return; + } + clearStuckTimer(); + stuckTimerRef.current = setTimeout(() => { + stuckTimerRef.current = null; + composingRef.current = false; + setIsComposing(false); + }, IME_STUCK_TIMEOUT_MS); + }, [clearStuckTimer]); + + useEffect(() => clearStuckTimer, [clearStuckTimer]); + const setComposerText = useCallback( (value: string) => { const composer = aui.composer(); @@ -380,6 +422,10 @@ function useImeComposerInputHandlers() { setCompositionState(true); }, [setCompositionState]); + const onCompositionUpdate = useCallback(() => { + refreshStuckTimer(); + }, [refreshStuckTimer]); + const onCompositionEnd = useCallback( (e: CompositionEvent) => { setCompositionState(false); @@ -399,6 +445,7 @@ function useImeComposerInputHandlers() { return { inputProps: { onCompositionStart, + onCompositionUpdate, onCompositionEnd, onChange, }, diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index c320f6d86b..cbc1a7c409 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -68,6 +68,11 @@ function isNativeComposing(event: Event) { return "isComposing" in event && (event as InputEvent).isComposing === true; } +// Mirrors the threshold in thread.tsx — see the comment there. Chrome on +// Windows-over-WSL (issue #5546) never fires `compositionend` after the +// IME commit, so the compose flag would otherwise stay true forever. +const IME_STUCK_TIMEOUT_MS = 2500; + function fileToBase64DataURL(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -284,6 +289,7 @@ export function SharedComposer({ const [isComposing, setIsComposing] = useState(false); const textareaRef = useRef(null); const composingRef = useRef(false); + const stuckImeTimerRef = useRef | null>(null); const fileInputRef = useRef(null); const audioInputRef = useRef(null); @@ -474,11 +480,40 @@ export function SharedComposer({ setPendingImages((prev) => prev.filter((p) => p.id !== id)); }, []); + function clearStuckImeTimer() { + if (stuckImeTimerRef.current) { + clearTimeout(stuckImeTimerRef.current); + stuckImeTimerRef.current = null; + } + } + function setCompositionState(next: boolean) { composingRef.current = next; setIsComposing(next); + clearStuckImeTimer(); + if (next) { + stuckImeTimerRef.current = setTimeout(() => { + stuckImeTimerRef.current = null; + composingRef.current = false; + setIsComposing(false); + }, IME_STUCK_TIMEOUT_MS); + } } + function refreshStuckImeTimer() { + if (!composingRef.current) { + return; + } + clearStuckImeTimer(); + stuckImeTimerRef.current = setTimeout(() => { + stuckImeTimerRef.current = null; + composingRef.current = false; + setIsComposing(false); + }, IME_STUCK_TIMEOUT_MS); + } + + useEffect(() => () => clearStuckImeTimer(), []); + async function send() { if (composingRef.current) return; const msg = text.trim(); @@ -753,6 +788,9 @@ export function SharedComposer({ onCompositionStart={() => { setCompositionState(true); }} + onCompositionUpdate={() => { + refreshStuckImeTimer(); + }} onCompositionEnd={(e: CompositionEvent) => { setCompositionState(false); setText(e.currentTarget.value); diff --git a/tests/studio/playwright_chat_ime_i18n.py b/tests/studio/playwright_chat_ime_i18n.py index c882d88cbd..afc089b473 100644 --- a/tests/studio/playwright_chat_ime_i18n.py +++ b/tests/studio/playwright_chat_ime_i18n.py @@ -3,12 +3,16 @@ """Studio chat composer IME + multilingual regression smoke. -Covers two surfaces: +Covers three surfaces: A. Stuck IME composition (issue #5318 / PR #5327): duplicate compositionstart with no compositionend left isComposing=true, dropping all subsequent keystrokes including ASCII. B. Multilingual paste round-trip across 31 scripts -- guards the controlled-textarea / React state plumbing against Unicode mangling. + C. Stuck compositionend (issue #5546): Chrome on Windows over WSL + fires compositionstart + compositionupdate but never compositionend, + wedging Send disabled after the IME commits. Verifies the + watchdog in useImeComposerInputHandlers releases the flag. Model-free; the bug surface is the composer, not inference. @@ -424,6 +428,57 @@ with sync_playwright() as p: info("stuck-composition recovery PASS") clear() + # 6b. WSL + Windows Chrome repro for issue #5546: Chrome never emits + # compositionend after the IME commit, so the watchdog has to + # release the composing flag on its own once the events go silent. + # This dispatches a realistic "compose, commit, then nothing" + # sequence — no compositionend, no follow-up keystrokes — and + # waits for the Send button to come back enabled. + step("BUG REPRO: stuck compositionend recovery (issue #5546)") + clear() + composer.click() + composer.evaluate( + """(el) => { + el.focus(); + el.dispatchEvent(new CompositionEvent('compositionstart', {bubbles:true, data:''})); + el.dispatchEvent(new CompositionEvent('compositionupdate', {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, + })); + // Deliberately omit compositionend — that is the WSL/Chrome + // bug surface. The watchdog in useImeComposerInputHandlers + // should reset isComposing after IME_STUCK_TIMEOUT_MS. + }""" + ) + send_btn_5546 = page.locator('button[aria-label="Send message"]') + if send_btn_5546.count() == 0: + soft_fail("Send button not found for #5546 repro") + else: + # Watchdog is 2500ms; allow generous slack for slow CI. + try: + expect(send_btn_5546).not_to_be_disabled(timeout = 8_000) + info("Send button enabled after compositionend never fired") + except Exception: + shoot("06b-compositionend-watchdog-FAIL") + fail( + "Send button stayed disabled with no compositionend — " + "watchdog did not release the composing flag (issue #5546)." + ) + after_value = read_value() + if "你好" not in after_value: + soft_fail( + f"compositionend-watchdog repro lost committed text: {after_value!r}" + ) + shoot("06b-compositionend-watchdog") + info("compositionend watchdog recovery 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") @@ -451,7 +506,8 @@ 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"normal_composition=OK stuck_recovery=OK " + f"compositionend_watchdog=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 5b1437b4fc..588bc38b3f 100644 --- a/tests/studio/test_composer_rtl_bidi_attribute.py +++ b/tests/studio/test_composer_rtl_bidi_attribute.py @@ -71,3 +71,30 @@ def test_ime_playwright_script_does_not_read_studio_old_pw(): "STUDIO_OLD_PW" not in code_only ), "IME Playwright script still references dead STUDIO_OLD_PW env var" assert 'os.environ["STUDIO_NEW_PW"]' in code_only + + +def test_main_composer_has_stuck_compositionend_watchdog(): + """Issue #5546: Chrome on Windows over WSL never emits compositionend + after the IME commit. The composer keeps a watchdog that releases the + composing flag once events go silent; without it Send stays disabled + forever and CJK input is effectively dropped.""" + src = THREAD_TSX.read_text() + assert "IME_STUCK_TIMEOUT_MS" in src, ( + "main composer is missing the stuck-compositionend watchdog " + "(issue #5546)" + ) + assert "onCompositionUpdate" in src, ( + "main composer is missing onCompositionUpdate wiring; the " + "watchdog only resets while the IME is actively emitting events" + ) + + +def test_compare_composer_has_stuck_compositionend_watchdog(): + src = SHARED_TSX.read_text() + assert "IME_STUCK_TIMEOUT_MS" in src, ( + "compare composer is missing the stuck-compositionend watchdog " + "(issue #5546)" + ) + assert "onCompositionUpdate" in src, ( + "compare composer is missing onCompositionUpdate wiring" + )