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"); + }); +});