import { View, Text, TextInput, Pressable, ActivityIndicator, NativeSyntheticEvent, TextInputContentSizeChangeEventData, TextInputKeyPressEventData, TextInputSelectionChangeEventData, BackHandler, } from "react-native"; import { useState, useRef, useCallback, useEffect, useLayoutEffect, useImperativeHandle, forwardRef, } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated"; import { useDictation } from "@/hooks/use-dictation"; import { DictationOverlay } from "./dictation-controls"; import { RealtimeVoiceOverlay } from "./realtime-voice-overlay"; import type { DaemonClient } from "@server/client/daemon-client"; import { useSessionStore } from "@/stores/session-store"; import { useVoiceOptional } from "@/contexts/voice-context"; import { useToast } from "@/contexts/toast-context"; import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities"; import { collectImageFilesFromClipboardData, filesToImageAttachments, } from "@/utils/image-attachments-from-files"; import type { AttachmentMetadata } from "@/attachments/types"; import type { ComposerAttachment } from "@/attachments/types"; import { focusWithRetries } from "@/utils/web-focus"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useWebElementScrollbar } from "@/components/use-web-scrollbar"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { formatShortcut } from "@/utils/format-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { markScrollInvestigationEvent, markScrollInvestigationRender, } from "@/utils/scroll-jank-investigation"; import { isWeb } from "@/constants/platform"; import { useComposerHeightMirror } from "./composer-height-mirror"; export type ImageAttachment = AttachmentMetadata; export interface MessagePayload { text: string; attachments: ComposerAttachment[]; cwd: string; /** When true, bypasses queue and sends immediately even if agent is running */ forceSend?: boolean; } export interface AttachmentMenuItem { id: string; label: string; onSelect: () => void; disabled?: boolean; icon?: React.ReactElement | null; } export interface MessageInputProps { value: string; onChangeText: (text: string) => void; onSubmit: (payload: MessagePayload) => void; /** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */ hasExternalContent?: boolean; /** When true, the submit button stays visible and can submit even with no content. */ allowEmptySubmit?: boolean; /** Optional accessibility label for the primary submit button. */ submitButtonAccessibilityLabel?: string; submitIcon?: "arrow" | "return"; isSubmitDisabled?: boolean; isSubmitLoading?: boolean; attachments: ComposerAttachment[]; cwd: string; attachmentMenuItems: AttachmentMenuItem[]; onAttachButtonRef?: (node: View | null) => void; onAddImages?: (images: ImageAttachment[]) => void; client: DaemonClient | null; /** Dictation start gate from host runtime (socket connected + directory ready). */ isReadyForDictation?: boolean; placeholder?: string; autoFocus?: boolean; autoFocusKey?: string; disabled?: boolean; /** True when this composer's pane is focused. Used to gate global hotkeys and stop dictation when hidden. */ isPaneFocused?: boolean; /** Content to render on the left side of the button row (e.g., AgentStatusBar) */ leftContent?: React.ReactNode; /** Content to render on the right side before the voice button (e.g., context window meter) */ beforeVoiceContent?: React.ReactNode; /** Content to render on the right side after voice button (e.g., realtime button, cancel button) */ rightContent?: React.ReactNode; voiceServerId?: string; voiceAgentId?: string; /** When true and there's sendable content, calls onQueue instead of onSubmit */ isAgentRunning?: boolean; /** Controls what the default send action (Enter, send button, dictation) does * when the agent is running. "interrupt" sends immediately, "queue" queues. */ defaultSendBehavior?: "interrupt" | "queue"; /** Callback for queue button when agent is running */ onQueue?: (payload: MessagePayload) => void; /** Optional handler used when submit button is in loading state. */ onSubmitLoadingPress?: () => void; /** Intercept key press events before default handling. Return true to prevent default. */ onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean; /** Reports cursor selection updates from the underlying input. */ onSelectionChange?: (selection: { start: number; end: number }) => void; onFocusChange?: (focused: boolean) => void; onHeightChange?: (height: number) => void; /** Extra styles merged onto the input wrapper (e.g. elevated background). */ inputWrapperStyle?: import("react-native").ViewStyle; } export interface MessageInputRef { focus: () => void; blur: () => void; runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean; /** * Web-only: return the underlying DOM element for focus assertions/retries. * May return null if not mounted or on native. */ getNativeElement?: () => HTMLElement | null; } const MIN_INPUT_HEIGHT_MOBILE = 30; const MIN_INPUT_HEIGHT_DESKTOP = 46; const MAX_INPUT_HEIGHT = 160; const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE; 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; } >; type TextAreaHandle = { scrollHeight?: number; clientHeight?: number; offsetHeight?: number; scrollTop?: number; selectionStart?: number | null; selectionEnd?: number | null; style?: { height?: string; overflowY?: string; } & Record; }; function logWebStickyBottom(_event: string, _details: Record): void { // Intentionally disabled: this path is too noisy during voice debugging. } function getDebugNow(): number | null { if (typeof performance !== "undefined" && typeof performance.now === "function") { return Number(performance.now().toFixed(3)); } return null; } function getElementDescriptor(element: HTMLElement | null): string | null { if (!element) return null; const tag = element.tagName?.toLowerCase() ?? "unknown"; const id = element.id ? `#${element.id}` : ""; const testId = element.getAttribute?.("data-testid"); const label = element.getAttribute?.("aria-label"); const suffix = testId ? `[data-testid="${testId}"]` : label ? `[aria-label="${label}"]` : ""; return `${tag}${id}${suffix}`; } function getScrollableAncestorChain(element: HTMLElement | null): string[] { if (!element || typeof window === "undefined") { return []; } const results: string[] = []; let current = element.parentElement; while (current) { const style = window.getComputedStyle(current); const overflowY = style.overflowY; const canScroll = (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") && current.scrollHeight > current.clientHeight; if (canScroll) { results.push(getElementDescriptor(current) ?? current.tagName.toLowerCase()); } current = current.parentElement; } return results; } export const MessageInput = forwardRef(function MessageInput( { value, onChangeText, onSubmit, hasExternalContent = false, allowEmptySubmit = false, submitButtonAccessibilityLabel, submitIcon = "arrow", isSubmitDisabled = false, isSubmitLoading = false, attachments, cwd, attachmentMenuItems, onAttachButtonRef, onAddImages, client, isReadyForDictation, placeholder = "Message...", autoFocus = false, autoFocusKey, disabled = false, isPaneFocused = true, leftContent, beforeVoiceContent, rightContent, voiceServerId, voiceAgentId, isAgentRunning = false, defaultSendBehavior = "interrupt", onQueue, onSubmitLoadingPress, onKeyPress: onKeyPressCallback, onSelectionChange: onSelectionChangeCallback, onFocusChange, onHeightChange, inputWrapperStyle, }, ref, ) { const { theme } = useUnistyles(); const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg; const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`; markScrollInvestigationRender(investigationComponentId); const toast = useToast(); const voice = useVoiceOptional(); const sendKeys = useShortcutKeys("message-input-send"); const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle"); const dictationToggleKeys = useShortcutKeys("dictation-toggle"); const focusInputKeys = useShortcutKeys("focus-message-input"); const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); const [isInputFocused, setIsInputFocused] = useState(false); const rootRef = useRef(null); const inputWrapperRef = useRef(null); const textInputRef = useRef unknown }) | null>( null, ); const isInputFocusedRef = useRef(false); useImperativeHandle(ref, () => ({ focus: () => { textInputRef.current?.focus(); }, blur: () => { textInputRef.current?.blur?.(); }, runKeyboardAction: (action) => { if (action === "focus") { textInputRef.current?.focus(); return true; } if (action === "send" || action === "dictation-confirm") { if (isDictatingRef.current) { sendAfterTranscriptRef.current = true; confirmDictation(); return true; } return false; } if (action === "voice-toggle") { handleToggleRealtimeVoiceShortcut(); return true; } if (action === "voice-mute-toggle") { if (isRealtimeVoiceForCurrentAgent) { voice?.toggleMute(); } return true; } if (action === "dictation-cancel") { if (isDictatingRef.current) { cancelDictation(); return true; } return false; } if (action === "dictation-toggle") { if (isDictatingRef.current) { sendAfterTranscriptRef.current = true; confirmDictation(); } else { void startDictationIfAvailable(); } return true; } return false; }, getNativeElement: () => { 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; }, })); const inputHeightRef = useRef(MIN_INPUT_HEIGHT); const overlayTransition = useSharedValue(0); const sendAfterTranscriptRef = useRef(false); const valueRef = useRef(value); const serverInfo = useSessionStore( useCallback( (state) => { if (!voiceServerId) { return null; } return state.sessions[voiceServerId]?.serverInfo ?? null; }, [voiceServerId], ), ); useEffect(() => { valueRef.current = value; }, [value]); useEffect(() => { return () => { onFocusChange?.(false); }; }, [onFocusChange]); // Autofocus on web when autoFocus is true, and re-run when focus key changes. useEffect(() => { if (!isWeb || !autoFocus) return; return focusWithRetries({ focus: () => textInputRef.current?.focus(), isFocused: () => { const current = textInputRef.current as | (TextInput & { getNativeRef?: () => unknown }) | null; const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current; const element = native instanceof HTMLElement ? native : null; const active = typeof document !== "undefined" ? document.activeElement : null; return Boolean(element) && active === element; }, }); }, [autoFocus, autoFocusKey]); const handleDictationTranscript = useCallback( (text: string, _meta: { requestId: string }) => { if (!text) return; const current = valueRef.current; const shouldPad = current.length > 0 && !/\s$/.test(current); const nextValue = `${current}${shouldPad ? " " : ""}${text}`; const shouldAutoSend = sendAfterTranscriptRef.current; sendAfterTranscriptRef.current = false; if (shouldAutoSend) { // Respect send behavior setting: when "queue", dictation queues too. if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) { onQueue({ text: nextValue, attachments, cwd }); onChangeText(""); } else { onSubmit({ text: nextValue, attachments, cwd, forceSend: isAgentRunning || undefined, }); } } else { onChangeText(nextValue); } }, [onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior], ); const handleDictationError = useCallback( (error: Error) => { console.error("[MessageInput] Dictation error:", error); toast.error(error.message); }, [toast], ); const dictationUnavailableMessage = resolveVoiceUnavailableMessage({ serverInfo, mode: "dictation", }); const canStartDictation = useCallback(() => { const socketConnected = client?.isConnected ?? false; const readyForDictation = isReadyForDictation ?? socketConnected; return socketConnected && readyForDictation && !disabled && !dictationUnavailableMessage; }, [client, disabled, dictationUnavailableMessage, isReadyForDictation]); const canConfirmDictation = useCallback(() => { const socketConnected = client?.isConnected ?? false; return socketConnected; }, [client]); const isConnected = client?.isConnected ?? false; const isDictationStartEnabled = (isReadyForDictation ?? isConnected) && !disabled; const { isRecording: isDictating, isProcessing: isDictationProcessing, partialTranscript: dictationPartialTranscript, volume: dictationVolume, duration: dictationDuration, error: dictationError, status: dictationStatus, startDictation, cancelDictation, confirmDictation, retryFailedDictation, discardFailedDictation, } = useDictation({ client, onTranscript: handleDictationTranscript, onError: handleDictationError, canStart: canStartDictation, canConfirm: canConfirmDictation, autoStopWhenHidden: { isVisible: isPaneFocused }, enableDuration: true, }); const isDictatingRef = useRef(isDictating); useEffect(() => { isDictatingRef.current = isDictating; }, [isDictating]); const isRealtimeVoiceForCurrentAgent = !!voice && !!voiceServerId && !!voiceAgentId && voice.isVoiceModeForAgent(voiceServerId, voiceAgentId); const showDictationOverlay = isDictating || isDictationProcessing || dictationStatus === "failed"; const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent; const showOverlay = showDictationOverlay || showRealtimeOverlay; useEffect(() => { if (isDictating || isDictationProcessing) { return; } sendAfterTranscriptRef.current = false; }, [dictationStatus, isDictating, isDictationProcessing]); const startDictationIfAvailable = useCallback(async () => { if (dictationUnavailableMessage) { isDictatingRef.current = false; toast.error(dictationUnavailableMessage); return; } if (!canStartDictation()) { isDictatingRef.current = false; return; } // Keep hotkey toggling deterministic between the async start call and the // state-ref sync effect, so a rapid second toggle routes to confirm. isDictatingRef.current = true; await startDictation(); }, [canStartDictation, dictationUnavailableMessage, startDictation, toast]); // Animate overlay useEffect(() => { overlayTransition.value = withTiming(showOverlay ? 1 : 0, { duration: 200, }); }, [overlayTransition, showOverlay]); const overlayAnimatedStyle = useAnimatedStyle(() => ({ opacity: overlayTransition.value, pointerEvents: overlayTransition.value > 0.5 ? "auto" : "none", })); const inputAnimatedStyle = useAnimatedStyle(() => ({ opacity: 1 - overlayTransition.value, })); const handleVoicePress = useCallback(async () => { if (isRealtimeVoiceForCurrentAgent && voice) { voice.toggleMute(); return; } if (isDictating) { await cancelDictation(); } else { await startDictationIfAvailable(); } }, [ cancelDictation, isDictating, isRealtimeVoiceForCurrentAgent, startDictationIfAvailable, voice, ]); const handleCancelRecording = useCallback(async () => { await cancelDictation(); }, [cancelDictation]); const handleAcceptRecording = useCallback(async () => { sendAfterTranscriptRef.current = false; await confirmDictation(); }, [confirmDictation]); const handleAcceptAndSendRecording = useCallback(async () => { sendAfterTranscriptRef.current = true; await confirmDictation(); }, [confirmDictation]); const handleRetryFailedRecording = useCallback(() => { void retryFailedDictation(); }, [retryFailedDictation]); const handleDiscardFailedRecording = useCallback(() => { discardFailedDictation(); }, [discardFailedDictation]); const handleStopRealtimeVoice = useCallback(async () => { if (!voice || !isRealtimeVoiceForCurrentAgent) { return; } const tasks: Promise[] = []; if (isAgentRunning && client && voiceAgentId) { tasks.push(client.cancelAgent(voiceAgentId)); } tasks.push(voice.stopVoice()); const results = await Promise.allSettled(tasks); results.forEach((result) => { if (result.status === "rejected") { console.error("[MessageInput] Failed to stop realtime voice", result.reason); } }); }, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId]); const handleToggleRealtimeVoiceShortcut = useCallback(() => { if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) { return; } if (voice.isVoiceSwitching) { return; } if (voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)) { void handleStopRealtimeVoice(); return; } void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => { console.error("[MessageInput] Failed to start realtime voice", error); const message = error instanceof Error ? error.message : typeof error === "string" ? error : null; if (message && message.trim().length > 0) { toast.error(message); } }); }, [disabled, handleStopRealtimeVoice, isConnected, toast, voice, voiceAgentId, voiceServerId]); const handleSendMessage = useCallback(() => { const trimmed = value.trim(); if (!trimmed && attachments.length === 0 && !hasExternalContent && !allowEmptySubmit) return; const payload = { text: trimmed, attachments, cwd, forceSend: isAgentRunning || undefined, }; onSubmit(payload); inputHeightRef.current = MIN_INPUT_HEIGHT; setInputHeight(MIN_INPUT_HEIGHT); onHeightChange?.(MIN_INPUT_HEIGHT); }, [ allowEmptySubmit, value, attachments, cwd, onSubmit, isAgentRunning, onHeightChange, hasExternalContent, ]); const handleQueueMessage = useCallback(() => { if (!onQueue) return; const trimmed = value.trim(); if (!trimmed && attachments.length === 0) return; const payload = { text: trimmed, attachments, cwd, }; onQueue(payload); onChangeText(""); inputHeightRef.current = MIN_INPUT_HEIGHT; setInputHeight(MIN_INPUT_HEIGHT); onHeightChange?.(MIN_INPUT_HEIGHT); }, [value, attachments, cwd, onQueue, onChangeText, onHeightChange]); // Default send action: respects the sendBehavior setting. // When "interrupt" (default), primary action sends immediately (interrupts). // When "queue", primary action queues when agent is running. const handleDefaultSendAction = useCallback(() => { if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) { handleQueueMessage(); } else { handleSendMessage(); } }, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]); // Alternate send action: always the opposite of the default. const handleAlternateSendAction = useCallback(() => { if (defaultSendBehavior === "queue") { handleSendMessage(); // interrupt } else if (onQueue) { handleQueueMessage(); // queue } }, [defaultSendBehavior, handleSendMessage, handleQueueMessage, onQueue]); // Web input height measurement function isTextAreaLike(v: unknown): v is TextAreaHandle { return typeof v === "object" && v !== null && "scrollHeight" in v; } const getWebTextArea = useCallback((): TextAreaHandle | null => { const ref = textInputRef.current; if (!ref) return null; if (typeof (ref as any).getNativeRef === "function") { const native = (ref as any).getNativeRef(); if (isTextAreaLike(native)) return native; } if (isTextAreaLike(ref)) return ref; return null; }, []); const webTextareaRef = useRef(null); useLayoutEffect(() => { if (isWeb) { webTextareaRef.current = getWebTextArea() as HTMLElement | null; } }, [getWebTextArea]); const inputScrollbar = useWebElementScrollbar(webTextareaRef, { enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT, }); const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => { const ref = target === "root" ? rootRef.current : inputWrapperRef.current; if (!ref) return null; return ref instanceof HTMLElement ? ref : (ref as unknown as { getBoundingClientRect?: () => DOMRect }).getBoundingClientRect ? (ref as unknown as HTMLElement) : null; }, []); useEffect(() => { if (!isWeb || !onAddImages) { return; } const textarea = getWebTextArea(); if ( !textarea || typeof (textarea as any).addEventListener !== "function" || typeof (textarea as any).removeEventListener !== "function" ) { return; } let disposed = false; const handlePaste = (event: ClipboardEvent) => { if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) { return; } const imageFiles = collectImageFilesFromClipboardData(event.clipboardData); if (imageFiles.length === 0) { return; } event.preventDefault(); void filesToImageAttachments(imageFiles) .then((attachments) => { if (disposed || attachments.length === 0) { return; } onAddImages(attachments); }) .catch((error) => { console.error("[MessageInput] Failed to process pasted images:", error); }); }; (textarea as any).addEventListener("paste", handlePaste); return () => { disposed = true; (textarea as any).removeEventListener("paste", handlePaste); }; }, [ disabled, getWebTextArea, isConnected, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages, ]); useEffect(() => { if (!isWeb || typeof ResizeObserver === "undefined") { return; } const textarea = getWebTextArea(); const root = getWebElement("root"); const wrapper = getWebElement("wrapper"); const observed = [ { name: "composer_root", element: root }, { name: "composer_wrapper", element: wrapper }, { name: "composer_textarea", element: textarea as unknown as HTMLElement | null }, ].filter( (entry): entry is { name: string; element: HTMLElement } => entry.element instanceof HTMLElement, ); if (observed.length === 0) { return; } const observer = new ResizeObserver((entries) => { for (const entry of entries) { const target = entry.target as HTMLElement; const match = observed.find((item) => item.element === target); if (!match) { continue; } const textareaNode = getWebTextArea(); logWebStickyBottom("composer_element_resized", { target: match.name, width: target.clientWidth, height: target.clientHeight, offsetHeight: target.offsetHeight, scrollHeight: target.scrollHeight, textareaClientHeight: textareaNode?.clientHeight ?? null, textareaOffsetHeight: textareaNode?.offsetHeight ?? null, textareaScrollHeight: textareaNode?.scrollHeight ?? null, textareaScrollTop: (textareaNode as unknown as HTMLTextAreaElement | null)?.scrollTop ?? null, valueLength: valueRef.current.length, }); } }); for (const entry of observed) { observer.observe(entry.element); } return () => { observer.disconnect(); }; }, [getWebElement, getWebTextArea]); useEffect(() => { if (!isWeb) { return; } const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null; if (!textarea || typeof textarea.addEventListener !== "function") { return; } const handleScroll = () => { const textareaElement = textarea as unknown as HTMLElement; const chatScroller = typeof document !== "undefined" ? (document.querySelector('[data-testid="agent-chat-scroll"]') as HTMLElement | null) : null; logWebStickyBottom("composer_textarea_scrolled", { now: getDebugNow(), scrollTop: textarea.scrollTop, clientHeight: textarea.clientHeight ?? null, scrollHeight: textarea.scrollHeight ?? null, selectionStart: textarea.selectionStart ?? null, selectionEnd: textarea.selectionEnd ?? null, textareaDescriptor: getElementDescriptor(textareaElement), chatScrollerDescriptor: getElementDescriptor(chatScroller), chatScrollerContainsTextarea: Boolean( chatScroller && textareaElement && chatScroller.contains(textareaElement), ), textareaScrollableAncestors: getScrollableAncestorChain(textareaElement), valueLength: valueRef.current.length, }); }; textarea.addEventListener("scroll", handleScroll, { passive: true }); return () => { textarea.removeEventListener("scroll", handleScroll); }; }, [getWebTextArea]); function setBoundedInputHeight(nextHeight: number) { const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight)); if (Math.abs(inputHeightRef.current - bounded) < 1) return; inputHeightRef.current = bounded; setInputHeight(bounded); onHeightChange?.(bounded); } useComposerHeightMirror({ value, textareaRef: webTextareaRef, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT, onHeight: setBoundedInputHeight, }); function handleContentSizeChange( event: NativeSyntheticEvent, ) { if (isWeb) return; setBoundedInputHeight(event.nativeEvent.contentSize.height); } function handleSelectionChange(event: NativeSyntheticEvent) { const start = event.nativeEvent.selection?.start ?? 0; const end = event.nativeEvent.selection?.end ?? start; if (isWeb) { const textarea = getWebTextArea(); logWebStickyBottom("composer_selection_changed", { now: getDebugNow(), start, end, textareaScrollTop: textarea?.scrollTop ?? null, textareaClientHeight: textarea?.clientHeight ?? null, textareaScrollHeight: textarea?.scrollHeight ?? null, }); } onSelectionChangeCallback?.({ start, end }); } 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 (isImeComposingKeyboardEvent(event.nativeEvent)) return; // Allow parent to intercept key events (e.g., for autocomplete navigation) if (onKeyPressCallback) { const handled = onKeyPressCallback({ key: event.nativeEvent.key, preventDefault: () => event.preventDefault(), }); if (handled) return; } const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; if (event.nativeEvent.key !== "Enter") return; // Shift+Enter: add newline (default behavior, don't intercept) if (shiftKey) return; // Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): alternate action if ((metaKey || ctrlKey) && isAgentRunning && onQueue) { if (isSubmitDisabled || isSubmitLoading || disabled) return; event.preventDefault(); handleAlternateSendAction(); return; } // Enter: default send action (interrupt or queue, based on setting) if (isSubmitDisabled || isSubmitLoading || disabled) return; event.preventDefault(); handleDefaultSendAction(); } const hasAttachments = attachments.length > 0; const hasRealContent = value.trim().length > 0 || hasAttachments; const hasSendableContent = hasRealContent || hasExternalContent; const shouldShowSendButton = hasSendableContent || allowEmptySubmit || isSubmitLoading; const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function"; const isSendButtonDisabled = disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)); const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning; const defaultSubmitAccessibilityLabel = canPressLoadingButton ? "Interrupt agent" : defaultActionQueues ? "Queue message" : isAgentRunning ? "Send and interrupt" : "Send message"; const submitAccessibilityLabel = submitButtonAccessibilityLabel ?? defaultSubmitAccessibilityLabel; const handleInputChange = useCallback( (nextValue: string) => { markScrollInvestigationEvent(investigationComponentId, "inputChange"); onChangeText(nextValue); if (isWeb) { logWebStickyBottom("composer_text_changed", { valueLength: nextValue.length, lineCount: nextValue.split("\n").length, }); } }, [investigationComponentId, onChangeText], ); return ( {/* Regular input */} {/* Text input */} { isInputFocusedRef.current = true; setIsInputFocused(true); onFocusChange?.(true); }} onBlur={() => { isInputFocusedRef.current = false; setIsInputFocused(false); onFocusChange?.(false); }} style={[ styles.textInput, isWeb ? { height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT, } : { minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT, }, ]} multiline scrollEnabled={isWeb ? inputHeight >= MAX_INPUT_HEIGHT : true} onContentSizeChange={handleContentSizeChange} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} onSelectionChange={handleSelectionChange} autoFocus={isWeb && autoFocus} /> {inputScrollbar} {isWeb && isPaneFocused && !isInputFocused && !value && focusInputKeys ? ( {formatShortcut(focusInputKeys[0], getShortcutOs())} to focus ) : null} {/* Button row */} {/* Left: attachment button + leftContent slot */} [ styles.attachButton, hovered && styles.iconButtonHovered, (!isConnected || disabled) && styles.buttonDisabled, ]} > {({ hovered }) => ( )} Add attachment {attachmentMenuItems.map((item) => ( {item.label} ))} {leftContent} {/* Right: voice button, contextual button (realtime/send/cancel) */} {beforeVoiceContent} [ styles.voiceButton, hovered && !isDictating && styles.iconButtonHovered, !isDictationStartEnabled && styles.buttonDisabled, isDictating && styles.voiceButtonRecording, ]} > {({ hovered }) => isDictating ? ( ) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? ( ) : ( ) } {isRealtimeVoiceForCurrentAgent ? voice?.isMuted ? "Unmute voice" : "Mute voice" : "Dictation"} {(isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys) ? ( ) : null} {rightContent} {shouldShowSendButton && ( {isSubmitLoading ? ( ) : submitIcon === "return" ? ( ) : ( )} {submitButtonAccessibilityLabel ?? (defaultActionQueues ? "Queue" : "Send")} {sendKeys ? : null} )} {/* Dictation overlay */} {showDictationOverlay ? ( ) : showRealtimeOverlay && voice ? ( { void handleStopRealtimeVoice(); }} /> ) : null} ); }); const styles = StyleSheet.create(((theme: any) => ({ container: { position: "relative", }, inputWrapper: { flexDirection: "column", gap: theme.spacing[3], backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, borderRadius: theme.borderRadius["2xl"], paddingVertical: { xs: theme.spacing[2], md: theme.spacing[4], }, paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[4], }, ...(isWeb ? { transitionProperty: "border-color", transitionDuration: "200ms", transitionTimingFunction: "ease-in-out", } : {}), }, textInputScrollWrapper: { position: "relative", }, focusHintText: { position: "absolute", top: 0, right: 0, fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, opacity: 0.5, }, textInput: { width: "100%", color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, lineHeight: theme.fontSize.base * 1.4, ...(isWeb ? { outlineStyle: "none" as const, outlineWidth: 0, outlineColor: "transparent", } : {}), }, buttonRow: { flexDirection: "row", alignItems: "flex-end", justifyContent: "space-between", marginHorizontal: -6, }, leftButtonGroup: { minWidth: 0, flexShrink: 1, flexGrow: 1, flexDirection: "row", alignItems: "flex-end", gap: theme.spacing[0], }, rightButtonGroup: { flexShrink: 0, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], }, attachButton: { width: 28, height: 28, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, attachButtonAnchor: { width: 28, height: 28, alignItems: "center", justifyContent: "center", }, voiceButton: { width: 28, height: 28, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, voiceButtonRecording: { backgroundColor: theme.colors.destructive, }, sendButton: { width: 28, height: 28, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.accent, alignItems: "center", justifyContent: "center", marginLeft: theme.spacing[1], }, iconButtonHovered: { backgroundColor: theme.colors.surface2, }, tooltipRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, tooltipText: { fontSize: theme.fontSize.sm, color: theme.colors.popoverForeground, }, tooltipShortcut: { backgroundColor: theme.colors.surface3, borderColor: theme.colors.borderAccent, }, buttonDisabled: { opacity: 0.5, }, overlayContainer: { position: "absolute", display: "flex", alignItems: "center", justifyContent: "center", top: 0, left: 0, width: "100%", height: "100%", right: 0, bottom: 0, }, })) as any) as Record;