diff --git a/tests/e2e/chat-adapters-ui-messaging.spec.ts b/tests/e2e/chat-adapters-ui-messaging.spec.ts index 4f54b24dae3..0cff3371239 100644 --- a/tests/e2e/chat-adapters-ui-messaging.spec.ts +++ b/tests/e2e/chat-adapters-ui-messaging.spec.ts @@ -1072,6 +1072,9 @@ test.describe("Board send delivery refresh", () => { assignedAgentLocked: true, }), ); + // The comment and attachment reads below are faked. The chat bootstrap + // would answer them from the server, so make the page read them itself. + await page.route(/\/chat-bootstrap(?:\?|$)/, (route) => route.abort()); await page.route( new RegExp( `/api/issues/(${issue.id}|${issue.identifier})/(?:recent-comments|comments)(?:\\?|$)`, diff --git a/tests/e2e/disconnect-write-recovery.spec.ts b/tests/e2e/disconnect-write-recovery.spec.ts index 0f43d64fe2a..e4041f33261 100644 --- a/tests/e2e/disconnect-write-recovery.spec.ts +++ b/tests/e2e/disconnect-write-recovery.spec.ts @@ -119,9 +119,14 @@ test("a cold task waits for the first session and retains a title saved during a await sessionReady; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(session) }); }); + // The task arrives in the core read or, on a production build, in the + // chat bootstrap that the page's HTML starts. const coreReady = page.waitForResponse(response => { const url = new URL(response.url()); - return url.pathname === `/api/issues/${issue.identifier}` && url.searchParams.get("view") === "core" && response.ok(); + return response.ok() && ( + (url.pathname === `/api/issues/${issue.identifier}` && url.searchParams.get("view") === "core") + || url.pathname === `/api/issues/${issue.identifier}/chat-bootstrap` + ); }); await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`); await coreReady; diff --git a/tests/e2e/task-loading-frame.spec.ts b/tests/e2e/task-loading-frame.spec.ts index f452c04a3c6..a4bc1462b7a 100644 --- a/tests/e2e/task-loading-frame.spec.ts +++ b/tests/e2e/task-loading-frame.spec.ts @@ -233,7 +233,8 @@ test("a seeded Classic loading title uses the resolved title typography", async await page.goto(`/${fixture.company.issuePrefix}/issues`); await page.getByRole("link").filter({ hasText: fixture.title }).first().click(); await expect(page.getByTestId("issue-detail-header").getByRole("heading", { name: fixture.title, exact: true })).toBeVisible(); - await page.route("**/api/issues/*", async route => { await coreReady; await route.continue(); }); + // Also hold the hard load's early chat bootstrap: it carries the task too. + await page.route(/\/api\/issues\/[^/?]+(?:\/chat-bootstrap)?(?:\?|$)/, async route => { await coreReady; await route.continue(); }); // Keep the recent-task title seed, but exercise the loading frame without // a persisted full task that would correctly reveal the resolved header. await page.addInitScript(() => Object.keys(localStorage).filter(key => key.startsWith("paperclip:query-cache:v1:")).forEach(key => localStorage.removeItem(key))); diff --git a/tests/perf/issue-detail/README.md b/tests/perf/issue-detail/README.md index 27069fa031f..d62865fc166 100644 --- a/tests/perf/issue-detail/README.md +++ b/tests/perf/issue-detail/README.md @@ -24,6 +24,12 @@ Override the sample count (minimum five) or port when needed: PAPERCLIP_ISSUE_PERF_RUNS=7 PAPERCLIP_ISSUE_PERF_PORT=3210 pnpm exec playwright test --config tests/perf/issue-detail/playwright.config.ts ``` +S1 clicks as soon as the list row is visible. Set +`PAPERCLIP_ISSUE_PERF_LIST_DWELL_MS=1500` to wait on the list first, as a +person does. Task lists preload the chat of their top rows after the list's +own reads settle, so S1 with a dwell measures a preloaded open and S1 without +one measures an open that usually has to read the task. + Outputs include `baseline.md`, `baseline.json`, and a Chrome trace for the first run of each scenario/profile. Open `*.trace.json` in Chrome DevTools Performance to inspect the `issue-detail:*` user-timing marks. Set `PAPERCLIP_ISSUE_PERF_PROFILE=1` to capture actual Chrome CPU samples and diff --git a/tests/perf/issue-detail/issue-detail.perf.spec.ts b/tests/perf/issue-detail/issue-detail.perf.spec.ts index e469e442533..f678bd47940 100644 --- a/tests/perf/issue-detail/issue-detail.perf.spec.ts +++ b/tests/perf/issue-detail/issue-detail.perf.spec.ts @@ -7,6 +7,7 @@ const CAPTURE_PROFILE = process.env.PAPERCLIP_ISSUE_PERF_PROFILE === "1"; const RUNS = CAPTURE_PROFILE ? 1 : Number.isFinite(requestedRuns) ? Math.max(5, Math.floor(requestedRuns)) : 5; const OUTPUT_DIR = path.resolve(process.cwd(), "test-results/issue-detail-perf"); const PAGE_READY_TIMEOUT_MS = 90_000; +const LIST_DWELL_MS = Math.max(0, Number(process.env.PAPERCLIP_ISSUE_PERF_LIST_DWELL_MS ?? 0) || 0); const HEADER_MEASURE = "issue-detail:navigate→header-paint"; const CONTENT_MEASURE = "issue-detail:navigate→content-paint"; @@ -256,6 +257,10 @@ async function runScenario(browser: Browser, baseURL: string, seedData: Seed, pr await page.goto(`/${seedData.prefix}/issues`); const issueLink = page.locator("[data-inbox-issue-link]", { hasText: seedData.title }).first(); await expect(issueLink).toBeVisible({ timeout: PAGE_READY_TIMEOUT_MS }); + // Optional time spent reading the list before the click, as a person does. + // Background work the list starts after painting (such as preloading the + // top tasks) only helps when the click comes after it. + if (LIST_DWELL_MS > 0) await page.waitForTimeout(LIST_DWELL_MS); network.clear(); } if (captureProfile) { diff --git a/tests/perf/issue-detail/layout-stability.spec.ts b/tests/perf/issue-detail/layout-stability.spec.ts index 8b430037e10..4818064f7dd 100644 --- a/tests/perf/issue-detail/layout-stability.spec.ts +++ b/tests/perf/issue-detail/layout-stability.spec.ts @@ -73,12 +73,17 @@ test.afterEach(async ({ page }, info) => { test("Inbox click reveals once after reordered initial responses; refresh retains rows", async ({ page }) => { await installMeasurements(page); + // Exercise the separate reads: a failed chat bootstrap (the list's preload + // and the open's own) falls back to them. + await page.route(/\/chat-bootstrap(?:\?|$)/, (route) => route.abort()); await page.goto(`/${prefix}/inbox/all`); const link = page.getByRole("link", { name: new RegExp(`Open ${issue.identifier}:`) }); await expect(link).toBeVisible({ timeout: 90_000 }); let release!: () => void; const held = new Promise((resolve) => { release = resolve; }); - await page.route("**/api/issues/*/comments?*", async (route) => { await held; await route.continue(); }); + // The compact chat's comment window arrives last. (The chat no longer reads + // `/comments?…` on open, so holding that read did not delay the reveal.) + await page.route("**/api/issues/*/recent-comments", async (route) => { await held; await route.continue(); }); await link.click(); await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="true"]')).toBeVisible(); await expect(page.getByText("Historical message 159", { exact: true })).not.toBeVisible(); @@ -94,6 +99,34 @@ test("Inbox click reveals once after reordered initial responses; refresh retain expect(Math.max(...visibleFrames.map((f) => f.offset)) - Math.min(...visibleFrames.map((f) => f.offset))).toBeLessThanOrEqual(2); }); +test("a task the list preloaded reveals once at the latest message, without its own chat reads", async ({ page }) => { + await installMeasurements(page); + const preloaded = page.waitForResponse(response => /\/chat-bootstrap\?/.test(response.url()) && response.ok(), { timeout: 90_000 }); + await page.goto(`/${prefix}/inbox/all`); + const link = page.getByRole("link", { name: new RegExp(`Open ${issue.identifier}:`) }); + await expect(link).toBeVisible({ timeout: 90_000 }); + await preloaded; + const chatReads: string[] = []; + let opening = true; + page.on("request", request => { + if (opening && /\/api\/issues\/[^/]+\/(recent-comments|activity|runs|interactions|attachments)(\?|$)/.test(request.url())) chatReads.push(request.url()); + }); + await link.click(); + await ready(page); + // Later refreshes (such as after the task is marked read) are not part of the open. + opening = false; + const scroller = page.getByTestId("task-chat-scroller"); + await expect.poll(() => scroller.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight)).toBeLessThan(2); + await page.waitForTimeout(1200); + const frames = await page.evaluate(() => (window as unknown as { taskLayoutEvidence: { frames: { busy: string; anchor: string; offset: number }[] } }).taskLayoutEvidence.frames); + const visibleFrames = frames.filter((frame) => frame.busy === "false"); + expect(visibleFrames.length).toBeGreaterThan(2); + expect(new Set(visibleFrames.map((f) => f.anchor)).size).toBe(1); + expect(Math.max(...visibleFrames.map((f) => f.offset)) - Math.min(...visibleFrames.map((f) => f.offset))).toBeLessThanOrEqual(2); + // The open rendered from the preloaded cache instead of reading the chat again. + expect(chatReads).toEqual([]); +}); + test("reading anchor survives late media, composer resizing, older history and browser Back", async ({ page }) => { await page.route("**/api/workspace-preferences/me", async route => { const response = await route.fetch(); @@ -167,6 +200,8 @@ test("a loaded comment target positions after the reveal deadline when live-run let metadataReads = 0; const held = new Promise(resolve => { release = resolve; }); await page.route("**/api/issues/*/live-runs", async route => { metadataReads++; await held; await route.continue().catch(() => {}); }); + // Exercise the separate reads: a failed chat bootstrap falls back to them. + await page.route(/\/chat-bootstrap(?:\?|$)/, route => route.abort()); try { await page.goto(`/${prefix}/issues/${issue.identifier}#comment-${oldestCommentId}`); await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 20_000 }); diff --git a/ui/src/components/CoordinatorSidebar.test.tsx b/ui/src/components/CoordinatorSidebar.test.tsx index 2bfe5a03d38..ab03a60606d 100644 --- a/ui/src/components/CoordinatorSidebar.test.tsx +++ b/ui/src/components/CoordinatorSidebar.test.tsx @@ -17,6 +17,11 @@ vi.mock("@/hooks/useAgentChatNavigation", () => ({ useAgentChatNavigation: () => }) })); vi.mock("@/hooks/useRecentTasks", () => ({ useRecentTasks: () => ({ entries: fixture.recentTasks }) })); vi.mock("@/api/issues", () => ({ issuesApi: { listCompact: fixture.projectTasks } })); +const taskPrefetch = vi.hoisted(() => vi.fn(() => Promise.resolve())); +vi.mock("@/lib/issueDetailCache", async (importOriginal) => ({ + ...await importOriginal(), + prefetchIssueDetailForNavigation: taskPrefetch, +})); vi.mock("@/context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: fixture.openNewIssue }) })); vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ collapsed: fixture.collapsed, peeking: false, isMobile: false, setSidebarOpen: vi.fn() }) })); vi.mock("@/api/attention", () => ({ attentionApi: { list: fixture.attention, summary: fixture.summary } })); @@ -293,5 +298,10 @@ describe("Coordinator task status glyphs (BUG-07)", () => { expect(fixture.projectTasks).toHaveBeenCalledWith("company", expect.objectContaining({ projectId: "project-1", projection: "rows" })); expect(glyph("/issues/ONB-1")?.style.color).toBe("var(--status-task-icon-in_queue)"); expect(glyph("/issues/ONB-3")?.getAttribute("aria-label")).toBe("Idle"); + // A press on a project task warms its chat like a task list row does. + const link = container.querySelector('a[href="/issues/ONB-3"]'); + taskPrefetch.mockClear(); + await act(async () => link!.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, button: 0 }))); + expect(taskPrefetch).toHaveBeenCalledWith(expect.anything(), "ONB-3", expect.objectContaining({ history: true })); }); }); diff --git a/ui/src/components/CoordinatorSidebar.tsx b/ui/src/components/CoordinatorSidebar.tsx index 0558cdcd190..d62a88759eb 100644 --- a/ui/src/components/CoordinatorSidebar.tsx +++ b/ui/src/components/CoordinatorSidebar.tsx @@ -75,7 +75,7 @@ function ProjectRecent({ project }: { project: Project }) { {expanded &&
{isPending ?

Loading tasks…

: error ?

Couldn’t load tasks.

: tasks?.length ? tasks.map(task => - } />) + } />) :

No tasks yet.

}
} ; diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 9d1202e1ed6..48c6881f6ab 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -2,6 +2,7 @@ import { QueryErrorState } from "./QueryView"; import { ISSUE_SORT_FIELDS, sortIssues, type IssueSort, type IssueSortField } from "../lib/issue-sort"; import { useIssueRowDetails } from "../hooks/useIssueRowDetails"; import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode"; +import { topRenderedNavEntryRefs, usePreloadTaskChats } from "../hooks/usePreloadTaskChats"; import { IssueRowDetailsNotice } from "./IssueRowDetailsNotice"; import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks"; import { useIssueHierarchyContext } from "../hooks/useIssueHierarchyContext"; @@ -453,6 +454,8 @@ type IssueListRequestFilters = NonNullable[1]> interface IssuesListProps { issues: Issue[]; + /** Preload the chat of the top visible rows. Only the top-level tasks page sets this. */ + preloadTaskChats?: boolean; isLoading?: boolean; error?: Error | null; onRetry?: () => void; @@ -711,6 +714,7 @@ export function IssuesList(props: IssuesListProps) { function StreamlinedIssuesList({ issues, + preloadTaskChats = false, isLoading, error, onRetry, @@ -1483,6 +1487,13 @@ function StreamlinedIssuesList({ viewState.collapsedParents, viewState.nestingEnabled, ]); + // The top rows the list renders, in display order (collapsed groups and + // parents hide theirs), so the preload warms what a person can click. + const preloadTaskRefs = useMemo( + () => preloadTaskChats ? topRenderedNavEntryRefs(flatNavEntries, renderedIssueRowLimit) : [], + [flatNavEntries, preloadTaskChats, renderedIssueRowLimit], + ); + usePreloadTaskChats(selectedCompanyId, preloadTaskRefs, !collectionLoading && !error && preloadTaskRefs.length > 0); const { enrichIssueRow, error: rowDetailsError, retry: retryRowDetails } = useIssueRowDetails( selectedCompanyId, rootRef, diff --git a/ui/src/components/LegacyIssuesList.tsx b/ui/src/components/LegacyIssuesList.tsx index 0c72812c7c6..91751bdb049 100644 --- a/ui/src/components/LegacyIssuesList.tsx +++ b/ui/src/components/LegacyIssuesList.tsx @@ -2,6 +2,7 @@ import { QueryErrorState } from "./QueryView"; import { ISSUE_SORT_FIELDS, sortIssues, type IssueSort, type IssueSortField } from "../lib/issue-sort"; import { saveTaskCollectionPreferences } from "../lib/task-collection-preferences"; import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode"; +import { topRenderedNavEntryRefs, usePreloadTaskChats } from "../hooks/usePreloadTaskChats"; import { useShowAgentCreatedTasks } from "../hooks/useShowAgentCreatedTasks"; import { useIssueHierarchyContext } from "../hooks/useIssueHierarchyContext"; import { includeIssueHierarchyContext } from "../lib/issue-hierarchy-context"; @@ -438,6 +439,8 @@ type ProjectOption = Pick & Partial[1]>; interface IssuesListProps { + /** Preload the chat of the top visible rows. Only the top-level tasks page sets this. */ + preloadTaskChats?: boolean; issues: Issue[]; isLoading?: boolean; error?: Error | null; @@ -663,6 +666,7 @@ const MOBILE_TREE_INDENT = ["", "pl-4 sm:pl-0", "pl-8 sm:pl-0", "pl-12 sm:pl-0", export function IssuesList({ issues, + preloadTaskChats = false, isLoading, error, onRetry, @@ -1376,6 +1380,13 @@ export function IssuesList({ viewState.collapsedParents, viewState.nestingEnabled, ]); + // The top rows the list renders, in display order (collapsed groups and + // parents hide theirs), so the preload warms what a person can click. + const preloadTaskRefs = useMemo( + () => preloadTaskChats ? topRenderedNavEntryRefs(flatNavEntries, renderedIssueRowLimit) : [], + [flatNavEntries, preloadTaskChats, renderedIssueRowLimit], + ); + usePreloadTaskChats(selectedCompanyId, preloadTaskRefs, !isLoading && !error && (!hierarchyEnabled || !hierarchy.isLoading) && preloadTaskRefs.length > 0); const listNavStateRef = useRef({ flatNavEntries, diff --git a/ui/src/components/RecentTaskNavItem.tsx b/ui/src/components/RecentTaskNavItem.tsx index 350843cd029..7b7db9003a3 100644 --- a/ui/src/components/RecentTaskNavItem.tsx +++ b/ui/src/components/RecentTaskNavItem.tsx @@ -8,8 +8,9 @@ export function recentTaskPathRef(task: { id: string; identifier: string | null } /** - * A Recents task link. It warms the task on open intent the same way task - * lists do, under the reference the task page reads. + * A sidebar task link (Recents, a project's recent tasks). It warms the task + * on open intent the same way task lists do, under the reference the task + * page reads. */ export function RecentTaskNavItem({ task, diff --git a/ui/src/components/TaskList.preload.test.tsx b/ui/src/components/TaskList.preload.test.tsx new file mode 100644 index 00000000000..f0fe523844c --- /dev/null +++ b/ui/src/components/TaskList.preload.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { act, type ComponentProps } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createIssue } from "../../storybook/fixtures/paperclipData"; +import { TaskList, topRenderedTaskRefs, type TaskListItem } from "./TaskList"; +import { TooltipProvider } from "./ui/tooltip"; + +const preloads = vi.hoisted(() => [] as Array<{ companyId: string | null | undefined; refs: string[] }>); +vi.mock("../hooks/usePreloadTaskChats", () => ({ + usePreloadTaskChats: (companyId: string | null | undefined, refs: readonly string[]) => { preloads.push({ companyId, refs: [...refs] }); }, +})); +vi.mock("@/lib/router", () => ({ + Link: ({ children, to, state: _state, disableIssueQuicklook: _quicklook, issuePrefetch: _prefetch, ...props }: + ComponentProps<"a"> & { to?: string; state?: unknown; disableIssueQuicklook?: boolean; issuePrefetch?: unknown }) => + {children}, +})); + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const items: TaskListItem[] = Array.from({ length: 14 }, (_, index) => ({ + issue: createIssue({ + id: `task-${index}`, + identifier: `PAP-${index}`, + title: `Task ${index}`, + parentId: null, + // Newest first is the default order: PAP-13 is the top row. + updatedAt: new Date(Date.UTC(2026, 10, 1, 0, index)), + }), +})); + +describe("TaskList chat preload", () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + preloads.length = 0; + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + }); + + it("preloads the top ten rows in display order when the page asks for it", async () => { + await act(async () => root.render()); + const last = preloads.at(-1)!; + expect(last.companyId).toBe("company-1"); + expect(last.refs).toEqual(Array.from({ length: 10 }, (_, index) => `PAP-${13 - index}`)); + }); + + it("keeps display order in a large grouped and nested list", async () => { + // 600 tasks: 200 parents, each with two children, across two statuses. + const large: TaskListItem[] = []; + for (let parent = 0; parent < 200; parent += 1) { + const updatedAt = new Date(Date.UTC(2026, 10, 1, 0, 0, 600 - parent)); + large.push({ issue: createIssue({ id: `p-${parent}`, identifier: `P-${parent}`, title: `Parent ${parent}`, parentId: null, status: parent % 2 ? "todo" : "in_progress", updatedAt }) }); + for (const child of [0, 1]) { + large.push({ issue: createIssue({ id: `c-${parent}-${child}`, identifier: `C-${parent}-${child}`, title: `Child ${parent}.${child}`, parentId: `p-${parent}`, status: "todo", updatedAt }) }); + } + } + await act(async () => root.render()); + const refs = preloads.at(-1)!.refs; + expect(refs).toHaveLength(10); + // Rows in the order the list shows them: the first group's parents, each followed by its children. + const shown = [...host.querySelectorAll("a[href]")].map((link) => link.getAttribute("href")!.split("/").pop()!); + expect(refs).toEqual([...new Set(shown.filter((ref) => /^[PC]-/.test(ref)))].slice(0, 10)); + }); + + it("reads no row after the last one it preloads", () => { + let read = 0; + function* rows() { + for (let index = 0; index < 1_000; index += 1) { + read += 1; + if (read > 12) throw new Error("read past the preload limit"); + yield { id: `t-${index}`, identifier: `T-${index}` }; + } + } + // Rows 0 and 1 are not rendered yet, so they are skipped. + const rendered = new Set(Array.from({ length: 1_000 }, (_, index) => `t-${index}`).slice(2)); + expect(topRenderedTaskRefs(rows(), rendered, 10)).toEqual(Array.from({ length: 10 }, (_, index) => `T-${index + 2}`)); + expect(read).toBe(12); + }); + + it("preloads nothing for a list that does not ask, and needs no query client", async () => { + await act(async () => root.render()); + expect(preloads).toEqual([]); + expect(host.textContent).toContain("Task 13"); + }); +}); diff --git a/ui/src/components/TaskList.tsx b/ui/src/components/TaskList.tsx index 791219d0f37..9746ccf2942 100644 --- a/ui/src/components/TaskList.tsx +++ b/ui/src/components/TaskList.tsx @@ -18,6 +18,8 @@ import { AgentAvatar, type AvatarAgent } from "./AgentAvatar"; import { deriveInitials } from "./Identity"; import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; import { useTaskListColumnPreferences } from "../hooks/useTaskListColumnPreferences"; +import { usePreloadTaskChats } from "../hooks/usePreloadTaskChats"; +import { CHAT_BOOTSTRAP_PRELOAD_LIMIT } from "../lib/issue-chat-bootstrap"; import type { TaskCollectionPreferenceLocation } from "../lib/task-collection-preferences"; export type TaskListItem = { @@ -45,8 +47,29 @@ export function includeTaskListAncestors(items: readonly TaskListItem[], matches return items.filter(item => included.has(item.issue.id)); } +/** + * The route refs of the first `limit` rendered rows. Rows that are not + * rendered are skipped; no row after the last one returned is read. + */ +export function topRenderedTaskRefs(rows: Iterable>, rendered: ReadonlySet, limit: number) { + const refs: string[] = []; + if (limit <= 0) return refs; + for (const issue of rows) { + if (!rendered.has(issue.id)) continue; + refs.push(issue.identifier ?? issue.id); + if (refs.length >= limit) break; + } + return refs; +} + +/** Preloads the chats of a list's top rows. A component, so lists without it need no query client. */ +function TaskChatPreload({ companyId, refs }: { companyId: string; refs: readonly string[] }) { + usePreloadTaskChats(companyId, refs, true); + return null; +} + /** Opt-in collection built from the reviewed row, hierarchy and column primitives. */ -export function TaskList({ items, allItems = items, initialColumns = defaultTaskListColumns, density = "comfortable", collapsedIds, onCollapsedIdsChange, groupBy = "none", emptyState, issueLinkState, collectionKey, preferenceLocation, sort: controlledSort, onSortChange }: { +export function TaskList({ items, allItems = items, initialColumns = defaultTaskListColumns, density = "comfortable", collapsedIds, onCollapsedIdsChange, groupBy = "none", emptyState, issueLinkState, collectionKey, preferenceLocation, sort: controlledSort, onSortChange, preloadCompanyId }: { items: readonly TaskListItem[]; allItems?: readonly TaskListItem[]; initialColumns?: readonly TaskListColumnId[]; @@ -61,6 +84,8 @@ export function TaskList({ items, allItems = items, initialColumns = defaultTask preferenceLocation?: TaskCollectionPreferenceLocation; sort?: TaskListOrder; onSortChange?: (sort: TaskListSort | undefined) => void; + /** Preload the chat of the top rows for this company. Only a page's main task list sets this. */ + preloadCompanyId?: string | null; }) { const localKey = useId(); const { columns, order: columnOrder, setColumns, setOrder: setColumnOrder } = useTaskListColumnPreferences(preferenceLocation, initialColumns); @@ -142,6 +167,16 @@ export function TaskList({ items, allItems = items, initialColumns = defaultTask const label = groupBy === "status" ? issueFilterLabel(root.status) : groupBy === "project" ? root.project?.name ?? "No project" : ""; groups.set(label, [...(groups.get(label) ?? []), root]); } + // The top rendered rows, in display order. The walk is lazy: it stops after + // the preload limit, whatever the size of the list. + function* displayedRows(): Generator { + function* branch(issue: Issue): Generator { + yield issue; + if (!collapsed.has(issue.id)) for (const child of childMap.get(issue.id) ?? []) yield* branch(child); + } + for (const groupRoots of groups.values()) for (const root of groupRoots) yield* branch(root); + } + const preloadRefs = preloadCompanyId ? topRenderedTaskRefs(displayedRows(), renderedIds, CHAT_BOOTSTRAP_PRELOAD_LIMIT) : []; return ( setSort(undefined)} />}> @@ -160,6 +195,7 @@ export function TaskList({ items, allItems = items, initialColumns = defaultTask Rendering {Math.min(limit, rowIds.length)} of {rowIds.length} tasks : null} + {preloadCompanyId && preloadRefs.length ? : null} ); } diff --git a/ui/src/hooks/usePreloadTaskChats.test.ts b/ui/src/hooks/usePreloadTaskChats.test.ts new file mode 100644 index 00000000000..f495bfe0ef5 --- /dev/null +++ b/ui/src/hooks/usePreloadTaskChats.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import type { Issue } from "@paperclipai/shared"; +import { inboxNavEntryTask, type InboxKeyboardNavEntry, type InboxWorkItem } from "@/lib/inbox"; +import { topRenderedNavEntryRefs, topTaskRefs } from "./usePreloadTaskChats"; + +type Entry = Parameters[0][number]; + +const row = (index: number, budgetOrdinal = index + 1): Entry => + ({ type: "issue", issue: { id: `id-${index}`, identifier: `T-${index}` }, budgetOrdinal }); + +describe("topTaskRefs over a large grouped and nested Inbox", () => { + // 3,000 entries: a group header every 50 rows, non-task rows (approvals, + // runs) between tasks, and nested child tasks under their parents. + function inboxEntries(): InboxKeyboardNavEntry[] { + const entries: InboxKeyboardNavEntry[] = []; + for (let index = 0; entries.length < 3_000; index += 1) { + if (index % 50 === 0) entries.push({ type: "group", groupKey: `g-${index}`, label: `Group ${index}`, collapsed: false }); + if (index % 3 === 0) { + entries.push({ type: "top", itemKey: `approval-${index}`, item: { kind: "approval" } as unknown as InboxWorkItem }); + continue; + } + const issue = { id: `id-${index}`, identifier: `T-${index}` } as Issue; + entries.push({ type: "top", itemKey: `issue-${index}`, item: { kind: "issue", issue } as unknown as InboxWorkItem }); + entries.push({ type: "child", issueId: `child-${index}`, issue: { id: `child-${index}`, identifier: null } as unknown as Issue }); + } + return entries; + } + + it("takes the first ten tasks in display order, parents and children alike", () => { + expect(topTaskRefs(inboxEntries(), inboxNavEntryTask)).toEqual([ + "T-1", "child-1", "T-2", "child-2", "T-4", "child-4", "T-5", "child-5", "T-7", "child-7", + ]); + }); + + it("stops reading entries once it has ten tasks", () => { + let reads = 0; + const counted = function* () { + for (const entry of inboxEntries()) { + reads += 1; + if (reads > 20) throw new Error("read past the preload limit"); + yield entry; + } + }; + expect(topTaskRefs(counted(), inboxNavEntryTask)).toHaveLength(10); + // A header, ten tasks and the non-task rows between them. + expect(reads).toBe(14); + }); +}); + +describe("topRenderedNavEntryRefs", () => { + it("takes the first ten rendered rows in display order and skips group headers", () => { + const entries: Entry[] = [{ type: "group" }, row(0), row(1), { type: "group" }, ...Array.from({ length: 20 }, (_, index) => row(index + 2))]; + expect(topRenderedNavEntryRefs(entries, 100)).toEqual(Array.from({ length: 10 }, (_, index) => `T-${index}`)); + }); + + it("skips rows past the render budget", () => { + // Rows of a collapsed group consume budget but have no entries; later rows can fall past it. + const entries: Entry[] = [row(0, 1), row(1, 2), row(2, 150), row(3, 151)]; + expect(topRenderedNavEntryRefs(entries, 100)).toEqual(["T-0", "T-1"]); + }); + + it("stops reading at the limit in a list of thousands of rows", () => { + let reads = 0; + const entries = new Proxy(Array.from({ length: 5_000 }, (_, index) => row(index)), { + get(target, property, receiver) { + if (typeof property === "string" && /^\d+$/.test(property)) { + reads += 1; + if (reads > 10) throw new Error("read past the preload limit"); + } + return Reflect.get(target, property, receiver); + }, + }); + expect(topRenderedNavEntryRefs(entries, 10_000)).toHaveLength(10); + expect(reads).toBe(10); + }); +}); diff --git a/ui/src/hooks/usePreloadTaskChats.ts b/ui/src/hooks/usePreloadTaskChats.ts new file mode 100644 index 00000000000..6fae5e0bc2e --- /dev/null +++ b/ui/src/hooks/usePreloadTaskChats.ts @@ -0,0 +1,61 @@ +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { CHAT_BOOTSTRAP_PRELOAD_LIMIT, loadIssueChatBootstraps } from "@/lib/issue-chat-bootstrap"; +import { whenPageSettles } from "@/lib/when-page-settles"; + +/** + * The route refs of the first tasks in `items`, in order, up to the preload + * limit. `taskOf` returns the task an item shows, or null for other items. + * The walk stops at the limit, whatever the size of the list. + */ +export function topTaskRefs(items: Iterable, taskOf: (item: T) => { id: string; identifier: string | null } | null) { + const refs: string[] = []; + for (const item of items) { + const task = taskOf(item); + if (!task) continue; + refs.push(task.identifier ?? task.id); + if (refs.length >= CHAT_BOOTSTRAP_PRELOAD_LIMIT) break; + } + return refs; +} + +/** + * The route refs of the first rendered task rows of a list's flattened + * display order, up to the preload limit. Rows past the render budget are + * skipped; the walk stops at the limit. + */ +export function topRenderedNavEntryRefs( + entries: readonly ({ type: "issue"; issue: { id: string; identifier: string | null }; budgetOrdinal: number } | { type: string })[], + renderedRowLimit: number, +) { + const refs: string[] = []; + for (const entry of entries) { + if (entry.type !== "issue" || !("issue" in entry) || entry.budgetOrdinal > renderedRowLimit) continue; + refs.push(entry.issue.identifier ?? entry.issue.id); + if (refs.length >= CHAT_BOOTSTRAP_PRELOAD_LIMIT) break; + } + return refs; +} + +/** + * After a task list has painted and its own reads have settled, load what the + * chat of each of its top rows needs before its first frame, so opening one + * of them renders from cache. Tasks already cached, loading or preloaded + * recently are skipped, so a reorder or a revisit sends nothing new. A change + * of rows or company, or unmount, stops requests not yet sent. + */ +export function usePreloadTaskChats(companyId: string | null | undefined, refs: readonly string[], ready: boolean) { + const queryClient = useQueryClient(); + const refsKey = refs.slice(0, CHAT_BOOTSTRAP_PRELOAD_LIMIT).join(","); + useEffect(() => { + if (!ready || !companyId || !refsKey || typeof window.requestAnimationFrame !== "function") return; + const controller = new AbortController(); + const cancel = whenPageSettles(queryClient, () => { + void loadIssueChatBootstraps(queryClient, companyId, refsKey.split(","), { signal: controller.signal, background: true }); + }); + return () => { + cancel(); + controller.abort(); + }; + }, [companyId, queryClient, ready, refsKey]); +} diff --git a/ui/src/hooks/useRecentTaskOpenWarmup.test.tsx b/ui/src/hooks/useRecentTaskOpenWarmup.test.tsx index d6881997303..9ee09d08a41 100644 --- a/ui/src/hooks/useRecentTaskOpenWarmup.test.tsx +++ b/ui/src/hooks/useRecentTaskOpenWarmup.test.tsx @@ -8,6 +8,7 @@ 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 { resetIssueChatBootstrapsForTest } from "@/lib/issue-chat-bootstrap"; import { resetRecentTaskChatPreloadForTests, useRecentTaskOpenWarmup, type RecentTaskOpenWarmupTask } from "./useRecentTaskOpenWarmup"; const chatBootstrap = vi.hoisted(() => vi.fn()); @@ -57,6 +58,7 @@ function render(props: { companyId?: string | null; userId?: string | null; task beforeEach(() => { resetRecentTaskChatPreloadForTests(); + resetIssueChatBootstrapsForTest(); chatBootstrap.mockReset(); chatBootstrap.mockImplementation(async (_companyId: string, ids: string[]) => ({ items: ids.map((id) => bootstrapItem(Number(id.slice(3)))), @@ -164,20 +166,28 @@ describe("useRecentTaskOpenWarmup", () => { expect(queryClient.getQueryData([...queryKeys.issues.detail("PAP-5"), "core"])).toBeUndefined(); }); - it("drops the answer when the sidebar unmounts before it arrives", async () => { + it("keeps an answer in flight when the sidebar unmounts, so a task opened meanwhile can use it", 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); - })); + chatBootstrap.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve; })); 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(); + expect(queryClient.getQueryData([...queryKeys.issues.detail("PAP-0"), "core"])).toMatchObject({ id: "id-0" }); root = createRoot(container); }); + + it("drops the answer when the account changes before it arrives", async () => { + queryClient.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); + let answer!: (value: unknown) => void; + chatBootstrap.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve; })); + render({ tasks: tasks(1) }); + await settle(); + expect(chatBootstrap).toHaveBeenCalledTimes(1); + act(() => { queryClient.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } }); }); + await act(async () => { answer({ items: [bootstrapItem(0)] }); }); + expect(queryClient.getQueryData([...queryKeys.issues.detail("PAP-0"), "core"])).toBeUndefined(); + expect(queryClient.getQueryData([...queryKeys.issues.detail("id-0"), "core"])).toBeUndefined(); + }); }); diff --git a/ui/src/hooks/useRecentTaskOpenWarmup.ts b/ui/src/hooks/useRecentTaskOpenWarmup.ts index 861c26a20c3..5053305b098 100644 --- a/ui/src/hooks/useRecentTaskOpenWarmup.ts +++ b/ui/src/hooks/useRecentTaskOpenWarmup.ts @@ -1,8 +1,8 @@ import { useEffect, useRef } from "react"; -import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useQueryClient } 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 { loadIssueChatBootstraps } from "@/lib/issue-chat-bootstrap"; +import { whenPageSettles } from "@/lib/when-page-settles"; import { useWarmTaskDetailCode } from "./useWarmTaskDetailCode"; export type RecentTaskOpenWarmupTask = { id: string; identifier: string | null }; @@ -14,67 +14,6 @@ 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. * @@ -108,19 +47,9 @@ export function useRecentTaskOpenWarmup({ 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. - }); + // Skips tasks already cached or loading (a task list may have preloaded them). + const top = latestTasks.current.slice(0, ISSUE_CHAT_BOOTSTRAP_MAX_IDS); + void loadIssueChatBootstraps(queryClient, companyId, top, { signal: controller.signal }); }); return () => { cancel(); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index ded42d1a8cc..94d130948ce 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -116,6 +116,11 @@ export type InboxKeyboardNavEntry = issue: Issue; }; +/** The task an Inbox row shows, or null for a group header or a row that is not a task. */ +export function inboxNavEntryTask(entry: InboxKeyboardNavEntry) { + return entry.type === "child" ? entry.issue : entry.type === "top" && entry.item.kind === "issue" ? entry.item.issue : null; +} + export interface InboxProjectWorkspaceLookup { name: string; projectId?: string | null; diff --git a/ui/src/lib/issue-chat-bootstrap.test.ts b/ui/src/lib/issue-chat-bootstrap.test.ts new file mode 100644 index 00000000000..0bbcb4e3803 --- /dev/null +++ b/ui/src/lib/issue-chat-bootstrap.test.ts @@ -0,0 +1,388 @@ +// @vitest-environment jsdom + +import { QueryClient } from "@tanstack/react-query"; +import type { Issue, IssueRecentComments } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EARLY_CHAT_BOOTSTRAP_GLOBAL } from "./issue-detail-preload-events"; +import { + CHAT_BOOTSTRAP_BACKGROUND_REPEAT_MS, + CHAT_BOOTSTRAP_SHARE_DEADLINE_MS, + issueChatBootstrapEntryCountForTest, + loadIssueChatBootstraps, + readFromIssueChatBootstrap, + resetIssueChatBootstrapsForTest, + startIssueChatBootstrap, + type ChatBootstrapItem, +} from "./issue-chat-bootstrap"; +import { queryKeys } from "./queryKeys"; +import { ISSUE_CHAT_BOOTSTRAP_FRESH_MS } from "./issueChatBootstrapCache"; + +const chatBootstrap = vi.hoisted(() => vi.fn()); +vi.mock("@/api/issues", () => ({ issuesApi: { chatBootstrap } })); + +function idFor(number: number) { + return `00000000-0000-4000-8000-${String(number).padStart(12, "0")}`; +} + +function item(ref: string, overrides: Partial = {}): ChatBootstrapItem { + const number = Number(ref.split("-").pop() ?? 1); + const comment = { id: `comment-${number}`, body: "Latest reply", createdAt: "2026-11-09T00:00:00Z" }; + return { + ref, + issue: { id: idFor(number), identifier: `ABC-${number}`, companyId: "company-1", title: `Task ${ref}`, status: "done" } as unknown as Issue, + recentComments: { comments: [comment], latestCommentId: comment.id, hasMore: false } as unknown as IssueRecentComments, + commentInputFacts: [{ id: `fact-${number}` }] as never, + recentActivity: [{ id: `event-${number}` }] as never, + recentRuns: [{ runId: `run-${number}-b` }, { runId: `run-${number}-a` }] as never, + interactions: [], + attachments: [], + liveRuns: [], + activeRun: null, + ...overrides, + }; +} + +function respondWith(refs: readonly string[]) { + return { items: refs.map((ref) => item(ref)) }; +} + +const requestedRefs = () => chatBootstrap.mock.calls.map((call) => (call[1] as string[]).join(",")); + +let client: QueryClient; +beforeEach(() => { + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + resetIssueChatBootstrapsForTest(); + chatBootstrap.mockReset(); + chatBootstrap.mockImplementation(async (_companyId: string, refs: string[]) => respondWith(refs)); +}); +afterEach(() => { + client.clear(); + vi.useRealTimers(); + delete (window as unknown as Record)[EARLY_CHAT_BOOTSTRAP_GLOBAL]; +}); + +describe("loadIssueChatBootstraps", () => { + it("loads 30 tasks as three requests of ten, one at a time, and seeds the page's keys", async () => { + let concurrent = 0; + let maxConcurrent = 0; + chatBootstrap.mockImplementation(async (_companyId: string, refs: string[]) => { + maxConcurrent = Math.max(maxConcurrent, ++concurrent); + await Promise.resolve(); + concurrent--; + return respondWith(refs); + }); + const refs = Array.from({ length: 30 }, (_, index) => `ABC-${index + 1}`); + await loadIssueChatBootstraps(client, "company-1", refs); + expect(chatBootstrap.mock.calls.map((call) => (call[1] as string[]).length)).toEqual([10, 10, 10]); + expect(chatBootstrap.mock.calls[0]![0]).toBe("company-1"); + expect(chatBootstrap.mock.calls[0]![1][0]).toBe("ABC-1"); + expect(maxConcurrent).toBe(1); + expect(client.getQueryData([...queryKeys.issues.detail("ABC-30"), "core"])).toMatchObject({ identifier: "ABC-30" }); + expect(client.getQueryData([...queryKeys.issues.detail(idFor(30)), "core"])).toMatchObject({ identifier: "ABC-30" }); + }); + + it("skips tasks loaded recently and reloads them once they are stale", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + await loadIssueChatBootstraps(client, "company-1", ["ABC-1", "ABC-2"]); + await loadIssueChatBootstraps(client, "company-1", ["ABC-2", "ABC-3"]); + expect(requestedRefs()).toEqual(["ABC-1,ABC-2", "ABC-3"]); + vi.setSystemTime(Date.now() + ISSUE_CHAT_BOOTSTRAP_FRESH_MS + 1); + await loadIssueChatBootstraps(client, "company-1", ["ABC-1"]); + expect(requestedRefs()).toEqual(["ABC-1,ABC-2", "ABC-3", "ABC-1"]); + }); + + it("does not load a task again under its other reference (a list by identifier, Recents by id)", async () => { + await loadIssueChatBootstraps(client, "company-1", ["ABC-4"]); + await loadIssueChatBootstraps(client, "company-1", [{ id: idFor(4), identifier: "ABC-4" }, { id: idFor(5), identifier: "ABC-5" }]); + expect(requestedRefs()).toEqual(["ABC-4", idFor(5)]); + }); + + it("does not wait for or repeat a request already in flight for the same task", async () => { + let answer!: (value: unknown) => void; + chatBootstrap.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve; })); + const first = loadIssueChatBootstraps(client, "company-1", ["ABC-6"]); + await Promise.resolve(); + await loadIssueChatBootstraps(client, "company-1", ["abc-6"]); + expect(chatBootstrap).toHaveBeenCalledOnce(); + answer(respondWith(["ABC-6"])); + await first; + }); + + it("stops later requests when aborted, but keeps the one in flight usable by the page", async () => { + let answer!: (value: unknown) => void; + chatBootstrap.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve; })); + const controller = new AbortController(); + const refs = Array.from({ length: 12 }, (_, index) => `ABC-${index + 1}`); + const loading = loadIssueChatBootstraps(client, "company-1", refs, { signal: controller.signal }); + await Promise.resolve(); + // The click that opens a task unmounts the list and aborts its preload. + controller.abort(); + const ownRead = vi.fn(); + const interactions = readFromIssueChatBootstrap(client, ["unread", "ABC-3"], "ABC-3", (bootstrap) => bootstrap.recentActivity, ownRead); + answer(respondWith(refs.slice(0, 10))); + await loading; + await expect(interactions).resolves.toEqual([{ id: "event-3" }]); + expect(ownRead).not.toHaveBeenCalled(); + expect(chatBootstrap).toHaveBeenCalledOnce(); + expect(chatBootstrap.mock.calls[0]![2]).toBeUndefined(); + }); + + it("lets a failed preload fall back to the page's own reads and be retried", async () => { + chatBootstrap.mockRejectedValueOnce(new Error("busy")); + await loadIssueChatBootstraps(client, "company-1", ["ABC-1"]); + const read = vi.fn().mockResolvedValue("from server"); + await expect(readFromIssueChatBootstrap(client, ["unread", "ABC-1"], "ABC-1", (bootstrap) => bootstrap.interactions, read)).resolves.toBe("from server"); + expect(read).toHaveBeenCalledOnce(); + await loadIssueChatBootstraps(client, "company-1", ["ABC-1"]); + expect(chatBootstrap).toHaveBeenCalledTimes(2); + }); + + it("never shares or seeds a task that belongs to another company", async () => { + chatBootstrap.mockResolvedValueOnce({ items: [item("ABC-7", { issue: { ...item("ABC-7").issue, companyId: "company-2" } })] }); + await loadIssueChatBootstraps(client, "company-1", ["ABC-7"]); + expect(client.getQueryData([...queryKeys.issues.detail("ABC-7"), "core"])).toBeUndefined(); + const read = vi.fn().mockResolvedValue([]); + await readFromIssueChatBootstrap(client, ["unread", "ABC-7"], "ABC-7", (bootstrap) => bootstrap.interactions, read); + expect(read).toHaveBeenCalledOnce(); + }); +}); + +describe("readFromIssueChatBootstrap", () => { + it("lets a cold open's reads share one loading bootstrap instead of sending their own", async () => { + let answer!: (value: unknown) => void; + chatBootstrap.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve; })); + void loadIssueChatBootstraps(client, "company-1", ["ABC-9"]); + await Promise.resolve(); + const ownRead = vi.fn(); + const interactions = readFromIssueChatBootstrap(client, ["unread", "abc-9"], "abc-9", (bootstrap) => bootstrap.interactions, ownRead); + const activity = readFromIssueChatBootstrap(client, ["unread", "ABC-9"], "ABC-9", (bootstrap) => bootstrap.recentActivity, ownRead); + answer(respondWith(["ABC-9"])); + await expect(interactions).resolves.toEqual([]); + await expect(activity).resolves.toEqual([{ id: "event-9" }]); + expect(ownRead).not.toHaveBeenCalled(); + expect(chatBootstrap).toHaveBeenCalledOnce(); + }); + + it("reads from the server when the bootstrap omits the task, is not loading, or is no longer new", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + chatBootstrap.mockResolvedValueOnce({ items: [] }); + await loadIssueChatBootstraps(client, "company-1", ["ABC-4"]); + const read = vi.fn().mockResolvedValue(["server"]); + await expect(readFromIssueChatBootstrap(client, ["unread", "ABC-4"], "ABC-4", (bootstrap) => bootstrap.attachments, read)).resolves.toEqual(["server"]); + await expect(readFromIssueChatBootstrap(client, ["unread", "ABC-5"], "ABC-5", (bootstrap) => bootstrap.attachments, read)).resolves.toEqual(["server"]); + await loadIssueChatBootstraps(client, "company-1", ["ABC-8"]); + // A later read of a loaded task is a refresh: it asks the server. + vi.setSystemTime(Date.now() + 5_000); + await expect(readFromIssueChatBootstrap(client, ["unread", "ABC-8"], "ABC-8", (bootstrap) => bootstrap.attachments, read)).resolves.toEqual(["server"]); + expect(read).toHaveBeenCalledTimes(3); + }); +}); + +describe("startIssueChatBootstrap", () => { + it("adopts the hard load's early fetch instead of sending a request", async () => { + (window as unknown as Record)[EARLY_CHAT_BOOTSTRAP_GLOBAL] = { ref: "abc-3", promise: Promise.resolve(item("ABC-3")) }; + startIssueChatBootstrap(client, null, "ABC-3"); + const ownRead = vi.fn(); + await expect(readFromIssueChatBootstrap(client, ["unread", "ABC-3"], "ABC-3", (bootstrap) => bootstrap.recentActivity, ownRead)).resolves.toEqual([{ id: "event-3" }]); + expect(client.getQueryData([...queryKeys.issues.detail("ABC-3"), "core"])).toMatchObject({ identifier: "ABC-3" }); + expect(chatBootstrap).not.toHaveBeenCalled(); + expect(ownRead).not.toHaveBeenCalled(); + expect((window as unknown as Record)[EARLY_CHAT_BOOTSTRAP_GLOBAL]).toBeUndefined(); + }); + + it("does not use an early answer for a task of another company than the page's", async () => { + (window as unknown as Record)[EARLY_CHAT_BOOTSTRAP_GLOBAL] = { ref: "ABC-3", promise: Promise.resolve(item("ABC-3")) }; + startIssueChatBootstrap(client, "company-2", "ABC-3"); + const read = vi.fn().mockResolvedValue([]); + await readFromIssueChatBootstrap(client, ["unread", "ABC-3"], "ABC-3", (bootstrap) => bootstrap.recentActivity, read); + expect(read).toHaveBeenCalledOnce(); + expect(client.getQueryData([...queryKeys.issues.detail("ABC-3"), "core"])).toBeUndefined(); + }); + + it("falls back to the page's reads when the early fetch failed", async () => { + (window as unknown as Record)[EARLY_CHAT_BOOTSTRAP_GLOBAL] = { ref: "ABC-3", promise: Promise.resolve(null) }; + startIssueChatBootstrap(client, null, "ABC-3"); + const read = vi.fn().mockResolvedValue([]); + await readFromIssueChatBootstrap(client, ["unread", "ABC-3"], "ABC-3", (bootstrap) => bootstrap.recentActivity, read); + expect(read).toHaveBeenCalledOnce(); + }); + + it("ignores an early fetch for another task and loads this one through the company endpoint", async () => { + (window as unknown as Record)[EARLY_CHAT_BOOTSTRAP_GLOBAL] = { ref: "ABC-1", promise: Promise.resolve(item("ABC-1")) }; + startIssueChatBootstrap(client, "company-1", "ABC-2"); + await Promise.resolve(); + await expect(readFromIssueChatBootstrap(client, ["unread", "ABC-2"], "ABC-2", (bootstrap) => bootstrap.issue.identifier, vi.fn())).resolves.toBe("ABC-2"); + expect(requestedRefs()).toEqual(["ABC-2"]); + }); +}); + +describe("background preloads", () => { + it("do not preload a task again for five minutes while a list stays open, but an open still loads it", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + await loadIssueChatBootstraps(client, "company-1", ["ABC-1", "ABC-2"], { background: true }); + // Past the 30-second freshness: the list re-renders after a live reorder. + vi.setSystemTime(Date.now() + ISSUE_CHAT_BOOTSTRAP_FRESH_MS + 1); + await loadIssueChatBootstraps(client, "company-1", ["ABC-2", "ABC-1", "ABC-3"], { background: true }); + expect(requestedRefs()).toEqual(["ABC-1,ABC-2", "ABC-3"]); + // Open intent is not a background preload. + await loadIssueChatBootstraps(client, "company-1", ["ABC-1"]); + expect(requestedRefs()).toEqual(["ABC-1,ABC-2", "ABC-3", "ABC-1"]); + vi.setSystemTime(Date.now() + CHAT_BOOTSTRAP_BACKGROUND_REPEAT_MS); + await loadIssueChatBootstraps(client, "company-1", ["ABC-2"], { background: true }); + expect(requestedRefs()).toEqual(["ABC-1,ABC-2", "ABC-3", "ABC-1", "ABC-2"]); + }); + + it("leave a seeded key that a live event invalidated to the page's own read", async () => { + await loadIssueChatBootstraps(client, "company-1", ["ABC-4"], { background: true }); + const key = queryKeys.issues.interactions("ABC-4"); + // An event for this task (the fast live lane) marks the seeded key stale. + await client.invalidateQueries({ queryKey: key }); + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + const read = vi.fn().mockResolvedValue([{ id: "new" }]); + await expect(readFromIssueChatBootstrap(client, key, "ABC-4", (bootstrap) => bootstrap.interactions, read)).resolves.toEqual([{ id: "new" }]); + expect(read).toHaveBeenCalledOnce(); + }); +}); + +describe("safeguards", () => { + it("does not request a task again by its identifier while its id's request is in flight", async () => { + let answer!: (value: unknown) => void; + chatBootstrap.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve; })); + // Recents asks by id (and knows the identifier); the list then asks by identifier. + const recents = loadIssueChatBootstraps(client, "company-1", [{ id: idFor(5), identifier: "ABC-5" }]); + await Promise.resolve(); + await loadIssueChatBootstraps(client, "company-1", ["ABC-5"]); + expect(chatBootstrap).toHaveBeenCalledOnce(); + answer({ items: [item("ABC-5", { ref: idFor(5) })] }); + await recents; + }); + + it("stops waiting for a stalled bootstrap, so the page and its Retry read the server", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + chatBootstrap.mockImplementationOnce(() => new Promise(() => {})); + void loadIssueChatBootstraps(client, "company-1", ["ABC-6"]); + await Promise.resolve(); + const read = vi.fn().mockResolvedValue(["server"]); + const first = readFromIssueChatBootstrap(client, ["unread"], "ABC-6", (bootstrap) => bootstrap.attachments, read); + await vi.advanceTimersByTimeAsync(CHAT_BOOTSTRAP_SHARE_DEADLINE_MS); + await expect(first).resolves.toEqual(["server"]); + // A retry does not join the stuck request again. + await expect(readFromIssueChatBootstrap(client, ["unread"], "ABC-6", (bootstrap) => bootstrap.attachments, read)).resolves.toEqual(["server"]); + expect(read).toHaveBeenCalledTimes(2); + }); + + it("never shares a bootstrap with a refresh of a key that already holds data", async () => { + let answer!: (value: unknown) => void; + chatBootstrap.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve; })); + void loadIssueChatBootstraps(client, "company-1", ["ABC-9"]); + await Promise.resolve(); + // An accepted confirmation was written and its key invalidated: the refresh + // must read the server, not the bootstrap's older pending interaction. + const key = queryKeys.issues.interactions("ABC-9"); + client.setQueryData(key, [{ id: "accepted" }]); + const read = vi.fn().mockResolvedValue([{ id: "accepted" }]); + await expect(readFromIssueChatBootstrap(client, key, "ABC-9", (bootstrap) => bootstrap.interactions, read)).resolves.toEqual([{ id: "accepted" }]); + expect(read).toHaveBeenCalledOnce(); + answer(respondWith(["ABC-9"])); + }); + + it("never gives one account's bootstrap to another account", async () => { + client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); + await loadIssueChatBootstraps(client, "company-1", ["ABC-3"]); + client.clear(); + client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } }); + const read = vi.fn().mockResolvedValue(["server"]); + await expect(readFromIssueChatBootstrap(client, ["unread"], "ABC-3", (bootstrap) => bootstrap.attachments, read)).resolves.toEqual(["server"]); + expect(read).toHaveBeenCalledOnce(); + // The new account loads the task for itself. + await loadIssueChatBootstraps(client, "company-1", ["ABC-3"]); + expect(requestedRefs()).toEqual(["ABC-3", "ABC-3"]); + }); + + it("drops an early answer when the account changes before it arrives", async () => { + client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); + let answer!: (value: unknown) => void; + (window as unknown as Record)[EARLY_CHAT_BOOTSTRAP_GLOBAL] = { + ref: "ABC-3", startedAt: Date.now(), promise: new Promise((resolve) => { answer = resolve; }), + }; + startIssueChatBootstrap(client, null, "ABC-3"); + client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } }); + answer(item("ABC-3")); + const read = vi.fn().mockResolvedValue([]); + await readFromIssueChatBootstrap(client, ["unread"], "ABC-3", (bootstrap) => bootstrap.recentActivity, read); + expect(read).toHaveBeenCalledOnce(); + expect(client.getQueryData([...queryKeys.issues.detail("ABC-3"), "core"])).toBeUndefined(); + }); + + it("binds an early answer adopted before the session loaded to the first account that uses it", async () => { + (window as unknown as Record)[EARLY_CHAT_BOOTSTRAP_GLOBAL] = { ref: "ABC-3", startedAt: Date.now(), promise: Promise.resolve(item("ABC-3")) }; + // The page renders the task before its first session read finishes. + await startIssueChatBootstrap(client, null, "ABC-3"); + client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); + const ownRead = vi.fn(); + await expect(readFromIssueChatBootstrap(client, ["unread"], "ABC-3", (bootstrap) => bootstrap.recentActivity, ownRead)).resolves.toEqual([{ id: "event-3" }]); + expect(ownRead).not.toHaveBeenCalled(); + // Once used by user-1, another account cannot use it. + client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } }); + const read = vi.fn().mockResolvedValue([]); + await readFromIssueChatBootstrap(client, ["unread"], "ABC-3", (bootstrap) => bootstrap.recentActivity, read); + expect(read).toHaveBeenCalledOnce(); + }); + + it("never seeds a pre-session early answer after a sign-out, nor for the next account", async () => { + let answer!: (value: unknown) => void; + (window as unknown as Record)[EARLY_CHAT_BOOTSTRAP_GLOBAL] = { + ref: "ABC-3", startedAt: Date.now(), promise: new Promise((resolve) => { answer = resolve; }), + }; + // Adopted before the first session read finishes. + startIssueChatBootstrap(client, null, "ABC-3"); + client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }); + // user-1 signs out, then user-2 signs in, before the answer arrives. + client.setQueryData(queryKeys.auth.session, null); + client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } }); + answer(item("ABC-3")); + await Promise.resolve(); + await Promise.resolve(); + expect(client.getQueryData([...queryKeys.issues.detail("ABC-3"), "core"])).toBeUndefined(); + const read = vi.fn().mockResolvedValue([]); + await readFromIssueChatBootstrap(client, ["unread"], "ABC-3", (bootstrap) => bootstrap.recentActivity, read); + expect(read).toHaveBeenCalledOnce(); + }); + + it("revokes a pre-session early answer when the first session has no user", async () => { + (window as unknown as Record)[EARLY_CHAT_BOOTSTRAP_GLOBAL] = { ref: "ABC-3", startedAt: Date.now(), promise: new Promise(() => {}) }; + startIssueChatBootstrap(client, null, "ABC-3"); + client.setQueryData(queryKeys.auth.session, null); + client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } }); + const read = vi.fn().mockResolvedValue([]); + await readFromIssueChatBootstrap(client, ["unread"], "ABC-3", (bootstrap) => bootstrap.recentActivity, read); + expect(read).toHaveBeenCalledOnce(); + }); + + it("stamps an early answer with its request time and ignores one that is already stale", async () => { + const startedAt = Date.now() - 5_000; + (window as unknown as Record)[EARLY_CHAT_BOOTSTRAP_GLOBAL] = { ref: "ABC-3", startedAt, promise: Promise.resolve(item("ABC-3")) }; + await startIssueChatBootstrap(client, null, "ABC-3"); + expect(client.getQueryState([...queryKeys.issues.detail("ABC-3"), "core"])?.dataUpdatedAt).toBe(startedAt); + + (window as unknown as Record)[EARLY_CHAT_BOOTSTRAP_GLOBAL] = { + ref: "ABC-4", startedAt: Date.now() - ISSUE_CHAT_BOOTSTRAP_FRESH_MS - 1, promise: Promise.resolve(item("ABC-4")), + }; + startIssueChatBootstrap(client, "company-1", "ABC-4"); + await Promise.resolve(); + // The page read the task itself instead of seeding the stale answer. + expect(requestedRefs()).toEqual(["ABC-4"]); + }); + + it("drops expired answers so they do not stay in memory", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + await loadIssueChatBootstraps(client, "company-1", ["ABC-1"]); + await Promise.resolve(); + vi.setSystemTime(Date.now() + ISSUE_CHAT_BOOTSTRAP_FRESH_MS + 1); + client.clear(); + await loadIssueChatBootstraps(client, "company-1", ["ABC-2"]); + await Promise.resolve(); + // Only ABC-2 remains, under its identifier and its id. + expect(issueChatBootstrapEntryCountForTest()).toBe(2); + }); +}); diff --git a/ui/src/lib/issue-chat-bootstrap.ts b/ui/src/lib/issue-chat-bootstrap.ts new file mode 100644 index 00000000000..2c244ed5cb7 --- /dev/null +++ b/ui/src/lib/issue-chat-bootstrap.ts @@ -0,0 +1,348 @@ +import type { QueryClient, QueryKey } from "@tanstack/react-query"; +import { ISSUE_CHAT_BOOTSTRAP_MAX_IDS } from "@paperclipai/shared"; +import { api } from "@/api/client"; +import { issuesApi } from "@/api/issues"; +import { EARLY_CHAT_BOOTSTRAP_GLOBAL } from "@/lib/issue-detail-preload-events"; +import { queryKeys } from "@/lib/queryKeys"; +import { + hasFreshIssueChatFirstFrame, + ISSUE_CHAT_BOOTSTRAP_FRESH_MS, + seedIssueChatBootstrap, + type IssueChatBootstrapEntry, +} from "@/lib/issueChatBootstrapCache"; + +// A task opens instantly when everything its chat reads before the first frame +// is already cached. One bootstrap request returns those reads for up to ten +// tasks, in the shapes of the per-task endpoints, and `issueChatBootstrapCache` +// writes them under the exact keys the task page reads. This module decides +// when to send that request and lets the page's reads share it: +// +// - Task lists and the sidebar's Recents preload their top rows in the background. +// - A cold open (direct link, or a task that was not preloaded) starts one +// bootstrap for that task, and the page's gating reads wait for it instead of +// sending about ten requests of their own. +// - A hard load of a task URL adopts the fetch the HTML started (see +// `vite-issue-detail-preload`). + +export type ChatBootstrapItem = IssueChatBootstrapEntry; + +/** + * How many top rows of a task list are preloaded: one request. Each task + * costs the server about fifty queries, and a resting hover warms any other + * row before it is clicked. + */ +export const CHAT_BOOTSTRAP_PRELOAD_LIMIT = ISSUE_CHAT_BOOTSTRAP_MAX_IDS; +/** How long after it arrives a bootstrap answer may stand in for a page read. */ +const SHARE_WINDOW_MS = 2_000; +/** + * How long a page read waits for a loading bootstrap before it reads the + * server itself. A stalled bootstrap must not keep the chat (or its Retry) + * from loading. + */ +export const CHAT_BOOTSTRAP_SHARE_DEADLINE_MS = 5_000; + +/** + * The account a request was sent for: a user id, `null` when the session has + * no user, or `undefined` while this document has not read its session yet. + * `revoked` marks a request whose answer must never be used. + */ +type Owner = { account: string | null | undefined; revoked: boolean }; + +/** One task's part of a bootstrap request. It is never used by another account. */ +type Entry = { promise: Promise; owner: Owner; startedAt: number; settledAt?: number }; +const entries = new Map(); + +function refKey(ref: string) { + return /^[0-9a-f-]{36}$/i.test(ref) ? ref.toLowerCase() : ref.toUpperCase(); +} + +/** The session's user id, `null` without a user, `undefined` before the first session read. */ +function sessionAccount(queryClient: QueryClient): string | null | undefined { + const session = queryClient.getQueryState<{ user?: { id?: unknown } | null } | null>(queryKeys.auth.session)?.data; + if (session === undefined) return undefined; + return typeof session?.user?.id === "string" ? session.user.id : null; +} + +/** + * Whether `owner`'s answer may be used now. A request sent before the first + * session read (a hard load's early fetch) can only have used this + * document's original sign-in, so it belongs to the first account the + * session reports; a session without a user revokes it. + */ +function ownerMatches(owner: Owner, current: string | null | undefined) { + if (owner.revoked) return false; + if (owner.account !== undefined) return owner.account === current; + if (current === undefined) return true; + if (current === null) { + owner.revoked = true; + return false; + } + owner.account = current; + return true; +} + +/** Owners sent before the first session read, bound as soon as the session is known. */ +const pendingOwners = new Set(); +let stopWatchingSession: (() => void) | null = null; + +function newOwner(queryClient: QueryClient): Owner { + const owner: Owner = { account: sessionAccount(queryClient), revoked: false }; + if (owner.account !== undefined) return owner; + pendingOwners.add(owner); + stopWatchingSession ??= queryClient.getQueryCache().subscribe(() => { + const current = sessionAccount(queryClient); + if (current === undefined) return; + // Bind (or, for a session without a user, revoke) before anything else + // can sign in, so a later account never inherits these answers. + for (const pending of pendingOwners) ownerMatches(pending, current); + pendingOwners.clear(); + stopWatchingSession?.(); + stopWatchingSession = null; + }); + return owner; +} + +function isExpired(entry: Entry, now: number) { + return entry.settledAt !== undefined && now - entry.settledAt >= ISSUE_CHAT_BOOTSTRAP_FRESH_MS; +} + +/** This account's entry for `ref` that is loading or loaded recently. */ +function currentEntry(queryClient: QueryClient, ref: string, now = Date.now()) { + const entry = entries.get(refKey(ref)); + if (!entry || isExpired(entry, now)) return undefined; + return ownerMatches(entry.owner, sessionAccount(queryClient)) ? entry : undefined; +} + +/** Remove `entry` from every ref that still points at it. */ +function forget(entry: Entry) { + for (const [key, value] of entries) if (value === entry) entries.delete(key); +} + +/** + * Track one task's part of a request under every ref it is known by: the + * refs the caller gave at once, and the task's id and identifier when the + * answer arrives. A list (identifiers) and Recents (ids) then never load the + * same task twice, even while a request is in flight. Expired entries are + * dropped, so answers do not stay in memory. + */ +function track(queryClient: QueryClient, refs: readonly string[], owner: Owner, startedAt: number, promise: Promise) { + const now = Date.now(); + for (const [key, entry] of entries) if (isExpired(entry, now)) entries.delete(key); + const entry: Entry = { startedAt, owner, promise }; + for (const ref of refs) entries.set(refKey(ref), entry); + void promise.then((item) => { + entry.settledAt = Date.now(); + if (!item) { + // A failed or empty answer must not suppress later reads or preloads. + forget(entry); + return; + } + for (const alias of [item.issue.id, item.issue.identifier]) { + if (alias && !currentEntry(queryClient, alias)) entries.set(refKey(alias), entry); + } + }); +} + +/** One task to request: the ref sent to the server, plus every ref it is known by. */ +type Wanted = { ref: string; refs: string[] }; + +function startChunk(queryClient: QueryClient, companyId: string, wanted: readonly Wanted[]) { + const refs = wanted.map((target) => target.ref); + const requestedAt = Date.now(); + const owner = newOwner(queryClient); + // A synchronous throw (bad input) is only a missed preload as well. + const request = Promise.resolve().then(() => issuesApi.chatBootstrap(companyId, refs)).then((response) => { + const byRef = new Map(); + // Never seed or share an answer read with the previous account's cookie. + if (!ownerMatches(owner, sessionAccount(queryClient))) return byRef; + for (const item of response.items) { + if (!seedIssueChatBootstrap(queryClient, item, { companyId, requestedAt })) continue; + byRef.set(refKey(item.ref), item); + } + return byRef; + }); + for (const target of wanted) { + track(queryClient, target.refs, owner, requestedAt, request.then((byRef) => byRef.get(refKey(target.ref)), () => undefined)); + } + return request.then(() => undefined, () => undefined); +} + +/** + * How long a background preload (a task list) skips a task it already + * preloaded. A list that stays open re-renders as live events reorder it; + * events keep a preloaded task's cache current or mark it stale, so the list + * does not need to preload the same task again every 30 seconds. + */ +export const CHAT_BOOTSTRAP_BACKGROUND_REPEAT_MS = 5 * 60_000; +const backgroundPreloadedAt = new Map(); + +function preloadedInBackgroundRecently(refs: readonly string[], now: number) { + return refs.some((ref) => { + const at = backgroundPreloadedAt.get(refKey(ref)); + return at !== undefined && now - at < CHAT_BOOTSTRAP_BACKGROUND_REPEAT_MS; + }); +} + +function rememberBackgroundPreload(refs: readonly string[], now: number) { + for (const [key, at] of backgroundPreloadedAt) if (now - at >= CHAT_BOOTSTRAP_BACKGROUND_REPEAT_MS) backgroundPreloadedAt.delete(key); + for (const ref of refs) backgroundPreloadedAt.set(refKey(ref), now); +} + +/** A task to preload: a ref, or its id with the identifier it may also be cached under. */ +export type ChatBootstrapTarget = string | { id: string; identifier: string | null }; + +/** + * Load the bootstrap for these tasks in requests of at most ten, one request + * at a time, and seed the cache as each one arrives. Tasks loading, loaded + * within `ISSUE_CHAT_BOOTSTRAP_FRESH_MS`, or whose first frame is already cached + * and fresh are skipped. + * + * `signal` stops later requests, never one in flight: a click that unmounts + * the list must still be able to use the answer for the task it opens. + * `background` (a list's preload) also skips tasks it preloaded within + * `CHAT_BOOTSTRAP_BACKGROUND_REPEAT_MS`. + */ +export async function loadIssueChatBootstraps( + queryClient: QueryClient, + companyId: string, + targets: readonly ChatBootstrapTarget[], + options: { signal?: AbortSignal; background?: boolean } = {}, +) { + const now = Date.now(); + // Keyed by the normalized ref; the request keeps the caller's spelling. + const wanted = new Map(); + for (const target of targets) { + const refs = typeof target === "string" ? [target] : [target.id, target.identifier].filter((ref): ref is string => !!ref); + if (!refs.length || wanted.has(refKey(refs[0]!))) continue; + if (refs.some((ref) => currentEntry(queryClient, ref, now))) continue; + if (hasFreshIssueChatFirstFrame(queryClient, refs, now)) continue; + if (options.background && preloadedInBackgroundRecently(refs, now)) continue; + wanted.set(refKey(refs[0]!), { ref: refs[0]!, refs }); + } + const ordered = [...wanted.values()]; + for (let start = 0; start < ordered.length; start += ISSUE_CHAT_BOOTSTRAP_MAX_IDS) { + if (options.signal?.aborted) return; + const chunk = ordered.slice(start, start + ISSUE_CHAT_BOOTSTRAP_MAX_IDS); + if (options.background) rememberBackgroundPreload(chunk.flatMap((target) => target.refs), Date.now()); + await startChunk(queryClient, companyId, chunk); + } +} + +/** Set by the inline script; `startedAt` is the request's `Date.now()`. */ +type EarlyChatBootstrap = { ref: string; startedAt?: number; promise: Promise }; + +/** + * Adopt the fetch the page's inline script started on a hard load of this task + * (see `vite-issue-detail-preload`), so the first answer needs no new request. + */ +function adoptEarlyChatBootstrap(queryClient: QueryClient, companyId: string | null | undefined, ref: string) { + if (typeof window === "undefined") return false; + const holder = window as unknown as Record; + const early = holder[EARLY_CHAT_BOOTSTRAP_GLOBAL]; + if (!early || refKey(early.ref) !== refKey(ref)) return false; + delete holder[EARLY_CHAT_BOOTSTRAP_GLOBAL]; + // Staleness counts from when the server could last have read the task. + const startedAt = typeof early.startedAt === "number" ? early.startedAt : Date.now(); + // A page that took this long to start reads the task itself. + if (Date.now() - startedAt >= ISSUE_CHAT_BOOTSTRAP_FRESH_MS) return false; + const owner = newOwner(queryClient); + track(queryClient, [ref], owner, startedAt, early.promise.then((item) => { + // The answer belongs to the account that loaded the page. + if (!item?.issue || !ownerMatches(owner, sessionAccount(queryClient))) return undefined; + // The route's task is the one the page opens; a known company must match it. + const ownerCompanyId = companyId ?? item.issue.companyId; + return seedIssueChatBootstrap(queryClient, item, { companyId: ownerCompanyId, requestedAt: startedAt }) ? item : undefined; + }, () => undefined)); + return true; +} + +/** + * The single-task route, for a task whose company the caller does not know + * (a link's open intent). The task's own company owns the answer. + */ +function startSingle(queryClient: QueryClient, ref: string) { + const requestedAt = Date.now(); + const owner = newOwner(queryClient); + const path = `/issues/${encodeURIComponent(ref)}/chat-bootstrap`; + track(queryClient, [ref], owner, requestedAt, Promise.resolve().then(() => api.get(path, { cache: "no-store" })).then((item) => { + if (!item?.issue || !ownerMatches(owner, sessionAccount(queryClient))) return undefined; + return seedIssueChatBootstrap(queryClient, item, { companyId: item.issue.companyId, requestedAt }) ? item : undefined; + }, () => undefined)); +} + +/** + * Start a single-task bootstrap for a cold open or an open intent, unless one + * is loading, loaded recently, or the task's first frame is cached and fresh. + * Without a company it uses the single-task route. Resolves when the task's + * bootstrap (new or already loading) settles; it never rejects. + */ +export function startIssueChatBootstrap( + queryClient: QueryClient, + companyId: string | null | undefined, + ref: string | null | undefined, +): Promise { + if (!ref) return Promise.resolve(); + const settled = () => currentEntry(queryClient, ref)?.promise ?? Promise.resolve(); + if (currentEntry(queryClient, ref)) return settled(); + if (adoptEarlyChatBootstrap(queryClient, companyId, ref)) return settled(); + if (companyId) { + void loadIssueChatBootstraps(queryClient, companyId, [ref]); + return settled(); + } + if (hasFreshIssueChatFirstFrame(queryClient, [ref])) return Promise.resolve(); + startSingle(queryClient, ref); + return settled(); +} + +/** + * For a task page's first read of `key`: when this account's bootstrap for + * the task is loading or just arrived, use its part of the answer instead of + * sending another request. A key that already holds data is being refreshed + * (after a write, an invalidation or a poll), so it always asks the server. + * Falls back to the normal read when there is no bootstrap, or it lacks the task. + */ +export async function readFromIssueChatBootstrap( + queryClient: QueryClient, + key: QueryKey, + ref: string, + pick: (item: ChatBootstrapItem) => T | undefined, + read: () => Promise, +): Promise { + if (queryClient.getQueryData(key) !== undefined) return read(); + const entry = currentEntry(queryClient, ref); + // Only share an answer that is still loading or a moment old. + if (entry && (entry.settledAt === undefined || Date.now() - entry.settledAt < SHARE_WINDOW_MS)) { + let timer: ReturnType | undefined; + const stalled = new Promise<"stalled">((resolve) => { timer = setTimeout(() => resolve("stalled"), CHAT_BOOTSTRAP_SHARE_DEADLINE_MS); }); + const answer = await Promise.race([entry.promise, stalled]); + clearTimeout(timer); + if (answer === "stalled") { + // Later reads (and Retry) go to the server instead of the stuck request. + forget(entry); + return read(); + } + const item = answer; + const value = item ? pick(item) : undefined; + if (value !== undefined) return value; + } + return read(); +} + +/** Whether two sorted id lists are the same set. */ +export function sameIds(a: readonly string[], b: readonly string[]) { + return a.length === b.length && a.every((id, index) => id === b[index]); +} + +/** Test seam. */ +export function resetIssueChatBootstrapsForTest() { + entries.clear(); + backgroundPreloadedAt.clear(); + pendingOwners.clear(); + stopWatchingSession?.(); + stopWatchingSession = null; +} + +/** Test seam: how many refs hold an entry. */ +export function issueChatBootstrapEntryCountForTest() { + return entries.size; +} diff --git a/ui/src/lib/issue-chat-history-queries.ts b/ui/src/lib/issue-chat-history-queries.ts index 58bf0cdaed9..35456a47fd5 100644 --- a/ui/src/lib/issue-chat-history-queries.ts +++ b/ui/src/lib/issue-chat-history-queries.ts @@ -4,6 +4,7 @@ import { activityApi, type RunForIssue } from "@/api/activity"; import { heartbeatsApi } from "@/api/heartbeats"; import { issuesApi } from "@/api/issues"; import { prefetchIssueIdBatches } from "@/hooks/useIssueIdBatches"; +import { readFromIssueChatBootstrap } from "@/lib/issue-chat-bootstrap"; import { queryKeys } from "@/lib/queryKeys"; // The compact task chat reveals its first frame only after these reads settle @@ -53,8 +54,12 @@ export function prefetchIssueCommentInputFacts(queryClient: QueryClient, issueRe /** Recent activity, merged with events already observed while the task is open. */ export async function fetchRecentIssueActivity(queryClient: QueryClient, issueRef: string) { - const recent = await activityApi.recentForIssue(issueRef); - const previous = queryClient.getQueryData(recentIssueActivityQueryKey(issueRef)) ?? []; + const cached = queryClient.getQueryData(recentIssueActivityQueryKey(issueRef)); + // A cold open may already have this in a loading bootstrap; a refresh asks the server. + const recent = cached + ? await activityApi.recentForIssue(issueRef) + : await readFromIssueChatBootstrap(queryClient, recentIssueActivityQueryKey(issueRef), issueRef, (item) => item.recentActivity, () => activityApi.recentForIssue(issueRef)); + const previous = cached ?? []; return [...new Map([...previous, ...recent].map((event) => [event.id, event])).values()]; } @@ -65,8 +70,10 @@ export async function fetchRecentIssueRunWindow( options: { conversationMode?: boolean } = {}, ): Promise { const key = recentIssueRunWindowQueryKey(issueRef); - let recent = await activityApi.recentRunsForIssue(issueRef); const previousWindow = queryClient.getQueryData(key); + let recent = previousWindow + ? await activityApi.recentRunsForIssue(issueRef) + : await readFromIssueChatBootstrap(queryClient, key, issueRef, (item) => item.recentRuns, () => activityApi.recentRunsForIssue(issueRef)); let previous = previousWindow?.runs ?? []; let reconciledFullHistory = false; if (previous.some((run) => diff --git a/ui/src/lib/issue-comment-window-query.ts b/ui/src/lib/issue-comment-window-query.ts index 2597fdb77f6..e41211028ba 100644 --- a/ui/src/lib/issue-comment-window-query.ts +++ b/ui/src/lib/issue-comment-window-query.ts @@ -1,6 +1,7 @@ import type { QueryClient } from "@tanstack/react-query"; import type { IssueComment, IssueRecentComments } from "@paperclipai/shared"; import { issuesApi } from "@/api/issues"; +import { readFromIssueChatBootstrap } from "./issue-chat-bootstrap"; import { queryKeys } from "./queryKeys"; export function mergeWindowComments(...windows: readonly IssueComment[][]): IssueComment[] { @@ -14,8 +15,11 @@ export function getIssueRecentCommentsQueryOptions(queryClient: QueryClient, iss queryKey: recentKey, queryFn: async ({ signal }: { signal: AbortSignal }): Promise => { const previous = queryClient.getQueryData(recentKey); + // A cold open may already have this window in a loading bootstrap. + if (!previous) { + return readFromIssueChatBootstrap(queryClient, recentKey, issueRef, (item) => item.recentComments, () => issuesApi.recentComments(issueRef, { signal })); + } const snapshot = await issuesApi.recentComments(issueRef, { signal }); - if (!previous) return snapshot; // A burst can contain more than the cold-open window. Catch up from the // saved cursor instead of dropping messages between two invalidations. let cursor = previous.latestCommentId; diff --git a/ui/src/lib/issue-detail-preload-events.ts b/ui/src/lib/issue-detail-preload-events.ts index 47f8e232827..43ed1f292d6 100644 --- a/ui/src/lib/issue-detail-preload-events.ts +++ b/ui/src/lib/issue-detail-preload-events.ts @@ -1 +1,4 @@ export const TASK_COLLECTION_PAINTED_EVENT = "paperclip:task-collection-painted"; + +/** Window property holding a hard load's early chat bootstrap fetch: `{ ref, promise }`. */ +export const EARLY_CHAT_BOOTSTRAP_GLOBAL = "__PAPERCLIP_EARLY_CHAT_BOOTSTRAP__"; diff --git a/ui/src/lib/issueDetailCache.test.ts b/ui/src/lib/issueDetailCache.test.ts index 290eab37b15..060eb7370c5 100644 --- a/ui/src/lib/issueDetailCache.test.ts +++ b/ui/src/lib/issueDetailCache.test.ts @@ -17,6 +17,7 @@ import { seedIssueDetailCache, } from "./issueDetailCache"; import { queryKeys } from "./queryKeys"; +import { resetIssueChatBootstrapsForTest } from "./issue-chat-bootstrap"; vi.mock("@/api/issues", () => ({ issuesApi: { @@ -26,6 +27,7 @@ vi.mock("@/api/issues", () => ({ listComments: vi.fn(), listInteractions: vi.fn(), listAttachments: vi.fn(), + chatBootstrap: vi.fn(), }, })); vi.mock("@/api/activity", () => ({ @@ -206,14 +208,57 @@ describe("task navigation prefetch", () => { vi.mocked(issuesApi.listAttachments).mockResolvedValue([]); vi.mocked(heartbeatsApi.liveRunsForIssue).mockResolvedValue([]); vi.mocked(heartbeatsApi.activeRunForIssue).mockResolvedValue(null); + resetIssueChatBootstrapsForTest(); return issue; } + /** A task opened before has some chat reads cached; they refresh one by one. */ + const seenBefore = (client: QueryClient, ref: string) => client.setQueryData(queryKeys.issues.attachments(ref), []); + + it("warms a task with no chat history cached in one bootstrap request", async () => { + vi.clearAllMocks(); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const issue = mockHistoryReads(); + const ref = issue.identifier!; + const recentComments = { comments: [], latestCommentId: null, oldestCommentId: null, hasMore: false }; + vi.mocked(issuesApi.chatBootstrap).mockResolvedValue({ items: [{ + ref, issue, recentComments, commentInputFacts: [], recentActivity: [{ id: "event-1" }] as never, + recentRuns: [run("run-2", "failed"), run("run-1", "succeeded")] as never, + interactions: [], attachments: [], liveRuns: [], activeRun: null, + }] }); + try { + await prefetchIssueDetailForNavigation(client, ref, { issue, history: true }); + expect(issuesApi.chatBootstrap).toHaveBeenCalledWith(issue.companyId, [ref]); + expect(client.getQueryData(recentIssueActivityQueryKey(ref))).toEqual([{ id: "event-1" }]); + expect(client.getQueryData(recentIssueRunWindowQueryKey(ref))).toEqual({ runs: [run("run-2", "failed")], hasEarlierRuns: true }); + expect(client.getQueryData(queryKeys.issues.liveRuns(ref))).toEqual([]); + expect(client.getQueryData([...queryKeys.issues.comments(ref), "recent"])).toEqual(recentComments); + // The recent-comments read shares the bootstrap; the header keeps its own read. + expect(issuesApi.recentComments).not.toHaveBeenCalled(); + expect(issuesApi.getCore).toHaveBeenCalledOnce(); + for (const read of [activityApi.recentForIssue, activityApi.recentRunsForIssue, issuesApi.listInteractions, heartbeatsApi.liveRunsForIssue]) { + expect(read).not.toHaveBeenCalled(); + } + } finally { client.clear(); } + }); + + it("falls back to the separate reads when the bootstrap fails", async () => { + vi.clearAllMocks(); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const issue = mockHistoryReads(); + vi.mocked(issuesApi.chatBootstrap).mockRejectedValue(new Error("busy")); + try { + await prefetchIssueDetailForNavigation(client, issue.identifier!, { issue, history: true }); + expect(issuesApi.recentComments).toHaveBeenCalledOnce(); + expect(client.getQueryData(recentIssueActivityQueryKey(issue.identifier!))).toBeUndefined(); + } finally { client.clear(); } + }); it("fills every key the compact chat waits on before its first reveal", async () => { vi.clearAllMocks(); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const issue = mockHistoryReads(); const ref = issue.identifier!; + seenBefore(client, ref); try { await prefetchIssueDetailForNavigation(client, ref, { issue, history: true }); expect(client.getQueryData(recentIssueActivityQueryKey(ref))).toEqual([{ id: "event-1" }]); @@ -231,6 +276,7 @@ describe("task navigation prefetch", () => { vi.clearAllMocks(); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const issue = { ...mockHistoryReads(), status: "in_progress" as const }; + seenBefore(client, issue.identifier!); try { await prefetchIssueDetailForNavigation(client, issue.identifier!, { issue, history: true }); expect(heartbeatsApi.activeRunForIssue).toHaveBeenCalledWith(issue.identifier); @@ -249,6 +295,7 @@ describe("task navigation prefetch", () => { latestCommentId: "comment-b", oldestCommentId: "comment-a", hasMore: false, } as never); vi.mocked(activityApi.inputFactsForIssue).mockResolvedValue([{ id: "input-1" }] as never); + seenBefore(client, ref); try { await prefetchIssueDetailForNavigation(client, ref, { issue, history: true }); // The same ids, order and keys that the chat's useIssueIdBatches reads. diff --git a/ui/src/lib/issueDetailCache.ts b/ui/src/lib/issueDetailCache.ts index a22e7e9334c..db3639e5ce8 100644 --- a/ui/src/lib/issueDetailCache.ts +++ b/ui/src/lib/issueDetailCache.ts @@ -2,7 +2,13 @@ import type { QueryClient } from "@tanstack/react-query"; import type { InstanceExperimentalSettings, Issue, IssueComment } from "@paperclipai/shared"; import { issuesApi } from "@/api/issues"; import { getIssueRecentCommentsQueryOptions } from "./issue-comment-window-query"; -import { prefetchIssueChatHistory, prefetchIssueCommentInputFacts } from "./issue-chat-history-queries"; +import { + prefetchIssueChatHistory, + prefetchIssueCommentInputFacts, + recentIssueActivityQueryKey, + recentIssueRunWindowQueryKey, +} from "./issue-chat-history-queries"; +import { startIssueChatBootstrap } from "./issue-chat-bootstrap"; import { queryKeys } from "@/lib/queryKeys"; import { getNextIssueCommentPageParam, ISSUE_COMMENT_PAGE_SIZE } from "@/lib/optimistic-issue-comments"; @@ -230,7 +236,8 @@ export function prefetchIssueComments(queryClient: QueryClient, issueRef: string * * `history: true` also warms every other read that gates the compact chat's * first reveal (activity, runs, comment input facts, interactions, - * attachments, live runs). Callers + * attachments, live runs), in one chat-bootstrap request when none of them + * is cached yet. Callers * pass it when an open is likely: a mouse or pen press, a click, keyboard * selection, or a hover or focus that rests on the link. A pointer, touch or * focus passing over rows warms only the issue and its recent comments. @@ -251,6 +258,18 @@ export function prefetchIssueDetailForNavigation( if (isCompleteIssueSnapshot(options?.issue)) { seedIssueDetailCache(queryClient, options.issue, { issueRef }); } + // A task with none of its chat history cached warms it in one bootstrap + // request; the recent-comments read below shares it. A task seen before + // refreshes and reconciles its cached reads one by one. + const coldChat = !!options?.history && [ + recentIssueActivityQueryKey(issueRef), + recentIssueRunWindowQueryKey(issueRef), + queryKeys.issues.interactions(issueRef), + queryKeys.issues.attachments(issueRef), + queryKeys.issues.liveRuns(issueRef), + ].every((key) => queryClient.getQueryData(key) === undefined); + const bootstrap = coldChat ? startIssueChatBootstrap(queryClient, options?.issue?.companyId, issueRef) : null; + const history = !!options?.history && !coldChat; return Promise.all([ queryClient.prefetchQuery({ ...getIssueCoreQueryOptions(queryClient, issueRef), @@ -259,11 +278,12 @@ export function prefetchIssueDetailForNavigation( queryClient.prefetchQuery({ ...getIssueRecentCommentsQueryOptions(queryClient, issueRef), staleTime: ISSUE_COMMENTS_PREFETCH_STALE_TIME_MS, - }).then(() => options?.history + }).then(() => history // The chat also waits for the input facts of the comments it shows. ? prefetchIssueCommentInputFacts(queryClient, issueRef, ISSUE_COMMENTS_PREFETCH_STALE_TIME_MS) : undefined), - ...(options?.history + ...(bootstrap ? [bootstrap] : []), + ...(history ? [prefetchIssueChatHistory(queryClient, issueRef, { issue: options.issue ?? getCachedIssueDetail(queryClient, issueRef), staleTime: ISSUE_COMMENTS_PREFETCH_STALE_TIME_MS, diff --git a/ui/src/lib/vite-issue-detail-preload.test.ts b/ui/src/lib/vite-issue-detail-preload.test.ts index c94262ebdfa..712d4b64d46 100644 --- a/ui/src/lib/vite-issue-detail-preload.test.ts +++ b/ui/src/lib/vite-issue-detail-preload.test.ts @@ -1,7 +1,7 @@ import { runInNewContext } from "node:vm"; import { expect, it } from "vitest"; import { preloadIssueDetail } from "./vite-issue-detail-preload"; -import { TASK_COLLECTION_PAINTED_EVENT } from "./issue-detail-preload-events"; +import { EARLY_CHAT_BOOTSTRAP_GLOBAL, TASK_COLLECTION_PAINTED_EVENT } from "./issue-detail-preload-events"; function bootstrap(pathname: string, imports: Record = {}, responseOk = true, cacheControl = "public, max-age=31536000, immutable") { const hook = preloadIssueDetail().transformIndexHtml; @@ -15,17 +15,18 @@ function bootstrap(pathname: string, imports: Record = {}, res const links: { rel: string; href: string; crossOrigin: string }[] = []; const fetches: unknown[][] = []; const listeners = new Map void>(); + const win: Record = { addEventListener: (name: string, listener: (event: unknown) => void) => listeners.set(name, listener) }; runInNewContext(tags[0].children, { location: { pathname, origin: "https://board.example" }, fetch: (...args: unknown[]) => { fetches.push(args); return Promise.resolve({ ok: responseOk, headers: { get: () => cacheControl }, arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)) }); }, - window: { addEventListener: (name: string, listener: (event: unknown) => void) => listeners.set(name, listener) }, + window: win, document: { createElement: () => ({}), head: { appendChild: (link: typeof links[number]) => links.push(link) }, addEventListener: (name: string, listener: (event: unknown) => void) => listeners.set(name, listener), }, }); - return { links, fetches, painted: () => listeners.get(TASK_COLLECTION_PAINTED_EVENT)!({}), intent: (type: string, pathname: string, origin = "https://board.example") => { + return { win, links, fetches, painted: () => listeners.get(TASK_COLLECTION_PAINTED_EVENT)!({}), intent: (type: string, pathname: string, origin = "https://board.example") => { listeners.get(type)!({ target: { closest: () => ({ pathname, origin }) } }); } }; } @@ -81,3 +82,15 @@ it.each([[true, "public, max-age=31536000, immutable", 1], [false, "public, max- expect(page.links).toHaveLength(linkCount); expect(page.fetches).toHaveLength(1); }); + +it.each([["/PAP/issues/PAP-1", "PAP-1"], ["/issues/PAP-1/", "PAP-1"]])("starts the chat's first-frame data on a hard load of %s", (pathname, ref) => { + const page = bootstrap(pathname); + expect(page.fetches).toContainEqual([`/api/issues/${ref}/chat-bootstrap`, { credentials: "include", headers: { Accept: "application/json" } }]); + expect(page.win[EARLY_CHAT_BOOTSTRAP_GLOBAL]).toMatchObject({ ref }); +}); + +it.each(["/PAP/chats/helper", "/PAP/issues", "/PAP/issues/all", "/PAP/inbox/mine"])("does not fetch chat data on a hard load of %s", pathname => { + const page = bootstrap(pathname); + expect(page.fetches.filter(([url]) => String(url).includes("chat-bootstrap"))).toEqual([]); + expect(page.win[EARLY_CHAT_BOOTSTRAP_GLOBAL]).toBeUndefined(); +}); diff --git a/ui/src/lib/vite-issue-detail-preload.ts b/ui/src/lib/vite-issue-detail-preload.ts index 9d41da59bdf..e8f0b64d4e5 100644 --- a/ui/src/lib/vite-issue-detail-preload.ts +++ b/ui/src/lib/vite-issue-detail-preload.ts @@ -1,5 +1,5 @@ import type { Plugin } from "vite"; -import { TASK_COLLECTION_PAINTED_EVENT } from "./issue-detail-preload-events"; +import { EARLY_CHAT_BOOTSTRAP_GLOBAL, TASK_COLLECTION_PAINTED_EVENT } from "./issue-detail-preload-events"; export function preloadIssueDetail(): Plugin { return { @@ -47,7 +47,22 @@ export function preloadIssueDetail(): Plugin { } for (const href of ${JSON.stringify([...dependencies].map(fileName => `/${fileName}`))}) append(href); }; - if (isDetail(location.pathname)) preload(); + if (isDetail(location.pathname)) { + preload(); + // A hard load of a task starts the chat's first-frame data now, in + // parallel with the app's JavaScript; the page adopts this answer. + const parts = location.pathname.split("/").filter(Boolean); + const ref = parts[parts.length - 1]; + if (parts[parts.length - 2] === "issues" && ref) { + window[${JSON.stringify(EARLY_CHAT_BOOTSTRAP_GLOBAL)}] = { + ref: decodeURIComponent(ref), + startedAt: Date.now(), + promise: fetch("/api/issues/" + ref + "/chat-bootstrap", { credentials: "include", headers: { Accept: "application/json" } }) + .then(response => response.ok ? response.json() : null) + .catch(() => null), + }; + } + } window.addEventListener(${JSON.stringify(TASK_COLLECTION_PAINTED_EVENT)}, () => preload(true), { once: true }); const onIntent = event => { const link = event.target.closest?.("a[href]"); diff --git a/ui/src/lib/when-page-settles.ts b/ui/src/lib/when-page-settles.ts new file mode 100644 index 00000000000..99de0d14ddd --- /dev/null +++ b/ui/src/lib/when-page-settles.ts @@ -0,0 +1,63 @@ +import type { QueryClient } from "@tanstack/react-query"; + +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. + */ +export 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; +} + diff --git a/ui/src/pages/CoordinatorTasks.tsx b/ui/src/pages/CoordinatorTasks.tsx index 6d66fdaf8e3..c262e164d9e 100644 --- a/ui/src/pages/CoordinatorTasks.tsx +++ b/ui/src/pages/CoordinatorTasks.tsx @@ -292,7 +292,7 @@ export function CoordinatorTasks({ viewControl }: { viewControl?: ReactNode } = items={(needsAttention ? collection.matchingItems : collection.visibleItems).map(enrichItem)} allItems={collection.companyItems.map(enrichItem)} preferenceLocation={userId ? { companyId, collectionKey: `coordinator-tasks:${userId}` } : undefined} collectionKey={JSON.stringify([companyId, userId, needsAttention, deferredQuery, participantAgentId, workspaceIds, collection.view, collection.filters])} - sort={sort} onSortChange={setSort} + sort={sort} onSortChange={setSort} preloadCompanyId={collection.layout === "list" ? companyId : null} issueLinkState={issueLinkState} collapsedIds={collection.collapsedIds} onCollapsedIdsChange={collection.setCollapsedIds} /> : empty} {collection.layout === "board" ? collection.matchingItems.length ? enrichIssueRow(item.issue))} agents={agents.data} onUpdateIssue={(id, data) => update.mutate({ id, data })} /> : empty : null} diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 52518fa0cda..9819d41dbb5 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -2,6 +2,7 @@ import { errorCodes, isTransientError } from "@/api/errors"; import { useQueryView } from "@/components/QueryView"; import { useCoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation"; import { useWarmTaskDetailCode } from "@/hooks/useWarmTaskDetailCode"; +import { topTaskRefs, usePreloadTaskChats } from "../hooks/usePreloadTaskChats"; import { describeError } from "../api/errors"; import { useIssueRowDetails } from "../hooks/useIssueRowDetails"; import { IssueRowDetailsNotice } from "../components/IssueRowDetailsNotice"; @@ -180,6 +181,7 @@ import { type InboxWorkItemGroupBy, INBOX_FILTER_PREFERENCES_KEY_PREFIX, INBOX_ISSUE_COLUMNS_KEY, + inboxNavEntryTask, } from "../lib/inbox"; import { useDismissedInboxAlerts, useInboxDismissals, useReadInboxItems } from "../hooks/useInboxBadge"; import { useInboxSortAttention } from "../hooks/useInboxSortAttention"; @@ -2624,6 +2626,11 @@ function StreamlinedInbox({ }, [initialSourcesSettled, listError, revealScope]); const allLoaded = initialSourcesSettled || revealedScope === revealScope; useWarmTaskDetailCode(allLoaded && !listError && tab !== "blocked" && showWorkItemsSection); + // Preload the chat of the top visible tasks, in display order, so opening + // one renders its latest messages and composer from cache. + // The walk stops at the preload limit, whatever the size of the Inbox. + const preloadTaskRefs = useMemo(() => topTaskRefs(flatNavItems, inboxNavEntryTask), [flatNavItems]); + usePreloadTaskChats(selectedCompanyId, preloadTaskRefs, allLoaded && !listError && showWorkItemsSection); const showSeparatorBefore = (key: SectionKey) => visibleSections.indexOf(key) > 0; const { enrichIssueRow, error: rowDetailsError, retry: retryRowDetails } = useIssueRowDetails( diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 11af0e50932..844c21f5501 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -88,6 +88,7 @@ import { recentIssueRunWindowQueryKey, type RecentIssueRunWindow, } from "../lib/issue-chat-history-queries"; +import { readFromIssueChatBootstrap, sameIds, startIssueChatBootstrap } from "../lib/issue-chat-bootstrap"; import { heartbeatsApi, type ActiveRunForIssue, @@ -1504,7 +1505,14 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ const inputCommentIds = issueInputCommentIds(comments); const inputFacts = useIssueIdBatches( issueCommentInputFactsQueryKey(issueQueryRef), inputCommentIds, - ids => activityApi.inputFactsForIssue(issueQueryRef, ids), !!issueId && !historyExpanded, + ids => readFromIssueChatBootstrap( + queryClient, + [...issueCommentInputFactsQueryKey(issueQueryRef), "batch", ids], + issueQueryRef, + // The bootstrap answers exactly the saved recent-window comments. + (item) => sameIds(issueInputCommentIds(item.recentComments.comments), ids) ? item.commentInputFacts : undefined, + () => activityApi.inputFactsForIssue(issueQueryRef, ids), + ), !!issueId && !historyExpanded, ); const { data: liveRuns, @@ -1513,7 +1521,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ refetch: refetchLiveRuns, } = useQuery({ queryKey: queryKeys.issues.liveRuns(issueQueryRef), - queryFn: () => heartbeatsApi.liveRunsForIssue(issueQueryRef), + queryFn: () => readFromIssueChatBootstrap(queryClient, queryKeys.issues.liveRuns(issueQueryRef), issueQueryRef, (item) => item.liveRuns, () => heartbeatsApi.liveRunsForIssue(issueQueryRef)), enabled: !!issueId, refetchInterval: (query) => liveRunRefetchInterval(hasLiveConnection, query.state, 1000), placeholderData: @@ -1530,7 +1538,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({ refetch: refetchActiveRun, } = useQuery({ queryKey: queryKeys.issues.activeRun(issueQueryRef), - queryFn: () => heartbeatsApi.activeRunForIssue(issueQueryRef), + queryFn: () => readFromIssueChatBootstrap(queryClient, queryKeys.issues.activeRun(issueQueryRef), issueQueryRef, (item) => item.activeRun, () => heartbeatsApi.activeRunForIssue(issueQueryRef)), enabled: activeRunQueryEnabled, refetchInterval: (query) => liveRunCount > 0 ? false : liveRunRefetchInterval(hasLiveConnection, query.state, 1000), placeholderData: keepPreviousDataForSameQueryTail( @@ -3313,6 +3321,15 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS [location.state, resolvedIssueDetailState], ); + // A cold open sends one bootstrap request for everything the chat's first + // frame needs; the reads below wait for its answer instead of fanning out. + // A task preloaded by the list is already cached, so this is a no-op. + const bootstrapCompanyId = companies.find((company) => company.issuePrefix === companyPrefix?.toUpperCase())?.id ?? selectedCompanyId; + if (issueId && !conversation && queryClient.getQueryData([...queryKeys.issues.detail(issueId), "core"]) === undefined) { + startIssueChatBootstrap(queryClient, bootstrapCompanyId, issueId); + } + // The core issue keeps its own small, fast read so the header never waits + // for the larger chat bootstrap; a preloaded task already has it cached. const coreIssueQuery = useQuery({ ...getIssueCoreQueryOptions(queryClient, issueId!), enabled: !!issueId, @@ -3521,7 +3538,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS refetch: refetchInteractions, } = useQuery({ queryKey: queryKeys.issues.interactions(issueId!), - queryFn: () => issuesApi.listInteractions(issueId!), + queryFn: () => readFromIssueChatBootstrap(queryClient, queryKeys.issues.interactions(issueId!), issueId!, (item) => item.interactions, () => issuesApi.listInteractions(issueId!)), enabled: !!issueId, // A review can be committed between the initial fetch and live-socket // subscription. Reconcile even after its originating run has ended. @@ -3538,7 +3555,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS refetch: refetchAttachments, } = useQuery({ queryKey: queryKeys.issues.attachments(issueId!), - queryFn: () => issuesApi.listAttachments(issueId!), + queryFn: () => readFromIssueChatBootstrap(queryClient, queryKeys.issues.attachments(issueId!), issueId!, (item) => item.attachments, () => issuesApi.listAttachments(issueId!)), enabled: !!issueId, placeholderData: keepPreviousDataForSameQueryTail( issueId ?? "pending", diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index 10a24fdb84a..800b6e4e4f7 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -344,6 +344,7 @@ function OrganizationIssues({ return (