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).
This commit is contained in:
Huixin615
2026-06-10 23:59:38 +08:00
committed by GitHub
parent b3c2cc42cf
commit 5819bd8a59
9 changed files with 701 additions and 9 deletions
+61 -4
View File
@@ -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<HTMLDivElement | null>(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 (
<WorkspaceContainer>
<WorkspaceHeader></WorkspaceHeader>
@@ -61,6 +96,28 @@ export default function ChatsPage() {
</div>
</Link>
))}
{hasNextPage && !isSearching && (
<div
ref={sentinelRef}
aria-hidden="true"
className="h-px w-full"
data-testid="chats-page-sentinel"
/>
)}
{hasNextPage && isSearching && (
<div className="flex justify-center p-4">
<Button
variant="outline"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
data-testid="chats-page-load-more"
>
{isFetchingNextPage
? t.chats.loadingMore
: t.chats.loadMoreToSearch}
</Button>
</div>
)}
</div>
</ScrollArea>
</main>
@@ -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<HTMLDivElement | null>(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() {
</SidebarMenuItem>
);
})}
{hasNextPage && (
<>
<Button
variant="ghost"
size="sm"
className="mx-2 my-1 w-[calc(100%-1rem)] justify-center text-xs"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
data-testid="recent-chat-list-load-more"
>
{isFetchingNextPage
? t.chats.loadingMore
: t.chats.loadOlderChats}
</Button>
<div
ref={sentinelRef}
aria-hidden="true"
className="h-px w-full"
data-testid="recent-chat-list-sentinel"
/>
</>
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
+3
View File
@@ -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)
+3
View File
@@ -183,6 +183,9 @@ export interface Translations {
// Chats
chats: {
searchChats: string;
loadMoreToSearch: string;
loadingMore: string;
loadOlderChats: string;
};
// Page titles (document title)
+3
View File
@@ -240,6 +240,9 @@ export const zhCN: Translations = {
// Chats
chats: {
searchChats: "搜索对话",
loadMoreToSearch: "加载更多以搜索更早的对话",
loadingMore: "正在加载...",
loadOlderChats: "加载更早的对话",
},
// Page titles (document title)
+202
View File
@@ -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<AgentThread[]> | 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<AgentThread[]> | 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<ThreadsClient["search"]>[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<AgentThread[]> | undefined,
mapper: (thread: AgentThread) => AgentThread,
): InfiniteData<AgentThread[]> | undefined {
if (!oldData) {
return oldData;
}
return {
...oldData,
pages: oldData.pages.map((page) => page.map(mapper)),
};
}
export function filterInfiniteThreadsCache(
oldData: InfiniteData<AgentThread[]> | undefined,
predicate: (thread: AgentThread) => boolean,
): InfiniteData<AgentThread[]> | 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<AgentThread[]>,
readonly unknown[],
number
>({
queryKey: [...INFINITE_THREADS_QUERY_KEY_PREFIX, params],
initialPageParam: 0,
queryFn: async ({ pageParam }) => {
const response = (await apiClient.threads.search<AgentThreadState>({
...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<AgentThread[]> | 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<AgentThread[]> | undefined) =>
mapInfiniteThreadsCache(oldData, (t) =>
t.thread_id === threadId
? {
...t,
values: {
...t.values,
title,
},
}
: t,
),
);
},
});
}