Skip to content
53 changes: 50 additions & 3 deletions studio/frontend/src/components/assistant-ui/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof setTimeout> | 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);
Comment on lines +384 to +389

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

Comment on lines +387 to +389

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

}
},
[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();
Expand All @@ -380,6 +422,10 @@ function useImeComposerInputHandlers() {
setCompositionState(true);
}, [setCompositionState]);

const onCompositionUpdate = useCallback(() => {
refreshStuckTimer();
}, [refreshStuckTimer]);

const onCompositionEnd = useCallback(
(e: CompositionEvent<HTMLTextAreaElement>) => {
setCompositionState(false);
Expand All @@ -399,6 +445,7 @@ function useImeComposerInputHandlers() {
return {
inputProps: {
onCompositionStart,
onCompositionUpdate,
onCompositionEnd,
onChange,
},
Expand Down
38 changes: 38 additions & 0 deletions studio/frontend/src/features/chat/shared-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
Expand Down Expand Up @@ -284,6 +289,7 @@ export function SharedComposer({
const [isComposing, setIsComposing] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const composingRef = useRef(false);
const stuckImeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const audioInputRef = useRef<HTMLInputElement>(null);

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -753,6 +788,9 @@ export function SharedComposer({
onCompositionStart={() => {
setCompositionState(true);
}}
onCompositionUpdate={() => {
refreshStuckImeTimer();
}}
onCompositionEnd={(e: CompositionEvent<HTMLTextAreaElement>) => {
setCompositionState(false);
setText(e.currentTarget.value);
Expand Down
58 changes: 56 additions & 2 deletions tests/studio/playwright_chat_ime_i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -424,6 +428,55 @@ def clear() -> None:
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")
Expand Down Expand Up @@ -451,7 +504,8 @@ def clear() -> None:

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()
25 changes: 25 additions & 0 deletions tests/studio/test_composer_rtl_bidi_attribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,28 @@ 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"
Loading