mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
perf(ui): reduce task startup initialization and redundant reads (#16862)
## Thinking Path > - Paperclip users open tasks to read saved work and reply. > - Faster startup must preserve message positions and composer state. > - Chrome CPU samples showed unused shared schemas and catalog indexes being initialized before task content. > - The shared package exports definitions and module-local data, with no required import-time registrations. > - Let the browser bundler discard unused shared modules. > - Readiness marks also exposed a duplicate plan read even though full task detail contains that plan. > - Keep both improvements measurable through CPU profiles, gate transitions and real browser acceptance. ## Linked Issues or Issue Description Follow-up to #16849. Related layout work: #14758. Searches found no overlapping plan-cache PR. **What existing behavior does this improve?** Task startup evaluates unused schemas and catalog indexes reached through the shared package barrel. The task also waits for a second plan-document request after full task detail arrives, even when that response already includes the plan or an explicit absent-plan value. Profiling showed that removing the request alone gave a small and variable total gain; unused initialization was the larger cold-load opportunity. **Subsystem affected** Board task loading, shared-package bundling metadata, plan-query caching, and local issue-detail profiling. **Proposed behavior** Discard unused shared modules during browser bundling. Fill an unused plan query when a full task response arrives. List-only snapshots keep their normal plan fetch. Keep the existing coordinated reveal. ## What Changed - Declare the shared package side-effect free so the bundler can drop unused module initialization. Used schemas and catalog data still initialize normally. - Seed an absent plan query at the successful full-task HTTP response boundary. Use a 30-second plan-query freshness window. List cache merges do not update this plan cache, and a later detail response never overwrites an existing plan query. - Preserve document-scope invalidation for plan creation, edits and deletion. - Add opt-in Chrome CPU profiles and readiness transitions to the existing performance harness. Diagnostic runs use one sample per scenario/profile and are labelled separately from latency benchmarks. - Record only fixed gate names and readiness booleans in local browser marks. Reset the gate effects on task changes so fully cached navigation is profiled too. ## Verification - Production UI build, UI typecheck and token design gates passed. - 258 focused unit tests passed. The final freshness follow-up passed 14 tests, including query eviction followed by a list refresh, preserving a separately fetched newer plan, missing projection, task isolation, and document-event refresh/deletion. - Two focused reuse tests fail with the original HTTP cache function and pass with the fix. The original production browser case makes redundant plan reads and fails. - All 13 final browser cases passed serially: eight layout scenarios, four plan-present/absent and direct/list startup cases, and a cached-task switch that records every gate. The cached-switch case failed before the identity dependencies were added. - One earlier concurrent browser run failed a properties-resize reading-anchor assertion. Two isolated repeats and the final serial suite passed. This outlier is retained in the local logs; it was not treated as a reason to change the unchanged scroll owner speculatively. - Actual CPU samples and readiness artifacts were captured on a disposable local production preview. This is browser timing data, not exported telemetry. - Audited 247 non-test shared modules for required import-time registration. The four top-level loops construct exported local catalog data. No required registration or side-effect-only shared imports were found. - A compiled browser-library contract accepted valid task input, rejected an empty title, retained all seven agent statuses, and retained GitHub metadata plus 1,576 catalog entries. All 13 browser scenarios passed against the initialization candidate too. - Plan reuse alone did not establish a reliable total gain: a final local run was 4,356 ms cold and 1,116 ms warm on Fast 4G/4x CPU. - The next five-sample run with unused shared initialization removed measured **3,298 ms cold (24% faster)** and 1,079 ms warm. Median issue API server time was 35 versus 39 ms. First contentful paint fell from 2,808 to 1,648 ms. JavaScript before content fell from 1,722 to 1,670 KiB; CPU initialization mattered more than byte size. - Final alternating comparison: five samples per variant for both cold and warm navigation, same persisted task and Fast 4G/4x CPU settings, fresh browser per sample, order alternated between variants. **Cold content paint: 4,588 → 3,688 ms (20% faster). Warm: 996 → 1,030 ms (+35 ms).** No CPU profiler was active. The final initialization commit is the only source difference between these two builds. - These local measurements do not establish deployed staging latency or a speedup across every profile. Unthrottled timings varied with API latency. - Actual UI walkthrough on the final bundle: read the saved plan and conversation, submit one unassigned reply, refresh, navigate to Projects and Back, and inspect the new-task dialog. The reply persisted exactly once, confirmed independently through the comments API. No paid agent was woken. - Final head `a7043c474d`: Greptile 5/5, all review threads resolved, and all 56 checks passed, including full typecheck, build, unit and E2E suites. ## Risks Bundlers can discard unreferenced shared modules. Future required registrations or global initialization must be declared in the package side-effects metadata. Current module-local catalog construction remains when its exports are used. Merged list snapshots can retain old plan fields while receiving a new task-cache timestamp. This change seeds only from a new full HTTP response, rather than inferring plan freshness from those merged snapshots. Existing plan-query state is never overwritten. List-only, stale, invalidated, or incomplete data still uses the normal plan read. Existing document invalidations refresh the query. No API, database, permission or reveal-policy changes. ## Model Used OpenAI Codex, GPT-6, with reasoning, repository tools, code execution and browser verification. A more precise runtime model identifier and context-window size are unavailable. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
35c75b56d4
commit
d5b92cc41b
10 files changed
+254
-7
No files matched your search
@@ -12,6 +12,7 @@
|
||||
"directory": "packages/shared"
|
||||
},
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./telemetry": "./src/telemetry/index.ts",
|
||||
|
||||
@@ -26,6 +26,16 @@ PAPERCLIP_ISSUE_PERF_RUNS=7 PAPERCLIP_ISSUE_PERF_PORT=3210 pnpm exec playwright
|
||||
|
||||
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
|
||||
conversation-readiness transitions in one diagnostic sample per scenario/profile.
|
||||
Import the resulting `*.cpuprofile` in DevTools and inspect `*.readiness.json` for
|
||||
the task, comments, metadata, activity, runs, runtime, plan and transcript gates.
|
||||
Each gate records changes with `detail.ready`; use its last transition before
|
||||
content paint when a gate becomes pending after mount. These diagnostic runs
|
||||
include profiler overhead. Run without the flag for before/after latency claims.
|
||||
Readiness marks stay in the local browser and contain only fixed gate names and
|
||||
booleans; the harness enables them before navigation.
|
||||
|
||||
For bundle comparisons, build the UI and serve the production assets from an
|
||||
isolated local instance with `PAPERCLIP_UI_DEV_MIDDLEWARE=false`. The default
|
||||
managed server above uses development middleware, which does not reproduce
|
||||
@@ -50,6 +60,11 @@ Back navigation, and covers mobile reduced motion and explicit comment links.
|
||||
The eight scenarios also exercise same-task hash navigation without remounting
|
||||
on desktop and mobile, and stalled native/log reads that expose Retry after
|
||||
the 15-second request deadline.
|
||||
`plan-startup.spec.ts` also opens tasks with and without a saved plan while
|
||||
blocking the separate plan endpoint. Both must reveal the saved conversation
|
||||
from the task response without issuing that redundant read.
|
||||
It also checks task-list navigation, where a list-only snapshot needs one normal
|
||||
plan fetch and must still show a saved plan when present.
|
||||
Videos and per-frame `layout.json` measurements are written under each test's
|
||||
output directory. The two-pixel assertion measures a logical row's viewport
|
||||
offset; total scroll offset legitimately changes when history is prepended.
|
||||
|
||||
@@ -3,7 +3,8 @@ import path from "node:path";
|
||||
import { chromium, expect, test, type Browser, type BrowserContext, type Page } from "@playwright/test";
|
||||
|
||||
const requestedRuns = Number(process.env.PAPERCLIP_ISSUE_PERF_RUNS ?? 5);
|
||||
const RUNS = Number.isFinite(requestedRuns) ? Math.max(5, Math.floor(requestedRuns)) : 5;
|
||||
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 HEADER_MEASURE = "issue-detail:navigate→header-paint";
|
||||
@@ -132,9 +133,10 @@ async function configureProfile(context: BrowserContext, page: Page, profile: Pr
|
||||
return session;
|
||||
}
|
||||
|
||||
async function installVitalObserver(page: Page, cold: boolean): Promise<void> {
|
||||
await page.addInitScript(({ coldStart }) => {
|
||||
async function installVitalObserver(page: Page, cold: boolean, captureProfile: boolean): Promise<void> {
|
||||
await page.addInitScript(({ coldStart, captureProfile }) => {
|
||||
if (coldStart) window.__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__ = 0;
|
||||
window.__PAPERCLIP_ISSUE_DETAIL_PROFILE__ = captureProfile;
|
||||
(window as Window & { __issuePerfLcp?: number }).__issuePerfLcp = undefined;
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
@@ -142,7 +144,7 @@ async function installVitalObserver(page: Page, cold: boolean): Promise<void> {
|
||||
if (latest) (window as Window & { __issuePerfLcp?: number }).__issuePerfLcp = latest.startTime;
|
||||
}).observe({ type: "largest-contentful-paint", buffered: true });
|
||||
} catch {}
|
||||
}, { coldStart: cold });
|
||||
}, { coldStart: cold, captureProfile });
|
||||
}
|
||||
|
||||
async function startNetworkCapture(session: Awaited<ReturnType<typeof configureProfile>>) {
|
||||
@@ -242,7 +244,8 @@ function summarizeNetwork(records: Map<string, NetworkRecord>, seedData: Seed, c
|
||||
async function runScenario(browser: Browser, baseURL: string, seedData: Seed, profile: Profile, scenario: RunMetrics["scenario"], run: number): Promise<RunMetrics> {
|
||||
const context = await browser.newContext({ baseURL });
|
||||
const page = await context.newPage();
|
||||
await installVitalObserver(page, scenario.startsWith("S2"));
|
||||
const captureProfile = CAPTURE_PROFILE && run === 1;
|
||||
await installVitalObserver(page, scenario.startsWith("S2"), captureProfile);
|
||||
const session = await configureProfile(context, page, profile);
|
||||
const network = await startNetworkCapture(session);
|
||||
const tracePath = path.join(OUTPUT_DIR, `${scenario.slice(0, 2).toLowerCase()}-${profile.name}-run-${run}.trace.json`);
|
||||
@@ -252,16 +255,31 @@ async function runScenario(browser: Browser, baseURL: string, seedData: Seed, pr
|
||||
const issueLink = page.locator("[data-inbox-issue-link]", { hasText: seedData.title }).first();
|
||||
await expect(issueLink).toBeVisible({ timeout: PAGE_READY_TIMEOUT_MS });
|
||||
network.clear();
|
||||
}
|
||||
if (captureProfile) {
|
||||
await session.send("Profiler.enable");
|
||||
await session.send("Profiler.setSamplingInterval", { interval: 1000 });
|
||||
await session.send("Profiler.start");
|
||||
}
|
||||
if (scenario.startsWith("S1")) {
|
||||
await page.evaluate(() => {
|
||||
window.__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__ = performance.now();
|
||||
});
|
||||
await issueLink.click();
|
||||
await page.locator("[data-inbox-issue-link]", { hasText: seedData.title }).first().click();
|
||||
} else {
|
||||
await page.goto(`/${seedData.prefix}/issues/${seedData.identifier}`);
|
||||
}
|
||||
|
||||
await expect(page.getByTestId("issue-detail-header").filter({ visible: true }).first()).toBeVisible({ timeout: PAGE_READY_TIMEOUT_MS });
|
||||
const paint = await readPaintMetrics(page);
|
||||
if (captureProfile) {
|
||||
const { profile } = await session.send("Profiler.stop");
|
||||
const readiness = await page.evaluate(() => performance.getEntriesByType("mark")
|
||||
.filter((entry) => entry.name.startsWith("issue-detail:"))
|
||||
.map((entry) => ({ name: entry.name, startTime: entry.startTime, detail: (entry as PerformanceMark).detail })));
|
||||
await fs.writeFile(tracePath.replace(".trace.json", ".cpuprofile"), JSON.stringify(profile));
|
||||
await fs.writeFile(tracePath.replace(".trace.json", ".readiness.json"), JSON.stringify(readiness, null, 2));
|
||||
}
|
||||
const summary = summarizeNetwork(network, seedData, paint.contentPaintEpochMs);
|
||||
const { contentPaintEpochMs: _contentPaintEpochMs, ...reportedPaint } = paint;
|
||||
const scenarioPaint = scenario.startsWith("S1")
|
||||
@@ -312,6 +330,7 @@ function buildMarkdown(results: RunMetrics[]): string {
|
||||
"# Issue-detail performance baseline",
|
||||
"",
|
||||
`Runs per scenario/profile: ${RUNS}`,
|
||||
...(CAPTURE_PROFILE ? ["Diagnostic profile with profiler overhead; use benchmark mode for latency comparisons."] : []),
|
||||
"",
|
||||
"| Scenario | Profile | N | Header paint | Content paint | TTFB | FCP | LCP | Requests before content | API requests before content | Bytes before content | JS bytes before content | GET issue TTFB | GET issue server timing |",
|
||||
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
||||
@@ -347,7 +366,7 @@ test("issue-detail baseline", async ({ request, baseURL }) => {
|
||||
}
|
||||
|
||||
const report = buildMarkdown(results);
|
||||
await fs.writeFile(path.join(OUTPUT_DIR, "baseline.json"), JSON.stringify({ seed: seedData, results }, null, 2));
|
||||
await fs.writeFile(path.join(OUTPUT_DIR, "baseline.json"), JSON.stringify({ mode: CAPTURE_PROFILE ? "profile" : "benchmark", seed: seedData, results }, null, 2));
|
||||
await fs.writeFile(path.join(OUTPUT_DIR, "baseline.md"), report);
|
||||
console.log(`\n${report}\n`);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.use({ serviceWorkers: "block", viewport: { width: 1440, height: 900 } });
|
||||
test.setTimeout(60_000);
|
||||
|
||||
for (const hasPlan of [false, true]) {
|
||||
for (const entry of ["direct", "list"]) {
|
||||
test(`task reuses a full plan projection and fetches one for list-only snapshots (plan=${hasPlan}, entry=${entry})`, async ({ page, request }) => {
|
||||
const companyResponse = await request.post("/api/companies", { data: { name: `Plan startup ${Date.now()}` } });
|
||||
expect(companyResponse.ok()).toBe(true);
|
||||
const company = await companyResponse.json();
|
||||
const issueResponse = await request.post(`/api/companies/${company.id}/issues`, { data: { title: "Read a saved conversation", status: "backlog" } });
|
||||
expect(issueResponse.ok()).toBe(true);
|
||||
const issue = await issueResponse.json();
|
||||
const commentResponse = await request.post(`/api/issues/${issue.id}/comments`, { data: { body: "This saved reply must be readable without a redundant plan request." } });
|
||||
expect(commentResponse.ok()).toBe(true);
|
||||
if (hasPlan) {
|
||||
const planResponse = await request.put(`/api/issues/${issue.id}/documents/plan`, { data: { format: "markdown", title: "Saved plan", body: "# Follow the saved plan\n\nKeep the conversation stable." } });
|
||||
expect(planResponse.ok()).toBe(true);
|
||||
}
|
||||
let planRequests = 0;
|
||||
await page.route("**/api/issues/*/documents/plan", async (route) => {
|
||||
planRequests += 1;
|
||||
if (entry === "direct") await route.abort();
|
||||
else await route.continue();
|
||||
});
|
||||
if (entry === "list") {
|
||||
await page.goto(`/${company.issuePrefix}/issues`);
|
||||
await page.locator("[data-inbox-issue-link]", { hasText: "Read a saved conversation" }).first().click();
|
||||
} else {
|
||||
await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`);
|
||||
}
|
||||
const thread = page.getByTestId("task-chat-thread");
|
||||
await expect(thread.locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(thread.getByText("This saved reply must be readable without a redundant plan request.", { exact: true })).toBeVisible();
|
||||
if (hasPlan) await expect(thread.getByTestId("task-chat-plan-preview")).toBeVisible();
|
||||
expect(planRequests).toBe(entry === "direct" ? 0 : 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("profiling records every gate when switching back to a fully cached task", async ({ page, request }) => {
|
||||
const companyResponse = await request.post("/api/companies", { data: { name: `Readiness switches ${Date.now()}` } });
|
||||
expect(companyResponse.ok()).toBe(true);
|
||||
const company = await companyResponse.json();
|
||||
const tasks = [];
|
||||
for (const title of ["First cached task", "Second cached task"]) {
|
||||
const response = await request.post(`/api/companies/${company.id}/issues`, { data: { title, status: "backlog" } });
|
||||
expect(response.ok()).toBe(true);
|
||||
tasks.push(await response.json());
|
||||
}
|
||||
for (const [index, task] of tasks.entries()) {
|
||||
const destination = tasks[1 - index];
|
||||
const response = await request.post(`/api/issues/${task.id}/comments`, {
|
||||
data: { body: `[Switch task](/${company.issuePrefix}/issues/${destination.identifier})` },
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
}
|
||||
await page.addInitScript(() => { window.__PAPERCLIP_ISSUE_DETAIL_PROFILE__ = true; });
|
||||
await page.goto(`/${company.issuePrefix}/issues/${tasks[0].identifier}`);
|
||||
const ready = () => expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible();
|
||||
await ready();
|
||||
await page.getByRole("link", { name: new RegExp(`^Issue ${tasks[1].identifier}(?:[: ]|$)`) }).click({ timeout: 5_000 });
|
||||
await expect(page).toHaveURL(new RegExp(`${tasks[1].identifier}$`));
|
||||
await ready();
|
||||
await page.getByRole("link", { name: new RegExp(`^Issue ${tasks[0].identifier}(?:[: ]|$)`) }).click({ timeout: 5_000 });
|
||||
await expect(page).toHaveURL(new RegExp(`${tasks[0].identifier}$`));
|
||||
await ready();
|
||||
await expect.poll(() => page.evaluate(() => performance.getEntriesByType("mark")
|
||||
.filter((entry) => entry.name.startsWith("issue-detail:gate:") && (entry as PerformanceMark).detail?.ready)
|
||||
.map((entry) => entry.name.slice("issue-detail:gate:".length))))
|
||||
.toEqual(expect.arrayContaining(["issue", "comments", "interactions", "attachments", "work-products", "activity", "linked-runs", "runtime", "plan", "transcripts", "history"]));
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ISSUE_DETAIL_CONTENT_PAINT_MARK,
|
||||
ISSUE_DETAIL_CONTENT_MEASURE,
|
||||
scheduleIssueDetailPaintMeasure,
|
||||
recordIssueDetailReadiness,
|
||||
} from "@/lib/issue-detail-performance";
|
||||
import { hasWorkspaceRestoreFailure } from "@paperclipai/shared";
|
||||
import { workspaceRestoreMarkerDetail } from "@/lib/workspace-restore-marker";
|
||||
@@ -2910,6 +2911,9 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// history and plan before revealing; older runs outside that window do not
|
||||
// delay it, and the latch below keeps subsequent refreshes visible.
|
||||
const historyPending = initialHistoryPending || planLoading || transcriptHistoryPending;
|
||||
useEffect(() => {
|
||||
recordIssueDetailReadiness({ plan: !planLoading, transcripts: !transcriptHistoryPending, history: !historyPending });
|
||||
}, [issueId, planLoading, transcriptHistoryPending, historyPending]);
|
||||
const historyError =
|
||||
initialHistoryError ||
|
||||
planError ||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import type { Issue, IssueDocument } from "@paperclipai/shared";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { fetchIssueDetail, seedIssueDetailCache } from "@/lib/issueDetailCache";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useIssuePlanDocument } from "./useIssuePlanDocument";
|
||||
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: { get: vi.fn(), getDocument: vi.fn() } }));
|
||||
const issue = {
|
||||
id: "task-id", identifier: "TASK-1", companyId: "company-id", title: "Saved task",
|
||||
status: "todo", workMode: "standard", priority: "medium", projectId: null,
|
||||
parentId: null, description: null, assigneeAgentId: null, assigneeUserId: null,
|
||||
executionRunId: null, issueNumber: 1, requestDepth: 0, createdAt: new Date(), updatedAt: new Date(),
|
||||
} as Issue;
|
||||
const plan = { id: "plan-id", issueId: issue.id, key: "plan", body: "Saved plan", latestRevisionId: "revision-1" } as IssueDocument;
|
||||
const planKey = [...queryKeys.issues.documents(issue.id), "plan"];
|
||||
let client: QueryClient;
|
||||
let host: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
function View({ issueId = issue.id }: { issueId?: string }) {
|
||||
const result = useIssuePlanDocument(issueId);
|
||||
return <div data-loading={result.isLoading} data-error={result.isError}>{result.data?.body ?? "No plan"}</div>;
|
||||
}
|
||||
async function mount(issueId = issue.id) {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}><View issueId={issueId} /></QueryClientProvider>));
|
||||
}
|
||||
async function fetchDetail(value: IssueDocument | null | undefined) {
|
||||
vi.mocked(issuesApi.get).mockResolvedValue({ ...issue, planDocument: value });
|
||||
await fetchIssueDetail(client, issue.identifier!);
|
||||
}
|
||||
beforeEach(() => {
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
host = document.body.appendChild(document.createElement("div"));
|
||||
root = createRoot(host);
|
||||
vi.mocked(issuesApi.get).mockReset();
|
||||
vi.mocked(issuesApi.getDocument).mockReset().mockResolvedValue(plan);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
client.clear();
|
||||
host.remove();
|
||||
});
|
||||
it.each([null, plan])("uses the fetched task's plan without another loading round trip: %s", async (value) => {
|
||||
await fetchDetail(value);
|
||||
await mount();
|
||||
expect(host.firstElementChild?.getAttribute("data-loading")).toBe("false");
|
||||
expect(host.textContent).toBe(value?.body ?? "No plan");
|
||||
expect(issuesApi.getDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
it("revalidates document events after seeding an absent plan, including deletion", async () => {
|
||||
await fetchDetail(null);
|
||||
await mount();
|
||||
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) }); });
|
||||
expect(issuesApi.getDocument).toHaveBeenCalledExactlyOnceWith(issue.id, "plan");
|
||||
expect(client.getQueryData(planKey)).toEqual(plan);
|
||||
vi.mocked(issuesApi.getDocument).mockRejectedValue(new ApiError("Not found", 404, null));
|
||||
await act(async () => { await client.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) }); });
|
||||
expect(client.getQueryData(planKey)).toBeNull();
|
||||
expect(host.firstElementChild?.getAttribute("data-error")).toBe("false");
|
||||
});
|
||||
it.each(["stale", "invalidated"])("fetches a current plan when the seeded query is %s", async (state) => {
|
||||
await fetchDetail(null);
|
||||
if (state === "stale") client.setQueryData(planKey, null, { updatedAt: Date.now() - 60_000 });
|
||||
else await client.invalidateQueries({ queryKey: queryKeys.issues.documents(issue.id) });
|
||||
await mount();
|
||||
expect(issuesApi.getDocument).toHaveBeenCalledExactlyOnceWith(issue.id, "plan");
|
||||
expect(client.getQueryData(planKey)).toEqual(plan);
|
||||
});
|
||||
it("fetches normally for list-only snapshots and responses without a plan projection", async () => {
|
||||
client.setQueryData(queryKeys.issues.detail(issue.id), issue);
|
||||
await fetchDetail(undefined);
|
||||
vi.mocked(issuesApi.getDocument).mockReturnValue(new Promise(() => {}));
|
||||
await mount();
|
||||
expect(issuesApi.getDocument).toHaveBeenCalledExactlyOnceWith(issue.id, "plan");
|
||||
expect(host.firstElementChild?.getAttribute("data-loading")).toBe("true");
|
||||
});
|
||||
it("does not overwrite a separately fetched plan when task detail finishes later", async () => {
|
||||
const newerPlan = { ...plan, body: "Newer revision", latestRevisionId: "revision-2" };
|
||||
client.setQueryData(planKey, newerPlan);
|
||||
await fetchDetail(plan);
|
||||
await mount();
|
||||
expect(client.getQueryData(planKey)).toEqual(newerPlan);
|
||||
expect(host.textContent).toBe("Newer revision");
|
||||
});
|
||||
it("does not mistake a refreshed list snapshot for a fresh plan after query eviction", async () => {
|
||||
await fetchDetail(null);
|
||||
client.removeQueries({ queryKey: planKey, exact: true });
|
||||
seedIssueDetailCache(client, issue);
|
||||
await mount();
|
||||
expect(issuesApi.getDocument).toHaveBeenCalledExactlyOnceWith(issue.id, "plan");
|
||||
expect(client.getQueryData(planKey)).toEqual(plan);
|
||||
});
|
||||
it("does not seed another task's plan or fetch draft conversations", async () => {
|
||||
await fetchDetail(plan);
|
||||
await mount("different-task");
|
||||
expect(issuesApi.getDocument).toHaveBeenCalledExactlyOnceWith("different-task", "plan");
|
||||
vi.mocked(issuesApi.getDocument).mockClear();
|
||||
await mount("chat:draft");
|
||||
expect(issuesApi.getDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import { ApiError } from "@/api/client";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
||||
const PLAN_DOCUMENT_STALE_TIME_MS = 30_000;
|
||||
|
||||
/**
|
||||
* The issue's `plan` document, or null when none exists (a 404 is the normal
|
||||
* "no plan yet" answer, not an error). Shared by the redesigned thread (its
|
||||
@@ -15,6 +17,7 @@ export function useIssuePlanDocument(issueId: string | null | undefined) {
|
||||
return useQuery<IssueDocument | null>({
|
||||
queryKey: [...queryKeys.issues.documents(issueId ?? ""), "plan"],
|
||||
enabled: Boolean(issueId) && !issueId?.startsWith("chat:"),
|
||||
staleTime: PLAN_DOCUMENT_STALE_TIME_MS,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await issuesApi.getDocument(issueId!, "plan");
|
||||
|
||||
@@ -5,10 +5,14 @@ export const ISSUE_DETAIL_HEADER_MEASURE = "issue-detail:navigate→header-paint
|
||||
export const ISSUE_DETAIL_CONTENT_MEASURE = "issue-detail:navigate→content-paint";
|
||||
|
||||
let issueDetailNavigationGeneration = 0;
|
||||
const readinessGates = ["issue", "comments", "interactions", "attachments", "work-products", "activity", "linked-runs", "runtime", "plan", "transcripts", "history"] as const;
|
||||
type ReadinessGate = typeof readinessGates[number];
|
||||
const readinessState = new Map<ReadinessGate, boolean>();
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__?: number;
|
||||
__PAPERCLIP_ISSUE_DETAIL_PROFILE__?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +29,8 @@ export function beginIssueDetailNavigation(): void {
|
||||
performance.clearMarks(ISSUE_DETAIL_CONTENT_PAINT_MARK);
|
||||
performance.clearMeasures(ISSUE_DETAIL_HEADER_MEASURE);
|
||||
performance.clearMeasures(ISSUE_DETAIL_CONTENT_MEASURE);
|
||||
for (const gate of readinessState.keys()) performance.clearMarks(`issue-detail:gate:${gate}`);
|
||||
readinessState.clear();
|
||||
|
||||
const externallyCapturedStart = window.__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__;
|
||||
delete window.__PAPERCLIP_ISSUE_DETAIL_NAVIGATE_START__;
|
||||
@@ -33,6 +39,16 @@ export function beginIssueDetailNavigation(): void {
|
||||
});
|
||||
}
|
||||
|
||||
export function recordIssueDetailReadiness(gates: Partial<Record<ReadinessGate, boolean>>): void {
|
||||
if (!supportsPerformanceMarks() || !window.__PAPERCLIP_ISSUE_DETAIL_PROFILE__) return;
|
||||
for (const gate of readinessGates) {
|
||||
const ready = gates[gate];
|
||||
if (ready === undefined || readinessState.get(gate) === ready) continue;
|
||||
readinessState.set(gate, ready);
|
||||
performance.mark(`issue-detail:gate:${gate}`, { detail: { ready } });
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleIssueDetailPaintMeasure(
|
||||
markName: typeof ISSUE_DETAIL_HEADER_PAINT_MARK | typeof ISSUE_DETAIL_CONTENT_PAINT_MARK,
|
||||
measureName: typeof ISSUE_DETAIL_HEADER_MEASURE | typeof ISSUE_DETAIL_CONTENT_MEASURE,
|
||||
|
||||
@@ -118,6 +118,10 @@ export async function fetchIssueDetail(
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<Issue> {
|
||||
const issue = options ? await issuesApi.get(issueRef, options) : await issuesApi.get(issueRef);
|
||||
const planKey = [...queryKeys.issues.documents(issue.id), "plan"];
|
||||
if (issue.planDocument !== undefined && !queryClient.getQueryState(planKey)) {
|
||||
queryClient.setQueryData(planKey, issue.planDocument);
|
||||
}
|
||||
return seedIssueDetailCache(queryClient, issue, { issueRef });
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ import { prefetchIssueThread } from "../lib/issue-thread-queries";
|
||||
import { getIssueDetailQueryOptions } from "../lib/issueDetailCache";
|
||||
import {
|
||||
beginIssueDetailNavigation,
|
||||
recordIssueDetailReadiness,
|
||||
ISSUE_DETAIL_CONTENT_MEASURE,
|
||||
ISSUE_DETAIL_CONTENT_PAINT_MARK,
|
||||
ISSUE_DETAIL_HEADER_MEASURE,
|
||||
@@ -1588,6 +1589,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
});
|
||||
const resolvedActivity = activity ?? [];
|
||||
const resolvedLinkedRuns = linkedRuns ?? [];
|
||||
useEffect(() => {
|
||||
recordIssueDetailReadiness({ activity: !activityPending, "linked-runs": !linkedRunsPending, runtime: runtimeSelectionKnown });
|
||||
}, [issueId, issueQueryRef, activityPending, linkedRunsPending, runtimeSelectionKnown]);
|
||||
const retryFailedRun = useMutation({
|
||||
mutationFn: async (runId: string) => {
|
||||
const failedRun = resolvedLinkedRuns.find((run) => run.runId === runId);
|
||||
@@ -3362,6 +3366,9 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
isError: workProductsError,
|
||||
refetch: refetchWorkProducts,
|
||||
} = useIssueWorkProducts(issueId);
|
||||
useEffect(() => {
|
||||
recordIssueDetailReadiness({ issue: !!loadedIssue, comments: !commentsLoading, interactions: !interactionsLoading, attachments: !attachmentsLoading, "work-products": !workProductsLoading });
|
||||
}, [issueId, loadedIssue, commentsLoading, interactionsLoading, attachmentsLoading, workProductsLoading]);
|
||||
|
||||
const { data: liveRunCount = 0 } = useQuery<LiveRunForIssue[], Error, number>(
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user