mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
perf(task-chat): open tasks from a preloaded cache and cold tasks in one request (#20592)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - People open tasks from task lists, the Inbox and the sidebar many times each hour > - A task's chat shows its first frame only after about seven separate reads finish > - #20355 added one batched `chat-bootstrap` read that returns all of these reads. #20450 used it only for the sidebar's Recents > - The hosted database is CPU-bound, so the UI must open tasks faster and must also send fewer requests > - This pull request uses the bootstrap for task lists, for open intent on any task link, for cold opens and for a hard load of a task URL. It shares one coordination module between all of these paths > - The benefit is that a task opens from cache more often, a cold open sends one chat request instead of about seven, and the first content paint comes sooner ## Linked Issues or Issue Description Refs #20355 (server read that this pull request consumes). Refs #20450 (Recents preload, now on the shared path). **Is your feature request related to a problem?** A click on a task in a task list waited for about seven separate chat reads. The default task list (Coordinator) had no preload at all. A press or a resting hover on a task link sent the same seven reads, and the page then sent a second set. A hard load of a task URL started its chat reads only after the JavaScript bundle and the company list loaded. Sidebar project task links had no open-intent prefetch. **Describe the solution you'd like** Use the single `chat-bootstrap` read everywhere a task is about to open. Let the page's gating reads share a bootstrap that is in flight instead of sending their own requests. Keep the core issue read separate so the header does not wait for the larger read. **Describe alternatives you've considered** Preload 30 rows per list in three requests (the first prototype). Each task costs the server about fifty queries, and a resting hover warms any other row before the click, so this change preloads 10 rows in one request. ## What Changed - `ui/src/lib/issue-chat-bootstrap.ts`: one module decides when to send a bootstrap and lets page reads share it (`readFromIssueChatBootstrap`). Seeding uses the existing `issueChatBootstrapCache` (replace-style reads are refreshed when newer, merge-style reads are filled only when empty, other companies are ignored). It skips tasks that are loading, loaded in the last 30 seconds, or already fresh in cache, under the id or the identifier. It never aborts a request in flight, so a click that unmounts the list still uses the answer. Each request is bound to the account that sent it; a request sent before the first session read is bound to the first account the session reports, and a sign-out revokes it. A page read shares a bootstrap only while its key holds no data (a refresh after a write always reads the server), and waits at most 5 seconds for it. Every ref of a task (id and identifier) is tracked at request start. Entries expire after 30 seconds. - Task lists preload the chat of their top 10 rows in one request, after the list's own reads settle and the browser is idle (`usePreloadTaskChats`, `when-page-settles.ts`): the Coordinator task list (`TaskList` with `preloadCompanyId`), the streamlined and legacy issues lists and the streamlined Inbox. Recents now uses the same path, so a task in both places loads once. A list that stays open preloads each task at most once in five minutes: live events (#20563's lanes) keep a preloaded task's cache current or mark it stale, and a stale key is read from the server, never from an older bootstrap. All seeded keys are per-task keys on the fast live lane, not the ambient lane. - Open intent (press, click, resting hover or focus) on a task with no chat history in cache warms it with one bootstrap request instead of six separate reads (`prefetchIssueDetailForNavigation`). A task seen before still refreshes its cached reads one by one. - A cold open starts one bootstrap. The gating reads (recent comments, activity, runs, input facts, interactions, attachments, live runs, active run) wait for it instead of sending their own requests. The core issue read stays independent. - A hard load of a task URL starts `GET /api/issues/:ref/chat-bootstrap` from the inline preload script, in parallel with the JavaScript bundle. The page adopts that answer. - Sidebar project task links (Coordinator sidebar) get the same hover, press and focus warming as Recents and `IssueLinkQuicklook`. - Tests: unit tests for the coordination module, Recents, `TaskList` preload, sidebar warming and the cold-intent path. A new layout journey checks that a task the Inbox preloaded reveals once at its latest message without reading its chat again. Journeys that hold one separate read now also hold or fail the bootstrap. The Inbox reveal journey held a read that the chat no longer sends on open (it was flaky on master too), so it now holds the recent comment window. - The perf harness gains `PAPERCLIP_ISSUE_PERF_LIST_DWELL_MS` and the README documents it. ## Verification Measurement setup: production UI builds of master (`e01fe7f84a` and the earlier base `8d2bf78323`) and of this branch, served by one local server with `PAPERCLIP_UI_DEV_MIDDLEWARE=false` and embedded PostgreSQL. Only `index.html` changed between variants (both asset sets were present), so the server, database and seeded task were the same. Four rounds of the issue-detail harness, with master and branch alternated in each round: 20 pairs per S1 cell, 40 per S2 cell. The host was shared with other agents (load average 16 to 43), so the spread is large. Content paint (the task is readable), median paired delta, branch minus master: | Scenario | Profile | Pairs | Content paint | Branch faster in | API requests before content (master / branch) | |---|---|---:|---:|---:|---:| | S1 preloaded click (dwell 1500 ms) | unthrottled | 20 | -45 ms | 15/20 | 25 / 18 | | S1 preloaded click (dwell 1500 ms) | Fast 4G, 4x CPU | 20 | -23 ms | 13/20 | 26 / 18 | | S1 click, no dwell | unthrottled | 20 | -51 ms | 15/20 | 25 / 18 | | S1 click, no dwell | Fast 4G, 4x CPU | 20 | -21 ms | 12/20 | 26 / 18 | | S2 cold deep link | unthrottled | 40 | -61 ms | 28/40 | 54 / 40 | | S2 cold deep link | Fast 4G, 4x CPU | 40 | -129 ms | 29/40 | 47 / 39 | Medians (p25 to p75), content paint in ms: S1 dwell unthrottled 295 (283-315) → 248 (217-276). S1 dwell Fast 4G 1133 (1114-1226) → 1093 (853-1176). S1 no dwell unthrottled 295 (277-328) → 260 (233-332). S1 no dwell Fast 4G 984 (842-1196) → 923 (816-1110). S2 unthrottled 1002 (677-1235) → 896 (619-1280). S2 Fast 4G 4643 (4155-5061) → 4516 (4298-4894). Header paint is neutral to slightly later in two cells: S1 dwell unthrottled +39 ms (3/20 faster) and S2 Fast 4G +56 ms (17/40). In those cells the header now paints in the same frame as the content. In the first two rounds, the median gap from header to content fell from 105-157 ms to 29-74 ms unthrottled and from 557 ms to 298 ms for S2 on Fast 4G. Other header cells are -27 to +1 ms. The local server answers each read in a few milliseconds, so the network wait that the preload removes is small here. The request count is the stable result: a cold open sends one chat request instead of about seven. List page: the preload request starts after the first task row is in the DOM (64 ms after it unthrottled, 200-280 ms after it on Fast 4G, page clock). Time to the first visible row, 30-task list: unthrottled master 1863 ms vs branch 1563 ms (6 pairs, branch faster in 5). Fast 4G master 3626 ms vs branch 3727 ms (14 pairs, branch faster in 6, mixed signs). The preload starts after the row paints, so it cannot delay it; the Fast 4G difference is noise. One 10-task bootstrap took 78-162 ms on the server. Checks run: - `pnpm exec vitest run` for the bootstrap, cache, prefetch, Recents, sidebar, quicklook, `TaskList`, issues list, Inbox, issue detail and Coordinator tests: 18 files, 646 tests pass. - `pnpm exec tsc -p ui/tsconfig.json --noEmit`: clean, except missing generated Storybook catalog files that this change does not touch. - `pnpm check:token-gates`: clean. - `tests/e2e/task-loading-frame.spec.ts`, `disconnect-resilience.spec.ts` and `connection-reviews.spec.ts` (production build): 21 passed, 1 skipped. - `tests/perf/issue-detail/layout-stability.spec.ts` and `plan-startup.spec.ts` against the production build: all pass. The Inbox reveal journey and the new preloaded journey passed 4 of 4 repeats each. - `pnpm verify` asks for broader CI coverage because an e2e spec changed. ## Risks - More server work for list views: one bootstrap of up to 10 tasks per list visit (about fifty queries per task), skipped for tasks already loaded in the last 30 seconds. It runs only after the page's own reads settle and the browser is idle, and only while the tab is visible. - A resting hover on a never-opened task now sends one bootstrap instead of about six reads. The server work per task is about the same; the request count is lower. - If a bootstrap fails, each page read falls back to its own request. The fallback is slower than before for that one open (the reads start when the page mounts, not at the press). - The header can paint a few tens of milliseconds later in the same frame as the content when everything is cached. - An answer that arrives after a sign-out or account change is dropped. An answer for another company is ignored. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edits, Playwright runs). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6505538fc5
commit
2e6850e745
32 files changed
+1342
-111
No files matched your search
@@ -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)(?:\\?|$)`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<void>((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<void>(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 });
|
||||
|
||||
@@ -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<typeof import("@/lib/issueDetailCache")>(),
|
||||
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<HTMLAnchorElement>('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 }));
|
||||
});
|
||||
});
|
||||
@@ -75,7 +75,7 @@ function ProjectRecent({ project }: { project: Project }) {
|
||||
</div>
|
||||
{expanded && <div className="ml-5 border-l border-border pl-1">
|
||||
{isPending ? <p className="px-3 py-2 text-xs text-muted-foreground" role="status">Loading tasks…</p> : error ? <p className="px-3 py-2 text-xs text-muted-foreground">Couldn’t load tasks.</p> : tasks?.length ? tasks.map(task =>
|
||||
<SidebarNavItem className="design-sidebar-item" key={task.id} to={`/issues/${task.identifier ?? task.id}`} label={task.title} iconNode={<StatusIcon {...taskStatusIconProps(task)} />} />)
|
||||
<RecentTaskNavItem className="design-sidebar-item" key={task.id} task={task} label={task.title} iconNode={<StatusIcon {...taskStatusIconProps(task)} />} />)
|
||||
: <p className="px-3 py-2 text-xs text-muted-foreground">No tasks yet.</p>}
|
||||
</div>}
|
||||
</div>;
|
||||
|
||||
@@ -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<Parameters<typeof issuesApi.list>[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,
|
||||
|
||||
@@ -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<Project, "id" | "name"> & Partial<Pick<Project, "color
|
||||
type IssueListRequestFilters = NonNullable<Parameters<typeof issuesApi.list>[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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) =>
|
||||
<a href={to} {...props}>{children}</a>,
|
||||
}));
|
||||
|
||||
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(<TooltipProvider><TaskList items={items} preloadCompanyId="company-1" /></TooltipProvider>));
|
||||
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(<TooltipProvider><TaskList items={large} groupBy="status" preloadCompanyId="company-1" /></TooltipProvider>));
|
||||
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(<TooltipProvider><TaskList items={items} /></TooltipProvider>));
|
||||
expect(preloads).toEqual([]);
|
||||
expect(host.textContent).toContain("Task 13");
|
||||
});
|
||||
});
|
||||
@@ -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<Pick<Issue, "id" | "identifier">>, rendered: ReadonlySet<string>, 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<Issue> {
|
||||
function* branch(issue: Issue): Generator<Issue> {
|
||||
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 (
|
||||
<TaskListViewport visibleColumns={columns} columnOrder={columnOrder} controls={<TaskListColumnSettings visibleColumns={columns} onVisibleColumnsChange={updateColumns} onColumnOrderChange={setColumnOrder} onResetDefaults={() => setSort(undefined)} />}>
|
||||
<TaskListColumnHeader sort={sort && isTaskListColumnSort(sort) ? sort : undefined} onSortChange={setSort} onColumnOrderChange={setColumnOrder} />
|
||||
@@ -160,6 +195,7 @@ export function TaskList({ items, allItems = items, initialColumns = defaultTask
|
||||
<span>Rendering {Math.min(limit, rowIds.length)} of {rowIds.length} tasks</span>
|
||||
<Button variant="outline" size="sm" onClick={loadMore}>Show more tasks</Button>
|
||||
</div> : null}
|
||||
{preloadCompanyId && preloadRefs.length ? <TaskChatPreload companyId={preloadCompanyId} refs={preloadRefs} /> : null}
|
||||
</TaskListViewport>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof topRenderedNavEntryRefs>[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);
|
||||
});
|
||||
});
|
||||
@@ -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<T>(items: Iterable<T>, 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]);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = {}): 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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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<string, unknown>)[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);
|
||||
});
|
||||
});
|
||||
@@ -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<ChatBootstrapItem | undefined>; owner: Owner; startedAt: number; settledAt?: number };
|
||||
const entries = new Map<string, Entry>();
|
||||
|
||||
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<Owner>();
|
||||
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<ChatBootstrapItem | undefined>) {
|
||||
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<string, ChatBootstrapItem>();
|
||||
// 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<string, number>();
|
||||
|
||||
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<string, Wanted>();
|
||||
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<ChatBootstrapItem | null> };
|
||||
|
||||
/**
|
||||
* 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<string, EarlyChatBootstrap | undefined>;
|
||||
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<ChatBootstrapItem>(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<unknown> {
|
||||
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<T>(
|
||||
queryClient: QueryClient,
|
||||
key: QueryKey,
|
||||
ref: string,
|
||||
pick: (item: ChatBootstrapItem) => T | undefined,
|
||||
read: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<typeof setTimeout> | 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;
|
||||
}
|
||||
@@ -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<ActivityEvent[]>(recentIssueActivityQueryKey(issueRef)) ?? [];
|
||||
const cached = queryClient.getQueryData<ActivityEvent[]>(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<RecentIssueRunWindow> {
|
||||
const key = recentIssueRunWindowQueryKey(issueRef);
|
||||
let recent = await activityApi.recentRunsForIssue(issueRef);
|
||||
const previousWindow = queryClient.getQueryData<RecentIssueRunWindow>(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) =>
|
||||
|
||||
@@ -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<IssueRecentComments> => {
|
||||
const previous = queryClient.getQueryData<IssueRecentComments>(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;
|
||||
|
||||
@@ -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__";
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string[]> = {}, responseOk = true, cacheControl = "public, max-age=31536000, immutable") {
|
||||
const hook = preloadIssueDetail().transformIndexHtml;
|
||||
@@ -15,17 +15,18 @@ function bootstrap(pathname: string, imports: Record<string, string[]> = {}, res
|
||||
const links: { rel: string; href: string; crossOrigin: string }[] = [];
|
||||
const fetches: unknown[][] = [];
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const win: Record<string, unknown> = { 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();
|
||||
});
|
||||
@@ -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]");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}</div>
|
||||
{collection.layout === "board" ? collection.matchingItems.length
|
||||
? <KanbanBoard showOverflowFades issues={collection.matchingItems.map(item => enrichIssueRow(item.issue))} agents={agents.data} onUpdateIssue={(id, data) => update.mutate({ id, data })} /> : empty : null}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<ActiveRunForIssue | null>(
|
||||
@@ -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<IssueAttachment[]>(
|
||||
issueId ?? "pending",
|
||||
|
||||
@@ -344,6 +344,7 @@ function OrganizationIssues({
|
||||
return (
|
||||
<IssuesList
|
||||
issues={issues ?? []}
|
||||
preloadTaskChats
|
||||
isLoading={issuesView.kind === "loading" || issuesView.kind === "reconnecting"}
|
||||
isLoadingMoreIssues={isFetchingNextPage}
|
||||
agents={agents}
|
||||
|
||||
Reference in new issue
Block a user