import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { computeIsOutOfCredits } from '../credits/creditsUtils'; import { useAgentBi } from '../../analytics/mixpanelContext'; import { buildExcludeTitles, computeSuggestionsVisibility, suggestionsHaveConnectors, } from './promptSuggestionUtils'; import type { SuperagentCurrentUsage, SuperagentNativeClient, SuperagentPromptSuggestion } from '../../types'; // Safety net: clear the refreshing skeletons if a refresh request never resolves, // mirroring the web `useSuggestionsManager` 10s timeout. const REFRESH_TIMEOUT_MS = 10_000; const OUT_OF_CREDITS_USAGE_REFRESH_MS = 10_000; type SuggestionsSource = 'initial' | 'refresh' | 'message_complete'; type UsePromptSuggestionsParams = { apiClient?: SuperagentNativeClient; conversationId: string | null; isSending: boolean; hasAssistantMessage: boolean; /** * Monotonic counter from `useSuperagentConversation` that bumps only when an agent * turn genuinely completes. Watched to refetch after each real reply — a failed send * or a stop does NOT bump it, so (unlike an `isSending` transition) those don't revive * dismissed chips. Mirrors the web builder's `onSuggestionsMessageComplete`. */ agentMessageDoneSignal: number; }; export type PromptSuggestionsState = { suggestions: SuperagentPromptSuggestion[]; currentUsage: SuperagentCurrentUsage | null; isOutOfCredits: boolean; usageReady: boolean; refreshing: boolean; visible: boolean; showRestore: boolean; refresh: () => void; dismiss: () => void; restore: () => void; reportSelect: (suggestion: SuperagentPromptSuggestion) => void; refreshUsage: () => Promise; /** Called by the composer when the user sends a message, to hide chips for that turn. */ dismissOnSend: () => void; /** * Reverts `dismissOnSend` when a send never reached the conversation (delivered * false), so the chips the user had don't stay hidden until a later reply. */ undoDismissOnSend: () => void; }; /** * Native mirror of the web builder's suggestion manager * (`agent-editor/hooks/useSuggestionsManager` + `usePromptSuggestions`). Owns the * fetched chips, the refresh/dismiss/restore state machine, and the Mixpanel reporting. * * Fetches once when the agent has replied, and again each time a turn completes (so a * fresh set follows every reply). A send auto-dismisses the row until the next set lands; * the manual dismiss collapses it to a restore button for the session. */ export function usePromptSuggestions({ apiClient, conversationId, isSending, hasAssistantMessage, agentMessageDoneSignal, }: UsePromptSuggestionsParams): PromptSuggestionsState { const bi = useAgentBi(); const [suggestions, setSuggestions] = useState([]); const [refreshing, setRefreshing] = useState(false); const [dismissed, setDismissed] = useState(false); const [hiddenByUser, setHiddenByUser] = useState(false); const [currentUsage, setCurrentUsage] = useState(null); const [checkedUsageClient, setCheckedUsageClient] = useState(null); // The ref mirrors the state so the fetch guards can read the latest value without // widening their dependency lists. const [isOutOfCredits, setIsOutOfCredits] = useState(false); const isOutOfCreditsRef = useRef(false); const usageReady = !apiClient || checkedUsageClient === apiClient; const refreshTimeoutRef = useRef | null>(null); // Guards against out-of-order responses clobbering a newer fetch, and lets us drop a // response that resolves after the hook unmounts / the agent changes. const requestSeqRef = useRef(0); // Tracks which client received the initial usage/suggestions fetch. A replacement // client must be checked before sends resume, even if the hook stays mounted. const initializedClientRef = useRef(undefined); const initializedConversationIdRef = useRef(null); const prevDoneSignalRef = useRef(agentMessageDoneSignal); const usageRequestSeqRef = useRef(0); const clearRefreshTimeout = useCallback(() => { if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); refreshTimeoutRef.current = null; } }, []); const report = useCallback( (name: string, props?: Record) => { void bi.trackEditor(name, props); }, [bi], ); // Returns the fresh value so the caller can gate the very next fetch synchronously — // the state update won't be visible in the same tick. A failed read leaves the // last-known value in place (web parity: an absent usage read means "not out of credits"). const refreshUsage = useCallback(async (): Promise => { if (!apiClient) return isOutOfCreditsRef.current; const seq = ++usageRequestSeqRef.current; try { const usage = await apiClient.getCurrentUsage(); if (seq !== usageRequestSeqRef.current) return isOutOfCreditsRef.current; const out = computeIsOutOfCredits(usage); setCurrentUsage(usage); isOutOfCreditsRef.current = out; setIsOutOfCredits(out); return out; } catch { return isOutOfCreditsRef.current; } finally { if (seq === usageRequestSeqRef.current) setCheckedUsageClient(apiClient); } }, [apiClient]); const fetchSuggestions = useCallback( async (source: SuggestionsSource, options: { refresh?: boolean; exclude?: string[] } = {}) => { if (!apiClient || isOutOfCreditsRef.current) return; const seq = ++requestSeqRef.current; try { const result = await apiClient.getPromptSuggestions({ ...options, conversationId: conversationId ?? undefined, }); if (seq !== requestSeqRef.current) return; // superseded by a newer fetch const next = result?.suggestions ?? []; setSuggestions(next); if (next.length > 0) { // Fresh chips override a prior auto-dismiss so they surface after each reply. setDismissed(false); report('Suggestions Shown', { count: next.length, has_connectors: suggestionsHaveConnectors(next), source, }); } } catch { // Leave any chips already on screen in place — a failed refresh or post-reply // refetch shouldn't blank the previous set over a transient error (the initial // fetch starts empty, so there's nothing to lose there). Never surfaces an error UI. } finally { if (seq === requestSeqRef.current) { setRefreshing(false); clearRefreshTimeout(); } } }, [apiClient, clearRefreshTimeout, conversationId, report], ); // Single fetch driver, so the first reply can't trigger two parallel requests. // - Initial: once the client is ready (mirrors the web builder's `enabled: appId`). // Fetching is decoupled from `hasAssistantMessage` — that only gates *visibility* // — so it never coincides with the first agent-done signal. // - message_complete: on each genuine turn completion, via the conversation's // agent-done signal (not an `isSending` transition), so a failed send or a stop — // which clear the busy state without a real reply — don't refetch or revive // dismissed chips. Mirrors the web builder's `onSuggestionsMessageComplete`. useEffect(() => { if (!apiClient) { usageRequestSeqRef.current += 1; initializedClientRef.current = undefined; initializedConversationIdRef.current = null; setCheckedUsageClient(null); setCurrentUsage(null); isOutOfCreditsRef.current = false; setIsOutOfCredits(false); return; } // Re-read usage before each fetch so the credit gate stays in sync with the fetch it // guards (the runtime no longer refreshes usage on these events). if ( initializedClientRef.current !== apiClient || initializedConversationIdRef.current !== conversationId ) { initializedClientRef.current = apiClient; initializedConversationIdRef.current = conversationId; requestSeqRef.current += 1; setSuggestions([]); prevDoneSignalRef.current = agentMessageDoneSignal; // consume any concurrent bump void refreshUsage().then((out) => { if ( initializedClientRef.current !== apiClient || initializedConversationIdRef.current !== conversationId ) return; if (!out) void fetchSuggestions('initial'); }); return; } if (agentMessageDoneSignal > prevDoneSignalRef.current) { prevDoneSignalRef.current = agentMessageDoneSignal; void refreshUsage().then((out) => { if ( initializedClientRef.current !== apiClient || initializedConversationIdRef.current !== conversationId ) return; if (!out) void fetchSuggestions('message_complete'); }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [apiClient, agentMessageDoneSignal, conversationId]); // An upgrade can complete outside this screen without producing an agent turn or // remount. Poll only while blocked so newly purchased credits unlock the composer. useEffect(() => { if (!apiClient || !isOutOfCredits) return; const interval = setInterval(() => { void refreshUsage(); }, OUT_OF_CREDITS_USAGE_REFRESH_MS); return () => clearInterval(interval); }, [apiClient, isOutOfCredits, refreshUsage]); useEffect(() => clearRefreshTimeout, [clearRefreshTimeout]); const refresh = useCallback(() => { if (!apiClient || isOutOfCreditsRef.current) return; report('Suggestions Refresh'); setRefreshing(true); clearRefreshTimeout(); refreshTimeoutRef.current = setTimeout(() => setRefreshing(false), REFRESH_TIMEOUT_MS); void fetchSuggestions('refresh', { refresh: true, exclude: buildExcludeTitles(suggestions) }); }, [apiClient, clearRefreshTimeout, fetchSuggestions, report, suggestions]); const dismiss = useCallback(() => { report('Dismiss Suggestions'); setHiddenByUser(true); }, [report]); const restore = useCallback(() => { report('Suggestions Restore'); setHiddenByUser(false); }, [report]); const reportSelect = useCallback( (suggestion: SuperagentPromptSuggestion) => { report('Prompt Suggestion Click', { suggestion: suggestion.prompt, has_connector: Boolean(suggestion.connector_types?.length), connector_types: suggestion.connector_types?.length ? suggestion.connector_types : undefined, }); }, [report], ); const dismissOnSend = useCallback(() => setDismissed(true), []); const undoDismissOnSend = useCallback(() => setDismissed(false), []); const { visible, showRestore } = useMemo( () => computeSuggestionsVisibility({ suggestionCount: suggestions.length, refreshing, isSending, dismissed, hiddenByUser, hasAssistantMessage, isOutOfCredits, }), [suggestions.length, refreshing, isSending, dismissed, hiddenByUser, hasAssistantMessage, isOutOfCredits], ); return { suggestions, currentUsage, isOutOfCredits, usageReady, refreshing, visible, showRestore, refresh, dismiss, restore, reportSelect, refreshUsage, dismissOnSend, undoDismissOnSend, }; }