import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { themedColor } from '../../theme'; import { ActivityIndicator, Keyboard, Pressable, ScrollView, StyleSheet, Text, useWindowDimensions, View, type NativeScrollEvent, type NativeSyntheticEvent } from 'react-native'; import { ArrowDown } from 'lucide-react-native'; import { resolveThinkingPhase } from './agentPhase'; import { buildOutOfCreditsShownProperties, buildOutOfCreditsUpgradeProperties, isCreditSendAllowed, } from '../credits/creditsUtils'; import { ClarifyingQuestionsCard } from '../../toolWidgets/clarifyingQuestions/ClarifyingQuestionsCard'; import { composerStyles } from './composerStyles'; import { ConversationComposer } from './ConversationComposer'; import { ConversationMessageList } from './ConversationMessageList'; import { QueuedMessages } from './QueuedMessages'; import { MarkdownScrollContext, type MarkdownScroller } from './markdownScrollContext'; import { useAgentBi } from '../../analytics/mixpanelContext'; import { TypingIndicator } from './conversationParts'; import { conversationStyles } from './conversationStyles'; import { styles } from '../../styles'; import { findPendingClarification } from './conversationRuntime'; import { buildAttachmentPrompt, MAX_ATTACHMENTS } from '../attachments/mediaUtils'; import { getModelLabel } from '../settings/modelOptions'; import { useSuperagentConnectors, useSuperagentConversationRuntime, useSuperagentMedia, useSuperagentShellOptions, } from '../../runtime/runtimeContext'; import { hasRealAssistantMessage } from './promptSuggestionUtils'; import { useKeyboardHeight } from './useKeyboardHeight'; import { usePromptSuggestions } from './usePromptSuggestions'; import type { SuperagentAgent, SuperagentMediaAttachment, SuperagentMessage, SuperagentNativeClient, SuperagentReplyTo, } from '../../types'; import type { useSuperagentConversation } from './useSuperagentConversation'; const MAX_CHAT_WIDTH = 900; const SCROLL_TO_BOTTOM_THRESHOLD = 120; // px from bottom const CLARIFICATION_MAX_HEIGHT_RATIO = 0.55; // leaves the transcript visible above const CLARIFICATION_MIN_HEIGHT = 220; // floor once the keyboard eats into the ratio's budget const mergeTranscript = (existing: string, transcript: string) => existing ? `${existing} ${transcript}` : transcript; type ConversationState = ReturnType; export function ConversationChat({ agent, apiClient, initialMessage, connectorIds, conversation, onOpenModelSettings, }: { agent: SuperagentAgent; /** REST client, used here to fetch prompt suggestions. Absent in fallback-only mode. */ apiClient?: SuperagentNativeClient; /** Auto-sent once as the first message when the conversation is ready (fresh agent). */ initialMessage?: string; connectorIds?: string[]; conversation: ConversationState; onOpenModelSettings?: () => void; }) { const bi = useAgentBi(); const { height: windowHeight } = useWindowDimensions(); const keyboardHeight = useKeyboardHeight(); const { availableConnectors, onConnectConnector } = useSuperagentConnectors(); const { currentUserAvatarUrl } = useSuperagentConversationRuntime(); const { onPickFiles, onPickPhotos, onTakePhoto, onStartLiveVoice } = useSuperagentMedia(); const { contentTopInset, onViewPlans, showDebugPayloads } = useSuperagentShellOptions(); // Import-from-drive/voice-input/markdown/tool-renderer overrides aren't part // of the mobile runtime surface; the message list + composer fall back to their // built-in defaults, so those child props are simply left unset. const scrollRef = useRef(null); // Host-component ref for anchor-link measureLayout (Fabric requires a native // component ref as the relative node, not a findNodeHandle number). const messagesContainerRef = useRef(null); const shouldAutoScrollRef = useRef(true); // Re-arm tail-follow only on an away→bottom transition, never while parked at the bottom. const wasAwayFromBottomRef = useRef(false); // Lets a content-size change (streaming growth) recompute the button without a scroll event. const lastScrollMetricsRef = useRef({ offsetY: 0, layoutHeight: 0 }); const lastTailMessageIdRef = useRef(null); const [draft, setDraft] = useState(''); const [attachments, setAttachments] = useState([]); const [replyTo, setReplyTo] = useState(null); const [showScrollToBottom, setShowScrollToBottom] = useState(false); // Conversation failed to initialize (apiClient path): no conversation id to send // against, so block sends rather than clear the composer and drop the text. const conversationUnavailable = Boolean(conversation.initError && !conversation.conversationId); // Prompt-suggestion chips above the composer, gated on a real agent reply existing. const hasAssistantMessage = useMemo( () => hasRealAssistantMessage(conversation.messages), [conversation.messages], ); const suggestions = usePromptSuggestions({ apiClient, conversationId: conversation.conversationId, isSending: conversation.isSending, hasAssistantMessage, agentMessageDoneSignal: conversation.agentMessageDoneSignal, }); // Gated on the answer being in flight, not the busy state: busy lags the tool // status both ways — still true as a question lands, already false mid-resume. const pendingClarification = useMemo( () => (conversation.isResolvingClarification ? undefined : findPendingClarification(conversation.messages)), [conversation.isResolvingClarification, conversation.messages], ); const [isClarificationCollapsed, setIsClarificationCollapsed] = useState(false); const pendingClarificationId = pendingClarification?.id; useEffect(() => setIsClarificationCollapsed(false), [pendingClarificationId]); // a new question opens expanded const creditSendAllowed = isCreditSendAllowed(suggestions); const hasComposerContent = draft.trim().length > 0 || attachments.length > 0; const canSend = hasComposerContent && !conversation.isSending && !conversation.isLoading && !conversationUnavailable && creditSendAllowed; // Enqueue behind the running turn: same gates as canSend but while busy, and only // when the queue isn't already full. const canQueue = hasComposerContent && conversation.isSending && !conversation.isLoading && !conversationUnavailable && creditSendAllowed && !conversation.isQueueFull; const reportedOutOfCreditsRef = useRef(false); useEffect(() => { if (reportedOutOfCreditsRef.current || !suggestions.isOutOfCredits || !suggestions.currentUsage) return; reportedOutOfCreditsRef.current = true; void bi.track('User out of credits', buildOutOfCreditsShownProperties(suggestions.currentUsage)); }, [bi, suggestions.currentUsage, suggestions.isOutOfCredits]); const handleOutOfCreditsViewPlans = useCallback(() => { if (suggestions.currentUsage) { void bi.track('User clicked upgrade', buildOutOfCreditsUpgradeProperties(suggestions.currentUsage)); } onViewPlans?.(); void suggestions.refreshUsage(); }, [bi, onViewPlans, suggestions.currentUsage, suggestions.refreshUsage]); // Plain object (not useMemo): the hook returns a fresh state object each render, so a // memo keyed on it would never cache, and ConversationComposer isn't memoized so its // identity doesn't matter. This is the composer's view-model — only the fields the // chips need, decoupled from the hook's send-flow methods (dismissOnSend/undo...). const composerSuggestions = { items: suggestions.suggestions, visible: suggestions.visible, showRestore: suggestions.showRestore, refreshing: suggestions.refreshing, onSelect: suggestions.reportSelect, onRefresh: suggestions.refresh, onDismiss: suggestions.dismiss, onRestore: suggestions.restore, }; const displayedMessages = useMemo( () => { if (conversation.messages.length > 0) return conversation.messages; // While the real conversation is still loading, don't fabricate a welcome // bubble — it would render next to the loading panel as if the agent already // sent an intro. if (conversation.isLoading) return []; // A seeded prompt will be the first turn (auto-sent, intro skipped), so don't // show the fabricated welcome placeholder before/alongside it. if (initialMessage?.trim()) return []; return [createWelcomeMessage(agent)]; }, [agent, conversation.messages, conversation.isLoading, initialMessage], ); // Derived once per message-list change rather than on every streamed-token // re-render (the chat re-renders frequently while a turn is in flight). const thinkingPhase = useMemo(() => resolveThinkingPhase(displayedMessages), [displayedMessages]); const tailMessage = displayedMessages[displayedMessages.length - 1]; const tailMessageId = tailMessage ? getMessageAutoScrollId(tailMessage, displayedMessages.length - 1) : null; const tailMessageContent = tailMessage?.content ?? ''; const scrollToBottom = useCallback((animated = true) => { setTimeout(() => scrollRef.current?.scrollToEnd({ animated }), 0); }, []); // Bring a markdown heading into view when an in-document anchor link is tapped // (handed down to MarkdownText via context). Pausing auto-scroll keeps the // tail-follow effect from yanking us back to the bottom. const markdownScroller = useMemo(() => ({ scrollViewIntoView: (target) => { const container = messagesContainerRef.current; if (!container) return; // measureLayout returns y relative to the messages container, which sits // below the scroll content's top padding (contentTopInset when the host // hides the header, otherwise the scrollContent paddingTop). Add it so the // heading lands at the top instead of below it. const scrollContentPaddingTop = Number(StyleSheet.flatten(conversationStyles.scrollContent).paddingTop) || 0; const topInset = contentTopInset ?? scrollContentPaddingTop; target.measureLayout( container, (_x, y) => { shouldAutoScrollRef.current = false; scrollRef.current?.scrollTo({ animated: true, y: Math.max(y + topInset - 12, 0) }); }, () => {}, ); }, }), [contentTopInset]); useEffect(() => { if (!tailMessageId) return; const tailMessageChanged = lastTailMessageIdRef.current !== tailMessageId; if (tailMessageChanged) { lastTailMessageIdRef.current = tailMessageId; shouldAutoScrollRef.current = true; } if (shouldAutoScrollRef.current) { scrollToBottom(tailMessageChanged); } }, [conversation.isSending, scrollToBottom, tailMessageContent, tailMessageId]); const pauseAutoScroll = useCallback(() => { shouldAutoScrollRef.current = false; }, []); const handleChatTouchStart = useCallback(() => { pauseAutoScroll(); Keyboard.dismiss(); }, [pauseAutoScroll]); const scrollAfterContentResize = useCallback((_contentWidth: number, contentHeight: number) => { const { layoutHeight } = lastScrollMetricsRef.current; if (shouldAutoScrollRef.current) { scrollToBottom(false); // Non-animated scrollToEnd may emit no onScroll, so sync the derived state by hand. if (layoutHeight > 0) lastScrollMetricsRef.current.offsetY = Math.max(contentHeight - layoutHeight, 0); setShowScrollToBottom(false); wasAwayFromBottomRef.current = false; return; } // Streaming growth fires no scroll event while paused — recompute from the last metrics. const { offsetY } = lastScrollMetricsRef.current; if (layoutHeight > 0) { const distanceFromBottom = contentHeight - offsetY - layoutHeight; const isAway = distanceFromBottom > SCROLL_TO_BOTTOM_THRESHOLD; setShowScrollToBottom(isAway); wasAwayFromBottomRef.current = isAway; } }, [scrollToBottom]); const handleScroll = useCallback((event: NativeSyntheticEvent) => { const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; lastScrollMetricsRef.current = { offsetY: contentOffset.y, layoutHeight: layoutMeasurement.height }; const distanceFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height; const atBottom = distanceFromBottom <= SCROLL_TO_BOTTOM_THRESHOLD; setShowScrollToBottom(!atBottom); if (atBottom && wasAwayFromBottomRef.current) shouldAutoScrollRef.current = true; wasAwayFromBottomRef.current = !atBottom; }, []); const handleScrollToBottomPress = useCallback(() => { shouldAutoScrollRef.current = true; scrollToBottom(true); }, [scrollToBottom]); // Bound to the seed text so a manual resend of the seeded prompt still carries its // connectors, but editing the draft away drops them (mirrors the web composer). const pendingSeedRef = useRef<{ text: string; connectorIds: string[] } | undefined>(undefined); // Core send path, parameterized on the draft text so callers that just merged // new text into the draft (e.g. dictation send-now) don't race the state update. const sendDraftContent = useCallback(async (selectedDraft: string) => { const selectedAttachments = attachments; const selectedReplyTo = replyTo; const content = selectedDraft.trim() || buildAttachmentPrompt(selectedAttachments); const seed = pendingSeedRef.current; // Only the unedited seed prompt inherits its connectors. const seedConnectors = seed && content === seed.text ? seed.connectorIds : undefined; if (!creditSendAllowed) return; Keyboard.dismiss(); setDraft(''); setAttachments([]); setReplyTo(null); // Hide suggestions for this turn; a fresh set arrives when the agent replies. suggestions.dismissOnSend(); const delivered = await conversation.sendMessage(content, { fileUrls: selectedAttachments.map((attachment) => attachment.url), replyTo: selectedReplyTo ?? undefined, connectorIds: seedConnectors, }); // Keep the seed only to retry the same failed prompt; drop it otherwise. if (delivered || !seed || content !== seed.text) { pendingSeedRef.current = undefined; } if (!delivered) { // The send failed before reaching the conversation — restore what the user // had typed (unless they've already started a new draft in the meantime), and // revert the optimistic dismiss so the chips they had don't stay hidden. setDraft((current) => current || selectedDraft); setAttachments((current) => (current.length > 0 ? current : selectedAttachments)); setReplyTo((current) => current ?? selectedReplyTo); suggestions.undoDismissOnSend(); } }, [attachments, conversation, conversationUnavailable, creditSendAllowed, replyTo, suggestions.dismissOnSend, suggestions.undoDismissOnSend]); const sendMessage = useCallback(() => sendDraftContent(draft), [draft, sendDraftContent]); // Dictation "send now": merge the transcript into the draft (so a failed send // restores it into the composer) and send that merged content directly. Reads // draft + send path through refs: it's invoked after the async transcription // gap, and the in-flight composer closure would otherwise send the draft (and // attachments) as they were when the recording was stopped — ignoring e.g. a // suggestion tapped or an attachment removed while "Transcribing…" was up. const draftRef = useRef(draft); draftRef.current = draft; const sendDraftContentRef = useRef(sendDraftContent); sendDraftContentRef.current = sendDraftContent; const sendTranscript = useCallback((transcript: string) => { const merged = mergeTranscript(draftRef.current, transcript); setDraft(merged); void sendDraftContentRef.current(merged); }, []); // Auto-send the seeded prompt (from the home composer / a tapped idea) as the // first message, once the conversation is ready. The seed is claimed once per mount // (see pendingFirstMessage), so this ref only guards against the async send being // re-entered by a deps-change re-render. const autoSendStartedRef = useRef(false); useEffect(() => { if (autoSendStartedRef.current) return; const text = initialMessage?.trim(); if (!text) return; if (!suggestions.usageReady) return; // Auto-send can never run — credits are exhausted, or init failed terminally // (no conversation id and no fallback) — so surface the seed in the composer // once instead of losing it. if (conversationUnavailable || suggestions.isOutOfCredits) { autoSendStartedRef.current = true; if (connectorIds?.length) pendingSeedRef.current = { text, connectorIds }; setDraft((current) => current || text); return; } // Wait until the send path can actually deliver — a conversation id (apiClient) // or an onSendMessage fallback; sending before then drops the message. if (!conversation.isSendReady || conversation.isLoading || conversation.isSending) return; // Latch before the async send so a deps-change re-render can't double-fire it. autoSendStartedRef.current = true; // Hide suggestions for this turn, same as a composer send (a fresh set arrives on // reply) — otherwise stale chips can flash once the seeded turn completes. suggestions.dismissOnSend(); void (async () => { const delivered = await conversation.sendMessage(text, { connectorIds }); if (!delivered) { // Surface the prompt + its connectors in the composer so the resend keeps them. if (connectorIds?.length) pendingSeedRef.current = { text, connectorIds }; setDraft((current) => current || text); suggestions.undoDismissOnSend(); } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ initialMessage, conversation.isSendReady, conversation.isLoading, conversation.isSending, conversationUnavailable, suggestions.isOutOfCredits, suggestions.usageReady, ]); const addAttachments = useCallback((incoming: SuperagentMediaAttachment[]) => { setAttachments((current) => [...current, ...incoming].slice(0, MAX_ATTACHMENTS)); }, []); const appendTranscript = useCallback((transcript: string) => { setDraft((current) => mergeTranscript(current, transcript)); }, []); const replyToMessage = useCallback((message: SuperagentMessage) => { const content = message.content?.trim(); if (!content) return; setReplyTo({ content, messageId: message.id, }); }, []); const composerContext = useMemo( () => ({ agentId: agent.id, conversationId: conversation.conversationId }), [agent.id, conversation.conversationId], ); const modelLabel = getModelLabel(agent.model); const clearReply = useCallback(() => setReplyTo(null), []); const removeAttachment = useCallback( (index: number) => setAttachments((current) => current.filter((_, itemIndex) => itemIndex !== index)), [], ); return ( { lastScrollMetricsRef.current.layoutHeight = event.nativeEvent.layout.height; }} onContentSizeChange={scrollAfterContentResize} onScroll={handleScroll} onScrollBeginDrag={pauseAutoScroll} onTouchStart={handleChatTouchStart} scrollEventThrottle={16} > {/* Show the live status whenever the agent is working — either a send is in flight, or the current turn has an active tool (e.g. resumed after an approval, or in_progress) where isSending isn't set. The in-bubble timeline is hidden in those cases, so this stays the single status row. */} {(conversation.isSending || thinkingPhase) ? : null} {showScrollToBottom ? ( [conversationStyles.scrollToBottomButton, pressed && styles.pressed]} > ) : null} {/* Sits outside the transcript's ScrollView. maxHeight subtracts keyboardHeight because useWindowDimensions doesn't shrink for the keyboard on its own. */} {pendingClarification ? ( 0} key={pendingClarification.id} maxHeight={Math.max( CLARIFICATION_MIN_HEIGHT, windowHeight * CLARIFICATION_MAX_HEIGHT_RATIO - keyboardHeight, )} onToggleCollapsed={() => setIsClarificationCollapsed((collapsed) => !collapsed)} submitToolCallInput={conversation.submitToolCallInput} toolCall={pendingClarification} /> ) : null} {/* Minimizing the card hands the turn back to the composer; suggestions stay hidden so the pending question keeps the user's attention. */} {!pendingClarification || isClarificationCollapsed ? ( ) : null} ); } function ConversationStates({ conversation }: { conversation: ConversationState }) { return ( <> {conversation.hasMoreMessages ? ( Load previous messages ) : null} {conversation.initError ? : null} {conversation.isLoading ? : null} ); } function LoadingPanel() { return ( Loading conversation... ); } function ErrorPanel({ message }: { message: string }) { return ( Something went wrong {message} ); } function createWelcomeMessage(agent: SuperagentAgent): SuperagentMessage { return { id: 'welcome', role: 'assistant', content: `Tell ${agent.name || 'your Superagent'} what you want to get done.`, }; } function getMessageAutoScrollId(message: SuperagentMessage, index: number) { return message.id ?? `${message.role}-${index}`; }