mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-28 14:10:37 +00:00
* 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>
194 lines
6.4 KiB
TypeScript
194 lines
6.4 KiB
TypeScript
import { afterEach, expect, rs, test } from "@rstest/core";
|
|
import {
|
|
QueryClient,
|
|
QueryClientProvider,
|
|
useQuery,
|
|
} from "@tanstack/react-query";
|
|
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
import type { PropsWithChildren } from "react";
|
|
|
|
const mocks = rs.hoisted(() => ({ fetch: rs.fn() }));
|
|
rs.mock("@/core/api/fetcher", () => ({ fetch: mocks.fetch }));
|
|
|
|
import { PROJECTS_QUERY_KEY } from "@/core/projects/api";
|
|
import { useArchiveThread } from "@/core/threads/archive";
|
|
import { usePinThread } from "@/core/threads/hooks";
|
|
|
|
const original = {
|
|
thread_id: "chat",
|
|
metadata: { deerflow_pinned: true },
|
|
values: { title: "Report" },
|
|
updated_at: "2026-01-01T00:00:00Z",
|
|
};
|
|
afterEach(() => {
|
|
cleanup();
|
|
rs.clearAllMocks();
|
|
});
|
|
|
|
function setup() {
|
|
const client = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
const key = ["thread", "metadata", "chat", false];
|
|
client.setQueryData(key, original);
|
|
client.setQueryData(["threads", "searchInfinite", { archived: false }], {
|
|
pages: [{ threads: [original], hiddenCount: 0, hasMore: false }],
|
|
pageParams: [0],
|
|
});
|
|
const wrapper = ({ children }: PropsWithChildren) => (
|
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
|
);
|
|
return { client, key, ...renderHook(() => useArchiveThread(), { wrapper }) };
|
|
}
|
|
|
|
test("archive preserves the active snapshot and resets list pagination after success", async () => {
|
|
mocks.fetch.mockResolvedValue(
|
|
new Response(JSON.stringify({ metadata: { deerflow_archived: true } })),
|
|
);
|
|
const { client, key, result } = setup();
|
|
await act(async () => {
|
|
await result.current.mutateAsync({ threadId: "chat", archived: true });
|
|
});
|
|
expect(mocks.fetch).toHaveBeenCalledWith(
|
|
expect.stringContaining("/api/threads/chat"),
|
|
expect.objectContaining({
|
|
method: "PATCH",
|
|
body: JSON.stringify({ metadata: { deerflow_archived: true } }),
|
|
}),
|
|
);
|
|
expect(client.getQueryData(key)).toEqual({
|
|
...original,
|
|
metadata: { deerflow_pinned: true, deerflow_archived: true },
|
|
});
|
|
expect(
|
|
client.getQueryData(["threads", "searchInfinite", { archived: false }]),
|
|
).toBeUndefined();
|
|
client.clear();
|
|
});
|
|
|
|
test("failed archive keeps the visible thread and metadata unchanged", async () => {
|
|
mocks.fetch.mockRejectedValue(new Error("Unavailable"));
|
|
const { client, key, result } = setup();
|
|
await act(async () => {
|
|
await expect(
|
|
result.current.mutateAsync({ threadId: "chat", archived: true }),
|
|
).rejects.toThrow("Unavailable");
|
|
});
|
|
expect(client.getQueryData(key)).toEqual(original);
|
|
expect(
|
|
client.getQueryData(["threads", "searchInfinite", { archived: false }]),
|
|
).toEqual({
|
|
pages: [{ threads: [original], hiddenCount: 0, hasMore: false }],
|
|
pageParams: [0],
|
|
});
|
|
client.clear();
|
|
});
|
|
|
|
test("archive restarts an initial metadata read cancelled by the mutation", async () => {
|
|
const client = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
let reads = 0;
|
|
const wrapper = ({ children }: PropsWithChildren) => (
|
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
|
);
|
|
mocks.fetch.mockResolvedValue(
|
|
new Response(JSON.stringify({ metadata: { deerflow_archived: true } })),
|
|
);
|
|
const { result } = renderHook(
|
|
() => ({
|
|
metadata: useQuery({
|
|
queryKey: ["thread", "metadata", "chat", false],
|
|
queryFn: async () => {
|
|
reads += 1;
|
|
if (reads === 1) return new Promise<typeof original>(() => undefined);
|
|
return {
|
|
...original,
|
|
metadata: { ...original.metadata, deerflow_archived: true },
|
|
};
|
|
},
|
|
}),
|
|
mutation: useArchiveThread(),
|
|
}),
|
|
{ wrapper },
|
|
);
|
|
await waitFor(() => expect(reads).toBe(1));
|
|
await act(async () => {
|
|
await result.current.mutation.mutateAsync({
|
|
threadId: "chat",
|
|
archived: true,
|
|
});
|
|
});
|
|
await waitFor(() =>
|
|
expect(result.current.metadata.data?.metadata).toEqual({
|
|
deerflow_pinned: true,
|
|
deerflow_archived: true,
|
|
}),
|
|
);
|
|
client.clear();
|
|
});
|
|
|
|
test("a late pin response cannot roll back the confirmed archive flag", async () => {
|
|
const { client, key, result: archive } = setup();
|
|
const wrapper = ({ children }: PropsWithChildren) => (
|
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
|
);
|
|
const { result: pin } = renderHook(() => usePinThread(), { wrapper });
|
|
let finishPin!: (value: Response) => void;
|
|
mocks.fetch.mockImplementationOnce(
|
|
() =>
|
|
new Promise<Response>((resolve) => {
|
|
finishPin = resolve;
|
|
}),
|
|
);
|
|
let pendingPin!: Promise<unknown>;
|
|
act(() => {
|
|
pendingPin = pin.current.mutateAsync({ threadId: "chat", pinned: true });
|
|
});
|
|
await waitFor(() => expect(finishPin).toBeDefined());
|
|
mocks.fetch.mockResolvedValue(
|
|
new Response(JSON.stringify({ metadata: { deerflow_archived: true } })),
|
|
);
|
|
await act(async () => {
|
|
await archive.current.mutateAsync({ threadId: "chat", archived: true });
|
|
});
|
|
await act(async () => {
|
|
finishPin(
|
|
new Response(
|
|
JSON.stringify({
|
|
metadata: { deerflow_pinned: true, deerflow_archived: false },
|
|
}),
|
|
),
|
|
);
|
|
await pendingPin;
|
|
});
|
|
expect(client.getQueryData(key)).toEqual({
|
|
...original,
|
|
metadata: { deerflow_pinned: true, deerflow_archived: true },
|
|
});
|
|
client.clear();
|
|
});
|
|
|
|
test("archive invalidates project-scoped thread lists on success", async () => {
|
|
mocks.fetch.mockResolvedValue(
|
|
new Response(JSON.stringify({ metadata: { deerflow_archived: true } })),
|
|
);
|
|
const { client, result } = setup();
|
|
const projectListKey = [...PROJECTS_QUERY_KEY, "threads", "proj-1"];
|
|
client.setQueryData(projectListKey, {
|
|
pages: [[{ ...original }]],
|
|
pageParams: [0],
|
|
});
|
|
const invalidate = rs.spyOn(client, "invalidateQueries");
|
|
await act(async () => {
|
|
await result.current.mutateAsync({ threadId: "chat", archived: true });
|
|
});
|
|
// The project page's thread list changes membership with the archive flag;
|
|
// every other thread mutation invalidates this prefix (regression: archive
|
|
// was the lone path leaving an open project page stale).
|
|
const keys = invalidate.mock.calls.map(([filters]) => filters?.queryKey);
|
|
expect(keys).toContainEqual([...PROJECTS_QUERY_KEY, "threads"]);
|
|
expect(client.getQueryState(projectListKey)?.isInvalidated).toBe(true);
|
|
client.clear();
|
|
});
|