mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-28 00:48:07 +00:00
* feat(frontend): pin recent chats * fix(threads): address pin-chat review feedback - Stop bumping updated_at on metadata-only PATCH (pin/unpin) via a new update_metadata(touch=False) path so unpinning no longer jumps a chat to the top of the updated_at-sorted recent list. - Narrow patchThreadMetadata to a ThreadMetadataPatchResponse matching the Gateway's actual response (no values/context). - Namespace the pinned metadata key as deerflow_pinned for consistency with deerflow_sidecar / deerflow_branch. - Cover touch/touch=False behavior in repo + router tests; document the e2e mock's updated_at preservation now mirrors production. * style(frontend): format thread utils test * fix(threads): make pinned ordering server-side * test(frontend): keep infinite-scroll fixture order stable * test(frontend): stabilize lark reconnect e2e * docs: clarify thread pin metadata contract
126 lines
4.5 KiB
TypeScript
126 lines
4.5 KiB
TypeScript
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. Keep updated_at
|
|
// monotonically descending to match the backend's updated_at-desc search
|
|
// order, 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: new Date(
|
|
Date.UTC(2025, 5, 30, 12, 0, 0) - i * 60_000,
|
|
).toISOString(),
|
|
};
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|