import { View, TextInput, Pressable, NativeSyntheticEvent, TextInputContentSizeChangeEventData, TextInputKeyPressEventData, Image, Platform, Text, ActivityIndicator, } from "react-native"; import { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Mic, ArrowUp, AudioLines, Square, Paperclip, X, Pencil } from "lucide-react-native"; import Animated, { useSharedValue, useAnimatedStyle, withTiming, FadeIn, FadeOut, } from "react-native-reanimated"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useRealtime } from "@/contexts/realtime-context"; import { useDictation } from "@/hooks/use-dictation"; import { FOOTER_HEIGHT } from "@/constants/layout"; import { VoiceNoteRecordingOverlay } from "./voice-note-recording-overlay"; import { DictationStatusNotice, type DictationToastVariant } from "./dictation-status-notice"; import { generateMessageId } from "@/types/stream"; import { AgentStatusBar } from "./agent-status-bar"; import { RealtimeControls } from "./realtime-controls"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; import { AUDIO_DEBUG_ENABLED } from "@/config/audio-debug"; import { AudioDebugNotice, type AudioDebugInfo } from "./audio-debug-notice"; import { useSessionStore } from "@/stores/session-store"; import type { UseWebSocketReturn } from "@/hooks/use-websocket"; import type { SessionOutboundMessage } from "@server/server/messages"; type QueuedMessage = { id: string; text: string; images?: Array<{ uri: string; mimeType: string }>; }; interface AgentInputAreaProps { agentId: string; serverId: string; onSubmitMessage?: (payload: { text: string; images?: Array<{ uri: string; mimeType: string }>; }) => Promise; /** Externally controlled loading state. When true, disables the submit button. */ isSubmitLoading?: boolean; value?: string; onChangeText?: (text: string) => void; } const MIN_INPUT_HEIGHT = 50; const MAX_INPUT_HEIGHT = 160; const EMPTY_ARRAY: readonly QueuedMessage[] = []; const MAX_INPUT_WIDTH = 960; const BASE_VERTICAL_PADDING = (FOOTER_HEIGHT - MIN_INPUT_HEIGHT) / 2; // Android currently crashes inside ViewGroup.dispatchDraw when running Reanimated // entering/exiting animations (see react-native-reanimated#8422), so guard them. const SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS = Platform.OS === "android"; const REALTIME_FADE_IN = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS ? undefined : FadeIn.duration(250); const REALTIME_FADE_OUT = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS ? undefined : FadeOut.duration(250); const IS_WEB = Platform.OS === "web"; const SHOULD_DEBUG_INPUT_HEIGHT = IS_WEB; type WebTextInputKeyPressEvent = NativeSyntheticEvent< TextInputKeyPressEventData & { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; } >; type TextAreaHandle = { scrollHeight?: number; style?: { height?: string; overflowY?: string; } & Record; }; type DictationToastConfig = { variant: DictationToastVariant; title: string; subtitle?: string; meta?: string; actionLabel?: string; onAction?: () => void; onDismiss?: () => void; }; export function AgentInputArea({ agentId, serverId, onSubmitMessage, isSubmitLoading = false, value, onChangeText, }: AgentInputAreaProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); const ws = useSessionStore((state) => state.sessions[serverId]?.ws); const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId) ); const getDraftInput = useSessionStore((state) => state.getDraftInput); const saveDraftInput = useSessionStore((state) => state.saveDraftInput); const queuedMessagesRaw = useSessionStore((state) => state.sessions[serverId]?.queuedMessages?.get(agentId) ); const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY; const methods = useSessionStore((state) => state.sessions[serverId]?.methods); const sendAgentMessage = methods?.sendAgentMessage; const cancelAgentRun = methods?.cancelAgentRun; const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); // Noop fallback for sendAgentAudio (required by useDictation) const noopSendAgentAudio = useCallback(async () => {}, []); const sendAgentAudio = methods?.sendAgentAudio ?? noopSendAgentAudio; // Inert WebSocket fallback for when ws is undefined const inertWebSocket = useMemo( () => ({ isConnected: false, isConnecting: false, conversationId: null, lastError: null, send: () => {}, on: () => () => {}, sendPing: () => {}, sendUserMessage: () => {}, clearAgentAttention: () => {}, subscribeConnectionStatus: () => () => {}, getConnectionState: () => ({ isConnected: false, isConnecting: false }), }), [] ); const wsOrInert = ws ?? inertWebSocket; const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime(); const [internalInput, setInternalInput] = useState(""); const userInput = value ?? internalInput; const setUserInput = onChangeText ?? setInternalInput; const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); const [isProcessing, setIsProcessing] = useState(false); const [selectedImages, setSelectedImages] = useState>([]); const [isCancellingAgent, setIsCancellingAgent] = useState(false); const [audioDebugInfo, setAudioDebugInfo] = useState(null); const [connectionStatus, setConnectionStatus] = useState(() => wsOrInert.getConnectionState ? wsOrInert.getConnectionState() : { isConnected: wsOrInert.isConnected, isConnecting: wsOrInert.isConnecting } ); const textInputRef = useRef unknown }) | null>(null); const inputHeightRef = useRef(MIN_INPUT_HEIGHT); const baselineInputHeightRef = useRef(null); const overlayTransition = useSharedValue(0); const { pickImages } = useImageAttachmentPicker(); const shouldShowAudioDebug = AUDIO_DEBUG_ENABLED; const agentIdRef = useRef(agentId); const sendAgentMessageRef = useRef(sendAgentMessage); const onSubmitMessageRef = useRef(onSubmitMessage); const agentStatusRef = useRef(undefined); const updateQueueRef = useRef< ((updater: (current: QueuedMessage[]) => QueuedMessage[]) => void) | null >(null); const submitMessage = useCallback( async (text: string, images?: Array<{ uri: string; mimeType: string }>) => { if (onSubmitMessageRef.current) { await onSubmitMessageRef.current({ text, images }); return; } await sendAgentMessageRef.current?.(agentIdRef.current, text, images); }, [] ); const handleDictationTranscript = useCallback( (text: string) => { if (!text) { return; } const shouldQueue = agentStatusRef.current === "running"; if (shouldQueue) { updateQueueRef.current?.((current) => [ ...current, { id: generateMessageId(), text, }, ]); return; } void (async () => { try { await submitMessage(text); } catch (error) { console.error("[AgentInput] Failed to send transcribed message:", error); updateQueueRef.current?.((current) => [ ...current, { id: generateMessageId(), text, }, ]); } })(); }, [submitMessage] ); const handleDictationError = useCallback((error: Error) => { console.error("[AgentInput] Dictation error:", error); }, []); const canStartDictation = useCallback(() => { const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected; const allowed = !isRealtimeMode && socketConnected && Boolean(agent); console.log("[AgentInput] canStartDictation", { allowed, isRealtimeMode, wsConnected: socketConnected, }); return allowed; }, [agent, isRealtimeMode, wsOrInert]); const canConfirmDictation = useCallback(() => { const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected; const allowed = socketConnected && Boolean(agent); console.log("[AgentInput] canConfirmDictation", { allowed, wsConnected: socketConnected, }); return allowed; }, [agent, wsOrInert]); const { isRecording: isDictating, isProcessing: isDictationProcessing, volume: dictationVolume, duration: dictationDuration, pendingRequestId: dictationPendingRequestId, error: dictationError, status: dictationStatus, retryAttempt: dictationRetryAttempt, maxRetryAttempts: dictationMaxRetryAttempts, retryInfo: dictationRetryInfo, startDictation, cancelDictation, confirmDictation, retryFailedDictation, discardFailedDictation, } = useDictation({ agentId, sendAgentAudio, ws: wsOrInert, mode: "transcribe_only", onTranscript: handleDictationTranscript, onError: handleDictationError, canStart: canStartDictation, canConfirm: canConfirmDictation, enableDuration: true, }); const dictationRequestIdRef = useRef(null); useEffect(() => { dictationRequestIdRef.current = dictationPendingRequestId; }, [dictationPendingRequestId]); useEffect(() => { if (!wsOrInert.subscribeConnectionStatus) { return; } return wsOrInert.subscribeConnectionStatus((status) => { setConnectionStatus(status); }); }, [wsOrInert]); const handleRetryFailedRecording = useCallback(() => { void retryFailedDictation(); }, [retryFailedDictation]); const handleDiscardFailedRecording = useCallback(() => { discardFailedDictation(); }, [discardFailedDictation]); const dictationToast = useMemo(() => { console.log('[AgentInputArea] Connection status:', { isConnected: connectionStatus.isConnected, wsIsConnected: wsOrInert.isConnected, wsIsConnecting: wsOrInert.isConnecting, hasWs: !!ws, }); if (dictationStatus === "retrying") { const attempt = dictationRetryInfo?.attempt ?? Math.max(1, dictationRetryAttempt || 1); const maxAttempts = dictationRetryInfo?.maxAttempts ?? dictationMaxRetryAttempts; const nextLabel = dictationRetryInfo?.nextRetryMs && dictationRetryInfo.nextRetryMs > 0 ? ` · Next in ${Math.ceil(dictationRetryInfo.nextRetryMs / 1000)}s` : ""; return { variant: "warning", title: "Retrying dictation…", subtitle: dictationRetryInfo?.errorMessage ?? dictationError ?? "Network error", meta: `Attempt ${attempt}/${maxAttempts}${nextLabel}`, }; } if (dictationStatus === "failed") { return { variant: "error", title: "Dictation failed", subtitle: dictationRetryInfo?.errorMessage ?? dictationError ?? "Unknown error", actionLabel: "Retry", onAction: handleRetryFailedRecording, onDismiss: handleDiscardFailedRecording, }; } return null; }, [ dictationError, dictationMaxRetryAttempts, dictationRetryAttempt, dictationRetryInfo, dictationStatus, handleDiscardFailedRecording, handleRetryFailedRecording, ]); useEffect(() => { const shouldShowOverlay = isDictating || isDictationProcessing || dictationStatus === "failed"; overlayTransition.value = withTiming(shouldShowOverlay ? 1 : 0, { duration: 250 }); }, [dictationStatus, isDictating, isDictationProcessing, overlayTransition]); const debugInputHeight = (label: string, payload: Record) => { if (!SHOULD_DEBUG_INPUT_HEIGHT) { return; } console.log(`[AgentInput][InputHeight] ${label}`, payload); }; useEffect(() => { inputHeightRef.current = inputHeight; }, [inputHeight]); useEffect(() => { agentIdRef.current = agentId; }, [agentId]); useEffect(() => { sendAgentMessageRef.current = sendAgentMessage; }, [sendAgentMessage]); useEffect(() => { onSubmitMessageRef.current = onSubmitMessage; }, [onSubmitMessage]); async function handleSendMessage() { const socketConnected = wsOrInert.getConnectionState ? wsOrInert.getConnectionState().isConnected : wsOrInert.isConnected; if (!userInput.trim() || !socketConnected) return; if (!sendAgentMessage && !onSubmitMessageRef.current) return; const message = userInput.trim(); const imageAttachments = selectedImages.length > 0 ? selectedImages : undefined; const isControlled = value !== undefined; if (!isControlled) { setUserInput(""); } setSelectedImages([]); setInputHeight(MIN_INPUT_HEIGHT); setIsProcessing(true); try { await submitMessage(message, imageAttachments); } catch (error) { console.error("[AgentInput] Failed to send message:", error); // Restore input on error so user doesn't lose their message setUserInput(message); if (imageAttachments) { setSelectedImages(imageAttachments); } } finally { setIsProcessing(false); } } async function handlePickImage() { const result = await pickImages(); if (!result?.assets?.length) { return; } const newImages = result.assets.map(asset => ({ uri: asset.uri, mimeType: asset.mimeType || "image/jpeg", })); setSelectedImages(prev => [...prev, ...newImages]); } function handleRemoveImage(index: number) { setSelectedImages(prev => prev.filter((_, i) => i !== index)); } async function handleVoicePress() { console.log("[AgentInput] handleVoicePress", { isDictating, isRealtimeMode, wsConnected: wsOrInert.isConnected, }); if (isDictating || isRealtimeMode) { return; } if (shouldShowAudioDebug) { setAudioDebugInfo(null); } try { await startDictation(); console.log("[AgentInput] startDictation invoked"); } catch (error) { const isCancelled = error instanceof Error && error.message.includes("Recording cancelled"); if (!isCancelled) { console.error("[AgentInput] Failed to start recording:", error); } } } async function handleCancelRecording() { console.log("[AgentInput] handleCancelRecording", { isDictating, isDictationProcessing, }); if (dictationStatus === "failed") { handleDiscardFailedRecording(); return; } if (!isDictating && !isDictationProcessing) { return; } try { await cancelDictation(); } catch (error) { console.error("[AgentInput] Failed to cancel recording:", error); } } async function handleSendRecording() { console.log("[AgentInput] handleSendRecording", { isDictating, isDictationProcessing, }); if (dictationStatus === "failed") { handleRetryFailedRecording(); return; } if (!isDictating) { return; } try { await confirmDictation(); } catch (error) { console.error("[AgentInput] Failed to send recording:", error); } } useEffect(() => { if (!shouldShowAudioDebug) { return; } const unsubscribe = wsOrInert.on("transcription_result", (message: SessionOutboundMessage) => { if (message.type !== "transcription_result") { return; } const pendingRequestId = dictationRequestIdRef.current; if (!pendingRequestId || message.payload.requestId !== pendingRequestId) { return; } dictationRequestIdRef.current = null; setAudioDebugInfo({ requestId: pendingRequestId, transcript: message.payload.text?.trim(), debugRecordingPath: message.payload.debugRecordingPath ?? undefined, format: message.payload.format, byteLength: message.payload.byteLength, duration: message.payload.duration, avgLogprob: message.payload.avgLogprob, isLowConfidence: message.payload.isLowConfidence, }); }); return () => { unsubscribe(); }; }, [shouldShowAudioDebug, wsOrInert]); function isTextAreaLike(value: unknown): value is TextAreaHandle { if (!value || typeof value !== "object") { return false; } const candidate = value as TextAreaHandle; return typeof candidate.scrollHeight === "number" && typeof candidate.style === "object"; } function getWebTextArea(): TextAreaHandle | null { if (!IS_WEB) { return null; } const node = textInputRef.current; if (!node) { debugInputHeight("missing-ref", {}); return null; } if (isTextAreaLike(node)) { debugInputHeight("using-ref", { scrollHeight: node.scrollHeight, inlineHeight: node.style?.height, }); return node; } if (typeof (node as { getNativeRef?: () => unknown }).getNativeRef === "function") { const native = (node as { getNativeRef?: () => unknown }).getNativeRef?.(); if (isTextAreaLike(native)) { debugInputHeight("using-native-ref", { scrollHeight: native.scrollHeight, inlineHeight: native.style?.height, }); return native; } } debugInputHeight("no-textarea-found", {}); return null; } function focusWebTextInput(): void { if (!IS_WEB) { return; } const node = textInputRef.current; if (!node) { return; } const target: unknown = typeof (node as { focus?: () => void }).focus === "function" ? node : typeof (node as { getNativeRef?: () => unknown }).getNativeRef === "function" ? (node as { getNativeRef?: () => unknown }).getNativeRef?.() : null; if (target && typeof (target as { focus?: () => void }).focus === "function") { const exec = () => (target as { focus: () => void }).focus(); if (typeof requestAnimationFrame === "function") { requestAnimationFrame(exec); } else { setTimeout(exec, 0); } } } function measureWebInputHeight(source: string): boolean { if (!IS_WEB) { return false; } const element = getWebTextArea(); if (!element?.style || typeof element.scrollHeight !== "number") { debugInputHeight(`${source}-missing`, {}); return false; } const previousHeight = element.style.height; element.style.height = "auto"; const measuredHeight = element.scrollHeight; element.style.height = previousHeight ?? ""; const bounded = applyMeasuredHeight(measuredHeight, source, { scrollHeight: element.scrollHeight, inlineHeight: previousHeight, }); element.style.height = `${bounded}px`; element.style.minHeight = `${MIN_INPUT_HEIGHT}px`; element.style.maxHeight = `${MAX_INPUT_HEIGHT}px`; element.style.overflowY = bounded >= MAX_INPUT_HEIGHT ? "auto" : "hidden"; return true; } function applyMeasuredHeight( measuredHeight: number, source: string, details: Record = {}, ): number { const existingBaseline = baselineInputHeightRef.current; if (existingBaseline === null) { baselineInputHeightRef.current = measuredHeight; } const baseline = baselineInputHeightRef.current ?? measuredHeight; const normalizedHeight = measuredHeight - baseline + MIN_INPUT_HEIGHT; const bounded = Math.min( MAX_INPUT_HEIGHT, Math.max(MIN_INPUT_HEIGHT, normalizedHeight), ); debugInputHeight(source, { measuredHeight, normalizedHeight, bounded, baseline, ...details, }); setBoundedInputHeight(bounded); return bounded; } function setBoundedInputHeight(nextHeight: number) { const boundedHeight = Math.min( MAX_INPUT_HEIGHT, Math.max(MIN_INPUT_HEIGHT, nextHeight), ); if (Math.abs(inputHeightRef.current - boundedHeight) < 1) { return; } debugInputHeight("set-state", { nextHeight, boundedHeight, previousHeight: inputHeightRef.current, }); setInputHeight(boundedHeight); } function handleContentSizeChange( event: NativeSyntheticEvent, ) { if (IS_WEB && measureWebInputHeight("web-measure")) { return; } applyMeasuredHeight(event.nativeEvent.contentSize.height, "native-measure", { width: event.nativeEvent.contentSize.width, }); } const isAgentRunning = agent?.status === "running"; agentStatusRef.current = agent?.status; const hasText = userInput.trim().length > 0; const hasImages = selectedImages.length > 0; const hasSendableContent = hasText || hasImages; const shouldShowSendButton = !isAgentRunning && hasSendableContent; const shouldShowVoiceControls = !hasSendableContent; const shouldHandleDesktopSubmit = IS_WEB; const updateQueue = useCallback( (updater: (current: QueuedMessage[]) => QueuedMessage[]) => { setQueuedMessages(serverId, (prev: Map) => { const next = new Map(prev); next.set(agentId, updater(prev.get(agentId) ?? [])); return next; }); }, [agentId, serverId, setQueuedMessages], ); useEffect(() => { updateQueueRef.current = updateQueue; }, [updateQueue]); useLayoutEffect(() => { if (!IS_WEB) { return; } measureWebInputHeight("layout-effect"); }, [userInput]); function handleDesktopSubmitKeyPress(event: WebTextInputKeyPressEvent) { if (!shouldHandleDesktopSubmit) { return; } if (event.nativeEvent.key !== "Enter") { return; } const { metaKey, ctrlKey, shiftKey } = event.nativeEvent; if (shiftKey || metaKey || ctrlKey) { return; } event.preventDefault(); if (isAgentRunning) { if (!hasSendableContent || !wsOrInert.isConnected) { return; } handleQueueCurrentInput(); return; } if (!shouldShowSendButton || isProcessing || !wsOrInert.isConnected) { return; } void handleSendMessage(); } useEffect(() => { if (!isAgentRunning || !wsOrInert.isConnected) { setIsCancellingAgent(false); } }, [isAgentRunning, wsOrInert.isConnected]); useEffect(() => { if (!IS_WEB) { return; } const listener = (event: KeyboardEvent) => { const key = event.key.toLowerCase(); const isCommandLike = event.metaKey || event.ctrlKey; if (isCommandLike && !event.altKey && key === "d") { event.preventDefault(); event.stopPropagation(); if (isDictating) { void handleSendRecording(); return; } if (!isRealtimeMode && shouldShowVoiceControls && wsOrInert.isConnected) { void handleVoicePress(); } return; } if (key === "escape" && isDictating) { event.preventDefault(); event.stopPropagation(); void handleCancelRecording(); } }; window.addEventListener("keydown", listener, true); return () => { window.removeEventListener("keydown", listener, true); }; }, [ isDictating, isRealtimeMode, shouldShowVoiceControls, wsOrInert, handleVoicePress, handleSendRecording, handleCancelRecording, ]); useEffect(() => { focusWebTextInput(); }, [agentId]); // Hydrate draft only when switching agents useEffect(() => { const draft = getDraftInput(agentId); if (!draft) { setUserInput(""); setSelectedImages([]); return; } setUserInput(draft.text); setSelectedImages(draft.images); }, [agentId, getDraftInput]); // Persist drafts into the shared session store with change detection to avoid redundant work useEffect(() => { const existing = getDraftInput(agentId); const isSameText = existing?.text === userInput; const existingImages: Array<{ uri: string; mimeType?: string }> = existing?.images ?? []; const isSameImages = existingImages.length === selectedImages.length && existingImages.every((img: { uri: string; mimeType?: string }, idx: number) => { return img.uri === selectedImages[idx]?.uri && img.mimeType === selectedImages[idx]?.mimeType; }); if (isSameText && isSameImages) { return; } saveDraftInput(agentId, { text: userInput, images: selectedImages }); }, [agentId, userInput, selectedImages, getDraftInput, saveDraftInput]); const overlayAnimatedStyle = useAnimatedStyle(() => { return { transform: [{ translateY: (1 - overlayTransition.value) * FOOTER_HEIGHT }], opacity: overlayTransition.value, pointerEvents: overlayTransition.value > 0.5 ? ("auto" as const) : ("none" as const), }; }); const inputAnimatedStyle = useAnimatedStyle(() => { return { opacity: 1 - overlayTransition.value, pointerEvents: overlayTransition.value < 0.5 ? ("auto" as const) : ("none" as const), }; }); const keyboardAnimatedStyle = useAnimatedStyle(() => { "worklet"; const absoluteHeight = Math.abs(keyboardHeight.value); const shift = Math.max(0, absoluteHeight - insets.bottom); return { transform: [{ translateY: -shift }], }; }); async function handleRealtimePress() { try { if (isRealtimeMode) { await stopRealtime(); } else { if (!wsOrInert.isConnected || !serverId) { return; } await startRealtime(serverId); } } catch (error) { console.error("[AgentInput] Failed to toggle realtime mode:", error); } } function handleCancelAgent() { if (!agent || agent.status !== "running" || isCancellingAgent) { return; } if (!wsOrInert.isConnected || !cancelAgentRun) { return; } setIsCancellingAgent(true); cancelAgentRun(agentIdRef.current); } function handleQueueCurrentInput() { if (!hasSendableContent) return; const newItem = { id: generateMessageId(), text: userInput.trim(), images: selectedImages.length ? selectedImages : undefined, }; updateQueue((current) => [...current, newItem]); setUserInput(""); setSelectedImages([]); setInputHeight(MIN_INPUT_HEIGHT); } function handleEditQueuedMessage(id: string) { const item = queuedMessages.find((q) => q.id === id); if (!item) return; updateQueue((current) => current.filter((q) => q.id !== id)); setUserInput(item.text); setSelectedImages(item.images ?? []); } async function handleSendQueuedNow(id: string) { const item = queuedMessages.find((q) => q.id === id); if (!item || !wsOrInert.isConnected) return; if (!sendAgentMessage && !onSubmitMessageRef.current) return; updateQueue((current) => current.filter((q) => q.id !== id)); // Cancels current agent run before sending queued prompt handleCancelAgent(); await submitMessage(item.text, item.images); } const realtimeButton = ( {isRealtimeMode ? ( ) : ( )} ); const voiceButton = ( {isDictating ? ( ) : ( )} ); return ( {/* Border separator */} {/* Realtime controls - only when active */} {isRealtimeMode && ( )} {/* Input area */} {/* Regular input controls */} {shouldShowAudioDebug && audioDebugInfo ? ( setAudioDebugInfo(null)} /> ) : null} {/* Queue list */} {queuedMessages.length > 0 && ( {queuedMessages.map((item) => ( {item.text} handleEditQueuedMessage(item.id)} style={styles.queueActionButton}> handleSendQueuedNow(item.id)} style={[styles.queueActionButton, styles.queueSendButton]} > ))} )} {/* Image preview pills */} {hasImages && ( {selectedImages.map((image, index) => ( handleRemoveImage(index)} style={styles.removeImageButton}> ))} )} {/* Full-width text input */} = MAX_INPUT_HEIGHT} onContentSizeChange={handleContentSizeChange} editable={!isDictating && wsOrInert.isConnected} onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined} /> {/* Button row below input */} {/* Left button group */} {/* Right button group */} {isAgentRunning ? ( <> {shouldShowVoiceControls && voiceButton} {hasSendableContent && ( )} {realtimeButton} {isCancellingAgent ? ( ) : ( )} ) : shouldShowSendButton ? ( {isSubmitLoading ? ( ) : ( )} ) : shouldShowVoiceControls ? ( <> {voiceButton} {realtimeButton} ) : null} {/* Voice note recording overlay */} {dictationToast ? ( ) : null} ); } const styles = StyleSheet.create(((theme: any) => ({ container: { flexDirection: "column", position: "relative", }, borderSeparator: { height: theme.borderWidth[1], backgroundColor: theme.colors.border, }, realtimeControlsContainer: { height: FOOTER_HEIGHT, }, inputAreaContainer: { position: "relative", minHeight: FOOTER_HEIGHT, alignItems: "center", width: "100%", overflow: "hidden", }, inputAreaContent: { width: "100%", maxWidth: MAX_INPUT_WIDTH, }, inputContainer: { flexDirection: "column", paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], gap: theme.spacing[3], minHeight: FOOTER_HEIGHT, width: "100%", }, overlayContainer: { position: "absolute", left: 0, right: 0, bottom: 0, alignItems: "center", paddingBottom: theme.spacing[4], }, dictationToastPortal: { position: "absolute", left: theme.spacing[4], right: theme.spacing[4], bottom: theme.spacing[4], }, imagePreviewContainer: { flexDirection: "row", gap: theme.spacing[2], flexWrap: "wrap", }, imagePill: { flexDirection: "row", alignItems: "center", backgroundColor: theme.colors.muted, borderRadius: theme.borderRadius.lg, padding: theme.spacing[1], gap: theme.spacing[2], }, imageThumbnail: { width: 40, height: 40, borderRadius: theme.borderRadius.md, }, removeImageButton: { width: 24, height: 24, alignItems: "center", justifyContent: "center", }, textInput: { width: "100%", paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], backgroundColor: theme.colors.muted, borderRadius: theme.borderRadius.lg, color: theme.colors.foreground, fontSize: theme.fontSize.lg, lineHeight: theme.fontSize.lg * 1.4, ...(IS_WEB ? { outlineStyle: "none" as const, outlineWidth: 0, outlineColor: "transparent", } : {}), }, buttonRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", }, leftButtonGroup: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, rightButtonGroup: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, attachButton: { width: 40, height: 40, alignItems: "center", justifyContent: "center", }, sendButton: { width: 40, height: 40, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.palette.blue[600], alignItems: "center", justifyContent: "center", }, voiceButton: { width: 40, height: 40, borderRadius: theme.borderRadius.full, backgroundColor: "transparent", alignItems: "center", justifyContent: "center", }, voiceButtonRecording: { backgroundColor: theme.colors.destructive, }, realtimeButton: { width: 40, height: 40, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.accentForeground, alignItems: "center", justifyContent: "center", }, realtimeButtonActive: { backgroundColor: theme.colors.palette.blue[600], }, cancelButton: { width: 40, height: 40, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.palette.red[500], alignItems: "center", justifyContent: "center", }, queueButton: { width: 40, height: 40, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.palette.blue[600], alignItems: "center", justifyContent: "center", }, buttonDisabled: { opacity: 0.5, }, queueContainer: { flexDirection: "column", gap: theme.spacing[2], }, queueItem: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], backgroundColor: theme.colors.card, borderRadius: theme.borderRadius.lg, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, gap: theme.spacing[2], }, queueText: { flex: 1, color: theme.colors.foreground, fontSize: theme.fontSize.base, }, queueActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, queueActionButton: { width: 32, height: 32, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", backgroundColor: theme.colors.muted, }, queueSendButton: { backgroundColor: theme.colors.palette.blue[600], }, })) as any) as Record;