Files
PaperClipAI/ui/src/lib/issueChatBootstrapCache.test.ts
T
DottaandClaude Opus 5.5 7329eaf353 perf(ui): open sidebar Recents tasks from cache (#20450)
## 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>
2026-11-10 08:00:13 -06:00

158 lines
8.5 KiB
TypeScript

import { QueryClient } from "@tanstack/react-query";
import type { ActivityEvent, Issue, IssueComment } from "@paperclipai/shared";
import { afterEach, describe, expect, it } from "vitest";
import type { RunForIssue } from "@/api/activity";
import { hasFreshIssueChatFirstFrame, seedIssueChatBootstrap, type IssueChatBootstrapEntry } from "./issueChatBootstrapCache";
import {
issueCommentInputFactsQueryKey,
recentIssueActivityQueryKey,
recentIssueRunWindowQueryKey,
} from "./issue-chat-history-queries";
import { getCachedIssueDetail } from "./issueDetailCache";
import { queryKeys } from "./queryKeys";
const ID = "11111111-1111-4111-8111-111111111111";
const IDENTIFIER = "PAP-7";
const REQUESTED_AT = 1_000_000;
function issue(overrides: Partial<Issue> = {}) {
return {
id: ID, identifier: IDENTIFIER, companyId: "company-1", title: "Recent task", status: "todo", priority: "medium",
workMode: "standard", projectId: null, parentId: null, description: null, assigneeAgentId: null,
assigneeUserId: null, executionRunId: null, issueNumber: 7, requestDepth: 0,
createdAt: new Date(0), updatedAt: new Date(0),
...overrides,
} as Issue;
}
const comment = (id: string) => ({ id, issueId: ID, body: id, createdAt: new Date(0) }) as unknown as IssueComment;
const event = (id: string) => ({ id, action: "comment.created" }) as unknown as ActivityEvent;
const run = (runId: string) => ({ runId, status: "succeeded" }) as unknown as RunForIssue;
function item(overrides: Partial<IssueChatBootstrapEntry> = {}): IssueChatBootstrapEntry {
return {
ref: ID,
issue: issue(),
recentComments: { comments: [comment("c2"), comment("c1")], hasMore: true, oldestCommentId: "c1", latestCommentId: "c2" },
commentInputFacts: [event("fact-1")],
recentActivity: [event("a1"), event("a2"), event("a1")],
recentRuns: [run("r2"), run("r1")],
interactions: [],
attachments: [],
liveRuns: [],
activeRun: null,
...overrides,
};
}
const coreKey = (ref: string) => [...queryKeys.issues.detail(ref), "core"];
const commentsKey = (ref: string) => [...queryKeys.issues.comments(ref), "recent"];
let queryClient: QueryClient;
function client() {
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return queryClient;
}
afterEach(() => queryClient?.clear());
describe("seedIssueChatBootstrap", () => {
it("fills every first-frame key in the shape of its single endpoint, under id and identifier", () => {
const qc = client();
const entry = item();
expect(seedIssueChatBootstrap(qc, entry, { companyId: "company-1", requestedAt: REQUESTED_AT })).toBe(true);
for (const ref of [ID, IDENTIFIER]) {
expect(qc.getQueryData(coreKey(ref))).toBe(entry.issue);
expect(qc.getQueryData(commentsKey(ref))).toBe(entry.recentComments);
// The activity key holds the de-duplicated recent answer.
expect(qc.getQueryData(recentIssueActivityQueryKey(ref))).toEqual([event("a1"), event("a2")]);
// A cold open shows the latest run and discloses the earlier one.
expect(qc.getQueryData(recentIssueRunWindowQueryKey(ref))).toEqual({ runs: [run("r2")], hasEarlierRuns: true });
expect(qc.getQueryData(queryKeys.issues.interactions(ref))).toEqual([]);
expect(qc.getQueryData(queryKeys.issues.attachments(ref))).toEqual([]);
expect(qc.getQueryData(queryKeys.issues.liveRuns(ref))).toEqual([]);
// The input facts are stored as the single batch the chat requests: the sorted saved comment ids.
const factsKey = issueCommentInputFactsQueryKey(ref);
expect(qc.getQueryData([...factsKey, "batch-ids"])).toEqual([["c1", "c2"]]);
expect(qc.getQueryData([...factsKey, "batch", ["c1", "c2"]])).toEqual([event("fact-1")]);
expect(qc.getQueryState(coreKey(ref))?.dataUpdatedAt).toBe(REQUESTED_AT);
}
// Core cannot stand in for the full detail read and its related data.
expect(qc.getQueryData(queryKeys.issues.detail(ID))).toBeUndefined();
expect(getCachedIssueDetail(qc, IDENTIFIER)).toBeUndefined();
});
it("stores an explicit null active run but leaves missing fields unset", () => {
const qc = client();
const entry = item({ activeRun: null });
delete (entry as Partial<IssueChatBootstrapEntry>).liveRuns;
delete (entry as Partial<IssueChatBootstrapEntry>).commentInputFacts;
seedIssueChatBootstrap(qc, entry, { companyId: "company-1", requestedAt: REQUESTED_AT });
expect(qc.getQueryState(queryKeys.issues.activeRun(ID))?.data).toBeNull();
expect(qc.getQueryState(queryKeys.issues.activeRun(ID))?.status).toBe("success");
expect(qc.getQueryState(queryKeys.issues.liveRuns(ID))).toBeUndefined();
expect(qc.getQueryState([...issueCommentInputFactsQueryKey(ID), "batch-ids"])).toBeUndefined();
});
it("ignores an item for another company", () => {
const qc = client();
expect(seedIssueChatBootstrap(qc, item({ issue: issue({ companyId: "company-2" }) }), { companyId: "company-1", requestedAt: REQUESTED_AT })).toBe(false);
expect(qc.getQueryCache().getAll()).toHaveLength(0);
});
it("never replaces cached data that is as new or newer, or a key with a read in flight or pending invalidation", () => {
const qc = client();
const newer = issue({ title: "Newer title" });
qc.setQueryData(coreKey(ID), newer, { updatedAt: REQUESTED_AT + 1 });
qc.setQueryData(queryKeys.issues.interactions(ID), ["stale"], { updatedAt: REQUESTED_AT - 1 });
qc.invalidateQueries({ queryKey: queryKeys.issues.interactions(ID) });
qc.setQueryData(queryKeys.issues.attachments(ID), ["older"], { updatedAt: REQUESTED_AT - 1 });
seedIssueChatBootstrap(qc, item(), { companyId: "company-1", requestedAt: REQUESTED_AT });
expect(qc.getQueryData(coreKey(ID))).toBe(newer);
expect(qc.getQueryData(queryKeys.issues.interactions(ID))).toEqual(["stale"]);
// A whole-answer endpoint with older, valid data takes the newer answer.
expect(qc.getQueryData(queryKeys.issues.attachments(ID))).toEqual([]);
});
it("only fills empty keys whose page merges observed history", () => {
const qc = client();
const observed = { comments: [comment("c3")], hasMore: false, oldestCommentId: "c3", latestCommentId: "c3" };
qc.setQueryData(commentsKey(ID), observed, { updatedAt: REQUESTED_AT - 10_000 });
qc.setQueryData(recentIssueActivityQueryKey(ID), [event("seen")], { updatedAt: REQUESTED_AT - 10_000 });
qc.setQueryData(recentIssueRunWindowQueryKey(ID), { runs: [run("r9")], hasEarlierRuns: false }, { updatedAt: REQUESTED_AT - 10_000 });
qc.setQueryData([...issueCommentInputFactsQueryKey(ID), "batch-ids"], [["c3"]]);
seedIssueChatBootstrap(qc, item(), { companyId: "company-1", requestedAt: REQUESTED_AT });
expect(qc.getQueryData(commentsKey(ID))).toBe(observed);
expect(qc.getQueryData(recentIssueActivityQueryKey(ID))).toEqual([event("seen")]);
expect(qc.getQueryData(recentIssueRunWindowQueryKey(ID))).toEqual({ runs: [run("r9")], hasEarlierRuns: false });
expect(qc.getQueryData([...issueCommentInputFactsQueryKey(ID), "batch-ids"])).toEqual([["c3"]]);
});
});
describe("hasFreshIssueChatFirstFrame", () => {
it("is true only while every first-frame read is cached, valid and fresh", () => {
const qc = client();
expect(hasFreshIssueChatFirstFrame(qc, [ID], REQUESTED_AT)).toBe(false);
seedIssueChatBootstrap(qc, item(), { companyId: "company-1", requestedAt: REQUESTED_AT });
expect(hasFreshIssueChatFirstFrame(qc, [IDENTIFIER], REQUESTED_AT + 29_999)).toBe(true);
expect(hasFreshIssueChatFirstFrame(qc, [IDENTIFIER], REQUESTED_AT + 30_000)).toBe(false);
qc.invalidateQueries({ queryKey: commentsKey(IDENTIFIER) });
expect(hasFreshIssueChatFirstFrame(qc, [IDENTIFIER], REQUESTED_AT + 1)).toBe(false);
});
it("is false after a light warm that cached only the core issue and recent comments", () => {
const qc = client();
const entry = item();
qc.setQueryData(coreKey(IDENTIFIER), entry.issue, { updatedAt: REQUESTED_AT });
qc.setQueryData(commentsKey(IDENTIFIER), entry.recentComments, { updatedAt: REQUESTED_AT });
expect(hasFreshIssueChatFirstFrame(qc, [ID, IDENTIFIER], REQUESTED_AT + 1)).toBe(false);
});
it("is false while a recent comment's input facts are missing", () => {
const qc = client();
seedIssueChatBootstrap(qc, item(), { companyId: "company-1", requestedAt: REQUESTED_AT });
const factsKey = issueCommentInputFactsQueryKey(IDENTIFIER);
qc.removeQueries({ queryKey: [...factsKey, "batch", ["c1", "c2"]], exact: true });
expect(hasFreshIssueChatFirstFrame(qc, [IDENTIFIER], REQUESTED_AT + 1)).toBe(false);
});
});