diff --git a/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx b/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx index 76fdf79f8097..80f59e98b367 100644 --- a/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx +++ b/apps/desktop/src/app/chat/composer/enter-submit-dom-race.test.tsx @@ -2,6 +2,8 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' import { useRef, useState } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { isImeComposingKeyEvent } from './ime' + // No global setupFiles registers auto-cleanup, so unmount between tests — // otherwise a second render() leaks the first editor and getByTestId('editor') // matches multiple nodes. @@ -78,6 +80,10 @@ function Harness({ } const handleKeyDown = (event: React.KeyboardEvent) => { + if (isImeComposingKeyEvent(event)) { + return + } + if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() @@ -132,6 +138,21 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)', expect(onSubmit).toHaveBeenCalledWith('hello world') }) + it('does not submit when Chromium reports IME Enter as keyCode 229', async () => { + const onSubmit = vi.fn() + const { getByTestId } = render( + + ) + const editor = getByTestId('editor') + + await act(async () => { + editor.textContent = '你好' + fireEvent.keyDown(editor, { key: 'Enter', keyCode: 229 }) + }) + + expect(onSubmit).not.toHaveBeenCalled() + }) + it('queues a fast-typed message while busy instead of draining the queue or cancelling', async () => { const onQueue = vi.fn() const onDrain = vi.fn() diff --git a/apps/desktop/src/app/chat/composer/ime.test.ts b/apps/desktop/src/app/chat/composer/ime.test.ts new file mode 100644 index 000000000000..3cf4673425f3 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/ime.test.ts @@ -0,0 +1,27 @@ +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' +import { describe, expect, it } from 'vitest' + +import { isImeComposingKeyEvent } from './ime' + +function keyEvent({ isComposing = false, keyCode = 13 }: { isComposing?: boolean; keyCode?: number }) { + return { + nativeEvent: { + isComposing, + keyCode + } + } as ReactKeyboardEvent +} + +describe('isImeComposingKeyEvent', () => { + it('detects standard composing key events', () => { + expect(isImeComposingKeyEvent(keyEvent({ isComposing: true }))).toBe(true) + }) + + it('detects Chromium IME processing keyCode 229 after isComposing flips false', () => { + expect(isImeComposingKeyEvent(keyEvent({ isComposing: false, keyCode: 229 }))).toBe(true) + }) + + it('does not treat normal Enter as IME composition', () => { + expect(isImeComposingKeyEvent(keyEvent({ isComposing: false, keyCode: 13 }))).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/ime.ts b/apps/desktop/src/app/chat/composer/ime.ts new file mode 100644 index 000000000000..f80d43838c4f --- /dev/null +++ b/apps/desktop/src/app/chat/composer/ime.ts @@ -0,0 +1,11 @@ +import type { KeyboardEvent as ReactKeyboardEvent } from 'react' + +const CHROMIUM_IME_PROCESSING_KEY_CODE = 229 + +export function isImeComposingKeyEvent(event: ReactKeyboardEvent): boolean { + // Chromium/Electron can report the IME confirmation key as keyCode 229 + // even when isComposing is already false by the time React handles keydown. + // Treat it as composition so Enter confirms CJK preedit text instead of + // submitting the composer. + return event.nativeEvent.isComposing || event.nativeEvent.keyCode === CHROMIUM_IME_PROCESSING_KEY_CODE +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index d8b06a68d377..1c176cbc6693 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -59,6 +59,7 @@ import { } from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' +import { isImeComposingKeyEvent } from './ime' import { useSlashCompletions } from './hooks/use-slash-completions' import { useVoiceConversation } from './hooks/use-voice-conversation' import { useVoiceRecorder } from './hooks/use-voice-recorder' @@ -672,11 +673,10 @@ export function ChatBar({ const handleEditorKeyDown = (event: KeyboardEvent) => { // 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 - // this guard, pressing Enter to finalise a Korean/Japanese/Chinese IME - // preedit fires submitDraft() and splits the message mid-word. - if (composingRef.current || event.nativeEvent.isComposing) { + // Check React's composition ref, native isComposing, and Chromium's + // IME-processing keyCode 229 fallback. Some Electron/macOS/CJK paths flip + // isComposing false before the Enter keydown reaches React. + if (composingRef.current || isImeComposingKeyEvent(event)) { return } diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 9bcc05da3bf2..e41a09c6a75f 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -36,6 +36,7 @@ import { onComposerInsertRequest } from '@/app/chat/composer/focus' import { useAtCompletions } from '@/app/chat/composer/hooks/use-at-completions' +import { isImeComposingKeyEvent } from '@/app/chat/composer/ime' import { useSlashCompletions } from '@/app/chat/composer/hooks/use-slash-completions' import { dragHasAttachments, @@ -1401,6 +1402,12 @@ const UserEditComposer: FC = ({ cwd, gateway, sessionId } ) const handleKeyDown = (event: KeyboardEvent) => { + // IME Enter confirms the composed text in the edit composer. Do not let it + // select trigger items or submit the edited message. + if (isImeComposingKeyEvent(event)) { + return + } + if (trigger && triggerItems.length > 0) { if (event.key === 'ArrowDown') { event.preventDefault()