import { useCallback, useMemo, useRef, useState } from "react"; import useSWR from "swr"; import useSWRInfinite from "swr/infinite"; import type { AgentApiClient, AgentChatSessionSummary, AgentSessionPage } from "@/features/agent"; import { useWorkbenchSessionResources } from "./workbench-session-resources"; const TTL = 60_000; export function useAgentHistory(client: AgentApiClient, scope: string) { const resources = useWorkbenchSessionResources(); const [topicFilter, setTopicFilter] = useState<{ scope: string; id?: string }>({ scope }); const topicId = topicFilter.scope === scope ? topicFilter.id : undefined; const setTopicId = useCallback((id: string | undefined) => setTopicFilter({ scope, id }), [scope]); const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const [enabled, setEnabled] = useState(false); const [pendingUpdateKey, setPendingUpdateKey] = useState(null); const viewKey = JSON.stringify([scope, query, topicId]); const view = useRef({ scope, query, topicId }); view.current = { scope, query, topicId }; const staged = useRef<{ key: string; page: AgentSessionPage } | null>(null); const [orderVersion, setOrderVersion] = useState(0); const key = useCallback((q: string, cursor?: string | null, topic?: string) => JSON.stringify(["history", scope, q, cursor ?? null, topic ?? null]), [scope]); const fetchPage = useCallback((q: string, cursor?: string | null, force = false, topic?: string) => resources.cache.load(key(q, cursor, topic), () => client.listSessionPage({ q, topicId: topic, includeCollaboration: true, cursor: cursor ?? undefined, signal: AbortSignal.timeout(15_000) }), TTL, { force }), [client, key, resources]); const recent = useSWR(["agent-recent-sessions", scope], () => fetchPage(""), { revalidateOnFocus: false, shouldRetryOnError: false, }); const pages = useSWRInfinite( (_index, previous: AgentSessionPage | null) => !enabled || (previous && !previous.hasMore) ? null : ["agent-history-page", scope, query, previous?.nextCursor ?? null, topicId ?? null], ([, , q, cursor, topic]: [string, string, string, string | null, string | null]) => fetchPage(q, cursor, false, topic ?? undefined), { revalidateOnFocus: false, revalidateFirstPage: false, shouldRetryOnError: false, persistSize: false }, ); const sessions = useMemo(() => [...new Map((pages.data ?? (query || topicId ? [] : recent.data ? [recent.data] : [])) .flatMap((page) => page.sessions).map((session) => [session.id, session])).values()], [pages.data, query, recent.data, topicId]); const refresh = useCallback(async () => { const page = staged.current?.key === viewKey ? staged.current.page : await fetchPage(query, null, true, topicId); if (view.current.scope !== scope || view.current.query !== query || view.current.topicId !== topicId) return; staged.current = null; await pages.mutate([page], { revalidate: false }); await pages.setSize(1); if (!query && !topicId) await recent.mutate(page, { revalidate: false }); setPendingUpdateKey(null); setOrderVersion((version) => version + 1); }, [fetchPage, pages, query, recent, scope, topicId, viewKey]); const ensure = useCallback(async () => { setOpen(true); setEnabled(true); const stale = (resources.cache.peek(key(query, null, topicId))?.expiresAt ?? 0) <= Date.now(); const page = await fetchPage(query, null, false, topicId); if (view.current.scope !== scope || view.current.query !== query || view.current.topicId !== topicId) return; // Opening a window never invalidates a fresh page or hides existing content. if (!pages.data?.length) await pages.mutate([page], { revalidate: false }); else if (stale) { staged.current = { key: viewKey, page }; setPendingUpdateKey(viewKey); } if (!query && !topicId) await recent.mutate(page, { revalidate: false }); }, [fetchPage, key, pages, query, recent, resources, scope, topicId, viewKey]); const update = useCallback(async (change: (sessions: AgentChatSessionSummary[]) => AgentChatSessionSummary[]) => { resources.cache.updateMatching((cacheKey) => { if (!cacheKey.startsWith('["history",')) return false; const value = JSON.parse(cacheKey) as unknown[]; return value[0] === "history" && value[1] === scope; }, (page) => ({ ...page, sessions: change(page.sessions) })); const recentPage = resources.cache.peek(key(""))?.value; if (recentPage) { const next = { ...recentPage, sessions: change(recentPage.sessions) }; resources.cache.set(key(""), next, TTL); if (view.current.scope === scope) await recent.mutate(next, { revalidate: false }); } if (view.current.scope !== scope || view.current.query !== query || view.current.topicId !== topicId) return; await pages.mutate((current) => current?.map((page) => ({ ...page, sessions: change(page.sessions) })), { revalidate: false }); // Search and later pages can now have different membership/order. Retain what is being read. setPendingUpdateKey(viewKey); }, [key, pages, query, recent, resources, scope, topicId, viewKey]); return { topicId, setTopicId, sessions, recentSessions: recent.data?.sessions ?? [], query, setQuery, loading: (open ? pages.isLoading : recent.isLoading), error: pages.error ?? recent.error, loadingMore: pages.isValidating, hasMore: pages.data?.at(-1)?.hasMore ?? recent.data?.hasMore ?? false, loadMore: () => pages.setSize(pages.size + 1), refresh, ensure, update, pendingUpdate: pendingUpdateKey === viewKey, isOpen: open, orderVersion, close: () => setOpen(false), }; }