-
-
Notifications
You must be signed in to change notification settings - Fork 5.9k
studio/chat: release stuck IME flag when compositionend never fires #5551
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ec2d407
4d85dc3
357402c
2c3c979
a2fd555
597af0d
87365b5
8d9b73d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -80,6 +80,7 @@ import { | |
| type CompositionEvent, | ||
| type FC, | ||
| type FormEvent, | ||
| type KeyboardEvent, | ||
| useCallback, | ||
| useEffect, | ||
| useRef, | ||
|
|
@@ -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
+387
to
+389
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The watchdog callback clears 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(); | ||
|
|
@@ -380,6 +423,10 @@ function useImeComposerInputHandlers() { | |
| setCompositionState(true); | ||
| }, [setCompositionState]); | ||
|
|
||
| const onCompositionUpdate = useCallback(() => { | ||
| refreshStuckTimer(); | ||
| }, [refreshStuckTimer]); | ||
|
|
||
| const onCompositionEnd = useCallback( | ||
| (e: CompositionEvent<HTMLTextAreaElement>) => { | ||
| setCompositionState(false); | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the watchdog has already cleared Useful? React with 👍 / 👎. |
||
| }, | ||
| [refreshStuckTimer], | ||
| ); | ||
|
|
||
| return { | ||
| inputProps: { | ||
| onCompositionStart, | ||
| onCompositionUpdate, | ||
| onCompositionEnd, | ||
| onChange, | ||
| onKeyDown, | ||
| }, | ||
| isComposing, | ||
| isComposingRef: composingRef, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In an active IME session where the user pauses for more than 2.5s while choosing a candidate, this timer flips
composingRef/isComposingto false even though nocompositionendor 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 inSharedComposer.Useful? React with 👍 / 👎.