Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/adr/0167-task-title-search-and-execution-observation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
7 changes: 4 additions & 3 deletions web/src/v2/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? [])]
Expand Down
89 changes: 89 additions & 0 deletions web/src/v2/task-search.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading