Skip to content
73 changes: 70 additions & 3 deletions studio/frontend/src/components/assistant-ui/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
type CompositionEvent,
type FC,
type FormEvent,
type KeyboardEvent,
useCallback,
useEffect,
useRef,
Expand Down Expand Up @@ -353,16 +354,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 +423,10 @@ function useImeComposerInputHandlers() {
setCompositionState(true);
}, [setCompositionState]);

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

const onCompositionEnd = useCallback(
(e: CompositionEvent<HTMLTextAreaElement>) => {
setCompositionState(false);
Expand All @@ -396,11 +443,31 @@ 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.
// Re-arm the watchdog at the same time — otherwise the WSL+Chrome path
// this PR targets (no compositionend, no follow-up input event) would
// leave composingRef pinned true indefinitely and Send blocked again.
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing || e.keyCode === 229) {
composingRef.current = true;
refreshStuckTimer();
}
Comment on lines +456 to +459

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

},
[refreshStuckTimer],
);

return {
inputProps: {
onCompositionStart,
onCompositionUpdate,
onCompositionEnd,
onChange,
onKeyDown,
},
isComposing,
isComposingRef: composingRef,
Expand Down
51 changes: 49 additions & 2 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 @@ -682,8 +717,17 @@ 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. Re-arm the
// watchdog on the same path — without it the WSL+Chrome no-compositionend
// case would leave composingRef pinned forever after an IME keypress and
// re-lock Send.
if (e.nativeEvent.isComposing || e.keyCode === 229) {
composingRef.current = true;
refreshStuckImeTimer();
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (!busy) {
Expand Down Expand Up @@ -753,6 +797,9 @@ export function SharedComposer({
onCompositionStart={() => {
setCompositionState(true);
}}
onCompositionUpdate={() => {
refreshStuckImeTimer();
}}
onCompositionEnd={(e: CompositionEvent<HTMLTextAreaElement>) => {
setCompositionState(false);
setText(e.currentTarget.value);
Expand Down
Loading
Loading