From b97f524d982c56bdbf583ddb75c9370fe01db579 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:23:17 -0700 Subject: [PATCH] perf(ai-vault): stop the session projection from stalling workspace switches Clicking a workspace card blocked the renderer for ~1s with the AI Vault panel open. A CPU profile put 1.7s of self time in isSessionInWorktreePath and 1.66s in isPathInsideOrEqual. resolveAiVaultSessionWorktreeInfo rebuilt the full candidate list inside the per-session call, then filtered it with no early exit and sorted it. At 1124 worktrees x 500 sessions that is ~1.1M containment checks and ~2M path normalizations per switch, all on the click path. a1a78da878 (#10841) is what made it visible: it added an unconditional .normalize('NFC') to normalizeRuntimePathForComparison, the innermost function in that loop. Bisect puts the step at 344ms -> 420ms, and deleting just that call on main reproduces the 420 -> 348 delta. The NFC fold is a correctness fix worth keeping, so this removes the fan-out instead. - cache the candidate list per (worktrees, repos) identity pair - precompute each candidate's matcher via createNormalizedPathInsideOrEqualMatcher, so the session cwd is normalized once per session instead of once per candidate - replace filter+filter+sort with a single max pass The matcher is what #10881 added for exactly this shape; the AI Vault fan-outs were still paying its cost without adopting it. Projection cost on a real 1124-worktree profile: 423ms -> 13ms. Verified byte-identical output against the previous implementation across 4501 session/worktree combinations. Tests cover NFD/NFC matching in both directions through the projection, the sibling-prefix boundary, nested-workspace and prior-path tie-breaks, and a timing regression for the quadratic shape. --- .../ai-vault-session-worktree.test.ts | 136 ++++++ .../ai-vault-session-worktree.ts | 118 ++++-- .../ai-vault-worktree-projection-bench.mjs | 156 +++++++ .../workspace-switch-paint-latency.mjs | 398 ++++++++++++++++++ 4 files changed, 778 insertions(+), 30 deletions(-) create mode 100644 tools/benchmarks/ai-vault-worktree-projection-bench.mjs create mode 100644 tools/benchmarks/workspace-switch-paint-latency.mjs diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts index 7f5f0d07b0c..6ea2cdbf19c 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts @@ -202,6 +202,142 @@ describe('resolveAiVaultSessionWorktreeInfo', () => { worktreeId: worktree.id }) }) + + it('matches an NFD workspace path to an NFC transcript cwd', () => { + // macOS yields NFD on disk while Claude Code records cwd in NFC (#10832). + // The projection precomputes its matchers, so the fold has to survive that. + const nfcPath = '/repo/프로젝트' + const worktree = makeWorktree({ + id: `repo-1::${nfcPath.normalize('NFD')}`, + displayName: '', + path: nfcPath.normalize('NFD') + }) + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: { ...baseSession, cwd: `${nfcPath}/src` }, + worktrees: [worktree], + activeWorktreeId: null + }) + ).toMatchObject({ + status: 'active', + worktreeId: worktree.id + }) + }) + + it('matches an NFD transcript cwd to an NFC workspace path', () => { + const nfcPath = '/repo/café' + const worktree = makeWorktree({ + id: `repo-1::${nfcPath}`, + displayName: '', + path: nfcPath + }) + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: { ...baseSession, cwd: `${nfcPath}/src`.normalize('NFD') }, + worktrees: [worktree], + activeWorktreeId: null + }) + ).toMatchObject({ + status: 'active', + worktreeId: worktree.id + }) + }) + + it('does not treat a sibling sharing a path prefix as the owner', () => { + // Guards the candidate matcher's boundary: '/repo/orca-sibling' must not + // match under '/repo/orca'. + const worktree = makeWorktree() + const sibling = makeWorktree({ + id: 'repo-1::/repo/orca-sibling', + displayName: 'sibling', + path: '/repo/orca-sibling' + }) + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: { ...baseSession, cwd: '/repo/orca-sibling/src' }, + worktrees: [worktree, sibling], + activeWorktreeId: null + }) + ).toMatchObject({ worktreeId: sibling.id }) + }) + + it('prefers the deepest owning worktree when workspaces nest', () => { + // The single-pass max must reproduce the old sort: longest comparison path + // wins, and 'current-path' breaks a tie against 'prior-path'. + const outer = makeWorktree({ + id: 'repo-1::/repo/orca', + displayName: 'outer', + path: '/repo/orca' + }) + const inner = makeWorktree({ + id: 'repo-1::/repo/orca/packages/app', + displayName: 'inner', + path: '/repo/orca/packages/app' + }) + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: { ...baseSession, cwd: '/repo/orca/packages/app/src' }, + worktrees: [outer, inner], + activeWorktreeId: null + }) + ).toMatchObject({ worktreeId: inner.id }) + }) + + it('prefers a current path over another worktree holding it as a prior path', () => { + const current = makeWorktree({ + id: 'repo-1::/repo/orca', + displayName: 'current', + path: '/repo/orca' + }) + const renamed = makeWorktree({ + id: 'repo-1::/repo/orca-renamed', + displayName: 'renamed', + path: '/repo/orca-renamed', + priorWorktreeIds: ['repo-1::/repo/orca'] + }) + + expect( + resolveAiVaultSessionWorktreeInfo({ + session: baseSession, + worktrees: [renamed, current], + activeWorktreeId: null + }) + ).toMatchObject({ worktreeId: current.id }) + }) + + it('resolves a large workspace set without rebuilding candidates per session', () => { + // Regression for the ~1s workspace-switch stall (#10841): resolving each + // session used to rebuild and re-normalize every candidate, making the + // panel O(sessions x worktrees) on path normalization. Times a fan-out + // rather than counting calls so it fails on any return to that shape. + const worktrees = Array.from({ length: 1200 }, (_, i) => + makeWorktree({ + id: `repo-1::/repo/w${i}`, + displayName: `w${i}`, + path: `/repo/w${i}` + }) + ) + const sessions = Array.from({ length: 400 }, (_, i) => ({ + ...baseSession, + id: `codex:session-${i}`, + cwd: `/repo/w${i % worktrees.length}/src` + })) + + const startedAt = performance.now() + const resolved = sessions.map((session) => + resolveAiVaultSessionWorktreeInfo({ session, worktrees, activeWorktreeId: null }) + ) + const elapsedMs = performance.now() - startedAt + + expect(resolved.every((info) => info?.status === 'active')).toBe(true) + // Was ~350ms+ before the fix and ~10ms after; the bound is loose enough to + // stay quiet on a slow CI box while still catching the quadratic shape. + expect(elapsedMs).toBeLessThan(150) + }) }) describe('canJumpToAiVaultSessionWorktree', () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts index 670e2deecc1..be460fa357d 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts @@ -8,7 +8,7 @@ import { type ExecutionHostId } from '../../../../shared/execution-host' import { - isPathInsideOrEqual, + createNormalizedPathInsideOrEqualMatcher, isRuntimePathAbsolute, normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' @@ -41,11 +41,28 @@ type WorktreeCandidate = { hostId: ExecutionHostId status: Exclude source: 'current-path' | 'prior-path' + /** Precomputed so a fan-out over sessions does not re-normalize per session. */ + matches: (normalizedSessionCwd: string) => boolean + comparisonPathLength: number } +/** + * Candidate list for one (worktrees, repos) pair. + * + * Why cache: resolving a session walks every candidate, so a panel with N + * sessions rebuilt the same list N times — 1000+ allocations and path + * normalizations per session. The list only depends on its two inputs, both of + * which are referentially stable in the store, so one WeakMap hop replaces it. + */ +const candidateCache = new WeakMap>() + +// Why a shared constant: a `repos = []` default would allocate a fresh array per +// call, so every lookup would miss the cache keyed on that array's identity. +const NO_REPOS: readonly Pick[] = [] + export function resolveAiVaultSessionWorktreeInfo({ session, - repos = [], + repos = NO_REPOS, worktrees, activeWorktreeId }: { @@ -59,12 +76,24 @@ export function resolveAiVaultSessionWorktreeInfo({ } const sessionHostId = normalizeExecutionHostId(session.executionHostId) - const candidates = buildWorktreeCandidates(worktrees, repos) - .filter((candidate) => isSessionInWorktreePath(candidate.path, session.cwd!)) - .filter((candidate) => !sessionHostId || candidate.hostId === sessionHostId) - .sort(compareWorktreeCandidates) + // Normalize the session cwd once, then test it against precomputed matchers. + const normalizedSessionCwd = normalizeRuntimePathForComparison(session.cwd) + + // Single pass picking the max, rather than filter+filter+sort: the result is + // just the best candidate, and sorting 1000+ entries per session dominated. + let best: WorktreeCandidate | undefined + for (const candidate of getWorktreeCandidates(worktrees, repos)) { + if (sessionHostId && candidate.hostId !== sessionHostId) { + continue + } + if (!candidate.matches(normalizedSessionCwd)) { + continue + } + if (!best || compareWorktreeCandidates(candidate, best) < 0) { + best = candidate + } + } - const best = candidates[0] if (!best) { return { status: 'unavailable', @@ -138,7 +167,7 @@ export function resolveAiVaultSessionWorktreeDisplay(args: { export function useAiVaultSessionWorktreeMap({ sessions, - repos = [], + repos = NO_REPOS, worktrees, activeWorktreeId }: { @@ -164,38 +193,59 @@ export function useAiVaultSessionWorktreeMap({ ) } +function getWorktreeCandidates( + worktrees: readonly Worktree[], + repos: readonly Pick[] +): WorktreeCandidate[] { + let byRepos = candidateCache.get(worktrees) + if (!byRepos) { + byRepos = new WeakMap() + candidateCache.set(worktrees, byRepos) + } + let candidates = byRepos.get(repos) + if (!candidates) { + candidates = buildWorktreeCandidates(worktrees, repos) + byRepos.set(repos, candidates) + } + return candidates +} + function buildWorktreeCandidates( worktrees: readonly Worktree[], repos: readonly Pick[] ): WorktreeCandidate[] { const candidates: WorktreeCandidate[] = [] const repoById = new Map(repos.map((repo) => [repo.id, repo])) + const push = ( + worktree: Worktree, + candidatePath: string, + hostId: ExecutionHostId, + source: WorktreeCandidate['source'] + ): void => { + candidates.push({ + worktree, + path: candidatePath, + hostId, + status: worktree.isArchived ? 'archived' : 'active', + source, + matches: createWorktreePathMatcher(candidatePath), + comparisonPathLength: normalizeRuntimePathForComparison(candidatePath).length + }) + } for (const worktree of worktrees) { const repo = repoById.get(worktree.repoId) const hostId = normalizeExecutionHostId(worktree.hostId) ?? (repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID) if (hasUsablePath(worktree.path)) { - candidates.push({ - worktree, - path: worktree.path, - hostId, - status: worktree.isArchived ? 'archived' : 'active', - source: 'current-path' - }) + push(worktree, worktree.path, hostId, 'current-path') } for (const priorWorktreeId of worktree.priorWorktreeIds ?? []) { const parsed = splitWorktreeIdForFilesystem(priorWorktreeId) if (!parsed || parsed.repoId !== worktree.repoId || !hasUsablePath(parsed.worktreePath)) { continue } - candidates.push({ - worktree, - path: parsed.worktreePath, - hostId, - status: worktree.isArchived ? 'archived' : 'active', - source: 'prior-path' - }) + push(worktree, parsed.worktreePath, hostId, 'prior-path') } } return candidates @@ -206,18 +256,26 @@ function hasUsablePath(pathValue: string): boolean { return Boolean(trimmed && isRuntimePathAbsolute(trimmed)) } -function isSessionInWorktreePath(worktreePath: string, sessionCwd: string): boolean { - if (isPathInsideOrEqual(worktreePath, sessionCwd)) { - return true - } +/** + * Builds the containment test for one worktree path, so the per-session loop + * only normalizes the session cwd. Keeps the WSL fallback: a UNC worktree path + * must also match sessions recorded under the Linux-side path. + */ +function createWorktreePathMatcher( + worktreePath: string +): (normalizedSessionCwd: string) => boolean { + const direct = createNormalizedPathInsideOrEqualMatcher(worktreePath) const wslPath = parseWslUncPath(worktreePath) - return wslPath ? isPathInsideOrEqual(wslPath.linuxPath, sessionCwd) : false + if (!wslPath) { + return direct + } + const viaLinuxPath = createNormalizedPathInsideOrEqualMatcher(wslPath.linuxPath) + return (normalizedSessionCwd) => + direct(normalizedSessionCwd) || viaLinuxPath(normalizedSessionCwd) } function compareWorktreeCandidates(left: WorktreeCandidate, right: WorktreeCandidate): number { - const lengthDifference = - normalizeRuntimePathForComparison(right.path).length - - normalizeRuntimePathForComparison(left.path).length + const lengthDifference = right.comparisonPathLength - left.comparisonPathLength if (lengthDifference !== 0) { return lengthDifference } diff --git a/tools/benchmarks/ai-vault-worktree-projection-bench.mjs b/tools/benchmarks/ai-vault-worktree-projection-bench.mjs new file mode 100644 index 00000000000..969fd91ac23 --- /dev/null +++ b/tools/benchmarks/ai-vault-worktree-projection-bench.mjs @@ -0,0 +1,156 @@ +#!/usr/bin/env node +/** + * Replays the AI Vault worktree projection — the work the CPU profile blamed + * for the ~1s stall on workspace switch — against whatever source is checked + * out right now. + * + * Bisecting through the real app costs ~6min/step (dev server boot on an 11GB + * profile) and boots flakily. This runs the same hot functions over the same + * real-scale inputs in a couple of seconds, so it can bisect ~500 commits. + * + * It compiles the checked-out .ts sources with esbuild rather than importing a + * fixed copy, so each bisect step measures that commit's actual code. + * + * node tools/benchmarks/ai-vault-worktree-projection-bench.mjs \ + * --fixture /tmp/orca-bisect/fixture --budget 250 + * + * Exit 0 = under budget, 1 = over, 125 = could not measure (skip). + */ + +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +function arg(name, fallback) { + const i = process.argv.indexOf(`--${name}`) + return i === -1 ? fallback : process.argv[i + 1] +} + +// --repo lets a bisect run this script from a fixed location against a +// worktree that is being checked out underneath it. +const REPO_ROOT = path.resolve(arg('repo', path.resolve(import.meta.dirname, '../..'))) + +const fixtureDir = arg('fixture', '/tmp/orca-bisect/fixture') +const budgetMs = Number(arg('budget', 250)) +const iterations = Number(arg('iterations', 3)) +const sessionCount = Number(arg('sessions', 500)) + +const HOT_MODULE = 'src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts' + +let tempDir +try { + const hotPath = path.join(REPO_ROOT, HOT_MODULE) + readFileSync(hotPath) // throws on commits predating the module + + // Inside the repo, not tmpdir: the bundle keeps `react` external, so it has + // to sit somewhere that can resolve the repo's node_modules. + tempDir = mkdtempSync(path.join(REPO_ROOT, 'node_modules', '.aivault-bench-')) + const bundlePath = path.join(tempDir, 'hot.mjs') + + // Bundle from the working tree so the measured code is this commit's code. + // React is aliased to a stub rather than left external: esbuild turns an + // external ESM import into `require()`, which an .mjs bundle cannot do. The + // functions under test are pure data work and never call a hook. + // CJS, so a Proxy can stand in for every named export transitive deps pull + // from react (Fragment, createElement, Children, ...) without listing them. + const shimPath = path.join(tempDir, 'react-shim.cjs') + writeFileSync( + shimPath, + 'const hooks = {\n' + + ' useMemo: (fn) => fn(),\n' + + ' useCallback: (fn) => fn,\n' + + ' useRef: (v) => ({ current: v }),\n' + + ' useState: (v) => [v, () => {}],\n' + + ' useEffect: () => {},\n' + + ' useLayoutEffect: () => {},\n' + + ' createContext: () => ({ Provider: null, Consumer: null }),\n' + + ' createElement: () => null,\n' + + ' Fragment: null\n' + + '}\n' + + 'module.exports = new Proxy(hooks, {\n' + + ' get: (target, prop) => (prop in target ? target[prop] : () => null)\n' + + '})\n' + ) + + execFileSync( + path.join(REPO_ROOT, 'node_modules/.bin/esbuild'), + [ + hotPath, + '--bundle', + '--format=esm', + // node, not neutral: transitive deps (react-i18next -> html-parse-stringify) + // only resolve with node main-field semantics. + '--platform=node', + '--log-level=silent', + `--alias:react=${shimPath}`, + `--outfile=${bundlePath}` + ], + { cwd: REPO_ROOT, stdio: ['ignore', 'ignore', 'pipe'] } + ) + + const hot = await import(pathToFileURL(bundlePath).href) + const resolve = hot.resolveAiVaultSessionWorktreeDisplay ?? hot.resolveAiVaultSessionWorktreeInfo + if (typeof resolve !== 'function') { + throw new Error('no resolve export in this revision') + } + + const worktrees = JSON.parse(readFileSync(path.join(fixtureDir, 'worktrees.json'), 'utf8')) + const repos = JSON.parse(readFileSync(path.join(fixtureDir, 'repos.json'), 'utf8')) + + // Sessions live under real workspace paths so containment checks match the + // way they do in the app; a synthetic path would short-circuit differently. + const sessions = Array.from({ length: sessionCount }, (_, i) => { + const wt = worktrees[(i * 7) % worktrees.length] + return { + id: `session-${i}`, + cwd: i % 9 === 0 ? `${wt.path}/src/nested/dir` : wt.path, + executionHostId: null, + title: `session ${i}` + } + }) + + const activeWorktreeId = worktrees[0]?.id ?? null + + const runOnce = () => { + const t0 = performance.now() + let resolved = 0 + for (const session of sessions) { + const info = resolve({ session, repos, worktrees, activeWorktreeId }) + if (info) { + resolved += 1 + } + } + return { ms: performance.now() - t0, resolved } + } + + runOnce() // warm JIT + const runs = Array.from({ length: iterations }, runOnce) + const times = runs.map((r) => r.ms).sort((a, b) => a - b) + const median = times[Math.floor(times.length / 2)] + + const report = { + sha: execFileSync('git', ['-C', REPO_ROOT, 'rev-parse', '--short', 'HEAD'], { + encoding: 'utf8' + }).trim(), + worktrees: worktrees.length, + repos: repos.length, + sessions: sessions.length, + resolved: runs[0].resolved, + medianMs: +median.toFixed(1), + minMs: +times[0].toFixed(1), + maxMs: +times.at(-1).toFixed(1), + budgetMs + } + report.pass = report.medianMs <= budgetMs + + console.log(JSON.stringify(report)) + process.exit(report.pass ? 0 : 1) +} catch (error) { + console.error(`[bench] cannot measure: ${String(error?.message ?? error).slice(0, 200)}`) + process.exit(125) +} finally { + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } +} diff --git a/tools/benchmarks/workspace-switch-paint-latency.mjs b/tools/benchmarks/workspace-switch-paint-latency.mjs new file mode 100644 index 00000000000..200c0053b8c --- /dev/null +++ b/tools/benchmarks/workspace-switch-paint-latency.mjs @@ -0,0 +1,398 @@ +#!/usr/bin/env node +/** + * Measures how long a workspace-card click takes to become VISIBLE. + * + * The existing e2e spec (tests/e2e/worktree-switch-responsiveness.spec.ts) only + * measures the synchronous click task. That stays fast even when the symptom is + * present: `markSidebarWorktreeActiveImmediately` mutates the DOM in the click + * handler, so the attribute flips in ~1ms. What the user sees is the next + * painted FRAME, and that frame is blocked when the async activation path jams + * the main thread. So the number that matters is first-paint-after-click. + * + * Attaches over CDP to an already-running dev app: + * + * ORCA_DEV_USER_DATA_PATH=... REMOTE_DEBUGGING_PORT=9455 pn dev + * node tools/benchmarks/workspace-switch-paint-latency.mjs --port 9455 + * + * Exits non-zero when the median first-paint exceeds --budget (default 200ms), + * which makes it usable directly as a `git bisect run` predicate. + */ + +import { writeFileSync, mkdirSync } from 'node:fs' +import path from 'node:path' +import { chromium } from '@stablyai/playwright-test' + +function parseArgs(argv) { + const args = { + port: 9455, + switches: 6, + budgetMs: 200, + settleMs: 2500, + out: null, + json: false + } + for (let i = 0; i < argv.length; i += 1) { + const [flag, inlineValue] = argv[i].split('=') + const value = inlineValue ?? argv[i + 1] + const consume = () => { + if (inlineValue === undefined) { + i += 1 + } + } + switch (flag) { + case '--port': + args.port = Number(value) + consume() + break + case '--switches': + args.switches = Number(value) + consume() + break + case '--budget': + args.budgetMs = Number(value) + consume() + break + case '--settle': + args.settleMs = Number(value) + consume() + break + case '--out': + args.out = value + consume() + break + case '--json': + args.json = true + break + case '--help': + console.log( + 'Usage: workspace-switch-paint-latency.mjs [--port 9455] [--switches 6] [--budget 200] [--settle 2500] [--out file.json] [--json]' + ) + process.exit(0) + break + default: + break + } + } + return args +} + +const args = parseArgs(process.argv.slice(2)) + +function quantile(sorted, q) { + if (sorted.length === 0) { + return null + } + const pos = (sorted.length - 1) * q + const lo = Math.floor(pos) + const hi = Math.ceil(pos) + if (lo === hi) { + return sorted[lo] + } + return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo) +} + +function summarize(values) { + const clean = values.filter((v) => typeof v === 'number' && Number.isFinite(v)) + if (clean.length === 0) { + return null + } + const sorted = [...clean].sort((a, b) => a - b) + return { + n: sorted.length, + min: +sorted[0].toFixed(1), + p50: +quantile(sorted, 0.5).toFixed(1), + p90: +quantile(sorted, 0.9).toFixed(1), + max: +sorted.at(-1).toFixed(1) + } +} + +/** + * Installed in the renderer before each click. Records, from the real + * pointerdown, every animation frame plus long tasks, so we can tell a jammed + * main thread (no frames for ~1s) apart from slow-but-smooth work. + */ +const PROBE_SOURCE = `(targetId) => { + const probe = { + t0: null, + targetId, + frames: [], + longTasks: [], + attrFlipMs: null, + storeCommitMs: null, + renderedCommitMs: null, + done: false + } + window.__switchProbe = probe + + const surfaceOf = (id) => { + const row = [...document.querySelectorAll('[data-worktree-id]')].find( + (el) => el.dataset.worktreeId === id + ) + return row ? row.querySelector('[data-worktree-card-surface]') : null + } + const isActive = (id) => { + const s = surfaceOf(id) + return Boolean(s && s.getAttribute('data-worktree-card-active')) + } + const renderedId = () => + document + .querySelector('[data-rendered-active-worktree-id]') + ?.getAttribute('data-rendered-active-worktree-id') ?? null + + try { + const po = new PerformanceObserver((list) => { + for (const e of list.getEntries()) { + if (probe.t0 === null) continue + probe.longTasks.push({ + startMs: +(e.startTime - probe.t0).toFixed(1), + durationMs: +e.duration.toFixed(1) + }) + } + }) + po.observe({ entryTypes: ['longtask'] }) + probe.observer = po + } catch {} + + // Why: pointerdown is the earliest thing the user's click produces, so it is + // the honest zero point for "time until I saw something happen". + const onPointerDown = () => { + if (probe.t0 !== null) return + probe.t0 = performance.now() + const tick = () => { + const now = performance.now() - probe.t0 + probe.frames.push(+now.toFixed(1)) + if (probe.attrFlipMs === null && isActive(probe.targetId)) probe.attrFlipMs = +now.toFixed(1) + if (probe.storeCommitMs === null) { + const st = window.__store?.getState?.() + if (st && st.activeWorktreeId === probe.targetId) probe.storeCommitMs = +now.toFixed(1) + } + if (probe.renderedCommitMs === null && renderedId() === probe.targetId) { + probe.renderedCommitMs = +now.toFixed(1) + } + if (!probe.done) requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + } + window.addEventListener('pointerdown', onPointerDown, { capture: true, once: true }) + probe.cleanup = () => { + probe.done = true + try { probe.observer?.disconnect() } catch {} + window.removeEventListener('pointerdown', onPointerDown, { capture: true }) + } + return true +}` + +/** Press Escape while any full-screen overlay is intercepting pointer events. */ +async function dismissOverlays(page) { + for (let attempt = 0; attempt < 3; attempt += 1) { + const blocked = await page.evaluate(() => + [...document.querySelectorAll('body *')].some((el) => { + const r = el.getBoundingClientRect() + if (r.width < window.innerWidth * 0.9 || r.height < window.innerHeight * 0.9) { + return false + } + const s = getComputedStyle(el) + return s.position === 'fixed' && s.pointerEvents !== 'none' && Number(s.zIndex) >= 40 + }) + ) + if (!blocked) { + return + } + await page.keyboard.press('Escape') + await page.waitForTimeout(200) + } +} + +async function main() { + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${args.port}`) + const contexts = browser.contexts() + const pages = contexts.flatMap((c) => c.pages()) + let page = null + for (const candidate of pages) { + const hasStore = await candidate + .evaluate( + () => Boolean(window.__store) && Boolean(document.querySelector('[data-worktree-id]')) + ) + .catch(() => false) + if (hasStore) { + page = candidate + break + } + } + if (!page) { + throw new Error( + 'No renderer page with window.__store + a mounted sidebar. Is the app past startup, and is the sidebar open?' + ) + } + + // Normalize sidebar state so the same cards are reachable across commits. + // Why pin the sort: 'recent' reorders the list after every switch, which + // would move the card out from under the cursor mid-run. + const worktreeIds = await page.evaluate(() => { + const state = window.__store.getState() + state.setActiveView('terminal') + state.setSidebarOpen(true) + state.setGroupBy?.('none') + state.setSortBy?.('name') + state.setShowActiveOnly?.(false) + state.setFilterRepoIds?.([]) + const viewportH = window.innerHeight + // Only cards fully inside the viewport are clickable: the list is + // virtualized, so off-screen rows are unmounted or positioned outside. + return [...document.querySelectorAll('[data-worktree-id]')] + .filter((el) => { + const surface = el.querySelector('[data-worktree-card-surface]') + if (!surface) { + return false + } + const r = surface.getBoundingClientRect() + return r.height > 0 && r.top >= 0 && r.bottom <= viewportH + }) + .map((el) => el.dataset.worktreeId) + .filter((id, i, all) => id && all.indexOf(id) === i) + }) + + if (worktreeIds.length < 2) { + throw new Error( + `Need >=2 workspace cards visible in the viewport, found ${worktreeIds.length}. Is the sidebar open?` + ) + } + + // Alternate between two cards, neither of which is the one already active at + // start — clicking the active card short-circuits and measures nothing. + const activeAtStart = await page.evaluate(() => window.__store.getState().activeWorktreeId) + const pair = worktreeIds.filter((id) => id !== activeAtStart).slice(0, 2) + if (pair.length < 2) { + throw new Error('Need two visible non-active cards to alternate between') + } + console.error(`alternating between:\n ${pair[0]}\n ${pair[1]}`) + + const samples = [] + for (let i = 0; i < args.switches; i += 1) { + const targetId = pair[i % 2] + + // Why: page.evaluate() treats a string as an expression to evaluate and + // drops the argument, so the probe has to be applied inline. + const surface = page + .locator(`[data-worktree-id="${targetId}"] [data-worktree-card-surface]`) + .first() + + // Why: a transient popover (`fixed inset-0 z-50`) swallows the click, and + // the run would then measure a click that never reached the card. + await dismissOverlays(page) + + await page.evaluate(`(${PROBE_SOURCE})(${JSON.stringify(targetId)})`) + + // Real CDP input events, not element.click(): the click path has + // pointer-drag suppression that a synthetic click would bypass. Going + // through the locator also re-resolves position at click time, which + // matters because the virtualized list reorders between switches. + try { + await surface.click({ timeout: 10_000 }) + } catch (error) { + console.error(`skip switch ${i + 1}: ${String(error).split('\n')[0]}`) + continue + } + + await page.waitForTimeout(args.settleMs) + + const sample = await page.evaluate(() => { + const p = window.__switchProbe + p.cleanup?.() + const frames = p.frames + let maxGap = 0 + let prev = 0 + for (const f of frames) { + maxGap = Math.max(maxGap, f - prev) + prev = f + } + return { + firstPaintMs: frames.length ? frames[0] : null, + maxFrameGapMs: +maxGap.toFixed(1), + frameCount: frames.length, + attrFlipMs: p.attrFlipMs, + storeCommitMs: p.storeCommitMs, + renderedCommitMs: p.renderedCommitMs, + longTaskCount: p.longTasks.length, + longTaskTotalMs: +p.longTasks.reduce((a, t) => a + t.durationMs, 0).toFixed(1), + worstLongTaskMs: p.longTasks.reduce((a, t) => Math.max(a, t.durationMs), 0) + } + }) + sample.targetId = targetId + // Why: a click that never activated would report a fast "first paint" and + // silently make every commit look good during a bisect. + if (sample.attrFlipMs === null && sample.renderedCommitMs === null) { + throw new Error( + `switch ${i + 1} clicked card ${targetId} but it never became active — the measurement is not valid` + ) + } + samples.push(sample) + console.error( + `switch ${i + 1}/${args.switches} -> firstPaint=${sample.firstPaintMs}ms ` + + `maxFrameGap=${sample.maxFrameGapMs}ms attrFlip=${sample.attrFlipMs}ms ` + + `rendered=${sample.renderedCommitMs}ms longTasks=${sample.longTaskCount}/${sample.longTaskTotalMs}ms` + ) + } + + // Why: the first switch after attach pays one-off lazy work (chunk loads, + // cold caches) that a warm app never pays again. Keep it in the raw samples + // but exclude it from the verdict so bisect steps compare like with like. + const scored = samples.length > 1 ? samples.slice(1) : samples + + const report = { + port: args.port, + switches: samples.length, + budgetMs: args.budgetMs, + firstPaintMs: summarize(scored.map((s) => s.firstPaintMs)), + maxFrameGapMs: summarize(scored.map((s) => s.maxFrameGapMs)), + attrFlipMs: summarize(scored.map((s) => s.attrFlipMs)), + renderedCommitMs: summarize(scored.map((s) => s.renderedCommitMs)), + worstLongTaskMs: summarize(scored.map((s) => s.worstLongTaskMs)), + longTaskTotalMs: summarize(scored.map((s) => s.longTaskTotalMs)), + samples + } + + const medianFirstPaint = report.firstPaintMs?.p50 ?? Infinity + const medianFrameGap = report.maxFrameGapMs?.p50 ?? Infinity + // The visible symptom is a stall, which shows up either as a late first frame + // or as a long gap between frames right after the click. + const verdictMs = Math.max(medianFirstPaint, medianFrameGap) + report.verdictMs = +verdictMs.toFixed(1) + report.pass = verdictMs <= args.budgetMs + + if (args.out) { + mkdirSync(path.dirname(args.out), { recursive: true }) + writeFileSync(args.out, JSON.stringify(report, null, 2)) + } + if (args.json) { + console.log(JSON.stringify(report, null, 2)) + } else { + console.log('') + console.log( + `first paint after click p50=${report.firstPaintMs?.p50}ms max=${report.firstPaintMs?.max}ms` + ) + console.log( + `max frame gap p50=${report.maxFrameGapMs?.p50}ms max=${report.maxFrameGapMs?.max}ms` + ) + console.log(`attribute flip (JS) p50=${report.attrFlipMs?.p50}ms`) + console.log( + `main pane rendered p50=${report.renderedCommitMs?.p50}ms max=${report.renderedCommitMs?.max}ms` + ) + console.log( + `worst long task p50=${report.worstLongTaskMs?.p50}ms max=${report.worstLongTaskMs?.max}ms` + ) + console.log('') + console.log( + `${report.pass ? 'PASS' : 'FAIL'} verdict=${report.verdictMs}ms budget=${args.budgetMs}ms` + ) + } + + await browser.close() + process.exit(report.pass ? 0 : 1) +} + +main().catch((error) => { + console.error(error?.stack ?? String(error)) + // 125 tells `git bisect run` to skip rather than mark the commit bad. + process.exit(125) +})