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;