mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(composer): avoid sending during IME composition (#1720)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
40cce5c409
commit
71dd9fda96
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user