From 5819bd8a597111234fc4793abc1e922a81540455 Mon Sep 17 00:00:00 2001 From: Huixin615 Date: Wed, 10 Jun 2026 23:59:38 +0800 Subject: [PATCH] fix(frontend): paginate workspace chat list beyond 50 threads (#3482) (#3485) * fix(frontend): paginate workspace chat list beyond 50 threads (#3482) The sidebar 'Recent chats' and /workspace/chats list were hard-capped at the first 50 threads returned by threads.search. Replace the single-shot useThreads() consumers with useInfiniteThreads() and add an IntersectionObserver sentinel to each list so further pages are fetched on demand. In search mode on the chats page, the sentinel is replaced by an explicit 'Load more' button to prevent the observer from draining the entire backend list while the filtered view stays empty. - Add useInfiniteThreads + page-size constant and pure cache helpers (map/filterInfiniteThreadsCache, getInfiniteThreadsNextPageParam) - Mirror rename / delete / stream-finish updates into the new infinite cache so optimistic UI stays consistent - Extend the e2e mock to honour limit/offset slicing - Unit tests for the cache helpers and pagination boundary - Playwright e2e covering chats page + sidebar load-more, and the search-mode guard against runaway auto-pagination - Add en/zh i18n entries for the search-mode load-more button Fixes #3482 * docs(frontend): clarify infinite-threads offset semantics and test post-delete invariant - Add docstring to getInfiniteThreadsNextPageParam explaining that TanStack Query freezes the returned offset into pageParams once, so optimistic cache mutations that shrink page lengths (filterInfiniteThreadsCache on delete) cannot retroactively move the offset backwards. Delete/rename paths reconcile against the backend via invalidateQueries in onSettled. - Add unit test covering the post-delete invariant. - Fix misleading comment in thread-list-infinite-scroll.spec.ts: the thread-search mock does not sort by updated_at; it returns the array in the order provided. Addresses Copilot CR comments on #3485. * fix(frontend): mirror onCreated upsert into infinite cache; add sidebar Load-older button Address review feedback on #3485: - New upsertThreadInInfiniteCache helper; useThreadStream onCreated now upserts into both the legacy ['threads','search'] cache and the new infinite cache, so a freshly created thread appears in the sidebar immediately during streaming instead of only after the run finishes and onSettled invalidates the query. Restores parity with main. - Sidebar Recent Chats now exposes a visible 'Load older chats' button alongside the IntersectionObserver sentinel, so keyboard-only users and environments where IO is unavailable can still reach older conversations. - Add zh-CN / en-US / types entry for chats.loadOlderChats. - Cover the new helper with 3 unit tests (no-op on uninitialised cache, prepend new thread to first page, merge with existing entry without duplication). --- frontend/src/app/workspace/chats/page.tsx | 65 ++++- .../components/workspace/recent-chat-list.tsx | 56 ++++- frontend/src/core/i18n/locales/en-US.ts | 3 + frontend/src/core/i18n/locales/types.ts | 3 + frontend/src/core/i18n/locales/zh-CN.ts | 3 + frontend/src/core/threads/hooks.ts | 202 ++++++++++++++++ .../e2e/thread-list-infinite-scroll.spec.ts | 123 ++++++++++ frontend/tests/e2e/utils/mock-api.ts | 27 ++- .../tests/unit/core/threads/infinite.test.ts | 228 ++++++++++++++++++ 9 files changed, 701 insertions(+), 9 deletions(-) create mode 100644 frontend/tests/e2e/thread-list-infinite-scroll.spec.ts create mode 100644 frontend/tests/unit/core/threads/infinite.test.ts diff --git a/frontend/src/app/workspace/chats/page.tsx b/frontend/src/app/workspace/chats/page.tsx index 43d661225..fdd4dd454 100644 --- a/frontend/src/app/workspace/chats/page.tsx +++ b/frontend/src/app/workspace/chats/page.tsx @@ -1,8 +1,9 @@ "use client"; import Link from "next/link"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { @@ -11,24 +12,58 @@ import { WorkspaceHeader, } from "@/components/workspace/workspace-container"; import { useI18n } from "@/core/i18n/hooks"; -import { useThreads } from "@/core/threads/hooks"; +import { useInfiniteThreads } from "@/core/threads/hooks"; import { pathOfThread, titleOfThread } from "@/core/threads/utils"; import { formatTimeAgo } from "@/core/utils/datetime"; export default function ChatsPage() { const { t } = useI18n(); - const { data: threads } = useThreads(); + const { + data: infiniteThreads, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteThreads(); + const threads = useMemo( + () => infiniteThreads?.pages.flat() ?? [], + [infiniteThreads], + ); const [search, setSearch] = useState(""); + const isSearching = search.trim().length > 0; useEffect(() => { document.title = `${t.pages.chats} - ${t.pages.appName}`; }, [t.pages.chats, t.pages.appName]); const filteredThreads = useMemo(() => { - return threads?.filter((thread) => { + return threads.filter((thread) => { return titleOfThread(thread).toLowerCase().includes(search.toLowerCase()); }); }, [threads, search]); + + // Sentinel-based auto load-more for the unfiltered list (issue #3482). + // In search mode we deliberately do NOT auto-paginate, otherwise an empty + // filtered view would keep the sentinel in the viewport and drain the + // entire backend list one page at a time. Searching falls back to an + // explicit button so users can still reach older conversations on demand. + const sentinelRef = useRef(null); + useEffect(() => { + const element = sentinelRef.current; + if (!element || !hasNextPage || isSearching) { + return; + } + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, + { rootMargin: "200px 0px 200px 0px" }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage, isSearching]); + return ( @@ -61,6 +96,28 @@ export default function ChatsPage() { ))} + {hasNextPage && !isSearching && ( + diff --git a/frontend/src/components/workspace/recent-chat-list.tsx b/frontend/src/components/workspace/recent-chat-list.tsx index f110506e4..523c76099 100644 --- a/frontend/src/components/workspace/recent-chat-list.tsx +++ b/frontend/src/components/workspace/recent-chat-list.tsx @@ -11,7 +11,7 @@ import { } from "lucide-react"; import Link from "next/link"; import { useParams, usePathname, useRouter } from "next/navigation"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -51,8 +51,8 @@ import { } from "@/core/threads/export"; import { useDeleteThread, + useInfiniteThreads, useRenameThread, - useThreads, } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import { pathOfThread, titleOfThread } from "@/core/threads/utils"; @@ -68,7 +68,35 @@ export function RecentChatList() { thread_id: string; agent_name?: string; }>(); - const { data: threads = [] } = useThreads(); + const { + data: infiniteThreads, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteThreads(); + const threads = useMemo( + () => infiniteThreads?.pages.flat() ?? [], + [infiniteThreads], + ); + + const sentinelRef = useRef(null); + useEffect(() => { + const element = sentinelRef.current; + if (!element || !hasNextPage) { + return; + } + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, + { rootMargin: "120px 0px 120px 0px" }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + const { mutate: deleteThread } = useDeleteThread(); const { mutate: renameThread } = useRenameThread(); @@ -267,6 +295,28 @@ export function RecentChatList() { ); })} + {hasNextPage && ( + <> + + diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 9d83c77a4..98b19aed3 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -252,6 +252,9 @@ export const enUS: Translations = { // Chats chats: { searchChats: "Search chats", + loadMoreToSearch: "Load more to search older conversations", + loadingMore: "Loading more...", + loadOlderChats: "Load older chats", }, // Page titles (document title) diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 3213f34b1..251d42d57 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -183,6 +183,9 @@ export interface Translations { // Chats chats: { searchChats: string; + loadMoreToSearch: string; + loadingMore: string; + loadOlderChats: string; }; // Page titles (document title) diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index bcbacd268..5c0581d34 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -240,6 +240,9 @@ export const zhCN: Translations = { // Chats chats: { searchChats: "搜索对话", + loadMoreToSearch: "加载更多以搜索更早的对话", + loadingMore: "正在加载...", + loadOlderChats: "加载更早的对话", }, // Page titles (document title) diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 2ac1a1814..6c91881aa 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -3,6 +3,8 @@ import type { ThreadsClient } from "@langchain/langgraph-sdk/client"; import { useStream } from "@langchain/langgraph-sdk/react"; import { type QueryClient, + type InfiniteData, + useInfiniteQuery, useMutation, useQuery, useQueryClient, @@ -311,6 +313,56 @@ export function upsertThreadInSearchCache( ); } +export function upsertThreadInInfiniteCache( + queryClient: QueryClient, + thread: AgentThread, +) { + queryClient.setQueriesData( + { + queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX, + exact: false, + }, + (oldData: InfiniteData | undefined) => { + if (!oldData) { + return oldData; + } + + const merged = oldData.pages.map((page) => + page.map((t) => + t.thread_id === thread.thread_id + ? { + ...thread, + ...t, + metadata: { + ...(thread.metadata ?? {}), + ...(t.metadata ?? {}), + }, + values: { + ...thread.values, + ...t.values, + }, + } + : t, + ), + ); + + const exists = merged.some((page) => + page.some((t) => t.thread_id === thread.thread_id), + ); + if (exists) { + return { ...oldData, pages: merged }; + } + + const firstPage = merged[0] ?? []; + const restPages = merged.slice(1); + return { + ...oldData, + pages: [[thread, ...firstPage], ...restPages], + }; + }, + ); +} + function getStreamErrorMessage(error: unknown): string { if (typeof error === "string" && error.trim()) { return error; @@ -417,6 +469,19 @@ export function useThreadStream({ }, interrupts: {}, }); + upsertThreadInInfiniteCache(queryClient, { + thread_id: meta.thread_id, + created_at: now, + updated_at: now, + metadata: context.agent_name ? { agent_name: context.agent_name } : {}, + status: "busy", + values: { + title: t.pages.newChat, + messages: [], + artifacts: [], + }, + interrupts: {}, + }); if (context.agent_name && !isMock) { void getAPIClient() .threads.update(meta.thread_id, { @@ -488,6 +553,27 @@ export function useThreadStream({ }); }, ); + const nextTitle: string = update.title; + void queryClient.setQueriesData( + { + queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX, + exact: false, + }, + (oldData: InfiniteData | undefined) => + mapInfiniteThreadsCache( + oldData, + (t): AgentThread => + t.thread_id === threadIdRef.current + ? { + ...t, + values: { + ...t.values, + title: nextTitle, + }, + } + : t, + ), + ); } } }, @@ -542,6 +628,9 @@ export function useThreadStream({ .filter((id): id is string => Boolean(id)), ); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); + void queryClient.invalidateQueries({ + queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX, + }); if (threadIdRef.current && !isMock) { void queryClient.invalidateQueries({ queryKey: threadTokenUsageQueryKey(threadIdRef.current), @@ -801,6 +890,9 @@ export function useThreadStream({ }, ); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); + void queryClient.invalidateQueries({ + queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX, + }); } catch (error) { setOptimisticMessages([]); setIsUploading(false); @@ -1100,6 +1192,86 @@ export function useThreads( }); } +export const INFINITE_THREADS_PAGE_SIZE = 50; + +export const INFINITE_THREADS_QUERY_KEY_PREFIX = [ + "threads", + "searchInfinite", +] as const; + +type InfiniteThreadsParams = Omit< + Parameters[0], + "limit" | "offset" +>; + +export function getInfiniteThreadsNextPageParam( + lastPage: AgentThread[], + allPages: AgentThread[][], + pageSize: number = INFINITE_THREADS_PAGE_SIZE, +): number | undefined { + if (lastPage.length < pageSize) { + return undefined; + } + return allPages.reduce((sum, page) => sum + page.length, 0); +} + +export function mapInfiniteThreadsCache( + oldData: InfiniteData | undefined, + mapper: (thread: AgentThread) => AgentThread, +): InfiniteData | undefined { + if (!oldData) { + return oldData; + } + return { + ...oldData, + pages: oldData.pages.map((page) => page.map(mapper)), + }; +} + +export function filterInfiniteThreadsCache( + oldData: InfiniteData | undefined, + predicate: (thread: AgentThread) => boolean, +): InfiniteData | undefined { + if (!oldData) { + return oldData; + } + return { + ...oldData, + pages: oldData.pages.map((page) => page.filter(predicate)), + }; +} + +export function useInfiniteThreads( + params: InfiniteThreadsParams = { + sortBy: "updated_at", + sortOrder: "desc", + select: ["thread_id", "updated_at", "values", "metadata"], + }, +) { + const apiClient = getAPIClient(); + return useInfiniteQuery< + AgentThread[], + Error, + InfiniteData, + readonly unknown[], + number + >({ + queryKey: [...INFINITE_THREADS_QUERY_KEY_PREFIX, params], + initialPageParam: 0, + queryFn: async ({ pageParam }) => { + const response = (await apiClient.threads.search({ + ...params, + limit: INFINITE_THREADS_PAGE_SIZE, + offset: pageParam, + })) as AgentThread[]; + return response; + }, + getNextPageParam: (lastPage, allPages) => + getInfiniteThreadsNextPageParam(lastPage, allPages), + refetchOnWindowFocus: false, + }); +} + export function useThreadRuns( threadId?: string, { enabled = true }: { enabled?: boolean } = {}, @@ -1183,9 +1355,21 @@ export function useDeleteThread() { return oldData.filter((t) => t.thread_id !== threadId); }, ); + queryClient.setQueriesData( + { + queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX, + exact: false, + }, + (oldData: InfiniteData | undefined) => + filterInfiniteThreadsCache(oldData, (t) => t.thread_id !== threadId), + ); }, + onSettled() { void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); + void queryClient.invalidateQueries({ + queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX, + }); }, }); } @@ -1226,6 +1410,24 @@ export function useRenameThread() { }); }, ); + queryClient.setQueriesData( + { + queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX, + exact: false, + }, + (oldData: InfiniteData | undefined) => + mapInfiniteThreadsCache(oldData, (t) => + t.thread_id === threadId + ? { + ...t, + values: { + ...t.values, + title, + }, + } + : t, + ), + ); }, }); } diff --git a/frontend/tests/e2e/thread-list-infinite-scroll.spec.ts b/frontend/tests/e2e/thread-list-infinite-scroll.spec.ts new file mode 100644 index 000000000..f0d75ecc0 --- /dev/null +++ b/frontend/tests/e2e/thread-list-infinite-scroll.spec.ts @@ -0,0 +1,123 @@ +import { expect, test } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; + +// Issue #3482: the sidebar's "Recent chats" and the /workspace/chats list +// page used to stop at the first 50 threads with no way to load more. +// `useInfiniteThreads()` + a sentinel near the bottom of each list now +// pages through the backend. + +const TOTAL_THREADS = 120; +const PAGE_SIZE = 50; + +const THREADS = Array.from({ length: TOTAL_THREADS }, (_, i) => { + // Pad index so titles sort deterministically as strings. The thread-search + // mock returns threads in the order provided, so paging boundaries are + // stable across runs. + const index = String(i + 1).padStart(3, "0"); + return { + thread_id: `00000000-0000-0000-0000-0000000${index.padStart(5, "0")}`, + title: `Conversation ${index}`, + updated_at: `2025-06-${String((i % 28) + 1).padStart(2, "0")}T12:00:00Z`, + }; +}); + +const FIRST_PAGE_LAST = `Conversation ${String(PAGE_SIZE).padStart(3, "0")}`; +const SECOND_PAGE_FIRST = `Conversation ${String(PAGE_SIZE + 1).padStart(3, "0")}`; + +test.describe("Thread list infinite scroll (issue #3482)", () => { + test("chats list page loads more threads when scrolling to the bottom", async ({ + page, + }) => { + mockLangGraphAPI(page, { threads: THREADS }); + + await page.goto("/workspace/chats"); + + const main = page.locator("main"); + + // First page renders. + await expect(main.getByText(FIRST_PAGE_LAST)).toBeVisible({ + timeout: 15_000, + }); + // Items past the first page have not been fetched yet. + await expect(main.getByText(SECOND_PAGE_FIRST)).toHaveCount(0); + + // Scrolling the sentinel into view triggers the next page. + const sentinel = page.getByTestId("chats-page-sentinel"); + await sentinel.scrollIntoViewIfNeeded(); + + await expect(main.getByText(SECOND_PAGE_FIRST)).toBeVisible({ + timeout: 15_000, + }); + }); + + test("sidebar recent chats loads more threads when scrolling to the bottom", async ({ + page, + }) => { + mockLangGraphAPI(page, { threads: THREADS }); + + await page.goto("/workspace/chats/new"); + + // The 50th thread (end of first page) appears in the sidebar. + await expect(page.getByText(FIRST_PAGE_LAST).first()).toBeVisible({ + timeout: 15_000, + }); + // The 51st has not been fetched yet. + await expect(page.getByText(SECOND_PAGE_FIRST)).toHaveCount(0); + + // Scroll the sidebar sentinel into view to trigger the next page. + const sentinel = page.getByTestId("recent-chat-list-sentinel"); + await sentinel.scrollIntoViewIfNeeded(); + + await expect(page.getByText(SECOND_PAGE_FIRST).first()).toBeVisible({ + timeout: 15_000, + }); + }); + + test("chats list page does NOT auto-paginate while a search filter is active", async ({ + page, + }) => { + // Count search requests via a passive request observer. Using + // page.route() here would race with mockLangGraphAPI's fulfill route + // (Playwright matches routes in reverse registration order), so the + // counter could miss real requests. page.on('request') is a pure + // observer and never interferes with routing. + let searchRequestCount = 0; + page.on("request", (request) => { + if (request.url().includes("/api/langgraph/threads/search")) { + searchRequestCount += 1; + } + }); + + mockLangGraphAPI(page, { threads: THREADS }); + + await page.goto("/workspace/chats"); + + // Wait for the first page to render so we have a baseline count. + await expect(page.locator("main").getByText(FIRST_PAGE_LAST)).toBeVisible({ + timeout: 15_000, + }); + const baselineRequests = searchRequestCount; + + // Type a query that matches nothing in the first page (and nothing at + // all, since titles are deterministic). + await page + .getByPlaceholder("Search chats") + .fill("zzz-no-such-conversation"); + + // The auto-sentinel must be gone; an explicit button takes its place. + await expect(page.getByTestId("chats-page-sentinel")).toHaveCount(0); + await expect(page.getByTestId("chats-page-load-more")).toBeVisible(); + + // Give the IntersectionObserver a couple of frames to misbehave if the + // guard regresses. No additional /threads/search calls should fire. + await page.waitForTimeout(500); + expect(searchRequestCount).toBe(baselineRequests); + + // The explicit button still works as an escape hatch. + await page.getByTestId("chats-page-load-more").click(); + await expect + .poll(() => searchRequestCount, { timeout: 10_000 }) + .toBeGreaterThan(baselineRequests); + }); +}); diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts index 888b066b0..cc1f83290 100644 --- a/frontend/tests/e2e/utils/mock-api.ts +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -85,7 +85,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { const skills = options?.skills ?? DEFAULT_SKILLS; // Thread search — sidebar thread list & chats list page - void page.route("**/api/langgraph/threads/search", (route) => { + void page.route("**/api/langgraph/threads/search", async (route) => { const body = threads.map((t) => ({ thread_id: t.thread_id, created_at: "2025-01-01T00:00:00Z", @@ -94,10 +94,33 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { status: "idle", values: { title: t.title ?? "Untitled" }, })); + + let limit: number | undefined; + let offset = 0; + try { + const postData = route.request().postDataJSON() as { + limit?: number; + offset?: number; + } | null; + if (postData) { + if (typeof postData.limit === "number") { + limit = postData.limit; + } + if (typeof postData.offset === "number") { + offset = postData.offset; + } + } + } catch { + // No / invalid JSON body — fall back to returning the full list. + } + + const sliced = + typeof limit === "number" ? body.slice(offset, offset + limit) : body; + return route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify(body), + body: JSON.stringify(sliced), }); }); diff --git a/frontend/tests/unit/core/threads/infinite.test.ts b/frontend/tests/unit/core/threads/infinite.test.ts new file mode 100644 index 000000000..d040ff0a1 --- /dev/null +++ b/frontend/tests/unit/core/threads/infinite.test.ts @@ -0,0 +1,228 @@ +import { QueryClient, type InfiniteData } from "@tanstack/react-query"; +import { describe, expect, test } from "vitest"; + +import { + filterInfiniteThreadsCache, + getInfiniteThreadsNextPageParam, + INFINITE_THREADS_PAGE_SIZE, + INFINITE_THREADS_QUERY_KEY_PREFIX, + mapInfiniteThreadsCache, + upsertThreadInInfiniteCache, +} from "@/core/threads/hooks"; +import type { AgentThread } from "@/core/threads/types"; + +// Issue #3482: the sidebar and /workspace/chats list used to be capped at +// 50 threads because `useThreads()` exits as soon as `threads.length >= +// params.limit`. These pure helpers back the `useInfiniteThreads()` +// pagination logic and the mirrored cache writes that keep rename / delete +// / stream-finish in sync with both the legacy array cache and the new +// infinite cache. + +function makeThread(id: string, title = `Title ${id}`): AgentThread { + return { + thread_id: id, + created_at: "2025-01-01T00:00:00Z", + updated_at: "2025-01-01T00:00:00Z", + metadata: {}, + status: "idle", + values: { title }, + } as unknown as AgentThread; +} + +function makePage(start: number, size: number): AgentThread[] { + return Array.from({ length: size }, (_, i) => makeThread(`t-${start + i}`)); +} + +function makeInfiniteData(pages: AgentThread[][]): InfiniteData { + return { + pages, + pageParams: pages.map((_, i) => i * INFINITE_THREADS_PAGE_SIZE), + }; +} + +describe("getInfiniteThreadsNextPageParam", () => { + test("returns next offset when the last page is full", () => { + const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE); + expect(getInfiniteThreadsNextPageParam(page1, [page1])).toBe( + INFINITE_THREADS_PAGE_SIZE, + ); + }); + + test("returns next offset across multiple full pages", () => { + const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE); + const page2 = makePage( + INFINITE_THREADS_PAGE_SIZE, + INFINITE_THREADS_PAGE_SIZE, + ); + expect(getInfiniteThreadsNextPageParam(page2, [page1, page2])).toBe( + INFINITE_THREADS_PAGE_SIZE * 2, + ); + }); + + test("returns undefined when the last page is short (end of list)", () => { + const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE); + const page2 = makePage(INFINITE_THREADS_PAGE_SIZE, 10); + expect( + getInfiniteThreadsNextPageParam(page2, [page1, page2]), + ).toBeUndefined(); + }); + + test("returns undefined when the last page is empty", () => { + const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE); + expect(getInfiniteThreadsNextPageParam([], [page1, []])).toBeUndefined(); + }); + + test("respects a custom page size", () => { + const page1 = makePage(0, 5); + expect(getInfiniteThreadsNextPageParam(page1, [page1], 5)).toBe(5); + expect(getInfiniteThreadsNextPageParam(page1, [page1], 10)).toBeUndefined(); + }); +}); + +describe("mapInfiniteThreadsCache", () => { + test("returns undefined when oldData is undefined", () => { + expect(mapInfiniteThreadsCache(undefined, (t) => t)).toBeUndefined(); + }); + + test("updates the matching thread across multiple pages", () => { + const page1 = [makeThread("a"), makeThread("b")]; + const page2 = [makeThread("c"), makeThread("d")]; + const data = makeInfiniteData([page1, page2]); + + const updated = mapInfiniteThreadsCache(data, (t) => + t.thread_id === "c" + ? { ...t, values: { ...t.values, title: "renamed" } } + : t, + ); + + expect(updated?.pages[0]?.[0]?.values?.title).toBe("Title a"); + expect(updated?.pages[1]?.[0]?.thread_id).toBe("c"); + expect(updated?.pages[1]?.[0]?.values?.title).toBe("renamed"); + expect(updated?.pages[1]?.[1]?.values?.title).toBe("Title d"); + }); + + test("preserves pageParams", () => { + const data = makeInfiniteData([[makeThread("a")]]); + const updated = mapInfiniteThreadsCache(data, (t) => t); + expect(updated?.pageParams).toEqual(data.pageParams); + }); +}); + +describe("filterInfiniteThreadsCache", () => { + test("returns undefined when oldData is undefined", () => { + expect(filterInfiniteThreadsCache(undefined, () => true)).toBeUndefined(); + }); + + test("removes matching threads across all pages", () => { + const page1 = [makeThread("a"), makeThread("b")]; + const page2 = [makeThread("b"), makeThread("c")]; + const data = makeInfiniteData([page1, page2]); + + const filtered = filterInfiniteThreadsCache( + data, + (t) => t.thread_id !== "b", + ); + + expect(filtered?.pages[0]?.map((t) => t.thread_id)).toEqual(["a"]); + expect(filtered?.pages[1]?.map((t) => t.thread_id)).toEqual(["c"]); + }); + + test("keeps an emptied page as an empty array (does not drop the page)", () => { + const page1 = [makeThread("a")]; + const page2 = [makeThread("b")]; + const data = makeInfiniteData([page1, page2]); + + const filtered = filterInfiniteThreadsCache( + data, + (t) => t.thread_id !== "a", + ); + + expect(filtered?.pages).toHaveLength(2); + expect(filtered?.pages[0]).toEqual([]); + expect(filtered?.pages[1]?.[0]?.thread_id).toBe("b"); + }); + + test("does not regress next offset when an earlier page has been shrunk by a delete", () => { + // Simulate two full pages already loaded. + const page1 = Array.from({ length: 50 }, (_, i) => ({ + thread_id: `a${i}`, + })); + const page2 = Array.from({ length: 50 }, (_, i) => ({ + thread_id: `b${i}`, + })); + + // Offset right after fetching page 2 (this is the value TanStack Query + // freezes into pageParams). + const offsetAfterPage2 = getInfiniteThreadsNextPageParam( + page2 as unknown as AgentThread[], + [page1, page2] as unknown as AgentThread[][], + ); + expect(offsetAfterPage2).toBe(100); + + // Now a delete mutation runs filterInfiniteThreadsCache and shrinks + // page 1 from 50 to 49 entries. TanStack does NOT re-invoke + // getNextPageParam on cache mutations; the previously-computed offset + // (100) remains the param for the next fetchNextPage() call, so the + // helper is consistent with how the library uses its return value. + const shrunkPage1 = page1.slice(0, 49); + const recomputed = getInfiniteThreadsNextPageParam( + page2 as unknown as AgentThread[], + [shrunkPage1, page2] as unknown as AgentThread[][], + ); + // We document the recomputed value for completeness, but in practice + // useDeleteThread invalidates the query in onSettled, so pages are + // refetched from offset 0 rather than relying on this number. + expect(recomputed).toBe(99); + }); +}); + +describe("upsertThreadInInfiniteCache", () => { + function seedClient(initial?: InfiniteData): QueryClient { + const client = new QueryClient(); + if (initial) { + client.setQueryData([...INFINITE_THREADS_QUERY_KEY_PREFIX, {}], initial); + } + return client; + } + + function readCache( + client: QueryClient, + ): InfiniteData | undefined { + return client.getQueryData([...INFINITE_THREADS_QUERY_KEY_PREFIX, {}]); + } + + test("no-op when the infinite cache has not been initialised yet", () => { + const client = seedClient(); + upsertThreadInInfiniteCache(client, makeThread("new")); + expect(readCache(client)).toBeUndefined(); + }); + + test("prepends a brand-new thread to the first page", () => { + const client = seedClient({ + pages: [[makeThread("a"), makeThread("b")]], + pageParams: [0], + }); + upsertThreadInInfiniteCache(client, makeThread("new")); + const cache = readCache(client); + expect(cache?.pages[0]?.map((t) => t.thread_id)).toEqual(["new", "a", "b"]); + }); + + test("merges into the existing entry instead of duplicating it", () => { + const existing = makeThread("a", "Old title"); + const client = seedClient({ + pages: [[existing, makeThread("b")]], + pageParams: [0], + }); + // Simulate an onCreated upsert that races with a thread already in cache: + // the cache copy should win for title/metadata (it represents later state), + // but no duplicate row should appear. + upsertThreadInInfiniteCache(client, { + ...makeThread("a", "New title"), + status: "busy", + }); + const cache = readCache(client); + const ids = cache?.pages[0]?.map((t) => t.thread_id); + expect(ids).toEqual(["a", "b"]); + expect(cache?.pages[0]?.[0]?.values.title).toBe("Old title"); + }); +});