import { useCallback, useEffect, useRef } from 'react'; import type { Dispatch, MutableRefObject, SetStateAction } from 'react'; import { countAssistantResponses, isQueuedResponse, pollConversation } from './conversationRuntime'; import { addQueued, editQueued, MAX_PENDING_TURNS, promoteQueued, removeQueued } from './queuedMessagesUtils'; import type { QueuedSuperagentMessage, SuperagentMessage, SuperagentNativeClient, SuperagentRealtimeClient } from '../../types'; type SetSending = (value: boolean) => void; type ConversationQueueParams = { apiClient?: SuperagentNativeClient; conversationId: string | null; realtimeClient?: SuperagentRealtimeClient; messagesRef: MutableRefObject; setMessages: Dispatch>; queuedMessages: QueuedSuperagentMessage[]; setQueuedMessages: Dispatch>; pollIntervalRef: MutableRefObject | null>; emitAgentDone: () => void; onConversationSettledRef: MutableRefObject<(() => Promise | void) | undefined>; }; /** * The message-queue subsystem for `useSuperagentConversation`: it owns the pending-turn * count and the local queued-follow-ups strip, promotes the next queued message into the * live thread as each turn finishes (`settleTurn`), and exposes enqueue/edit/delete. * Split out of the conversation hook to keep both files focused (and under the line cap). */ export function useConversationQueue({ apiClient, conversationId, realtimeClient, messagesRef, setMessages, queuedMessages, setQueuedMessages, pollIntervalRef, emitAgentDone, onConversationSettledRef, }: ConversationQueueParams) { // Number of turns the backend still owes us: the running turn plus each follow-up the // user queued behind it. Mirrors the web agent-editor's pendingCountRef — the // authoritative busy signal inside sendMessage (the isSending state closure is stale). const pendingCountRef = useRef(0); // Latest queue snapshot, read synchronously by the settle path (several agent_done // events can fire back-to-back before the state effect flushes). const queuedMessagesRef = useRef(queuedMessages); useEffect(() => { queuedMessagesRef.current = queuedMessages; }, [queuedMessages]); // A turn finished: drop one pending turn, promote the next queued follow-up into the // live thread (the backend runs the already-persisted queued message next), and only // clear the busy state once the whole burst has drained. `setSendingIfCurrent` is the // generation-guarded setter from the active send. Recurses through handlersRef to // re-arm the no-realtime poll for the promoted turn. const handlersRef = useRef<{ settleTurn: (set: SetSending, pendingClarification?: boolean) => void; hardAbort: (set: SetSending) => void; }>(); // Bound up front from the agent_done payload, or per call from the poll, which // reads it off the snapshot — the settle path is reached both ways. const makeOnSettle = useCallback( (setSendingIfCurrent: SetSending, pendingClarification = false) => (completed: boolean, pendingClarificationFromSnapshot = false) => { const handlers = handlersRef.current; if (!handlers) return; if (completed) { handlers.settleTurn(setSendingIfCurrent, pendingClarification || pendingClarificationFromSnapshot); } else { handlers.hardAbort(setSendingIfCurrent); } }, [], ); const settleTurn = useCallback((setSendingIfCurrent: SetSending, pendingClarification = false) => { // A clarification pause stops counting the turn: its resume emits no agent_done, // so no second settle is coming, and the user may never answer at all. Queued // follow-ups stay parked and editable — the server holds them until the answer. pendingCountRef.current = Math.max(0, pendingCountRef.current - 1); if (pendingClarification) { setSendingIfCurrent(false); return; } if (pendingCountRef.current > 0) { // More turns are still coming. Promote the next queued follow-up (if any) so it // shows in the thread; adding a user row doesn't change the assistant baseline. const baseline = countAssistantResponses(messagesRef.current); const { head, rest } = promoteQueued(queuedMessagesRef.current); if (head) { queuedMessagesRef.current = rest; setQueuedMessages(rest); const promoted: SuperagentMessage = { id: head.id, role: 'user', content: head.content, createdAt: new Date().toISOString() }; // Skip the append if a refresh already surfaced this id, so it can't duplicate. setMessages((current) => (current.some((message) => message.id === promoted.id) ? current : [...current, promoted])); } // Realtime waits for the promoted turn's agent_done; without it, poll to completion. if (!realtimeClient && apiClient && conversationId) { if (pollIntervalRef.current) clearInterval(pollIntervalRef.current); pollIntervalRef.current = pollConversation(apiClient, conversationId, setMessages, baseline, makeOnSettle(setSendingIfCurrent)); } return; } // Nothing left — fully settle. setSendingIfCurrent(false); setQueuedMessages([]); queuedMessagesRef.current = []; emitAgentDone(); onConversationSettledRef.current?.(); }, [apiClient, conversationId, emitAgentDone, makeOnSettle, messagesRef, onConversationSettledRef, pollIntervalRef, realtimeClient, setMessages, setQueuedMessages]); // Hard-cap bail-out: the turn never settled (backend stuck). Give up the whole burst // rather than promote into a jam — clear busy + queue, no completion signal. const hardAbort = useCallback((setSendingIfCurrent: SetSending) => { pendingCountRef.current = 0; setSendingIfCurrent(false); setQueuedMessages([]); queuedMessagesRef.current = []; onConversationSettledRef.current?.(); }, [onConversationSettledRef, setQueuedMessages]); useEffect(() => { handlersRef.current = { settleTurn, hardAbort }; }, [settleTurn, hardAbort]); // Enqueue a follow-up behind the running turn(s); it drains via promote-on-done. // pendingCountRef only goes above 0 on the apiClient path, so the queue endpoints exist. const enqueue = useCallback(async (userMessage: SuperagentMessage): Promise => { if (pendingCountRef.current >= MAX_PENDING_TURNS || !apiClient || !conversationId) return false; pendingCountRef.current += 1; if (!userMessage.hidden) { queuedMessagesRef.current = addQueued(queuedMessagesRef.current, { id: userMessage.id!, content: userMessage.content }); setQueuedMessages(queuedMessagesRef.current); } try { const response = await apiClient.addMessage(conversationId, userMessage); if (!isQueuedResponse(response)) { // Stale race: the running turn had actually finished, so the backend ran this // immediately. Move it out of the strip into the live thread; its completion // arrives through the normal agent_done/poll settle path. if (!userMessage.hidden) { queuedMessagesRef.current = removeQueued(queuedMessagesRef.current, userMessage.id!); setQueuedMessages(queuedMessagesRef.current); } // A concurrent promote may already have appended this id — don't duplicate it. setMessages((current) => (current.some((message) => message.id === userMessage.id) ? current : [...current, userMessage])); } return true; } catch (error) { console.error('Superagent enqueue failed', error); pendingCountRef.current = Math.max(0, pendingCountRef.current - 1); if (!userMessage.hidden) { queuedMessagesRef.current = removeQueued(queuedMessagesRef.current, userMessage.id!); setQueuedMessages(queuedMessagesRef.current); } return false; } }, [apiClient, conversationId, setMessages, setQueuedMessages]); // Optimistically edit a queued follow-up, syncing to the backend; roll back on failure. const editQueuedMessage = useCallback(async (messageId: string, newContent: string) => { if (!apiClient || !conversationId) return false; const previous = queuedMessagesRef.current; const optimistic = editQueued(previous, messageId, newContent); queuedMessagesRef.current = optimistic; setQueuedMessages(optimistic); try { await apiClient.editQueuedMessage(conversationId, messageId, newContent); return true; } catch { queuedMessagesRef.current = previous; setQueuedMessages(previous); return false; } }, [apiClient, conversationId, setQueuedMessages]); // Optimistically drop a queued follow-up (one fewer pending turn), syncing to the // backend; roll back on failure. The active turn's own settle owns the busy state. const deleteQueuedMessage = useCallback(async (messageId: string) => { if (!apiClient || !conversationId) return false; const previous = queuedMessagesRef.current; if (!previous.some((message) => message.id === messageId)) return false; const optimistic = removeQueued(previous, messageId); queuedMessagesRef.current = optimistic; setQueuedMessages(optimistic); pendingCountRef.current = Math.max(0, pendingCountRef.current - 1); try { await apiClient.deleteQueuedMessage(conversationId, messageId); return true; } catch { queuedMessagesRef.current = previous; setQueuedMessages(previous); pendingCountRef.current += 1; return false; } }, [apiClient, conversationId, setQueuedMessages]); // Clear pending turns + the queued strip (agent switch / stop). const resetQueue = useCallback(() => { pendingCountRef.current = 0; queuedMessagesRef.current = []; setQueuedMessages([]); }, [setQueuedMessages]); return { pendingCountRef, makeOnSettle, enqueue, editQueuedMessage, deleteQueuedMessage, resetQueue }; }