import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { SUPERAGENT_CONNECTOR_CATALOG } from '../connectors/connectorCatalog'; import { isPaymentIntegration } from '../../runtime/superagentApiClient'; import { connectorLabel } from '../connectors/connectorLabel'; import { getDeclaredConnectors, getTriggeredConnectors } from './messageUtils'; import { useAgentBi } from '../../analytics/mixpanelContext'; import { MESSAGES_PAGE_SIZE } from './constants'; import { countAssistantResponses, createUserMessage, didMessageLand, getMessageCursor, findPendingClarification, hasNewAssistantResponse, hasPendingClarification, hasRunningToolCall, isQueuedResponse, isVisibleMessage, mergeConversationSnapshot, mergeMessage, normalizeMessages, pollConversation, pollQueuedSend, refreshConversation, sendWithFallback, } from './conversationRuntime'; import { isQueueFull as isQueueFullUtil } from './queuedMessagesUtils'; import { useConversationQueue } from './useConversationQueue'; import type { QueuedSuperagentMessage, SuperagentConversation, SuperagentMessage, SuperagentNativeClient, SuperagentRealtimeClient, SuperagentReplyTo } from '../../types'; type SendMessageHandler = (params: { agentId: string; fileUrls?: string[]; message: string; replyTo?: SuperagentReplyTo; requestedConnectors?: string[]; connectRequested?: boolean; hidden?: boolean }) => Promise | SuperagentMessage | SuperagentMessage[] | void; type UseConversationParams = { agentId: string; apiClient?: SuperagentNativeClient; realtimeClient?: SuperagentRealtimeClient; currentUserId?: string | null; fallbackMessages: SuperagentMessage[]; fallbackSending: boolean; onAgentMessageDone?: () => Promise | void; onConversationSettled?: () => Promise | void; onSendMessage?: SendMessageHandler; // Skip the hidden intro bootstrap on an empty conversation — used when a seeded // prompt will be auto-sent as the first turn, so no welcome intro precedes it. skipIntro?: boolean; }; const CONNECTABLE_IDS = new Set(SUPERAGENT_CONNECTOR_CATALOG.map((connector) => connector.id)); function buildConnectorTriggerPrompt(connectorIds: string[]): string { const names = connectorIds.map(connectorLabel).join(', '); return `Connect the requested integrations now: ${names}. Use them for my original request.`; } export function useSuperagentConversation({ agentId, apiClient, realtimeClient, currentUserId, fallbackMessages, fallbackSending, onAgentMessageDone, onConversationSettled, onSendMessage, skipIntro, }: UseConversationParams) { const bi = useAgentBi(); const [conversationId, setConversationId] = useState(null); const [messages, setMessages] = useState(fallbackMessages); const [queuedMessages, setQueuedMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isSending, setIsSending] = useState(false); const [initError, setInitError] = useState(null); // An answer in flight hands the composer back immediately, so follow-ups queue. const [isResolvingClarification, setIsResolvingClarification] = useState(false); const [hasMoreMessages, setHasMoreMessages] = useState(false); // Monotonic counter bumped exactly when an agent turn genuinely completes (i.e. // wherever onAgentMessageDone fires). Consumers (e.g. prompt suggestions) can watch // it to react to real completions only — a failed send or a stop clears the busy // state without bumping this, so they aren't mistaken for a finished reply. const [agentMessageDoneSignal, setAgentMessageDoneSignal] = useState(0); const sendGenerationRef = useRef(0); const pollIntervalRef = useRef | null>(null); const loadingPreviousRef = useRef(false); const settleTimeoutRef = useRef | null>(null); const messagesRef = useRef(messages); const onAgentMessageDoneRef = useRef(onAgentMessageDone); const onConversationSettledRef = useRef(onConversationSettled); useEffect(() => { messagesRef.current = messages; }, [messages]); useEffect(() => { onAgentMessageDoneRef.current = onAgentMessageDone; onConversationSettledRef.current = onConversationSettled; }, [onAgentMessageDone, onConversationSettled]); // Fires the genuine-completion callback and bumps the signal in lockstep, so every // real agent-message-done is observable to consumers. Stable identity (empty deps), // so replacing the direct ref calls with it doesn't change any memoization. const emitAgentDone = useCallback(() => { onAgentMessageDoneRef.current?.(); setAgentMessageDoneSignal((n) => n + 1); }, []); // Cancel any in-flight poll/settle timers — called on each new send and on unmount. const clearPendingSettlers = useCallback(() => { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; } if (settleTimeoutRef.current) { clearTimeout(settleTimeoutRef.current); settleTimeoutRef.current = null; } }, []); // Clear any active poll interval / settle timeout when the hook unmounts. useEffect(() => { return () => clearPendingSettlers(); }, [clearPendingSettlers]); // Message-queue subsystem (enqueue-while-busy, promote-on-done, edit/delete). It owns // the pending-turn count; `makeOnSettle` builds the completion handler each turn routes // through. See useConversationQueue. const { pendingCountRef, makeOnSettle, enqueue, editQueuedMessage, deleteQueuedMessage, resetQueue, } = useConversationQueue({ apiClient, conversationId, realtimeClient, messagesRef, setMessages, queuedMessages, setQueuedMessages, pollIntervalRef, emitAgentDone, onConversationSettledRef, }); useEffect(() => { if (!apiClient) setMessages(fallbackMessages); }, [apiClient, fallbackMessages]); useEffect(() => { if (!apiClient) return; const client = apiClient; let cancelled = false; // Clear the previous agent/client's conversation up front so a stale id or // thread can't linger while the new one loads — or, if the reload fails, be // shown or receive sends (conversationUnavailable only blocks with no id). setConversationId(null); setMessages([]); setHasMoreMessages(false); resetQueue(); // drop the previous agent's pending turns / queued follow-ups async function initConversation() { setIsLoading(true); setInitError(null); try { const conversations = await client.getConversations(1); let conversation = conversations[0] ?? await client.createConversation({}); const page = await client.getMessages(conversation.id, { limit: MESSAGES_PAGE_SIZE }); if (cancelled) return; conversation = { ...conversation, messages: page.messages }; setConversationId(conversation.id); setMessages(normalizeMessages(conversation.messages ?? [])); setHasMoreMessages(Boolean(page.has_more ?? page.hasMore)); if (!skipIntro && (conversation.messages ?? []).length === 0) { // Seed the intro via bootstrap-intro (hidden seed, no sandbox acquisition) // instead of sending a visible "Hey!" through the full /messages path — // opening an empty chat shouldn't stall on a cold sandbox or burn a // tool-capable turn. The assistant intro arrives via realtime / a later // refresh, so we don't block on it here. Skipped when a seeded prompt // will be the first turn (no welcome intro should precede the task). await client.bootstrapIntro(conversation.id); if (cancelled) return; await refreshConversation(client, conversation.id, setMessages); } } catch (error) { if (!cancelled) setInitError(error instanceof Error ? error.message : 'Failed to load conversation'); } finally { if (!cancelled) setIsLoading(false); } } initConversation(); return () => { cancelled = true; }; }, [agentId, apiClient]); useEffect(() => { if (!realtimeClient || !conversationId) return undefined; return realtimeClient.subscribeToConversation(conversationId, { onMessage(message) { // Don't clear the sending/stop state on intermediate assistant messages // (tool-call steps or streamed content arrive before the turn is done). // The turn ends on `agent_done` (onAgentDone) or the 5s settle fallback. setMessages((current) => mergeMessage(current, message)); }, onConversation(conversation) { if (conversation.id !== conversationId) return; const incoming = normalizeMessages(conversation.messages ?? []); setMessages((current) => mergeConversationSnapshot(current, incoming)); }, onAgentDone(payload) { // Shared conversations: the backend tags agent_done with the sender's // platform user id. Ignore completions from OTHER collaborators so a // teammate's finishing run doesn't clear THIS user's sending state or fire // their completion callbacks early (matches the web client). if (payload?.sender_platform_user_id && currentUserId && payload.sender_platform_user_id !== currentUserId) { return; } // Agent finished via realtime — cancel the 5s settle fallback so // onConversationSettled doesn't also fire when the timer elapses. if (settleTimeoutRef.current) { clearTimeout(settleTimeoutRef.current); settleTimeoutRef.current = null; } // Guard the busy-state clear against a newer send superseding this turn // while we refresh/poll: only clear if no later send has started since // (mirrors setSendingForCurrentSend in sendMessage). const sendGeneration = sendGenerationRef.current; const setSendingIfCurrent = (value: boolean) => { if (sendGenerationRef.current === sendGeneration) setIsSending(value); }; // Route the turn end through settleTurn: it drops one pending turn and either // promotes the next queued follow-up (keeping busy) or fully settles. const onSettle = makeOnSettle(setSendingIfCurrent, payload?.pending_clarification === true); if (!apiClient) { onSettle(true); return; } const handOffToPoll = (assistantCountBefore: number) => { // Keep the busy state and let the bounded poll finish the turn (it // retries refreshes and settles once no tool is running or the cap is // hit), matching the no-realtime poll and 5s settle paths. if (pollIntervalRef.current) clearInterval(pollIntervalRef.current); pollIntervalRef.current = pollConversation(apiClient, conversationId, setMessages, assistantCountBefore, onSettle); }; refreshConversation(apiClient, conversationId, setMessages) .then((refreshed) => { // agent_done arrived while a tool is still running in the snapshot — // don't settle yet, let the poll loop finish it. if (hasRunningToolCall(refreshed)) { handOffToPoll(countAssistantResponses(refreshed)); return; } onSettle(true); }) .catch(() => { // The final refresh failed, so we can't tell whether a tool is still // running — hand off to the bounded poll instead of clearing the busy // state blind (it will settle on completion or the hard cap). handOffToPoll(countAssistantResponses(messagesRef.current)); }); }, onReconnect() { // A reconnect means the transient socket error cleared — drop the // "something went wrong" state so it doesn't linger as a failed load. setInitError(null); if (apiClient) refreshConversation(apiClient, conversationId, setMessages).catch(() => {}); }, onError(error) { setInitError(error instanceof Error ? error.message : 'Realtime connection failed'); }, }); }, [apiClient, conversationId, currentUserId, realtimeClient]); const visibleMessages = useMemo(() => { // Dedupe by id: a promoted queued message can momentarily coexist with a server // echo / stale-race append of the same id, and React must never see a duplicate key. const seen = new Set(); return messages.filter((message, index) => { if (!isVisibleMessage(message, index)) return false; if (message.id) { if (seen.has(message.id)) return false; seen.add(message.id); } return true; }); }, [messages]); // Resolves true when the send was accepted (queued/sent), false when it failed // before reaching the conversation — so the composer can restore the draft. const sendMessage = useCallback(async ( content: string, options: { fileUrls?: string[]; replyTo?: SuperagentReplyTo; connectorIds?: string[]; connectRequested?: boolean; hidden?: boolean } = {}, ): Promise => { const trimmedContent = content.trim(); const fileUrls = options.fileUrls?.filter(Boolean) ?? []; if (!trimmedContent && fileUrls.length === 0) return false; // Skip hidden sends (connector-OAuth auto-triggers) — only real user turns count. if (!options.hidden) { void bi.trackEditor('Message Sent', { has_files: fileUrls.length > 0, is_first: !messagesRef.current.some((message, index) => message.role === 'user' && isVisibleMessage(message, index)), }); } // Build the message once so the enqueue and active-send paths share the same // optimistic row / connector seed / hidden flag. const userMessage = createUserMessage(trimmedContent, { fileUrls, replyTo: options.replyTo }); // connectRequested is the phase-2 flag that triggers the backend connect flow; // phase-1 sends declare requested_connectors only. See the auto-trigger below. const requestedConnectors = options.connectorIds?.filter(Boolean) ?? []; if (requestedConnectors.length > 0) { userMessage.additional_message_params = { ...userMessage.additional_message_params, requested_connectors: requestedConnectors, ...(options.connectRequested ? { connect_requested_connectors: true } : {}), }; } if (options.hidden) userMessage.hidden = true; // Busy → enqueue behind the running turn(s). The active turn keeps its poller/realtime; // this message drains via promote-on-done (see useConversationQueue). if (pendingCountRef.current > 0) return enqueue(userMessage); // A new send from idle supersedes any pending poll/settle timers from before. clearPendingSettlers(); const assistantCountBefore = countAssistantResponses(messagesRef.current); setMessages((current) => [...current, userMessage]); if (!apiClient || !conversationId) { if (!onSendMessage) { // No send path yet (no fallback handler, or the conversation is still // loading) — drop the optimistic bubble so the turn doesn't look sent. setMessages((current) => current.filter((message) => message.id !== userMessage.id)); return false; } const sendGeneration = sendGenerationRef.current + 1; sendGenerationRef.current = sendGeneration; const setSendingForCurrentSend = (value: boolean) => { if (sendGenerationRef.current === sendGeneration) setIsSending(value); }; setIsSending(true); const { delivered, hasAssistantResponse } = await sendWithFallback( agentId, trimmedContent, fileUrls, options.replyTo, onSendMessage, setMessages, setSendingForCurrentSend, userMessage.id, { requestedConnectors: requestedConnectors.length > 0 ? requestedConnectors : undefined, connectRequested: options.connectRequested, hidden: options.hidden }, ); if (hasAssistantResponse) emitAgentDone(); // Report actual delivery so the composer restores the draft on a rejected // fallback send instead of silently dropping the user's text/attachments. return delivered; } const sendGeneration = sendGenerationRef.current + 1; sendGenerationRef.current = sendGeneration; const setSendingForCurrentSend = (value: boolean) => { if (sendGenerationRef.current === sendGeneration) setIsSending(value); }; // The turn end (and any promoted follow-up turns) route through settleTurn. const onSettle = makeOnSettle(setSendingForCurrentSend); pendingCountRef.current += 1; setIsSending(true); // Wait for the turn to complete: poll without realtime, otherwise a 5s // fallback in case agent_done never arrives. const armSettleFallback = () => { if (!realtimeClient) { if (pollIntervalRef.current) clearInterval(pollIntervalRef.current); pollIntervalRef.current = pollConversation(apiClient, conversationId, setMessages, assistantCountBefore, onSettle); } else { settleTimeoutRef.current = setTimeout(async () => { settleTimeoutRef.current = null; // agent_done is late — hand off to the bounded poll loop, which keeps // "sending" until tools finish (or the hard cap), instead of clearing // it mid-turn. const handOffToPoll = () => { if (pollIntervalRef.current) clearInterval(pollIntervalRef.current); pollIntervalRef.current = pollConversation(apiClient, conversationId, setMessages, assistantCountBefore, onSettle); }; try { const refreshed = await refreshConversation(apiClient, conversationId, setMessages); if (hasRunningToolCall(refreshed)) { handOffToPoll(); return; } } catch { // Refresh failed — we can't tell whether a tool is still running, so // hand off to the bounded poll rather than clearing the busy state // blind (it retries and settles on completion or the hard cap). handOffToPoll(); return; } onSettle(true); }, 5000); } }; try { const response = await apiClient.addMessage(conversationId, userMessage); if (isQueuedResponse(response)) { // Queued behind ANOTHER session's turn (collaborator/device); the optimistic // bubble already shows in the thread. Wait for OUR message to drain from the // backend queue, then poll its reply. Our own follow-ups take the enqueue // branch above and never reach here. if (pollIntervalRef.current) clearInterval(pollIntervalRef.current); pollIntervalRef.current = pollQueuedSend( apiClient, conversationId, userMessage.id!, setMessages, (interval) => { pollIntervalRef.current = interval; }, onSettle, ); } else { let refreshedMessages; try { refreshedMessages = await refreshConversation(apiClient, conversationId, setMessages); } catch { // addMessage already succeeded (a non-queued send returns only after the // agent is invoked), so the turn IS delivered. A failed post-send refresh // must NOT drop the optimistic row or report failure — that would make // ConversationChat restore the draft and the user resend, duplicating the // message. Hand off to the bounded poll/settle loop and report delivered. armSettleFallback(); return true; } // Only complete immediately if a new assistant reply landed AND no tool is // still running — otherwise hand off to armSettleFallback (pollConversation // without realtime), which keeps "sending" until tools finish. Matches the // poll/settle paths so tool-using turns don't drop the busy state early. if (!realtimeClient && hasNewAssistantResponse(refreshedMessages, assistantCountBefore) && !hasRunningToolCall(refreshedMessages)) { onSettle(true); } else { armSettleFallback(); } } return true; } catch (error) { console.error('Superagent send failed', error); // The error itself can't say whether the message landed (see didMessageLand), so ask // the server. If it has the row, keep the optimistic bubble, let the poll/settle loop // finish the turn, and report delivered — restoring the draft would have the user // resend a turn the backend is already running. if (await didMessageLand(apiClient, conversationId, userMessage.id, setMessages)) { armSettleFallback(); return true; } // The message never landed: drop the optimistic bubble — otherwise the turn looks // sent and a later refresh/resend could duplicate it. No error bubble in the chat — // the composer restores the draft when the send reports failure. setMessages((current) => current.filter((message) => message.id !== userMessage.id)); pendingCountRef.current = Math.max(0, pendingCountRef.current - 1); setSendingForCurrentSend(false); return false; } }, [agentId, apiClient, clearPendingSettlers, conversationId, bi, makeOnSettle, onSendMessage, realtimeClient]); // Phase-2 connector auto-trigger (mirrors web's useRequestedConnectorAutoTrigger): // after the agent replies to the phase-1 declare, send a hidden follow-up that // flips connect_requested_connectors so the backend runs the OAuth connect flow. const connectTriggerRef = useRef>(new Set()); const connectAttemptsRef = useRef>(new Map()); useEffect(() => { const hasApiPath = Boolean(apiClient && conversationId); const hasFallbackPath = !apiClient && Boolean(onSendMessage); if (!hasApiPath && !hasFallbackPath) return; // apiClient configured but no id yet → wait // fallbackSending is the host's send state; local isSending clears when its handler returns. if (isLoading || isSending || fallbackSending) return; const all = messagesRef.current; if (!all.some((message) => message.role === 'assistant')) return; const triggered = new Set(getTriggeredConnectors(all)); // Payments run through the separate payment-setup surface, not this OAuth trigger. const pending = getDeclaredConnectors(all).filter( (id) => CONNECTABLE_IDS.has(id) && !isPaymentIntegration(id) && !triggered.has(id), ); if (pending.length === 0) return; const key = `${conversationId ?? agentId}:${pending.join(',')}`; if (connectTriggerRef.current.has(key)) return; // Cap attempts: a failed send re-arms the trigger key below, so without a // cap this effect would retry forever. const attempts = connectAttemptsRef.current.get(key) ?? 0; if (attempts >= 2) return; connectAttemptsRef.current.set(key, attempts + 1); connectTriggerRef.current.add(key); void sendMessage(buildConnectorTriggerPrompt(pending), { connectorIds: pending, connectRequested: true, hidden: true, }).then((delivered) => { if (!delivered) connectTriggerRef.current.delete(key); // bounded retry (attempt cap above) }); }, [agentId, apiClient, conversationId, fallbackSending, isLoading, isSending, messages, onSendMessage, sendMessage]); const deleteMessage = useCallback(async (messageId: string) => { if (!messageId || messageId === 'welcome') return false; if (!apiClient || !conversationId) { setMessages((current) => current.filter((message) => message.id !== messageId)); return true; } try { await apiClient.deleteMessage(conversationId, messageId); // Remove locally before refreshing: mergeConversationSnapshot keeps rows // missing from the server snapshot, so without this the just-deleted // message would be resurrected. setMessages((current) => current.filter((message) => message.id !== messageId)); await refreshConversation(apiClient, conversationId, setMessages); return true; } catch { // A failed delete is not a load failure — don't drive the "something went // wrong" panel (it would persist). Signal failure via the return value so // the caller can surface it. return false; } }, [apiClient, conversationId]); const stop = useCallback(async () => { void bi.trackEditor('Stop Generation'); sendGenerationRef.current += 1; clearPendingSettlers(); resetQueue(); if (!apiClient || !conversationId) { setIsSending(false); return; } setIsSending(false); await apiClient.stopConversation(conversationId); await refreshConversation(apiClient, conversationId, setMessages); }, [apiClient, clearPendingSettlers, conversationId, bi, resetQueue]); const loadPrevious = useCallback(async () => { if (loadingPreviousRef.current) return; const before = getMessageCursor(messages[0]); if (!apiClient || !conversationId || !before) return; loadingPreviousRef.current = true; void bi.trackEditor('Chat Load Older'); try { const page = await apiClient.getMessages(conversationId, { limit: MESSAGES_PAGE_SIZE, before }); setMessages((current) => [...normalizeMessages(page.messages), ...current]); setHasMoreMessages(Boolean(page.has_more ?? page.hasMore)); } catch { // Pagination failed — swallow so it isn't an unhandled rejection from the // button, and leave hasMoreMessages as-is so the user can retry. } finally { loadingPreviousRef.current = false; } }, [apiClient, conversationId, messages, bi]); const submitToolCallInput = useCallback(async (toolCallId: string, approve: boolean, extraUserInput?: unknown, originRequestId?: string): Promise => { if (!apiClient || !conversationId) return null; // Only a clarification resume needs turn bookkeeping: its turn was parked, so // nothing else will settle it. Other approvals already settled when they paused, // and the batch card submits several in a row — counting those would settle the // burst on the first one, while its siblings are still waiting. if (findPendingClarification(messagesRef.current)?.id !== toolCallId) { const approved = await apiClient.submitToolCallInput(conversationId, toolCallId, approve, extraUserInput, originRequestId); if (approved.messages) { setMessages((current) => mergeConversationSnapshot(current, normalizeMessages(approved.messages!))); } onConversationSettled?.(); return approved; } // Guard the busy-state clear against a newer send superseding this turn // (same pattern as sendMessage / onAgentDone). const sendGeneration = sendGenerationRef.current; // Stop and any later send bump the generation and reset the queue, so a resume // resolving after that must not touch the count or strip — they belong to another // turn now. Guarding the busy setter alone isn't enough: settleTurn mutates both. const isCurrentTurn = () => sendGenerationRef.current === sendGeneration; const setSendingIfCurrent = (value: boolean) => { if (isCurrentTurn()) setIsSending(value); }; // Counting the resume is what makes a message typed while the agent answers // queue behind it instead of racing the lock it still holds. Only reached // because the user answered, so walking away from a question leaks nothing. pendingCountRef.current += 1; setIsResolvingClarification(true); setSendingIfCurrent(true); try { const updated = await apiClient.submitToolCallInput(conversationId, toolCallId, approve, extraUserInput, originRequestId); const incoming = updated.messages ? normalizeMessages(updated.messages) : []; if (updated.messages) { setMessages((current) => mergeConversationSnapshot(current, incoming)); } // No agent_done arrives for a resumed turn, so settle it here. settleTurn owns // the settled notification — firing it here too would double it, and fire it // early whenever follow-ups remain. if (isCurrentTurn()) { makeOnSettle(setSendingIfCurrent, hasPendingClarification(incoming))(true); } return updated; } catch (error) { // The server still has the tool waiting, so roll back rather than settle: // settling would promote a follow-up it can't run and report a false completion. if (isCurrentTurn()) { pendingCountRef.current = Math.max(0, pendingCountRef.current - 1); setSendingIfCurrent(false); } throw error; } finally { setIsResolvingClarification(false); } }, [apiClient, conversationId, makeOnSettle, onConversationSettled, pendingCountRef]); return { agentMessageDoneSignal, conversationId, deleteMessage, deleteQueuedMessage, editQueuedMessage, hasMoreMessages, initError, isLoading, isResolvingClarification, isSending: isSending || fallbackSending, // The composer's enqueue affordance is capped once the running turn plus the // queued follow-ups reach MAX_PENDING_TURNS; pendingCountRef stays the hard guard. isQueueFull: isQueueFullUtil(queuedMessages.length, isSending || fallbackSending), // Ready to send when the chosen path can reach the real thread: with an // apiClient that means waiting for conversationId (so a seeded prompt goes to // the API conversation, not the onSendMessage fallback); without an apiClient, // the onSendMessage fallback is the only path, so an id never comes. isSendReady: apiClient ? conversationId != null : Boolean(onSendMessage), loadPrevious, messages: visibleMessages, queuedMessages, sendMessage, submitToolCallInput, stop, }; }