mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(frontend): keep the thread list offset past hidden side chats (#6702)
* fix(frontend): keep the thread list offset past hidden side chats
Primary thread lists hide sidecar threads on the client, so a page of 50
visible chats can consume more server rows. fetchInfiniteThreadsPage kept
that server offset in a Symbol property on the page array, but TanStack
Query's structural sharing (replaceEqualDeep) rebuilds a changed array
without symbol keys, and the list cache writers (rename, unread, pin,
delete, upsert) rebuild pages with map/filter. After the first refetch or
cache write, getInfiniteThreadsNextPageParam fell back to the sum of the
visible page lengths, so the next page started at 50 instead of 53 and
re-fetched rows already loaded. The same fallback treated a full page
that lost a row to a local delete as the end of the list.
Each cached page is now { threads, hiddenCount, hasMore }. The next offset
is the sum of visible and hidden rows over the loaded pages, so local
deletes and inserts still move it with the server list, and the end of the
list comes from the server response rather than the page length.
useInfiniteThreads projects the pages back to thread arrays with select,
so list components are unchanged.
* test(frontend): pin the sidebar's next-page offset past side chats in E2E
Three side chats in the first 50 server rows push the sidebar's second
page to offset 53. After a rename through the sidebar menu, scrolling to
the sentinel must request offset 53, not 50. On main this test fails with
the request at offset 50.
* docs(changelog): reference #6702
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
1f15e0b43c
commit
eb7fc28010
10
CHANGELOG.md
10
CHANGELOG.md
@ -5791,6 +5791,15 @@ This release closes that milestone with **621 merged pull requests**.
|
||||
agent as an empty string, or as an unrelated host variable's value when that
|
||||
name existed. Values are now passed through as loaded, as BoxLite and the
|
||||
local provider already did. ([#6673])
|
||||
- **frontend:** Loading more chats in the sidebar and on the Chats page no longer
|
||||
re-requests chats it already has. Side chats are hidden from these lists, so a
|
||||
page of 50 visible chats can span more server rows; the page kept that offset
|
||||
as a hidden property on its array, which TanStack Query's structural sharing
|
||||
and the lists' own cache updates dropped. After a rename, a finished run or
|
||||
any list refresh, the next page started too early and fetched overlapping
|
||||
rows, and removing a chat from a full page stopped the list from loading more
|
||||
until it refetched. Each cached page now records how many rows it hid and
|
||||
whether the server has more. ([#6702])
|
||||
|
||||
- **frontend:** A one-time scheduled task whose `run_at` is a bare date
|
||||
(`"2026-12-07"`, which a chat-created task can carry) now shows midnight of
|
||||
@ -11572,3 +11581,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
||||
[#6689]: https://github.com/bytedance/deer-flow/pull/6689
|
||||
[#6691]: https://github.com/bytedance/deer-flow/pull/6691
|
||||
[#6692]: https://github.com/bytedance/deer-flow/pull/6692
|
||||
[#6702]: https://github.com/bytedance/deer-flow/pull/6702
|
||||
|
||||
@ -4430,6 +4430,12 @@
|
||||
`$2b$12$...` 形式的 bcrypt 哈希或生成的密码)会再次被当作变量名查找,传入沙箱
|
||||
或 agent 时变成空字符串;若同名主机变量存在,则变成该变量的值。现在这些值按
|
||||
加载结果原样传递,与 BoxLite 和本地提供者一致。([#6673])
|
||||
- **前端:** 侧边栏和对话页加载更多对话时,不再重复请求已加载的对话。侧边对话
|
||||
不在这些列表中显示,因此一页 50 条可见对话可能跨越更多服务端行;页面原先把这个
|
||||
偏移量作为数组上的隐藏属性保存,TanStack Query 的结构共享和列表自身的缓存更新
|
||||
都会把它丢掉。重命名、运行结束或任何列表刷新之后,下一页会从过早的位置开始,
|
||||
取回重叠的行;从满页中移除一条对话也会让列表在重新获取前停止加载更多。现在每个
|
||||
缓存页都会记录自己隐藏了多少行,以及服务端是否还有更多。([#6702])
|
||||
|
||||
- **前端:** `run_at` 只有日期(如 `"2026-12-07"`,对话中创建的任务可能如此)的单次定时
|
||||
任务,现在显示为任务时区当天的零点,与服务端实际执行时间一致。此前界面把末尾的
|
||||
@ -9270,3 +9276,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
||||
[#6689]: https://github.com/bytedance/deer-flow/pull/6689
|
||||
[#6691]: https://github.com/bytedance/deer-flow/pull/6691
|
||||
[#6692]: https://github.com/bytedance/deer-flow/pull/6692
|
||||
[#6702]: https://github.com/bytedance/deer-flow/pull/6702
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type InfiniteData,
|
||||
type QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
@ -14,6 +13,7 @@ import { useThreadActivityFeature } from "@/core/features/hooks";
|
||||
import {
|
||||
INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
mapInfiniteThreadsCache,
|
||||
type InfiniteThreadsData,
|
||||
} from "./hooks";
|
||||
import { isThreadOriginKind, type ThreadOriginKind } from "./origin";
|
||||
import type { AgentThread } from "./types";
|
||||
@ -311,10 +311,10 @@ export function cachedThreadUnread(
|
||||
data.forEach(visit);
|
||||
}
|
||||
}
|
||||
for (const [, data] of queryClient.getQueriesData<
|
||||
InfiniteData<AgentThread[]>
|
||||
>({ queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX })) {
|
||||
data?.pages.forEach((page) => page.forEach(visit));
|
||||
for (const [, data] of queryClient.getQueriesData<InfiniteThreadsData>({
|
||||
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
})) {
|
||||
data?.pages.forEach((page) => page.threads.forEach(visit));
|
||||
}
|
||||
return known;
|
||||
}
|
||||
@ -333,7 +333,7 @@ export function setThreadUnreadInCaches(
|
||||
{ queryKey: THREAD_SEARCH_QUERY_KEY, exact: false },
|
||||
(data) => (Array.isArray(data) ? data.map(patch) : data),
|
||||
);
|
||||
queryClient.setQueriesData<InfiniteData<AgentThread[]>>(
|
||||
queryClient.setQueriesData<InfiniteThreadsData>(
|
||||
{ queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX, exact: false },
|
||||
(data) => mapInfiniteThreadsCache(data, patch),
|
||||
);
|
||||
|
||||
@ -1750,13 +1750,14 @@ export function upsertThreadInInfiniteCache(
|
||||
exact: false,
|
||||
predicate: (query) => !hasArchiveFilter(query),
|
||||
},
|
||||
(oldData: InfiniteData<AgentThread[]> | undefined) => {
|
||||
(oldData: InfiniteThreadsData | undefined) => {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
const merged = oldData.pages.map((page) =>
|
||||
page.map((t) =>
|
||||
const merged = oldData.pages.map((page) => ({
|
||||
...page,
|
||||
threads: page.threads.map((t) =>
|
||||
t.thread_id === thread.thread_id
|
||||
? {
|
||||
...thread,
|
||||
@ -1772,20 +1773,27 @@ export function upsertThreadInInfiniteCache(
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
}));
|
||||
|
||||
const exists = merged.some((page) =>
|
||||
page.some((t) => t.thread_id === thread.thread_id),
|
||||
page.threads.some((t) => t.thread_id === thread.thread_id),
|
||||
);
|
||||
if (exists) {
|
||||
return { ...oldData, pages: merged };
|
||||
}
|
||||
|
||||
const firstPage = merged[0] ?? [];
|
||||
const firstPage = merged[0] ?? {
|
||||
threads: [],
|
||||
hiddenCount: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
const restPages = merged.slice(1);
|
||||
return {
|
||||
...oldData,
|
||||
pages: [[thread, ...firstPage], ...restPages],
|
||||
pages: [
|
||||
{ ...firstPage, threads: [thread, ...firstPage.threads] },
|
||||
...restPages,
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
@ -3370,10 +3378,6 @@ export const INFINITE_THREADS_QUERY_KEY_PREFIX = [
|
||||
"searchInfinite",
|
||||
] as const;
|
||||
|
||||
const INFINITE_THREADS_NEXT_PAGE_PARAM = Symbol(
|
||||
"deerflow.infiniteThreads.nextPageParam",
|
||||
);
|
||||
|
||||
type InfiniteThreadsParams = Omit<
|
||||
NonNullable<Parameters<ThreadsClient["search"]>[0]>,
|
||||
"limit" | "offset"
|
||||
@ -3385,29 +3389,30 @@ type InfiniteThreadsSearchClient = {
|
||||
};
|
||||
};
|
||||
|
||||
type InfiniteThreadsPageWithNextParam = AgentThread[] & {
|
||||
[INFINITE_THREADS_NEXT_PAGE_PARAM]?: number;
|
||||
/**
|
||||
* One loaded page of an infinite thread list. `threads` holds the rows the
|
||||
* list shows; `hiddenCount` counts the server rows the page consumed but the
|
||||
* client filtered out (sidecar threads). Both live in the page data itself so
|
||||
* cache writes and structural sharing, which rebuild pages, keep them.
|
||||
*/
|
||||
export type InfiniteThreadsPage = {
|
||||
threads: AgentThread[];
|
||||
hiddenCount: number;
|
||||
/** False once the server returned fewer rows than requested. */
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
function annotateInfiniteThreadsPage(
|
||||
page: AgentThread[],
|
||||
nextPageParam: number | undefined,
|
||||
): AgentThread[] {
|
||||
if (nextPageParam !== undefined) {
|
||||
Reflect.set(page, INFINITE_THREADS_NEXT_PAGE_PARAM, nextPageParam);
|
||||
}
|
||||
return page;
|
||||
}
|
||||
export type InfiniteThreadsData = InfiniteData<InfiniteThreadsPage, number>;
|
||||
|
||||
export async function fetchInfiniteThreadsPage(
|
||||
apiClient: InfiniteThreadsSearchClient,
|
||||
params: InfiniteThreadsParams,
|
||||
pageParam: number,
|
||||
pageSize: number = INFINITE_THREADS_PAGE_SIZE,
|
||||
): Promise<AgentThread[]> {
|
||||
): Promise<InfiniteThreadsPage> {
|
||||
const threads: AgentThread[] = [];
|
||||
let offset = pageParam;
|
||||
let nextPageParam: number | undefined;
|
||||
let hasMore = true;
|
||||
|
||||
while (threads.length < pageSize) {
|
||||
const currentLimit = pageSize - threads.length;
|
||||
@ -3430,59 +3435,73 @@ export async function fetchInfiniteThreadsPage(
|
||||
offset += response.length;
|
||||
|
||||
if (response.length < currentLimit) {
|
||||
nextPageParam = undefined;
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
nextPageParam = offset;
|
||||
}
|
||||
|
||||
return annotateInfiniteThreadsPage(threads, nextPageParam);
|
||||
return {
|
||||
threads,
|
||||
hiddenCount: offset - pageParam - threads.length,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
export function getInfiniteThreadsNextPageParam(
|
||||
lastPage: AgentThread[],
|
||||
allPages: AgentThread[][],
|
||||
pageSize: number = INFINITE_THREADS_PAGE_SIZE,
|
||||
lastPage: InfiniteThreadsPage,
|
||||
allPages: InfiniteThreadsPage[],
|
||||
): number | undefined {
|
||||
const annotatedNextPageParam = Reflect.get(
|
||||
lastPage as InfiniteThreadsPageWithNextParam,
|
||||
INFINITE_THREADS_NEXT_PAGE_PARAM,
|
||||
);
|
||||
if (typeof annotatedNextPageParam === "number") {
|
||||
return annotatedNextPageParam;
|
||||
}
|
||||
|
||||
if (lastPage.length < pageSize) {
|
||||
if (!lastPage.hasMore) {
|
||||
return undefined;
|
||||
}
|
||||
return allPages.reduce((sum, page) => sum + page.length, 0);
|
||||
// Count visible rows from the cache instead of freezing the fetch-time
|
||||
// offset: a local delete or insert changes the server list the same way.
|
||||
return allPages.reduce(
|
||||
(offset, page) => offset + page.threads.length + page.hiddenCount,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/** List components read plain thread pages; pagination state stays cached. */
|
||||
function selectInfiniteThreads(
|
||||
data: InfiniteThreadsData,
|
||||
): InfiniteData<AgentThread[], number> {
|
||||
return {
|
||||
pages: data.pages.map((page) => page.threads),
|
||||
pageParams: data.pageParams,
|
||||
};
|
||||
}
|
||||
|
||||
function updateInfiniteThreadsPages(
|
||||
oldData: InfiniteThreadsData | undefined,
|
||||
update: (threads: AgentThread[]) => AgentThread[],
|
||||
): InfiniteThreadsData | undefined {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
return {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => ({
|
||||
...page,
|
||||
threads: update(page.threads),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapInfiniteThreadsCache(
|
||||
oldData: InfiniteData<AgentThread[]> | undefined,
|
||||
oldData: InfiniteThreadsData | undefined,
|
||||
mapper: (thread: AgentThread) => AgentThread,
|
||||
): InfiniteData<AgentThread[]> | undefined {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
return {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => page.map(mapper)),
|
||||
};
|
||||
): InfiniteThreadsData | undefined {
|
||||
return updateInfiniteThreadsPages(oldData, (threads) => threads.map(mapper));
|
||||
}
|
||||
|
||||
export function filterInfiniteThreadsCache(
|
||||
oldData: InfiniteData<AgentThread[]> | undefined,
|
||||
oldData: InfiniteThreadsData | undefined,
|
||||
predicate: (thread: AgentThread) => boolean,
|
||||
): InfiniteData<AgentThread[]> | undefined {
|
||||
if (!oldData) {
|
||||
return oldData;
|
||||
}
|
||||
return {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => page.filter(predicate)),
|
||||
};
|
||||
): InfiniteThreadsData | undefined {
|
||||
return updateInfiniteThreadsPages(oldData, (threads) =>
|
||||
threads.filter(predicate),
|
||||
);
|
||||
}
|
||||
|
||||
function mergeThreadMetadata(
|
||||
@ -3545,7 +3564,7 @@ function setThreadInCaches(
|
||||
);
|
||||
queryClient.setQueriesData(
|
||||
filters.infiniteSearch,
|
||||
(oldData: InfiniteData<AgentThread[]> | undefined) =>
|
||||
(oldData: InfiniteThreadsData | undefined) =>
|
||||
mapInfiniteThreadsCache(oldData, (thread) =>
|
||||
thread.thread_id === threadId ? mapper(thread) : thread,
|
||||
),
|
||||
@ -3587,9 +3606,9 @@ export function useInfiniteThreads(
|
||||
) {
|
||||
const apiClient = getAPIClient();
|
||||
return useInfiniteQuery<
|
||||
AgentThread[],
|
||||
InfiniteThreadsPage,
|
||||
Error,
|
||||
InfiniteData<AgentThread[]>,
|
||||
InfiniteData<AgentThread[], number>,
|
||||
readonly unknown[],
|
||||
number
|
||||
>({
|
||||
@ -3604,6 +3623,7 @@ export function useInfiniteThreads(
|
||||
),
|
||||
getNextPageParam: (lastPage, allPages) =>
|
||||
getInfiniteThreadsNextPageParam(lastPage, allPages),
|
||||
select: selectInfiniteThreads,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled,
|
||||
});
|
||||
@ -3959,7 +3979,7 @@ export function useDeleteThread() {
|
||||
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
exact: false,
|
||||
},
|
||||
(oldData: InfiniteData<AgentThread[]> | undefined) =>
|
||||
(oldData: InfiniteThreadsData | undefined) =>
|
||||
filterInfiniteThreadsCache(
|
||||
oldData,
|
||||
(t) => !deletedThreadIds.has(t.thread_id),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
@ -27,6 +27,39 @@ const THREADS = Array.from({ length: TOTAL_THREADS }, (_, i) => {
|
||||
const FIRST_PAGE_LAST = `Conversation ${String(PAGE_SIZE).padStart(3, "0")}`;
|
||||
const SECOND_PAGE_FIRST = `Conversation ${String(PAGE_SIZE + 1).padStart(3, "0")}`;
|
||||
|
||||
// Side chats are hidden from the primary lists, so a page of 50 visible
|
||||
// chats spans more server rows. Three side chats in the first 50 rows push
|
||||
// the second page to server offset 53.
|
||||
const SIDECAR_INDEXES = new Set([4, 21, 38]);
|
||||
const THREADS_WITH_SIDE_CHATS = THREADS.map((thread, i) =>
|
||||
SIDECAR_INDEXES.has(i)
|
||||
? {
|
||||
...thread,
|
||||
metadata: {
|
||||
deerflow_sidecar: true,
|
||||
parent_thread_id: THREADS[0]!.thread_id,
|
||||
},
|
||||
}
|
||||
: thread,
|
||||
);
|
||||
const SECOND_PAGE_OFFSET = PAGE_SIZE + SIDECAR_INDEXES.size;
|
||||
|
||||
function observeSearchOffsets(page: Page) {
|
||||
const offsets: number[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (
|
||||
request.method() === "POST" &&
|
||||
/^\/api\/(?:langgraph\/)?threads\/search$/.test(
|
||||
new URL(request.url()).pathname,
|
||||
)
|
||||
) {
|
||||
const body = request.postDataJSON() as { offset?: number } | null;
|
||||
offsets.push(body?.offset ?? 0);
|
||||
}
|
||||
});
|
||||
return offsets;
|
||||
}
|
||||
|
||||
test.describe("Thread list infinite scroll (issue #3482)", () => {
|
||||
test("chats list page loads more threads when scrolling to the bottom", async ({
|
||||
page,
|
||||
@ -130,4 +163,47 @@ test.describe("Thread list infinite scroll (issue #3482)", () => {
|
||||
.poll(() => searchRequestCount, { timeout: 10_000 })
|
||||
.toBeGreaterThan(baselineRequests);
|
||||
});
|
||||
test("sidebar loads the next page past hidden side chats after a rename", async ({
|
||||
page,
|
||||
}) => {
|
||||
const offsets = observeSearchOffsets(page);
|
||||
mockLangGraphAPI(page, { threads: THREADS_WITH_SIDE_CHATS });
|
||||
|
||||
await page.goto("/workspace/chats/new");
|
||||
|
||||
const lastVisibleOnFirstPage = `Conversation ${String(
|
||||
SECOND_PAGE_OFFSET,
|
||||
).padStart(3, "0")}`;
|
||||
await expect(page.getByText(lastVisibleOnFirstPage).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Renaming rewrites the cached pages; the next page must still start
|
||||
// after the side chats the first page skipped.
|
||||
const firstItem = page
|
||||
.locator(
|
||||
`a[data-sidebar="menu-button"][href="/workspace/chats/${THREADS[0]!.thread_id}"]`,
|
||||
)
|
||||
.locator("xpath=..");
|
||||
await firstItem.hover();
|
||||
await firstItem.getByRole("button", { name: "More" }).click();
|
||||
await page.getByRole("menuitem", { name: "Rename" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("textbox").fill("Renamed conversation");
|
||||
await dialog.getByRole("button", { name: "Save" }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(firstItem).toContainText("Renamed conversation");
|
||||
|
||||
offsets.length = 0;
|
||||
await page
|
||||
.getByTestId("recent-chat-list-sentinel")
|
||||
.scrollIntoViewIfNeeded();
|
||||
|
||||
const nextFirst = `Conversation ${String(SECOND_PAGE_OFFSET + 1).padStart(3, "0")}`;
|
||||
await expect(page.getByText(nextFirst).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(offsets).toContain(SECOND_PAGE_OFFSET);
|
||||
expect(offsets).not.toContain(PAGE_SIZE);
|
||||
});
|
||||
});
|
||||
|
||||
@ -83,10 +83,14 @@ function renderGroupedSection(): ReturnType<typeof render> {
|
||||
[...INFINITE_THREADS_QUERY_KEY_PREFIX, { archived: false }],
|
||||
{
|
||||
pages: [
|
||||
[
|
||||
makeThread("thread-active", ACTIVE_PROJECT_ID),
|
||||
makeThread("thread-archived", ARCHIVED_PROJECT_ID),
|
||||
],
|
||||
{
|
||||
threads: [
|
||||
makeThread("thread-active", ACTIVE_PROJECT_ID),
|
||||
makeThread("thread-archived", ARCHIVED_PROJECT_ID),
|
||||
],
|
||||
hiddenCount: 0,
|
||||
hasMore: false,
|
||||
},
|
||||
],
|
||||
pageParams: [0],
|
||||
},
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
import { afterEach, describe, expect, rs, test } from "@rstest/core";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
type InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
|
||||
const nav = rs.hoisted(() => ({ activeThreadId: null as string | null }));
|
||||
@ -26,7 +22,10 @@ import { AuthProvider } from "@/core/auth/AuthProvider";
|
||||
import type { User } from "@/core/auth/types";
|
||||
import type { Locale } from "@/core/i18n";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
import { INFINITE_THREADS_QUERY_KEY_PREFIX } from "@/core/threads/hooks";
|
||||
import {
|
||||
INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
type InfiniteThreadsData,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread } from "@/core/threads/types";
|
||||
|
||||
import { expectNoRawIdentifiers } from "../../helpers/readable";
|
||||
@ -89,9 +88,12 @@ function renderList(locale: Locale, activeThreadId: string | null = null) {
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
clients.push(queryClient);
|
||||
queryClient.setQueryData<InfiniteData<AgentThread[]>>(
|
||||
queryClient.setQueryData<InfiniteThreadsData>(
|
||||
[...INFINITE_THREADS_QUERY_KEY_PREFIX, { archived: false }],
|
||||
{ pages: [THREADS], pageParams: [0] },
|
||||
{
|
||||
pages: [{ threads: THREADS, hiddenCount: 0, hasMore: false }],
|
||||
pageParams: [0],
|
||||
},
|
||||
);
|
||||
const user = {
|
||||
id: "user-1",
|
||||
|
||||
@ -32,7 +32,7 @@ function setup() {
|
||||
const key = ["thread", "metadata", "chat", false];
|
||||
client.setQueryData(key, original);
|
||||
client.setQueryData(["threads", "searchInfinite", { archived: false }], {
|
||||
pages: [[original]],
|
||||
pages: [{ threads: [original], hiddenCount: 0, hasMore: false }],
|
||||
pageParams: [0],
|
||||
});
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
@ -77,7 +77,10 @@ test("failed archive keeps the visible thread and metadata unchanged", async ()
|
||||
expect(client.getQueryData(key)).toEqual(original);
|
||||
expect(
|
||||
client.getQueryData(["threads", "searchInfinite", { archived: false }]),
|
||||
).toEqual({ pages: [[original]], pageParams: [0] });
|
||||
).toEqual({
|
||||
pages: [{ threads: [original], hiddenCount: 0, hasMore: false }],
|
||||
pageParams: [0],
|
||||
});
|
||||
client.clear();
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,165 @@
|
||||
import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
const apiMocks = rs.hoisted(() => ({
|
||||
search: rs.fn(),
|
||||
}));
|
||||
|
||||
rs.mock("@/core/api", () => ({
|
||||
getAPIClient: () => ({
|
||||
threads: {
|
||||
search: apiMocks.search,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { SIDECAR_METADATA_KEY } from "@/core/sidecar/thread";
|
||||
import {
|
||||
filterInfiniteThreadsCache,
|
||||
INFINITE_THREADS_PAGE_SIZE,
|
||||
INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
setThreadTitleInCaches,
|
||||
useInfiniteThreads,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread } from "@/core/threads/types";
|
||||
|
||||
// The first 50 server rows hold three sidecar threads, so the first visible
|
||||
// page of 50 primary threads ends at server offset 53, not 50.
|
||||
const SIDECAR_INDEXES = new Set([5, 22, 39]);
|
||||
const FIRST_PAGE_SERVER_ROWS =
|
||||
INFINITE_THREADS_PAGE_SIZE + SIDECAR_INDEXES.size;
|
||||
|
||||
function makeServerRows(count: number): AgentThread[] {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) =>
|
||||
({
|
||||
thread_id: `t-${index}`,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
metadata: SIDECAR_INDEXES.has(index)
|
||||
? { [SIDECAR_METADATA_KEY]: true, parent_thread_id: "parent" }
|
||||
: {},
|
||||
status: "idle",
|
||||
values: { title: `Title ${index}` },
|
||||
}) as unknown as AgentThread,
|
||||
);
|
||||
}
|
||||
|
||||
let serverRows: AgentThread[] = [];
|
||||
let queryClient: QueryClient;
|
||||
|
||||
function renderInfiniteThreads() {
|
||||
const wrapper = ({ children }: PropsWithChildren) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
return renderHook(() => useInfiniteThreads(), { wrapper });
|
||||
}
|
||||
|
||||
function requestedOffsets(): number[] {
|
||||
return apiMocks.search.mock.calls.map(
|
||||
([request]) => (request as { offset: number }).offset,
|
||||
);
|
||||
}
|
||||
|
||||
function loadedThreadIds(
|
||||
result: ReturnType<typeof renderInfiniteThreads>["result"],
|
||||
): string[] {
|
||||
return (result.current.data?.pages ?? []).flatMap((page) =>
|
||||
page.map((thread) => thread.thread_id),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadFirstPage() {
|
||||
const hook = renderInfiniteThreads();
|
||||
await waitFor(() => expect(hook.result.current.isSuccess).toBe(true));
|
||||
expect(hook.result.current.data?.pages[0]).toHaveLength(
|
||||
INFINITE_THREADS_PAGE_SIZE,
|
||||
);
|
||||
apiMocks.search.mockClear();
|
||||
return hook;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
serverRows = makeServerRows(120);
|
||||
apiMocks.search.mockImplementation(
|
||||
async ({ limit, offset }: { limit: number; offset: number }) =>
|
||||
serverRows.slice(offset, offset + limit),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
queryClient.clear();
|
||||
apiMocks.search.mockReset();
|
||||
});
|
||||
|
||||
test("a cache write keeps the next page at the server offset past hidden sidecars", async () => {
|
||||
const { result } = await loadFirstPage();
|
||||
|
||||
act(() => {
|
||||
setThreadTitleInCaches(queryClient, "t-3", "Renamed");
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.fetchNextPage();
|
||||
});
|
||||
|
||||
expect(requestedOffsets()[0]).toBe(FIRST_PAGE_SERVER_ROWS);
|
||||
const ids = loadedThreadIds(result);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
test("a refetch keeps the next page at the server offset past hidden sidecars", async () => {
|
||||
const { result } = await loadFirstPage();
|
||||
|
||||
serverRows = serverRows.map((thread) =>
|
||||
thread.thread_id === "t-3"
|
||||
? { ...thread, values: { ...thread.values, title: "Renamed" } }
|
||||
: thread,
|
||||
);
|
||||
await act(async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
});
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current.data?.pages[0]?.[3]?.values.title).toBe("Renamed"),
|
||||
);
|
||||
apiMocks.search.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchNextPage();
|
||||
});
|
||||
|
||||
expect(requestedOffsets()[0]).toBe(FIRST_PAGE_SERVER_ROWS);
|
||||
const ids = loadedThreadIds(result);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
test("removing a row from a full page keeps the list open for more pages", async () => {
|
||||
const { result } = await loadFirstPage();
|
||||
expect(result.current.hasNextPage).toBe(true);
|
||||
|
||||
act(() => {
|
||||
queryClient.setQueriesData(
|
||||
{ queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX },
|
||||
(data: Parameters<typeof filterInfiniteThreadsCache>[0]) =>
|
||||
filterInfiniteThreadsCache(
|
||||
data,
|
||||
(thread) => thread.thread_id !== "t-0",
|
||||
),
|
||||
);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current.data?.pages[0]).toHaveLength(
|
||||
INFINITE_THREADS_PAGE_SIZE - 1,
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.current.hasNextPage).toBe(true);
|
||||
});
|
||||
@ -1,9 +1,5 @@
|
||||
import { describe, expect, rs, test } from "@rstest/core";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryObserver,
|
||||
type InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
|
||||
import { PROJECTS_QUERY_KEY } from "@/core/projects/api";
|
||||
import {
|
||||
@ -12,6 +8,8 @@ import {
|
||||
getInfiniteThreadsNextPageParam,
|
||||
INFINITE_THREADS_PAGE_SIZE,
|
||||
INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
type InfiniteThreadsData,
|
||||
type InfiniteThreadsPage,
|
||||
invalidateStoppedThreadCaches,
|
||||
mapInfiniteThreadsCache,
|
||||
STOP_THREAD_FINALIZATION_REFETCH_DELAY_MS,
|
||||
@ -42,19 +40,32 @@ function makeThread(
|
||||
} as unknown as AgentThread;
|
||||
}
|
||||
|
||||
function makePage(start: number, size: number): AgentThread[] {
|
||||
return Array.from({ length: size }, (_, i) => makeThread(`t-${start + i}`));
|
||||
function makePage(
|
||||
start: number,
|
||||
size: number,
|
||||
{
|
||||
hiddenCount = 0,
|
||||
hasMore = size === INFINITE_THREADS_PAGE_SIZE,
|
||||
}: { hiddenCount?: number; hasMore?: boolean } = {},
|
||||
): InfiniteThreadsPage {
|
||||
return {
|
||||
threads: Array.from({ length: size }, (_, i) =>
|
||||
makeThread(`t-${start + i}`),
|
||||
),
|
||||
hiddenCount,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
function makeInfiniteData(pages: AgentThread[][]): InfiniteData<AgentThread[]> {
|
||||
function makeInfiniteData(pages: AgentThread[][]): InfiniteThreadsData {
|
||||
return {
|
||||
pages,
|
||||
pages: pages.map((threads) => ({ threads, hiddenCount: 0, hasMore: true })),
|
||||
pageParams: pages.map((_, i) => i * INFINITE_THREADS_PAGE_SIZE),
|
||||
};
|
||||
}
|
||||
|
||||
describe("getInfiniteThreadsNextPageParam", () => {
|
||||
test("returns next offset when the last page is full", () => {
|
||||
test("returns next offset when the last page has more rows", () => {
|
||||
const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE);
|
||||
expect(getInfiniteThreadsNextPageParam(page1, [page1])).toBe(
|
||||
INFINITE_THREADS_PAGE_SIZE,
|
||||
@ -72,7 +83,15 @@ describe("getInfiniteThreadsNextPageParam", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("returns undefined when the last page is short (end of list)", () => {
|
||||
test("advances past server rows that pages hid", () => {
|
||||
const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE, { hiddenCount: 3 });
|
||||
const page2 = makePage(53, INFINITE_THREADS_PAGE_SIZE, { hiddenCount: 1 });
|
||||
expect(getInfiniteThreadsNextPageParam(page2, [page1, page2])).toBe(
|
||||
INFINITE_THREADS_PAGE_SIZE * 2 + 4,
|
||||
);
|
||||
});
|
||||
|
||||
test("returns undefined when the last page reached the end of the list", () => {
|
||||
const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE);
|
||||
const page2 = makePage(INFINITE_THREADS_PAGE_SIZE, 10);
|
||||
expect(
|
||||
@ -82,13 +101,26 @@ describe("getInfiniteThreadsNextPageParam", () => {
|
||||
|
||||
test("returns undefined when the last page is empty", () => {
|
||||
const page1 = makePage(0, INFINITE_THREADS_PAGE_SIZE);
|
||||
expect(getInfiniteThreadsNextPageParam([], [page1, []])).toBeUndefined();
|
||||
const page2 = makePage(INFINITE_THREADS_PAGE_SIZE, 0);
|
||||
expect(
|
||||
getInfiniteThreadsNextPageParam(page2, [page1, page2]),
|
||||
).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();
|
||||
test("reads the end of the list from the page, not its length", () => {
|
||||
const shrunkByDelete = makePage(0, INFINITE_THREADS_PAGE_SIZE - 1, {
|
||||
hasMore: true,
|
||||
});
|
||||
expect(
|
||||
getInfiniteThreadsNextPageParam(shrunkByDelete, [shrunkByDelete]),
|
||||
).toBe(INFINITE_THREADS_PAGE_SIZE - 1);
|
||||
|
||||
const grownByInsert = makePage(0, INFINITE_THREADS_PAGE_SIZE, {
|
||||
hasMore: false,
|
||||
});
|
||||
expect(
|
||||
getInfiniteThreadsNextPageParam(grownByInsert, [grownByInsert]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -109,7 +141,7 @@ describe("fetchInfiniteThreadsPage", () => {
|
||||
2,
|
||||
);
|
||||
|
||||
expect(page.map((thread) => thread.thread_id)).toEqual([
|
||||
expect(page.threads.map((thread) => thread.thread_id)).toEqual([
|
||||
"primary-1",
|
||||
"primary-2",
|
||||
]);
|
||||
@ -125,7 +157,8 @@ describe("fetchInfiniteThreadsPage", () => {
|
||||
limit: 1,
|
||||
offset: 2,
|
||||
});
|
||||
expect(getInfiniteThreadsNextPageParam(page, [page], 2)).toBe(3);
|
||||
expect(page).toMatchObject({ hiddenCount: 1, hasMore: true });
|
||||
expect(getInfiniteThreadsNextPageParam(page, [page])).toBe(3);
|
||||
});
|
||||
|
||||
test("keeps sidecar rows when the caller explicitly searches for sidecars", async () => {
|
||||
@ -147,8 +180,11 @@ describe("fetchInfiniteThreadsPage", () => {
|
||||
2,
|
||||
);
|
||||
|
||||
expect(page.map((thread) => thread.thread_id)).toEqual(["sidecar-1"]);
|
||||
expect(getInfiniteThreadsNextPageParam(page, [page], 2)).toBeUndefined();
|
||||
expect(page.threads.map((thread) => thread.thread_id)).toEqual([
|
||||
"sidecar-1",
|
||||
]);
|
||||
expect(page).toMatchObject({ hiddenCount: 0, hasMore: false });
|
||||
expect(getInfiniteThreadsNextPageParam(page, [page])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -168,16 +204,20 @@ describe("mapInfiniteThreadsCache", () => {
|
||||
: 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");
|
||||
expect(updated?.pages[0]?.threads[0]?.values?.title).toBe("Title a");
|
||||
expect(updated?.pages[1]?.threads[0]?.thread_id).toBe("c");
|
||||
expect(updated?.pages[1]?.threads[0]?.values?.title).toBe("renamed");
|
||||
expect(updated?.pages[1]?.threads[1]?.values?.title).toBe("Title d");
|
||||
});
|
||||
|
||||
test("preserves pageParams", () => {
|
||||
const data = makeInfiniteData([[makeThread("a")]]);
|
||||
test("preserves pageParams and the pagination state of each page", () => {
|
||||
const data: InfiniteThreadsData = {
|
||||
pages: [makePage(0, 2, { hiddenCount: 3, hasMore: false })],
|
||||
pageParams: [0],
|
||||
};
|
||||
const updated = mapInfiniteThreadsCache(data, (t) => t);
|
||||
expect(updated?.pageParams).toEqual(data.pageParams);
|
||||
expect(updated?.pages[0]).toMatchObject({ hiddenCount: 3, hasMore: false });
|
||||
});
|
||||
});
|
||||
|
||||
@ -196,8 +236,8 @@ describe("filterInfiniteThreadsCache", () => {
|
||||
(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"]);
|
||||
expect(filtered?.pages[0]?.threads.map((t) => t.thread_id)).toEqual(["a"]);
|
||||
expect(filtered?.pages[1]?.threads.map((t) => t.thread_id)).toEqual(["c"]);
|
||||
});
|
||||
|
||||
test("keeps an emptied page as an empty array (does not drop the page)", () => {
|
||||
@ -211,46 +251,32 @@ describe("filterInfiniteThreadsCache", () => {
|
||||
);
|
||||
|
||||
expect(filtered?.pages).toHaveLength(2);
|
||||
expect(filtered?.pages[0]).toEqual([]);
|
||||
expect(filtered?.pages[1]?.[0]?.thread_id).toBe("b");
|
||||
expect(filtered?.pages[0]?.threads).toEqual([]);
|
||||
expect(filtered?.pages[1]?.threads[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}`,
|
||||
}));
|
||||
test("moves the next offset back with a delete and keeps hidden rows", () => {
|
||||
const data: InfiniteThreadsData = {
|
||||
pages: [
|
||||
makePage(0, INFINITE_THREADS_PAGE_SIZE, { hiddenCount: 3 }),
|
||||
makePage(53, INFINITE_THREADS_PAGE_SIZE),
|
||||
],
|
||||
pageParams: [0, 53],
|
||||
};
|
||||
|
||||
// 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[][],
|
||||
// The deleted thread also leaves the server list, so the next server
|
||||
// offset moves back by one; the hidden sidecar rows still count.
|
||||
const filtered = filterInfiniteThreadsCache(
|
||||
data,
|
||||
(t) => t.thread_id !== "t-0",
|
||||
);
|
||||
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);
|
||||
const pages = filtered?.pages ?? [];
|
||||
expect(getInfiniteThreadsNextPageParam(pages.at(-1)!, pages)).toBe(102);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertThreadInInfiniteCache", () => {
|
||||
function seedClient(initial?: InfiniteData<AgentThread[]>): QueryClient {
|
||||
function seedClient(initial?: InfiniteThreadsData): QueryClient {
|
||||
const client = new QueryClient();
|
||||
if (initial) {
|
||||
client.setQueryData([...INFINITE_THREADS_QUERY_KEY_PREFIX, {}], initial);
|
||||
@ -258,9 +284,7 @@ describe("upsertThreadInInfiniteCache", () => {
|
||||
return client;
|
||||
}
|
||||
|
||||
function readCache(
|
||||
client: QueryClient,
|
||||
): InfiniteData<AgentThread[]> | undefined {
|
||||
function readCache(client: QueryClient): InfiniteThreadsData | undefined {
|
||||
return client.getQueryData([...INFINITE_THREADS_QUERY_KEY_PREFIX, {}]);
|
||||
}
|
||||
|
||||
@ -271,21 +295,21 @@ describe("upsertThreadInInfiniteCache", () => {
|
||||
});
|
||||
|
||||
test("prepends a brand-new thread to the first page", () => {
|
||||
const client = seedClient({
|
||||
pages: [[makeThread("a"), makeThread("b")]],
|
||||
pageParams: [0],
|
||||
});
|
||||
const client = seedClient(
|
||||
makeInfiniteData([[makeThread("a"), makeThread("b")]]),
|
||||
);
|
||||
upsertThreadInInfiniteCache(client, makeThread("new"));
|
||||
const cache = readCache(client);
|
||||
expect(cache?.pages[0]?.map((t) => t.thread_id)).toEqual(["new", "a", "b"]);
|
||||
expect(cache?.pages[0]?.threads.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],
|
||||
});
|
||||
const client = seedClient(makeInfiniteData([[existing, makeThread("b")]]));
|
||||
// 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.
|
||||
@ -294,9 +318,9 @@ describe("upsertThreadInInfiniteCache", () => {
|
||||
status: "busy",
|
||||
});
|
||||
const cache = readCache(client);
|
||||
const ids = cache?.pages[0]?.map((t) => t.thread_id);
|
||||
const ids = cache?.pages[0]?.threads.map((t) => t.thread_id);
|
||||
expect(ids).toEqual(["a", "b"]);
|
||||
expect(cache?.pages[0]?.[0]?.values.title).toBe("Old title");
|
||||
expect(cache?.pages[0]?.threads[0]?.values.title).toBe("Old title");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -6,11 +6,7 @@ import {
|
||||
rs,
|
||||
test,
|
||||
} from "@rstest/core";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
type InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
@ -35,7 +31,10 @@ import {
|
||||
useMarkThreadRead,
|
||||
useThreadActivity,
|
||||
} from "@/core/threads/activity";
|
||||
import { INFINITE_THREADS_QUERY_KEY_PREFIX } from "@/core/threads/hooks";
|
||||
import {
|
||||
INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
type InfiniteThreadsData,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThread } from "@/core/threads/types";
|
||||
|
||||
const clients: QueryClient[] = [];
|
||||
@ -69,9 +68,12 @@ function setup(
|
||||
) {
|
||||
const { queryClient, wrapper } = newClient();
|
||||
queryClient.setQueryData(["threads", "search", { limit: 50 }], search);
|
||||
queryClient.setQueryData<InfiniteData<AgentThread[]>>(
|
||||
queryClient.setQueryData<InfiniteThreadsData>(
|
||||
[...INFINITE_THREADS_QUERY_KEY_PREFIX, {}],
|
||||
{ pages: [infinite], pageParams: [0] },
|
||||
{
|
||||
pages: [{ threads: infinite, hiddenCount: 0, hasMore: false }],
|
||||
pageParams: [0],
|
||||
},
|
||||
);
|
||||
const hook = renderHook(({ id }) => useMarkThreadRead(id), {
|
||||
wrapper,
|
||||
@ -140,11 +142,11 @@ describe("useMarkThreadRead", () => {
|
||||
expect(readPosts()).toHaveLength(1);
|
||||
expect(cachedThreadUnread(queryClient, "t-1")).toBe(false);
|
||||
expect(cachedThreadUnread(queryClient, "t-2")).toBe(true);
|
||||
const infinite = queryClient.getQueryData<InfiniteData<AgentThread[]>>([
|
||||
const infinite = queryClient.getQueryData<InfiniteThreadsData>([
|
||||
...INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
{},
|
||||
]);
|
||||
expect(infinite?.pages[0]?.[0]?.unread).toBe(false);
|
||||
expect(infinite?.pages[0]?.threads[0]?.unread).toBe(false);
|
||||
await act(async () => {
|
||||
finish(Response.json({ unread: false, read_version: 3 }));
|
||||
await rs.advanceTimersByTimeAsync(0);
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
|
||||
import {
|
||||
type InfiniteData,
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
} from "@tanstack/react-query";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
@ -21,6 +17,7 @@ rs.mock("@/core/api", () => ({
|
||||
|
||||
import {
|
||||
INFINITE_THREADS_QUERY_KEY_PREFIX,
|
||||
type InfiniteThreadsData,
|
||||
useRenameThread,
|
||||
} from "@/core/threads/hooks";
|
||||
import { DEFAULT_THREAD_SEARCH_PARAMS } from "@/core/threads/thread-search-query";
|
||||
@ -79,8 +76,8 @@ function readInfiniteSearchTitle(
|
||||
queryClient: QueryClient,
|
||||
queryKey: readonly unknown[],
|
||||
) {
|
||||
return queryClient.getQueryData<InfiniteData<AgentThread[]>>(queryKey)
|
||||
?.pages[0]?.[0]?.values.title;
|
||||
return queryClient.getQueryData<InfiniteThreadsData>(queryKey)?.pages[0]
|
||||
?.threads[0]?.values.title;
|
||||
}
|
||||
|
||||
test("stale list responses cannot restore the old title after rename", async () => {
|
||||
@ -109,11 +106,15 @@ test("stale list responses cannot restore the old title after rename", async ()
|
||||
] as const;
|
||||
const originalThread = makeThread(ORIGINAL_TITLE);
|
||||
const staleSearch = createDelayedResult([originalThread]);
|
||||
const staleInfiniteSearch = createDelayedResult([originalThread]);
|
||||
const staleInfiniteSearch = createDelayedResult({
|
||||
threads: [originalThread],
|
||||
hiddenCount: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
queryClient.setQueryData(searchKey, [originalThread]);
|
||||
queryClient.setQueryData<InfiniteData<AgentThread[]>>(infiniteSearchKey, {
|
||||
pages: [[originalThread]],
|
||||
queryClient.setQueryData<InfiniteThreadsData>(infiniteSearchKey, {
|
||||
pages: [{ threads: [originalThread], hiddenCount: 0, hasMore: false }],
|
||||
pageParams: [0],
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user