mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 17:22:00 +01:00
## 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>
43 lines
2.2 KiB
TypeScript
43 lines
2.2 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import { collectionFixture, type CollectionFixture } from "./fixture";
|
|
|
|
let fixture: CollectionFixture;
|
|
test.beforeAll(async ({ request }) => {
|
|
fixture = await collectionFixture(request);
|
|
});
|
|
|
|
// A 12-task history used to cost three serial five-ID summary requests on
|
|
// every page load. It must be one request.
|
|
test("Mine refreshes a recent-task history with one summaries request", async ({ page, request }) => {
|
|
const session = await (await request.get("/api/auth/get-session")).json().catch(() => null);
|
|
const userId: string | null = session?.user?.id ?? session?.session?.userId ?? null;
|
|
const companyId = fixture.company.id;
|
|
const taskIds = fixture.taskIds.slice(0, 12);
|
|
await page.addInitScript(({ companyId, userId, taskIds }) => {
|
|
const entries = taskIds.map((id, index) => ({
|
|
id, companyId, title: `Recent ${index}`, identifier: null, status: "backlog", recordedAt: Date.now() - index * 1000,
|
|
}));
|
|
for (const owner of new Set([userId ?? "__local_board__", "__local_board__"])) {
|
|
localStorage.setItem(`paperclip.recentTasks.v2:${companyId}:${owner}`, JSON.stringify(entries));
|
|
}
|
|
}, { companyId, userId, taskIds });
|
|
const summaries: URL[] = [];
|
|
page.on("request", item => {
|
|
const url = new URL(item.url());
|
|
if (url.pathname === `/api/companies/${companyId}/issues/summaries`) summaries.push(url);
|
|
});
|
|
const response = page.waitForResponse(item => new URL(item.url()).pathname === `/api/companies/${companyId}/issues/summaries`);
|
|
await page.goto(`/${fixture.company.issuePrefix}/issues?view=mine`);
|
|
// The server must accept the whole batch, not just receive it.
|
|
const first = await response;
|
|
expect(first.status()).toBe(200);
|
|
const rows = await first.json() as Array<{ id: string }>;
|
|
expect(rows.map(row => row.id).sort()).toEqual([...taskIds].sort());
|
|
// Allow any follow-up chunk to start before counting.
|
|
await page.waitForTimeout(1_500);
|
|
// The live-updates socket may refresh the batch once after it opens; that
|
|
// repeats the same request. Chunked requests would each carry fewer IDs.
|
|
for (const url of summaries) expect(url.searchParams.get("ids")!.split(",")).toHaveLength(12);
|
|
expect(summaries.length).toBeLessThanOrEqual(2);
|
|
});
|