import { View, Text, TextInput, ActivityIndicator, NativeSyntheticEvent, TextInputContentSizeChangeEventData, TextInputKeyPressEventData, TextInputSelectionChangeEventData, } from "react-native"; import { useState, useRef, useCallback, useEffect, useLayoutEffect, useImperativeHandle, useMemo, forwardRef, } from "react"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { ICON_SIZE, type Theme } from "@/styles/theme"; 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 { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit"; 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 { isWeb } from "@/constants/platform"; import { useComposerHeightMirror } from "./composer-height-mirror"; import { computeCanStartDictation } from "./message-input-state"; 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; /** Content rendered inside the bordered input surface, above the text input (e.g. attachment pills). */ attachmentSlot?: React.ReactNode; } 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; } >; interface TextAreaHandle { scrollHeight?: number; clientHeight?: number; offsetHeight?: number; scrollTop?: number; selectionStart?: number | null; selectionEnd?: number | null; style?: { height?: string; overflowY?: string; } & Record; } function AttachButtonIcon({ hovered, onAttachButtonRef, buttonIconSize, }: { hovered: boolean; onAttachButtonRef: ((node: View | null) => void) | undefined; buttonIconSize: number; }) { const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; return ( ); } function AttachmentMenuList({ items }: { items: AttachmentMenuItem[] }) { return ( <> {items.map((item) => ( {item.label} ))} ); } function AttachmentDropdown({ isConnected, disabled, attachButtonStyle, renderAttachButtonIcon, attachmentMenuItems, }: { isConnected: boolean; disabled: boolean; attachButtonStyle: React.ComponentProps["style"]; renderAttachButtonIcon: (input: { hovered?: boolean }) => React.ReactElement; attachmentMenuItems: AttachmentMenuItem[]; }) { return ( {renderAttachButtonIcon} Add attachment ); } function VoiceButtonIcon({ hovered, isDictating, isMutedRealtime, buttonIconSize, }: { hovered: boolean; isDictating: boolean; isMutedRealtime: boolean; buttonIconSize: number; }) { if (isDictating) { return ; } const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; if (isMutedRealtime) { return ; } return ; } type ShortcutChord = NonNullable["chord"]>; function VoiceTooltipBody({ voiceTooltipText, shortcut, }: { voiceTooltipText: string; shortcut: ShortcutChord | null | undefined; }) { return ( {voiceTooltipText} {shortcut ? : null} ); } function SendTooltipBody({ label, sendKeys, }: { label: string; sendKeys: ShortcutChord | null | undefined; }) { return ( {label} {sendKeys ? : null} ); } function SendButtonContent({ isSubmitLoading, submitIcon, buttonIconSize, }: { isSubmitLoading: boolean; submitIcon: "arrow" | "return"; buttonIconSize: number; }) { if (isSubmitLoading) { return ; } if (submitIcon === "return") { return ; } return ; } function resolveSubmitAccessibilityLabel(input: { submitButtonAccessibilityLabel: string | undefined; canPressLoadingButton: boolean; defaultActionQueues: boolean; isAgentRunning: boolean; }): string { if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel; if (input.canPressLoadingButton) return "Interrupt agent"; if (input.defaultActionQueues) return "Queue message"; if (input.isAgentRunning) return "Send and interrupt"; return "Send message"; } function resolveVoiceAccessibilityLabel(input: { isRealtimeVoiceForCurrentAgent: boolean; isMuted: boolean; isDictating: boolean; }): string { if (input.isRealtimeVoiceForCurrentAgent) { return input.isMuted ? "Unmute Voice mode" : "Mute Voice mode"; } if (input.isDictating) return "Stop dictation"; return "Start dictation"; } function resolveVoiceTooltipText(input: { isRealtimeVoiceForCurrentAgent: boolean; isMuted: boolean; }): string { if (input.isRealtimeVoiceForCurrentAgent) { return input.isMuted ? "Unmute voice" : "Mute voice"; } return "Dictation"; } function resolveSendTooltipLabel(input: { submitButtonAccessibilityLabel: string | undefined; defaultActionQueues: boolean; }): string { if (input.submitButtonAccessibilityLabel) return input.submitButtonAccessibilityLabel; return input.defaultActionQueues ? "Queue" : "Send"; } interface DesktopKeyPressContext { onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined; isAgentRunning: boolean; onQueue: ((payload: MessagePayload) => void) | undefined; isSubmitDisabled: boolean; isSubmitLoading: boolean; disabled: boolean; handleAlternateSendAction: () => void; handleDefaultSendAction: () => void; } function handleDesktopKeyPressImpl( event: WebTextInputKeyPressEvent, ctx: DesktopKeyPressContext, ): void { if (isImeComposingKeyboardEvent(event.nativeEvent)) return; if (ctx.onKeyPressCallback) { const handled = ctx.onKeyPressCallback({ key: event.nativeEvent.key, preventDefault: () => event.preventDefault(), }); if (handled) return; } const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; if (event.nativeEvent.key !== "Enter") return; if (shiftKey) return; if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) { if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; event.preventDefault(); ctx.handleAlternateSendAction(); return; } if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; event.preventDefault(); ctx.handleDefaultSendAction(); } interface KeyboardActionHandlers { textInputRef: React.MutableRefObject< TextInput | (TextInput & { getNativeRef?: () => unknown }) | null >; isDictatingRef: React.MutableRefObject; sendAfterTranscriptRef: React.MutableRefObject; confirmDictation: () => void | Promise; cancelDictation: () => void | Promise; startDictationIfAvailable: () => Promise; handleToggleRealtimeVoiceShortcut: () => void; isRealtimeVoiceForCurrentAgent: boolean; voice: { toggleMute: () => void } | null | undefined; } function runKeyboardActionImpl( action: MessageInputKeyboardActionKind, h: KeyboardActionHandlers, ): boolean { if (action === "focus") { h.textInputRef.current?.focus(); return true; } if (action === "send" || action === "dictation-confirm") { if (h.isDictatingRef.current) { h.sendAfterTranscriptRef.current = true; void h.confirmDictation(); return true; } return false; } if (action === "voice-toggle") { h.handleToggleRealtimeVoiceShortcut(); return true; } if (action === "voice-mute-toggle") { if (h.isRealtimeVoiceForCurrentAgent) { h.voice?.toggleMute(); } return true; } if (action === "dictation-cancel") { if (h.isDictatingRef.current) { void h.cancelDictation(); return true; } return false; } if (action === "dictation-toggle") { if (h.isDictatingRef.current) { h.sendAfterTranscriptRef.current = true; void h.confirmDictation(); } else { void h.startDictationIfAvailable(); } return true; } return false; } function getTextInputNativeElement( current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null, ): HTMLElement | null { if (!current) return null; const handle = current as TextInput & { getNativeRef?: () => unknown }; const native = typeof handle.getNativeRef === "function" ? handle.getNativeRef() : current; return native instanceof HTMLElement ? native : null; } interface PasteImagesEffectArgs { getWebTextArea: () => TextAreaHandle | null; isConnected: boolean; disabled: boolean; isDictating: boolean; isRealtimeVoiceForCurrentAgent: boolean; onAddImages: ((images: ImageAttachment[]) => void) | undefined; } function usePasteImagesEffect(args: PasteImagesEffectArgs): void { const { getWebTextArea, isConnected, disabled, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages, } = args; useEffect(() => { if (!isWeb || !onAddImages) return; const textarea = getWebTextArea() as | (TextAreaHandle & { addEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void; removeEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void; }) | null; if ( !textarea || typeof textarea.addEventListener !== "function" || typeof textarea.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((pastedAttachments) => { if (disposed || pastedAttachments.length === 0) return; onAddImages(pastedAttachments); return; }) .catch((error) => { console.error("[MessageInput] Failed to process pasted images:", error); }); }; textarea.addEventListener("paste", handlePaste); return () => { disposed = true; textarea.removeEventListener?.("paste", handlePaste); }; }, [ disabled, getWebTextArea, isConnected, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages, ]); } function useAutoFocusOnWebEffect( textInputRef: React.MutableRefObject< TextInput | (TextInput & { getNativeRef?: () => unknown }) | null >, autoFocus: boolean, autoFocusKey: string | undefined, ): void { useEffect(() => { if (!isWeb || !autoFocus) return; return focusWithRetries({ focus: () => textInputRef.current?.focus(), isFocused: () => { const element = getTextInputNativeElement(textInputRef.current); const active = typeof document !== "undefined" ? document.activeElement : null; return Boolean(element) && active === element; }, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoFocus, autoFocusKey]); } function MessageInputOverlay({ showDictationOverlay, showRealtimeOverlay, voice, dictationVolume, dictationDuration, isDictating, isDictationProcessing, dictationStatus, dictationError, onCancelRecording, onAcceptRecording, onAcceptAndSendRecording, onRetryFailedRecording, onDiscardFailedRecording, onRealtimeVoiceStop, }: { showDictationOverlay: boolean; showRealtimeOverlay: boolean; voice: | { isMuted: boolean; isVoiceSwitching: boolean; toggleMute: () => void; } | null | undefined; dictationVolume: number; dictationDuration: number; isDictating: boolean; isDictationProcessing: boolean; dictationStatus: React.ComponentProps["status"]; dictationError: string | null; onCancelRecording: () => Promise; onAcceptRecording: () => Promise; onAcceptAndSendRecording: () => Promise; onRetryFailedRecording: () => void; onDiscardFailedRecording: () => void; onRealtimeVoiceStop: () => void; }) { if (showDictationOverlay) { return ( ); } if (showRealtimeOverlay && voice) { return ( ); } return null; } function FocusHint({ visible, focusInputKeys, }: { visible: boolean; focusInputKeys: ShortcutChord | null | undefined; }) { if (!visible || !focusInputKeys) return null; return ( {formatShortcut(focusInputKeys[0], getShortcutOs())} to focus ); } function VoiceButtonTooltip({ onVoicePress, isDictationStartEnabled, voiceButtonAccessibilityLabel, voiceButtonStyle, renderVoiceButtonIcon, voiceTooltipText, isRealtimeVoiceForCurrentAgent, voiceMuteToggleKeys, dictationToggleKeys, }: { onVoicePress: () => void; isDictationStartEnabled: boolean; voiceButtonAccessibilityLabel: string; voiceButtonStyle: React.ComponentProps["style"]; renderVoiceButtonIcon: (input: { hovered?: boolean }) => React.ReactElement; voiceTooltipText: string; isRealtimeVoiceForCurrentAgent: boolean; voiceMuteToggleKeys: ShortcutChord | null | undefined; dictationToggleKeys: ShortcutChord | null | undefined; }) { const shortcut = isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys; return ( {renderVoiceButtonIcon} ); } function SendButtonTooltip({ shouldShow, canPressLoadingButton, onSubmitLoadingPress, onDefaultSendAction, isSendButtonDisabled, submitAccessibilityLabel, sendButtonCombinedStyle, isSubmitLoading, submitIcon, buttonIconSize, submitButtonAccessibilityLabel, defaultActionQueues, sendKeys, }: { shouldShow: boolean; canPressLoadingButton: boolean; onSubmitLoadingPress: (() => void) | undefined; onDefaultSendAction: () => void; isSendButtonDisabled: boolean; submitAccessibilityLabel: string; sendButtonCombinedStyle: React.ComponentProps["style"]; isSubmitLoading: boolean; submitIcon: "arrow" | "return"; buttonIconSize: number; submitButtonAccessibilityLabel: string | undefined; defaultActionQueues: boolean; sendKeys: ShortcutChord | null | undefined; }) { if (!shouldShow) return null; return ( ); } interface DictationTranscriptContext { value: string; defaultSendBehavior: "interrupt" | "queue"; isAgentRunning: boolean; onQueue: ((payload: MessagePayload) => void) | undefined; onSubmit: (payload: MessagePayload) => void; onChangeText: (text: string) => void; attachments: ComposerAttachment[]; cwd: string; autoSend: boolean; } function applyDictationTranscript(text: string, ctx: DictationTranscriptContext): void { if (!text) return; const shouldPad = ctx.value.length > 0 && !/\s$/.test(ctx.value); const nextValue = `${ctx.value}${shouldPad ? " " : ""}${text}`; if (!ctx.autoSend) { ctx.onChangeText(nextValue); return; } if (ctx.defaultSendBehavior === "queue" && ctx.isAgentRunning && ctx.onQueue) { ctx.onQueue({ text: nextValue, attachments: ctx.attachments, cwd: ctx.cwd }); ctx.onChangeText(""); return; } ctx.onSubmit({ text: nextValue, attachments: ctx.attachments, cwd: ctx.cwd, forceSend: ctx.isAgentRunning || undefined, }); } interface ToggleRealtimeVoiceContext { voice: | { isVoiceSwitching: boolean; isVoiceModeForAgent: (serverId: string, agentId: string) => boolean; startVoice: (serverId: string, agentId: string) => Promise; } | null | undefined; voiceServerId: string | undefined; voiceAgentId: string | undefined; isConnected: boolean; disabled: boolean; handleStopRealtimeVoice: () => Promise | void; toast: { error: (msg: string) => void }; } function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void { if (!ctx.voice || !ctx.voiceServerId || !ctx.voiceAgentId || !ctx.isConnected || ctx.disabled) { return; } if (ctx.voice.isVoiceSwitching) return; if (ctx.voice.isVoiceModeForAgent(ctx.voiceServerId, ctx.voiceAgentId)) { void ctx.handleStopRealtimeVoice(); return; } void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => { console.error("[MessageInput] Failed to start realtime voice", error); const message = extractErrorMessage(error); if (message && message.trim().length > 0) { ctx.toast.error(message); } }); } interface StartDictationContext { dictationUnavailableMessage: string | null | undefined; canStartDictation: () => boolean; isDictatingRef: React.MutableRefObject; toast: { error: (msg: string) => void }; startDictation: () => Promise; } async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise { if (ctx.dictationUnavailableMessage) { ctx.isDictatingRef.current = false; ctx.toast.error(ctx.dictationUnavailableMessage); return; } if (!ctx.canStartDictation()) { ctx.isDictatingRef.current = false; return; } ctx.isDictatingRef.current = true; await ctx.startDictation(); } interface StopRealtimeVoiceContext { voice: { stopVoice: () => Promise } | null | undefined; isRealtimeVoiceForCurrentAgent: boolean; isAgentRunning: boolean; client: { cancelAgent: (agentId: string) => Promise } | null; voiceAgentId: string | undefined; } async function stopRealtimeVoiceImpl(ctx: StopRealtimeVoiceContext): Promise { if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return; const tasks: Promise[] = []; if (ctx.isAgentRunning && ctx.client && ctx.voiceAgentId) { tasks.push(ctx.client.cancelAgent(ctx.voiceAgentId)); } tasks.push(ctx.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); } }); } interface VoicePressContext { isRealtimeVoiceForCurrentAgent: boolean; voice: { toggleMute: () => void } | null | undefined; isDictating: boolean; cancelDictation: () => Promise | void; startDictationIfAvailable: () => Promise; } async function handleVoicePressImpl(ctx: VoicePressContext): Promise { if (ctx.isRealtimeVoiceForCurrentAgent && ctx.voice) { ctx.voice.toggleMute(); return; } if (ctx.isDictating) { await ctx.cancelDictation(); return; } await ctx.startDictationIfAvailable(); } interface SendMessageContext { value: string; attachments: ComposerAttachment[]; hasExternalContent: boolean; allowEmptySubmit: boolean; cwd: string; isAgentRunning: boolean; onSubmit: (payload: MessagePayload) => void; onMinimizeHeight: () => void; } function sendMessageImpl(ctx: SendMessageContext): void { const trimmed = ctx.value.trim(); if ( !trimmed && ctx.attachments.length === 0 && !ctx.hasExternalContent && !ctx.allowEmptySubmit ) { return; } ctx.onSubmit({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd, forceSend: ctx.isAgentRunning || undefined, }); ctx.onMinimizeHeight(); } interface QueueMessageContext { value: string; attachments: ComposerAttachment[]; cwd: string; onQueue: ((payload: MessagePayload) => void) | undefined; onChangeText: (text: string) => void; onMinimizeHeight: () => void; } function queueMessageImpl(ctx: QueueMessageContext): void { if (!ctx.onQueue) return; const trimmed = ctx.value.trim(); if (!trimmed && ctx.attachments.length === 0) return; ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd }); ctx.onChangeText(""); ctx.onMinimizeHeight(); } function computeIsRealtimeVoiceForAgent( voice: { isVoiceModeForAgent: (serverId: string, agentId: string) => boolean } | null | undefined, voiceServerId: string | undefined, voiceAgentId: string | undefined, ): boolean { if (!voice || !voiceServerId || !voiceAgentId) return false; return voice.isVoiceModeForAgent(voiceServerId, voiceAgentId); } function computeShouldShowDictationOverlay( isDictating: boolean, isDictationProcessing: boolean, dictationStatus: string, ): boolean { return isDictating || isDictationProcessing || dictationStatus === "failed"; } interface SendableContentInput { value: string; attachments: ComposerAttachment[]; hasExternalContent: boolean; allowEmptySubmit: boolean; isSubmitLoading: boolean; } interface SendableContentOutput { hasAttachments: boolean; hasRealContent: boolean; hasSendableContent: boolean; shouldShowSendButton: boolean; } function computeSendableContent(input: SendableContentInput): SendableContentOutput { const hasAttachments = input.attachments.length > 0; const hasRealContent = input.value.trim().length > 0 || hasAttachments; const hasSendableContent = hasRealContent || input.hasExternalContent; const shouldShowSendButton = hasSendableContent || input.allowEmptySubmit || input.isSubmitLoading; return { hasAttachments, hasRealContent, hasSendableContent, shouldShowSendButton }; } function computeIsDictationStartEnabled( isReadyForDictation: boolean | undefined, isConnected: boolean, disabled: boolean, ): boolean { return (isReadyForDictation ?? isConnected) && !disabled; } function computeTextInputHeightStyle(inputHeight: number) { if (isWeb) { return { height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT, }; } return { minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT, }; } function isTextAreaLike(v: unknown): v is TextAreaHandle { return typeof v === "object" && v !== null && "scrollHeight" in v; } function getWebTextAreaImpl( current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null, ): TextAreaHandle | null { if (!current) return null; const candidate = current as { getNativeRef?: () => unknown }; if (typeof candidate.getNativeRef === "function") { const native = candidate.getNativeRef(); if (isTextAreaLike(native)) return native; } if (isTextAreaLike(current)) return current; return null; } interface SendButtonStateInput { disabled: boolean; isSubmitDisabled: boolean; isSubmitLoading: boolean; onSubmitLoadingPress: (() => void) | undefined; defaultSendBehavior: "interrupt" | "queue"; isAgentRunning: boolean; } interface SendButtonStateOutput { canPressLoadingButton: boolean; isSendButtonDisabled: boolean; defaultActionQueues: boolean; } function computeSendButtonState(input: SendButtonStateInput): SendButtonStateOutput { const canPressLoadingButton = input.isSubmitLoading && typeof input.onSubmitLoadingPress === "function"; const isSendButtonDisabled = input.disabled || (!canPressLoadingButton && (input.isSubmitDisabled || input.isSubmitLoading)); const defaultActionQueues = input.defaultSendBehavior === "queue" && input.isAgentRunning; return { canPressLoadingButton, isSendButtonDisabled, defaultActionQueues }; } interface DefaultSendActionContext { defaultSendBehavior: "interrupt" | "queue"; isAgentRunning: boolean; onQueue: ((payload: MessagePayload) => void) | undefined; handleSendMessage: () => void; handleQueueMessage: () => void; } function runDefaultSendAction(ctx: DefaultSendActionContext): void { if (ctx.defaultSendBehavior === "queue" && ctx.isAgentRunning && ctx.onQueue) { ctx.handleQueueMessage(); return; } ctx.handleSendMessage(); } function runAlternateSendAction(ctx: DefaultSendActionContext): void { if (ctx.defaultSendBehavior === "queue") { ctx.handleSendMessage(); return; } if (ctx.onQueue) { ctx.handleQueueMessage(); } } interface ResolvedMessageInputProps { value: string; onChangeText: (text: string) => void; onSubmit: (payload: MessagePayload) => void; hasExternalContent: boolean; allowEmptySubmit: boolean; submitButtonAccessibilityLabel: string | undefined; submitIcon: "arrow" | "return"; isSubmitDisabled: boolean; isSubmitLoading: boolean; attachments: ComposerAttachment[]; cwd: string; attachmentMenuItems: AttachmentMenuItem[]; onAttachButtonRef: ((node: View | null) => void) | undefined; onAddImages: ((images: ImageAttachment[]) => void) | undefined; client: DaemonClient | null; isReadyForDictation: boolean | undefined; placeholder: string; autoFocus: boolean; autoFocusKey: string | undefined; disabled: boolean; isPaneFocused: boolean; leftContent: React.ReactNode; beforeVoiceContent: React.ReactNode; rightContent: React.ReactNode; voiceServerId: string | undefined; voiceAgentId: string | undefined; isAgentRunning: boolean; defaultSendBehavior: "interrupt" | "queue"; onQueue: ((payload: MessagePayload) => void) | undefined; onSubmitLoadingPress: (() => void) | undefined; onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined; onSelectionChangeCallback: ((selection: { start: number; end: number }) => void) | undefined; onFocusChange: ((focused: boolean) => void) | undefined; onHeightChange: ((height: number) => void) | undefined; inputWrapperStyle: import("react-native").ViewStyle | undefined; attachmentSlot: React.ReactNode; } function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInputProps { return { value: props.value, onChangeText: props.onChangeText, onSubmit: props.onSubmit, hasExternalContent: props.hasExternalContent ?? false, allowEmptySubmit: props.allowEmptySubmit ?? false, submitButtonAccessibilityLabel: props.submitButtonAccessibilityLabel, submitIcon: props.submitIcon ?? "arrow", isSubmitDisabled: props.isSubmitDisabled ?? false, isSubmitLoading: props.isSubmitLoading ?? false, attachments: props.attachments, cwd: props.cwd, attachmentMenuItems: props.attachmentMenuItems, onAttachButtonRef: props.onAttachButtonRef, onAddImages: props.onAddImages, client: props.client, isReadyForDictation: props.isReadyForDictation, placeholder: props.placeholder ?? "Message...", autoFocus: props.autoFocus ?? false, autoFocusKey: props.autoFocusKey, disabled: props.disabled ?? false, isPaneFocused: props.isPaneFocused ?? true, leftContent: props.leftContent, beforeVoiceContent: props.beforeVoiceContent, rightContent: props.rightContent, voiceServerId: props.voiceServerId, voiceAgentId: props.voiceAgentId, isAgentRunning: props.isAgentRunning ?? false, defaultSendBehavior: props.defaultSendBehavior ?? "interrupt", onQueue: props.onQueue, onSubmitLoadingPress: props.onSubmitLoadingPress, onKeyPressCallback: props.onKeyPress, onSelectionChangeCallback: props.onSelectionChange, onFocusChange: props.onFocusChange, onHeightChange: props.onHeightChange, inputWrapperStyle: props.inputWrapperStyle, attachmentSlot: props.attachmentSlot, }; } function extractErrorMessage(error: unknown): string | null { if (error instanceof Error) return error.message; if (typeof error === "string") return error; return null; } export const MessageInput = forwardRef( function MessageInput(props, ref) { const { value, onChangeText, onSubmit, hasExternalContent, allowEmptySubmit, submitButtonAccessibilityLabel, submitIcon, isSubmitDisabled, isSubmitLoading, attachments, cwd, attachmentMenuItems, onAttachButtonRef, onAddImages, client, isReadyForDictation, placeholder, autoFocus, autoFocusKey, disabled, isPaneFocused, leftContent, beforeVoiceContent, rightContent, voiceServerId, voiceAgentId, isAgentRunning, defaultSendBehavior, onQueue, onSubmitLoadingPress, onKeyPressCallback, onSelectionChangeCallback, onFocusChange, onHeightChange, inputWrapperStyle, attachmentSlot, } = resolveMessageInputProps(props); const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg; 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) => runKeyboardActionImpl(action, { textInputRef, isDictatingRef, sendAfterTranscriptRef, confirmDictation, cancelDictation, startDictationIfAvailable, handleToggleRealtimeVoiceShortcut, isRealtimeVoiceForCurrentAgent, voice, }), getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : 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]); useAutoFocusOnWebEffect(textInputRef, autoFocus, autoFocusKey); const handleDictationTranscript = useCallback( (text: string, _meta: { requestId: string }) => { const autoSend = sendAfterTranscriptRef.current; sendAfterTranscriptRef.current = false; applyDictationTranscript(text, { value: valueRef.current, defaultSendBehavior, isAgentRunning, onQueue, onSubmit, onChangeText, attachments, cwd, autoSend, }); }, [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( () => computeCanStartDictation({ client, isReadyForDictation, disabled, dictationUnavailableMessage, }), [client, disabled, dictationUnavailableMessage, isReadyForDictation], ); const canConfirmDictation = useCallback(() => client?.isConnected ?? false, [client]); const isConnected = client?.isConnected ?? false; const isDictationStartEnabled = computeIsDictationStartEnabled( 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, enableDuration: true, }); const isDictatingRef = useRef(isDictating); useEffect(() => { isDictatingRef.current = isDictating; }, [isDictating]); const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent( voice, voiceServerId, voiceAgentId, ); const showDictationOverlay = computeShouldShowDictationOverlay( isDictating, isDictationProcessing, dictationStatus, ); const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent; const showOverlay = showDictationOverlay || showRealtimeOverlay; useEffect(() => { if (isDictating || isDictationProcessing) { return; } sendAfterTranscriptRef.current = false; }, [dictationStatus, isDictating, isDictationProcessing]); const startDictationIfAvailable = useCallback( () => startDictationIfAvailableImpl({ dictationUnavailableMessage, canStartDictation, isDictatingRef, toast, 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( () => handleVoicePressImpl({ isRealtimeVoiceForCurrentAgent, voice, isDictating, cancelDictation, 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( () => stopRealtimeVoiceImpl({ voice, isRealtimeVoiceForCurrentAgent, isAgentRunning, client, voiceAgentId, }), [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId], ); const handleToggleRealtimeVoiceShortcut = useCallback(() => { toggleRealtimeVoiceImpl({ voice, voiceServerId, voiceAgentId, isConnected, disabled, handleStopRealtimeVoice, toast, }); }, [disabled, handleStopRealtimeVoice, isConnected, toast, voice, voiceAgentId, voiceServerId]); const minimizeInputHeight = useCallback(() => { inputHeightRef.current = MIN_INPUT_HEIGHT; setInputHeight(MIN_INPUT_HEIGHT); onHeightChange?.(MIN_INPUT_HEIGHT); }, [onHeightChange]); const handleSendMessage = useCallback( () => sendMessageImpl({ value, attachments, hasExternalContent, allowEmptySubmit, cwd, isAgentRunning, onSubmit, onMinimizeHeight: minimizeInputHeight, }), [ allowEmptySubmit, value, attachments, cwd, onSubmit, isAgentRunning, hasExternalContent, minimizeInputHeight, ], ); const handleQueueMessage = useCallback( () => queueMessageImpl({ value, attachments, cwd, onQueue, onChangeText, onMinimizeHeight: minimizeInputHeight, }), [value, attachments, cwd, onQueue, onChangeText, minimizeInputHeight], ); const handleDefaultSendAction = useCallback(() => { runDefaultSendAction({ defaultSendBehavior, isAgentRunning, onQueue, handleSendMessage, handleQueueMessage, }); }, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]); const handleAlternateSendAction = useCallback(() => { runAlternateSendAction({ defaultSendBehavior, isAgentRunning, onQueue, handleSendMessage, handleQueueMessage, }); }, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]); const getWebTextArea = useCallback( (): TextAreaHandle | null => getWebTextAreaImpl(textInputRef.current), [], ); const webTextareaRef = useRef(null); useLayoutEffect(() => { if (isWeb) { webTextareaRef.current = getWebTextArea() as HTMLElement | null; } }, [getWebTextArea]); const inputScrollbar = useWebElementScrollbar(webTextareaRef, { enabled: isWeb && inputHeight >= MAX_INPUT_HEIGHT, }); usePasteImagesEffect({ getWebTextArea, isConnected, disabled, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages, }); const setBoundedInputHeight = useCallback( (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); }, [onHeightChange], ); useComposerHeightMirror({ value, textareaRef: webTextareaRef, minHeight: MIN_INPUT_HEIGHT, maxHeight: MAX_INPUT_HEIGHT, onHeight: setBoundedInputHeight, }); const handleContentSizeChange = useCallback( (event: NativeSyntheticEvent) => { if (isWeb) return; setBoundedInputHeight(event.nativeEvent.contentSize.height); }, [setBoundedInputHeight], ); const handleSelectionChange = useCallback( (event: NativeSyntheticEvent) => { const start = event.nativeEvent.selection?.start ?? 0; const end = event.nativeEvent.selection?.end ?? start; onSelectionChangeCallback?.({ start, end }); }, [onSelectionChangeCallback], ); const shouldHandleDesktopSubmit = isWeb; function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { if (!shouldHandleDesktopSubmit) return; handleDesktopKeyPressImpl(event, { onKeyPressCallback, isAgentRunning, onQueue, isSubmitDisabled, isSubmitLoading, disabled, handleAlternateSendAction, handleDefaultSendAction, }); } const { shouldShowSendButton } = computeSendableContent({ value, attachments, hasExternalContent, allowEmptySubmit, isSubmitLoading, }); const { canPressLoadingButton, isSendButtonDisabled, defaultActionQueues } = computeSendButtonState({ disabled, isSubmitDisabled, isSubmitLoading, onSubmitLoadingPress, defaultSendBehavior, isAgentRunning, }); useIosHardwareKeyboardSubmit({ isEnabled: isInputFocused && !isSendButtonDisabled, onSubmit: handleDefaultSendAction, }); const submitAccessibilityLabel = resolveSubmitAccessibilityLabel({ submitButtonAccessibilityLabel, canPressLoadingButton, defaultActionQueues, isAgentRunning, }); const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({ isRealtimeVoiceForCurrentAgent, isMuted: Boolean(voice?.isMuted), isDictating, }); const voiceTooltipText = resolveVoiceTooltipText({ isRealtimeVoiceForCurrentAgent, isMuted: Boolean(voice?.isMuted), }); const handleInputChange = useCallback( (nextValue: string) => { onChangeText(nextValue); }, [onChangeText], ); const handleInputFocus = useCallback(() => { isInputFocusedRef.current = true; setIsInputFocused(true); onFocusChange?.(true); }, [onFocusChange]); const handleInputBlur = useCallback(() => { isInputFocusedRef.current = false; setIsInputFocused(false); onFocusChange?.(false); }, [onFocusChange]); const attachButtonStyle = useCallback( ({ hovered }: { hovered?: boolean }) => [ styles.attachButton, Boolean(hovered) && styles.iconButtonHovered, (!isConnected || disabled) && styles.buttonDisabled, ], [isConnected, disabled], ); const voiceButtonStyle = useCallback( ({ hovered }: { hovered?: boolean }) => [ styles.voiceButton, Boolean(hovered) && !isDictating && styles.iconButtonHovered, !isDictationStartEnabled && styles.buttonDisabled, isDictating && styles.voiceButtonRecording, ], [isDictating, isDictationStartEnabled], ); const handleRealtimeVoiceStop = useCallback(() => { void handleStopRealtimeVoice(); }, [handleStopRealtimeVoice]); const inputWrapperCombinedStyle = useMemo( () => [styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle], [inputWrapperStyle, inputAnimatedStyle], ); const textInputStyle = useMemo( () => [styles.textInput, computeTextInputHeightStyle(inputHeight)], [inputHeight], ); const sendButtonCombinedStyle = useMemo( () => [styles.sendButton, isSendButtonDisabled && styles.buttonDisabled], [isSendButtonDisabled], ); const overlayContainerStyle = useMemo( () => [styles.overlayContainer, overlayAnimatedStyle], [overlayAnimatedStyle], ); const renderAttachButtonIcon = useCallback( ({ hovered }: { hovered?: boolean }) => ( ), [onAttachButtonRef, buttonIconSize], ); const renderVoiceButtonIcon = useCallback( ({ hovered }: { hovered?: boolean }) => ( ), [isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize], ); return ( {/* Regular input */} {attachmentSlot} {/* Text input */} = MAX_INPUT_HEIGHT : true} onContentSizeChange={handleContentSizeChange} editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined} onSelectionChange={handleSelectionChange} autoFocus={isWeb && autoFocus} /> {inputScrollbar} {/* Button row */} {/* Left: attachment button + leftContent slot */} {leftContent} {/* Right: voice button, contextual button (realtime/send/cancel) */} {beforeVoiceContent} {rightContent} ); }, ); const styles = StyleSheet.create((theme: Theme) => ({ 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", outlineWidth: 0, outlineColor: "transparent", } as object) : {}), }, 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, }, 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 unknown as Record; const ThemedPlus = withUnistyles(Plus); const ThemedMic = withUnistyles(Mic); const ThemedMicOff = withUnistyles(MicOff); const ThemedTextInput = withUnistyles(TextInput); const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const textInputPlaceholderColorMapping = (theme: Theme) => ({ placeholderTextColor: theme.colors.surface4, });