fix(composer): avoid sending during IME composition (#1720)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Zavian Wang
2026-05-15 11:53:47 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 40cce5c409
commit 71dd9fda96
3 changed files with 148 additions and 1 deletions
+27
View File
@@ -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<HTMLTextAreaElement>(null);
const isComposingTextRef = useRef(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const mediaStreamRef = useRef<MediaStream | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
@@ -817,6 +836,8 @@ const Conversations = ({ variant = 'page', composer = 'text' }: ConversationsPro
}, [messages, replyMode, rustChat]);
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
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}
@@ -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
+24 -1
View File
@@ -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');