From 3e5a9f5afe4e23ba8bf21f712947cafc15b6953d Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:13:28 -0500 Subject: [PATCH] perf(ui): load recent tasks with bounded summaries (#16851) Replace passive recent-task detail reads with bounded, authorized summaries. Keep account isolation, stored history, task actions, and live recovery behavior. Overlap access checks with shell settings and avoid duplicate session and health reads on the first healthy socket opening. Retain domain reconciliation. Add focused coverage and local performance evidence. Isolate the existing cache capacity test from entry expiry on slow CI runners. Co-Authored-By: Paperclip --- doc/DEVELOPING.md | 8 + doc/SPEC-implementation.md | 12 + doc/SPEC.md | 7 + ...6-10-19-sidebar-apps-startup-evidence.json | 3224 +++++++++++++++++ ...-10-19-sidebar-apps-startup-performance.md | 161 + maps/agent-access-map/tasks.md | 8 + maps/feature-map/navigation-preferences.md | 8 + packages/shared/src/constants.ts | 1 + packages/shared/src/index.ts | 3 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/issue.ts | 7 + packages/shared/src/validators/index.ts | 1 + packages/shared/src/validators/issue.ts | 8 + scripts/benchmark-apps-startup.mjs | 182 + .../issue-list-assignee-filter-routes.test.ts | 23 +- .../__tests__/issue-summaries-routes.test.ts | 113 + server/src/routes/issues.ts | 29 + server/src/routes/openapi.ts | 13 + server/src/services/issue-summaries.ts | 38 + ui/src/App.tsx | 9 +- ui/src/api/issue-summaries.test.ts | 51 + ui/src/api/issues.ts | 13 + .../ShellConfigurationGate.test.tsx | 54 + ui/src/components/ShellConfigurationGate.tsx | 7 + ui/src/components/SidebarRecentTasks.test.tsx | 16 +- ui/src/components/SidebarRecentTasks.tsx | 14 +- .../LiveUpdatesProvider.recovery.test.tsx | 15 +- ui/src/context/LiveUpdatesProvider.tsx | 15 +- ui/src/hooks/useRecentTasks.test.tsx | 138 +- ui/src/hooks/useRecentTasks.ts | 124 +- ui/src/lib/queryKeys.ts | 3 + ui/src/lib/recent-tasks.ts | 4 +- 32 files changed, 4225 insertions(+), 85 deletions(-) create mode 100644 doc/plans/2026-10-19-sidebar-apps-startup-evidence.json create mode 100644 doc/plans/2026-10-19-sidebar-apps-startup-performance.md create mode 100644 scripts/benchmark-apps-startup.mjs create mode 100644 server/src/__tests__/issue-summaries-routes.test.ts create mode 100644 server/src/services/issue-summaries.ts create mode 100644 ui/src/api/issue-summaries.test.ts create mode 100644 ui/src/components/ShellConfigurationGate.test.tsx create mode 100644 ui/src/components/ShellConfigurationGate.tsx diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index dfec311ad64..ab3093c2ce1 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -771,6 +771,14 @@ Activity-only reorderings wait for one second without further activity changes; new and removed tasks appear immediately. Titles, status, and live indicators stay current during that delay. +The sidebar renders saved snapshots immediately and refreshes its exact five IDs +with one company/account-scoped summary query. Passive refreshes do not fetch +full task details or inspect workspaces. Successful omissions remove unavailable +tasks; failed batch reads retain saved history. Existing full-detail cache data +can update an entry only after the current account's summary confirms access. +The first healthy live subscription reconciles domain data without re-fetching +session and health; reconnects and visibility recovery retain full reconciliation. + ### Local Linux runner qualification Before serving requests, the server reads `getconf GNU_LIBC_VERSION` once with diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 41936bf8b37..ccb23223a5a 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -1206,6 +1206,7 @@ Invites tab does not hide this Cloud action. ## 10.4 Tasks (Issues) - `GET /companies/:companyId/issues` +- `GET /companies/:companyId/issues/summaries?ids=,` - `POST /companies/:companyId/issues` - `GET /issues/:issueId` - `PATCH /issues/:issueId` @@ -1240,6 +1241,17 @@ for a duplicate request id, and return `409` with the current projection for a stale revision or an unconfirmed unfinished-goal replacement. These controls do not create issue comments. +The task-summary endpoint accepts one to five comma-separated UUIDs, normalizes +case and whitespace, and removes duplicates after validating the five-entry +bound. It returns accessible, non-hidden tasks in request order with only ID, +company ID, title, identifier, status, update time, and external-conversation +state. Missing, foreign-company and unauthorized tasks are omitted. Company +access and normal task-read authorization apply; task-bridge keys are denied. +Only the `ids` query parameter is accepted. Malformed, repeated, nested, empty +and oversized ID parameters return `400`, including mixed scalar/nested syntax. +Responses are private and not stored by HTTP caches. Reads do not trigger +full-detail enrichment, workspace inspection or execution recovery. + ### 10.4.1 Atomic Checkout Contract `POST /issues/:issueId/checkout` request: diff --git a/doc/SPEC.md b/doc/SPEC.md index fe0a4d53f3a..fd08d326c33 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -799,3 +799,10 @@ A company may use a shared API model to acknowledge accepted human messages acro Pipelines is no longer a product feature. Its database tables remain for existing data preservation; Cases and Routines continue independently. + +### Recent task navigation summaries + +Recent-task navigation refreshes a bounded, authorized summary projection for +its saved task IDs. The projection avoids full task/workspace enrichment and +preserves company/task access rules. The HTTP contract is defined in +`SPEC-implementation.md` section 10.4. diff --git a/doc/plans/2026-10-19-sidebar-apps-startup-evidence.json b/doc/plans/2026-10-19-sidebar-apps-startup-evidence.json new file mode 100644 index 00000000000..f2c61880f6a --- /dev/null +++ b/doc/plans/2026-10-19-sidebar-apps-startup-evidence.json @@ -0,0 +1,3224 @@ +{ + "finalSmoke": { + "sourceRevision": "085f7a6f1e", + "validStatus": 200, + "cacheControl": "private, no-store", + "publicFields": [ + "companyId", + "externalConversationState", + "id", + "identifier", + "status", + "title", + "updatedAt" + ], + "invalidQueries": [ + { + "suffix": "&ids[]=bad", + "status": 400 + }, + { + "suffix": "&ids[x]=bad", + "status": 400 + }, + { + "suffix": "&ids=bad", + "status": 400 + } + ], + "detailRequests": 0, + "summaryRequests": 2, + "sessionRequests": 1, + "healthRequests": 1, + "events": [ + { + "event": "summary-request", + "at": 551.9182919999998 + }, + { + "event": "summary-response", + "at": 580.4755 + }, + { + "event": "summary-request", + "at": 590.1321249999999 + }, + { + "event": "socket-open", + "at": 590.196709 + }, + { + "event": "summary-response", + "at": 627.1864169999999 + } + ], + "search": true, + "fiveRecentTasksVisible": true + }, + "date": "2026-10-19", + "environment": { + "machine": "Apple M5 Max", + "platform": "darwin", + "architecture": "arm64", + "browser": "151.0.7922.34", + "playwright": "1.62.1", + "node": "24.19.0", + "viewport": "1440x1000", + "latencyMs": 50, + "downloadMbps": 9, + "uploadMbps": 1.5, + "cpuSlowdown": 4, + "serviceWorkers": "blocked", + "coldCache": "disabled; new context per sample", + "inApp": "new context; dashboard sidebar refresh completes, then two seconds settle before normal Connectors navigation", + "api": "one isolated loopback instance and persistent synthetic fixture shared by both UI variants; no change to full-detail handler", + "documentSelection": "both variants use the same route fulfillment for their production index.html; real assets/API use the same server and network profile" + }, + "notes": [ + "Five cold and five in-app samples per UI variant per trial. Primary comparison has the same route-splitting revision on both sides.", + "Usable means Connector list and enabled search are visible; the existing structural loading gate has gone. No networkidle wait.", + "Refresh request elapsed time runs from first recent-task request start to last recent-task response completion. SidebarReady is relative to navigation.", + "Encoded bytes are compressed HTTP response body bytes; decoded bytes are response-body bytes after decompression. HTTP headers are excluded.", + "Scripting and long-task measurements include two seconds after visible readiness. Preliminary timing variability is retained.", + "This is local evidence, not a staging deployment or proof of the historical staging latency.", + "Only the final verified-paired trial timestamps response completion before body/size evidence extraction. Earlier response timings include that capture overhead and are preliminary.", + "The unthrottled final smoke completed its first summary before socket opening and made the required catch-up batch: two summary requests, zero passive details, and one session/health read each. Under the measured profile each cold sample used one summary." + ], + "trials": [ + { + "file": "paired.json", + "primary": false, + "baselineUiRevision": "0ed760ce9a", + "candidateUiRevision": "3e8dbe8eb0", + "apiSourceAtCollection": "3e8dbe8eb06f26f111e309c9c15eee1ad1156676", + "notes": "Preliminary pair before route splitting. In-app ScriptDuration includes dashboard startup; do not compare that metric.", + "medians": [ + { + "scenario": "cold", + "variant": "baseline-paired", + "usableMs": 8073.958, + "sidebarReadyMs": 7029.787, + "refreshRequestElapsedMs": 296.08, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45433, + "recentEncodedBytes": null, + "apiRequests": 38, + "longTaskMs": 3153, + "scriptDurationSeconds": 3.395, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "scenario": "cold", + "variant": "candidate-paired", + "usableMs": 5834.239, + "sidebarReadyMs": 5215.036, + "refreshRequestElapsedMs": 173, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1151, + "recentEncodedBytes": null, + "apiRequests": 33, + "longTaskMs": 2737, + "scriptDurationSeconds": 2.964, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "scenario": "in-app", + "variant": "baseline-paired", + "usableMs": 867.154, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 682, + "scriptDurationSeconds": 2.956, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "scenario": "in-app", + "variant": "candidate-paired", + "usableMs": 866.823, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 656, + "scriptDurationSeconds": 2.817, + "sessionRequests": 0, + "healthRequests": 0 + } + ], + "samples": [ + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 1, + "usableMs": 7202.067, + "sidebarReadyMs": 6331.971, + "refreshRequestElapsedMs": 223.868, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45433, + "recentEncodedBytes": null, + "apiRequests": 38, + "longTaskMs": 2766, + "scriptDurationSeconds": 3.05, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 1, + "usableMs": 5641.245, + "sidebarReadyMs": 5064.444, + "refreshRequestElapsedMs": 117.063, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1151, + "recentEncodedBytes": null, + "apiRequests": 33, + "longTaskMs": 2592, + "scriptDurationSeconds": 2.758, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 2, + "usableMs": 8073.958, + "sidebarReadyMs": 7029.787, + "refreshRequestElapsedMs": 296.08, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45433, + "recentEncodedBytes": null, + "apiRequests": 38, + "longTaskMs": 3690, + "scriptDurationSeconds": 3.916, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 2, + "usableMs": 5689.078, + "sidebarReadyMs": 5133.161, + "refreshRequestElapsedMs": 160.329, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1151, + "recentEncodedBytes": null, + "apiRequests": 33, + "longTaskMs": 2672, + "scriptDurationSeconds": 2.861, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 3, + "usableMs": 10792.235, + "sidebarReadyMs": 8608.529, + "refreshRequestElapsedMs": 2608.629, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45433, + "recentEncodedBytes": null, + "apiRequests": 38, + "longTaskMs": 3285, + "scriptDurationSeconds": 3.663, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 3, + "usableMs": 7882.319, + "sidebarReadyMs": 6579.259, + "refreshRequestElapsedMs": 213.846, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1151, + "recentEncodedBytes": null, + "apiRequests": 34, + "longTaskMs": 3280, + "scriptDurationSeconds": 3.461, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 4, + "usableMs": 8555.088, + "sidebarReadyMs": 7702.738, + "refreshRequestElapsedMs": 1377.161, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45433, + "recentEncodedBytes": null, + "apiRequests": 38, + "longTaskMs": 3153, + "scriptDurationSeconds": 3.395, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 4, + "usableMs": 5834.239, + "sidebarReadyMs": 5215.036, + "refreshRequestElapsedMs": 173, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1151, + "recentEncodedBytes": null, + "apiRequests": 33, + "longTaskMs": 2737, + "scriptDurationSeconds": 2.964, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 5, + "usableMs": 6497.7, + "sidebarReadyMs": 5165.458, + "refreshRequestElapsedMs": 237.695, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45433, + "recentEncodedBytes": null, + "apiRequests": 38, + "longTaskMs": 3042, + "scriptDurationSeconds": 3.288, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 5, + "usableMs": 6514.287, + "sidebarReadyMs": 5629.264, + "refreshRequestElapsedMs": 215.912, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1151, + "recentEncodedBytes": null, + "apiRequests": 34, + "longTaskMs": 3152, + "scriptDurationSeconds": 3.313, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 1, + "usableMs": 897.882, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 697, + "scriptDurationSeconds": 3.141, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 1, + "usableMs": 1205.133, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 714, + "scriptDurationSeconds": 2.886, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 2, + "usableMs": 867.154, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 683, + "scriptDurationSeconds": 2.744, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 2, + "usableMs": 895.509, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 665, + "scriptDurationSeconds": 2.728, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 3, + "usableMs": 801.164, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 629, + "scriptDurationSeconds": 2.956, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 3, + "usableMs": 859.887, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 560, + "scriptDurationSeconds": 2.854, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 4, + "usableMs": 908.741, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 682, + "scriptDurationSeconds": 3.14, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 4, + "usableMs": 866.823, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 656, + "scriptDurationSeconds": 2.817, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 5, + "usableMs": 826.944, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 631, + "scriptDurationSeconds": 2.909, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 5, + "usableMs": 801.787, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 635, + "scriptDurationSeconds": 2.72, + "sessionRequests": 0, + "healthRequests": 0 + } + ] + }, + { + "file": "final-paired.json", + "primary": false, + "baselineUiRevision": "0ed760ce9a", + "candidateUiRevision": "9b7f0a9d63", + "apiSourceAtCollection": "53685ba64a6fe604073d13ee19da4a3111166407", + "notes": "Preliminary pair during broad local checks. In-app scripting is corrected, but one dashboard refresh overlapped navigation.", + "medians": [ + { + "scenario": "cold", + "variant": "baseline-paired", + "usableMs": 8883.334, + "sidebarReadyMs": 7688.779, + "refreshRequestElapsedMs": 1043.974, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45433, + "recentEncodedBytes": null, + "apiRequests": 38, + "longTaskMs": 3211, + "scriptDurationSeconds": 3.539, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "scenario": "cold", + "variant": "candidate-paired", + "usableMs": 9766.15, + "sidebarReadyMs": 8956.849, + "refreshRequestElapsedMs": 268.141, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1151, + "recentEncodedBytes": null, + "apiRequests": 34, + "longTaskMs": 3140, + "scriptDurationSeconds": 3.398, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "scenario": "in-app", + "variant": "baseline-paired", + "usableMs": 1345.404, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 941, + "scriptDurationSeconds": 1.002, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "scenario": "in-app", + "variant": "candidate-paired", + "usableMs": 1495.281, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 955, + "scriptDurationSeconds": 0.857, + "sessionRequests": 0, + "healthRequests": 0 + } + ], + "samples": [ + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 1, + "usableMs": 8212.617, + "sidebarReadyMs": 7256.619, + "refreshRequestElapsedMs": 585.623, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45433, + "recentEncodedBytes": null, + "apiRequests": 38, + "longTaskMs": 3028, + "scriptDurationSeconds": 3.398, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 1, + "usableMs": 9766.15, + "sidebarReadyMs": 8956.849, + "refreshRequestElapsedMs": 272.016, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1151, + "recentEncodedBytes": null, + "apiRequests": 34, + "longTaskMs": 3131, + "scriptDurationSeconds": 3.342, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 2, + "usableMs": 8883.334, + "sidebarReadyMs": 7688.779, + "refreshRequestElapsedMs": 936.361, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45433, + "recentEncodedBytes": null, + "apiRequests": 38, + "longTaskMs": 3393, + "scriptDurationSeconds": 3.658, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 2, + "usableMs": 7427.112, + "sidebarReadyMs": 6207.118, + "refreshRequestElapsedMs": 226.184, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1151, + "recentEncodedBytes": null, + "apiRequests": 34, + "longTaskMs": 3608, + "scriptDurationSeconds": 3.869, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 3, + "usableMs": 12205.635, + "sidebarReadyMs": 11093.693, + "refreshRequestElapsedMs": 2473.819, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45433, + "recentEncodedBytes": null, + "apiRequests": 38, + "longTaskMs": 3211, + "scriptDurationSeconds": 3.539, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 3, + "usableMs": 8356.922, + "sidebarReadyMs": 7601.248, + "refreshRequestElapsedMs": 129.672, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1151, + "recentEncodedBytes": null, + "apiRequests": 33, + "longTaskMs": 3140, + "scriptDurationSeconds": 3.352, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 4, + "usableMs": 8114.524, + "sidebarReadyMs": 6482.834, + "refreshRequestElapsedMs": 1043.974, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45433, + "recentEncodedBytes": null, + "apiRequests": 38, + "longTaskMs": 2801, + "scriptDurationSeconds": 3.21, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 4, + "usableMs": 10416.947, + "sidebarReadyMs": 8963.108, + "refreshRequestElapsedMs": 398.723, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1151, + "recentEncodedBytes": null, + "apiRequests": 34, + "longTaskMs": 4162, + "scriptDurationSeconds": 4.287, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 5, + "usableMs": 12753.371, + "sidebarReadyMs": 12110.56, + "refreshRequestElapsedMs": 1476.894, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45433, + "recentEncodedBytes": null, + "apiRequests": 38, + "longTaskMs": 3636, + "scriptDurationSeconds": 3.974, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 5, + "usableMs": 10044.584, + "sidebarReadyMs": 9053.982, + "refreshRequestElapsedMs": 268.141, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1151, + "recentEncodedBytes": null, + "apiRequests": 34, + "longTaskMs": 3109, + "scriptDurationSeconds": 3.398, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 1, + "usableMs": 1201.551, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 941, + "scriptDurationSeconds": 1.002, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 1, + "usableMs": 1586.811, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 763, + "scriptDurationSeconds": 0.853, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 2, + "usableMs": 2512.747, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 36342, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 1040, + "scriptDurationSeconds": 1.184, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 2, + "usableMs": 1570.341, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 1029, + "scriptDurationSeconds": 1.064, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 3, + "usableMs": 1345.404, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 856, + "scriptDurationSeconds": 0.932, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 3, + "usableMs": 932.586, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 738, + "scriptDurationSeconds": 0.792, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 4, + "usableMs": 918.31, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 742, + "scriptDurationSeconds": 0.796, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 4, + "usableMs": 1374.51, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 968, + "scriptDurationSeconds": 0.964, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 5, + "usableMs": 1958.018, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 1093, + "scriptDurationSeconds": 1.142, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 5, + "usableMs": 1495.281, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": null, + "apiRequests": 8, + "longTaskMs": 955, + "scriptDurationSeconds": 0.857, + "sessionRequests": 0, + "healthRequests": 0 + } + ] + }, + { + "file": "settled-paired.json", + "primary": false, + "baselineUiRevision": "0ed760ce9a", + "candidateUiRevision": "2e11e65497", + "apiSourceAtCollection": "2e11e65497f16e5952ca01c04cb0b32a88b25ebe", + "notes": "Before route splitting; dashboard refresh settles before navigation. Own broad checks stopped.", + "medians": [ + { + "scenario": "cold", + "variant": "baseline-paired", + "usableMs": 5690.316, + "sidebarReadyMs": 5056.492, + "refreshRequestElapsedMs": 202.57, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45441, + "recentEncodedBytes": 5714, + "apiRequests": 37, + "longTaskMs": 2761, + "scriptDurationSeconds": 2.923, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "scenario": "cold", + "variant": "candidate-paired", + "usableMs": 5611.985, + "sidebarReadyMs": 5050.298, + "refreshRequestElapsedMs": 181.98, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1159, + "recentEncodedBytes": 373, + "apiRequests": 33, + "longTaskMs": 2562, + "scriptDurationSeconds": 2.727, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "scenario": "in-app", + "variant": "baseline-paired", + "usableMs": 771.973, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 594, + "scriptDurationSeconds": 0.7, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "scenario": "in-app", + "variant": "candidate-paired", + "usableMs": 806.669, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 576, + "scriptDurationSeconds": 0.697, + "sessionRequests": 0, + "healthRequests": 0 + } + ], + "samples": [ + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 1, + "usableMs": 6372.463, + "sidebarReadyMs": 5311.903, + "refreshRequestElapsedMs": 202.57, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45441, + "recentEncodedBytes": 5714, + "apiRequests": 38, + "longTaskMs": 3028, + "scriptDurationSeconds": 3.21, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 1, + "usableMs": 5611.985, + "sidebarReadyMs": 4986.252, + "refreshRequestElapsedMs": 176.394, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1159, + "recentEncodedBytes": 373, + "apiRequests": 33, + "longTaskMs": 2562, + "scriptDurationSeconds": 2.763, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 2, + "usableMs": 5664.711, + "sidebarReadyMs": 5055.264, + "refreshRequestElapsedMs": 196.084, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45441, + "recentEncodedBytes": 5714, + "apiRequests": 37, + "longTaskMs": 2561, + "scriptDurationSeconds": 2.801, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 2, + "usableMs": 5669.675, + "sidebarReadyMs": 5167.714, + "refreshRequestElapsedMs": 278.181, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1159, + "recentEncodedBytes": 373, + "apiRequests": 33, + "longTaskMs": 2340, + "scriptDurationSeconds": 2.629, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 3, + "usableMs": 6206.861, + "sidebarReadyMs": 5120.534, + "refreshRequestElapsedMs": 234.49, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45441, + "recentEncodedBytes": 5714, + "apiRequests": 38, + "longTaskMs": 2855, + "scriptDurationSeconds": 3.114, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 3, + "usableMs": 5911.426, + "sidebarReadyMs": 5343.754, + "refreshRequestElapsedMs": 181.98, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1159, + "recentEncodedBytes": 373, + "apiRequests": 33, + "longTaskMs": 2665, + "scriptDurationSeconds": 2.768, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 4, + "usableMs": 5690.316, + "sidebarReadyMs": 5056.492, + "refreshRequestElapsedMs": 204.805, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45441, + "recentEncodedBytes": 5714, + "apiRequests": 37, + "longTaskMs": 2761, + "scriptDurationSeconds": 2.923, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 4, + "usableMs": 5557.281, + "sidebarReadyMs": 4991.211, + "refreshRequestElapsedMs": 130.103, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1159, + "recentEncodedBytes": 373, + "apiRequests": 33, + "longTaskMs": 2608, + "scriptDurationSeconds": 2.727, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 5, + "usableMs": 5646.777, + "sidebarReadyMs": 4998.796, + "refreshRequestElapsedMs": 190.295, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45441, + "recentEncodedBytes": 5714, + "apiRequests": 37, + "longTaskMs": 2571, + "scriptDurationSeconds": 2.824, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 5, + "usableMs": 5546.605, + "sidebarReadyMs": 5050.298, + "refreshRequestElapsedMs": 186.991, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1159, + "recentEncodedBytes": 373, + "apiRequests": 33, + "longTaskMs": 2433, + "scriptDurationSeconds": 2.679, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 1, + "usableMs": 854.462, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 603, + "scriptDurationSeconds": 0.739, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 1, + "usableMs": 734.52, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 553, + "scriptDurationSeconds": 0.659, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 2, + "usableMs": 754.342, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 533, + "scriptDurationSeconds": 0.659, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 2, + "usableMs": 806.669, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 576, + "scriptDurationSeconds": 0.721, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 3, + "usableMs": 771.973, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 614, + "scriptDurationSeconds": 0.7, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 3, + "usableMs": 909.93, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 682, + "scriptDurationSeconds": 0.763, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 4, + "usableMs": 818.856, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 594, + "scriptDurationSeconds": 0.703, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 4, + "usableMs": 827.828, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 638, + "scriptDurationSeconds": 0.688, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 5, + "usableMs": 767.423, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 548, + "scriptDurationSeconds": 0.686, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 5, + "usableMs": 794.232, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 561, + "scriptDurationSeconds": 0.697, + "sessionRequests": 0, + "healthRequests": 0 + } + ] + }, + { + "file": "aligned-paired.json", + "primary": false, + "baselineUiRevision": "9179d8dcc3", + "candidateUiRevision": "0833da6927", + "apiSourceAtCollection": "0833da6927f5563c9a48d179e2b1bf0fd99f711d", + "notes": "Earlier aligned comparison before strict mixed-query rejection. Both include route splitting; own verification finished before measurement.", + "medians": [ + { + "scenario": "cold", + "variant": "baseline-paired", + "usableMs": 3903.7, + "sidebarReadyMs": 3362.624, + "refreshRequestElapsedMs": 280.074, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45441, + "recentEncodedBytes": 5714, + "apiRequests": 35, + "longTaskMs": 2130, + "scriptDurationSeconds": 2.231, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "scenario": "cold", + "variant": "candidate-paired", + "usableMs": 3993.845, + "sidebarReadyMs": 3325.17, + "refreshRequestElapsedMs": 255.915, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1159, + "recentEncodedBytes": 373, + "apiRequests": 31, + "longTaskMs": 2226, + "scriptDurationSeconds": 2.318, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "scenario": "in-app", + "variant": "baseline-paired", + "usableMs": 1031.491, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 578, + "scriptDurationSeconds": 0.732, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "scenario": "in-app", + "variant": "candidate-paired", + "usableMs": 1016.634, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 589, + "scriptDurationSeconds": 0.769, + "sessionRequests": 0, + "healthRequests": 0 + } + ], + "samples": [ + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 1, + "usableMs": 4360.777, + "sidebarReadyMs": 3722.146, + "refreshRequestElapsedMs": 293.752, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45441, + "recentEncodedBytes": 5714, + "apiRequests": 35, + "longTaskMs": 2306, + "scriptDurationSeconds": 2.467, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 1, + "usableMs": 3993.845, + "sidebarReadyMs": 3309.853, + "refreshRequestElapsedMs": 204.027, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1159, + "recentEncodedBytes": 373, + "apiRequests": 31, + "longTaskMs": 1699, + "scriptDurationSeconds": 1.817, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 2, + "usableMs": 3903.7, + "sidebarReadyMs": 3362.624, + "refreshRequestElapsedMs": 280.074, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45441, + "recentEncodedBytes": 5714, + "apiRequests": 35, + "longTaskMs": 2130, + "scriptDurationSeconds": 2.231, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 2, + "usableMs": 3758.007, + "sidebarReadyMs": 3187.727, + "refreshRequestElapsedMs": 224.363, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1159, + "recentEncodedBytes": 373, + "apiRequests": 31, + "longTaskMs": 1933, + "scriptDurationSeconds": 2.118, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 3, + "usableMs": 3727.118, + "sidebarReadyMs": 3212.651, + "refreshRequestElapsedMs": 282.411, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45441, + "recentEncodedBytes": 5714, + "apiRequests": 35, + "longTaskMs": 1963, + "scriptDurationSeconds": 2.132, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 3, + "usableMs": 3991.534, + "sidebarReadyMs": 3325.17, + "refreshRequestElapsedMs": 259.658, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1159, + "recentEncodedBytes": 373, + "apiRequests": 31, + "longTaskMs": 2229, + "scriptDurationSeconds": 2.318, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 4, + "usableMs": 3825.72, + "sidebarReadyMs": 3308.899, + "refreshRequestElapsedMs": 251.272, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45441, + "recentEncodedBytes": 5714, + "apiRequests": 35, + "longTaskMs": 2087, + "scriptDurationSeconds": 2.175, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 4, + "usableMs": 4642.801, + "sidebarReadyMs": 3964.002, + "refreshRequestElapsedMs": 281.571, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1159, + "recentEncodedBytes": 373, + "apiRequests": 31, + "longTaskMs": 2496, + "scriptDurationSeconds": 2.664, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 5, + "usableMs": 4527.04, + "sidebarReadyMs": 3904.082, + "refreshRequestElapsedMs": 271.605, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45441, + "recentEncodedBytes": 5714, + "apiRequests": 35, + "longTaskMs": 2579, + "scriptDurationSeconds": 2.67, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 5, + "usableMs": 4127.62, + "sidebarReadyMs": 3567.652, + "refreshRequestElapsedMs": 255.915, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1159, + "recentEncodedBytes": 373, + "apiRequests": 31, + "longTaskMs": 2226, + "scriptDurationSeconds": 2.341, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 1, + "usableMs": 1110.789, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 563, + "scriptDurationSeconds": 0.712, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 1, + "usableMs": 1016.634, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 644, + "scriptDurationSeconds": 0.818, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 2, + "usableMs": 1031.491, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 719, + "scriptDurationSeconds": 0.827, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 2, + "usableMs": 1059.682, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 657, + "scriptDurationSeconds": 0.838, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 3, + "usableMs": 1121.941, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 578, + "scriptDurationSeconds": 0.732, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 3, + "usableMs": 980.045, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 589, + "scriptDurationSeconds": 0.769, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 4, + "usableMs": 918.499, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 574, + "scriptDurationSeconds": 0.744, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 4, + "usableMs": 939.822, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 569, + "scriptDurationSeconds": 0.742, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 5, + "usableMs": 909.478, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 588, + "scriptDurationSeconds": 0.726, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 5, + "usableMs": 1162.615, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 525, + "scriptDurationSeconds": 0.717, + "sessionRequests": 0, + "healthRequests": 0 + } + ] + }, + { + "file": "qualified-paired.json", + "primary": false, + "baselineUiRevision": "9179d8dcc3", + "candidateUiRevision": "085f7a6f1e", + "apiSourceAtCollection": "085f7a6f1e2cac82be323ab099820767597936eb", + "notes": "Earlier final-code comparison; response timestamps include body/size evidence extraction. Own builds and tests finished before measurement.", + "medians": [ + { + "scenario": "cold", + "variant": "baseline-paired", + "usableMs": 4680.475, + "sidebarReadyMs": 3803.295, + "refreshRequestElapsedMs": 356.623, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45442, + "recentEncodedBytes": 5715, + "apiRequests": 36, + "longTaskMs": 2345, + "scriptDurationSeconds": 2.509, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "scenario": "cold", + "variant": "candidate-paired", + "usableMs": 4744.501, + "sidebarReadyMs": 3705.925, + "refreshRequestElapsedMs": 296.856, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1160, + "recentEncodedBytes": 372, + "apiRequests": 32, + "longTaskMs": 2375, + "scriptDurationSeconds": 2.505, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "scenario": "in-app", + "variant": "baseline-paired", + "usableMs": 1022.015, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 715, + "scriptDurationSeconds": 0.785, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "scenario": "in-app", + "variant": "candidate-paired", + "usableMs": 1107.055, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 688, + "scriptDurationSeconds": 0.798, + "sessionRequests": 0, + "healthRequests": 0 + } + ], + "samples": [ + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 1, + "usableMs": 4680.475, + "sidebarReadyMs": 3803.295, + "refreshRequestElapsedMs": 356.623, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45442, + "recentEncodedBytes": 5715, + "apiRequests": 36, + "longTaskMs": 2417, + "scriptDurationSeconds": 2.584, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 1, + "usableMs": 4327.614, + "sidebarReadyMs": 3644.672, + "refreshRequestElapsedMs": 274.789, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1160, + "recentEncodedBytes": 372, + "apiRequests": 31, + "longTaskMs": 2239, + "scriptDurationSeconds": 2.414, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 2, + "usableMs": 4361.808, + "sidebarReadyMs": 3682.878, + "refreshRequestElapsedMs": 298.75, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45442, + "recentEncodedBytes": 5715, + "apiRequests": 36, + "longTaskMs": 2345, + "scriptDurationSeconds": 2.504, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 2, + "usableMs": 3651.984, + "sidebarReadyMs": 3126.717, + "refreshRequestElapsedMs": 286.414, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1160, + "recentEncodedBytes": 372, + "apiRequests": 31, + "longTaskMs": 2025, + "scriptDurationSeconds": 2.215, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 3, + "usableMs": 4235.918, + "sidebarReadyMs": 3460.25, + "refreshRequestElapsedMs": 377.859, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45442, + "recentEncodedBytes": 5715, + "apiRequests": 36, + "longTaskMs": 2173, + "scriptDurationSeconds": 2.383, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 3, + "usableMs": 5303.79, + "sidebarReadyMs": 4118.112, + "refreshRequestElapsedMs": 296.856, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1160, + "recentEncodedBytes": 372, + "apiRequests": 32, + "longTaskMs": 2375, + "scriptDurationSeconds": 2.505, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 4, + "usableMs": 5184.695, + "sidebarReadyMs": 4264.175, + "refreshRequestElapsedMs": 556.835, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45442, + "recentEncodedBytes": 5715, + "apiRequests": 36, + "longTaskMs": 2375, + "scriptDurationSeconds": 2.707, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 4, + "usableMs": 5140.032, + "sidebarReadyMs": 4239.946, + "refreshRequestElapsedMs": 476.32, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1160, + "recentEncodedBytes": 372, + "apiRequests": 32, + "longTaskMs": 2496, + "scriptDurationSeconds": 2.729, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 5, + "usableMs": 4705.507, + "sidebarReadyMs": 3996.379, + "refreshRequestElapsedMs": 349.06, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45442, + "recentEncodedBytes": 5715, + "apiRequests": 36, + "longTaskMs": 2219, + "scriptDurationSeconds": 2.509, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 5, + "usableMs": 4744.501, + "sidebarReadyMs": 3705.925, + "refreshRequestElapsedMs": 303.5, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1160, + "recentEncodedBytes": 372, + "apiRequests": 32, + "longTaskMs": 2413, + "scriptDurationSeconds": 2.634, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 1, + "usableMs": 1098.122, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 737, + "scriptDurationSeconds": 0.899, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 1, + "usableMs": 1107.055, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 734, + "scriptDurationSeconds": 0.881, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 2, + "usableMs": 1213.539, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 608, + "scriptDurationSeconds": 0.785, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 2, + "usableMs": 983.904, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 637, + "scriptDurationSeconds": 0.798, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 3, + "usableMs": 1022.015, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 758, + "scriptDurationSeconds": 0.76, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 3, + "usableMs": 925.74, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 688, + "scriptDurationSeconds": 0.699, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 4, + "usableMs": 976.57, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 715, + "scriptDurationSeconds": 0.79, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 4, + "usableMs": 1119.518, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 543, + "scriptDurationSeconds": 0.7, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 5, + "usableMs": 995.147, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 690, + "scriptDurationSeconds": 0.769, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 5, + "usableMs": 1466.728, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 982, + "scriptDurationSeconds": 1.119, + "sessionRequests": 0, + "healthRequests": 0 + } + ] + }, + { + "file": "verified-paired.json", + "primary": true, + "baselineUiRevision": "9179d8dcc3", + "candidateUiRevision": "085f7a6f1e", + "apiSourceAtCollection": "341bd900024637344e7dca2f8194d74093dc8281", + "notes": "Primary comparison: final request guard and benchmark self-copy guard; response completion is timestamped before body/size extraction. Both include route splitting; own builds and tests finished before measurement.", + "medians": [ + { + "scenario": "cold", + "variant": "baseline-paired", + "usableMs": 3747.821, + "sidebarReadyMs": 3156.889, + "refreshRequestElapsedMs": 245.863, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45442, + "recentEncodedBytes": 5715, + "apiRequests": 35, + "longTaskMs": 1942, + "scriptDurationSeconds": 2.12, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "scenario": "cold", + "variant": "candidate-paired", + "usableMs": 3668.362, + "sidebarReadyMs": 3169.998, + "refreshRequestElapsedMs": 243.895, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1160, + "recentEncodedBytes": 372, + "apiRequests": 31, + "longTaskMs": 1923, + "scriptDurationSeconds": 2.025, + "sessionRequests": 1, + "healthRequests": 1 + }, + { + "scenario": "in-app", + "variant": "baseline-paired", + "usableMs": 1019.377, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 546, + "scriptDurationSeconds": 0.677, + "sessionRequests": 0, + "healthRequests": 0 + }, + { + "scenario": "in-app", + "variant": "candidate-paired", + "usableMs": 782.996, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 479, + "scriptDurationSeconds": 0.62, + "sessionRequests": 0, + "healthRequests": 0 + } + ], + "samples": [ + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 1, + "usableMs": 3962.963, + "sidebarReadyMs": 3444.512, + "refreshRequestElapsedMs": 279.334, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45442, + "recentEncodedBytes": 5715, + "apiRequests": 35, + "longTaskMs": 2148, + "scriptDurationSeconds": 2.253, + "sessionRequests": 1, + "healthRequests": 1, + "endpointCounts": { + "/api/auth/get-session": 1, + "/api/instance/settings/experimental": 1, + "/api/health": 1, + "/api/companies": 1, + "/api/plugins/ui-contributions": 1, + "/api/cli-auth/me": 1, + "/api/companies/:id/projects": 1, + "/api/companies/:id/resource-memberships/me": 1, + "/api/companies/:id/agents": 1, + "/api/companies/:id/live-runs": 1, + "/api/issues/:id": 5, + "/api/companies/:id/inbox-dismissals": 1, + "/api/companies/:id/approvals": 1, + "/api/companies/:id/join-requests": 1, + "/api/companies/:id/dashboard": 1, + "/api/companies/:id/issues": 1, + "/api/companies/:id/heartbeat-runs": 1, + "/api/companies/:id/tools/action-requests": 1, + "/api/announcements/current": 1, + "/api/companies/:id/primary-agent/me": 1, + "/api/companies/:id/skills": 1, + "/api/companies/:id/routines": 1, + "/api/sidebar-preferences/me": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/companies/:id/user-directory": 1, + "/api/agent-avatars/cap-v1/turquoise-cherry/rest.png": 1 + } + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 1, + "usableMs": 3524.138, + "sidebarReadyMs": 2995.852, + "refreshRequestElapsedMs": 248.218, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1160, + "recentEncodedBytes": 372, + "apiRequests": 31, + "longTaskMs": 1765, + "scriptDurationSeconds": 1.865, + "sessionRequests": 1, + "healthRequests": 1, + "endpointCounts": { + "/api/auth/get-session": 1, + "/api/health": 1, + "/api/instance/settings/experimental": 1, + "/api/companies": 1, + "/api/plugins/ui-contributions": 1, + "/api/cli-auth/me": 1, + "/api/companies/:id/projects": 1, + "/api/companies/:id/resource-memberships/me": 1, + "/api/companies/:id/agents": 1, + "/api/companies/:id/live-runs": 1, + "/api/companies/:id/issues/summaries": 1, + "/api/companies/:id/inbox-dismissals": 1, + "/api/companies/:id/approvals": 1, + "/api/companies/:id/join-requests": 1, + "/api/companies/:id/dashboard": 1, + "/api/companies/:id/issues": 1, + "/api/companies/:id/heartbeat-runs": 1, + "/api/companies/:id/tools/action-requests": 1, + "/api/announcements/current": 1, + "/api/companies/:id/primary-agent/me": 1, + "/api/companies/:id/skills": 1, + "/api/companies/:id/routines": 1, + "/api/sidebar-preferences/me": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/companies/:id/user-directory": 1, + "/api/agent-avatars/cap-v1/turquoise-cherry/rest.png": 1 + } + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 2, + "usableMs": 3668.005, + "sidebarReadyMs": 3156.889, + "refreshRequestElapsedMs": 245.863, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45442, + "recentEncodedBytes": 5715, + "apiRequests": 35, + "longTaskMs": 1942, + "scriptDurationSeconds": 2.04, + "sessionRequests": 1, + "healthRequests": 1, + "endpointCounts": { + "/api/auth/get-session": 1, + "/api/instance/settings/experimental": 1, + "/api/health": 1, + "/api/companies": 1, + "/api/plugins/ui-contributions": 1, + "/api/cli-auth/me": 1, + "/api/companies/:id/projects": 1, + "/api/companies/:id/resource-memberships/me": 1, + "/api/companies/:id/agents": 1, + "/api/companies/:id/live-runs": 1, + "/api/issues/:id": 5, + "/api/companies/:id/inbox-dismissals": 1, + "/api/companies/:id/approvals": 1, + "/api/companies/:id/join-requests": 1, + "/api/companies/:id/dashboard": 1, + "/api/companies/:id/issues": 1, + "/api/companies/:id/heartbeat-runs": 1, + "/api/companies/:id/tools/action-requests": 1, + "/api/announcements/current": 1, + "/api/companies/:id/primary-agent/me": 1, + "/api/companies/:id/skills": 1, + "/api/companies/:id/routines": 1, + "/api/sidebar-preferences/me": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/companies/:id/user-directory": 1, + "/api/agent-avatars/cap-v1/turquoise-cherry/rest.png": 1 + } + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 2, + "usableMs": 3647.001, + "sidebarReadyMs": 3131.925, + "refreshRequestElapsedMs": 243.895, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1160, + "recentEncodedBytes": 372, + "apiRequests": 31, + "longTaskMs": 1898, + "scriptDurationSeconds": 1.997, + "sessionRequests": 1, + "healthRequests": 1, + "endpointCounts": { + "/api/auth/get-session": 1, + "/api/health": 1, + "/api/instance/settings/experimental": 1, + "/api/companies": 1, + "/api/plugins/ui-contributions": 1, + "/api/cli-auth/me": 1, + "/api/companies/:id/projects": 1, + "/api/companies/:id/resource-memberships/me": 1, + "/api/companies/:id/agents": 1, + "/api/companies/:id/live-runs": 1, + "/api/companies/:id/issues/summaries": 1, + "/api/companies/:id/inbox-dismissals": 1, + "/api/companies/:id/approvals": 1, + "/api/companies/:id/join-requests": 1, + "/api/companies/:id/dashboard": 1, + "/api/companies/:id/issues": 1, + "/api/companies/:id/heartbeat-runs": 1, + "/api/companies/:id/tools/action-requests": 1, + "/api/announcements/current": 1, + "/api/companies/:id/primary-agent/me": 1, + "/api/companies/:id/skills": 1, + "/api/companies/:id/routines": 1, + "/api/sidebar-preferences/me": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/companies/:id/user-directory": 1, + "/api/agent-avatars/cap-v1/turquoise-cherry/rest.png": 1 + } + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 3, + "usableMs": 3348.346, + "sidebarReadyMs": 2933.069, + "refreshRequestElapsedMs": 217.577, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45442, + "recentEncodedBytes": 5715, + "apiRequests": 35, + "longTaskMs": 1534, + "scriptDurationSeconds": 1.671, + "sessionRequests": 1, + "healthRequests": 1, + "endpointCounts": { + "/api/auth/get-session": 1, + "/api/instance/settings/experimental": 1, + "/api/health": 1, + "/api/companies": 1, + "/api/plugins/ui-contributions": 1, + "/api/cli-auth/me": 1, + "/api/companies/:id/projects": 1, + "/api/companies/:id/resource-memberships/me": 1, + "/api/companies/:id/agents": 1, + "/api/companies/:id/live-runs": 1, + "/api/issues/:id": 5, + "/api/companies/:id/inbox-dismissals": 1, + "/api/companies/:id/approvals": 1, + "/api/companies/:id/join-requests": 1, + "/api/companies/:id/dashboard": 1, + "/api/companies/:id/issues": 1, + "/api/companies/:id/heartbeat-runs": 1, + "/api/companies/:id/tools/action-requests": 1, + "/api/announcements/current": 1, + "/api/companies/:id/primary-agent/me": 1, + "/api/companies/:id/skills": 1, + "/api/companies/:id/routines": 1, + "/api/sidebar-preferences/me": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/companies/:id/user-directory": 1, + "/api/agent-avatars/cap-v1/turquoise-cherry/rest.png": 1 + } + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 3, + "usableMs": 3736.381, + "sidebarReadyMs": 3223.897, + "refreshRequestElapsedMs": 247.874, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1160, + "recentEncodedBytes": 372, + "apiRequests": 31, + "longTaskMs": 1996, + "scriptDurationSeconds": 2.1, + "sessionRequests": 1, + "healthRequests": 1, + "endpointCounts": { + "/api/auth/get-session": 1, + "/api/health": 1, + "/api/instance/settings/experimental": 1, + "/api/companies": 1, + "/api/plugins/ui-contributions": 1, + "/api/cli-auth/me": 1, + "/api/companies/:id/projects": 1, + "/api/companies/:id/resource-memberships/me": 1, + "/api/companies/:id/agents": 1, + "/api/companies/:id/live-runs": 1, + "/api/companies/:id/issues/summaries": 1, + "/api/companies/:id/inbox-dismissals": 1, + "/api/companies/:id/approvals": 1, + "/api/companies/:id/join-requests": 1, + "/api/companies/:id/dashboard": 1, + "/api/companies/:id/issues": 1, + "/api/companies/:id/heartbeat-runs": 1, + "/api/companies/:id/tools/action-requests": 1, + "/api/announcements/current": 1, + "/api/companies/:id/primary-agent/me": 1, + "/api/companies/:id/skills": 1, + "/api/companies/:id/routines": 1, + "/api/sidebar-preferences/me": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/companies/:id/user-directory": 1, + "/api/agent-avatars/cap-v1/turquoise-cherry/rest.png": 1 + } + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 4, + "usableMs": 3747.821, + "sidebarReadyMs": 3148.815, + "refreshRequestElapsedMs": 191.527, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45442, + "recentEncodedBytes": 5715, + "apiRequests": 35, + "longTaskMs": 1941, + "scriptDurationSeconds": 2.12, + "sessionRequests": 1, + "healthRequests": 1, + "endpointCounts": { + "/api/auth/get-session": 1, + "/api/instance/settings/experimental": 1, + "/api/health": 1, + "/api/companies": 1, + "/api/plugins/ui-contributions": 1, + "/api/cli-auth/me": 1, + "/api/companies/:id/projects": 1, + "/api/companies/:id/resource-memberships/me": 1, + "/api/companies/:id/agents": 1, + "/api/companies/:id/live-runs": 1, + "/api/issues/:id": 5, + "/api/companies/:id/inbox-dismissals": 1, + "/api/companies/:id/approvals": 1, + "/api/companies/:id/join-requests": 1, + "/api/companies/:id/dashboard": 1, + "/api/companies/:id/issues": 1, + "/api/companies/:id/heartbeat-runs": 1, + "/api/companies/:id/tools/action-requests": 1, + "/api/announcements/current": 1, + "/api/companies/:id/primary-agent/me": 1, + "/api/companies/:id/skills": 1, + "/api/companies/:id/routines": 1, + "/api/sidebar-preferences/me": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/companies/:id/user-directory": 1, + "/api/agent-avatars/cap-v1/turquoise-cherry/rest.png": 1 + } + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 4, + "usableMs": 3742.022, + "sidebarReadyMs": 3229.374, + "refreshRequestElapsedMs": 240.993, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1160, + "recentEncodedBytes": 372, + "apiRequests": 31, + "longTaskMs": 2020, + "scriptDurationSeconds": 2.12, + "sessionRequests": 1, + "healthRequests": 1, + "endpointCounts": { + "/api/auth/get-session": 1, + "/api/health": 1, + "/api/instance/settings/experimental": 1, + "/api/companies": 1, + "/api/plugins/ui-contributions": 1, + "/api/cli-auth/me": 1, + "/api/companies/:id/projects": 1, + "/api/companies/:id/resource-memberships/me": 1, + "/api/companies/:id/agents": 1, + "/api/companies/:id/live-runs": 1, + "/api/companies/:id/issues/summaries": 1, + "/api/companies/:id/inbox-dismissals": 1, + "/api/companies/:id/approvals": 1, + "/api/companies/:id/join-requests": 1, + "/api/companies/:id/dashboard": 1, + "/api/companies/:id/issues": 1, + "/api/companies/:id/heartbeat-runs": 1, + "/api/companies/:id/tools/action-requests": 1, + "/api/announcements/current": 1, + "/api/companies/:id/primary-agent/me": 1, + "/api/companies/:id/skills": 1, + "/api/companies/:id/routines": 1, + "/api/sidebar-preferences/me": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/companies/:id/user-directory": 1, + "/api/agent-avatars/cap-v1/turquoise-cherry/rest.png": 1 + } + }, + { + "variant": "baseline-paired", + "scenario": "cold", + "run": 5, + "usableMs": 3831.558, + "sidebarReadyMs": 3304.649, + "refreshRequestElapsedMs": 254.249, + "recentRequests": 5, + "detailRequests": 5, + "summaryRequests": 0, + "recentDecodedBytes": 45442, + "recentEncodedBytes": 5715, + "apiRequests": 35, + "longTaskMs": 2082, + "scriptDurationSeconds": 2.17, + "sessionRequests": 1, + "healthRequests": 1, + "endpointCounts": { + "/api/auth/get-session": 1, + "/api/instance/settings/experimental": 1, + "/api/health": 1, + "/api/companies": 1, + "/api/plugins/ui-contributions": 1, + "/api/cli-auth/me": 1, + "/api/companies/:id/projects": 1, + "/api/companies/:id/resource-memberships/me": 1, + "/api/companies/:id/agents": 1, + "/api/companies/:id/live-runs": 1, + "/api/issues/:id": 5, + "/api/companies/:id/inbox-dismissals": 1, + "/api/companies/:id/approvals": 1, + "/api/companies/:id/join-requests": 1, + "/api/companies/:id/dashboard": 1, + "/api/companies/:id/issues": 1, + "/api/companies/:id/heartbeat-runs": 1, + "/api/companies/:id/tools/action-requests": 1, + "/api/announcements/current": 1, + "/api/companies/:id/primary-agent/me": 1, + "/api/companies/:id/skills": 1, + "/api/companies/:id/routines": 1, + "/api/sidebar-preferences/me": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/companies/:id/user-directory": 1, + "/api/agent-avatars/cap-v1/turquoise-cherry/rest.png": 1 + } + }, + { + "variant": "candidate-paired", + "scenario": "cold", + "run": 5, + "usableMs": 3668.362, + "sidebarReadyMs": 3169.998, + "refreshRequestElapsedMs": 230.188, + "recentRequests": 1, + "detailRequests": 0, + "summaryRequests": 1, + "recentDecodedBytes": 1160, + "recentEncodedBytes": 372, + "apiRequests": 31, + "longTaskMs": 1923, + "scriptDurationSeconds": 2.025, + "sessionRequests": 1, + "healthRequests": 1, + "endpointCounts": { + "/api/auth/get-session": 1, + "/api/health": 1, + "/api/instance/settings/experimental": 1, + "/api/companies": 1, + "/api/plugins/ui-contributions": 1, + "/api/cli-auth/me": 1, + "/api/companies/:id/projects": 1, + "/api/companies/:id/resource-memberships/me": 1, + "/api/companies/:id/agents": 1, + "/api/companies/:id/live-runs": 1, + "/api/companies/:id/issues/summaries": 1, + "/api/companies/:id/inbox-dismissals": 1, + "/api/companies/:id/approvals": 1, + "/api/companies/:id/join-requests": 1, + "/api/companies/:id/dashboard": 1, + "/api/companies/:id/issues": 1, + "/api/companies/:id/heartbeat-runs": 1, + "/api/companies/:id/tools/action-requests": 1, + "/api/announcements/current": 1, + "/api/companies/:id/primary-agent/me": 1, + "/api/companies/:id/skills": 1, + "/api/companies/:id/routines": 1, + "/api/sidebar-preferences/me": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/companies/:id/user-directory": 1, + "/api/agent-avatars/cap-v1/turquoise-cherry/rest.png": 1 + } + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 1, + "usableMs": 892.526, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 563, + "scriptDurationSeconds": 0.677, + "sessionRequests": 0, + "healthRequests": 0, + "endpointCounts": { + "/api/companies/:id/tools/action-requests": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/announcements/2026-09-14-connectors/image": 1 + } + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 1, + "usableMs": 782.996, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 479, + "scriptDurationSeconds": 0.62, + "sessionRequests": 0, + "healthRequests": 0, + "endpointCounts": { + "/api/companies/:id/tools/action-requests": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/announcements/2026-09-14-connectors/image": 1 + } + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 2, + "usableMs": 1019.377, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 721, + "scriptDurationSeconds": 0.815, + "sessionRequests": 0, + "healthRequests": 0, + "endpointCounts": { + "/api/companies/:id/tools/action-requests": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/announcements/2026-09-14-connectors/image": 1 + } + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 2, + "usableMs": 1038.235, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 507, + "scriptDurationSeconds": 0.643, + "sessionRequests": 0, + "healthRequests": 0, + "endpointCounts": { + "/api/companies/:id/tools/action-requests": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/announcements/2026-09-14-connectors/image": 1 + } + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 3, + "usableMs": 993.656, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 473, + "scriptDurationSeconds": 0.613, + "sessionRequests": 0, + "healthRequests": 0, + "endpointCounts": { + "/api/companies/:id/tools/action-requests": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/announcements/2026-09-14-connectors/image": 1 + } + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 3, + "usableMs": 769.521, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 476, + "scriptDurationSeconds": 0.603, + "sessionRequests": 0, + "healthRequests": 0, + "endpointCounts": { + "/api/companies/:id/tools/action-requests": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/announcements/2026-09-14-connectors/image": 1 + } + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 4, + "usableMs": 1050.354, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 505, + "scriptDurationSeconds": 0.655, + "sessionRequests": 0, + "healthRequests": 0, + "endpointCounts": { + "/api/companies/:id/tools/action-requests": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/announcements/2026-09-14-connectors/image": 1 + } + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 4, + "usableMs": 899.629, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 562, + "scriptDurationSeconds": 0.708, + "sessionRequests": 0, + "healthRequests": 0, + "endpointCounts": { + "/api/companies/:id/tools/action-requests": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/announcements/2026-09-14-connectors/image": 1 + } + }, + { + "variant": "baseline-paired", + "scenario": "in-app", + "run": 5, + "usableMs": 1134.494, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 546, + "scriptDurationSeconds": 0.733, + "sessionRequests": 0, + "healthRequests": 0, + "endpointCounts": { + "/api/companies/:id/tools/action-requests": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/announcements/2026-09-14-connectors/image": 1 + } + }, + { + "variant": "candidate-paired", + "scenario": "in-app", + "run": 5, + "usableMs": 765.374, + "sidebarReadyMs": null, + "refreshRequestElapsedMs": null, + "recentRequests": 0, + "detailRequests": 0, + "summaryRequests": 0, + "recentDecodedBytes": 0, + "recentEncodedBytes": 0, + "apiRequests": 8, + "longTaskMs": 463, + "scriptDurationSeconds": 0.594, + "sessionRequests": 0, + "healthRequests": 0, + "endpointCounts": { + "/api/companies/:id/tools/action-requests": 1, + "/api/mcp/connections": 2, + "/api/companies/:id/tools/gallery": 1, + "/api/companies/:id/tools/applications": 1, + "/api/companies/:id/tools/connections": 1, + "/api/companies/:id/chat-endpoints": 1, + "/api/announcements/2026-09-14-connectors/image": 1 + } + } + ] + } + ], + "journey": { + "sourceRevision": "0833da6927", + "steps": [ + { + "name": "Open Apps with five saved tasks", + "passed": true + }, + { + "name": "Search and open an existing connector", + "passed": true + }, + { + "name": "Open a recent task and return to Apps", + "passed": true + }, + { + "name": "Rename a task from the sidebar", + "passed": true + }, + { + "name": "Archive from inbox without losing recent history", + "passed": true + }, + { + "name": "Pause and restart the unassigned disposable task", + "passed": true + }, + { + "name": "Reload and confirm persisted title and membership", + "passed": true + }, + { + "name": "geometry", + "before": { + "row": { + "x": 20, + "y": 745, + "width": 200, + "height": 31.5 + }, + "list": { + "x": 504, + "y": 188, + "width": 912, + "height": 4562 + } + }, + "after": { + "row": { + "x": 20, + "y": 745, + "width": 200, + "height": 31.5 + }, + "list": { + "x": 504, + "y": 188, + "width": 912, + "height": 4562 + } + } + }, + { + "name": "Delayed summaries preserve sidebar rows and connector geometry", + "passed": true + } + ] + }, + "verification": { + "focusedUi": 85, + "summaryApi": 14, + "openapi": 23, + "typecheck": "passed", + "build": "passed", + "tokenGates": "passed", + "maps": "passed", + "localFullSuite": "Stopped after five failures in the 1347-case existing chat integration file. Four hit 15-second timeouts. All ten matched cases passed on an unchanged isolated rerun. Do not interpret this as a passing local pnpm test:run.", + "originalFailures": [ + "binds Slack tools to the admitted linked request, recovers its provenance and revokes retained access", + "surfaces an attachment-only Telegram ingestion failure without waking the agent", + "blocks a committed native review response and selected files at transport after gate_changed", + "rechecks revoked board_author authority before acceptance and before PUT", + "preserves all 100000 characters for github existing-comment publication" + ] + } +} diff --git a/doc/plans/2026-10-19-sidebar-apps-startup-performance.md b/doc/plans/2026-10-19-sidebar-apps-startup-performance.md new file mode 100644 index 00000000000..49af7107eeb --- /dev/null +++ b/doc/plans/2026-10-19-sidebar-apps-startup-performance.md @@ -0,0 +1,161 @@ +# Sidebar and Apps startup performance + +Outcome: one tested, reviewable PR and an isolated local preview. Preserve +sidebar accuracy, account isolation, loading layout and recovery. No merge or +staging deployment. PR: https://github.com/paperclipai/paperclip/pull/16851 + +## Implementation + +- The five-ID summary route applies existing company/task authorization, omits + unavailable IDs and uses a dedicated projection with no detail/workspace + enrichment. The bound applies before deduplication. Responses are private/no-store. +- Recent history uses one account-scoped query. Successful omissions apply only + to the requested IDs. Revisiting a cached omission requires a fresh response; + fetch completion remains observable when React Query shares the data object. + Full-detail cache updates require positive access confirmation and never + activate detail queries. Failures preserve snapshots and query errors. +- Access checks overlap shell settings; the protected subtree waits for both. + Only the first healthy socket opening exempts exact session/health keys. + Domain catch-up and recovery remain active. Queued/status lifecycle events and + pause/restart refresh summaries; progress/run-log events do not. Inbox archive + preserves recent history. + +Initial checkout: clean at `0ed760ce9a`. Branch: +`codex/sidebar-apps-startup-performance`. Route-splitting PR +[#16849](https://github.com/paperclipai/paperclip/pull/16849) landed during this +work. The branch was rebased onto `9179d8dcc3`; the primary comparison includes +that same route splitting on both sides. Tested application source: +`085f7a6f1e2cac82be323ab099820767597936eb`. This includes strict rejection of mixed +scalar/array or nested query syntax under Express's simple parser. Later edits +only update the evidence; earlier trials remain in the sample record. + +## Evidence + +The [sample record](2026-10-19-sidebar-apps-startup-evidence.json) contains every +primary sample, endpoint counts, prior trials, source revisions, environment and +browser-journey assertions. Earlier noisy trials remain visible. + +Primary comparison: production UI at `9179d8dcc3` versus `085f7a6f1e`, sharing +the same final API and persistent synthetic fixture. The full-detail handler is +unchanged. Five cold and five in-app samples per revision alternate in the same +Chromium build. Profile: 1440x1000, 50ms latency, 9Mbps down, 1.5Mbps up, 4x CPU; +service workers blocked. Cold contexts disable browser cache. In-app timing +starts after dashboard recent-task refresh and two seconds of settling. +Usability requires the existing connector list and search controls; no +`networkidle` wait is used. Own builds/tests finished before this comparison. + +| Median | Baseline | Candidate | +| --- | ---: | ---: | +| Cold Apps usable | 3748ms | 3668ms | +| In-app Apps usable | 1019ms | 783ms | +| Sidebar refresh complete after navigation | 3157ms | 3170ms | +| Recent-task request span | 246ms | 244ms | +| Recent-task requests, cold | 5 full details | 1 summary; 0 details | +| Recent-task decoded body bytes | 45,442 | 1,160 | +| Recent-task encoded body bytes | 5,715 | 372 | +| All API requests, cold | 35 | 31 | +| Cold long-task time | 1942ms | 1923ms | +| Cold scripting time | 2.120s | 2.025s | +| In-app long-task time | 546ms | 479ms | +| In-app scripting time | 0.677s | 0.620s | + +Encoded bytes fell 93.5%; decoded bytes fell 97.4%. The request span is essentially +unchanged (246ms to 244ms), so no meaningful local API latency gain is claimed. +Cold readiness fell 79ms (2.1%); in-app readiness fell 236ms (23.2%) in this run. +Earlier aligned trials had both positive and negative readiness changes; all +samples remain visible. The reliable result is smaller task refreshes, not an +established overall Apps speedup. Final response timestamps are captured before +body/size evidence extraction. Earlier trials included that collection overhead +in response timings and are preliminary. +Warm navigation reuses fresh sidebar +data without a refresh. Each candidate cold load made one session and one +health request. Focused socket tests also prove the initial exemption/recovery. + +The final unthrottled smoke completed its first summary before the socket opened, +then made the required domain catch-up batch. It recorded two summary requests, +zero passive details, and one session/health request each. The timing trace is +in `finalSmoke` in the sample record. Each refresh uses one batch; removing this +second refresh would break the required fetch-to-subscription reconciliation. + +The real browser journey passed: five recent tasks; connector search and +permissions; task navigation and return; rename; inbox archive; pause/restart of +an unassigned disposable task; reload persistence; delayed summary responses. +Sidebar-row and connector-list rectangles were identical before/after releasing +held responses. No agent run was started. + +## Verification and limits + +- Passed after rebase: 85 focused UI/access/layout tests, 14 embedded PostgreSQL + summary-route tests, 23 OpenAPI tests, full typecheck/build, token gates, maps + and diff checks. +- Earlier `pnpm test:run` reported five failures in the existing 1347-case chat + integration file; four reached 15-second timeouts. All ten matched cases passed + on an unchanged isolated rerun. The already-failed broad run was then stopped. + A complete green local `pnpm test:run` is **not** claimed. Latest-head CI status + belongs in the PR handoff; focused passes do not replace it. +- Review regressions cover cached revisits, repeated omissions, in-app scripting + scope and preparing both paired-build asset sets. Review/check status is on the PR. +- The asset preparer canonicalizes paths and skips copying onto itself. A real + filesystem probe reproduced `ERR_FS_CP_EINVAL` without the guard, then executed + the actual preparation block with `ui/dist` as candidate/server. It preserved + candidate files and prepared the complete baseline asset union. +- Setup failures are retained in local logs: an initial build had concurrent + writers; a compiled workspace server needs the TS loader for source exports; + an archived UI needs tracked announcement assets. Successful builds use one + writer per output directory. A browser observation URL typo was corrected + before the complete passing journey. +- Both builds show the existing local implicit-board Assistant Connection status + error. Connector search/navigation and the synthetic connector remain usable. +- This is local verification, not a staging deployment or proof of historical + staging latency. Disk cleanup, workspace caching, route-splitting ownership, + dependencies, migrations and telemetry remain outside this PR. Merging was + excluded from the original handoff; the user later authorized a merge after + the PR checks pass. Staging deployment remains excluded. + +## PR check repair + +At `453d488398`, every CI lane passed except serialized server shard 7/9. +The [failed job](https://github.com/paperclipai/paperclip/actions/runs/39285071851/job/118169386829) +reported one failure among 176 tests: the compact issue-list cache capacity test +expected 256 entries and found 238. Its 261 HTTP requests took 5,631 ms, crossing +the cache's 5,000 ms expiry. The production cache correctly prunes expired rows. +The capacity assertion therefore depended on runner speed. + +The test now holds `Date.now()` fixed while it fills and checks the cache, then +restores the clock in `finally`. Real HTTP, database operations and timers still +run. This isolates capacity eviction from expiry without changing production +behavior. The earlier CI failure remains recorded here; a successful rerun does +not erase it. + +The focused local rerun passed all 29 tests across the issue-list assignee/filter +and summary-route suites with real embedded PostgreSQL. `git diff --check` passed. +The next remote CI run qualifies this test-only repair; the application build +and browser measurements above remain unchanged. + +## Preview and reproduction + +Running preview: http://127.0.0.1:3214/APP/apps + +The final production UI and compiled server use the PR head, with the TS loader +for workspace dependency exports. The performance comparison used `085f7a6f1e`. +The final preview also includes the strict query guard. Configuration/evidence: +`/tmp/paperclip-apps-perf-20261019/`; disposable database port 55489. Fixture: +one synthetic company, five tasks, a disabled draft connector and a paused agent +with heartbeat disabled. Only this task's own preview/test processes were stopped. +Native PostgreSQL package symlinks were restored with its install hook. + +Archive `ui/` and `announcements/` at the baseline revision, link the same installed +dependencies, and build from that UI directory with `PAPERCLIP_BUILD_COMMIT` set +to its full SHA. Save the two production builds in separate directories, then run: + +```sh +PAPERCLIP_APPS_BENCH_BASELINE_DIST=/path/to/baseline-dist \ +PAPERCLIP_APPS_BENCH_CANDIDATE_DIST=/path/to/candidate-dist \ +PAPERCLIP_APPS_BENCH_SERVER_DIST=/path/to/server-checkout/ui/dist \ +node scripts/benchmark-apps-startup.mjs comparison http://127.0.0.1:3214/ /tmp/apps-comparison +``` + +The harness rejects non-loopback targets, creates/reuses synthetic fixtures, +prepares both hashed asset sets in the static server directory, swaps only the +production HTML document and measures real asset/API traffic. It writes JSON +and screenshots. Do not run a build that clears server assets during it. diff --git a/maps/agent-access-map/tasks.md b/maps/agent-access-map/tasks.md index 28ade0fa130..6e574e49ea6 100644 --- a/maps/agent-access-map/tasks.md +++ b/maps/agent-access-map/tasks.md @@ -40,6 +40,14 @@ narrower/different APIs. ## `organize` +`GET /api/companies/{companyId}/issues/summaries?ids=...` reads up to five exact +UUIDs and returns only navigation summaries. Normal company and task-read +permissions apply to ordinary agent REST calls; task-bridge keys are denied. +Hidden, missing, foreign and private inaccessible IDs are omitted without an +existence disclosure. No dedicated CLI/native tool is added. Embedded database +route tests cover same-company agent access and company/task-bridge denials; +this does not establish a live native `call_api` journey. + The issue-list API accepts optional `includeAgentCreatedTasks`; false restricts independent results to human creation before search/pagination. Omission retains existing agent/API behavior. `includeParticipatedTasks=true` requires a board user diff --git a/maps/feature-map/navigation-preferences.md b/maps/feature-map/navigation-preferences.md index c12be921974..244995c2686 100644 --- a/maps/feature-map/navigation-preferences.md +++ b/maps/feature-map/navigation-preferences.md @@ -70,3 +70,11 @@ Scope: browser Storybook: A NotFound rendering story can help polish, but missing-resource/legacy-deep-link capability principally needs real App/router/browser scenarios preserving company and query intent. Production: Cold-open invalid company/resource and retired URL; verify explicit fallback or correct canonical redirect without silently targeting a different resource/company. Sources: Source: [NotFound.tsx](../../ui/src/pages/NotFound.tsx), [App.tsx](../../ui/src/App.tsx), [company-routes.ts](../../ui/src/lib/company-routes.ts). Tests: [App.activity-routing.test.tsx](../../ui/src/App.activity-routing.test.tsx), [App.cases-routing.test.tsx](../../ui/src/App.cases-routing.test.tsx), [App.test.tsx](../../ui/src/App.test.tsx), [company-routes.test.ts](../../ui/src/lib/company-routes.test.ts) + +### Recent task refresh performance + +Saved recent tasks render immediately. One summary batch refreshes at most five +IDs per company/account without passive full-detail or workspace inspection. +Successful omissions remove unavailable entries; failed reads preserve history. +Verify rename, inbox archive, pause/restart, reload, cross-tab updates and account +switches. Inbox archive does not remove the task from recent history. diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 75da3359c53..9ddd40d90b7 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,4 +1,5 @@ export const COMPANY_STATUSES = ["active", "paused", "archived"] as const; +export const ISSUE_SUMMARIES_MAX_IDS = 5; export type CompanyStatus = (typeof COMPANY_STATUSES)[number]; export const DEPLOYMENT_MODES = ["local_trusted", "authenticated"] as const; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 29c9ade00bb..1b82d5534a5 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -355,6 +355,7 @@ export { AGENT_ICON_NAMES, PROJECT_ICON_NAMES, ISSUE_STATUSES, + ISSUE_SUMMARIES_MAX_IDS, INBOX_MINE_ISSUE_STATUSES, INBOX_MINE_ISSUE_STATUS_FILTER, ISSUE_PRIORITIES, @@ -1055,6 +1056,7 @@ export type { ExternalObjectSummary, ExternalObjectSummaryItem, CompactIssue, + IssueSummary, Issue, IssueVisibility, IssuePrivacyConstraints, @@ -1929,6 +1931,7 @@ export { type CompanySearchExtractQuery, type CompanySearchQuery, createIssueSchema, + issueSummariesQuerySchema, setIssueTitleSchema, type SetIssueTitle, createIssueInputSchema, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 9e3cb1ed93a..d654b94f5df 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -661,6 +661,7 @@ export type { } from "./external-object.js"; export type { CompactIssue, + IssueSummary, Issue, IssueChangeReceiptEntry, IssueChanges, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 4d187a833bf..5a551ccbb60 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -831,6 +831,13 @@ export interface IssueChangeReceiptEntry { export type IssueChanges = Record; +export type IssueSummary = Pick< + Issue, + "id" | "companyId" | "title" | "identifier" | "status" | "updatedAt" +> & { + externalConversationState: "active" | "waiting" | null; +}; + export interface Issue { /** True only while the title is the provisional slice of the initial prompt. */ titleNeedsGeneration?: boolean; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index c2f5c414786..057e733abc5 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -420,6 +420,7 @@ export { export { createIssueSchema, + issueSummariesQuerySchema, setIssueTitleSchema, type SetIssueTitle, createIssueInputSchema, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index cf398d1f8bb..a0707d3a3bd 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -1,5 +1,7 @@ import { taskWorkspaceSelectionSchema } from "./task-workspace.js"; import { z } from "zod"; +import { ISSUE_SUMMARIES_MAX_IDS } from "../constants.js"; + import { ISSUE_EXECUTION_DECISION_OUTCOMES, ISSUE_EXECUTION_MONITOR_CLEAR_REASONS, @@ -44,6 +46,12 @@ import { import { objectWithoutDefaults } from "./partial.js"; import { questionSetToAskUserQuestionsPayload } from "../question-set.js"; +export const issueSummariesQuerySchema = z.object({ + ids: z.string().transform((value) => value.split(",").map((id) => id.trim().toLowerCase())) + .pipe(z.array(z.string().guid()).min(1).max(ISSUE_SUMMARIES_MAX_IDS)) + .transform((ids) => [...new Set(ids)]), +}).strict(); + export const issueBlockedInboxStateSchema = z.enum([ "needs_attention", "awaiting_decision", diff --git a/scripts/benchmark-apps-startup.mjs b/scripts/benchmark-apps-startup.mjs new file mode 100644 index 00000000000..28a41a08ca9 --- /dev/null +++ b/scripts/benchmark-apps-startup.mjs @@ -0,0 +1,182 @@ +// Local-only browser comparison. Creates synthetic fixtures; never target staging. +import fs from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { chromium } from "@playwright/test"; + +const [phase, origin, directory] = process.argv.slice(2); +if (!phase || !origin || !directory) throw new Error("Usage: node scripts/benchmark-apps-startup.mjs "); +const target = new URL(origin); +if (!["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || target.pathname !== "/" || target.username || target.password) { + throw new Error("Only a disposable loopback instance is supported"); +} +await fs.mkdir(directory, { recursive: true }); +async function api(path, body) { + const response = await fetch(new URL(`/api${path}`, target), body ? { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + } : undefined); + if (!response.ok) throw new Error(`${path}: ${response.status} ${await response.text()}`); + return response.json(); +} +const fixturePath = `${directory}/fixture.json`; +let fixture; +try { fixture = JSON.parse(await fs.readFile(fixturePath, "utf8")); } +catch (error) { + if (error.code !== "ENOENT") throw error; + const company = await api("/companies", { name: "Apps Startup Review" }); + const tasks = []; + for (let i = 1; i <= 5; i++) tasks.push(await api(`/companies/${company.id}/issues`, { + title: `Review task ${i}`, description: "Synthetic task context for the sidebar performance comparison.\n".repeat(100), + status: i === 1 ? "todo" : "done", priority: "medium", + })); + const application = await api(`/companies/${company.id}/tools/applications`, { name: "Review Connector", type: "mcp_http" }); + const connection = await api(`/companies/${company.id}/tools/connections`, { + applicationId: application.id, name: "Review account", transport: "mcp_remote", status: "draft", enabled: false, + transportConfig: { url: "https://example.com/mcp" }, + }); + const agent = await api(`/companies/${company.id}/agents`, { + name: "Review agent", role: "engineer", adapterType: "codex_local", + runtimeConfig: { heartbeat: { enabled: false } }, + }); + await api(`/agents/${agent.id}/pause`, {}); + const session = await api("/auth/get-session"); + fixture = { company, tasks, application, connection, agent, userId: session?.user?.id ?? null }; + await fs.writeFile(fixturePath, JSON.stringify(fixture, null, 2)); +} +const variants = process.env.PAPERCLIP_APPS_BENCH_BASELINE_DIST ? [ + { label: "baseline-paired", dist: process.env.PAPERCLIP_APPS_BENCH_BASELINE_DIST }, + { label: "candidate-paired", dist: process.env.PAPERCLIP_APPS_BENCH_CANDIDATE_DIST }, +] : [{ label: phase, dist: null }]; +if (variants.length > 1) { + if (!variants[1].dist) throw new Error("Paired mode requires PAPERCLIP_APPS_BENCH_CANDIDATE_DIST"); + const serverAssets = process.env.PAPERCLIP_APPS_BENCH_SERVER_DIST + ? `${process.env.PAPERCLIP_APPS_BENCH_SERVER_DIST}/assets` + : new URL("../ui/dist/assets/", import.meta.url); + await fs.mkdir(serverAssets, { recursive: true }); + const serverAssetsPath = await fs.realpath(serverAssets); + // Preserve both content-addressed asset sets in the loopback server's + // static directory. Selecting an HTML document alone is insufficient. + for (const variant of variants) { + const sourceAssetsPath = await fs.realpath(`${variant.dist}/assets`); + if (sourceAssetsPath !== serverAssetsPath) { + await fs.cp(sourceAssetsPath, serverAssetsPath, { recursive: true, force: false }); + } + } +} +const browser = await chromium.launch(); +const samples = []; +try { + for (const scenario of ["cold", "in-app"]) { + for (let run = 0; run < 5; run++) { + for (const variant of variants) { + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, serviceWorkers: "block" }); + await context.addInitScript(({ fixture }) => { + const entries = fixture.tasks.map((task, index) => ({ + id: task.id, companyId: task.companyId, title: task.title, identifier: task.identifier, + status: task.status, recordedAt: index + 1, snapshotUpdatedAt: new Date(task.updatedAt).getTime(), + })); + localStorage.setItem("paperclip.selectedCompanyId", fixture.company.id); + localStorage.setItem(`paperclip.recentTasks.v2:${fixture.company.id}:${fixture.userId ?? "__local_board__"}`, JSON.stringify(entries)); + window.__appsLongTasks = []; + new PerformanceObserver(list => window.__appsLongTasks.push(...list.getEntries().map(e => ({ start: e.startTime, duration: e.duration })))) + .observe({ type: "longtask", buffered: true }); + }, { fixture }); + const page = await context.newPage(); + if (variant.dist) { + // Select only the production document. Assets and APIs still travel + // through the real local server with the same network profile. + const html = await fs.readFile(`${variant.dist}/index.html`, "utf8"); + await page.route(url => url.origin === target.origin && + ["apps", "dashboard"].some(name => url.pathname === `/${fixture.company.issuePrefix}/${name}`), + route => route.fulfill({ status: 200, contentType: "text/html", body: html })); + } + const cdp = await context.newCDPSession(page); + await cdp.send("Network.enable"); + await cdp.send("Performance.enable"); + await cdp.send("Network.setCacheDisabled", { cacheDisabled: scenario === "cold" }); + await cdp.send("Network.emulateNetworkConditions", { offline: false, latency: 50, downloadThroughput: 9 * 1024 * 1024 / 8, uploadThroughput: 1.5 * 1024 * 1024 / 8 }); + await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 }); + if (scenario === "in-app") { + const initialDetails = new Set(); + let finishInitialRefresh; + const initialRefresh = new Promise(resolve => { finishInitialRefresh = resolve; }); + const observeInitialRefresh = async response => { + const path = new URL(response.url()).pathname; + if (path.endsWith("/issues/summaries")) { + await response.finished(); + finishInitialRefresh(); + } else if (fixture.tasks.some(task => path === `/api/issues/${task.id}`)) { + await response.finished(); + initialDetails.add(path); + if (initialDetails.size === fixture.tasks.length) finishInitialRefresh(); + } + }; + page.on("response", observeInitialRefresh); + await page.goto(new URL(`/${fixture.company.issuePrefix}/dashboard`, target).href); + await page.getByRole("link", { name: "Connectors", exact: true }).first().waitFor({ timeout: 90_000 }); + await Promise.race([initialRefresh, new Promise((_, reject) => { + const timeout = setTimeout(() => reject(new Error("Initial recent-task refresh did not finish")), 90_000); + timeout.unref(); + })]); + await page.waitForTimeout(2_000); + page.off("response", observeInitialRefresh); + } + const initialMetrics = await cdp.send("Performance.getMetrics"); + const scriptDurationAtStart = scenario === "in-app" + ? initialMetrics.metrics.find(metric => metric.name === "ScriptDuration")?.value ?? 0 + : 0; + const records = []; + const requests = []; + page.on("request", request => { + const path = new URL(request.url()).pathname; + if (path.startsWith("/api/")) requests.push({ path, startedMs: performance.now() - started }); + }); + let detailCompleted = 0; + let detailReadyMax = 0; + let sidebarReady = null; + let observedNavigationStart; + const started = performance.now(); + page.on("response", async response => { + const url = new URL(response.url()); + if (!url.pathname.startsWith("/api/")) return; + try { + await response.finished(); + const completedMs = performance.now() - started; + const body = await response.body(); + const sizes = await response.request().sizes(); + records.push({ path: url.pathname, status: response.status(), decodedBytes: body.byteLength, encodedBytes: sizes.responseBodySize, completedMs }); + if (url.pathname.endsWith("/issues/summaries")) sidebarReady = completedMs; + if (fixture.tasks.some(task => url.pathname === `/api/issues/${task.id}`)) { + detailCompleted++; + detailReadyMax = Math.max(detailReadyMax, completedMs); + if (detailCompleted >= fixture.tasks.length) sidebarReady = detailReadyMax; + } + } catch { /* navigation cancellation has no completed response */ } + }); + observedNavigationStart = await page.evaluate(() => performance.now()); + if (scenario === "cold") await page.goto(new URL(`/${fixture.company.issuePrefix}/apps`, target).href); + else await page.getByRole("link", { name: "Connectors", exact: true }).first().click(); + await page.getByRole("list", { name: "Connector list", exact: true }).waitFor({ timeout: 90_000 }); + await page.getByRole("searchbox", { name: "Search connectors" }).waitFor(); + const usableMs = performance.now() - started; + await page.waitForTimeout(2_000); + const metrics = await cdp.send("Performance.getMetrics"); + const longTasks = await page.evaluate(() => window.__appsLongTasks); + const recentRecords = records.filter(record => record.path.endsWith("/issues/summaries") || fixture.tasks.some(task => record.path === `/api/issues/${task.id}`)); + samples.push({ variant: variant.label, scenario, run: run + 1, usableMs, sidebarReadyMs: sidebarReady, recentBytes: recentRecords.reduce((n, r) => n + r.decodedBytes, 0), + recentEncodedBytes: recentRecords.reduce((n, r) => n + r.encodedBytes, 0), + apiRequests: requests.length, + recentRequests: requests.filter(record => record.path.endsWith("/issues/summaries") || fixture.tasks.some(task => record.path === `/api/issues/${task.id}`)).length, + records, requests, + longTaskMs: longTasks.filter(task => scenario === "cold" || task.start >= observedNavigationStart).reduce((n, task) => n + task.duration, 0), + scriptDurationSeconds: (metrics.metrics.find(metric => metric.name === "ScriptDuration")?.value ?? 0) - scriptDurationAtStart, + }); + if (run === 0) await page.screenshot({ path: `${directory}/${variant.label}-${scenario}.png`, fullPage: true }); + await context.close(); + console.log(`${variant.label} ${scenario} ${run + 1}: connectors=${Math.round(usableMs)}ms recent=${samples.at(-1).recentRequests} requests/${samples.at(-1).recentBytes} bytes`); + } + } + } +} finally { await browser.close(); } +await fs.writeFile(`${directory}/${phase}.json`, JSON.stringify({ phase, origin, revision: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(), + browser: browser.version(), viewport: "1440x1000", profile: "50ms latency; 9Mbps down; 1.5Mbps up; 4x CPU", samples, +}, null, 2)); diff --git a/server/src/__tests__/issue-list-assignee-filter-routes.test.ts b/server/src/__tests__/issue-list-assignee-filter-routes.test.ts index 1b09113950a..269f5d20010 100644 --- a/server/src/__tests__/issue-list-assignee-filter-routes.test.ts +++ b/server/src/__tests__/issue-list-assignee-filter-routes.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import express from "express"; import request from "supertest"; import { eq } from "drizzle-orm"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { activityLog, agents, companies, companyMemberships, createDb, heartbeatRuns, issues, principalPermissionGrants } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -634,14 +634,21 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => { }); const app = createApp(companyId); - for (let index = 0; index < ISSUE_LIST_SERVER_CACHE_MAX_ENTRIES + 5; index += 1) { - const res = await request(app) - .get(`/api/companies/${companyId}/issues`) - .query({ view: "compact", limit: "20", q: `cache-key-${index}` }); - expect(res.status, JSON.stringify(res.body)).toBe(200); - } + // Filling the cache can take longer than its five-second expiry on CI. + // Keep entries alive so this test measures capacity eviction alone. + const clock = vi.spyOn(Date, "now").mockReturnValue(Date.now()); + try { + for (let index = 0; index < ISSUE_LIST_SERVER_CACHE_MAX_ENTRIES + 5; index += 1) { + const res = await request(app) + .get(`/api/companies/${companyId}/issues`) + .query({ view: "compact", limit: "20", q: `cache-key-${index}` }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + } - expect(__getIssueListResponseCacheSizeForTests()).toBe(ISSUE_LIST_SERVER_CACHE_MAX_ENTRIES); + expect(__getIssueListResponseCacheSizeForTests()).toBe(ISSUE_LIST_SERVER_CACHE_MAX_ENTRIES); + } finally { + clock.mockRestore(); + } }); it("logs request_storm_detected for identical in-flight compact issue-list fanout without query values", async () => { diff --git a/server/src/__tests__/issue-summaries-routes.test.ts b/server/src/__tests__/issue-summaries-routes.test.ts new file mode 100644 index 00000000000..c832d92af03 --- /dev/null +++ b/server/src/__tests__/issue-summaries-routes.test.ts @@ -0,0 +1,113 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, expect, it, vi } from "vitest"; +import { + agents, chatConversations, chatEndpoints, issues, toolApplications, toolConnections, +} from "@paperclipai/db"; +import { issueRoutes } from "../routes/issues.js"; +import * as issueServices from "../services/issues.js"; +import { errorHandler } from "../middleware/index.js"; +import { describeEmbeddedPostgres, seedCompanyWithBoardAccess, useEmbeddedPostgres } from "./helpers/route-test-harness.js"; + +describeEmbeddedPostgres("bounded issue summaries", () => { + const ctx = useEmbeddedPostgres("paperclip-issue-summaries-"); + const oldPrivacy = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + beforeAll(() => { process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce"; }); + afterAll(() => { + if (oldPrivacy === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; + else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = oldPrivacy; + }); + + async function fixture() { + const company = await seedCompanyWithBoardAccess(ctx.db, "Summaries"); + const rows = await ctx.db.insert(issues).values([ + { companyId: company.companyId, title: "First", description: "Do not serialize this".repeat(100), status: "todo" }, + { companyId: company.companyId, title: "Second", status: "done" }, + ]).returning(); + return { ...company, rows }; + } + function app(actor: Express.Request["actor"]) { + const result = express(); + result.use(express.json()); + result.use((req, _res, next) => { req.actor = actor; next(); }); + result.use("/api", issueRoutes(ctx.db, {} as never)); + result.use(errorHandler); + return result; + } + const url = (companyId: string) => `/api/companies/${companyId}/issues/summaries`; + + it("returns only summaries in requested order without full-detail reads", async () => { + const f = await fixture(); + const original = issueServices.issueService; + const forbiddenRead = vi.fn(() => { throw new Error("Full detail read invoked"); }); + const spy = vi.spyOn(issueServices, "issueService").mockImplementation((...args) => ({ + ...original(...args), getById: forbiddenRead, list: forbiddenRead, + })); + try { + const res = await request(app(f.actor)).get(url(f.companyId)).query({ ids: ` ${f.rows[1]!.id.toUpperCase()},${f.rows[0]!.id},${f.rows[1]!.id} ` }).expect(200); + expect(res.headers["cache-control"]).toBe("private, no-store"); + expect(res.body.map((row: { id: string }) => row.id)).toEqual([f.rows[1]!.id, f.rows[0]!.id]); + expect(Object.keys(res.body[0]).sort()).toEqual([ + "companyId", "externalConversationState", "id", "identifier", "status", "title", "updatedAt", + ]); + expect(res.body[0].externalConversationState).toBeNull(); + expect(forbiddenRead).not.toHaveBeenCalled(); + } finally { spy.mockRestore(); } + }); + + it.each(["", "?ids=", "?ids=nope", "?ids=,", "?ids[]=nope", "?ids[x]=nope", + `?ids=${randomUUID()}&ids[]=nope`, `?ids=${randomUUID()}&ids[x]=nope`, + `?ids=${randomUUID()}&ids=${randomUUID()}`, `?ids=${Array(6).fill(randomUUID()).join(",")}`, + ])("rejects invalid query %s", async (query) => { + const f = await fixture(); + await request(app(f.actor)).get(url(f.companyId) + query).expect(400); + }); + + it("omits hidden, private, foreign and missing IDs without revealing them", async () => { + const f = await fixture(); + const foreign = await fixture(); + const hidden = f.rows[1]!; + await ctx.db.update(issues).set({ hiddenAt: new Date() }).where(eq(issues.id, hidden.id)); + const privateId = randomUUID(); + await ctx.db.insert(issues).values({ id: privateId, companyId: f.companyId, title: "Private", visibility: "private", privacyRootIssueId: privateId, responsibleUserId: "someone-else", createdByUserId: "someone-else" }); + const ids = [f.rows[0]!.id, hidden.id, privateId, foreign.rows[0]!.id, randomUUID()]; + const res = await request(app(f.actor)).get(url(f.companyId)).query({ ids: ids.join(",") }).expect(200); + expect(res.body.map((row: { id: string }) => row.id)).toEqual([f.rows[0]!.id]); + const empty = await request(app(f.actor)).get(url(f.companyId)).query({ ids: ids.slice(1).join(",") }).expect(200); + expect(empty.body).toEqual([]); + await request(app(f.actor)).get(url(foreign.companyId)).query({ ids: foreign.rows[0]!.id }).expect(403); + }); + + it("permits ordinary same-company agents and rejects task-bridge keys", async () => { + const f = await fixture(); + const [agent] = await ctx.db.insert(agents).values({ companyId: f.companyId, name: "Reader", role: "engineer", adapterType: "process" }).returning(); + const actor: Express.Request["actor"] = { type: "agent", agentId: agent!.id, companyId: f.companyId, source: "agent_key" }; + const res = await request(app(actor)).get(url(f.companyId)).query({ ids: f.rows[0]!.id }).expect(200); + expect(res.body).toHaveLength(1); + await request(app({ ...actor, keyScope: { kind: "task_bridge", issueId: f.rows[0]!.id } } as Express.Request["actor"])) + .get(url(f.companyId)).query({ ids: f.rows[0]!.id }).expect(403); + }); + + it("projects conversation readiness changes without advancing the task version", async () => { + const f = await fixture(); + const [agent] = await ctx.db.insert(agents).values({ companyId: f.companyId, name: "Chat", role: "general", adapterType: "process" }).returning(); + const issue = f.rows[0]!; + await ctx.db.update(issues).set({ status: "in_review", assigneeAgentId: agent!.id }).where(eq(issues.id, issue.id)); + const [application] = await ctx.db.insert(toolApplications).values({ companyId: f.companyId, name: "Slack", type: "native" }).returning(); + const connectionId = randomUUID(); + await ctx.db.insert(toolConnections).values({ id: connectionId, companyId: f.companyId, applicationId: application!.id, uid: connectionId, name: "Slack", transport: "chat_sdk", connectionPurpose: "channel" }); + const endpointId = randomUUID(); + await ctx.db.insert(chatEndpoints).values({ id: endpointId, companyId: f.companyId, connectionId, provider: "slack", publicId: endpointId, assignedAgentId: agent!.id, status: "active" }); + const [conversation] = await ctx.db.insert(chatConversations).values({ companyId: f.companyId, endpointId, issueId: issue.id, externalConversationId: "channel", externalThreadId: issue.id, externalLabel: "#summary-test", state: "waiting" }).returning(); + const read = () => request(app(f.actor)).get(url(f.companyId)).query({ ids: issue.id }).expect(200); + const waiting = (await read()).body[0]; + expect(waiting.externalConversationState).toBe("waiting"); + expect((await issueServices.issueService(ctx.db).getById(issue.id))?.externalConversationState).toBe("waiting"); + await ctx.db.update(chatConversations).set({ state: "active" }).where(eq(chatConversations.id, conversation!.id)); + const active = (await read()).body[0]; + expect(active.externalConversationState).toBe("active"); + expect(active.updatedAt).toBe(waiting.updatedAt); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index c7139f83572..6f9e39ca283 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1,4 +1,6 @@ import { parseObject } from "../adapters/utils.js"; +import { issueSummariesQuerySchema, type IssueSummary } from "@paperclipai/shared"; +import { listIssueSummaries } from "../services/issue-summaries.js"; import { assertLegacyRunTaskOwnership } from "../legacy-run-task-ownership.js"; import { enqueueFastResponse } from "../services/fast-responses.js"; import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "../services/workspace-restore-recovery-state.js"; @@ -8210,6 +8212,33 @@ export function issueRoutes( res.json(result); }); + router.get("/companies/:companyId/issues/summaries", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + if (isTaskBridgeKeyActor(req)) { + res.status(403).json({ error: "Task bridge keys cannot use company-wide issue list APIs" }); + return; + } + const parsed = issueSummariesQuerySchema.safeParse(req.query); + if (!parsed.success) { + res.status(400).json({ error: "ids must be the only query parameter and contain one to five comma-separated UUIDs" }); + return; + } + const rows = await listIssueSummaries(db, companyId, parsed.data.ids, await issueReadSqlCondition(db, req.actor)); + const visible = await filterIssuesForActor(req, rows); + const summaries: IssueSummary[] = visible.map((row) => ({ + id: row.id, + companyId: row.companyId, + title: row.title, + identifier: row.identifier, + status: row.status as IssueSummary["status"], + updatedAt: row.updatedAt, + externalConversationState: row.externalConversationState, + })); + res.setHeader("Cache-Control", "private, no-store"); + res.json(summaries); + }); + router.get("/companies/:companyId/issues", async (req, res) => { const startedAt = Date.now(); const companyId = req.params.companyId as string; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 8d6511b266f..c861eeba8e5 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -4227,6 +4227,19 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/issues/summaries", + tags: ["issues"], + summary: "Get authorized recent-task summaries", + description: "Accepts only the ids query parameter: one to five comma-separated UUIDs, bounded before deduplication. IDs are trimmed and normalized to lowercase. Repeated, array and nested query syntax is rejected, including mixed forms. Returns accessible, visible tasks in request order; unavailable IDs are omitted. This private, no-store read excludes full task and workspace enrichment.", + request: { + params: z.object({ companyId: z.string() }), + query: z.object({ ids: z.string().min(1).describe("One to five comma-separated task UUIDs; repeated query parameters are rejected") }), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden }, +}); + registry.registerPath({ method: "post", path: "/api/companies/{companyId}/issues", diff --git a/server/src/services/issue-summaries.ts b/server/src/services/issue-summaries.ts new file mode 100644 index 00000000000..485bb2250db --- /dev/null +++ b/server/src/services/issue-summaries.ts @@ -0,0 +1,38 @@ +import { and, eq, inArray, isNull, type SQL } from "drizzle-orm"; +import { issues, type Db } from "@paperclipai/db"; +import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared"; +import { externalConversationStateSql } from "./slack-conversation-state.js"; + +/** A bounded projection with the columns needed by task-read authorization. */ +export async function listIssueSummaries( + db: Db, + companyId: string, + ids: readonly string[], + readCondition: SQL, +) { + if (ids.length > ISSUE_SUMMARIES_MAX_IDS) throw new Error("Too many issue summary IDs"); + if (ids.length === 0) return []; + const rows = await db.select({ + id: issues.id, + companyId: issues.companyId, + title: issues.title, + identifier: issues.identifier, + status: issues.status, + updatedAt: issues.updatedAt, + externalConversationState: externalConversationStateSql(), + projectId: issues.projectId, + parentId: issues.parentId, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + }).from(issues).where(and( + eq(issues.companyId, companyId), + inArray(issues.id, [...ids]), + isNull(issues.hiddenAt), + readCondition, + )); + const byId = new Map(rows.map((row) => [row.id, row])); + return [...new Set(ids)].flatMap((id) => { + const row = byId.get(id); + return row ? [row] : []; + }); +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b9286cf717d..f7eaabe95da 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -18,6 +18,7 @@ import { import { useHiddenSettings } from "./hooks/useHiddenSettings"; import { OnboardingWizardVariant } from "./components/OnboardingWizardVariant"; import { CloudAccessGate } from "./components/CloudAccessGate"; +import { ShellConfigurationGate } from "./components/ShellConfigurationGate"; import { PaperclipLoading } from "./components/AnimatedPaperclipIcon"; import { AGENT_FILTER_TABS } from "./lib/agent-filter-tabs"; import { useCombinedInboxTasksEnabled } from "./hooks/useCombinedInboxTasksEnabled"; @@ -776,17 +777,20 @@ export function App() { } /> } /> } /> - : }> + }> + }> {/* The identity APIs enforce the chat rollout flag. Nonmembers cannot read experimental settings, but a private invitation may request membership. */} } /> + } /> } /> } /> } /> - : }> + }> + }> } /> } /> } /> @@ -852,6 +856,7 @@ export function App() { {boardRoutes(streamlinedUiEnabled, combinedInboxTasksEnabled)} } /> + diff --git a/ui/src/api/issue-summaries.test.ts b/ui/src/api/issue-summaries.test.ts new file mode 100644 index 00000000000..4e4f95f84db --- /dev/null +++ b/ui/src/api/issue-summaries.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { issuesApi } from "./issues"; + +const first = "11111111-1111-4111-8111-111111111111"; +const second = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + +describe("issue summary HTTP requests", () => { + const fetchMock = vi.fn(); + beforeEach(() => { fetchMock.mockReset(); vi.stubGlobal("fetch", fetchMock); }); + afterEach(() => vi.unstubAllGlobals()); + + it("normalizes IDs, forwards cancellation, and disables the HTTP cache", async () => { + fetchMock.mockResolvedValue(Response.json([])); + const controller = new AbortController(); + await issuesApi.summaries("company", [second.toUpperCase(), ` ${first} `, first], { signal: controller.signal }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toBe(`/api/companies/company/issues/summaries?ids=${first}%2C${second}`); + expect(options.cache).toBe("no-store"); + expect(options.signal).toBeInstanceOf(AbortSignal); + }); + + it("does not send empty or oversized batches", async () => { + expect(await issuesApi.summaries("company", [])).toEqual([]); + await expect(issuesApi.summaries("company", Array(6).fill(first))).rejects.toThrow("Too many"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not reuse an in-flight response after an account change", async () => { + let resolveOld!: (value: Response) => void; + fetchMock.mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve; })) + .mockResolvedValueOnce(Response.json([{ id: first, title: "New account" }])); + const oldAccount = issuesApi.summaries("company", [first]); + const newAccount = issuesApi.summaries("company", [first]); + expect((await newAccount)[0].title).toBe("New account"); + resolveOld(Response.json([{ id: first, title: "Old account" }])); + expect((await oldAccount)[0].title).toBe("Old account"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("aborts a summary request when its observer leaves", async () => { + fetchMock.mockImplementation((_url, options) => new Promise((_resolve, reject) => { + options.signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))); + })); + const controller = new AbortController(); + const pending = issuesApi.summaries("company", [first], { signal: controller.signal }); + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(true); + }); +}); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index af057efd112..2488a6a157e 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -1,4 +1,6 @@ import type { SetIssueTitle } from "@paperclipai/shared"; +import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared"; +import type { IssueSummary } from "@paperclipai/shared"; import type { ExecutionReconciliation } from "@paperclipai/shared"; import type { AcceptedPlanDecompositionSummary, @@ -169,6 +171,17 @@ function issueListSearchParams(filters?: IssueListFilters) { } export const issuesApi = { + summaries: async (companyId: string, issueIds: readonly string[], options?: RequestOptions): Promise => { + if (issueIds.length > ISSUE_SUMMARIES_MAX_IDS) throw new Error("Too many issue summary IDs"); + const ids = [...new Set(issueIds.map((id) => id.trim().toLowerCase()))].sort(); + if (ids.length === 0) return []; + const params = new URLSearchParams({ ids: ids.join(",") }); + const path = `/companies/${companyId}/issues/summaries?${params}`; + // React Query deduplicates within an account; URL-only HTTP coalescing must + // never join a request sent with the previous account's cookie. + detachInflightGet(path); + return api.get(path, { ...options, cache: "no-store" }); + }, /** Fetch every page for bounded task-detail relations, not just the default first page. */ listAll: async (companyId: string, filters: Omit, options?: RequestOptions): Promise => { const pageSize = 500; diff --git a/ui/src/components/ShellConfigurationGate.test.tsx b/ui/src/components/ShellConfigurationGate.test.tsx new file mode 100644 index 00000000000..152ede20c43 --- /dev/null +++ b/ui/src/components/ShellConfigurationGate.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, expect, it, vi } from "vitest"; +import { CloudAccessGate } from "./CloudAccessGate"; +import { ShellConfigurationGate } from "./ShellConfigurationGate"; +import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled"; + +vi.mock("@/lib/router", async () => await import("react-router-dom")); +vi.mock("./AnimatedPaperclipIcon", () => ({ PaperclipLoading: () =>
Loading shell
})); +const { health, session, access, settings } = vi.hoisted(() => ({ health: vi.fn(), session: vi.fn(), access: vi.fn(), settings: vi.fn() })); +vi.mock("../api/health", () => ({ healthApi: { get: health } })); +vi.mock("../api/auth", () => ({ authApi: { getSession: session } })); +vi.mock("../api/access", () => ({ accessApi: { getCurrentBoardAccess: access } })); +vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: settings } })); +(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => vi.resetAllMocks()); + +it.each([false, true])("runs access checks while shell settings are pending (membership=%s)", async allowMembershipRequest => { + let resolveSettings!: (value: unknown) => void; + health.mockResolvedValue({ status: "ok", deploymentMode: "authenticated", bootstrapStatus: "ready" }); + session.mockResolvedValue({ user: { id: "user" } }); + access.mockResolvedValue({ companyIds: ["company"] }); + settings.mockReturnValue(new Promise(resolve => { resolveSettings = resolve; })); + function RoutesUnderTest() { + const { loaded, enabled } = useStreamlinedUiEnabled(); + return }> + }> + {enabled ? "Streamlined shell" : "Legacy shell"}} /> + + ; + } + const container = document.createElement("div"); + const root = createRoot(container); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + try { + await act(async () => root.render()); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + expect(health).toHaveBeenCalledTimes(1); + expect(session).toHaveBeenCalledTimes(1); + expect(access).toHaveBeenCalledTimes(1); + expect(container.textContent).not.toContain("Streamlined shell"); + expect(container.textContent).not.toContain("Legacy shell"); + await act(async () => { resolveSettings({ enableStreamlinedUi: false }); }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + expect(container.textContent).toBe("Legacy shell"); + } finally { + await act(async () => root.unmount()); + client.clear(); + } +}); diff --git a/ui/src/components/ShellConfigurationGate.tsx b/ui/src/components/ShellConfigurationGate.tsx new file mode 100644 index 00000000000..9b3a5687a39 --- /dev/null +++ b/ui/src/components/ShellConfigurationGate.tsx @@ -0,0 +1,7 @@ +import { Outlet } from "@/lib/router"; +import { PaperclipLoading } from "./AnimatedPaperclipIcon"; + +/** Keep shell selection stable while the independent access gate loads. */ +export function ShellConfigurationGate({ loaded }: { loaded: boolean }) { + return loaded ? : ; +} diff --git a/ui/src/components/SidebarRecentTasks.test.tsx b/ui/src/components/SidebarRecentTasks.test.tsx index a9817c6d9de..b102ae40a51 100644 --- a/ui/src/components/SidebarRecentTasks.test.tsx +++ b/ui/src/components/SidebarRecentTasks.test.tsx @@ -18,6 +18,7 @@ const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn() })); const mockAgentsApi = vi.hoisted(() => ({ wakeup: vi.fn() })); const mockIssuesApi = vi.hoisted(() => ({ get: vi.fn(), + summaries: vi.fn(), update: vi.fn(), archiveFromInbox: vi.fn(), getTreeControlState: vi.fn(), @@ -63,6 +64,10 @@ describe("SidebarRecentTasks", () => { window.localStorage.clear(); Object.values(mockAgentsApi).forEach((mock) => mock.mockReset()); Object.values(mockIssuesApi).forEach((mock) => mock.mockReset()); + mockIssuesApi.summaries.mockImplementation(async (_company: string, ids: string[]) => { + const rows = await Promise.all(ids.map(id => mockIssuesApi.get.getMockImplementation()?.(id))); + return rows.filter(row => row && !row.hiddenAt); + }); container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -154,10 +159,9 @@ describe("SidebarRecentTasks", () => { }); const link = container.querySelector('a[href="/issues/issue-1"]'); - expect(mockIssuesApi.get).toHaveBeenCalledWith("issue-1"); - expect(queryClient.getQueryData(["issues", "detail", "issue-1"])).toMatchObject({ - title: "Refreshed title", - }); + expect(mockIssuesApi.get).not.toHaveBeenCalled(); + expect(mockIssuesApi.summaries).toHaveBeenCalledTimes(1); + expect(queryClient.getQueryData(["issues", "detail", "issue-1"])).toBeUndefined(); expect(link?.textContent).toContain("Refreshed title"); expect(link?.textContent).toContain("1 live"); expect(link?.querySelector('[aria-label="In Progress"]')).toBeNull(); @@ -179,6 +183,7 @@ describe("SidebarRecentTasks", () => { identifier: `PAP-${id}`, status: "done" as const, hiddenAt: null, + projectId: null, updatedAt: new Date(id), })); tasks.forEach((task) => recordRecentTask(task, "user-1")); @@ -206,7 +211,8 @@ describe("SidebarRecentTasks", () => { expect(order()).toEqual(originalOrder); await act(async () => { await vi.advanceTimersByTimeAsync(1); }); expect(order()).toEqual(["/issues/issue-2", "/issues/issue-1", "/issues/issue-3"]); - expect(mockIssuesApi.get).toHaveBeenCalledTimes(3); + expect(mockIssuesApi.get).not.toHaveBeenCalled(); + expect(mockIssuesApi.summaries).toHaveBeenCalledTimes(1); queryClient.clear(); }); diff --git a/ui/src/components/SidebarRecentTasks.tsx b/ui/src/components/SidebarRecentTasks.tsx index 5241dbb648c..3a9f57c2491 100644 --- a/ui/src/components/SidebarRecentTasks.tsx +++ b/ui/src/components/SidebarRecentTasks.tsx @@ -1,11 +1,11 @@ import { useState, type FormEvent } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { Archive, MoreHorizontal, Pencil, RefreshCw } from "lucide-react"; import { agentsApi } from "@/api/agents"; -import { authApi } from "@/api/auth"; import { issuesApi } from "@/api/issues"; import { queryKeys } from "@/lib/queryKeys"; import { useRecentTasks } from "@/hooks/useRecentTasks"; +import { useAccountIdentity } from "@/api/companies-query"; import { useSidebar } from "@/context/SidebarContext"; import { useOptionalToastActions } from "@/context/ToastContext"; import { @@ -76,15 +76,10 @@ export function SidebarRecentTasks({ }) { const { collapsed, peeking } = useSidebar(); const rail = collapsed && !peeking; - const { data: session, isPending } = useQuery({ - queryKey: queryKeys.auth.session, - queryFn: () => authApi.getSession(), - retry: false, - }); + const { userId, settled } = useAccountIdentity(); - if (!companyId || isPending) return null; + if (!companyId || !settled) return null; - const userId = session?.user?.id ?? session?.session?.userId ?? null; return ( ({ pushToast: vi.fn() })); +const { sessionRead, healthRead } = vi.hoisted(() => ({ sessionRead: vi.fn(), healthRead: vi.fn() })); vi.mock("./CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1" } }), })); vi.mock("./ToastContext", () => ({ useToastActions: () => ({ pushToast }) })); vi.mock("../lib/router", () => ({ useLocation: () => ({ pathname: "/tasks" }) })); vi.mock("../api/auth", () => ({ - authApi: { getSession: async () => ({ user: { id: "viewer" }, session: { id: "session", userId: "viewer" } }) }, + authApi: { getSession: sessionRead }, })); -vi.mock("../api/health", () => ({ healthApi: { get: async () => ({ deploymentMode: "authenticated" }) } })); +vi.mock("../api/health", () => ({ healthApi: { get: healthRead } })); import { LiveUpdatesProvider } from "./LiveUpdatesProvider"; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -41,6 +42,8 @@ describe("LiveUpdatesProvider connection recovery", () => { vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); read.mockReset(); read.mockResolvedValue("current task data"); + sessionRead.mockReset().mockResolvedValue({ user: { id: "viewer" }, session: { id: "session", userId: "viewer" } }); + healthRead.mockReset().mockResolvedValue({ deploymentMode: "authenticated" }); Socket.instances = []; client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity, staleTime: Infinity } } }); client.setQueryData(queryKeys.auth.session, { user: { id: "viewer" }, session: { id: "session", userId: "viewer" } }); @@ -80,9 +83,17 @@ describe("LiveUpdatesProvider connection recovery", () => { await act(async () => Socket.instances[0].onopen?.()); await act(async () => vi.advanceTimersByTimeAsync(1)); expect(container.textContent).toBe("reply saved during connection setup"); + expect(sessionRead).not.toHaveBeenCalled(); + expect(healthRead).not.toHaveBeenCalled(); const readsAfterConnect = read.mock.calls.length; await act(async () => vi.advanceTimersByTimeAsync(30_000)); expect(read).toHaveBeenCalledTimes(readsAfterConnect); + await act(async () => Socket.instances[0].onclose?.()); + await act(async () => vi.advanceTimersByTimeAsync(1_001)); + await act(async () => Socket.instances.at(-1)?.onopen?.()); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(sessionRead).toHaveBeenCalled(); + expect(healthRead).toHaveBeenCalled(); }); it.each(["missing", "throws"])("polls visible data and resumes realtime when the constructor %s", async (failure) => { diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 22a111b9d15..6e1c76af7ea 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -1233,6 +1233,7 @@ function invalidateHeartbeatQueries( companyId: string, payload: Record, ) { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.summaries(companyId) }); // Note: liveRuns(companyId) is intentionally NOT invalidated here — it is // event-sourced via applyRunLifecycleToCompanyLiveRuns in the caller. queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId) }); @@ -1897,6 +1898,8 @@ function canUseLiveSession(sessionStatus: string, hasSession: boolean, deploymen export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const { visible } = usePageVisibility(); const wasHidden = useRef(!visible); + const hasOpenedSocket = useRef(false); + const hasNeededSocketRecovery = useRef(!visible); const { selectedCompanyId, selectedCompany } = useCompany(); const queryClient = useQueryClient(); const { pushToast } = useToastActions(); @@ -1961,6 +1964,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { useEffect(() => { if (!visible) { wasHidden.current = true; + hasNeededSocketRecovery.current = true; invalidationBatcher.dispose(); return; } @@ -2002,6 +2006,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const scheduleReconnect = () => { if (closed || reconnectTimer !== null) return; reconnectAttempt += 1; + hasNeededSocketRecovery.current = true; const delayMs = Math.min( 15000, 1000 * 2 ** Math.min(reconnectAttempt - 1, 4), @@ -2036,7 +2041,15 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { } // The initial page queries can finish before the first subscription, // too. Reconcile that gap as well as reconnects: events are not replayed. - void queryClient.invalidateQueries({ type: "active" }, { cancelRefetch: false }); + const initialHealthyOpen = !hasOpenedSocket.current && !hasNeededSocketRecovery.current; + hasOpenedSocket.current = true; + void queryClient.invalidateQueries({ + type: "active", + ...(initialHealthyOpen ? { predicate: (query) => { + const key = JSON.stringify(query.queryKey); + return key !== JSON.stringify(queryKeys.auth.session) && key !== JSON.stringify(queryKeys.health); + } } : {}), + }, { cancelRefetch: false }); reconnectAttempt = 0; }; diff --git a/ui/src/hooks/useRecentTasks.test.tsx b/ui/src/hooks/useRecentTasks.test.tsx index 987fedc3467..5fb6995627e 100644 --- a/ui/src/hooks/useRecentTasks.test.tsx +++ b/ui/src/hooks/useRecentTasks.test.tsx @@ -14,14 +14,14 @@ import { } from "@/lib/recent-tasks"; import { useRecentTasks } from "./useRecentTasks"; -const { getIssue } = vi.hoisted(() => ({ getIssue: vi.fn() })); -vi.mock("@/api/issues", () => ({ issuesApi: { get: getIssue } })); +const { getIssue, summaries } = vi.hoisted(() => ({ getIssue: vi.fn(), summaries: vi.fn() })); +vi.mock("@/api/issues", () => ({ issuesApi: { get: getIssue, summaries } })); (globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true; const task = (updatedAt: number, title: string, status: "todo" | "done" = "todo") => ({ id: "issue-1", companyId: "company-1", identifier: "TASK-1", title, status, - updatedAt: new Date(updatedAt), hiddenAt: null, + updatedAt: new Date(updatedAt), hiddenAt: null, projectId: null, }); const storageKey = getRecentTasksStorageKey("company-1", "user-1"); // Node 24 uses jsdom Storage; newer Node versions use the setup's storage shim. @@ -34,8 +34,8 @@ describe("recent task synchronization", () => { let clients: QueryClient[]; let renders: number; - function RecentTasks({ name }: { name: string }) { - const { entries } = useRecentTasks({ companyId: "company-1", userId: "user-1" }); + function RecentTasks({ name, companyId = "company-1", userId = "user-1" }: { name: string; companyId?: string; userId?: string }) { + const { entries } = useRecentTasks({ companyId, userId }); renders += 1; // Fail promptly if a regression restores the cross-cache render loop. if (renders > 100) throw new Error("Recent Tasks did not settle"); @@ -44,7 +44,10 @@ describe("recent task synchronization", () => { function client(snapshot?: ReturnType) { const value = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); - if (snapshot) value.setQueryData(queryKeys.issues.detail(snapshot.id), snapshot); + if (snapshot) { + value.setQueryData(queryKeys.issues.detail(snapshot.id), snapshot); + value.setQueryData(queryKeys.issues.summaryBatch("company-1", "user-1", [snapshot.id]), [snapshot]); + } clients.push(value); return value; } @@ -58,6 +61,7 @@ describe("recent task synchronization", () => { beforeEach(() => { localStorage.clear(); getIssue.mockReset(); + summaries.mockReset(); container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -146,15 +150,131 @@ describe("recent task synchronization", () => { expect(readRecentTasks(storageKey, "company-1")[0]?.recordedAt).toBe(100); }); - it.each([403, 404])("still removes tasks whose query returns %s", async (status) => { + it.each([403, 500])("retains snapshots when the batch fails with %s", async (status) => { recordRecentTask(task(10, "Unavailable"), "user-1"); - getIssue.mockRejectedValue(new ApiError("Unavailable", status, null)); + summaries.mockRejectedValue(new ApiError("Unavailable", status, null)); await act(async () => root.render( , )); await flush(); + expect(readRecentTasks(storageKey, "company-1")).toHaveLength(1); + expect(container.textContent).toBe("Unavailable:todo"); + expect(summaries).toHaveBeenCalledTimes(1); + expect(getIssue).not.toHaveBeenCalled(); + }); + + it("fetches five tasks once without activating detail queries", async () => { + const tasks = Array.from({ length: 5 }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${i}` })); + tasks.forEach(issue => recordRecentTask(issue, "user-1")); + summaries.mockResolvedValue(tasks); + const cache = client(); + await act(async () => root.render()); + await flush(); + expect(summaries).toHaveBeenCalledTimes(1); + expect(summaries.mock.calls[0][1]).toEqual(tasks.map(issue => issue.id).sort()); + expect(getIssue).not.toHaveBeenCalled(); + expect(cache.getQueryCache().findAll({ queryKey: ["issues", "detail"] })).toEqual([]); + }); + + it("does not fetch empty history", async () => { + await act(async () => root.render()); + await flush(); + expect(summaries).not.toHaveBeenCalled(); + }); + + it("prunes successful omissions without resurrecting cached full details", async () => { + const missing = task(10, "Removed"); + recordRecentTask(missing, "user-1"); + const cache = client(); + cache.setQueryData(queryKeys.issues.detail(missing.id), task(30, "Cached secret")); + summaries.mockResolvedValue([]); + await act(async () => root.render()); + await flush(); expect(readRecentTasks(storageKey, "company-1")).toEqual([]); expect(container.textContent).toBe(""); - expect(getIssue).toHaveBeenCalledTimes(1); + }); + + it("does not prune a task added while an older batch is pending", async () => { + recordRecentTask(task(10, "Missing old task"), "user-1"); + let resolveFirst!: (rows: unknown[]) => void; + summaries.mockReturnValueOnce(new Promise(resolve => { resolveFirst = resolve; })) + .mockReturnValue(new Promise(() => {})); + await act(async () => root.render()); + const added = { ...task(20, "New task"), id: "issue-2" }; + await act(async () => { recordRecentTask(added, "user-1"); }); + await act(async () => { resolveFirst([]); }); + await flush(); + expect(readRecentTasks(storageKey, "company-1").some(entry => entry.id === added.id)).toBe(true); + expect(container.textContent).toContain("New task"); + }); + + it("isolates an account switch while the old request is pending", async () => { + recordRecentTask(task(10, "Old account task"), "user-1"); + recordRecentTask({ ...task(20, "New account task"), id: "issue-2" }, "user-2"); + let resolveOld!: (rows: unknown[]) => void; + summaries.mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve; })) + .mockResolvedValue([{ ...task(20, "New account task"), id: "issue-2" }]); + const cache = client(); + await act(async () => root.render()); + await act(async () => root.render()); + await act(async () => { resolveOld([task(30, "Old response")]); }); + await flush(); + expect(container.textContent).toBe("New account task:todo"); + expect(readRecentTasks(getRecentTasksStorageKey("company-1", "user-2"), "company-1")[0]?.id).toBe("issue-2"); + expect(summaries.mock.calls.map(call => call[1])).toEqual([["issue-1"], ["issue-2"]]); + }); + + it("refreshes a cached omission when the user revisits an accessible task", async () => { + const first = task(10, "First"); + const second = { ...task(20, "Second"), id: "issue-2" }; + recordRecentTask(first, "user-1"); + recordRecentTask(second, "user-1"); + summaries.mockResolvedValue([second]); + const cache = client(); + await act(async () => root.render()); + await flush(); + expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id)).toEqual([second.id]); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + expect(cache.getQueryState(queryKeys.issues.summaryBatch("company-1", "user-1", [second.id]))?.status).toBe("success"); + summaries.mockResolvedValue([first, second]); + await act(async () => { recordRecentTask(first, "user-1", 30); }); + await flush(); + expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id).sort()).toEqual([first.id, second.id]); + expect(summaries.mock.calls.filter(call => call[1].length === 2)).toHaveLength(2); + }); + + it("keeps a revisited task when refreshing its cached omission fails", async () => { + const first = task(10, "First"); + const second = { ...task(20, "Second"), id: "issue-2" }; + recordRecentTask(first, "user-1"); + recordRecentTask(second, "user-1"); + summaries.mockResolvedValue([second]); + const cache = client(); + await act(async () => root.render()); + await flush(); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + summaries.mockRejectedValue(new ApiError("Unavailable", 500, null)); + await act(async () => { recordRecentTask(first, "user-1", 30); }); + await flush(); + expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id).sort()).toEqual([first.id, second.id]); + expect(cache.getQueryState(queryKeys.issues.summaryBatch("company-1", "user-1", [first.id, second.id]))?.status).toBe("error"); + expect(summaries.mock.calls.filter(call => call[1].length === 2)).toHaveLength(2); + }); + + it("prunes a revisited task when a fresh response repeats the cached omission", async () => { + const first = task(10, "First"); + const second = { ...task(20, "Second"), id: "issue-2" }; + recordRecentTask(first, "user-1"); + recordRecentTask(second, "user-1"); + const rows = [second]; + summaries.mockResolvedValue(rows); + const cache = client(); + await act(async () => root.render()); + await flush(); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + await act(async () => { recordRecentTask(first, "user-1", 30); }); + await flush(); + expect(summaries.mock.calls.filter(call => call[1].length === 2)).toHaveLength(2); + expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id)).toEqual([second.id]); }); }); diff --git a/ui/src/hooks/useRecentTasks.ts b/ui/src/hooks/useRecentTasks.ts index e50b1c464e3..5de4f3a59af 100644 --- a/ui/src/hooks/useRecentTasks.ts +++ b/ui/src/hooks/useRecentTasks.ts @@ -1,7 +1,6 @@ -import { useEffect, useMemo, useState } from "react"; -import { useQueries } from "@tanstack/react-query"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { Issue } from "@paperclipai/shared"; -import { ApiError } from "@/api/client"; import { issuesApi } from "@/api/issues"; import { queryKeys } from "@/lib/queryKeys"; import { @@ -33,9 +32,16 @@ export function useRecentTasks({ () => companyId ? getRecentTasksStorageKey(companyId, userId) : null, [companyId, userId], ); - const [entries, setEntries] = useState(() => ( - storageKey && companyId ? readRecentTasks(storageKey, companyId) : [] - )); + const queryClient = useQueryClient(); + const [snapshot, setSnapshot] = useState(() => ({ + storageKey, + entries: storageKey && companyId ? readRecentTasks(storageKey, companyId) : [], + })); + // A scope change must not request the previous account's IDs for one render. + const entries = snapshot.storageKey === storageKey + ? snapshot.entries + : storageKey && companyId ? readRecentTasks(storageKey, companyId) : []; + const setEntries = (next: RecentTaskEntry[]) => setSnapshot({ storageKey, entries: next }); useEffect(() => { if (storageKey && companyId) migrateRecentTasks(storageKey, companyId); @@ -62,53 +68,77 @@ export function useRecentTasks({ }; }, [companyId, storageKey]); - // Keep query observers in a fixed order when activity changes the display order. - const queryEntries = [...entries].sort((left, right) => left.id.localeCompare(right.id)); - const membership = JSON.stringify(queryEntries.map((entry) => entry.id)); - const detailQueries = useQueries({ - queries: queryEntries.map((entry) => ({ - queryKey: queryKeys.issues.detail(entry.id), - queryFn: () => issuesApi.get(entry.id), - retry: false, - staleTime: 30_000, - })), + const queryIds = useMemo(() => entries.map((entry) => entry.id).sort(), [entries]); + const membership = JSON.stringify(queryIds); + const scope = JSON.stringify([storageKey, queryIds]); + const summaryKey = queryKeys.issues.summaryBatch(companyId ?? "__none__", userId, queryIds); + // A cached omission predates IDs that the user has just added back. Only a + // response completed during this visit to the batch may remove those IDs. + const batchVisit = useMemo(() => ({ + initialUpdateCount: queryClient.getQueryState(summaryKey)?.dataUpdateCount ?? 0, + // The serialized scope includes the account, company, and sorted ID set. + // eslint-disable-next-line react-hooks/exhaustive-deps + }), [queryClient, scope]); + const summaryQuery = useQuery({ + queryKey: summaryKey, + queryFn: ({ signal }) => issuesApi.summaries(companyId!, queryIds, { signal }), + enabled: Boolean(companyId && queryIds.length), + retry: false, + staleTime: 30_000, }); - - const issueById = new Map(detailQueries.flatMap((query) => query.data ? [[query.data.id, query.data] as const] : [])); + // Observe completion even when structural sharing preserves the data object. + // The successful-update counter itself is only available on the cache state. + const fetchingSummaries = summaryQuery.isFetching; + const updateCount = queryClient.getQueryState(summaryKey)?.dataUpdateCount ?? 0; + const summaries = summaryQuery.isSuccess ? summaryQuery.data : undefined; + const confirmed = new Map((summaries ?? []).filter((issue) => issue.companyId === companyId) + .map((issue) => [issue.id, issue])); const refreshedEntries = entries.map((entry) => { - const issue = issueById.get(entry.id); - if (!issue || issue.companyId !== companyId || issue.hiddenAt) return entry; - return mergeRecentTaskSnapshot(entry, issue); + const issue = confirmed.get(entry.id); + return issue ? mergeRecentTaskSnapshot(entry, issue) : entry; }); - const queryRevision = detailQueries - .map((query) => `${query.dataUpdatedAt}:${query.errorUpdatedAt}:${query.status}`) - .join("|"); + const processed = useRef<{ scope: string; revision: number } | null>(null); useEffect(() => { - if (!storageKey || !companyId || detailQueries.length === 0) return; - - const resolvedIssues: Issue[] = []; - const removeIds = new Set(); - detailQueries.forEach((query, index) => { - const entry = queryEntries[index]; - if (!entry) return; - if (query.data) { - if (query.data.companyId !== companyId || query.data.hiddenAt) { - removeIds.add(entry.id); - } else { - resolvedIssues.push(query.data); - } - } else if (query.error instanceof ApiError && [403, 404].includes(query.error.status)) { - removeIds.add(entry.id); - } - }); - - updateRecentTaskSnapshots(storageKey, companyId, resolvedIssues); - pruneRecentTasks(storageKey, companyId, removeIds); - // Publish only when queries or membership change. A storage notification must - // not feed the same cached query results back into storage in every tab. + if (!storageKey || !companyId || !summaries) return; + if (processed.current?.scope === scope && processed.current.revision === updateCount) return; + processed.current = { scope, revision: updateCount }; + const returnedIds = new Set(summaries.filter((issue) => issue.companyId === companyId).map((issue) => issue.id)); + updateRecentTaskSnapshots(storageKey, companyId, summaries); + const omittedIds = new Set(queryIds.filter((id) => !returnedIds.has(id))); + if (omittedIds.size && updateCount <= batchVisit.initialUpdateCount) { + void summaryQuery.refetch({ cancelRefetch: false }); + return; + } + // Never prune IDs added after this particular batch was requested. + pruneRecentTasks(storageKey, companyId, omittedIds); + // A storage notification must not republish the same successful revision. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [companyId, membership, queryRevision, storageKey]); + }, [batchVisit, companyId, fetchingSummaries, scope, updateCount, summaries, storageKey]); + + useEffect(() => { + if (!storageKey || !companyId || !summaries) return; + const authorizedIds = new Set(summaries.filter((issue) => issue.companyId === companyId).map((issue) => issue.id)); + const mergeCached = (value: unknown) => { + if (!value || typeof value !== "object") return; + const issue = value as Issue; + if (!authorizedIds.has(issue.id) || issue.companyId !== companyId || issue.hiddenAt + || typeof issue.title !== "string" || typeof issue.status !== "string" + || !issue.updatedAt || !Object.hasOwn(issue, "projectId")) return; + // At equal task versions, the summary's independently computed readiness + // is newer than an already-cached full detail response. + const summary = summaries.find((item) => item.id === issue.id); + if (!summary || new Date(issue.updatedAt).getTime() <= new Date(summary.updatedAt).getTime()) return; + updateRecentTaskSnapshots(storageKey, companyId, [issue]); + }; + for (const id of authorizedIds) mergeCached(queryClient.getQueryData(queryKeys.issues.detail(id))); + return queryClient.getQueryCache().subscribe((event) => { + if (event.type !== "updated" || event.action.type !== "success") return; + const key = event.query.queryKey; + if (key.length === 3 && key[0] === "issues" && key[1] === "detail" + && event.query.state.status === "success") mergeCached(event.query.state.data); + }); + }, [companyId, queryClient, summaries, storageKey]); const [settledOrder, setSettledOrder] = useState(() => entries.map((entry) => entry.id)); const activityRevision = JSON.stringify(entries.map((entry) => [entry.id, entry.recordedAt])); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 74c2634ec14..83d8ed828fe 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -361,6 +361,9 @@ export const queryKeys = { "execution-workspace", executionWorkspaceId, ] as const, + summaries: (companyId: string) => ["issues", companyId, "summaries"] as const, + summaryBatch: (companyId: string, userId: string | null | undefined, sortedIds: readonly string[]) => + ["issues", companyId, "summaries", userId ?? "__local_board__", sortedIds] as const, detail: (id: string) => ["issues", "detail", id] as const, comments: (issueId: string) => ["issues", "comments", issueId] as const, commentsList: (issueId: string) => diff --git a/ui/src/lib/recent-tasks.ts b/ui/src/lib/recent-tasks.ts index 33d8a77a918..a937505e25d 100644 --- a/ui/src/lib/recent-tasks.ts +++ b/ui/src/lib/recent-tasks.ts @@ -1,4 +1,4 @@ -import type { Issue, IssueStatus } from "@paperclipai/shared"; +import type { Issue, IssueStatus, IssueSummary } from "@paperclipai/shared"; export const RECENT_TASKS_LIMIT = 5; export const RECENT_TASKS_UPDATED_EVENT = "paperclip:recent-tasks-updated"; @@ -104,7 +104,7 @@ export function writeRecentTasks(storageKey: string, entries: RecentTaskEntry[]) publishRecentTasks(storageKey, bounded); } -type TaskSnapshot = Pick; +type TaskSnapshot = Omit & Pick; export function mergeRecentTaskSnapshot(entry: RecentTaskEntry, issue: TaskSnapshot): RecentTaskEntry { if (entry.id !== issue.id || entry.companyId !== issue.companyId) return entry;