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>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-10 08:00:13 -06:00
1 parent c9226de30b
commit 7329eaf353
19 files changed
+1079 -43

No files matched your search

+14
View File
@@ -61,6 +61,20 @@ navigation start to the first Mine row (two frames after mount) over
a native scenario without request interception and a separate diagnostic scenario that delays
workspace reads by 3 seconds. The timings are attached evidence, not budgets.
`recent-task-open.spec.ts` covers opening a task from the sidebar's Recents.
It gives eight fixture tasks twelve saved comments each and seeds them as the
account's Recents. An idle Projects page must make exactly one
`issues/chat-bootstrap` request (at most ten tasks) and no per-task reads.
Clicking a preloaded task must show its latest comment without any of the
chat's first-frame reads (core issue, recent comments, comment input facts,
recent activity, recent runs, interactions, attachments, live and active run).
The full detail read and the task page's other side reads still follow the
first frame; the spec records them as an annotation. Its timing cases measure
click to the latest comment on screen over at least 5 fresh browser contexts
(empty HTTP cache and storage apart from Recents), natively and with 150 ms
added to every API response. They also record the idle page's API request
count. The timings are attached evidence, not budgets.
Run these focused browser checks before the full collection suite:
```sh
@@ -0,0 +1,183 @@
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" });
});
}
+16 -1
View File
@@ -1,5 +1,7 @@
import type { SetIssueTitle } from "@paperclipai/shared";
import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared";
import { ISSUE_CHAT_BOOTSTRAP_MAX_IDS, ISSUE_SUMMARIES_MAX_IDS, type IssueChatBootstrap } from "@paperclipai/shared";
import type { RunForIssue } from "./activity";
import type { ActiveRunForIssue, LiveRunForIssue } from "./heartbeats";
import type { IssueSummary } from "@paperclipai/shared";
import type { ExecutionReconciliation } from "@paperclipai/shared";
import type { IssueContextUsage } from "@paperclipai/shared";
@@ -300,6 +302,19 @@ export const issuesApi = {
detachInflightGet(path);
return api.get<IssueSummary[]>(path, { ...options, cache: "no-store" });
},
/**
* The task chat's first-frame reads for up to ten tasks in one request. Each
* item's fields have the shape of the matching single-task endpoint;
* missing and unreadable tasks are omitted.
*/
chatBootstrap: (companyId: string, issueRefs: readonly string[], options?: RequestOptions) => {
const refs = [...new Set(issueRefs.map((ref) => ref.trim()).filter(Boolean))];
if (refs.length === 0 || refs.length > ISSUE_CHAT_BOOTSTRAP_MAX_IDS) throw new Error("Chat bootstrap needs one to ten task references");
const path = `/companies/${companyId}/issues/chat-bootstrap?${new URLSearchParams({ ids: refs.join(",") })}`;
// Never join a request sent with the previous account's cookie.
detachInflightGet(path);
return api.get<IssueChatBootstrap<RunForIssue, LiveRunForIssue, ActiveRunForIssue>>(path, { ...options, cache: "no-store" });
},
/** Fetch every page for bounded task-detail relations, not just the default first page. */
listAll: async (companyId: string, filters: Omit<IssueListFilters, "limit" | "offset" | "sortField" | "sortDir" | "afterId" | "attention">, options?: RequestOptions): Promise<Issue[]> => {
const pageSize = 500;
+7 -1
View File
@@ -7,6 +7,7 @@ import { issuesApi } from "@/api/issues";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { useAgentChatNavigation } from "@/hooks/useAgentChatNavigation";
import { useRecentTasks } from "@/hooks/useRecentTasks";
import { useRecentTaskOpenWarmup } from "@/hooks/useRecentTaskOpenWarmup";
import { usePinnedNavigation } from "@/hooks/usePinnedNavigation";
import { useDialogActions } from "@/context/DialogContext";
import { useSidebar } from "@/context/SidebarContext";
@@ -15,6 +16,7 @@ import { Link, useLocation } from "@/lib/router";
import { agentRouteRef, cn, projectRouteRef, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
import { SidebarNavItem } from "./SidebarNavItem";
import { RecentTaskNavItem } from "./RecentTaskNavItem";
import { SidebarSection } from "./SidebarSection";
import { TruncatedTextMarqueeProvider } from "./TruncatedText";
import { useScrollbarWhileScrolling } from "./side-panel/use-scrollbar-while-scrolling";
@@ -28,6 +30,8 @@ import { agentChatPinKey } from "./AgentChatPin";
import { PinnedNavigationList, type PinnedNavigationItem } from "./PinnedNavigationList";
import { companyProjectsQuery } from "@/lib/company-projects-query";
const NO_RECENT_TASKS: never[] = [];
const moreDestinations = [
{ label: "Search", to: "/search", icon: Search },
{ label: "Dashboard", to: "/dashboard", icon: LayoutDashboard },
@@ -119,6 +123,8 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
const { entries: recentTasks } = useRecentTasks({ companyId, userId });
const { collapsed, peeking, isMobile, setSidebarOpen } = useSidebar();
const rail = collapsed && !peeking;
// Warm opens only while the Recents list is on screen.
useRecentTaskOpenWarmup({ companyId, userId, tasks: !rail && session.isSuccess ? recentTasks : NO_RECENT_TASKS });
const { openNewIssue } = useDialogActions();
const location = useLocation();
const attentionCount = useCoordinatorAttentionCount(companyId);
@@ -193,7 +199,7 @@ export function CoordinatorSidebar({ children }: { children?: ReactNode }) {
{!rail && session.isSuccess && recents.length > 0 && <div className="design-sidebar-recents"><SidebarSection label="Recents">
<RecentNavigationList>
{recents.map(item => item.kind === "task"
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/issues/${item.task.identifier ?? item.id}`} label={item.task.title} iconNode={<StatusIcon {...taskStatusIconProps(item.task)} />} />
? <RecentTaskNavItem className="design-sidebar-item" key={item.id} task={item.task} label={item.task.title} iconNode={<StatusIcon {...taskStatusIconProps(item.task)} />} />
: item.kind === "chat"
? <SidebarNavItem className="design-sidebar-item" key={item.id} to={`/chats/${encodeURIComponent(agentRouteRef(item.agent))}`} label={item.agent.name} iconNode={<AgentAvatar agent={item.agent} size={20} />} />
: <ProjectRecent key={item.id} project={item.project} />)}
@@ -2,6 +2,7 @@ import { useAccountIdentity } from "@/api/companies-query";
import { useCompany } from "@/context/CompanyContext";
import { useDialogActions } from "@/context/DialogContext";
import { useRecentTasks } from "@/hooks/useRecentTasks";
import { useRecentTaskOpenWarmup } from "@/hooks/useRecentTaskOpenWarmup";
import { DesignSidebar } from "./DesignSidebar";
/** Data adapter only; the visual subject is Components / Sidebar. */
@@ -10,5 +11,6 @@ export function DesignSandboxSidebar() {
const { userId } = useAccountIdentity();
const { openNewIssue } = useDialogActions();
const { entries } = useRecentTasks({ companyId: selectedCompanyId, userId });
useRecentTaskOpenWarmup({ companyId: selectedCompanyId, userId, tasks: entries });
return <DesignSidebar recentTasks={entries} onNewTask={() => openNewIssue()} />;
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { SidebarAccountMenu } from "./SidebarAccountMenu";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
import { SidebarNavExpandedProvider, SidebarNavItem } from "./SidebarNavItem";
import { SidebarSection } from "./SidebarSection";
import { RecentTaskNavItem, recentTaskPathRef } from "./RecentTaskNavItem";
import { TruncatedTextMarqueeProvider } from "./TruncatedText";
import { Button } from "./ui/button";
import { FlyoutAppearanceProvider } from "./ui/flyout";
@@ -47,7 +48,7 @@ export function DesignSidebar({ onNewTask, recentTasks = [] }: { onNewTask: () =
{companyRecentTasks.length > 0 ? <div className="design-sidebar-recents">
<SidebarSection label="Recent Tasks">
<div onScroll={handleRecentScroll} className="design-sidebar-recent-list scrollbar-while-scrolling" role="region" aria-label="Recent tasks" tabIndex={0}>
{companyRecentTasks.map(issue => <SidebarNavItem key={issue.id} className="design-sidebar-item" to={`${root}/issues/${issue.identifier}`} label={issue.title} />)}
{companyRecentTasks.map(issue => <RecentTaskNavItem key={issue.id} task={issue} className="design-sidebar-item" to={`${root}/issues/${recentTaskPathRef(issue)}`} label={issue.title} />)}
</div>
</SidebarSection>
</div> : null}
+10 -30
View File
@@ -8,8 +8,8 @@ import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDet
import {
getIssueDetailQueryOptions,
ISSUE_DETAIL_STALE_TIME_MS,
prefetchIssueDetailForNavigation,
} from "@/lib/issueDetailCache";
import { ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS, useIssueNavigationPrefetch } from "@/hooks/useIssueNavigationPrefetch";
import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -219,8 +219,7 @@ export function IssueQuicklookCard({
);
}
/** How long a hover rests on a task link before its full chat history is warmed. */
export const ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS = 120;
export { ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS };
export const IssueLinkQuicklook = React.forwardRef<
HTMLAnchorElement,
@@ -366,26 +365,7 @@ export const IssueLinkQuicklook = React.forwardRef<
});
const detailPath = createIssueDetailPath(issuePathId);
// A pointer, a touch or keyboard focus that passes over a row warms only the
// issue and its recent comments. A hover or focus that rests on the link, a
// mouse or pen press, or a click also warms the rest of the chat's first
// frame, so the open renders from cache. A touch can start a scroll, so it
// warms the full set only on the click.
const historyPrefetchTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelHistoryPrefetch = React.useCallback(() => {
if (historyPrefetchTimerRef.current) clearTimeout(historyPrefetchTimerRef.current);
historyPrefetchTimerRef.current = null;
}, []);
React.useEffect(() => cancelHistoryPrefetch, [cancelHistoryPrefetch]);
const handlePrefetch = React.useCallback((history = true) => {
if (history) cancelHistoryPrefetch();
void prefetchIssueDetailForNavigation(queryClient, issuePathId, { issue: issuePrefetch, history });
}, [cancelHistoryPrefetch, issuePathId, issuePrefetch, queryClient]);
const handleDwellPrefetch = React.useCallback(() => {
handlePrefetch(false);
cancelHistoryPrefetch();
historyPrefetchTimerRef.current = setTimeout(() => handlePrefetch(true), ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS);
}, [cancelHistoryPrefetch, handlePrefetch]);
const prefetchHandlers = useIssueNavigationPrefetch(issuePathId, issuePrefetch);
const link = (
<RouterDom.Link
ref={setTriggerRef}
@@ -393,34 +373,34 @@ export const IssueLinkQuicklook = React.forwardRef<
state={prefetchedState}
className={className}
onMouseEnter={(event) => {
handleDwellPrefetch();
prefetchHandlers.onMouseEnter(event);
onMouseEnter?.(event);
}}
onMouseLeave={(event) => {
cancelHistoryPrefetch();
prefetchHandlers.onMouseLeave(event);
onMouseLeave?.(event);
}}
onPointerDown={(event) => {
handlePrefetch(event.pointerType !== "touch" && event.button === 0);
prefetchHandlers.onPointerDown(event);
onPointerDown?.(event);
}}
onFocus={(event) => {
handleDwellPrefetch();
prefetchHandlers.onFocus(event);
openNow();
onFocus?.(event);
}}
onBlur={(event) => {
cancelHistoryPrefetch();
prefetchHandlers.onBlur(event);
// Let clicks inside the portaled quicklook content finish before closing.
setTimeout(() => close(), 0);
onBlur?.(event);
}}
onTouchStart={(event) => {
handlePrefetch(false);
prefetchHandlers.onTouchStart(event);
onTouchStart?.(event);
}}
onClickCapture={(event) => {
handlePrefetch();
prefetchHandlers.onClickCapture(event);
onClickCapture?.(event);
}}
onClick={(event) => {
@@ -0,0 +1,105 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
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_HISTORY_HOVER_PREFETCH_DELAY_MS } from "@/hooks/useIssueNavigationPrefetch";
import { RecentTaskNavItem } from "./RecentTaskNavItem";
const prefetches = vi.hoisted(() => [] as Array<{ ref: string; history: boolean }>);
const setSidebarOpen = vi.hoisted(() => vi.fn());
vi.mock("@/lib/issueDetailCache", () => ({
prefetchIssueDetailForNavigation: (_client: unknown, ref: string, options?: { history?: boolean }) => {
prefetches.push({ ref, history: options?.history === true });
return Promise.resolve();
},
}));
vi.mock("@/lib/router", () => ({
NavLink: ({ children, to, className, end: _end, state: _state, ...props }: {
children: ReactNode; to: string; end?: boolean; state?: unknown;
className?: string | ((state: { isActive: boolean }) => string);
}) => (
<a href={to} className={typeof className === "function" ? className({ isActive: false }) : className} {...props}>{children}</a>
),
}));
vi.mock("@/context/SidebarContext", () => ({
useSidebar: () => ({ collapsed: false, peeking: false, isMobile: true, setSidebarOpen }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
const queryClient = new QueryClient();
function renderItem(task: { id: string; identifier: string | null }, to?: string) {
prefetches.length = 0;
act(() => {
root.render(<QueryClientProvider client={queryClient}><RecentTaskNavItem task={task} to={to} label="Recent task" /></QueryClientProvider>);
});
return container.querySelector("a") as HTMLAnchorElement;
}
const hover = (link: HTMLAnchorElement, type: "mouseover" | "mouseout") =>
act(() => { link.dispatchEvent(new MouseEvent(type, { bubbles: true, relatedTarget: document.body })); });
const press = (link: HTMLAnchorElement, pointerType: string) =>
act(() => { link.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, pointerType })); });
beforeEach(() => {
vi.useFakeTimers();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.useRealTimers();
setSidebarOpen.mockClear();
});
describe("RecentTaskNavItem", () => {
it("opens and warms the identifier route, falling back to the id", () => {
expect(renderItem({ id: "id-1", identifier: "PAP-1" }).getAttribute("href")).toBe("/issues/PAP-1");
expect(renderItem({ id: "id-2", identifier: null }).getAttribute("href")).toBe("/issues/id-2");
expect(renderItem({ id: "id-1", identifier: "PAP-1" }, "/PAP/issues/PAP-1").getAttribute("href")).toBe("/PAP/issues/PAP-1");
});
it("warms only the light set when a pointer passes over, and the chat's first frame when it rests", () => {
const link = renderItem({ id: "id-1", identifier: "PAP-1" });
hover(link, "mouseover");
act(() => { vi.advanceTimersByTime(ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS - 1); });
hover(link, "mouseout");
act(() => { vi.advanceTimersByTime(1_000); });
expect(prefetches).toEqual([{ ref: "PAP-1", history: false }]);
hover(link, "mouseover");
act(() => { vi.advanceTimersByTime(ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS); });
expect(prefetches.at(-1)).toEqual({ ref: "PAP-1", history: true });
});
it("warms the first frame on a mouse press, keyboard focus that rests, and a click", () => {
const link = renderItem({ id: "id-1", identifier: "PAP-1" });
press(link, "mouse");
expect(prefetches).toEqual([{ ref: "PAP-1", history: true }]);
prefetches.length = 0;
act(() => { link.focus(); });
act(() => { vi.advanceTimersByTime(ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS); });
expect(prefetches).toEqual([{ ref: "PAP-1", history: false }, { ref: "PAP-1", history: true }]);
prefetches.length = 0;
act(() => { link.click(); });
expect(prefetches).toEqual([{ ref: "PAP-1", history: true }]);
// The sidebar's own click handling still runs.
expect(setSidebarOpen).toHaveBeenCalledWith(false);
});
it("keeps a touch that may start a scroll to the light set", () => {
const link = renderItem({ id: "id-1", identifier: "PAP-1" });
press(link, "touch");
act(() => { link.dispatchEvent(new TouchEvent("touchstart", { bubbles: true })); });
act(() => { vi.advanceTimersByTime(1_000); });
expect(prefetches.length).toBeGreaterThan(0);
expect(prefetches.every((prefetch) => !prefetch.history)).toBe(true);
});
});
+26
View File
@@ -0,0 +1,26 @@
import type { ComponentProps } from "react";
import { useIssueNavigationPrefetch } from "@/hooks/useIssueNavigationPrefetch";
import { SidebarNavItem } from "./SidebarNavItem";
/** The route reference a Recents link opens: the identifier when the task has one. */
export function recentTaskPathRef(task: { id: string; identifier: string | null }) {
return task.identifier ?? task.id;
}
/**
* A Recents task link. It warms the task on open intent the same way task
* lists do, under the reference the task page reads.
*/
export function RecentTaskNavItem({
task,
to,
...props
}: Omit<ComponentProps<typeof SidebarNavItem>, "to" | "linkHandlers"> & {
task: { id: string; identifier: string | null };
/** Defaults to the board-relative task path. */
to?: string;
}) {
const pathRef = recentTaskPathRef(task);
const linkHandlers = useIssueNavigationPrefetch(pathRef);
return <SidebarNavItem {...props} to={to ?? `/issues/${pathRef}`} linkHandlers={linkHandlers} />;
}
+5
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, type ReactNode } from "react";
import { NavLink } from "@/lib/router";
import type { IssueNavigationPrefetchHandlers } from "@/hooks/useIssueNavigationPrefetch";
import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll";
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { useSidebar } from "../context/SidebarContext";
@@ -66,6 +67,8 @@ interface SidebarNavItemProps {
trailingLabel?: string;
/** Rendered inside the right-aligned status cluster, before the live dot. */
liveAccessory?: ReactNode;
/** Extra link handlers, such as a task's open-intent prefetch. */
linkHandlers?: Partial<IssueNavigationPrefetchHandlers>;
}
export function SidebarNavItem({
@@ -87,6 +90,7 @@ export function SidebarNavItem({
trailing,
trailingLabel,
liveAccessory,
linkHandlers,
}: SidebarNavItemProps) {
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
// A contextual takeover forces full labels even when the saved global app
@@ -120,6 +124,7 @@ export function SidebarNavItem({
state={SIDEBAR_SCROLL_RESET_STATE}
end={end}
aria-label={railAriaLabel}
{...linkHandlers}
onClick={() => { if (isMobile) setSidebarOpen(false); }}
className={({ isActive }) =>
cn(
@@ -24,6 +24,7 @@ const mockIssuesApi = vi.hoisted(() => ({
getTreeControlState: vi.fn(),
createTreeHold: vi.fn(),
releaseTreeHold: vi.fn(),
chatBootstrap: vi.fn(async () => ({ items: [] })),
}));
vi.mock("@/api/agents", () => ({ agentsApi: mockAgentsApi }));
@@ -158,7 +159,7 @@ describe("SidebarRecentTasks", () => {
}
});
const link = container.querySelector('a[href="/issues/issue-1"]');
const link = container.querySelector('a[href="/issues/PAP-1"]');
expect(mockIssuesApi.get).not.toHaveBeenCalled();
expect(mockIssuesApi.summaries).toHaveBeenCalledTimes(1);
expect(queryClient.getQueryData(["issues", "detail", "issue-1"])).toBeUndefined();
@@ -190,7 +191,8 @@ describe("SidebarRecentTasks", () => {
mockIssuesApi.get.mockImplementation(async (id: string) => tasks.find((task) => task.id === id));
const queryClient = await render();
const order = () => Array.from(container.querySelectorAll("a")).map((link) => link.getAttribute("href"));
const originalOrder = ["/issues/issue-3", "/issues/issue-2", "/issues/issue-1"];
// Links open the identifier route the task page settles on.
const originalOrder = ["/issues/PAP-3", "/issues/PAP-2", "/issues/PAP-1"];
expect(order()).toEqual(originalOrder);
vi.useFakeTimers();
@@ -210,7 +212,7 @@ describe("SidebarRecentTasks", () => {
await act(async () => { await vi.advanceTimersByTimeAsync(499); });
expect(order()).toEqual(originalOrder);
await act(async () => { await vi.advanceTimersByTimeAsync(1); });
expect(order()).toEqual(["/issues/issue-2", "/issues/issue-1", "/issues/issue-3"]);
expect(order()).toEqual(["/issues/PAP-2", "/issues/PAP-1", "/issues/PAP-3"]);
expect(mockIssuesApi.get).not.toHaveBeenCalled();
expect(mockIssuesApi.summaries).toHaveBeenCalledTimes(1);
queryClient.clear();
@@ -309,7 +311,7 @@ describe("SidebarRecentTasks", () => {
expect(mockIssuesApi.archiveFromInbox).toHaveBeenCalledWith("issue-1");
expect(mockIssuesApi.update).not.toHaveBeenCalled();
expect(container.textContent).toContain("Recent Tasks");
expect(container.querySelector('a[href="/issues/issue-1"]')?.textContent).toContain(
expect(container.querySelector('a[href="/issues/PAP-1"]')?.textContent).toContain(
"Archive me",
);
expect(readRecentTasks(
@@ -507,7 +509,7 @@ describe("SidebarRecentTasks", () => {
await Promise.resolve();
});
expect(container.querySelector('a[href="/issues/issue-2"]')?.textContent).toContain("Cross-tab task");
expect(container.querySelector('a[href="/issues/PAP-2"]')?.textContent).toContain("Cross-tab task");
});
it("preserves pending restart wake retries from before the snapshot storage migration", async () => {
@@ -562,7 +564,7 @@ describe("SidebarRecentTasks", () => {
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
expect(container.querySelector('a[href="/issues/issue-hidden"]')).toBeNull();
expect(container.querySelector('a[href="/issues/PAP-3"]')).toBeNull();
expect(readRecentTasks(
getRecentTasksStorageKey("company-1", "user-1"),
"company-1",
+5 -3
View File
@@ -5,6 +5,7 @@ import { agentsApi } from "@/api/agents";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { useRecentTasks } from "@/hooks/useRecentTasks";
import { useRecentTaskOpenWarmup } from "@/hooks/useRecentTaskOpenWarmup";
import { useAccountIdentity } from "@/api/companies-query";
import { useSidebar } from "@/context/SidebarContext";
import { useOptionalToastActions } from "@/context/ToastContext";
@@ -30,7 +31,7 @@ import {
import { Input } from "@/components/ui/input";
import { StatusIcon } from "./StatusIcon";
import { SidebarSection } from "./SidebarSection";
import { SidebarNavItem } from "./SidebarNavItem";
import { RecentTaskNavItem } from "./RecentTaskNavItem";
import { taskStatusIconProps } from "@/lib/task-status-presentation";
const RECENT_TASK_MENU_ITEM_CLASS =
@@ -111,6 +112,7 @@ function RecentTasksList({
const [renameValue, setRenameValue] = useState("");
const [pendingAction, setPendingAction] = useState<"rename" | "archive" | "pause" | null>(null);
const restartRetryStorageKey = restartWakeRetryStorageKey(companyId, userId);
useRecentTaskOpenWarmup({ companyId, userId, tasks: entries });
if (entries.length === 0) return null;
@@ -250,8 +252,8 @@ function RecentTasksList({
<SidebarSection label="Recent Tasks">
{entries.map((entry) => (
<div key={entry.id} className="sidebar-action-row group/recent-task relative">
<SidebarNavItem
to={`/issues/${entry.id}`}
<RecentTaskNavItem
task={entry}
label={entry.title}
iconNode={entry.displayStatus === "monitoring" && !liveIssueIds.has(entry.id) ? <StatusIcon {...taskStatusIconProps(entry)} /> : undefined}
trailing={entry.status === "in_review" && entry.externalConversationState === "waiting"
+20
View File
@@ -34,6 +34,26 @@ export function prefetchIssueIdBatches<T>(
}))).then(() => undefined);
}
/**
* Store one already-fetched batch under the keys `useIssueIdBatches` reads.
* Only when no batch is cached for this key yet, so it never replaces or
* splits batches a page has already read.
*/
export function seedIssueIdBatch<T>(
client: QueryClient,
key: readonly unknown[],
ids: readonly string[],
data: T[],
updatedAt: number,
) {
if (!ids.length || ids.length > ISSUE_ID_BATCH_SIZE) return false;
if (client.getQueryData<string[][]>([...key, "batch-ids"])?.length) return false;
const batch = [...ids];
client.setQueryData<string[][]>([...key, "batch-ids"], [batch]);
client.setQueryData([...key, "batch", batch], data, { updatedAt });
return true;
}
export function useIssueIdBatches<T>(key: readonly unknown[], ids: string[], fetchBatch: (ids: string[]) => Promise<T[]>, enabled: boolean) {
const client = useQueryClient();
const serializedKey = JSON.stringify(key);
@@ -0,0 +1,58 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { FocusEvent, MouseEvent, PointerEvent, TouchEvent } from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
import { prefetchIssueDetailForNavigation } from "@/lib/issueDetailCache";
/** How long a hover rests on a task link before its full chat history is warmed. */
export const ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS = 120;
export interface IssueNavigationPrefetchHandlers {
onMouseEnter: (event: MouseEvent<HTMLAnchorElement>) => void;
onMouseLeave: (event: MouseEvent<HTMLAnchorElement>) => void;
onPointerDown: (event: PointerEvent<HTMLAnchorElement>) => void;
onFocus: (event: FocusEvent<HTMLAnchorElement>) => void;
onBlur: (event: FocusEvent<HTMLAnchorElement>) => void;
onTouchStart: (event: TouchEvent<HTMLAnchorElement>) => void;
onClickCapture: (event: MouseEvent<HTMLAnchorElement>) => void;
}
/**
* Link handlers that warm a task before it is opened.
*
* A pointer, a touch or keyboard focus that passes over a link warms only the
* issue and its recent comments. A hover or focus that rests on the link, a
* mouse or pen press, or a click also warms the rest of the chat's first frame,
* so the open renders from cache. A touch can start a scroll, so it warms the
* full set only on the click.
*/
export function useIssueNavigationPrefetch(
issuePathId: string,
issue: Issue | null = null,
): IssueNavigationPrefetchHandlers {
const queryClient = useQueryClient();
const historyPrefetchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelHistoryPrefetch = useCallback(() => {
if (historyPrefetchTimerRef.current) clearTimeout(historyPrefetchTimerRef.current);
historyPrefetchTimerRef.current = null;
}, []);
useEffect(() => cancelHistoryPrefetch, [cancelHistoryPrefetch]);
const prefetch = useCallback((history = true) => {
if (history) cancelHistoryPrefetch();
void prefetchIssueDetailForNavigation(queryClient, issuePathId, { issue, history });
}, [cancelHistoryPrefetch, issuePathId, issue, queryClient]);
const dwell = useCallback(() => {
prefetch(false);
cancelHistoryPrefetch();
historyPrefetchTimerRef.current = setTimeout(() => prefetch(true), ISSUE_HISTORY_HOVER_PREFETCH_DELAY_MS);
}, [cancelHistoryPrefetch, prefetch]);
return useMemo(() => ({
onMouseEnter: () => dwell(),
onMouseLeave: () => cancelHistoryPrefetch(),
onPointerDown: (event) => prefetch(event.pointerType !== "touch" && event.button === 0),
onFocus: () => dwell(),
onBlur: () => cancelHistoryPrefetch(),
onTouchStart: () => prefetch(false),
onClickCapture: () => prefetch(),
}), [cancelHistoryPrefetch, dwell, prefetch]);
}
@@ -0,0 +1,183 @@
// @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 { TASK_COLLECTION_PAINTED_EVENT } from "@/lib/issue-detail-preload-events";
import type { Issue } from "@paperclipai/shared";
import { seedIssueChatBootstrap, type IssueChatBootstrapEntry } from "@/lib/issueChatBootstrapCache";
import { queryKeys } from "@/lib/queryKeys";
import { resetRecentTaskChatPreloadForTests, useRecentTaskOpenWarmup, type RecentTaskOpenWarmupTask } from "./useRecentTaskOpenWarmup";
const chatBootstrap = vi.hoisted(() => vi.fn());
vi.mock("@/api/issues", () => ({ issuesApi: { chatBootstrap } }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const tasks = (count: number): RecentTaskOpenWarmupTask[] =>
Array.from({ length: count }, (_, index) => ({ id: `id-${index}`, identifier: `PAP-${index}` }));
function bootstrapItem(index: number, companyId = "company-1"): IssueChatBootstrapEntry {
return {
ref: `id-${index}`,
issue: {
id: `id-${index}`, identifier: `PAP-${index}`, companyId, title: `Task ${index}`, status: "todo", priority: "medium",
workMode: "standard", projectId: null, parentId: null, description: null, assigneeAgentId: null, assigneeUserId: null,
executionRunId: null, issueNumber: index, requestDepth: 0, createdAt: new Date(0), updatedAt: new Date(0),
} as Issue,
recentComments: { comments: [], hasMore: false, oldestCommentId: null, latestCommentId: null },
commentInputFacts: [], recentActivity: [], recentRuns: [], interactions: [], attachments: [], liveRuns: [], activeRun: null,
};
}
/** Long enough for two frames and the idle fallback timer. */
const settle = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 400)); });
let container: HTMLDivElement;
let root: Root;
let queryClient: QueryClient;
let visibility: DocumentVisibilityState;
function Probe(props: { companyId: string | null; userId: string | null; tasks: RecentTaskOpenWarmupTask[] }) {
useRecentTaskOpenWarmup(props);
return null;
}
function render(props: { companyId?: string | null; userId?: string | null; tasks: RecentTaskOpenWarmupTask[] }) {
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<Probe companyId={props.companyId === undefined ? "company-1" : props.companyId} userId={props.userId ?? "user-1"} tasks={props.tasks} />
</QueryClientProvider>,
);
});
}
beforeEach(() => {
resetRecentTaskChatPreloadForTests();
chatBootstrap.mockReset();
chatBootstrap.mockImplementation(async (_companyId: string, ids: string[]) => ({
items: ids.map((id) => bootstrapItem(Number(id.slice(3)))),
}));
visibility = "visible";
Object.defineProperty(document, "visibilityState", { configurable: true, get: () => visibility });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
afterEach(() => {
act(() => root.unmount());
container.remove();
queryClient.clear();
});
describe("useRecentTaskOpenWarmup", () => {
it("reads the ten most recent tasks in one request once the page settles, and seeds them", async () => {
render({ tasks: tasks(12) });
expect(chatBootstrap).not.toHaveBeenCalled();
await settle();
expect(chatBootstrap).toHaveBeenCalledTimes(1);
expect(chatBootstrap.mock.calls[0]![0]).toBe("company-1");
expect(chatBootstrap.mock.calls[0]![1]).toEqual(tasks(10).map((task) => task.id));
expect(queryClient.getQueryData([...queryKeys.issues.detail("PAP-3"), "core"])).toMatchObject({ id: "id-3" });
// Reordering or re-rendering Recents never starts a second request.
render({ tasks: [...tasks(12)].reverse() });
await settle();
expect(chatBootstrap).toHaveBeenCalledTimes(1);
});
it("warms the task-detail code only when Recents has a task", async () => {
const painted = vi.fn();
window.addEventListener(TASK_COLLECTION_PAINTED_EVENT, painted);
render({ tasks: [] });
await settle();
expect(painted).not.toHaveBeenCalled();
expect(chatBootstrap).not.toHaveBeenCalled();
render({ tasks: tasks(1) });
await settle();
expect(painted).toHaveBeenCalledTimes(1);
window.removeEventListener(TASK_COLLECTION_PAINTED_EVENT, painted);
});
it("waits for the page's own reads to finish", async () => {
let finish!: (value: string) => void;
void queryClient.fetchQuery({ queryKey: ["page-read"], queryFn: () => new Promise<string>((resolve) => { finish = resolve; }) });
render({ tasks: tasks(2) });
await settle();
expect(chatBootstrap).not.toHaveBeenCalled();
await act(async () => { finish("done"); });
await settle();
expect(chatBootstrap).toHaveBeenCalledTimes(1);
});
it("makes no request while the tab is hidden", async () => {
visibility = "hidden";
render({ tasks: tasks(2) });
await settle();
expect(chatBootstrap).not.toHaveBeenCalled();
visibility = "visible";
act(() => { document.dispatchEvent(new Event("visibilitychange")); });
await settle();
expect(chatBootstrap).toHaveBeenCalledTimes(1);
});
it("skips tasks whose first frame is cached and fresh, and makes no request when none is left", async () => {
// A full first frame for two tasks (as an earlier open or preload leaves it)...
seedIssueChatBootstrap(queryClient, bootstrapItem(0), { companyId: "company-1", requestedAt: Date.now() });
seedIssueChatBootstrap(queryClient, bootstrapItem(1), { companyId: "company-1", requestedAt: Date.now() });
// ...and a light warm (a brief hover) for the third, which is not a first frame.
queryClient.setQueryData([...queryKeys.issues.detail("PAP-2"), "core"], bootstrapItem(2).issue);
queryClient.setQueryData([...queryKeys.issues.comments("PAP-2"), "recent"], bootstrapItem(2).recentComments);
render({ tasks: tasks(3) });
await settle();
expect(chatBootstrap).toHaveBeenCalledTimes(1);
expect(chatBootstrap.mock.calls[0]![1]).toEqual(["id-2"]);
// Now all three are cached in full: no request at all.
resetRecentTaskChatPreloadForTests();
chatBootstrap.mockClear();
act(() => root.unmount());
root = createRoot(container);
render({ tasks: tasks(3) });
await settle();
expect(chatBootstrap).not.toHaveBeenCalled();
});
it("preloads once per account per page load and never seeds another company's tasks", async () => {
render({ tasks: tasks(1) });
await settle();
act(() => root.unmount());
root = createRoot(container);
render({ tasks: tasks(1) });
await settle();
expect(chatBootstrap).toHaveBeenCalledTimes(1);
// Another person in the same browser is their own scope.
chatBootstrap.mockImplementationOnce(async () => ({ items: [bootstrapItem(5, "company-2")] }));
render({ userId: "user-2", tasks: tasks(6).slice(5) });
await settle();
expect(chatBootstrap).toHaveBeenCalledTimes(2);
expect(queryClient.getQueryData([...queryKeys.issues.detail("PAP-5"), "core"])).toBeUndefined();
});
it("drops the answer when the sidebar unmounts before it arrives", async () => {
let answer!: (value: unknown) => void;
chatBootstrap.mockImplementationOnce((_companyId: string, _ids: string[], options: { signal: AbortSignal }) => new Promise((resolve) => {
answer = resolve;
expect(options.signal.aborted).toBe(false);
}));
render({ tasks: tasks(1) });
await settle();
expect(chatBootstrap).toHaveBeenCalledTimes(1);
const signal = (chatBootstrap.mock.calls[0]![2] as { signal: AbortSignal }).signal;
act(() => root.unmount());
expect(signal.aborted).toBe(true);
await act(async () => { answer({ items: [bootstrapItem(0)] }); });
expect(queryClient.getQueryData([...queryKeys.issues.detail("PAP-0"), "core"])).toBeUndefined();
root = createRoot(container);
});
});
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useRef } from "react";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { ISSUE_CHAT_BOOTSTRAP_MAX_IDS } from "@paperclipai/shared";
import { issuesApi } from "@/api/issues";
import { hasFreshIssueChatFirstFrame, seedIssueChatBootstrap } from "@/lib/issueChatBootstrapCache";
import { useWarmTaskDetailCode } from "./useWarmTaskDetailCode";
export type RecentTaskOpenWarmupTask = { id: string; identifier: string | null };
/** Accounts (company and user) whose Recents were preloaded during this page load. */
const preloadedScopes = new Set<string>();
export function resetRecentTaskChatPreloadForTests() {
preloadedScopes.clear();
}
const IDLE_TIMEOUT_MS = 2_000;
/**
* Run `start` once the page has painted, the tab is visible, no query is
* fetching (the page's own reads have finished) and the browser is idle.
* Returns a cancel function.
*/
function whenPageSettles(queryClient: QueryClient, start: () => void) {
const cleanups = new Set<() => void>();
const add = (cleanup: () => void) => cleanups.add(cleanup);
let done = false;
const cancel = () => {
done = true;
for (const cleanup of cleanups) cleanup();
cleanups.clear();
};
const step = () => {
if (done) return;
for (const cleanup of cleanups) cleanup();
cleanups.clear();
if (document.visibilityState !== "visible") {
const onVisible = () => { if (document.visibilityState === "visible") step(); };
document.addEventListener("visibilitychange", onVisible);
add(() => document.removeEventListener("visibilitychange", onVisible));
return;
}
if (queryClient.isFetching() > 0) {
add(queryClient.getQueryCache().subscribe(() => {
if (queryClient.isFetching() === 0) step();
}));
return;
}
const run = () => {
if (done) return;
// Something started while waiting for idle time: wait for it as well.
if (document.visibilityState !== "visible" || queryClient.isFetching() > 0) {
step();
return;
}
done = true;
cleanups.clear();
start();
};
if (typeof window.requestIdleCallback === "function") {
const handle = window.requestIdleCallback(run, { timeout: IDLE_TIMEOUT_MS });
add(() => window.cancelIdleCallback(handle));
} else {
const handle = window.setTimeout(run, 200);
add(() => window.clearTimeout(handle));
}
};
// Two frames: the page's primary content has painted.
let second: number | undefined;
const first = window.requestAnimationFrame(() => { second = window.requestAnimationFrame(step); });
add(() => {
window.cancelAnimationFrame(first);
if (second !== undefined) window.cancelAnimationFrame(second);
});
return cancel;
}
/**
* Make opening a recent task from the sidebar nearly instant.
*
* Once Recents has painted, warm the task-detail code as task lists do. Then,
* once the page is idle, read the chat's first frame for the most recent tasks
* in one `chat-bootstrap` request and seed it into the task page's caches.
* At most one request per account per page load; none for an empty list, a
* hidden tab or tasks whose first frame is already cached and fresh. Live
* updates keep seeded entries fresh afterwards, one task at a time.
*/
export function useRecentTaskOpenWarmup({
companyId,
userId,
tasks,
}: {
companyId: string | null | undefined;
userId: string | null | undefined;
tasks: readonly RecentTaskOpenWarmupTask[];
}) {
const queryClient = useQueryClient();
const hasTasks = tasks.length > 0;
useWarmTaskDetailCode(hasTasks);
// Read when the preload starts. A reorder must not cancel a request in flight.
const latestTasks = useRef(tasks);
latestTasks.current = tasks;
useEffect(() => {
if (!companyId || !hasTasks || typeof window.requestAnimationFrame !== "function") return;
const scope = `${companyId}:${userId ?? "__local_board__"}`;
if (preloadedScopes.has(scope)) return;
const controller = new AbortController();
const cancel = whenPageSettles(queryClient, () => {
if (preloadedScopes.has(scope)) return;
preloadedScopes.add(scope);
const ids = latestTasks.current
.slice(0, ISSUE_CHAT_BOOTSTRAP_MAX_IDS)
.filter((task) => !hasFreshIssueChatFirstFrame(queryClient, task.identifier ? [task.id, task.identifier] : [task.id]))
.map((task) => task.id);
if (!ids.length) return;
const requestedAt = Date.now();
// A synchronous throw (bad input) is also only a missed preload.
Promise.resolve().then(() => issuesApi.chatBootstrap(companyId, ids, { signal: controller.signal })).then((response) => {
if (controller.signal.aborted) return;
for (const item of response.items) seedIssueChatBootstrap(queryClient, item, { companyId, requestedAt });
}).catch(() => {
// Best effort: a task opened later reads its own first frame.
});
});
return () => {
cancel();
controller.abort();
};
}, [companyId, hasTasks, queryClient, userId]);
}
+13 -1
View File
@@ -81,6 +81,18 @@ export async function fetchRecentIssueRunWindow(
const existingRunIds = new Set(recent.map((run) => run.runId));
previous = previous.filter((run) => existingRunIds.has(run.runId));
}
return buildRecentIssueRunWindow(recent, previous, !reconciledFullHistory && previousWindow?.hasEarlierRuns === true);
}
/**
* The run window for a `/runs?view=recent` answer. `previous` holds runs
* already observed while the task is open; a cold open passes none.
*/
export function buildRecentIssueRunWindow(
recent: RunForIssue[],
previous: RunForIssue[] = [],
previouslyHadEarlierRuns = false,
): RecentIssueRunWindow {
// The second row refreshes a just-finished predecessor. Start with only
// the latest run, then keep every run observed while this task is open.
const visible = new Set([...previous.map((run) => run.runId), ...recent.slice(0, 1).map((run) => run.runId)]);
@@ -89,7 +101,7 @@ export async function fetchRecentIssueRunWindow(
// A first run is already visible. The disclosure needs evidence of an
// omitted run, and must retain that evidence as later turns arrive.
hasEarlierRuns: recent.some((run) => !visible.has(run.runId))
|| (!reconciledFullHistory && recent.length >= 2 && previousWindow?.hasEarlierRuns === true),
|| (recent.length >= 2 && previouslyHadEarlierRuns),
};
}
+157
View File
@@ -0,0 +1,157 @@
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);
});
});
+135
View File
@@ -0,0 +1,135 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { IssueChatBootstrap, IssueRecentComments } from "@paperclipai/shared";
import type { RunForIssue } from "@/api/activity";
import type { ActiveRunForIssue, LiveRunForIssue } from "@/api/heartbeats";
import { seedIssueIdBatch } from "@/hooks/useIssueIdBatches";
import { seedIssueDetailProjections } from "./issueDetailCache";
import {
buildRecentIssueRunWindow,
issueCommentInputFactsQueryKey,
issueInputCommentIds,
recentIssueActivityQueryKey,
recentIssueRunWindowQueryKey,
} from "./issue-chat-history-queries";
import { queryKeys } from "./queryKeys";
export type IssueChatBootstrapResponse = IssueChatBootstrap<RunForIssue, LiveRunForIssue, ActiveRunForIssue>;
export type IssueChatBootstrapEntry = IssueChatBootstrapResponse["items"][number];
/** How long seeded first-frame data counts as fresh: the app's query stale time. */
export const ISSUE_CHAT_BOOTSTRAP_FRESH_MS = 30_000;
/** The reference the caller asked for, plus the task's id and identifier. */
function bootstrapRefs(item: Pick<IssueChatBootstrapEntry, "ref" | "issue">) {
return [...new Set([item.ref, item.issue.id, item.issue.identifier].filter((ref): ref is string => !!ref))];
}
function coreKey(ref: string): QueryKey {
return [...queryKeys.issues.detail(ref), "core"];
}
function recentCommentsKey(ref: string): QueryKey {
return [...queryKeys.issues.comments(ref), "recent"];
}
function isFresh(queryClient: QueryClient, key: QueryKey, now: number) {
const state = queryClient.getQueryState(key);
return state?.data !== undefined && !state.isInvalidated && now - state.dataUpdatedAt < ISSUE_CHAT_BOOTSTRAP_FRESH_MS;
}
/**
* True when every read that gates a task's first chat frame is cached and
* fresh under one of its refs. A light warm (core issue and recent comments
* only) is not a first frame: the task still needs the preload.
*/
export function hasFreshIssueChatFirstFrame(queryClient: QueryClient, refs: readonly string[], now = Date.now()) {
return refs.some((ref) => {
const keys = [
coreKey(ref),
recentCommentsKey(ref),
recentIssueActivityQueryKey(ref),
recentIssueRunWindowQueryKey(ref),
queryKeys.issues.interactions(ref),
queryKeys.issues.attachments(ref),
queryKeys.issues.liveRuns(ref),
];
if (!keys.every((key) => isFresh(queryClient, key, now))) return false;
// The input facts of every saved recent comment, in the batches the chat reads.
const comments = queryClient.getQueryData<IssueRecentComments>(recentCommentsKey(ref))?.comments ?? [];
const factsKey = issueCommentInputFactsQueryKey(ref);
const batches = queryClient.getQueryData<string[][]>([...factsKey, "batch-ids"]) ?? [];
return issueInputCommentIds(comments).every((id) => {
const batch = batches.find((ids) => ids.includes(id));
return !!batch && isFresh(queryClient, [...factsKey, "batch", batch], now);
});
});
}
/**
* Write `data` under `key` unless the cache holds something at least as new.
*
* A key with a read in flight or a pending invalidation is left to that read.
* `replace` endpoints return the whole answer, so older cached data may be
* replaced. The others merge what the open page observed (comments, activity,
* runs), so only an empty key is filled.
*/
function seedKey(queryClient: QueryClient, key: QueryKey, data: unknown, requestedAt: number, replace: boolean) {
// A missing field (an older server, or a partial answer) is not an empty answer.
if (data === undefined) return false;
const state = queryClient.getQueryState(key);
if (state) {
if (state.fetchStatus === "fetching" || state.isInvalidated) return false;
if (state.data !== undefined && (!replace || state.dataUpdatedAt >= requestedAt)) return false;
}
// Stamped with the request time, not the arrival time, so staleness counts
// from when the server could last have read the task.
queryClient.setQueryData(key, data, { updatedAt: requestedAt });
return true;
}
/**
* Seed one chat-bootstrap item into the query keys the task page reads before
* its first frame, under every reference the task can be opened by.
*
* The item's `issue` is the `?view=core` payload, so it fills the core key and
* its projections, never the full detail key: core cannot stand in for the
* detail read's related data. Items for another company are ignored.
*/
export function seedIssueChatBootstrap(
queryClient: QueryClient,
item: IssueChatBootstrapEntry,
options: { companyId: string; requestedAt: number },
) {
const { companyId, requestedAt } = options;
if (!item?.issue || item.issue.companyId !== companyId) return false;
const inputCommentIds = item.recentComments ? issueInputCommentIds(item.recentComments.comments) : [];
for (const ref of bootstrapRefs(item)) {
seedKey(queryClient, coreKey(ref), item.issue, requestedAt, true);
seedKey(queryClient, recentCommentsKey(ref), item.recentComments, requestedAt, false);
if (item.commentInputFacts !== undefined) {
seedIssueIdBatch(queryClient, issueCommentInputFactsQueryKey(ref), inputCommentIds, item.commentInputFacts, requestedAt);
}
seedKey(
queryClient,
recentIssueActivityQueryKey(ref),
item.recentActivity && [...new Map(item.recentActivity.map((event) => [event.id, event])).values()],
requestedAt,
false,
);
seedKey(
queryClient,
recentIssueRunWindowQueryKey(ref),
item.recentRuns && buildRecentIssueRunWindow(item.recentRuns),
requestedAt,
false,
);
seedKey(queryClient, queryKeys.issues.interactions(ref), item.interactions, requestedAt, true);
seedKey(queryClient, queryKeys.issues.attachments(ref), item.attachments, requestedAt, true);
seedKey(queryClient, queryKeys.issues.liveRuns(ref), item.liveRuns, requestedAt, true);
// `null` is an answer: the task has no active run.
seedKey(queryClient, queryKeys.issues.activeRun(ref), item.activeRun, requestedAt, true);
}
// Plan document and work-product projections, filled only where empty.
seedIssueDetailProjections(queryClient, item.issue, item.ref);
return true;
}