import React, { useCallback, useEffect, useRef, useState } from 'react'; import { ActivityIndicator, Alert, Animated, Easing, Keyboard, Modal, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { ArrowUp, AudioLines, Camera, ChevronDown, FileText, Image as ImageIcon, Mic, Paperclip, Plus, Square, X, type LucideIcon, } from 'lucide-react-native'; import { AttachmentPreviewStrip } from '../attachments/AttachmentPreviewStrip'; import { ComposerSuggestions } from './ComposerSuggestions'; import { composerStyles } from './composerStyles'; import { MAX_ATTACHMENTS, normalizeMediaAttachments } from '../attachments/mediaUtils'; import { useAgentBi } from '../../analytics/mixpanelContext'; import { OutOfCreditsWidget } from '../credits/OutOfCreditsWidget'; import { useSuperagentMedia } from '../../runtime/runtimeContext'; import { createSyntheticLevelSource, formatRecordingDuration, levelToWaveHeight, smoothLevel } from '../../runtime/speechToTextUtils'; import { styles } from '../../styles'; import { themedColor, themedSurface } from '../../theme'; import type { SpeechToTextState } from '../../runtime/useSpeechToText'; import type { SuperagentComposerSuggestions, SuperagentLiveVoiceInput, SuperagentMediaActionContext, SuperagentMediaAttachment, SuperagentMediaPicker, SuperagentPromptSuggestion, SuperagentReplyTo, } from '../../types'; const MAX_CHAT_WIDTH = 900; // Native attach sources tracked on `Chat Attachment Added` (Drive is untracked — // it never surfaces in the shell). Not web's Google-Drive-only `Chat File Import`. type AttachmentSource = 'camera' | 'photos' | 'files'; // Temporarily hide the voice / Live Voice button in the composer while Live Voice is // being fixed on the native app. All the voice wiring (handlers, props, runtime) is // intentionally kept intact — flip this back to `true` to restore the button. const SHOW_VOICE_BUTTON = false; type VoiceState = 'idle' | 'processing'; export function ConversationComposer({ attachments, canQueue, canSend, context, creditSendAllowed = true, draft, isOutOfCredits, isQueueFull, isSending, modelLabel, onAddAttachments, onAppendTranscript, onChangeDraft, onClearReply, onImportFromDrive, onOpenModelSettings, onPickFiles, onPickPhotos, onRemoveAttachment, onSend, onSendTranscript, onStartLiveVoice, onStop, onTakePhoto, onViewPlans, replyTo, suggestions, }: { attachments: SuperagentMediaAttachment[]; // Enqueue behind the running turn is allowed (busy, queue available, not full). canQueue: boolean; canSend: boolean; context: SuperagentMediaActionContext; // Same credit gate `canSend` uses (usage loaded + not out of credits). Dictation // is disabled when false so out-of-credit users can't burn STT/provider capacity // on a transcript that can't be sent. creditSendAllowed?: boolean; draft: string; isOutOfCredits?: boolean; // The running turn plus queued follow-ups have reached the cap — enqueue is blocked. isQueueFull?: boolean; isSending: boolean; modelLabel?: string; onAddAttachments: (attachments: SuperagentMediaAttachment[]) => void; onAppendTranscript: (transcript: string) => void; onChangeDraft: (value: string) => void; onClearReply: () => void; onImportFromDrive?: SuperagentMediaPicker; onOpenModelSettings?: () => void; onPickFiles?: SuperagentMediaPicker; onPickPhotos?: SuperagentMediaPicker; onRemoveAttachment: (index: number) => void; onSend: () => void; onSendTranscript: (transcript: string) => void; onStartLiveVoice?: SuperagentLiveVoiceInput; onStop: () => void; onTakePhoto?: SuperagentMediaPicker; onViewPlans?: () => void; replyTo: SuperagentReplyTo | null; suggestions?: SuperagentComposerSuggestions; }) { const bi = useAgentBi(); const { speechToText } = useSuperagentMedia(); const inputRef = useRef(null); const [liveVoiceState, setLiveVoiceState] = useState('idle'); const [mediaError, setMediaError] = useState(null); const [isAttachmentMenuOpen, setIsAttachmentMenuOpen] = useState(false); const addFromPicker = useCallback(async (picker: SuperagentMediaPicker | undefined, source?: AttachmentSource) => { if (!picker || attachments.length >= MAX_ATTACHMENTS) return; setIsAttachmentMenuOpen(false); setMediaError(null); try { const next = normalizeMediaAttachments(await picker(context)); if (next.length === 0) return; // cancelled/empty picker — nothing attached, don't track const added = next.slice(0, MAX_ATTACHMENTS - attachments.length); onAddAttachments(added); // Only bounded, non-PII fields — never the file name or url. if (source) void bi.trackEditor('Chat Attachment Added', { source, count: added.length, kinds: [...new Set(added.map((attachment) => attachment.kind ?? 'file'))], mime_types: [...new Set(added.map((attachment) => attachment.mimeType).filter(Boolean))], }); } catch (error) { setMediaError(error instanceof Error ? error.message : 'Failed to attach media'); } }, [attachments.length, context, bi, onAddAttachments]); // Tap-to-start / tap-to-stop dictation. The hook owns the state machine, upload, // and error copy; the composer just appends the returned transcript to the draft // (stop & review) or hands it to the send path (send now). const handleDictate = useCallback(async () => { setMediaError(null); const transcript = await speechToText.toggle(); if (transcript) onAppendTranscript(transcript); }, [onAppendTranscript, speechToText.toggle]); const handleDictateSend = useCallback(async () => { setMediaError(null); const transcript = await speechToText.toggle(); if (transcript) onSendTranscript(transcript); }, [onSendTranscript, speechToText.toggle]); const handleDictateCancel = useCallback(() => { void speechToText.cancel(); }, [speechToText.cancel]); // Leaving the conversation unmounts the composer while the runtime (and its // recorder state) stay alive — discard any in-flight recording so the mic can't // keep running or get transcribed into the next conversation's draft. useEffect(() => { return () => { void speechToText.cancel(); }; }, [speechToText.cancel]); const startLiveVoice = useCallback(async () => { if (!onStartLiveVoice || liveVoiceState !== 'idle') return; setMediaError(null); setLiveVoiceState('processing'); try { await onStartLiveVoice(context); } catch (error) { Alert.alert( 'Live Voice unavailable', error instanceof Error ? error.message : 'Failed to start Live Voice', ); } finally { setLiveVoiceState('idle'); } }, [context, liveVoiceState, onStartLiveVoice]); const canAttach = Boolean(onImportFromDrive || onPickFiles || onPickPhotos || onTakePhoto) && attachments.length < MAX_ATTACHMENTS; const isDraftEmpty = draft.trim().length === 0 && attachments.length === 0; // iOS can't present a native picker over the still-open Modal — defer it to // Modal.onDismiss (iOS-only); other platforms launch on tap. const pendingPickerRef = useRef<{ picker: SuperagentMediaPicker; source?: AttachmentSource } | undefined>(undefined); const openAttachmentOptions = useCallback(() => { if (!canAttach) return; Keyboard.dismiss(); // else the keyboard covers the sheet setIsAttachmentMenuOpen(true); }, [canAttach]); // Closing without a pick clears any deferred picker so onDismiss can't launch one. const closeAttachmentMenu = useCallback(() => { pendingPickerRef.current = undefined; setIsAttachmentMenuOpen(false); }, []); const selectPicker = useCallback((picker: SuperagentMediaPicker | undefined, source?: AttachmentSource) => { if (!picker || attachments.length >= MAX_ATTACHMENTS) return; if (Platform.OS === 'ios') { pendingPickerRef.current = { picker, source }; setIsAttachmentMenuOpen(false); return; } setIsAttachmentMenuOpen(false); void addFromPicker(picker, source); }, [addFromPicker, attachments.length]); const handleDrawerDismiss = useCallback(() => { const pending = pendingPickerRef.current; pendingPickerRef.current = undefined; if (pending) void addFromPicker(pending.picker, pending.source); }, [addFromPicker]); // A controlled multiline TextInput on iOS can fail to visually repaint when its // value is set to '' from JS state while it holds focus (the send path clears the // draft this way). Force the native clear on the non-empty → empty transition so // the field always empties after a send; value='' agrees, so nothing re-asserts. const prevDraftRef = useRef(draft); useEffect(() => { if (prevDraftRef.current.length > 0 && draft.length === 0) inputRef.current?.clear(); prevDraftRef.current = draft; }, [draft]); // Tapping a suggestion fills the composer (it does NOT auto-send, matching web) and // focuses the input; the suggestion state owner reports the click for analytics. const handleSelectSuggestion = useCallback((suggestion: SuperagentPromptSuggestion) => { suggestions?.onSelect(suggestion); onChangeDraft(suggestion.prompt); inputRef.current?.focus(); }, [onChangeDraft, suggestions]); // Dictation errors flow through the hook; surface them in the same slot as // attachment/media errors, preferring a live dictation error when present. const composerError = speechToText.error ?? mediaError; return ( {composerError ? {composerError} : null} {isOutOfCredits ? : null} {replyTo ? : null} {suggestions ? ( ) : null} selectPicker(onImportFromDrive) : undefined} onPickFiles={onPickFiles ? () => selectPicker(onPickFiles, 'files') : undefined} onPickPhotos={onPickPhotos ? () => selectPicker(onPickPhotos, 'photos') : undefined} onTakePhoto={onTakePhoto ? () => selectPicker(onTakePhoto, 'camera') : undefined} visible={isAttachmentMenuOpen} /> {speechToText.state !== 'idle' ? ( ) : ( <> {onOpenModelSettings ? ( ) : null} )} ); } function AttachmentDrawer({ canAttach, onClose, onDismiss, onImportFromDrive, onPickFiles, onPickPhotos, onTakePhoto, visible, }: { canAttach: boolean; onClose: () => void; onDismiss?: () => void; onImportFromDrive?: () => void; onPickFiles?: () => void; onPickPhotos?: () => void; onTakePhoto?: () => void; visible: boolean; }) { return ( {/* Sibling (not wrapper) so screen readers reach each item, not one grouped button. */} {/* No-op press so sheet taps don't reach the backdrop; accessible={false} keeps items focusable. */} {}} style={composerStyles.attachmentDrawerSheet}> Add attachment {onImportFromDrive ? ( <> Plugins ) : null} ); } function AttachmentMenuItem({ Icon, disabled, label, onPress, }: { Icon: LucideIcon; disabled?: boolean; label: string; onPress?: () => void; }) { return ( [ composerStyles.attachmentMenuItem, disabled && composerStyles.disabledButton, pressed && styles.pressed, ]} > {label} ); } function ModelPickerButton({ label, onPress }: { label?: string; onPress: () => void }) { const displayLabel = label ?? 'Auto'; return ( [composerStyles.modelButton, pressed && styles.pressed]} > {displayLabel} ); } function ReplyPreview({ onClear, replyTo }: { onClear: () => void; replyTo: SuperagentReplyTo }) { return ( {replyTo.content} ); } function ToolButton({ Icon, active, disabled, label, onPress, }: { Icon: LucideIcon; active?: boolean; disabled?: boolean; label: string; onPress: () => void; }) { const iconColor = themedColor(disabled ? '#8E8E93' : '#F4F4F5'); return ( [ composerStyles.iconButton, active && composerStyles.activeButton, disabled && composerStyles.disabledButton, pressed && styles.pressed, ]}> ); } // Idle mic button (mirrors the web builder's standalone mic button). Recording // and processing visuals live in DictationBar, which replaces the whole composer // while dictation is active — this button only ever renders idle. function DictationButton({ disabled, onPress }: { disabled?: boolean; onPress: () => void }) { return ( [ composerStyles.iconButton, disabled && composerStyles.disabledButton, pressed && styles.pressed, ]}> ); } // Live voice waveform: a conveyor of level bars scrolling right→left. Each tick // samples the mic level (recorder metering via `getLevel`; a synthetic source // when the host can't meter), runs it through fast-attack/slow-decay smoothing, // appends a bar at the right edge, and slides the strip over one bar-slot with a // linear animation — so the motion is continuous, not stepped. Bars are a ring // buffer with stable keys, so per tick React mounts one bar and leaves the other // 63 untouched instead of restyling the whole strip. Loud bars render dark, // quiet ones gray. Bar geometry (width/gap) lives here, applied inline, because // the slide distance must stay equal to one rendered bar slot. const WAVE_BAR_COUNT = 64; const WAVE_TICK_MS = 70; const WAVE_BAR_WIDTH = 3; const WAVE_BAR_GAP = 3; const WAVE_MIN_HEIGHT = 4; const WAVE_MAX_HEIGHT = 26; const WAVE_LOUD_LEVEL = 0.35; // bars at/above this level (0..1) render "loud" (dark) function LiveWaveform({ getLevel }: { getLevel?: () => number }) { const [bars, setBars] = useState(() => Array.from({ length: WAVE_BAR_COUNT }, (_, index) => ({ key: index, level: 0 })), ); const nextKeyRef = useRef(WAVE_BAR_COUNT); const translate = useRef(new Animated.Value(0)).current; const [syntheticLevel] = useState(() => createSyntheticLevelSource()); useEffect(() => { let active = true; const step = () => { if (!active) return; const sample = getLevel ? getLevel() : syntheticLevel(); setBars((previous) => [ ...previous.slice(1), { key: nextKeyRef.current++, level: smoothLevel(previous[previous.length - 1].level, sample) }, ]); // New bar starts one slot past the right edge (clipped) and slides in. translate.setValue(WAVE_BAR_WIDTH + WAVE_BAR_GAP); Animated.timing(translate, { duration: WAVE_TICK_MS, easing: Easing.linear, toValue: 0, useNativeDriver: Platform.OS !== 'web', // rn-web lacks the native animated module }).start(({ finished }) => { if (finished) step(); }); }; step(); return () => { active = false; translate.stopAnimation(); }; }, [getLevel, syntheticLevel, translate]); const loudColor = themedSurface('#F4F4F5'); // actionPrimary — tracks the send button's inverse fill const quietColor = themedSurface('#505050'); // chipFill return ( {bars.map(({ key, level }) => ( = WAVE_LOUD_LEVEL ? loudColor : quietColor, height: levelToWaveHeight(level, WAVE_MIN_HEIGHT, WAVE_MAX_HEIGHT), width: WAVE_BAR_WIDTH, }, ]} /> ))} ); } // Action button for the dictation bar — rounded square matching the composer's // send button. `disabled` blocks interaction; `dimmed` applies the faded look // separately, so the button showing the in-flight spinner can stay fully opaque // while still being non-interactive. function DictationActionButton({ children, dimmed, disabled, inverse, label, onPress, }: { children: React.ReactNode; dimmed?: boolean; disabled?: boolean; inverse?: boolean; label: string; onPress: () => void; }) { return ( [ inverse ? composerStyles.dictationActionButtonInverse : composerStyles.dictationActionButton, dimmed && composerStyles.disabledButton, pressed && styles.pressed, ]} > {children} ); } function useRecordingSeconds(running: boolean) { const [seconds, setSeconds] = useState(0); useEffect(() => { setSeconds(0); if (!running) return; const id = setInterval(() => setSeconds((current) => current + 1), 1000); return () => clearInterval(id); }, [running]); return seconds; } // Recording bar — takes over the composer while dictating. Top row: scrolling // waveform with the elapsed timer at its right edge. Bottom row: a gray discard // (✕) button on the left; on the right a gray stop (⏹ → transcribe into the // composer for review) button next to an inverse send-now (↑ → transcribe and // send) button. While transcribing, the wave row disappears, a small status label // sits between the buttons, and the tapped action's button shows a spinner. function DictationBar({ getLevel, onCancel, onReview, onSendNow, state, }: { getLevel?: () => number; onCancel: () => void; onReview: () => void; onSendNow: () => void; state: SpeechToTextState; }) { const isRecording = state === 'recording'; const seconds = useRecordingSeconds(isRecording); const [pendingAction, setPendingAction] = useState<'review' | 'send' | null>(null); const handleReview = useCallback(() => { setPendingAction('review'); onReview(); }, [onReview]); const handleSendNow = useCallback(() => { setPendingAction('send'); onSendNow(); }, [onSendNow]); return ( {isRecording ? ( {formatRecordingDuration(seconds)} ) : null} {!isRecording ? Transcribing… : null} {pendingAction === 'review' && !isRecording ? ( ) : ( )} {pendingAction === 'send' && !isRecording ? ( ) : ( )} ); } function ComposerActionButton({ canQueue, canSend, isDraftEmpty, isLiveVoiceActive, isQueueFull, isSending, onSend, onStartVoice, onStop, }: { canQueue: boolean; canSend: boolean; isDraftEmpty: boolean; isLiveVoiceActive: boolean; isQueueFull?: boolean; isSending: boolean; onSend: () => void; onStartVoice?: () => void; onStop: () => void; }) { if (isSending) { // Keep Stop as the primary action, but expose a Send button beside it whenever // there's a draft — tapping it queues the message behind the running turn. It's // disabled (muted) once the queue is full. return ( {!isDraftEmpty ? ( [ composerStyles.sendButton, !canQueue && composerStyles.sendButtonDisabled, pressed && styles.pressed, ]} > ) : null} ); } if (isDraftEmpty && SHOW_VOICE_BUTTON) { const disabled = !onStartVoice; return ( [ composerStyles.sendButton, composerStyles.liveActionButton, isLiveVoiceActive && composerStyles.activeButton, disabled && composerStyles.sendButtonDisabled, pressed && styles.pressed, ]}> ); } return ( [ composerStyles.sendButton, !canSend && composerStyles.sendButtonDisabled, pressed && styles.pressed, ]}> ); }