From 71dd9fda9667e0c4536cceb549df643f20800c87 Mon Sep 17 00:00:00 2001 From: Zavian Wang <36817799+Zavianx@users.noreply.github.com> Date: Sat, 16 May 2026 02:53:47 +0800 Subject: [PATCH] fix(composer): avoid sending during IME composition (#1720) Co-authored-by: Steven Enamakel --- app/src/pages/Conversations.tsx | 27 ++++++ .../__tests__/Conversations.render.test.tsx | 97 +++++++++++++++++++ .../pages/__tests__/Conversations.test.tsx | 25 ++++- 3 files changed, 148 insertions(+), 1 deletion(-) diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 68e6178c7..ba3c5e338 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -112,6 +112,24 @@ export function isComposerInteractionBlocked(args: { return !args.rustChat || Boolean(args.activeThreadId) || args.welcomePending; } +interface ImeKeyboardEventLike { + isComposing?: boolean; + keyCode?: number; + which?: number; + nativeEvent?: { isComposing?: boolean; keyCode?: number; which?: number }; +} + +export function isImeCompositionKeyEvent(event: ImeKeyboardEventLike): boolean { + return ( + event.isComposing === true || + event.nativeEvent?.isComposing === true || + event.nativeEvent?.keyCode === 229 || + event.nativeEvent?.which === 229 || + event.keyCode === 229 || + event.which === 229 + ); +} + /** * Normalise the value thrown out of `dispatch(loadThreads()).unwrap()` into a * displayable string. `createAsyncThunk` re-throws Redux's `SerializedError` @@ -239,6 +257,7 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro }); const textInputRef = useRef(null); + const isComposingTextRef = useRef(false); const mediaRecorderRef = useRef(null); const mediaStreamRef = useRef(null); const audioChunksRef = useRef([]); @@ -817,6 +836,8 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro }, [messages, replyMode, rustChat]); const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (isComposingTextRef.current || isImeCompositionKeyEvent(e)) return; + const inlineSuffix = getInlineCompletionSuffix(inputValue, inlineSuggestionValue); const textarea = e.currentTarget; const caretAtEnd = @@ -1695,6 +1716,12 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro ref={textInputRef} value={inputValue} onChange={e => setInputValue(e.target.value)} + onCompositionStart={() => { + isComposingTextRef.current = true; + }} + onCompositionEnd={() => { + isComposingTextRef.current = false; + }} onKeyDown={handleInputKeyDown} placeholder="Type a message..." rows={1} diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index bcecd264d..3f5fb8217 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -671,6 +671,103 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { }); }); + it('sends with Enter when the composer is not composing text', async () => { + const { textarea, thread } = await renderSelectedConversation(); + + await act(async () => { + fireEvent.change(textarea, { target: { value: 'enter send' } }); + }); + await waitFor(() => { + expect(textarea).toHaveValue('enter send'); + expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled(); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter' }); + }); + + await waitFor(() => { + expect(chatSend).toHaveBeenCalledWith({ + threadId: thread.id, + message: 'enter send', + model: 'reasoning-v1', + }); + }); + }); + + it('does not send while an IME composition key event is confirming text', async () => { + const { textarea } = await renderSelectedConversation(); + + await act(async () => { + fireEvent.change(textarea, { target: { value: '你好' } }); + }); + await waitFor(() => { + expect(textarea).toHaveValue('你好'); + expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled(); + }); + + await act(async () => { + const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }); + Object.defineProperty(event, 'isComposing', { value: true }); + textarea.dispatchEvent(event); + }); + + expect(chatSend).not.toHaveBeenCalled(); + expect(textarea).toHaveValue('你好'); + }); + + it('does not send for legacy IME keyCode 229 events', async () => { + const { textarea } = await renderSelectedConversation(); + + await act(async () => { + fireEvent.change(textarea, { target: { value: 'かな' } }); + }); + await waitFor(() => { + expect(textarea).toHaveValue('かな'); + expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled(); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', keyCode: 229 }); + }); + + expect(chatSend).not.toHaveBeenCalled(); + expect(textarea).toHaveValue('かな'); + }); + + it('does not send while composition is active even if keydown lacks IME flags', async () => { + const { textarea, thread } = await renderSelectedConversation(); + + await act(async () => { + fireEvent.change(textarea, { target: { value: '안녕' } }); + }); + await waitFor(() => { + expect(textarea).toHaveValue('안녕'); + expect(screen.getByRole('button', { name: 'Send message' })).not.toBeDisabled(); + }); + + await act(async () => { + fireEvent.compositionStart(textarea); + fireEvent.keyDown(textarea, { key: 'Enter' }); + }); + + expect(chatSend).not.toHaveBeenCalled(); + expect(textarea).toHaveValue('안녕'); + + await act(async () => { + fireEvent.compositionEnd(textarea); + fireEvent.keyDown(textarea, { key: 'Enter' }); + }); + + await waitFor(() => { + expect(chatSend).toHaveBeenCalledWith({ + threadId: thread.id, + message: '안녕', + model: 'reasoning-v1', + }); + }); + }); + // Batch-5: Conversation category tabs keep stable labels and mapping (pr#1646). // // The tab set is fixed so categories do not disappear when the thread list diff --git a/app/src/pages/__tests__/Conversations.test.tsx b/app/src/pages/__tests__/Conversations.test.tsx index 536d9a2b7..c7e20f946 100644 --- a/app/src/pages/__tests__/Conversations.test.tsx +++ b/app/src/pages/__tests__/Conversations.test.tsx @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { formatThreadLoadError, isComposerInteractionBlocked } from '../Conversations'; +import { + formatThreadLoadError, + isComposerInteractionBlocked, + isImeCompositionKeyEvent, +} from '../Conversations'; describe('isComposerInteractionBlocked', () => { it('blocks composer interaction while the welcome agent loader is visible', () => { @@ -32,6 +36,25 @@ describe('isComposerInteractionBlocked', () => { }); }); +describe('isImeCompositionKeyEvent', () => { + it('detects active IME composition from the native event', () => { + expect(isImeCompositionKeyEvent({ nativeEvent: { isComposing: true } })).toBe(true); + }); + + it('detects legacy IME keyCode 229 fallbacks', () => { + expect(isImeCompositionKeyEvent({ keyCode: 229 })).toBe(true); + expect(isImeCompositionKeyEvent({ which: 229 })).toBe(true); + expect(isImeCompositionKeyEvent({ nativeEvent: { keyCode: 229 } })).toBe(true); + expect(isImeCompositionKeyEvent({ nativeEvent: { which: 229 } })).toBe(true); + }); + + it('does not treat ordinary Enter as IME composition', () => { + expect(isImeCompositionKeyEvent({ keyCode: 13, nativeEvent: { isComposing: false } })).toBe( + false + ); + }); +}); + describe('formatThreadLoadError', () => { it('returns Error.message for native Error instances', () => { expect(formatThreadLoadError(new Error('boom'))).toBe('boom');