From bc4c33c35616e4ea0cc9d4fdbe277424bef6c1a1 Mon Sep 17 00:00:00 2001 From: qzq Date: Mon, 9 Mar 2026 16:17:32 +0800 Subject: [PATCH 1/2] fix(chat-input): guard Enter during IME composition on macOS --- .../src/flow_chat/components/ChatInput.tsx | 19 +++++++++++++++++-- .../flow_chat/components/RichTextInput.tsx | 13 +++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 27923140..f45eabab 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -39,6 +39,7 @@ import { Tooltip, IconButton } from '@/component-library'; import './ChatInput.scss'; const log = createLogger('ChatInput'); +const IME_ENTER_GUARD_MS = 120; export interface ChatInputProps { className?: string; @@ -57,6 +58,8 @@ export const ChatInput: React.FC = ({ const richTextInputRef = useRef(null); const modeDropdownRef = useRef(null); + const isImeComposingRef = useRef(false); + const lastImeCompositionEndAtRef = useRef(0); const contexts = useContextStore(state => state.contexts); const addContext = useContextStore(state => state.addContext); @@ -736,10 +739,11 @@ export const ChatInput: React.FC = ({ } } - const isComposing = (e.nativeEvent as KeyboardEvent).isComposing; + const isComposing = (e.nativeEvent as KeyboardEvent).isComposing || isImeComposingRef.current; + const justFinishedComposition = Date.now() - lastImeCompositionEndAtRef.current < IME_ENTER_GUARD_MS; if (e.key === 'Enter' && !e.shiftKey) { - if (isComposing) { + if (isComposing || justFinishedComposition) { return; } @@ -759,6 +763,15 @@ export const ChatInput: React.FC = ({ } }, [handleSendOrCancel, derivedState, transition, templateState.fillState, moveToNextPlaceholder, moveToPrevPlaceholder, exitTemplateMode, slashCommandState, getFilteredModes, selectSlashCommandMode, canSwitchModes]); + const handleImeCompositionStart = useCallback(() => { + isImeComposingRef.current = true; + }, []); + + const handleImeCompositionEnd = useCallback(() => { + isImeComposingRef.current = false; + lastImeCompositionEndAtRef.current = Date.now(); + }, []); + const handleImageInput = useCallback(() => { const remaining = CHAT_INPUT_CONFIG.image.maxCount - currentImageCount; if (remaining <= 0) { @@ -974,6 +987,8 @@ export const ChatInput: React.FC = ({ value={inputState.value} onChange={handleInputChange} onKeyDown={handleKeyDown} + onCompositionStart={handleImeCompositionStart} + onCompositionEnd={handleImeCompositionEnd} placeholder={t('input.placeholder')} disabled={false} contexts={contexts} diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.tsx b/src/web-ui/src/flow_chat/components/RichTextInput.tsx index dee7072d..36506575 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.tsx +++ b/src/web-ui/src/flow_chat/components/RichTextInput.tsx @@ -4,7 +4,6 @@ */ import React, { useRef, useEffect, useCallback, useState } from 'react'; -import { useTranslation } from 'react-i18next'; import type { ContextItem } from '../../shared/types/context'; import './RichTextInput.scss'; @@ -19,6 +18,8 @@ export interface RichTextInputProps { value: string; onChange: (value: string, contexts: ContextItem[]) => void; onKeyDown?: (e: React.KeyboardEvent) => void; + onCompositionStart?: () => void; + onCompositionEnd?: () => void; onFocus?: () => void; onBlur?: () => void; placeholder?: string; @@ -34,6 +35,8 @@ export const RichTextInput = React.forwardRef { // IME composition: let the IME handle certain keys - const isComposing = (e.nativeEvent as KeyboardEvent).isComposing; + const isComposing = (e.nativeEvent as KeyboardEvent).isComposing || isComposingRef.current; // Handle tag deletion only when not composing if (!isComposing && e.key === 'Backspace' && internalRef.current) { @@ -529,12 +532,14 @@ export const RichTextInput = React.forwardRef { isComposingRef.current = true; - }, []); + onCompositionStart?.(); + }, [onCompositionStart]); const handleCompositionEnd = useCallback(() => { isComposingRef.current = false; + onCompositionEnd?.(); handleInput(); - }, [handleInput]); + }, [handleInput, onCompositionEnd]); return (
Date: Mon, 9 Mar 2026 16:17:39 +0800 Subject: [PATCH 2/2] test(chat): add IME composition Enter regression coverage --- .../e2e/page-objects/components/ChatInput.ts | 26 ++++++++++++++++++ tests/e2e/specs/chat/basic-chat.spec.ts | 27 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/tests/e2e/page-objects/components/ChatInput.ts b/tests/e2e/page-objects/components/ChatInput.ts index bab12079..ba8388a7 100644 --- a/tests/e2e/page-objects/components/ChatInput.ts +++ b/tests/e2e/page-objects/components/ChatInput.ts @@ -298,6 +298,32 @@ export class ChatInput extends BasePage { } return false; } + + async triggerCompositionStart(): Promise { + await browser.execute((selector) => { + const input = document.querySelector(selector); + if (!input) return; + + const event = typeof CompositionEvent !== 'undefined' + ? new CompositionEvent('compositionstart', { bubbles: true, data: '' }) + : new Event('compositionstart', { bubbles: true }); + + input.dispatchEvent(event); + }, this.selectors.textarea); + } + + async triggerCompositionEnd(): Promise { + await browser.execute((selector) => { + const input = document.querySelector(selector); + if (!input) return; + + const event = typeof CompositionEvent !== 'undefined' + ? new CompositionEvent('compositionend', { bubbles: true, data: '' }) + : new Event('compositionend', { bubbles: true }); + + input.dispatchEvent(event); + }, this.selectors.textarea); + } } export default ChatInput; diff --git a/tests/e2e/specs/chat/basic-chat.spec.ts b/tests/e2e/specs/chat/basic-chat.spec.ts index b7225fb6..4fb89240 100644 --- a/tests/e2e/specs/chat/basic-chat.spec.ts +++ b/tests/e2e/specs/chat/basic-chat.spec.ts @@ -100,6 +100,33 @@ describe('BitFun basic chat', () => { expect(placeholder.length).toBeGreaterThan(0); console.log('[Test] Input placeholder:', placeholder); }); + + it('should not send on Enter while IME composition is active', async function () { + if (!hasWorkspace) { + this.skip(); + return; + } + + const testMessage = 'IME composition guard message'; + await chatInput.clear(); + await chatInput.typeMessage(testMessage); + await chatInput.focus(); + + await chatInput.triggerCompositionStart(); + await browser.keys(['Enter']); + await browser.pause(300); + + const valueWhileComposing = await chatInput.getValue(); + expect(valueWhileComposing).toContain(testMessage); + + await chatInput.triggerCompositionEnd(); + await browser.pause(180); + await browser.keys(['Enter']); + await browser.pause(800); + + const valueAfterComposition = await chatInput.getValue(); + expect(valueAfterComposition).toBe(''); + }); }); describe('Send message (no wait for response)', () => {