From 2b8a4f9baaa8c6c76d9135b092d16172c88a5887 Mon Sep 17 00:00:00 2001 From: dolphin Date: Tue, 4 Aug 2026 22:33:35 +0800 Subject: [PATCH] fix(chat): let Enter commit an IME candidate before it sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing pinyin and pressing Enter sent the raw pinyin as the message. The chat input treated every non-shift Enter as "send" and called preventDefault on it, so the keystroke that should have committed the IME candidate never reached the box. Enter now sends only when no composition is in flight, and the event is left untouched while one is — so the first Enter commits the candidate and the second sends, which is what every other IME-aware input in the app already does. Composition is detected the same way useTextarea does it: a compositionstart / compositionend ref plus `isComposing`, `key === 'Process'` and the legacy 229 keyCode, because no single signal is reliable across Safari and the various IMEs. --- .../src/components/Chat/AiChatInput.tsx | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/frontend/client/src/components/Chat/AiChatInput.tsx b/src/frontend/client/src/components/Chat/AiChatInput.tsx index 965efefa3..9d616f17a 100644 --- a/src/frontend/client/src/components/Chat/AiChatInput.tsx +++ b/src/frontend/client/src/components/Chat/AiChatInput.tsx @@ -167,6 +167,8 @@ const AiChatInput = memo( // Exiting task mode discards the skill selection so the panel's checkboxes // reset in sync with the (now-hidden) skill chips. Track the previous value // to fire only on a true→false transition, not on mount or re-entry. + // True while an IME composition is in flight — see handleKeyDown. + const isComposingRef = useRef(false); const prevTaskModeRef = useRef(taskMode); useEffect(() => { if (prevTaskModeRef.current && !taskMode) { @@ -295,7 +297,18 @@ const AiChatInput = memo( const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { + // While an IME is composing (拼音 / かな / 한글), Enter belongs to the + // IME: it commits the candidate into the box. Sending on it shipped + // the raw pinyin instead. The event must also pass through + // untouched — preventDefault here would swallow the commit. + // `isComposing` misbehaves in Safari and some IMEs only report the + // legacy 229 keyCode, so every signal is consulted. + const composing = + isComposingRef.current || + e.nativeEvent.isComposing || + e.key === "Process" || + e.keyCode === 229; + if (e.key === "Enter" && !e.shiftKey && !composing) { e.preventDefault(); if (isStreaming) return; handleSend(); @@ -304,6 +317,16 @@ const AiChatInput = memo( [handleSend, isStreaming] ); + // Mirrors useTextarea's guard: true between compositionstart and + // compositionend, which is the only reliable signal on the browsers where + // `isComposing` is not. + const handleCompositionStart = useCallback(() => { + isComposingRef.current = true; + }, []); + const handleCompositionEnd = useCallback(() => { + isComposingRef.current = false; + }, []); + const hasSelectionTags = ((selectedOrgKbs && selectedOrgKbs.length > 0) || (chatFiles && chatFiles.length > 0) || uploadingFiles.length > 0 || (taskMode && dailySkills.length > 0)) && !isLingsi; // Tell the landing page whether the attachment bar is present so it can @@ -414,6 +437,8 @@ const AiChatInput = memo( value={text} onChange={(e) => setText(e.target.value)} onKeyDown={handleKeyDown} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} onPaste={handlePaste} onScroll={handleTextareaScroll} onHeightChange={updateTextareaScrollable}