mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The board UI sidebar has a Recents list of the tasks a person opened
last
> - The Recents list renders from a local snapshot, but its links had no
warm-up. A click waited for the task-detail code and about nine
first-frame reads
> - Task lists (Inbox, Tasks) already warm a task on hover, press and
click, and #20355 added a batched `chat-bootstrap` read that had no UI
consumer
> - The hosted database is CPU-bound, so a fix must add at most one read
per page load and must not compete with the page's own reads
> - This pull request gives Recents the same open-intent prefetch as
task lists, warms the task-detail code, and preloads the top ten recent
tasks with one `chat-bootstrap` request after the page is idle
> - The benefit is that a click on a recent task shows its chat from
cache, without a wait for the API
## Linked Issues or Issue Description
Refs #20355 (server read this PR consumes). Builds on #20389, #20398 and
#20419.
**Is your feature request related to a problem?**
Opening a task from the sidebar's Recents was slower than opening it
from a task list. The Recents links had no prefetch. The task page
waited for its first-frame reads after the click.
**Describe the solution you'd like**
Recents links use the task lists' open-intent prefetch. When the page is
idle, one batched request loads the first frame of the most recent tasks
into the task page's caches.
**Describe alternatives you've considered**
Per-task prefetch of every recent task. That makes up to ten times nine
requests per page load, which the hosted database cannot afford.
**Additional context**
I searched open PRs and remote branches for "chat-bootstrap" and
"instant task opens". The only related work is the server-side branches
that merged as #20355. There is no other in-flight UI consumer of
`chat-bootstrap`.
## What Changed
- `ui/src/hooks/useIssueNavigationPrefetch.ts`: the open-intent handlers
from `IssueLinkQuicklook` move into one hook (light warm on pass-over;
`history: true` on a resting hover or focus, a mouse or pen press, and a
click). `IssueLinkQuicklook` now uses the hook. Its behavior does not
change.
- `ui/src/components/RecentTaskNavItem.tsx`: one Recents link wrapper
that applies the hook. The classic list (`SidebarRecentTasks`), the
Coordinator task rows and `DesignSidebar` use it. `SidebarNavItem`
accepts the handlers through a new optional `linkHandlers` prop.
- Classic Recents links now open `/issues/<identifier>` (Coordinator
already did). The old UUID link remounted the task page after a redirect
to the identifier route.
- `ui/src/hooks/useRecentTaskOpenWarmup.ts`: when Recents shows a task,
it fires the task-detail code warm-up (`useWarmTaskDetailCode`). After
two frames, a visible tab, no fetching queries and an idle callback, it
sends one `issues/chat-bootstrap?ids=` request for the ten most recent
tasks. It skips tasks whose first frame is cached and fresh. It sends no
request for an empty list or a hidden tab. It sends at most one request
per account per page load. Unmount or an account change aborts the
request.
- `ui/src/lib/issueChatBootstrapCache.ts`: `seedIssueChatBootstrap`
writes each field under the keys the task page reads, for the requested
ref, the id and the identifier: core issue, recent comments, comment
input facts (one batch, through the new `seedIssueIdBatch`), recent
activity, the recent run window (through the new shared
`buildRecentIssueRunWindow`), interactions, attachments, live runs and
active run. An explicit `null` active run is stored. A missing field is
not stored. It does not replace data that is as new or newer, data with
a read in flight, or data with a pending invalidation. Keys whose page
merges observed history (comments, activity, runs, input facts) are
filled only when empty. Items for another company are ignored.
- The seed fills the `?view=core` key, not the full detail key.
`seedIssueDetailCache` is not used: the core payload cannot stand in for
the detail read's related data, and seeding it there would suppress that
read.
- `issuesApi.chatBootstrap` client method.
- `tests/perf/collections/recent-task-open.spec.ts` and its README
section.
## Verification
Local disposable instance (`local_trusted`, production UI build,
embedded PostgreSQL, collection fixture plus 12 comments on each of 8
recent tasks, streamlined UI with the Coordinator sidebar). Journey:
open Projects with 8 Recents, wait for network idle plus 3 s, click a
recent task, measure until its latest comment is on screen. Each run
uses a fresh browser context (empty HTTP cache and storage apart from
Recents). Median of 5 runs, same server and fixture for both arms:
| Arm | Click to first chat frame | Idle page API requests |
| --- | --- | --- |
| `master` (b6f4008f9b), loopback | 227 ms (first run: 249 ms) | 24
(range 21–25) |
| This PR, loopback | 134 ms | 22 (range 21–27) |
| `master`, +150 ms on each API response | 455 ms | 19 |
| This PR, +150 ms on each API response | 135 ms | 20 (+1
`chat-bootstrap`) |
With added latency, the first frame no longer depends on API latency.
- `PAPERCLIP_COLLECTION_PERF_BASE_URL=http://127.0.0.1:<port> pnpm exec
playwright test --config tests/perf/collections/playwright.config.ts
recent-task-open.spec.ts recent-task-summaries.spec.ts`: 5 passed. On
`master` the two assertion cases fail (0 `chat-bootstrap` requests; 10
first-frame reads after the click).
- Reads that still follow a click on a preloaded task: full detail (`GET
/issues/:id`), `work-products`, `documents`, `feedback-votes`,
`tree-control/state`, `tree-holds`, `runner-goal`, `browsers`, and the
`read` mark. The `chat-bootstrap` payload does not cover them. None
gates the chat's first frame (the frame shows at 135 ms while every API
response waits 150 ms).
- Unit tests: `RecentTaskNavItem.test.tsx` (intent levels, href),
`useRecentTaskOpenWarmup.test.tsx` (one request for the top ten, code
warm-up, wait for page reads, hidden tab, fresh entries skipped, once
per account, user isolation, abort on unmount),
`issueChatBootstrapCache.test.ts` (each key in the endpoint shape,
`null` versus missing fields, company isolation, no downgrade of newer,
in-flight or invalidated data, merge keys filled only when empty).
- `ui` Vitest: `src/components src/hooks src/lib src/api` 694 files
passed; `src/pages src/App src/context` 188 files passed. `pnpm
check:token-gates`: clean. `pnpm verify` exits 2 because a `tests/` file
is a global CI input; required CI supplies the broad coverage.
## Risks
- Low to medium. One extra read per page load when Recents has tasks
without a fresh cached first frame. The read starts only after the
page's own queries settle, and the server reads at most two tasks at a
time.
- A live event that arrives while the preload is in flight, for a task
with no cached keys, can leave seeded data up to 30 s old until the next
live event or the next open after the stale time. Inbox hover prefetch
has the same window.
- Classic Recents links now use the identifier route instead of the UUID
route. The task page already redirected UUID links to this route.
- `tests/perf/collections` is a manual suite. CI does not run it.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with
tool use (shell, file edits, browser automation through Playwright).
Standard context window.
## 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
- [ ] 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>
184 lines
9.2 KiB
TypeScript
184 lines
9.2 KiB
TypeScript
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
|
|
import { collectionFixture, type CollectionFixture } from "./fixture";
|
|
|
|
// Opening a task from the sidebar's Recents. A page load with Recents makes at
|
|
// most one batched chat-bootstrap read once the page is idle, and no per-task
|
|
// first-frame reads. Clicking a preloaded task then shows its chat from cache.
|
|
|
|
const RECENT_COUNT = 8;
|
|
const COMMENTS_PER_TASK = 12;
|
|
const IDLE_SETTLE_MS = 3_000;
|
|
|
|
type RecentTask = { id: string; identifier: string; title: string; marker: string };
|
|
let fixture: CollectionFixture;
|
|
let recents: RecentTask[];
|
|
|
|
async function ensureRecentTasks(request: APIRequestContext): Promise<RecentTask[]> {
|
|
const tasks: RecentTask[] = [];
|
|
for (const id of fixture.taskIds.slice(0, RECENT_COUNT)) {
|
|
const issueResponse = await request.get(`/api/issues/${id}?view=core`);
|
|
expect(issueResponse.ok(), await issueResponse.text()).toBeTruthy();
|
|
const issue = await issueResponse.json() as { id: string; identifier: string; title: string };
|
|
// No identifier in the marker: the chat would turn it into a task link.
|
|
const marker = `Recent chat marker ${issue.id.slice(0, 8)}`;
|
|
const recent = await (await request.get(`/api/issues/${id}/recent-comments`)).json() as { comments: Array<{ body: string }> };
|
|
if (!recent.comments.some(comment => comment.body === marker)) {
|
|
for (let index = recent.comments.length; index < COMMENTS_PER_TASK - 1; index += 1) {
|
|
const created = await request.post(`/api/issues/${id}/comments`, { data: { body: `Saved context ${index} for ${issue.identifier}. `.repeat(8) } });
|
|
expect(created.ok(), await created.text()).toBeTruthy();
|
|
}
|
|
const created = await request.post(`/api/issues/${id}/comments`, { data: { body: marker } });
|
|
expect(created.ok(), await created.text()).toBeTruthy();
|
|
}
|
|
tasks.push({ id: issue.id, identifier: issue.identifier, title: issue.title, marker });
|
|
}
|
|
return tasks;
|
|
}
|
|
|
|
async function seedRecents(page: Page, request: APIRequestContext) {
|
|
const session = await (await request.get("/api/auth/get-session")).json().catch(() => null);
|
|
const userId: string | null = session?.user?.id ?? session?.session?.userId ?? null;
|
|
await page.addInitScript(({ companyId, userId, recents }) => {
|
|
const entries = recents.map((task, index) => ({
|
|
id: task.id, companyId, title: task.title, identifier: task.identifier, 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: fixture.company.id, userId, recents });
|
|
}
|
|
|
|
/** Per-task reads that gate the chat's first frame, plus the full detail read. */
|
|
function classifyTaskRead(url: URL): string | null {
|
|
const match = url.pathname.match(/^\/api\/issues\/([^/]+)(\/.*)?$/);
|
|
if (!match) return null;
|
|
const rest = match[2] ?? "";
|
|
const view = url.searchParams.get("view");
|
|
if (rest === "" || rest === "/") return view === "core" ? "core" : "detail";
|
|
if (rest === "/recent-comments") return "recent-comments";
|
|
if (rest === "/activity" && view === "recent") return "activity";
|
|
if (rest === "/activity" && view === "comment-inputs") return "comment-inputs";
|
|
if (rest === "/runs" && view === "recent") return "runs";
|
|
if (rest === "/interactions") return "interactions";
|
|
if (rest === "/attachments") return "attachments";
|
|
if (rest === "/live-runs") return "live-runs";
|
|
if (rest === "/active-run") return "active-run";
|
|
if (rest === "/chat-bootstrap") return "chat-bootstrap-single";
|
|
return `other:${rest}`;
|
|
}
|
|
const FIRST_FRAME_READS = new Set(["core", "recent-comments", "activity", "comment-inputs", "runs", "interactions", "attachments", "live-runs", "active-run"]);
|
|
|
|
function recordRequests(page: Page) {
|
|
const requests: URL[] = [];
|
|
page.on("request", item => {
|
|
const url = new URL(item.url());
|
|
if (url.pathname.startsWith("/api/")) requests.push(url);
|
|
});
|
|
return requests;
|
|
}
|
|
|
|
async function loadIdle(page: Page) {
|
|
await page.goto(`/${fixture.company.issuePrefix}/projects`);
|
|
await expect(page.getByRole("link", { name: recents[0]!.title }).first()).toBeVisible();
|
|
await page.waitForLoadState("networkidle");
|
|
await page.waitForTimeout(IDLE_SETTLE_MS);
|
|
}
|
|
|
|
/** Click a Recents link and return ms from the click to the latest saved comment being on screen. */
|
|
async function openRecent(page: Page, task: RecentTask) {
|
|
await page.evaluate((marker) => {
|
|
const state = window as unknown as { __recentOpen?: { clickedAt?: number; shownAt?: number } };
|
|
state.__recentOpen = {};
|
|
document.addEventListener("pointerdown", () => { state.__recentOpen!.clickedAt ??= performance.now(); }, { capture: true, once: true });
|
|
const check = () => {
|
|
const visible = [...document.querySelectorAll("main *")].some(element =>
|
|
element.childElementCount === 0 && element.textContent?.trim() === marker && (element as HTMLElement).offsetParent !== null);
|
|
if (visible) {
|
|
state.__recentOpen!.shownAt = performance.now();
|
|
return;
|
|
}
|
|
requestAnimationFrame(check);
|
|
};
|
|
requestAnimationFrame(check);
|
|
}, task.marker);
|
|
await page.getByRole("link", { name: task.title }).first().click();
|
|
await expect(page.getByText(task.marker, { exact: true }).first()).toBeVisible({ timeout: 20_000 });
|
|
await page.waitForFunction(() => (window as unknown as { __recentOpen?: { shownAt?: number } }).__recentOpen?.shownAt !== undefined);
|
|
return page.evaluate(() => {
|
|
const state = (window as unknown as { __recentOpen: { clickedAt: number; shownAt: number } }).__recentOpen;
|
|
return state.shownAt - state.clickedAt;
|
|
});
|
|
}
|
|
|
|
function median(values: number[]) {
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
return sorted[Math.floor(sorted.length / 2)]!;
|
|
}
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
fixture = await collectionFixture(request);
|
|
recents = await ensureRecentTasks(request);
|
|
});
|
|
|
|
test("an idle page with Recents makes one chat-bootstrap read and no per-task reads", async ({ page, request }) => {
|
|
await seedRecents(page, request);
|
|
const requests = recordRequests(page);
|
|
await loadIdle(page);
|
|
const bootstraps = requests.filter(url => url.pathname === `/api/companies/${fixture.company.id}/issues/chat-bootstrap`);
|
|
expect(bootstraps).toHaveLength(1);
|
|
const ids = bootstraps[0]!.searchParams.get("ids")!.split(",");
|
|
expect(ids.length).toBeLessThanOrEqual(10);
|
|
expect(ids).toContain(recents[0]!.id);
|
|
expect(requests.map(classifyTaskRead).filter(Boolean)).toEqual([]);
|
|
});
|
|
|
|
test("clicking a preloaded recent task shows its chat without first-frame reads", async ({ page, request }) => {
|
|
await seedRecents(page, request);
|
|
await loadIdle(page);
|
|
const requests = recordRequests(page);
|
|
const before = requests.length;
|
|
const task = recents[1]!;
|
|
await openRecent(page, task);
|
|
const reads = requests.slice(before).map(classifyTaskRead).filter((kind): kind is string => !!kind);
|
|
// The full detail read revalidates after the first frame; it is not a gate.
|
|
test.info().annotations.push({ type: "after-click task reads", description: JSON.stringify(reads) });
|
|
expect(reads.filter(kind => FIRST_FRAME_READS.has(kind))).toEqual([]);
|
|
});
|
|
|
|
// Native loopback, then a diagnostic scenario that adds latency to every API
|
|
// response, as a slow hosted database would. Timings are evidence, not budgets.
|
|
for (const apiDelayMs of [0, 150]) {
|
|
test(`timing: click on a recent task to first chat frame (+${apiDelayMs} ms API latency)`, async ({ browser, request, baseURL }) => {
|
|
const runs = Math.max(5, Number(process.env.PAPERCLIP_COLLECTION_PERF_TIMING_RUNS ?? 5));
|
|
const timings: number[] = [];
|
|
const idleCounts: number[] = [];
|
|
const afterClick: string[][] = [];
|
|
for (let run = 0; run < runs; run += 1) {
|
|
// A fresh context per run: empty HTTP cache, memory cache and storage,
|
|
// except the seeded Recents history.
|
|
// browser.newContext() does not inherit the project `use` options.
|
|
const context = await browser.newContext({ baseURL, serviceWorkers: "block", viewport: { width: 1440, height: 900 } });
|
|
const page = await context.newPage();
|
|
if (apiDelayMs) {
|
|
await page.route("**/api/**", async route => {
|
|
await new Promise(resolve => setTimeout(resolve, apiDelayMs));
|
|
await route.fallback();
|
|
});
|
|
}
|
|
await seedRecents(page, request);
|
|
const requests = recordRequests(page);
|
|
await loadIdle(page);
|
|
idleCounts.push(requests.length);
|
|
timings.push(await openRecent(page, recents[(run % (RECENT_COUNT - 1)) + 1]!));
|
|
afterClick.push(requests.slice(idleCounts.at(-1)).map(classifyTaskRead).filter((kind): kind is string => !!kind));
|
|
await context.close();
|
|
}
|
|
const summary = {
|
|
apiDelayMs, runs, clickToFirstFrameMs: timings.map(Math.round), medianMs: Math.round(median(timings)),
|
|
idleApiRequests: idleCounts, medianIdleApiRequests: median(idleCounts), taskReadsAfterClick: afterClick,
|
|
};
|
|
console.log(`recent-task-open timing ${JSON.stringify(summary)}`);
|
|
await test.info().attach("recent-task-open-timing", { body: JSON.stringify(summary, null, 2), contentType: "application/json" });
|
|
});
|
|
}
|