fix(chat): let Enter commit an IME candidate before it sends

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.
This commit is contained in:
dolphin
2026-08-04 22:33:35 +08:00
parent b87cf76844
commit 2b8a4f9baa
@@ -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<HTMLTextAreaElement>) => {
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}