import { View, Text, TextInput, Pressable, ActivityIndicator, NativeSyntheticEvent, TextInputContentSizeChangeEventData, TextInputKeyPressEventData, TextInputSelectionChangeEventData, Image, Platform, BackHandler, } from 'react-native' import { useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react' import { StyleSheet, useUnistyles } from 'react-native-unistyles' import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, 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 { useAttachmentPreviewUrl } from '@/attachments/use-attachment-preview-url' import { focusWithRetries } from '@/utils/web-focus' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Shortcut } from '@/components/ui/shortcut' import type { MessageInputKeyboardActionKind } from '@/keyboard/actions' import { markScrollInvestigationEvent, markScrollInvestigationRender, } from '@/utils/scroll-jank-investigation' export type ImageAttachment = AttachmentMetadata export interface MessagePayload { text: string images?: ImageAttachment[] /** When true, bypasses queue and sends immediately even if agent is running */ forceSend?: boolean } export interface MessageInputProps { value: string onChangeText: (text: string) => void onSubmit: (payload: MessagePayload) => void isSubmitDisabled?: boolean isSubmitLoading?: boolean images?: ImageAttachment[] onPickImages?: () => void onAddImages?: (images: ImageAttachment[]) => void onRemoveImage?: (index: number) => 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 the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */ isScreenFocused?: 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 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 /** 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 } export interface MessageInputRef { focus: () => void blur: () => void runKeyboardAction: (action: MessageInputKeyboardActionKind) => void /** * 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 = 30 const MAX_INPUT_HEIGHT = 160 const IS_WEB = Platform.OS === 'web' type WebTextInputKeyPressEvent = NativeSyntheticEvent< TextInputKeyPressEventData & { metaKey?: boolean ctrlKey?: boolean shiftKey?: boolean } > 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 } function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) { const uri = useAttachmentPreviewUrl(image) if (!uri) { return } return } export const MessageInput = forwardRef(function MessageInput( { value, onChangeText, onSubmit, isSubmitDisabled = false, isSubmitLoading = false, images = [], onPickImages, onAddImages, onRemoveImage, client, isReadyForDictation, placeholder = 'Message...', autoFocus = false, autoFocusKey, disabled = false, isScreenFocused = true, leftContent, rightContent, voiceServerId, voiceAgentId, isAgentRunning = false, onQueue, onSubmitLoadingPress, onKeyPress: onKeyPressCallback, onSelectionChange: onSelectionChangeCallback, onFocusChange, onHeightChange, }, ref ) { const { theme } = useUnistyles() const investigationComponentId = `MessageInput:${voiceServerId ?? 'unknown-server'}:${voiceAgentId ?? 'unknown-agent'}` markScrollInvestigationRender(investigationComponentId) const toast = useToast() const voice = useVoiceOptional() console.log("[MessageInput] render", { voiceServerId: voiceServerId ?? null, voiceAgentId: voiceAgentId ?? null, hasVoice: Boolean(voice), isVoiceMode: voice?.isVoiceMode ?? false, isVoiceSwitching: voice?.isVoiceSwitching ?? false, isMuted: voice?.isMuted ?? false, disabled, isSubmitLoading, valueLength: value.length, imageCount: images.length, }) const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT) 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 } if (action === 'voice-toggle') { handleToggleRealtimeVoiceShortcut() return } if (action === 'voice-mute-toggle') { if (isRealtimeVoiceForCurrentAgent) { voice?.toggleMute() } return } if (action === 'dictation-cancel') { if (isDictatingRef.current) { cancelDictation() } return } if (action === 'dictation-toggle') { if (isDictatingRef.current) { sendAfterTranscriptRef.current = true confirmDictation() } else { void startDictationIfAvailable() } } }, getNativeElement: () => { if (!IS_WEB) 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 baselineInputHeightRef = useRef(null) 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(() => { console.log("[MessageInput] mount", { voiceServerId: voiceServerId ?? null, voiceAgentId: voiceAgentId ?? null, }) return () => { console.log("[MessageInput] unmount", { voiceServerId: voiceServerId ?? null, voiceAgentId: voiceAgentId ?? null, }) } }, [voiceAgentId, 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 (!IS_WEB || !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) { const imageAttachments = images.length > 0 ? images : undefined onSubmit({ text: nextValue, images: imageAttachments, forceSend: isAgentRunning || undefined, }) } else { onChangeText(nextValue) } if (IS_WEB && typeof requestAnimationFrame === 'function') { requestAnimationFrame(() => { measureWebInputHeight('dictation') }) } }, [onChangeText, onSubmit, images, isAgentRunning] ) const handleDictationError = useCallback((error: Error) => { console.error('[MessageInput] Dictation error:', error) }, []) 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: isScreenFocused }, 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(() => { console.log("[MessageInput] overlay_state", { showDictationOverlay, showRealtimeOverlay, showOverlay, isDictating, isDictationProcessing, dictationStatus, isRealtimeVoiceForCurrentAgent, }) }, [ dictationStatus, isDictating, isDictationProcessing, isRealtimeVoiceForCurrentAgent, showDictationOverlay, showOverlay, 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 } // 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() }, [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 () => { console.log("[MessageInput] handleVoicePress", { isRealtimeVoiceForCurrentAgent, isDictating, hasVoice: Boolean(voice), }) if (isRealtimeVoiceForCurrentAgent && voice) { voice.toggleMute() return } if (isDictating) { await cancelDictation() } else { await startDictationIfAvailable() } }, [ cancelDictation, isDictating, isRealtimeVoiceForCurrentAgent, startDictationIfAvailable, voice, ]) const handleCancelRecording = useCallback(async () => { console.log("[MessageInput] handleCancelRecording") await cancelDictation() }, [cancelDictation]) const handleAcceptRecording = useCallback(async () => { console.log("[MessageInput] handleAcceptRecording") sendAfterTranscriptRef.current = false await confirmDictation() }, [confirmDictation]) const handleAcceptAndSendRecording = useCallback(async () => { console.log("[MessageInput] handleAcceptAndSendRecording") sendAfterTranscriptRef.current = true await confirmDictation() }, [confirmDictation]) const handleRetryFailedRecording = useCallback(() => { console.log("[MessageInput] handleRetryFailedRecording") void retryFailedDictation() }, [retryFailedDictation]) const handleDiscardFailedRecording = useCallback(() => { console.log("[MessageInput] handleDiscardFailedRecording") discardFailedDictation() }, [discardFailedDictation]) const handleStopRealtimeVoice = useCallback(async () => { console.log("[MessageInput] handleStopRealtimeVoice", { hasVoice: Boolean(voice), isRealtimeVoiceForCurrentAgent, isAgentRunning, voiceAgentId: voiceAgentId ?? null, }) 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(() => { console.log("[MessageInput] handleToggleRealtimeVoiceShortcut", { hasVoice: Boolean(voice), voiceServerId: voiceServerId ?? null, voiceAgentId: voiceAgentId ?? null, isConnected, disabled, isVoiceSwitching: voice?.isVoiceSwitching ?? false, isVoiceModeForAgent: voice && voiceServerId && voiceAgentId ? voice.isVoiceModeForAgent(voiceServerId, voiceAgentId) : false, }) 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(() => { console.log("[MessageInput] handleSendMessage", { valueLength: value.length, imageCount: images.length, isAgentRunning, }) const trimmed = value.trim() if (!trimmed && images.length === 0) return const payload = { text: trimmed, images: images.length > 0 ? images : undefined, forceSend: isAgentRunning || undefined, } onSubmit(payload) inputHeightRef.current = MIN_INPUT_HEIGHT setInputHeight(MIN_INPUT_HEIGHT) onHeightChange?.(MIN_INPUT_HEIGHT) }, [value, images, onSubmit, isAgentRunning, onHeightChange]) const handleQueueMessage = useCallback(() => { if (!onQueue) return const trimmed = value.trim() if (!trimmed && images.length === 0) return const payload = { text: trimmed, images: images.length > 0 ? images : undefined, } onQueue(payload) onChangeText('') inputHeightRef.current = MIN_INPUT_HEIGHT setInputHeight(MIN_INPUT_HEIGHT) onHeightChange?.(MIN_INPUT_HEIGHT) }, [value, images, onQueue, onChangeText, onHeightChange]) // 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 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 (!IS_WEB || !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 (!IS_WEB || 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 (!IS_WEB) { 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 measureWebInputHeight(source: string): boolean { if (!IS_WEB) return false const textarea = getWebTextArea() if (!textarea || typeof textarea.scrollHeight !== 'number') return false const scrollHeight = textarea.scrollHeight ?? 0 if (baselineInputHeightRef.current === null && scrollHeight > 0) { baselineInputHeightRef.current = scrollHeight logWebStickyBottom('composer_baseline_measured', { source, baseline: scrollHeight, }) } const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT const rawTarget = scrollHeight > 0 ? scrollHeight : baseline const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, rawTarget)) const previousHeight = inputHeightRef.current if (Math.abs(previousHeight - bounded) >= 1) { inputHeightRef.current = bounded setInputHeight(bounded) onHeightChange?.(bounded) logWebStickyBottom('composer_height_changed', { source, previousHeight, nextHeight: bounded, scrollHeight, clientHeight: textarea.clientHeight ?? null, offsetHeight: textarea.offsetHeight ?? null, baseline, rawTarget, }) return true } return false } 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 const previousHeight = inputHeightRef.current inputHeightRef.current = bounded setInputHeight(bounded) onHeightChange?.(bounded) logWebStickyBottom('composer_height_changed_native', { previousHeight, nextHeight: bounded, }) } function handleContentSizeChange( event: NativeSyntheticEvent ) { const contentHeight = event.nativeEvent.contentSize.height if (IS_WEB) { logWebStickyBottom('composer_content_size_change', { reportedHeight: contentHeight, }) if (baselineInputHeightRef.current === null && contentHeight > 0) { baselineInputHeightRef.current = contentHeight logWebStickyBottom('composer_baseline_measured', { source: 'contentSizeChange', baseline: contentHeight, }) } setBoundedInputHeight(contentHeight) return } setBoundedInputHeight(contentHeight) } function handleSelectionChange(event: NativeSyntheticEvent) { const start = event.nativeEvent.selection?.start ?? 0 const end = event.nativeEvent.selection?.end ?? start if (IS_WEB) { 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 = IS_WEB function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { markScrollInvestigationEvent(investigationComponentId, 'keyPress') if (!shouldHandleDesktopSubmit) 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): queue when agent is running if ((metaKey || ctrlKey) && isAgentRunning && onQueue) { if (isSubmitDisabled || isSubmitLoading || disabled) return event.preventDefault() handleQueueMessage() return } // Enter: send (interrupts agent if running) if (isSubmitDisabled || isSubmitLoading || disabled) return event.preventDefault() handleSendMessage() } const hasImages = images.length > 0 const hasSendableContent = value.trim().length > 0 || hasImages const shouldShowSendButton = hasSendableContent || isSubmitLoading const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === 'function' const isSendButtonDisabled = disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)) const submitAccessibilityLabel = canPressLoadingButton ? 'Interrupt agent' : isAgentRunning ? 'Send and interrupt' : 'Send message' const handleInputChange = useCallback( (nextValue: string) => { markScrollInvestigationEvent(investigationComponentId, 'inputChange') onChangeText(nextValue) if (IS_WEB) { logWebStickyBottom('composer_text_changed', { valueLength: nextValue.length, lineCount: nextValue.split('\n').length, }) } }, [investigationComponentId, onChangeText] ) return ( {/* Regular input */} {/* Image preview pills */} {hasImages && ( {images.map((image, index) => ( onRemoveImage(index) : undefined} > {({ hovered }) => ( <> {onRemoveImage && ( )} )} ))} )} {/* Text input */} { isInputFocusedRef.current = true onFocusChange?.(true) }} onBlur={() => { isInputFocusedRef.current = false onFocusChange?.(false) }} style={[ styles.textInput, IS_WEB ? { height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT, } : { minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT, }, ]} multiline scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true} onContentSizeChange={handleContentSizeChange} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} onSelectionChange={handleSelectionChange} autoFocus={IS_WEB && autoFocus} /> {/* Button row */} {/* Left: attachment button + leftContent slot */} {onPickImages && ( Attach images )} {leftContent} {/* Right: voice button, contextual button (realtime/send/cancel) */} {isDictating ? ( ) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? ( ) : ( )} {isRealtimeVoiceForCurrentAgent ? voice?.isMuted ? 'Unmute voice' : 'Mute voice' : 'Dictation'} {rightContent} {hasSendableContent && isAgentRunning && onQueue && ( Queue )} {shouldShowSendButton && ( {isSubmitLoading ? ( ) : ( )} Send )} {/* 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], }, ...(IS_WEB ? { transitionProperty: 'border-color', transitionDuration: '200ms', transitionTimingFunction: 'ease-in-out', } : {}), }, imagePreviewContainer: { flexDirection: 'row', gap: theme.spacing[2], flexWrap: 'wrap', }, imagePill: { position: 'relative', borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.borderAccent, overflow: 'hidden', ...(IS_WEB ? { cursor: 'pointer', } : {}), }, imageThumbnail: { width: 48, height: 48, }, imageThumbnailPlaceholder: { width: 48, height: 48, backgroundColor: theme.colors.surface2, }, removeImageButton: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.5)', opacity: 0, ...(IS_WEB ? { transitionProperty: 'opacity', transitionDuration: '150ms', } : {}), }, removeImageButtonVisible: { opacity: 1, }, textInput: { width: '100%', color: theme.colors.foreground, fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.normal, lineHeight: theme.fontSize.lg * 1.4, ...(IS_WEB ? { outlineStyle: 'none' as const, outlineWidth: 0, outlineColor: 'transparent', } : {}), }, buttonRow: { flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-between', }, leftButtonGroup: { flexDirection: 'row', alignItems: 'center', gap: theme.spacing[2], }, rightButtonGroup: { flexDirection: 'row', alignItems: 'center', gap: theme.spacing[2], }, attachButton: { width: 34, height: 34, borderRadius: theme.borderRadius.full, alignItems: 'center', justifyContent: 'center', }, voiceButton: { width: 34, height: 34, borderRadius: theme.borderRadius.full, alignItems: 'center', justifyContent: 'center', }, voiceButtonRecording: { backgroundColor: theme.colors.destructive, }, queueButton: { width: 34, height: 34, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.surface1, alignItems: 'center', justifyContent: 'center', }, sendButton: { width: 34, height: 34, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.accent, alignItems: 'center', justifyContent: 'center', }, 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