diff --git a/packages/shared/package.json b/packages/shared/package.json index 77ee18413b1..b29212ed255 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,6 +12,7 @@ "directory": "packages/shared" }, "type": "module", + "sideEffects": false, "exports": { ".": "./src/index.ts", "./telemetry": "./src/telemetry/index.ts", diff --git a/tests/perf/issue-detail/README.md b/tests/perf/issue-detail/README.md index 687fdc3ab35..1bfe138ef9d 100644 --- a/tests/perf/issue-detail/README.md +++ b/tests/perf/issue-detail/README.md @@ -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. diff --git a/tests/perf/issue-detail/issue-detail.perf.spec.ts b/tests/perf/issue-detail/issue-detail.perf.spec.ts index f8ffb4b2d9f..2dc5cca727d 100644 --- a/tests/perf/issue-detail/issue-detail.perf.spec.ts +++ b/tests/perf/issue-detail/issue-detail.perf.spec.ts @@ -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 { - await page.addInitScript(({ coldStart }) => { +async function installVitalObserver(page: Page, cold: boolean, captureProfile: boolean): Promise { + 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 { 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>) { @@ -242,7 +244,8 @@ function summarizeNetwork(records: Map, seedData: Seed, c async function runScenario(browser: Browser, baseURL: string, seedData: Seed, profile: Profile, scenario: RunMetrics["scenario"], run: number): Promise { 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`); }); diff --git a/tests/perf/issue-detail/plan-startup.spec.ts b/tests/perf/issue-detail/plan-startup.spec.ts new file mode 100644 index 00000000000..faa514308b1 --- /dev/null +++ b/tests/perf/issue-detail/plan-startup.spec.ts @@ -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"])); +}); diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index d41d9f609ae..284743610bd 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -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 || diff --git a/ui/src/hooks/useIssuePlanDocument.test.tsx b/ui/src/hooks/useIssuePlanDocument.test.tsx new file mode 100644 index 00000000000..664daeffaa7 --- /dev/null +++ b/ui/src/hooks/useIssuePlanDocument.test.tsx @@ -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; +function View({ issueId = issue.id }: { issueId?: string }) { + const result = useIssuePlanDocument(issueId); + return
{result.data?.body ?? "No plan"}
; +} +async function mount(issueId = issue.id) { + await act(async () => root.render()); +} +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(); +}); diff --git a/ui/src/hooks/useIssuePlanDocument.ts b/ui/src/hooks/useIssuePlanDocument.ts index 9b0f0ed50dd..ae0f87f3b19 100644 --- a/ui/src/hooks/useIssuePlanDocument.ts +++ b/ui/src/hooks/useIssuePlanDocument.ts @@ -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({ 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"); diff --git a/ui/src/lib/issue-detail-performance.ts b/ui/src/lib/issue-detail-performance.ts index eb493f2948a..3b117744359 100644 --- a/ui/src/lib/issue-detail-performance.ts +++ b/ui/src/lib/issue-detail-performance.ts @@ -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(); 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>): 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, diff --git a/ui/src/lib/issueDetailCache.ts b/ui/src/lib/issueDetailCache.ts index 886dc3df805..86e1aa09e4c 100644 --- a/ui/src/lib/issueDetailCache.ts +++ b/ui/src/lib/issueDetailCache.ts @@ -118,6 +118,10 @@ export async function fetchIssueDetail( options?: { signal?: AbortSignal }, ): Promise { 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 }); } diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index d68ddcec242..254588259ce 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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( {