Files
PaperClipAI/ui/src/hooks/useRecentTasks.test.tsx
T
DottaandClaude Opus 5.5 7e93ccb4b3 perf: fetch recent task summaries in one request (#20389)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board UI sidebar shows the person's recent tasks on every page,
including the Mine inbox
> - The sidebar refreshes those rows through `GET
/companies/:id/issues/summaries`, which accepted at most five IDs
> - `useRecentTasks` sent the chunks one after another, so a 25-task
history cost five sequential requests
> - On staging each request took 3–6 s, so the recent-task rows settled
about 20 s after load and every request competed with the inbox rows
> - This pull request raises the per-request cap to 50 and fetches any
remaining chunks in parallel
> - The benefit is one summaries request for a typical history instead
of a serial chain

## Linked Issues or Issue Description

**What existing behavior does this improve?**
The refresh of the sidebar's recent tasks on page load.

**Subsystem affected**
Board UI (`ui/src/hooks/useRecentTasks.ts`) and the `/issues/summaries`
route bound (`packages/shared`, `server/src/routes/issues.ts`).

**Current behavior**
A history of N recent tasks sends ceil(N/5) summaries requests in
series. Measured on staging on 2026-11-09 for a cold Mine load: five
requests of five IDs, started at 1.0 s, 4.3 s, 8.1 s, 12.3 s and 18.4 s,
each taking 3.3–6.1 s.

**Proposed behavior**
Up to 50 recent tasks are one request. Longer histories send their
chunks at the same time.

**Reason and benefit**
The staging server is saturated, so each extra serial round trip delays
the sidebar and the inbox rows. One request removes four round trips
from a typical load.

## What Changed

- `ISSUE_SUMMARIES_MAX_IDS` is now 50 (was 5). The shared validator, the
route's 400 message, the service guard and the OpenAPI description use
the new bound.
- `useRecentTasks` builds all chunks first and fetches them at most
three at a time (`RECENT_TASK_SUMMARY_CONCURRENCY`). The recent list has
no size cap, so this keeps a long history from becoming a burst. The
cache key, omission handling and pruning are unchanged. A failed chunk
still fails the batch, so nothing is pruned on a partial failure.
- Tests: the route test accepts exactly `MAX` IDs and rejects `MAX + 1`;
a hook test checks that at most three chunks are in flight; the API test
uses the constant; `useRecentTasks` tests cover a 25-task history in one
request and a 103-task history with three chunks in flight together,
with and without a failed chunk.
- New browser check
`tests/perf/collections/recent-task-summaries.spec.ts`: a Mine load with
a 12-task history sends summaries requests that each carry all 12 IDs,
and the server answers 200 with all 12 tasks.

## Verification

- `pnpm verify` (selected Vitest files) passes.
- `server`: `npx vitest run src/__tests__/issue-summaries-routes.test.ts
src/__tests__/issue-display-status.test.ts` passes (embedded
PostgreSQL).
- `ui`: `npx vitest run src/hooks/useRecentTasks.test.tsx
src/api/issue-summaries.test.ts
src/components/task-chat/TaskChatBlockerLinks.test.tsx
src/components/CoordinatorSidebar.test.tsx
src/components/SidebarRecentTasks.test.tsx` passes.
- Browser check against a disposable local instance:
`PAPERCLIP_COLLECTION_PERF_BASE_URL=http://127.0.0.1:<port> pnpm exec
playwright test --config tests/perf/collections/playwright.config.ts
recent-task-summaries.spec.ts`. It passes on this branch. With
`master`'s hook and constant it fails: three requests of 5, 5 and 2 IDs.

## Risks

- Low risk. The route still validates UUIDs, deduplicates, and applies
the same read authorization per row. Blocker attention is computed with
set-based queries, so 50 IDs is the same number of queries as 5.
- During a rolling deploy, a new UI against an old server would get a
400 for a history above five tasks. The server serves the UI bundle, so
both change together on a normal deploy. The sidebar keeps its stored
snapshot when the refresh fails.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, browser). 1M-token context window. Extended
thinking was off.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:42:32 -06:00

338 lines
17 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared";
import { ApiError } from "@/api/client";
import { queryKeys } from "@/lib/queryKeys";
import {
getRecentTasksStorageKey,
readRecentTasks,
recordRecentTask,
RECENT_TASKS_UPDATED_EVENT,
} from "@/lib/recent-tasks";
import { RECENT_TASK_SUMMARY_CONCURRENCY, useRecentTasks } from "./useRecentTasks";
const { getIssue, summaries } = vi.hoisted(() => ({ getIssue: vi.fn(), summaries: vi.fn() }));
vi.mock("@/api/issues", () => ({ issuesApi: { get: getIssue, summaries } }));
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
const task = (updatedAt: number, title: string, status: "todo" | "done" = "todo") => ({
id: "issue-1", companyId: "company-1", identifier: "TASK-1", title, status,
updatedAt: new Date(updatedAt), hiddenAt: null, projectId: null,
});
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
// Node 24 uses jsdom Storage; newer Node versions use the setup's storage shim.
// jsdom's Storage proxy does not support spying on instance methods.
const storageMethods = window.localStorage instanceof window.Storage ? window.Storage.prototype : window.localStorage;
describe("recent task synchronization", () => {
let container: HTMLDivElement;
let root: Root;
let clients: QueryClient[];
let renders: number;
function RecentTasks({ name, companyId = "company-1", userId = "user-1" }: { name: string; companyId?: string; userId?: string }) {
const { entries } = useRecentTasks({ companyId, userId });
renders += 1;
// Fail promptly if a regression restores the cross-cache render loop.
if (renders > 100) throw new Error("Recent Tasks did not settle");
return <div data-tab={name}>{entries.map((entry) => `${entry.title}:${entry.status}`).join(",")}</div>;
}
function client(snapshot?: ReturnType<typeof task>) {
const value = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
if (snapshot) {
value.setQueryData(queryKeys.issues.detail(snapshot.id), snapshot);
value.setQueryData(queryKeys.issues.summaryBatch("company-1", "user-1", [snapshot.id]), [snapshot]);
}
clients.push(value);
return value;
}
async function flush() {
await act(async () => {
for (let index = 0; index < 5; index += 1) await new Promise((resolve) => setTimeout(resolve, 0));
});
}
beforeEach(() => {
localStorage.clear();
getIssue.mockReset();
summaries.mockReset();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
clients = [];
renders = 0;
});
afterEach(async () => {
await act(async () => root.unmount());
clients.forEach((value) => value.clear());
container.remove();
vi.restoreAllMocks();
});
it("settles independent stale and fresh query caches and displays the newer snapshot in both", async () => {
const older = task(10, "Old title");
const newer = task(20, "New title", "done");
recordRecentTask(older, "user-1");
const staleClient = client(older);
const freshClient = client(newer);
const setItem = vi.spyOn(storageMethods, "setItem");
await act(async () => root.render(<>
<QueryClientProvider client={staleClient}><RecentTasks name="stale" /></QueryClientProvider>
<QueryClientProvider client={freshClient}><RecentTasks name="fresh" /></QueryClientProvider>
</>));
await flush();
expect(container.querySelector('[data-tab="stale"]')?.textContent).toBe("New title:done");
expect(container.querySelector('[data-tab="fresh"]')?.textContent).toBe("New title:done");
expect(setItem).toHaveBeenCalledTimes(1);
expect(getIssue).not.toHaveBeenCalled();
const settledRenders = renders;
await flush();
expect(renders).toBe(settledRenders);
// A real query update still propagates after the two caches settle.
await act(async () => {
freshClient.setQueryData(queryKeys.issues.detail(newer.id), task(30, "Renamed", "done"));
});
await flush();
expect(container.querySelector('[data-tab="stale"]')?.textContent).toBe("Renamed:done");
expect(readRecentTasks(storageKey, "company-1")[0]?.snapshotUpdatedAt).toBe(30);
expect(setItem).toHaveBeenCalledTimes(2);
});
it("receives storage updates without republishing cached data", async () => {
const cached = task(20, "Cached", "done");
recordRecentTask(cached, "user-1");
await act(async () => root.render(
<QueryClientProvider client={client(cached)}><RecentTasks name="receiver" /></QueryClientProvider>,
));
await flush();
// Simulate a concurrent write from another tab with an older snapshot.
const incoming = { ...readRecentTasks(storageKey, "company-1")[0], title: "Older", snapshotUpdatedAt: 10 };
localStorage.setItem(storageKey, JSON.stringify([incoming]));
const setItem = vi.spyOn(storageMethods, "setItem");
const publish = vi.fn();
window.addEventListener(RECENT_TASKS_UPDATED_EVENT, publish);
try {
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: storageKey })); });
await flush();
expect(container.textContent).toBe("Cached:done");
expect(setItem).not.toHaveBeenCalled();
expect(publish).not.toHaveBeenCalled();
} finally {
window.removeEventListener(RECENT_TASKS_UPDATED_EVENT, publish);
}
});
it("ignores writes from the old application after migration", async () => {
const legacyKey = "paperclip.recentTasks:company-1:user-1";
localStorage.setItem(legacyKey, JSON.stringify([{ ...task(10, "Legacy"), recordedAt: 100 }]));
await act(async () => root.render(
<QueryClientProvider client={client(task(20, "Current", "done"))}><RecentTasks name="current" /></QueryClientProvider>,
));
await flush();
expect(container.textContent).toBe("Current:done");
const settledRenders = renders;
await act(async () => {
localStorage.setItem(legacyKey, "[]");
window.dispatchEvent(new StorageEvent("storage", { key: legacyKey }));
window.dispatchEvent(new CustomEvent(RECENT_TASKS_UPDATED_EVENT, { detail: { storageKey: legacyKey, entries: [] } }));
});
await flush();
expect(renders).toBe(settledRenders);
expect(container.textContent).toBe("Current:done");
expect(readRecentTasks(storageKey, "company-1")[0]?.recordedAt).toBe(100);
});
it.each([403, 500])("retains snapshots when the batch fails with %s", async (status) => {
recordRecentTask(task(10, "Unavailable"), "user-1");
summaries.mockRejectedValue(new ApiError("Unavailable", status, null));
await act(async () => root.render(
<QueryClientProvider client={client()}><RecentTasks name="unavailable" /></QueryClientProvider>,
));
await flush();
expect(readRecentTasks(storageKey, "company-1")).toHaveLength(1);
expect(container.textContent).toBe("Unavailable:todo");
expect(summaries).toHaveBeenCalledTimes(1);
expect(getIssue).not.toHaveBeenCalled();
});
it("fetches five tasks once without activating detail queries", async () => {
const tasks = Array.from({ length: 5 }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${i}` }));
tasks.forEach(issue => recordRecentTask(issue, "user-1"));
summaries.mockResolvedValue(tasks);
const cache = client();
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="batch" /></QueryClientProvider>));
await flush();
expect(summaries).toHaveBeenCalledTimes(1);
expect(summaries.mock.calls[0][1]).toEqual(tasks.map(issue => issue.id).sort());
expect(getIssue).not.toHaveBeenCalled();
expect(cache.getQueryCache().findAll({ queryKey: ["issues", "detail"] })).toEqual([]);
});
it("does not fetch empty history", async () => {
await act(async () => root.render(<QueryClientProvider client={client()}><RecentTasks name="empty" /></QueryClientProvider>));
await flush();
expect(summaries).not.toHaveBeenCalled();
});
it("fetches a 25-task history in one request", async () => {
const tasks = Array.from({ length: 25 }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${String(i).padStart(2, "0")}` }));
tasks.forEach(issue => recordRecentTask(issue, "user-1"));
summaries.mockResolvedValue(tasks);
await act(async () => root.render(<QueryClientProvider client={client()}><RecentTasks name="staging-size" /></QueryClientProvider>));
await flush();
expect(summaries).toHaveBeenCalledTimes(1);
expect(summaries.mock.calls[0][1]).toHaveLength(25);
});
it.each([false, true])("refreshes long histories in parallel bounded requests without pruning on a partial failure (%s)", async fails => {
// Three chunks, all in flight together within the concurrency limit.
const count = ISSUE_SUMMARIES_MAX_IDS * 2 + 3;
const tasks = Array.from({ length: count }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${String(i).padStart(3, "0")}` }));
tasks.forEach(issue => recordRecentTask(issue, "user-1"));
const pending: Array<() => void> = [];
summaries.mockImplementation(async (_company, ids: string[]) => {
expect(ids.length).toBeLessThanOrEqual(ISSUE_SUMMARIES_MAX_IDS);
const call = summaries.mock.calls.length;
// Each chunk waits until the test releases it, so all three must be in flight together.
await new Promise<void>(resolve => pending.push(resolve));
if (fails && call === 2) throw new ApiError("Unavailable", 500, null);
return tasks.filter(issue => ids.includes(issue.id)).map(issue => ({ ...issue, title: `Refreshed ${issue.title}`, updatedAt: new Date(10_000) }));
});
await act(async () => root.render(<QueryClientProvider client={client()}><RecentTasks name="long-history" /></QueryClientProvider>));
await flush();
expect(summaries).toHaveBeenCalledTimes(3);
expect(pending).toHaveLength(3);
pending.forEach(resolve => resolve());
await flush();
const entries = readRecentTasks(storageKey, "company-1");
expect(entries).toHaveLength(count);
expect(entries.every(entry => entry.title.startsWith("Refreshed"))).toBe(!fails);
expect(getIssue).not.toHaveBeenCalled();
});
it("limits how many summary chunks are in flight at once", async () => {
const count = ISSUE_SUMMARIES_MAX_IDS * 5;
const tasks = Array.from({ length: count }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${String(i).padStart(3, "0")}` }));
tasks.forEach(issue => recordRecentTask(issue, "user-1"));
const pending: Array<() => void> = [];
summaries.mockImplementation(async (_company, ids: string[]) => {
await new Promise<void>(resolve => pending.push(resolve));
return tasks.filter(issue => ids.includes(issue.id));
});
await act(async () => root.render(<QueryClientProvider client={client()}><RecentTasks name="many" /></QueryClientProvider>));
await flush();
expect(summaries).toHaveBeenCalledTimes(RECENT_TASK_SUMMARY_CONCURRENCY);
while (pending.length) {
pending.splice(0).forEach(resolve => resolve());
await flush();
}
expect(summaries).toHaveBeenCalledTimes(5);
expect(readRecentTasks(storageKey, "company-1")).toHaveLength(count);
});
it("prunes successful omissions without resurrecting cached full details", async () => {
const missing = task(10, "Removed");
recordRecentTask(missing, "user-1");
const cache = client();
cache.setQueryData(queryKeys.issues.detail(missing.id), task(30, "Cached secret"));
summaries.mockResolvedValue([]);
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="missing" /></QueryClientProvider>));
await flush();
expect(readRecentTasks(storageKey, "company-1")).toEqual([]);
expect(container.textContent).toBe("");
});
it("does not prune a task added while an older batch is pending", async () => {
recordRecentTask(task(10, "Missing old task"), "user-1");
let resolveFirst!: (rows: unknown[]) => void;
summaries.mockReturnValueOnce(new Promise(resolve => { resolveFirst = resolve; }))
.mockReturnValue(new Promise(() => {}));
await act(async () => root.render(<QueryClientProvider client={client()}><RecentTasks name="race" /></QueryClientProvider>));
const added = { ...task(20, "New task"), id: "issue-2" };
await act(async () => { recordRecentTask(added, "user-1"); });
await act(async () => { resolveFirst([]); });
await flush();
expect(readRecentTasks(storageKey, "company-1").some(entry => entry.id === added.id)).toBe(true);
expect(container.textContent).toContain("New task");
});
it("isolates an account switch while the old request is pending", async () => {
recordRecentTask(task(10, "Old account task"), "user-1");
recordRecentTask({ ...task(20, "New account task"), id: "issue-2" }, "user-2");
let resolveOld!: (rows: unknown[]) => void;
summaries.mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve; }))
.mockResolvedValue([{ ...task(20, "New account task"), id: "issue-2" }]);
const cache = client();
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="account" /></QueryClientProvider>));
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="account" userId="user-2" /></QueryClientProvider>));
await act(async () => { resolveOld([task(30, "Old response")]); });
await flush();
expect(container.textContent).toBe("New account task:todo");
expect(readRecentTasks(getRecentTasksStorageKey("company-1", "user-2"), "company-1")[0]?.id).toBe("issue-2");
expect(summaries.mock.calls.map(call => call[1])).toEqual([["issue-1"], ["issue-2"]]);
});
it("refreshes a cached omission when the user revisits an accessible task", async () => {
const first = task(10, "First");
const second = { ...task(20, "Second"), id: "issue-2" };
recordRecentTask(first, "user-1");
recordRecentTask(second, "user-1");
summaries.mockResolvedValue([second]);
const cache = client();
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="revisited" /></QueryClientProvider>));
await flush();
expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id)).toEqual([second.id]);
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
expect(cache.getQueryState(queryKeys.issues.summaryBatch("company-1", "user-1", [second.id]))?.status).toBe("success");
summaries.mockResolvedValue([first, second]);
await act(async () => { recordRecentTask(first, "user-1", 30); });
await flush();
expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id).sort()).toEqual([first.id, second.id]);
expect(summaries.mock.calls.filter(call => call[1].length === 2)).toHaveLength(2);
});
it("keeps a revisited task when refreshing its cached omission fails", async () => {
const first = task(10, "First");
const second = { ...task(20, "Second"), id: "issue-2" };
recordRecentTask(first, "user-1");
recordRecentTask(second, "user-1");
summaries.mockResolvedValue([second]);
const cache = client();
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="retry-failed" /></QueryClientProvider>));
await flush();
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
summaries.mockRejectedValue(new ApiError("Unavailable", 500, null));
await act(async () => { recordRecentTask(first, "user-1", 30); });
await flush();
expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id).sort()).toEqual([first.id, second.id]);
expect(cache.getQueryState(queryKeys.issues.summaryBatch("company-1", "user-1", [first.id, second.id]))?.status).toBe("error");
expect(summaries.mock.calls.filter(call => call[1].length === 2)).toHaveLength(2);
});
it("prunes a revisited task when a fresh response repeats the cached omission", async () => {
const first = task(10, "First");
const second = { ...task(20, "Second"), id: "issue-2" };
recordRecentTask(first, "user-1");
recordRecentTask(second, "user-1");
const rows = [second];
summaries.mockResolvedValue(rows);
const cache = client();
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="still-missing" /></QueryClientProvider>));
await flush();
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
await act(async () => { recordRecentTask(first, "user-1", 30); });
await flush();
expect(summaries.mock.calls.filter(call => call[1].length === 2)).toHaveLength(2);
expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id)).toEqual([second.id]);
});
});