From 7329eaf35302bb2a8f5dcfb1cbf86106229b421c Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:00:13 -0600 Subject: [PATCH] perf(ui): open sidebar Recents tasks from cache (#20450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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/` (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: 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 --- tests/perf/collections/README.md | 14 ++ .../perf/collections/recent-task-open.spec.ts | 183 ++++++++++++++++++ ui/src/api/issues.ts | 17 +- ui/src/components/CoordinatorSidebar.tsx | 8 +- ui/src/components/DesignSandboxSidebar.tsx | 2 + ui/src/components/DesignSidebar.tsx | 3 +- ui/src/components/IssueLinkQuicklook.tsx | 40 +--- ui/src/components/RecentTaskNavItem.test.tsx | 105 ++++++++++ ui/src/components/RecentTaskNavItem.tsx | 26 +++ ui/src/components/SidebarNavItem.tsx | 5 + ui/src/components/SidebarRecentTasks.test.tsx | 14 +- ui/src/components/SidebarRecentTasks.tsx | 8 +- ui/src/hooks/useIssueIdBatches.ts | 20 ++ ui/src/hooks/useIssueNavigationPrefetch.ts | 58 ++++++ ui/src/hooks/useRecentTaskOpenWarmup.test.tsx | 183 ++++++++++++++++++ ui/src/hooks/useRecentTaskOpenWarmup.ts | 130 +++++++++++++ ui/src/lib/issue-chat-history-queries.ts | 14 +- ui/src/lib/issueChatBootstrapCache.test.ts | 157 +++++++++++++++ ui/src/lib/issueChatBootstrapCache.ts | 135 +++++++++++++ 19 files changed, 1079 insertions(+), 43 deletions(-) create mode 100644 tests/perf/collections/recent-task-open.spec.ts create mode 100644 ui/src/components/RecentTaskNavItem.test.tsx create mode 100644 ui/src/components/RecentTaskNavItem.tsx create mode 100644 ui/src/hooks/useIssueNavigationPrefetch.ts create mode 100644 ui/src/hooks/useRecentTaskOpenWarmup.test.tsx create mode 100644 ui/src/hooks/useRecentTaskOpenWarmup.ts create mode 100644 ui/src/lib/issueChatBootstrapCache.test.ts create mode 100644 ui/src/lib/issueChatBootstrapCache.ts diff --git a/tests/perf/collections/README.md b/tests/perf/collections/README.md index 25a2e875003..66c5c778290 100644 --- a/tests/perf/collections/README.md +++ b/tests/perf/collections/README.md @@ -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 diff --git a/tests/perf/collections/recent-task-open.spec.ts b/tests/perf/collections/recent-task-open.spec.ts new file mode 100644 index 00000000000..12c37cacab7 --- /dev/null +++ b/tests/perf/collections/recent-task-open.spec.ts @@ -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 { + 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" }); + }); +} diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 1be23279292..16dfffa285c 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -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(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>(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, options?: RequestOptions): Promise => { const pageSize = 500; diff --git a/ui/src/components/CoordinatorSidebar.tsx b/ui/src/components/CoordinatorSidebar.tsx index d33e596fa21..27ee46e9560 100644 --- a/ui/src/components/CoordinatorSidebar.tsx +++ b/ui/src/components/CoordinatorSidebar.tsx @@ -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 &&
{recents.map(item => item.kind === "task" - ? } /> + ? } /> : item.kind === "chat" ? } /> : )} diff --git a/ui/src/components/DesignSandboxSidebar.tsx b/ui/src/components/DesignSandboxSidebar.tsx index bb52b1a1f13..b72c20541f4 100644 --- a/ui/src/components/DesignSandboxSidebar.tsx +++ b/ui/src/components/DesignSandboxSidebar.tsx @@ -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 openNewIssue()} />; } diff --git a/ui/src/components/DesignSidebar.tsx b/ui/src/components/DesignSidebar.tsx index 7489c4bed19..c0cfd9f0a0e 100644 --- a/ui/src/components/DesignSidebar.tsx +++ b/ui/src/components/DesignSidebar.tsx @@ -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 ?
- {companyRecentTasks.map(issue => )} + {companyRecentTasks.map(issue => )}
: null} diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx index ca87c4f498c..106f19f22dc 100644 --- a/ui/src/components/IssueLinkQuicklook.tsx +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -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 | 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 = ( { - 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) => { diff --git a/ui/src/components/RecentTaskNavItem.test.tsx b/ui/src/components/RecentTaskNavItem.test.tsx new file mode 100644 index 00000000000..17e8af4c129 --- /dev/null +++ b/ui/src/components/RecentTaskNavItem.test.tsx @@ -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); + }) => ( + {children} + ), +})); +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(); + }); + 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); + }); +}); diff --git a/ui/src/components/RecentTaskNavItem.tsx b/ui/src/components/RecentTaskNavItem.tsx new file mode 100644 index 00000000000..350843cd029 --- /dev/null +++ b/ui/src/components/RecentTaskNavItem.tsx @@ -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, "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 ; +} diff --git a/ui/src/components/SidebarNavItem.tsx b/ui/src/components/SidebarNavItem.tsx index ed1a17afc8e..029c25331ba 100644 --- a/ui/src/components/SidebarNavItem.tsx +++ b/ui/src/components/SidebarNavItem.tsx @@ -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; } 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( diff --git a/ui/src/components/SidebarRecentTasks.test.tsx b/ui/src/components/SidebarRecentTasks.test.tsx index b102ae40a51..1239f1bdee1 100644 --- a/ui/src/components/SidebarRecentTasks.test.tsx +++ b/ui/src/components/SidebarRecentTasks.test.tsx @@ -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", diff --git a/ui/src/components/SidebarRecentTasks.tsx b/ui/src/components/SidebarRecentTasks.tsx index 54c6ceaf742..20e18e5da1e 100644 --- a/ui/src/components/SidebarRecentTasks.tsx +++ b/ui/src/components/SidebarRecentTasks.tsx @@ -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({ {entries.map((entry) => (
- : undefined} trailing={entry.status === "in_review" && entry.externalConversationState === "waiting" diff --git a/ui/src/hooks/useIssueIdBatches.ts b/ui/src/hooks/useIssueIdBatches.ts index ca476ca4d63..be0beb8e57c 100644 --- a/ui/src/hooks/useIssueIdBatches.ts +++ b/ui/src/hooks/useIssueIdBatches.ts @@ -34,6 +34,26 @@ export function prefetchIssueIdBatches( }))).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( + 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([...key, "batch-ids"])?.length) return false; + const batch = [...ids]; + client.setQueryData([...key, "batch-ids"], [batch]); + client.setQueryData([...key, "batch", batch], data, { updatedAt }); + return true; +} + export function useIssueIdBatches(key: readonly unknown[], ids: string[], fetchBatch: (ids: string[]) => Promise, enabled: boolean) { const client = useQueryClient(); const serializedKey = JSON.stringify(key); diff --git a/ui/src/hooks/useIssueNavigationPrefetch.ts b/ui/src/hooks/useIssueNavigationPrefetch.ts new file mode 100644 index 00000000000..732ffe5641c --- /dev/null +++ b/ui/src/hooks/useIssueNavigationPrefetch.ts @@ -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) => void; + onMouseLeave: (event: MouseEvent) => void; + onPointerDown: (event: PointerEvent) => void; + onFocus: (event: FocusEvent) => void; + onBlur: (event: FocusEvent) => void; + onTouchStart: (event: TouchEvent) => void; + onClickCapture: (event: MouseEvent) => 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 | 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]); +} diff --git a/ui/src/hooks/useRecentTaskOpenWarmup.test.tsx b/ui/src/hooks/useRecentTaskOpenWarmup.test.tsx new file mode 100644 index 00000000000..d6881997303 --- /dev/null +++ b/ui/src/hooks/useRecentTaskOpenWarmup.test.tsx @@ -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( + + + , + ); + }); +} + +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((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); + }); +}); diff --git a/ui/src/hooks/useRecentTaskOpenWarmup.ts b/ui/src/hooks/useRecentTaskOpenWarmup.ts new file mode 100644 index 00000000000..861c26a20c3 --- /dev/null +++ b/ui/src/hooks/useRecentTaskOpenWarmup.ts @@ -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(); + +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]); +} diff --git a/ui/src/lib/issue-chat-history-queries.ts b/ui/src/lib/issue-chat-history-queries.ts index 69bbc39b15f..58bf0cdaed9 100644 --- a/ui/src/lib/issue-chat-history-queries.ts +++ b/ui/src/lib/issue-chat-history-queries.ts @@ -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), }; } diff --git a/ui/src/lib/issueChatBootstrapCache.test.ts b/ui/src/lib/issueChatBootstrapCache.test.ts new file mode 100644 index 00000000000..bffae918095 --- /dev/null +++ b/ui/src/lib/issueChatBootstrapCache.test.ts @@ -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 = {}) { + 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 { + 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).liveRuns; + delete (entry as Partial).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); + }); +}); diff --git a/ui/src/lib/issueChatBootstrapCache.ts b/ui/src/lib/issueChatBootstrapCache.ts new file mode 100644 index 00000000000..8d3aa3cc5ef --- /dev/null +++ b/ui/src/lib/issueChatBootstrapCache.ts @@ -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; +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) { + 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(recentCommentsKey(ref))?.comments ?? []; + const factsKey = issueCommentInputFactsQueryKey(ref); + const batches = queryClient.getQueryData([...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; +}