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,