From 8023e1f0c37cc730be03b99691352cb6e0a6119f Mon Sep 17 00:00:00 2001 From: cjh-store <88535245+cjh-store@users.noreply.github.com> Date: Mon, 13 Apr 2026 18:07:30 +0800 Subject: [PATCH] fix: skip Enter key handling during IME composition (#270) * fix: skip Enter key handling during IME composition During CJK input (Chinese, Japanese, Korean), pressing Enter while composing characters should confirm character selection, not send the message. Check isComposing and keyCode 229 on key events before handling Enter, matching react-native-web's own IME detection logic. Fixes #241, #264 * fix: scope IME composition guard to web-only handler Move isComposing check to top of handleDesktopKeyPress so it never leaks into cross-platform onKeyPress interfaces. This keeps CJK IME handling explicit and web-gated. * refactor: remove IS_WEB alias, use isWeb from platform.ts directly --------- Co-authored-by: Mohamed Boudra --- packages/app/src/components/message-input.tsx | 53 +++++++++++-------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 80c9e2054..fbe4cb3d5 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -115,13 +115,15 @@ export interface MessageInputRef { const MIN_INPUT_HEIGHT = 30; const MAX_INPUT_HEIGHT = 160; -const IS_WEB = isWeb; type WebTextInputKeyPressEvent = NativeSyntheticEvent< TextInputKeyPressEventData & { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; + // Web-only: present on DOM KeyboardEvent during IME composition (CJK input). + isComposing?: boolean; + keyCode?: number; } >; @@ -222,7 +224,7 @@ export const MessageInput = forwardRef(funct ref, ) { const { theme } = useUnistyles(); - const buttonIconSize = IS_WEB ? theme.iconSize.md : theme.iconSize.lg; + const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg; const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`; markScrollInvestigationRender(investigationComponentId); const toast = useToast(); @@ -293,7 +295,7 @@ export const MessageInput = forwardRef(funct return false; }, getNativeElement: () => { - if (!IS_WEB) return null; + if (!isWeb) return null; const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null; const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current; return native instanceof HTMLElement ? native : null; @@ -328,7 +330,7 @@ export const MessageInput = forwardRef(funct // Autofocus on web when autoFocus is true, and re-run when focus key changes. useEffect(() => { - if (!IS_WEB || !autoFocus) return; + if (!isWeb || !autoFocus) return; return focusWithRetries({ focus: () => textInputRef.current?.focus(), isFocused: () => { @@ -371,7 +373,7 @@ export const MessageInput = forwardRef(funct onChangeText(nextValue); } - if (IS_WEB && typeof requestAnimationFrame === "function") { + if (isWeb && typeof requestAnimationFrame === "function") { requestAnimationFrame(() => { measureWebInputHeight("dictation"); }); @@ -627,13 +629,13 @@ export const MessageInput = forwardRef(funct const webTextareaRef = useRef(null); useLayoutEffect(() => { - if (IS_WEB) { + if (isWeb) { webTextareaRef.current = getWebTextArea() as HTMLElement | null; } }, [getWebTextArea]); const inputScrollbar = useWebElementScrollbar(webTextareaRef, { - enabled: IS_WEB && inputHeight >= MAX_INPUT_HEIGHT, + enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT, }); const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => { @@ -647,7 +649,7 @@ export const MessageInput = forwardRef(funct }, []); useEffect(() => { - if (!IS_WEB || !onAddImages) { + if (!isWeb || !onAddImages) { return; } @@ -700,7 +702,7 @@ export const MessageInput = forwardRef(funct ]); useEffect(() => { - if (!IS_WEB || typeof ResizeObserver === "undefined") { + if (!isWeb || typeof ResizeObserver === "undefined") { return; } @@ -754,7 +756,7 @@ export const MessageInput = forwardRef(funct }, [getWebElement, getWebTextArea]); useEffect(() => { - if (!IS_WEB) { + if (!isWeb) { return; } const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null; @@ -792,7 +794,7 @@ export const MessageInput = forwardRef(funct }, [getWebTextArea]); function measureWebInputHeight(source: string): boolean { - if (!IS_WEB) return false; + if (!isWeb) return false; const textarea = getWebTextArea(); if (!textarea || typeof textarea.scrollHeight !== "number") return false; const scrollHeight = textarea.scrollHeight ?? 0; @@ -846,7 +848,7 @@ export const MessageInput = forwardRef(funct event: NativeSyntheticEvent, ) { const contentHeight = event.nativeEvent.contentSize.height; - if (IS_WEB) { + if (isWeb) { logWebStickyBottom("composer_content_size_change", { reportedHeight: contentHeight, }); @@ -866,7 +868,7 @@ export const MessageInput = forwardRef(funct function handleSelectionChange(event: NativeSyntheticEvent) { const start = event.nativeEvent.selection?.start ?? 0; const end = event.nativeEvent.selection?.end ?? start; - if (IS_WEB) { + if (isWeb) { const textarea = getWebTextArea(); logWebStickyBottom("composer_selection_changed", { now: getDebugNow(), @@ -880,12 +882,17 @@ export const MessageInput = forwardRef(funct onSelectionChangeCallback?.({ start, end }); } - const shouldHandleDesktopSubmit = IS_WEB; + const shouldHandleDesktopSubmit = isWeb; function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { markScrollInvestigationEvent(investigationComponentId, "keyPress"); if (!shouldHandleDesktopSubmit) return; + // IME composition in progress (e.g. CJK input) — all key events belong to the + // IME, not the app. keyCode 229 is a Chromium fallback for when isComposing is + // cleared before the keydown fires. + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; + // Allow parent to intercept key events (e.g., for autocomplete navigation) if (onKeyPressCallback) { const handled = onKeyPressCallback({ @@ -935,7 +942,7 @@ export const MessageInput = forwardRef(funct (nextValue: string) => { markScrollInvestigationEvent(investigationComponentId, "inputChange"); onChangeText(nextValue); - if (IS_WEB) { + if (isWeb) { logWebStickyBottom("composer_text_changed", { valueLength: nextValue.length, lineCount: nextValue.split("\n").length, @@ -966,7 +973,7 @@ export const MessageInput = forwardRef(funct @@ -998,7 +1005,7 @@ export const MessageInput = forwardRef(funct }} style={[ styles.textInput, - IS_WEB + isWeb ? { height: inputHeight, minHeight: MIN_INPUT_HEIGHT, @@ -1010,12 +1017,12 @@ export const MessageInput = forwardRef(funct }, ]} multiline - scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true} + scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true} onContentSizeChange={handleContentSizeChange} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} onSelectionChange={handleSelectionChange} - autoFocus={IS_WEB && autoFocus} + autoFocus={isWeb && autoFocus} /> {inputScrollbar} @@ -1206,7 +1213,7 @@ const styles = StyleSheet.create(((theme: any) => ({ xs: theme.spacing[3], md: theme.spacing[4], }, - ...(IS_WEB + ...(isWeb ? { transitionProperty: "border-color", transitionDuration: "200ms", @@ -1225,7 +1232,7 @@ const styles = StyleSheet.create(((theme: any) => ({ borderWidth: 1, borderColor: theme.colors.borderAccent, overflow: "hidden", - ...(IS_WEB + ...(isWeb ? { cursor: "pointer", } @@ -1250,7 +1257,7 @@ const styles = StyleSheet.create(((theme: any) => ({ justifyContent: "center", backgroundColor: "rgba(0, 0, 0, 0.5)", opacity: 0, - ...(IS_WEB + ...(isWeb ? { transitionProperty: "opacity", transitionDuration: "150ms", @@ -1269,7 +1276,7 @@ const styles = StyleSheet.create(((theme: any) => ({ fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, lineHeight: theme.fontSize.base * 1.4, - ...(IS_WEB + ...(isWeb ? { outlineStyle: "none" as const, outlineWidth: 0,