diff --git a/apps/desktop/src/app/chat/composer/enter-stale-ime-flag.test.tsx b/apps/desktop/src/app/chat/composer/enter-stale-ime-flag.test.tsx new file mode 100644 index 000000000000..64d2ee43a1d4 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/enter-stale-ime-flag.test.tsx @@ -0,0 +1,129 @@ +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { useRef } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +afterEach(cleanup) + +// Faithful mirror of index.tsx's IME wiring: the composition guard at the top +// of handleEditorKeyDown (self-heal + swallow), the compositionstart/end +// handlers, and the blur reset. +// +// Regression repro for #44135: compositionend can be missed (focus jumps, +// input-source switches, programmatic DOM swaps mid-preedit), leaving +// composingRef wedged true. Before the fix, a wedged flag silently swallowed +// every Enter — and, via the form onSubmit guard, the Send button — until the +// composer remounted, which read as "Enter has no effect, no error, nothing +// reaches the gateway". The fix trusts Chromium's per-keydown isComposing flag +// to clear a stale ref, and clears it on blur (a composition never survives +// focus loss). +function Harness({ onSubmit, wedgeComposing }: { onSubmit: (text: string) => void; wedgeComposing?: boolean }) { + const editorRef = useRef(null) + const composingRef = useRef(Boolean(wedgeComposing)) + + const submitDraft = () => { + onSubmit(editorRef.current?.textContent ?? '') + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (composingRef.current && !event.nativeEvent.isComposing) { + composingRef.current = false + } + + if (composingRef.current || event.nativeEvent.isComposing) { + return + } + + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault() + submitDraft() + } + } + + return ( +
+
{ + composingRef.current = false + }} + onCompositionEnd={() => { + composingRef.current = false + }} + onCompositionStart={() => { + composingRef.current = true + }} + onKeyDown={handleKeyDown} + ref={editorRef} + suppressContentEditableWarning + /> +
+ ) +} + +describe('composer Enter — stale IME composition flag recovery (#44135)', () => { + it('sends on Enter despite a wedged composing flag when the native event says not composing', async () => { + const onSubmit = vi.fn() + const { getByTestId } = render() + const editor = getByTestId('editor') + + await act(async () => { + editor.textContent = 'hello after wedge' + fireEvent.keyDown(editor, { key: 'Enter', isComposing: false }) + }) + + expect(onSubmit).toHaveBeenCalledWith('hello after wedge') + }) + + it('still swallows Enter during a genuine composition (isComposing keydown)', async () => { + const onSubmit = vi.fn() + const { getByTestId } = render() + const editor = getByTestId('editor') + + await act(async () => { + fireEvent.compositionStart(editor) + editor.textContent = '你好' + // The Enter that confirms the preedit: Chromium stamps isComposing=true. + fireEvent.keyDown(editor, { key: 'Enter', isComposing: true }) + }) + + expect(onSubmit).not.toHaveBeenCalled() + + // After compositionend, the next Enter sends normally. + await act(async () => { + fireEvent.compositionEnd(editor) + fireEvent.keyDown(editor, { key: 'Enter', isComposing: false }) + }) + + expect(onSubmit).toHaveBeenCalledWith('你好') + }) + + it('unblocks the Send button after blur even when compositionend was missed', async () => { + const onSubmit = vi.fn() + const { getByTestId } = render() + const editor = getByTestId('editor') + + await act(async () => { + fireEvent.compositionStart(editor) + editor.textContent = '发送' + // compositionend never fires (the wedge) — the user mouses to Send, + // blurring the editor. + fireEvent.blur(editor) + fireEvent.click(getByTestId('send')) + }) + + expect(onSubmit).toHaveBeenCalledWith('发送') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index c18313a73865..46660fdf252a 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -762,6 +762,18 @@ export function ChatBar({ } const handleEditorKeyDown = (event: KeyboardEvent) => { + // Self-heal a stale composition flag before the guard below reads it. + // compositionend can be missed (focus jumps, input-source switches, or a + // programmatic DOM swap mid-preedit abort the composition without the + // event reaching us), and a wedged composingRef silently swallows every + // Enter — and, via the form onSubmit guard, the Send button — until the + // component remounts (#44135). Chromium stamps isComposing on every + // keydown of a genuine composition, so when the native flag says we're + // not composing, trust it and recover. + if (composingRef.current && !event.nativeEvent.isComposing) { + composingRef.current = false + } + // IME composition: Enter confirms composed text, not a message submission. // We check both composingRef (set by compositionstart/compositionend, robust // across browsers) and nativeEvent.isComposing (Chromium fallback). Without @@ -1604,7 +1616,15 @@ export function ChatBar({ contentEditable={!disabled} data-placeholder={placeholder} data-slot={RICH_INPUT_SLOT} - onBlur={() => window.setTimeout(closeTrigger, 80)} + onBlur={() => { + // A composition never survives focus loss (Chromium commits the + // preedit and fires compositionend on blur) — but if that event is + // missed, the wedged flag would block the Send button's form-submit + // guard forever (#44135). Clear unconditionally: by the time blur + // runs there is nothing left composing in this editor. + composingRef.current = false + window.setTimeout(closeTrigger, 80) + }} onCompositionEnd={event => { composingRef.current = false