From 14211e1f33db736209e58c4af1c875d68b5885d6 Mon Sep 17 00:00:00 2001 From: Qiyuanqiii <267806965+Qiyuanqiii@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:32:37 +0800 Subject: [PATCH] perf(workbench): pause task polling while browsing history --- ...-title-search-and-execution-observation.md | 7 ++ docs/usage.md | 3 + web/src/v2/app.tsx | 7 +- web/src/v2/task-search.test.tsx | 89 +++++++++++++++++++ 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/docs/adr/0167-task-title-search-and-execution-observation.md b/docs/adr/0167-task-title-search-and-execution-observation.md index 136bd58e..f951659d 100644 --- a/docs/adr/0167-task-title-search-and-execution-observation.md +++ b/docs/adr/0167-task-title-search-and-execution-observation.md @@ -67,6 +67,13 @@ than stale idle. A list refresh retrieves statuses together; rows never poll the own execution endpoints. Loading, no matches, failure/retry, more pages and truncation remain distinct states. +While the conversation sidebar is visible, a single cached page polls every 15 +seconds. Loading additional pages pauses periodic reads because an infinite-query +refresh rereads every cached page sequentially. Historical rows show the last +observation until a manual refresh or an existing invalidation, focus or reconnect +refresh updates them. Switching to a single-page query resumes polling without +discarding historical pages, changing the selected task or replacing the draft. + ## Alternatives, validation and rollback Client-only filtering cannot discover unloaded matches. Polling each row adds diff --git a/docs/usage.md b/docs/usage.md index 455efc2a..a3d00115 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -696,6 +696,9 @@ During an attempt, `run events` may contain at most 32 ordered `model.delta` rec 列表同时返回 Go 投影的 `execution_state`,区分执行中、停止中、等待审批、暂停、空闲和 终态。输入框可编辑(`composer_state=ready`)不表示空闲。状态读取失败、缺少执行观察源 或存在未确认的执行归属时显示“状态未知”;侧栏统一刷新列表,不逐行请求执行状态。 +对话侧栏可见且只缓存一页时,每 15 秒自动刷新;加载多页历史后暂停定时刷新,状态代表 +最近一次读取结果。可点击“刷新对话列表”更新已加载页,任务变更、重新聚焦或恢复连接 +仍沿用现有刷新机制。切换到只缓存一页的搜索结果时恢复定时刷新,已加载的历史页与草稿保留。 公开合同与兼容性见 [ADR 0167](adr/0167-task-title-search-and-execution-observation.md)。 打开 `/threads/{thread_id}` 时,页面从 `GET /api/v1/threads/{thread_id}/transcript` diff --git a/web/src/v2/app.tsx b/web/src/v2/app.tsx index 89dd6ae5..3f869a97 100644 --- a/web/src/v2/app.tsx +++ b/web/src/v2/app.tsx @@ -281,9 +281,10 @@ function V2WorkbenchContent({ client }: { client: APIClient }) { { limit: 100, status: "active", ...(appliedThreadSearch ? { q: appliedThreadSearch } : {}) }, pageParam, signal), initialPageParam: "", getNextPageParam: (last) => last.page.next_cursor || undefined, enabled: !searchPending && !searchTooLong, - // One bounded list refresh keeps row states current without per-task reads. - // Searching never loads older pages until the user requests them. - refetchInterval: sidebarVisible && surface === "conversation" && !searchPending ? 15_000 : false, + // Infinite queries refetch every cached page. Poll only a single page; + // history still refreshes explicitly and through the existing invalidations. + refetchInterval: (query) => sidebarVisible && surface === "conversation" && !searchPending && + (query.state.data?.pages.length ?? 1) === 1 ? 15_000 : false, }); const workspaces = useMemo(() => [...new Map([...importedWorkspaces, ...(workspacesQuery.data?.pages.flatMap(({ items }) => items) ?? [])] diff --git a/web/src/v2/task-search.test.tsx b/web/src/v2/task-search.test.tsx index 78032ec0..55407e33 100644 --- a/web/src/v2/task-search.test.tsx +++ b/web/src/v2/task-search.test.tsx @@ -284,6 +284,95 @@ describe("V2 task title search", () => { expect(queryClient.getQueryState(archived)?.isInvalidated).toBe(false); }); + it.each(["", "task"])("pauses polling across five history pages for query %j while preserving explicit refreshes", async (title) => { + vi.useFakeTimers(); + let execution_state: ThreadView["execution_state"] = "running"; + const tasks = [current, ...Array.from({ length: 4 }, (_, index) => ({ + ...current, id: `history-${index + 1}`, title: `History task ${index + 1}`, + }))]; + const { client, queryClient, listCalls } = start((_query, cursor) => { + const index = cursor ? Number(cursor.slice("history-".length)) : 0; + return page([{ ...tasks[index], execution_state }], index < tasks.length - 1 ? `history-${index + 1}` : ""); + }); + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + fireEvent.change(screen.getByRole("textbox", { name: "当前任务草稿" }), { target: { value: "历史浏览中的草稿" } }); + if (title) { + fireEvent.click(screen.getByRole("button", { name: "搜索" })); + fireEvent.change(screen.getByRole("searchbox", { name: "搜索对话" }), { target: { value: title } }); + await act(async () => { await vi.advanceTimersByTimeAsync(251); }); + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + } + for (let index = 1; index < tasks.length; index++) { + fireEvent.click(screen.getByRole("button", { name: title ? "加载更多匹配对话" : "加载更早对话" })); + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + expect(screen.getByRole("button", { name: tasks[index].title })).toBeInTheDocument(); + } + const loadedCalls = listCalls().length; + expect(loadedCalls).toBe(tasks.length + (title ? 1 : 0)); + execution_state = "completed"; + await act(async () => { await vi.advanceTimersByTimeAsync(45_000); }); + expect(listCalls()).toHaveLength(loadedCalls); + expect(screen.getByRole("button", { name: current.title })).toHaveAccessibleDescription("执行中"); + + fireEvent.click(screen.getByRole("button", { name: "刷新对话列表" })); + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + const cursors = ["", "history-1", "history-2", "history-3", "history-4"]; + expect(listCalls().slice(loadedCalls).map((call) => call[2])).toEqual(cursors); + for (const task of tasks) { + expect(screen.getByRole("button", { name: task.title })).toHaveAccessibleDescription("已完成"); + } + execution_state = "paused"; + await act(async () => { + await queryClient.invalidateQueries({ queryKey: v2QueryKeys.threads("active") }); + await vi.advanceTimersByTimeAsync(1); + }); + expect(listCalls().slice(loadedCalls + tasks.length).map((call) => call[2])).toEqual(cursors); + for (const task of tasks) { + expect(screen.getByRole("button", { name: task.title })).toHaveAccessibleDescription("已暂停"); + } + await act(async () => { await vi.advanceTimersByTimeAsync(45_000); }); + expect(listCalls()).toHaveLength(loadedCalls + tasks.length * 2); + expect(client.get).not.toHaveBeenCalled(); + expect(screen.getByRole("textbox", { name: "当前任务草稿" })).toHaveValue("历史浏览中的草稿"); + expect(screen.getByTestId("search-current-task")).toHaveTextContent(current.id); + expect(window.location.hash).toBe(`#/threads/${current.id}`); + }); + + it("resumes single-page search polling and pauses again when restoring cached history", async () => { + vi.useFakeTimers(); + let execution_state: ThreadView["execution_state"] = "running"; + const older = { ...current, id: "older-history", title: "Older history" }; + const match = { ...current, id: "single-match", title: "Single needle" }; + const { client, listCalls } = start((query, cursor) => query.q + ? page([{ ...match, execution_state }]) + : page([{ ...(cursor ? older : current), execution_state }], cursor ? "" : "older-page")); + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + fireEvent.click(screen.getByRole("button", { name: "加载更早对话" })); + await act(async () => { await vi.advanceTimersByTimeAsync(45_001); }); + expect(listCalls()).toHaveLength(2); + + fireEvent.click(screen.getByRole("button", { name: "搜索" })); + fireEvent.change(screen.getByRole("searchbox", { name: "搜索对话" }), { target: { value: "needle" } }); + await act(async () => { await vi.advanceTimersByTimeAsync(251); }); + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + expect(listCalls()).toHaveLength(3); + expect(screen.getByRole("button", { name: match.title })).toHaveAccessibleDescription("执行中"); + execution_state = "completed"; + await act(async () => { await vi.advanceTimersByTimeAsync(15_000); }); + expect(listCalls()).toHaveLength(4); + expect(listCalls().at(-1)?.slice(0, 3)).toEqual(["/threads", { limit: 100, status: "active", q: "needle" }, ""]); + expect(screen.getByRole("button", { name: match.title })).toHaveAccessibleDescription("已完成"); + + fireEvent.click(screen.getByRole("button", { name: "清除对话搜索" })); + await act(async () => { await vi.advanceTimersByTimeAsync(1); }); + expect(listCalls().slice(4).map((call) => call[2])).toEqual(["", "older-page"]); + expect(screen.getByRole("button", { name: older.title })).toBeInTheDocument(); + await act(async () => { await vi.advanceTimersByTimeAsync(45_000); }); + expect(listCalls()).toHaveLength(6); + expect(client.get).not.toHaveBeenCalled(); + expect(screen.getByTestId("search-current-task")).toHaveTextContent(current.id); + }); + it("refreshes all visible rows with one list request and stops periodic reads when the sidebar is hidden", async () => { vi.useFakeTimers(); let execution_state: ThreadView["execution_state"] = "running";