import { View, Text, TextInput, Pressable, ActivityIndicator, NativeSyntheticEvent, TextInputContentSizeChangeEventData, TextInputKeyPressEventData, 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 { usePanelStore } from "@/stores/panel-store"; 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; export interface ImageAttachment { uri: string; mimeType: string; } 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; onRemoveImage?: (index: number) => void; client: DaemonClient | null; placeholder?: string; autoFocus?: boolean; 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; /** Intercept key press events before default handling. Return true to prevent default. */ onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean; } export interface MessageInputRef { focus: () => void; blur: () => 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; style?: { height?: string; overflowY?: string; } & Record; }; export const MessageInput = forwardRef( function MessageInput( { value, onChangeText, onSubmit, isSubmitDisabled = false, isSubmitLoading = false, images = [], onPickImages, onRemoveImage, client, placeholder = "Message...", autoFocus = false, disabled = false, isScreenFocused = true, leftContent, rightContent, voiceServerId, voiceAgentId, isAgentRunning = false, onQueue, onKeyPress: onKeyPressCallback, }, ref ) { const { theme } = useUnistyles(); const toast = useToast(); const voice = useVoiceOptional(); const toggleAgentList = usePanelStore((state) => state.toggleAgentList); const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); const textInputRef = useRef< TextInput | (TextInput & { getNativeRef?: () => unknown }) | null >(null); const isInputFocusedRef = useRef(false); useImperativeHandle(ref, () => ({ focus: () => { textInputRef.current?.focus(); }, blur: () => { textInputRef.current?.blur?.(); }, 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(() => { valueRef.current = value; }, [value]); // Autofocus on web when autoFocus prop is true useEffect(() => { if (!IS_WEB || !autoFocus) return; // Use requestAnimationFrame to ensure DOM is ready const rafId = requestAnimationFrame(() => { textInputRef.current?.focus(); }); return () => cancelAnimationFrame(rafId); }, [autoFocus]); 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 }); } else { onChangeText(nextValue); } if (IS_WEB && typeof requestAnimationFrame === "function") { requestAnimationFrame(() => { measureWebInputHeight("dictation"); }); } }, [onChangeText, onSubmit, images] ); 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; return socketConnected && !disabled && !dictationUnavailableMessage; }, [client, disabled, dictationUnavailableMessage]); const canConfirmDictation = useCallback(() => { const socketConnected = client?.isConnected ?? false; return socketConnected; }, [client]); const isConnected = client?.isConnected ?? false; 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(() => { if (isDictating || isDictationProcessing) { return; } sendAfterTranscriptRef.current = false; }, [dictationStatus, isDictating, isDictationProcessing]); const startDictationIfAvailable = useCallback(async () => { if (dictationUnavailableMessage) { toast.error(dictationUnavailableMessage); return; } await startDictation(); }, [dictationUnavailableMessage, startDictation, toast]); // Cmd+D to start/submit dictation, Cmd+Shift+D toggles realtime voice, Escape cancels dictation useEffect(() => { if (!IS_WEB) return; const toggleRealtimeVoice = () => { if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) { return; } if (voice.isVoiceSwitching) { return; } if (voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)) { const tasks: Promise[] = []; if (isAgentRunning && client) { tasks.push(client.cancelAgent(voiceAgentId)); } tasks.push(voice.stopVoice()); void Promise.allSettled(tasks).then((results) => { results.forEach((result) => { if (result.status === "rejected") { console.error( "[MessageInput] Failed to stop realtime voice", result.reason ); } }); }); 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); } }); }; const resolveNativeInput = (): unknown => { const current = textInputRef.current as any; if (!current) return null; if (typeof current.getNativeRef === "function") { return current.getNativeRef(); } return current; }; function handleKeyDown(event: KeyboardEvent) { const nativeInput = resolveNativeInput(); const isFromInput = Boolean(nativeInput && event.target === nativeInput); if (!isScreenFocused && !isInputFocusedRef.current && !isFromInput) { return; } const isMod = event.metaKey || event.ctrlKey; const isKeyD = event.code === "KeyD" || event.key.toLowerCase() === "d"; if (isMod && event.shiftKey && isKeyD && !event.repeat) { event.preventDefault(); toggleRealtimeVoice(); return; } if ( isRealtimeVoiceForCurrentAgent && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey && (event.code === "Space" || event.key === " ") && !event.repeat ) { event.preventDefault(); voice?.toggleMute(); return; } const dictating = isDictatingRef.current; // Cmd+D: start dictation or submit if already dictating if (isMod && isKeyD) { event.preventDefault(); if (dictating) { sendAfterTranscriptRef.current = true; confirmDictation(); } else { void startDictationIfAvailable(); } return; } // Escape: cancel dictation if (event.key === "Escape" && dictating) { event.preventDefault(); cancelDictation(); } } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [ cancelDictation, client, confirmDictation, disabled, isAgentRunning, isConnected, isRealtimeVoiceForCurrentAgent, isScreenFocused, startDictationIfAvailable, toast, voiceAgentId, voiceServerId, voice, ]); // 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 && 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); }, [value, images, onSubmit, isAgentRunning]); 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); }, [value, images, onQueue, onChangeText]); // Web input height measurement function isTextAreaLike(v: unknown): v is TextAreaHandle { return typeof v === "object" && v !== null && "scrollHeight" in v; } function getWebTextArea(): 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; } function measureWebInputHeight(source: string): boolean { if (!IS_WEB) return false; const textarea = getWebTextArea(); if (!textarea || typeof textarea.scrollHeight !== "number") return false; const prevHeight = textarea.style?.height; const prevOverflow = textarea.style?.overflowY; if (textarea.style) { textarea.style.height = "auto"; textarea.style.overflowY = "hidden"; } const scrollHeight = textarea.scrollHeight ?? 0; if (textarea.style) { textarea.style.height = prevHeight ?? ""; textarea.style.overflowY = prevOverflow ?? ""; } if (baselineInputHeightRef.current === null && scrollHeight > 0) { baselineInputHeightRef.current = 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) ); if (Math.abs(inputHeightRef.current - bounded) >= 1) { inputHeightRef.current = bounded; setInputHeight(bounded); 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; inputHeightRef.current = bounded; setInputHeight(bounded); } function handleContentSizeChange( event: NativeSyntheticEvent ) { if (IS_WEB) { measureWebInputHeight("contentSizeChange"); return; } const contentHeight = event.nativeEvent.contentSize.height; setBoundedInputHeight(contentHeight); } const shouldHandleDesktopSubmit = IS_WEB; function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { 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; const key = event.nativeEvent.key.toLowerCase(); // Cmd+B or Ctrl+B: toggle sidebar if ((metaKey || ctrlKey) && key === "b") { event.preventDefault(); toggleAgentList(); return; } // Cmd+E or Ctrl+E: toggle explorer sidebar if ((metaKey || ctrlKey) && key === "e") { event.preventDefault(); toggleFileExplorer(); return; } // Cmd+Shift+D or Ctrl+Shift+D: toggle realtime voice mode if ((metaKey || ctrlKey) && shiftKey && key === "d") { event.preventDefault(); handleToggleRealtimeVoiceShortcut(); return; } // Cmd+D or Ctrl+D: start dictation or submit if already dictating if ((metaKey || ctrlKey) && key === "d") { event.preventDefault(); if (isDictating) { sendAfterTranscriptRef.current = true; confirmDictation(); } else { void startDictationIfAvailable(); } return; } // Escape: cancel dictation if (event.nativeEvent.key === "Escape" && isDictating) { event.preventDefault(); cancelDictation(); return; } if ( isRealtimeVoiceForCurrentAgent && !metaKey && !ctrlKey && !shiftKey && event.nativeEvent.key === " " ) { event.preventDefault(); voice?.toggleMute(); return; } 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; return ( {/* Regular input */} {/* Image preview pills */} {hasImages && ( {images.map((image, index) => ( onRemoveImage(index) : undefined} > {({ hovered }) => ( <> {onRemoveImage && ( )} )} ))} )} {/* Text input */} { isInputFocusedRef.current = true; }} onBlur={() => { isInputFocusedRef.current = 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 } 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} {shouldShowSendButton && 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, }, 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;