From 104f9655e43540dacdf3c5189681e06a280ec64e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:53:48 -0700 Subject: [PATCH 01/25] perf(git): answer remote-URL questions from one subprocess, not one per remote (#18158) Four copies of the same loop ran `git remote` and then a serial `git remote get-url ` per remote to answer "which remote has this URL". On a repo with 58 remotes that is 59 subprocesses -- measured at 1083 ms -- for one question, and worktree create asks it several times. `git remote -v` answers for every remote from one child, reporting the same insteadOf-expanded first fetch URL `get-url` prints. The batched `cat-file --batch-check` branch-conflict probe decides from stdout, but its WSL route was unfenced, so a login-shell fallback printed the distro banner onto the stream it parses. That broke the one-line-per-ref contract, made every batch undecided, and fell straight back to one `show-ref` per remote -- the cost the batch exists to remove. Measured at 58 remotes / 4346 branches, spawns and wall time: push-target remote scan 59 -> 1 (1083 ms -> 8 ms) branch-conflict probe 60 -> 3 (984 ms -> 43 ms) configured push target 123 -> 6 (2707 ms -> 157 ms) --- src/main/git/exact-ref-probe.ts | 6 +- src/main/git/remote.test.ts | 59 ++++++ src/main/git/remote.ts | 38 ++-- ...repo-branch-conflict-batched-probe.test.ts | 102 ++++++++++ src/main/git/repo-branch-conflict.ts | 12 +- src/main/git/upstream.test.ts | 10 + .../worktree-push-target-reconciliation.ts | 25 +-- .../worktree-push-target-remote-scan.test.ts | 178 ++++++++++++++++++ .../ipc/worktree-push-target-setup.test.ts | 10 + src/main/ipc/worktree-push-target-setup.ts | 53 ++---- ...remote-push-target-materialization.test.ts | 11 ++ src/relay/git-handler-push-target.test.ts | 11 ++ src/relay/git-handler-push-target.ts | 33 ++-- src/shared/git-binary-compatibility.test.ts | 36 ++++ .../git-configured-branch-target.test.ts | 175 +++++++++++++++++ src/shared/git-configured-branch-target.ts | 34 ++-- src/shared/git-remote-url-index.test.ts | 112 +++++++++++ src/shared/git-remote-url-index.ts | 64 +++++++ 18 files changed, 841 insertions(+), 128 deletions(-) create mode 100644 src/main/git/repo-branch-conflict-batched-probe.test.ts create mode 100644 src/main/ipc/worktree-push-target-remote-scan.test.ts create mode 100644 src/shared/git-configured-branch-target.test.ts create mode 100644 src/shared/git-remote-url-index.test.ts create mode 100644 src/shared/git-remote-url-index.ts diff --git a/src/main/git/exact-ref-probe.ts b/src/main/git/exact-ref-probe.ts index 96bb421b8e8..ce89fac3f69 100644 --- a/src/main/git/exact-ref-probe.ts +++ b/src/main/git/exact-ref-probe.ts @@ -157,7 +157,11 @@ export async function probeAnyExactRefBatched( } catch { return { found: false, unknown: true } } - const lines = stdout.split('\n').filter((line) => line.trim().length > 0) + // Trim per line so a CRLF-translating host's `\r` does not become part of the type. + const lines = stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) // One line per input, in order; a short read means the batch never answered for the rest. if (lines.length !== safeRefs.length) { return { found: false, unknown: true } diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index 11ac2c21264..feb237eb18a 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -193,6 +193,17 @@ describe('git remote operations', () => { if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'pr-pynickle-orca') { return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' } } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: [ + 'origin\thttps://github.com/stablyai/orca.git (fetch)', + 'origin\thttps://github.com/stablyai/orca.git (push)', + 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (fetch)', + 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (push)' + ].join('\n'), + stderr: '' + } + } if (args[0] === 'remote') { return { stdout: 'origin\npr-pynickle-orca\n', stderr: '' } } @@ -207,6 +218,54 @@ describe('git remote operations', () => { ) }) + // Regression: normalizing a URL-valued push remote used to run `git remote` and then a + // serial `git remote get-url` per remote -- 59 subprocesses on a 58-remote repo. + it('normalizes a URL-valued push remote from one remote table read at 58 remotes', async () => { + const remotes = [ + { name: 'origin', url: 'https://github.com/stablyai/orca.git' }, + ...Array.from({ length: 56 }, (_, index) => ({ + name: `pr-user${index}-orca`, + url: `https://github.com/user${index}/orca.git` + })), + { name: 'pr-pynickle-orca', url: 'https://github.com/pynickle/orca.git' } + ] + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'imp/chinese-translation\n', stderr: '' } + } + if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.remote')) { + return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' } + } + if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.merge')) { + return { stdout: 'refs/heads/imp/chinese-translation\n', stderr: '' } + } + if (args[0] === 'config') { + throw new Error(`config key is not set: ${args.join(' ')}`) + } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: remotes + .flatMap(({ name, url }) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join('\n'), + stderr: '' + } + } + if (args[0] === 'remote') { + throw new Error(`unexpected remote scan: ${args.join(' ')}`) + } + return { stdout: '', stderr: '' } + }) + + await gitPush('/repo', false) + + const remoteReads = gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'remote') + expect(remoteReads.map(([args]) => args)).toEqual([['remote', '-v']]) + expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( + ['push', '--set-upstream', 'pr-pynickle-orca', 'HEAD:imp/chinese-translation'], + { cwd: '/repo' } + ) + }) + it('uses an explicit push target even when it differs from the local branch name', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '', stderr: '' }) diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index 2baf3b77137..20cf8415d04 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -4,6 +4,7 @@ import { } from '../../shared/git-remote-error' import { resolveEffectiveGitUpstream } from '../../shared/git-effective-upstream' import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name' +import { findGitRemoteNameByFetchUrl } from '../../shared/git-remote-url-index' import type { GitPushTarget } from '../../shared/worktree/types' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' @@ -84,6 +85,8 @@ type ConfiguredPushRemote = { branchRemote: string | null } +// One `git remote -v` instead of `git remote` plus a serial `git remote get-url` +// per remote; both print the same insteadOf-expanded fetch URL. async function findRemoteNameForUrl( worktreePath: string, remoteUrl: string, @@ -91,30 +94,13 @@ async function findRemoteNameForUrl( ): Promise { try { const { stdout } = await gitExecFileAsync( - ['remote'], + ['remote', '-v'], gitOptionsForWorktree(worktreePath, options) ) - const remotes = stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - for (const remoteName of remotes) { - try { - const { stdout: urlStdout } = await gitExecFileAsync( - ['remote', 'get-url', remoteName], - gitOptionsForWorktree(worktreePath, options) - ) - if (urlStdout.trim() === remoteUrl) { - return remoteName - } - } catch { - // Ignore a remote that disappeared or has no fetch URL. - } - } + return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl) } catch { return null } - return null } async function normalizePushRemote( @@ -141,11 +127,17 @@ async function getConfiguredPushRemote( if (!remote) { return null } + const normalizedRemote = await normalizePushRemote(worktreePath, remote, options) + // The two usually name the same URL; resolving it twice reads the remote table twice. + if (!branchRemote) { + return { remote: normalizedRemote, branchRemote: null } + } return { - remote: await normalizePushRemote(worktreePath, remote, options), - branchRemote: branchRemote - ? await normalizePushRemote(worktreePath, branchRemote, options) - : null + remote: normalizedRemote, + branchRemote: + branchRemote === remote + ? normalizedRemote + : await normalizePushRemote(worktreePath, branchRemote, options) } } diff --git a/src/main/git/repo-branch-conflict-batched-probe.test.ts b/src/main/git/repo-branch-conflict-batched-probe.test.ts new file mode 100644 index 00000000000..e6e672c65a2 --- /dev/null +++ b/src/main/git/repo-branch-conflict-batched-probe.test.ts @@ -0,0 +1,102 @@ +// Why: the batched `cat-file --batch-check` conflict probe decides from stdout, so a +// WSL login-shell fallback that prints the distro banner onto that stream desynchronizes +// the one-line-per-ref contract. Every batch then came back undecided and fell through to +// one `show-ref` subprocess per remote -- the cost the batch exists to remove. These tests +// pin the fence request and the resulting subprocess count at 58 remotes. + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) + +vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) + +import { getBranchConflictKind } from './repo-branch-conflict' + +const REMOTES = Array.from({ length: 58 }, (_, index) => `r${index}`) +const BRANCH = 'user/feature' +const WSL_BANNER = + 'Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 5.15.167.4-microsoft-standard-WSL2 x86_64)\n' + + 'To run a command as administrator (user "root"), use "sudo ".\n' + +type GitExecOptions = { stdin?: string; captureWslLoginShellOutput?: boolean } + +/** + * Stand-in for a WSL-routed runner: the login shell prepends its banner to stdout unless + * the caller asked for the fenced form, which slices the payload back out. + */ +function installLoginShellRunner(): { argv: string[][] } { + const argv: string[][] = [] + gitExecFileAsyncMock.mockImplementation(async (args: string[], options: GitExecOptions = {}) => { + argv.push(args) + if (args[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (args[0] === 'remote') { + return { stdout: `${WSL_BANNER}${REMOTES.join('\n')}\n`, stderr: '' } + } + if (args[0] === 'show-ref') { + throw Object.assign(new Error('missing ref'), { code: 1, stderr: '' }) + } + if (args[0] === 'cat-file') { + const payload = `${(options.stdin ?? '') + .split('\n') + .filter(Boolean) + .map((ref) => `${ref} missing`) + .join('\n')}\n` + return { + stdout: options.captureWslLoginShellOutput ? payload : `${WSL_BANNER}${payload}`, + stderr: '' + } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + return { argv } +} + +function countSubcommand(argv: readonly string[][], subcommand: string): number { + return argv.filter((args) => args[0] === subcommand).length +} + +describe('getBranchConflictKind batched remote probe', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('asks the WSL login shell to fence the batch payload it parses', async () => { + installLoginShellRunner() + + await getBranchConflictKind('/repo', BRANCH) + + const batchCall = gitExecFileAsyncMock.mock.calls.find(([args]) => args[0] === 'cat-file') + expect(batchCall?.[1]).toMatchObject({ captureWslLoginShellOutput: true }) + }) + + it('answers from one batched subprocess instead of one show-ref per remote', async () => { + const { argv } = installLoginShellRunner() + + await expect(getBranchConflictKind('/repo', BRANCH)).resolves.toBeNull() + + expect(countSubcommand(argv, 'cat-file')).toBe(1) + expect(countSubcommand(argv, 'show-ref')).toBe(0) + }) + + it('still falls back to per-ref probes when the batch itself fails', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (args[0] === 'remote') { + return { stdout: `${REMOTES.join('\n')}\n`, stderr: '' } + } + if (args[0] === 'cat-file') { + throw new Error('cat-file is unavailable on this host') + } + if (args[0] === 'show-ref') { + return { stdout: '', stderr: '' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + await expect(getBranchConflictKind('/repo', BRANCH)).resolves.toBe('remote') + }) +}) diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts index c799d3770be..5c6e03b94b0 100644 --- a/src/main/git/repo-branch-conflict.ts +++ b/src/main/git/repo-branch-conflict.ts @@ -147,10 +147,12 @@ export function getBranchConflictKind( const execOptions = gitExecOptions(path, options) const runLocalGit = ( argv: string[], - commandOptions?: ExactRefProbeExecOptions & { stdin?: string } + commandOptions?: ExactRefProbeExecOptions & { stdin?: string }, + captureWslLoginShellOutput = false ): Promise<{ stdout: string }> => gitExecFileAsync(argv, { ...execOptions, + ...(captureWslLoginShellOutput ? { captureWslLoginShellOutput: true } : {}), ...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }), ...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }), ...(commandOptions?.stdin === undefined ? {} : { stdin: commandOptions.stdin }) @@ -160,7 +162,13 @@ export function getBranchConflictKind( branchName, allowedBaseRef, {}, - (argv, commandOptions) => runLocalGit(argv, commandOptions) + // Why fenced: the batch decides from stdout, and a WSL login-shell fallback writes + // the distro's rc/motd banner to that same stream. The extra lines break the + // one-line-per-ref contract, so every batch came back undecided and fell through to + // one `show-ref` subprocess per remote -- the exact cost the batch exists to remove. + // `show-ref --verify --quiet` prints nothing and is read by exit code, so it needs + // no fence; the capture wrapper preserves the payload's exit status either way. + (argv, commandOptions) => runLocalGit(argv, commandOptions, true) ) } diff --git a/src/main/git/upstream.test.ts b/src/main/git/upstream.test.ts index 701808a8a31..b7644ad5543 100644 --- a/src/main/git/upstream.test.ts +++ b/src/main/git/upstream.test.ts @@ -363,6 +363,16 @@ describe('getUpstreamStatus', () => { if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'pr-pynickle-orca') { return Promise.resolve({ stdout: 'https://github.com/pynickle/orca.git\n' }) } + if (args[0] === 'remote' && args[1] === '-v') { + return Promise.resolve({ + stdout: [ + 'origin\thttps://github.com/stablyai/orca.git (fetch)', + 'origin\thttps://github.com/stablyai/orca.git (push)', + 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (fetch)', + 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (push)' + ].join('\n') + }) + } if (args[0] === 'remote') { return Promise.resolve({ stdout: 'origin\npr-pynickle-orca\n' }) } diff --git a/src/main/ipc/worktree-push-target-reconciliation.ts b/src/main/ipc/worktree-push-target-reconciliation.ts index e59da7bdfe7..b28ae36b6f4 100644 --- a/src/main/ipc/worktree-push-target-reconciliation.ts +++ b/src/main/ipc/worktree-push-target-reconciliation.ts @@ -13,7 +13,7 @@ import { listWorktrees } from '../git/worktree' import type { SshGitProvider } from '../providers/ssh-git-provider' import type { GitPushTarget } from '../../shared/worktree/types' import { WORKTREE_ID_SEPARATOR, worktreeIdComparisonKey } from '../../shared/worktree/id' -import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner' +import { parseGitRemoteFetchUrls } from '../../shared/git-remote-url-index' import { findWorktreeMetaReferencingRemote, hasBranchConfigUsingRemote, @@ -44,26 +44,9 @@ async function listPrRemoteCandidates( } catch { return [] } - const candidates = new Map() - for (const line of iterateProcessOutputLines(stdout)) { - const parsed = parseRemoteVerboseLine(line) - if (parsed?.direction === 'fetch' && isOrcaGeneratedPrRemoteName(parsed.name)) { - candidates.set(parsed.name, parsed.url) - } - } - return [...candidates.entries()].map(([name, url]) => ({ name, url })) -} - -function parseRemoteVerboseLine( - line: string -): { name: string; url: string; direction: 'fetch' | 'push' } | null { - const tabIndex = line.indexOf('\t') - if (tabIndex === -1) { - return null - } - const name = line.slice(0, tabIndex) - const match = /^(.*) \((fetch|push)\)$/.exec(line.slice(tabIndex + 1).trim()) - return match ? { name, url: match[1], direction: match[2] as 'fetch' | 'push' } : null + return [...parseGitRemoteFetchUrls(stdout)] + .filter(([name]) => isOrcaGeneratedPrRemoteName(name)) + .map(([name, url]) => ({ name, url })) } async function shouldReclaimPrRemote( diff --git a/src/main/ipc/worktree-push-target-remote-scan.test.ts b/src/main/ipc/worktree-push-target-remote-scan.test.ts new file mode 100644 index 00000000000..f2392c5e71a --- /dev/null +++ b/src/main/ipc/worktree-push-target-remote-scan.test.ts @@ -0,0 +1,178 @@ +// Why: `findRemoteForUrl` used to run `git remote` and then one serial +// `git remote get-url` per remote. These tests pin both halves of the fix: the +// subprocess count at 58 remotes, and result-for-result parity with the old scan +// across the remote shapes a real repo produces. + +import { describe, expect, it } from 'vitest' +import { parseGitHubOwnerRepo } from '../github/gh-utils' +import { findRemoteForUrl } from './worktree-push-target-setup' +import type { GitRemoteExec } from './worktree-push-target-cleanup' + +const SSH_FORK = 'git@github.com:contributor/orca.git' +const HTTPS_FORK = 'https://github.com/contributor/orca.git' +const GITLAB_FORK = 'https://gitlab.com/contributor/orca.git' +const UPSTREAM = 'https://github.com/stablyai/orca.git' + +type RemoteRow = { name: string; fetchUrl: string; pushUrl?: string } + +type CountingExec = GitRemoteExec & { spawns: string[][] } + +function makeExec(remotes: readonly RemoteRow[]): CountingExec { + const spawns: string[][] = [] + const exec: GitRemoteExec = async (args: string[]) => { + spawns.push(args) + if (args[0] === 'remote' && args.length === 1) { + return { stdout: `${remotes.map((remote) => remote.name).join('\n')}\n` } + } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: remotes + .flatMap((remote) => [ + `${remote.name}\t${remote.fetchUrl} (fetch)`, + `${remote.name}\t${remote.pushUrl ?? remote.fetchUrl} (push)` + ]) + .join('\n') + } + } + if (args[0] === 'remote' && args[1] === 'get-url') { + const match = remotes.find((remote) => remote.name === args[2]) + if (!match) { + throw new Error(`No such remote ${args[2]}`) + } + return { stdout: `${match.fetchUrl}\n` } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + } + return Object.assign(exec, { spawns }) +} + +/** The pre-fix scan, kept as the oracle the batched form must reproduce exactly. */ +async function findRemoteForUrlPerRemote( + execGit: GitRemoteExec, + repoPath: string, + remoteUrl: string +): Promise { + const target = parseGitHubOwnerRepo(remoteUrl) + try { + const { stdout } = await execGit(['remote'], repoPath) + for (const remote of stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean)) { + try { + const { stdout: urlStdout } = await execGit(['remote', 'get-url', remote], repoPath) + const candidateUrl = urlStdout.trim() + const candidate = parseGitHubOwnerRepo(candidateUrl) + if ( + target && + candidate && + target.owner.toLowerCase() === candidate.owner.toLowerCase() && + target.repo.toLowerCase() === candidate.repo.toLowerCase() + ) { + return remote + } + if (candidateUrl === remoteUrl) { + return remote + } + } catch { + // Ignore a remote that disappeared or has no fetch URL. + } + } + } catch { + return null + } + return null +} + +const fiftyEightRemotes: RemoteRow[] = [ + { name: 'origin', fetchUrl: UPSTREAM }, + ...Array.from({ length: 56 }, (_, index) => ({ + name: `pr-user${index}-orca`, + fetchUrl: `https://github.com/user${index}/orca.git` + })), + { name: 'pr-contributor-orca', fetchUrl: SSH_FORK } +] + +const matrix: { name: string; remotes: RemoteRow[]; lookupUrl: string }[] = [ + { name: 'no remotes', remotes: [], lookupUrl: SSH_FORK }, + { + name: 'one matching remote', + remotes: [{ name: 'origin', fetchUrl: SSH_FORK }], + lookupUrl: SSH_FORK + }, + { + name: 'one non-matching remote', + remotes: [{ name: 'origin', fetchUrl: UPSTREAM }], + lookupUrl: SSH_FORK + }, + { name: '58 remotes, match last', remotes: fiftyEightRemotes, lookupUrl: SSH_FORK }, + { + name: '58 remotes, no match', + remotes: fiftyEightRemotes, + lookupUrl: 'https://github.com/nobody/other.git' + }, + { + name: 'duplicate URLs on two remotes', + remotes: [ + { name: 'origin', fetchUrl: UPSTREAM }, + { name: 'fork-a', fetchUrl: SSH_FORK }, + { name: 'fork-b', fetchUrl: SSH_FORK } + ], + lookupUrl: SSH_FORK + }, + { + name: 'fetch and push URLs differ', + remotes: [{ name: 'split', fetchUrl: SSH_FORK, pushUrl: HTTPS_FORK }], + lookupUrl: SSH_FORK + }, + { + name: 'SSH-form lookup against an HTTPS-form remote', + remotes: [ + { name: 'origin', fetchUrl: UPSTREAM }, + { name: 'fork', fetchUrl: HTTPS_FORK } + ], + lookupUrl: SSH_FORK + }, + { + name: 'HTTPS-form lookup against an SSH-form remote', + remotes: [ + { name: 'origin', fetchUrl: UPSTREAM }, + { name: 'fork', fetchUrl: SSH_FORK } + ], + lookupUrl: HTTPS_FORK + }, + { + name: 'non-GitHub provider matches only on the exact URL', + remotes: [{ name: 'gitlab-fork', fetchUrl: GITLAB_FORK }], + lookupUrl: GITLAB_FORK + }, + { + name: 'non-GitHub provider with a different host does not match', + remotes: [{ name: 'gitlab-fork', fetchUrl: GITLAB_FORK }], + lookupUrl: 'https://bitbucket.org/contributor/orca.git' + } +] + +describe('findRemoteForUrl', () => { + it.each(matrix)('matches the per-remote scan for $name', async ({ remotes, lookupUrl }) => { + const expected = await findRemoteForUrlPerRemote(makeExec(remotes), '/repo', lookupUrl) + await expect(findRemoteForUrl(makeExec(remotes), '/repo', lookupUrl)).resolves.toBe(expected) + }) + + it('answers from one subprocess at 58 remotes instead of one per remote', async () => { + const legacyExec = makeExec(fiftyEightRemotes) + await findRemoteForUrlPerRemote(legacyExec, '/repo', 'https://github.com/nobody/other.git') + expect(legacyExec.spawns).toHaveLength(fiftyEightRemotes.length + 1) + + const exec = makeExec(fiftyEightRemotes) + await findRemoteForUrl(exec, '/repo', 'https://github.com/nobody/other.git') + expect(exec.spawns).toEqual([['remote', '-v']]) + }) + + it('returns null when the remote table cannot be read', async () => { + const failing: GitRemoteExec = async () => { + throw new Error('not a git repository') + } + await expect(findRemoteForUrl(failing, '/repo', SSH_FORK)).resolves.toBeNull() + }) +}) diff --git a/src/main/ipc/worktree-push-target-setup.test.ts b/src/main/ipc/worktree-push-target-setup.test.ts index 2d9670c2f9b..ef1e44aa923 100644 --- a/src/main/ipc/worktree-push-target-setup.test.ts +++ b/src/main/ipc/worktree-push-target-setup.test.ts @@ -16,6 +16,13 @@ const REPO = '/repo-root' const FORK_SSH = 'git@github.com:contributor/orca.git' const FORK_HTTPS = 'https://github.com/contributor/orca.git' +/** Real `git remote -v` shape: a fetch row and a push row per remote, tab-separated. */ +export function renderRemoteVerbose(remotes: Record): string { + return Object.entries(remotes) + .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join('\n') +} + // A stateful fake git: `remotes` maps name -> url. `remote add` mutates it so // later lookups see the new remote, matching real git behavior. Defaults // `symbolic-ref --short HEAD` to a real branch name, since a worktree's HEAD @@ -31,6 +38,9 @@ function makeRepoExec( if (args[0] === 'remote' && args.length === 1) { return { stdout: Object.keys(remotes).join('\n'), stderr: '' } } + if (args[0] === 'remote' && args[1] === '-v' && args.length === 2) { + return { stdout: renderRemoteVerbose(remotes), stderr: '' } + } if (args[0] === 'remote' && args[1] === 'get-url') { const url = remotes[args[2]!] if (!url) { diff --git a/src/main/ipc/worktree-push-target-setup.ts b/src/main/ipc/worktree-push-target-setup.ts index 59ef4c8fea5..c4a82d94933 100644 --- a/src/main/ipc/worktree-push-target-setup.ts +++ b/src/main/ipc/worktree-push-target-setup.ts @@ -5,53 +5,33 @@ // repo. The store-aware ownership decision stays with the caller via a predicate. import type { GitPushTarget } from '../../shared/worktree/types' -import { parseGitHubOwnerRepo } from '../github/gh-utils' -import type { GitRemoteExec } from './worktree-push-target-cleanup' +import { findGitRemoteNameByFetchUrl } from '../../shared/git-remote-url-index' +import { sameGitHubRemoteUrl, type GitRemoteExec } from './worktree-push-target-cleanup' import { buildNarrowForkFetchRefspec, ensureRemoteTracksBranchNarrowly } from '../git/fork-remote-refspec' +// One `git remote -v` replaces `git remote` plus a serial `git remote get-url` per +// remote -- 59 subprocesses at 58 remotes, on every push-target resolution (#17914). export async function findRemoteForUrl( execGit: GitRemoteExec, repoPath: string, remoteUrl: string ): Promise { - const target = parseGitHubOwnerRepo(remoteUrl) try { - const { stdout } = await execGit(['remote'], repoPath) - for (const remote of stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean)) { - try { - const { stdout: urlStdout } = await execGit(['remote', 'get-url', remote], repoPath) - const candidateUrl = urlStdout.trim() - const candidate = parseGitHubOwnerRepo(candidateUrl) - if ( - target && - candidate && - target.owner.toLowerCase() === candidate.owner.toLowerCase() && - target.repo.toLowerCase() === candidate.repo.toLowerCase() - ) { - return remote - } - if (candidateUrl === remoteUrl) { - return remote - } - } catch { - // Ignore a remote that disappeared or has no fetch URL. - } - } + const { stdout } = await execGit(['remote', '-v'], repoPath) + return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => + sameGitHubRemoteUrl(candidateUrl, remoteUrl) + ) } catch { return null } - return null } // O(1) probe used before materializing on demand (push/pull/fetch/fast-forward): -// a single `remote get-url ` avoids the O(remotes) `findRemoteForUrl` scan -// once a fork remote already exists under its expected name (#17828). +// a single `remote get-url ` skips the whole-remote-table read once a fork +// remote already exists under its expected name (#17828). export async function remoteAlreadyMatchesUrl( execGit: GitRemoteExec, repoPath: string, @@ -60,18 +40,7 @@ export async function remoteAlreadyMatchesUrl( ): Promise { try { const { stdout } = await execGit(['remote', 'get-url', remoteName], repoPath) - const candidateUrl = stdout.trim() - if (candidateUrl === remoteUrl) { - return true - } - const target = parseGitHubOwnerRepo(remoteUrl) - const candidate = parseGitHubOwnerRepo(candidateUrl) - return Boolean( - target && - candidate && - target.owner.toLowerCase() === candidate.owner.toLowerCase() && - target.repo.toLowerCase() === candidate.repo.toLowerCase() - ) + return sameGitHubRemoteUrl(stdout.trim(), remoteUrl) } catch { return false } diff --git a/src/main/ipc/worktree-remote-push-target-materialization.test.ts b/src/main/ipc/worktree-remote-push-target-materialization.test.ts index 68ed98ab35c..695baa10dec 100644 --- a/src/main/ipc/worktree-remote-push-target-materialization.test.ts +++ b/src/main/ipc/worktree-remote-push-target-materialization.test.ts @@ -553,6 +553,17 @@ describe('materializeWorktreePushTargetRemoteSsh', () => { } throw new Error('No such remote') } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: [ + 'origin\thttps://github.com/stablyai/orca.git (fetch)', + 'origin\thttps://github.com/stablyai/orca.git (push)', + `${SIBLING_REMOTE}\t${FORK_URL} (fetch)`, + `${SIBLING_REMOTE}\t${FORK_URL} (push)` + ].join('\n'), + stderr: '' + } + } if (args[0] === 'remote' && args.length === 1) { return { stdout: `origin\n${SIBLING_REMOTE}\n`, stderr: '' } } diff --git a/src/relay/git-handler-push-target.test.ts b/src/relay/git-handler-push-target.test.ts index c040a645a16..b6fe5e96ad0 100644 --- a/src/relay/git-handler-push-target.test.ts +++ b/src/relay/git-handler-push-target.test.ts @@ -46,6 +46,17 @@ function gitForConfig(config: { } return { stdout: `${config.base ?? ''}\n`, stderr: '' } } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: (config.remotes ?? []) + .flatMap((name) => { + const url = config.remoteUrls?.[name] ?? '' + return [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`] + }) + .join('\n'), + stderr: '' + } + } if (args[0] === 'remote' && args.length === 1) { return { stdout: `${config.remotes?.join('\n') ?? ''}\n`, stderr: '' } } diff --git a/src/relay/git-handler-push-target.ts b/src/relay/git-handler-push-target.ts index 40765491c56..d81111d45d3 100644 --- a/src/relay/git-handler-push-target.ts +++ b/src/relay/git-handler-push-target.ts @@ -1,5 +1,6 @@ import { assertGitPushTargetShape } from '../shared/git-push-target-validation' import { gitRefTargetsBranchOnRemote } from '../shared/git-remote-branch-name' +import { findGitRemoteNameByFetchUrl } from '../shared/git-remote-url-index' import type { GitPushTarget } from '../shared/worktree/types' type RelayGit = (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> @@ -67,31 +68,19 @@ type ConfiguredPushRemote = { branchRemote: string | null } +// Host-side twin of `src/main/git/remote.ts`: one `git remote -v` instead of +// `git remote` plus a serial `git remote get-url` per remote. async function findRemoteNameForUrl( git: RelayGit, worktreePath: string, remoteUrl: string ): Promise { try { - const { stdout } = await git(['remote'], worktreePath) - const remotes = stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - for (const remoteName of remotes) { - try { - const { stdout: urlStdout } = await git(['remote', 'get-url', remoteName], worktreePath) - if (urlStdout.trim() === remoteUrl) { - return remoteName - } - } catch { - // Ignore a remote that disappeared or has no fetch URL. - } - } + const { stdout } = await git(['remote', '-v'], worktreePath) + return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl) } catch { return null } - return null } async function normalizePushRemote( @@ -120,9 +109,17 @@ async function getConfiguredPushRemote( if (!remote) { return null } + const normalizedRemote = await normalizePushRemote(git, worktreePath, remote) + // The two usually name the same URL; resolving it twice reads the remote table twice. + if (!branchRemote) { + return { remote: normalizedRemote, branchRemote: null } + } return { - remote: await normalizePushRemote(git, worktreePath, remote), - branchRemote: branchRemote ? await normalizePushRemote(git, worktreePath, branchRemote) : null + remote: normalizedRemote, + branchRemote: + branchRemote === remote + ? normalizedRemote + : await normalizePushRemote(git, worktreePath, branchRemote) } } diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index a61e64464da..5ad37398d14 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -15,6 +15,7 @@ import { isUnsupportedWorktreeListZError } from './git-worktree-command-capabilities' import { gitCredentialPromptGuardEnv } from './git-credential-prompt-env' +import { parseGitRemoteFetchUrls } from './git-remote-url-index' import { GIT_HISTORY_COMMIT_FORMAT, parseGitHistoryLog } from './git-history-log-parser' import { githubPullRequestHeadLocalRef, @@ -214,6 +215,41 @@ describeBinaryCompatibility('real Git binary compatibility', () => { await expect(runGit(['merge-base', '--end-of-options', head, unrelated])).rejects.toBeDefined() }) + // Why pin this: Orca answers "which remote has this URL" from one `git remote -v` + // instead of one `git remote get-url` per remote. That is only equivalent if both + // commands report the same URL — the insteadOf-expanded first `remote..url`, + // which a raw config read does not produce — on every supported Git. + it('reports the same fetch URL from remote -v as from remote get-url', async () => { + await runGit(['config', 'url.git@example.invalid:.insteadOf', 'https://example.invalid/']) + await runGit(['remote', 'add', 'compat-single', 'https://example.invalid/a/repo.git']) + await runGit(['remote', 'add', 'compat-multi', 'https://example.invalid/b/repo.git']) + await runGit([ + 'config', + '--add', + 'remote.compat-multi.url', + 'https://example.invalid/b2/repo.git' + ]) + await runGit([ + 'config', + 'remote.compat-multi.pushurl', + 'https://push.example.invalid/b/repo.git' + ]) + try { + const fetchUrls = parseGitRemoteFetchUrls((await runGit(['remote', '-v'])).stdout) + for (const name of ['compat-single', 'compat-multi']) { + const getUrl = (await runGit(['remote', 'get-url', name])).stdout.trim() + expect(fetchUrls.get(name)).toBe(getUrl) + } + expect(fetchUrls.get('compat-single')).toBe('git@example.invalid:a/repo.git') + // A `pushurl` must not displace the fetch URL the scan compares against. + expect(fetchUrls.get('compat-multi')).toBe('git@example.invalid:b/repo.git') + } finally { + await runGit(['remote', 'remove', 'compat-single']) + await runGit(['remote', 'remove', 'compat-multi']) + await runGit(['config', '--unset-all', 'url.git@example.invalid:.insteadOf']) + } + }) + it('recognizes ref and merge-tree compatibility boundaries', async () => { const fetchHeadPath = join(repoPath, '.git', 'FETCH_HEAD') await writeFile(fetchHeadPath, 'sentinel\n') diff --git a/src/shared/git-configured-branch-target.test.ts b/src/shared/git-configured-branch-target.test.ts new file mode 100644 index 00000000000..a599a53bbb8 --- /dev/null +++ b/src/shared/git-configured-branch-target.test.ts @@ -0,0 +1,175 @@ +// Why: resolving a URL-valued `branch..remote` (or `remote.pushDefault`) to a +// remote name used to cost `git remote` plus one serial `git remote get-url` per remote. +// `hasConfiguredBranchPushTarget` resolves up to two of them, so a 58-remote repo paid +// up to 118 subprocesses for one question. These tests pin the count and result parity. + +import { describe, expect, it } from 'vitest' +import { + getConfiguredBranchRemoteUpstream, + hasConfiguredBranchPushTarget +} from './git-configured-branch-target' + +const BRANCH = 'imp/translation' +const FORK_URL = 'https://github.com/contributor/orca.git' +const UPSTREAM_URL = 'https://github.com/stablyai/orca.git' + +type RemoteRow = { name: string; fetchUrl: string; pushUrl?: string } + +type Fixture = { + remotes: readonly RemoteRow[] + config: Readonly> +} + +function makeRunner(fixture: Fixture): { + runGit: (args: string[]) => Promise<{ stdout: string }> + spawns: string[][] +} { + const spawns: string[][] = [] + const runGit = async (args: string[]): Promise<{ stdout: string }> => { + spawns.push(args) + if (args[0] === 'config' && args[1] === '--get') { + const value = fixture.config[args[2]] + if (value === undefined) { + throw Object.assign(new Error('config key is not set'), { code: 1 }) + } + return { stdout: `${value}\n` } + } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: fixture.remotes + .flatMap((remote) => [ + `${remote.name}\t${remote.fetchUrl} (fetch)`, + `${remote.name}\t${remote.pushUrl ?? remote.fetchUrl} (push)` + ]) + .join('\n') + } + } + if (args[0] === 'remote' && args.length === 1) { + return { stdout: `${fixture.remotes.map((remote) => remote.name).join('\n')}\n` } + } + if (args[0] === 'remote' && args[1] === 'get-url') { + const match = fixture.remotes.find((remote) => remote.name === args[2]) + if (!match) { + throw new Error(`No such remote ${args[2]}`) + } + return { stdout: `${match.fetchUrl}\n` } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + } + return { runGit, spawns } +} + +const fiftyEightRemotes: RemoteRow[] = [ + { name: 'origin', fetchUrl: UPSTREAM_URL }, + ...Array.from({ length: 56 }, (_, index) => ({ + name: `pr-user${index}-orca`, + fetchUrl: `https://github.com/user${index}/orca.git` + })), + { name: 'pr-contributor-orca', fetchUrl: FORK_URL } +] + +describe('hasConfiguredBranchPushTarget', () => { + it('resolves both URL-valued remotes from one remote table read at 58 remotes', async () => { + const { runGit, spawns } = makeRunner({ + remotes: fiftyEightRemotes, + config: { + [`branch.${BRANCH}.pushRemote`]: FORK_URL, + [`branch.${BRANCH}.remote`]: FORK_URL, + [`branch.${BRANCH}.merge`]: `refs/heads/${BRANCH}` + } + }) + + await expect(hasConfiguredBranchPushTarget(runGit, BRANCH)).resolves.toBe(true) + + // Both the push remote and the branch remote name the same URL, so one table read answers. + expect(spawns.filter((args) => args[0] === 'remote')).toEqual([['remote', '-v']]) + expect(spawns.filter((args) => args[1] === 'get-url')).toEqual([]) + }) + + it('keeps the not-set case false when no remote is configured', async () => { + const { runGit } = makeRunner({ remotes: fiftyEightRemotes, config: {} }) + await expect(hasConfiguredBranchPushTarget(runGit, BRANCH)).resolves.toBe(false) + }) + + it('keeps the URL itself as the remote name when nothing matches', async () => { + const { runGit } = makeRunner({ + remotes: [{ name: 'origin', fetchUrl: UPSTREAM_URL }], + config: { + [`branch.${BRANCH}.pushRemote`]: FORK_URL, + [`branch.${BRANCH}.remote`]: FORK_URL, + [`branch.${BRANCH}.merge`]: 'refs/heads/other' + } + }) + // Unchanged no-match fallback: both remotes stay the raw URL, so they still agree + // and the differently named merge branch is still pushable. + await expect(hasConfiguredBranchPushTarget(runGit, BRANCH)).resolves.toBe(true) + }) +}) + +describe('getConfiguredBranchRemoteUpstream', () => { + const remoteTrackingRefExists = async (): Promise => true + + it('picks the first remote holding a duplicated URL', async () => { + const { runGit, spawns } = makeRunner({ + remotes: [ + { name: 'origin', fetchUrl: UPSTREAM_URL }, + { name: 'fork-a', fetchUrl: FORK_URL }, + { name: 'fork-b', fetchUrl: FORK_URL } + ], + config: { + [`branch.${BRANCH}.remote`]: FORK_URL, + [`branch.${BRANCH}.merge`]: `refs/heads/${BRANCH}` + } + }) + + await expect( + getConfiguredBranchRemoteUpstream(runGit, BRANCH, remoteTrackingRefExists) + ).resolves.toEqual({ + upstreamName: `fork-a/${BRANCH}`, + remoteName: 'fork-a', + branchName: BRANCH, + isConfiguredUpstream: false + }) + expect(spawns.filter((args) => args[0] === 'remote')).toEqual([['remote', '-v']]) + }) + + it('ignores a push URL when fetch and push differ', async () => { + const { runGit } = makeRunner({ + remotes: [{ name: 'split', fetchUrl: UPSTREAM_URL, pushUrl: FORK_URL }], + config: { + [`branch.${BRANCH}.remote`]: FORK_URL, + [`branch.${BRANCH}.merge`]: `refs/heads/${BRANCH}` + } + }) + await expect( + getConfiguredBranchRemoteUpstream(runGit, BRANCH, remoteTrackingRefExists) + ).resolves.toBeNull() + }) + + it('returns null with no remotes at all', async () => { + const { runGit } = makeRunner({ + remotes: [], + config: { + [`branch.${BRANCH}.remote`]: FORK_URL, + [`branch.${BRANCH}.merge`]: `refs/heads/${BRANCH}` + } + }) + await expect( + getConfiguredBranchRemoteUpstream(runGit, BRANCH, remoteTrackingRefExists) + ).resolves.toBeNull() + }) + + it('keeps a plain named remote untouched', async () => { + const { runGit, spawns } = makeRunner({ + remotes: fiftyEightRemotes, + config: { + [`branch.${BRANCH}.remote`]: 'origin', + [`branch.${BRANCH}.merge`]: `refs/heads/${BRANCH}` + } + }) + await expect( + getConfiguredBranchRemoteUpstream(runGit, BRANCH, remoteTrackingRefExists) + ).resolves.toMatchObject({ remoteName: 'origin' }) + expect(spawns.filter((args) => args[0] === 'remote')).toEqual([]) + }) +}) diff --git a/src/shared/git-configured-branch-target.ts b/src/shared/git-configured-branch-target.ts index 5c28f023814..9faca91dce7 100644 --- a/src/shared/git-configured-branch-target.ts +++ b/src/shared/git-configured-branch-target.ts @@ -1,4 +1,5 @@ import { gitRefTargetsBranchOnRemote } from './git-remote-branch-name' +import { findGitRemoteNameByFetchUrl } from './git-remote-url-index' type GitCommandRunner = (args: string[]) => Promise<{ stdout: string }> @@ -25,30 +26,18 @@ function isUrlValuedRemote(remote: string): boolean { return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(remote) || /^[^@/:]+@[^:]+:.+/.test(remote) } +// `hasConfiguredBranchPushTarget` resolves up to two URL-valued remotes, so the old +// per-remote `get-url` scan cost up to 2 x (1 + remotes) subprocesses per call. async function findRemoteNameForUrl( runGit: GitCommandRunner, remoteUrl: string ): Promise { try { - const { stdout } = await runGit(['remote']) - const remotes = stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - for (const remoteName of remotes) { - try { - const { stdout: urlStdout } = await runGit(['remote', 'get-url', remoteName]) - if (urlStdout.trim() === remoteUrl) { - return remoteName - } - } catch { - // Ignore a remote that disappeared or has no fetch URL. - } - } + const { stdout } = await runGit(['remote', '-v']) + return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl) } catch { return null } - return null } export async function getConfiguredBranchRemoteUpstream( @@ -101,11 +90,14 @@ export async function hasConfiguredBranchPushTarget( const pushRemoteName = isUrlValuedRemote(remote) ? ((await findRemoteNameForUrl(runGit, remote)) ?? remote) : remote - const branchRemoteName = branchRemote - ? isUrlValuedRemote(branchRemote) - ? ((await findRemoteNameForUrl(runGit, branchRemote)) ?? branchRemote) - : branchRemote - : null + // The two usually name the same URL; resolving it twice reads the remote table twice. + const branchRemoteName = !branchRemote + ? null + : branchRemote === remote + ? pushRemoteName + : isUrlValuedRemote(branchRemote) + ? ((await findRemoteNameForUrl(runGit, branchRemote)) ?? branchRemote) + : branchRemote if (gitRefTargetsBranchOnRemote(baseRef, pushRemoteName, branchName)) { return false } diff --git a/src/shared/git-remote-url-index.test.ts b/src/shared/git-remote-url-index.test.ts new file mode 100644 index 00000000000..e798e136ae4 --- /dev/null +++ b/src/shared/git-remote-url-index.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { + findGitRemoteNameByFetchUrl, + parseGitRemoteFetchUrls, + parseGitRemoteVerboseLine +} from './git-remote-url-index' + +const SSH_URL = 'git@github.com:contributor/orca.git' +const HTTPS_URL = 'https://github.com/contributor/orca.git' + +function verbose(rows: readonly (readonly [string, string])[]): string { + return rows.map(([name, url]) => `${name}\t${url}`).join('\n') +} + +describe('parseGitRemoteVerboseLine', () => { + it('reads the name, URL and direction', () => { + expect(parseGitRemoteVerboseLine(`origin\t${HTTPS_URL} (fetch)`)).toEqual({ + name: 'origin', + url: HTTPS_URL, + direction: 'fetch' + }) + }) + + it('keeps a URL that itself contains spaces and parentheses', () => { + const url = '/tmp/my repo (mirror)' + expect(parseGitRemoteVerboseLine(`local\t${url} (push)`)).toEqual({ + name: 'local', + url, + direction: 'push' + }) + }) + + it('rejects the URL-less row git prints for a pushurl-only remote', () => { + expect(parseGitRemoteVerboseLine('pushonly\t')).toBeNull() + expect(parseGitRemoteVerboseLine('not a remote row')).toBeNull() + }) +}) + +describe('parseGitRemoteFetchUrls', () => { + it('returns nothing for a repo with no remotes', () => { + expect([...parseGitRemoteFetchUrls('')]).toEqual([]) + }) + + it('keeps only fetch rows, in git remote order', () => { + const stdout = verbose([ + ['a', `${SSH_URL} (fetch)`], + ['a', `${SSH_URL} (push)`], + ['b', `${HTTPS_URL} (fetch)`], + ['b', 'https://github.com/contributor/other.git (push)'] + ]) + expect([...parseGitRemoteFetchUrls(stdout)]).toEqual([ + ['a', SSH_URL], + ['b', HTTPS_URL] + ]) + }) + + it('parses CRLF output', () => { + const stdout = `a\t${SSH_URL} (fetch)\r\na\t${SSH_URL} (push)\r\n` + expect([...parseGitRemoteFetchUrls(stdout)]).toEqual([['a', SSH_URL]]) + }) + + it('takes the first URL of a multi-URL remote, matching remote get-url', () => { + const stdout = verbose([ + ['multi', `${SSH_URL} (fetch)`], + ['multi', `${SSH_URL} (push)`], + ['multi', `${HTTPS_URL} (push)`] + ]) + expect(parseGitRemoteFetchUrls(stdout).get('multi')).toBe(SSH_URL) + }) + + it('scales to 58 remotes without losing order', () => { + const rows = Array.from({ length: 58 }, (_, index) => [ + `r${index}`, + `https://example.com/o${index}/repo.git` + ]) + const stdout = rows + .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join('\n') + const parsed = [...parseGitRemoteFetchUrls(stdout)] + expect(parsed).toHaveLength(58) + expect(parsed[0]).toEqual(['r0', 'https://example.com/o0/repo.git']) + expect(parsed[57]).toEqual(['r57', 'https://example.com/o57/repo.git']) + }) +}) + +describe('findGitRemoteNameByFetchUrl', () => { + const stdout = verbose([ + ['origin', 'https://github.com/stablyai/orca.git (fetch)'], + ['origin', 'https://github.com/stablyai/orca.git (push)'], + ['first-fork', `${SSH_URL} (fetch)`], + ['first-fork', `${SSH_URL} (push)`], + ['second-fork', `${SSH_URL} (fetch)`], + ['second-fork', `${SSH_URL} (push)`] + ]) + + it('returns the first remote holding a duplicated URL', () => { + expect(findGitRemoteNameByFetchUrl(stdout, (url) => url === SSH_URL)).toBe('first-fork') + }) + + it('returns null when nothing matches', () => { + expect(findGitRemoteNameByFetchUrl(stdout, (url) => url === HTTPS_URL)).toBeNull() + }) + + it('ignores push URLs when fetch and push differ', () => { + const split = verbose([ + ['split', `${SSH_URL} (fetch)`], + ['split', `${HTTPS_URL} (push)`] + ]) + expect(findGitRemoteNameByFetchUrl(split, (url) => url === SSH_URL)).toBe('split') + expect(findGitRemoteNameByFetchUrl(split, (url) => url === HTTPS_URL)).toBeNull() + }) +}) diff --git a/src/shared/git-remote-url-index.ts b/src/shared/git-remote-url-index.ts new file mode 100644 index 00000000000..ec4f2b4a509 --- /dev/null +++ b/src/shared/git-remote-url-index.ts @@ -0,0 +1,64 @@ +// Why: "which remote has this URL?" was answered with one `git remote get-url` +// subprocess per remote, awaited serially -- 58 spawns on a repo with 58 remotes, +// on every push-target resolution. `git remote -v` answers for every remote from +// one child. +// +// `remote -v` is the faithful one-command form, not `config --get-regexp '^remote\.'`: +// both `remote -v` and `remote get-url` print the URL *after* `url..insteadOf` +// expansion and pick the first of several `remote..url` values, while raw config +// reads return the unexpanded value and the last of the multiple values. +// +// `remote -v` also predates `remote get-url` (2.7), so this lowers rather than raises +// the Git floor and needs no capability gate. + +import { iterateProcessOutputLines } from './process-output-field-scanner' + +export type GitRemoteVerboseEntry = { + name: string + url: string + direction: 'fetch' | 'push' +} + +// Greedy prefix so a URL containing spaces or parentheses keeps them. +const REMOTE_VERBOSE_URL_PATTERN = /^(.*) \((fetch|push)\)$/ + +/** Parse one `\t (fetch|push)` row. */ +export function parseGitRemoteVerboseLine(line: string): GitRemoteVerboseEntry | null { + const tabIndex = line.indexOf('\t') + if (tabIndex === -1) { + return null + } + const name = line.slice(0, tabIndex) + const match = REMOTE_VERBOSE_URL_PATTERN.exec(line.slice(tabIndex + 1).trim()) + return match ? { name, url: match[1], direction: match[2] as 'fetch' | 'push' } : null +} + +/** + * Fetch URL per remote in `git remote` order -- the value `git remote get-url ` + * prints. A remote configured with only a `pushurl` has no fetch row and is absent + * here; `get-url` echoed the remote's own name for it, which no caller can match. + */ +export function parseGitRemoteFetchUrls(stdout: string): Map { + const fetchUrls = new Map() + for (const line of iterateProcessOutputLines(stdout)) { + const parsed = parseGitRemoteVerboseLine(line) + // First wins: `get-url` without `--all` prints the first `remote..url`. + if (parsed?.direction === 'fetch' && !fetchUrls.has(parsed.name)) { + fetchUrls.set(parsed.name, parsed.url) + } + } + return fetchUrls +} + +/** First remote whose fetch URL matches, in the order the per-remote scan visited them. */ +export function findGitRemoteNameByFetchUrl( + stdout: string, + matchesUrl: (url: string) => boolean +): string | null { + for (const [name, url] of parseGitRemoteFetchUrls(stdout)) { + if (matchesUrl(url)) { + return name + } + } + return null +} From 5510ad0cad7bcb73b5f76aa8b0f3ffe5ff2d41de Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:56:23 -0700 Subject: [PATCH 02/25] fix(diff-comments): stop review refreshes from blanking inline comment cards (#18142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commentableLineSet was memoized on array identity. Review surfaces hand the decorator a fresh-but-equal number[] on every PR/MR data refresh, so the set churned, tore down the overlay+zone effect (unmounting every comment card's React root and clearing the zone map) while the zone-creating effect — which does not depend on the set — never re-ran. Monaco kept the view zones as untracked blank gaps, and the next refresh stacked more on top. - memoize the set on a joined value key so equal refreshes are a no-op - split the add-button overlay (needs the set) from the zone teardown (must not), so the teardown's deps stay a subset of the zone-creating effect's - have the teardown actually removeZone what it stops tracking --- ...ommentDecorator.commentable-lines.test.tsx | 284 ++++++++++++++++++ .../diff-comments/useDiffCommentDecorator.tsx | 33 +- 2 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx new file mode 100644 index 00000000000..d954bafa62b --- /dev/null +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx @@ -0,0 +1,284 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { editor as MonacoEditor, IDisposable } from 'monaco-editor' +import type { DecoratedDiffComment } from './decorated-diff-comment' +import type * as ReactDomClientModule from 'react-dom/client' +import type * as DiffCommentZoneCardModule from './diff-comment-zone-card' + +const storeFixture = vi.hoisted(() => ({ + activeGroupIdByWorktree: {}, + clearDeliveredDiffComments: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: typeof storeFixture) => unknown) => selector(storeFixture) +})) + +// Stub only the card render: this suite is about zone/root lifecycle, not card markup. +vi.mock('./diff-comment-zone-card', async (importOriginal) => ({ + ...(await importOriginal()), + renderDiffCommentZoneCard: vi.fn() +})) + +const rootCounts = vi.hoisted(() => ({ created: 0, unmounted: 0 })) + +// Count only roots the decorator creates for its zones — @testing-library/react creates its own. +vi.mock('react-dom/client', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createRoot: (container: Element, options?: Parameters[1]) => { + const isZoneRoot = container.classList?.contains('orca-diff-comment-inline') ?? false + if (isZoneRoot) { + rootCounts.created += 1 + } + const root = actual.createRoot(container, options) + return { + render: (node: Parameters[0]) => root.render(node), + unmount: () => { + if (isZoneRoot) { + rootCounts.unmounted += 1 + } + root.unmount() + } + } + } + } +}) + +import { useDiffCommentDecorator } from './useDiffCommentDecorator' + +type FakeEditor = { + editor: MonacoEditor.ICodeEditor + domNode: HTMLElement + zones: Map + emitMouseMove: (lineNumber: number) => void +} + +function createFakeEditor(): FakeEditor { + const domNode = document.createElement('div') + document.body.appendChild(domNode) + const zones = new Map() + let nextZoneId = 0 + const mouseMoveListeners: ((e: { target: { position: { lineNumber: number } } }) => void)[] = [] + const noopDisposable: IDisposable = { dispose: () => {} } + + const editor = { + getDomNode: () => domNode, + getModel: () => ({}), + getOption: () => 19, + getTopForLineNumber: () => 0, + getScrollTop: () => 0, + getLayoutInfo: () => ({ height: 400 }), + setScrollTop: () => {}, + deltaDecorations: () => [], + getTargetAtClientPoint: () => null, + onMouseMove: (listener: (e: { target: { position: { lineNumber: number } } }) => void) => { + mouseMoveListeners.push(listener) + return noopDisposable + }, + onMouseLeave: () => noopDisposable, + onDidScrollChange: () => noopDisposable, + changeViewZones: (callback: (accessor: MonacoEditor.IViewZoneChangeAccessor) => void) => + callback({ + addZone: (zone: MonacoEditor.IViewZone) => { + const id = `zone-${(nextZoneId += 1)}` + zones.set(id, zone) + return id + }, + removeZone: (id: string) => { + zones.delete(id) + }, + layoutZone: () => {} + } as unknown as MonacoEditor.IViewZoneChangeAccessor) + } as unknown as MonacoEditor.ICodeEditor + + return { + editor, + domNode, + zones, + emitMouseMove: (lineNumber) => { + for (const listener of mouseMoveListeners) { + listener({ target: { position: { lineNumber } } }) + } + } + } +} + +const FILE_PATH = 'src/index.ts' +const REVIEW_SURFACE_ID = 'pr:acme/widgets:42' + +function reviewNote(index: number): DecoratedDiffComment { + return { + id: `review-note-${index}`, + worktreeId: REVIEW_SURFACE_ID, + filePath: FILE_PATH, + lineNumber: 10 + index, + body: `Please rename this (${index}).`, + createdAt: index, + side: 'modified', + author: 'octocat' + } +} + +// Every refresh of remote review data yields a fresh-but-equal array, exactly as the main process ships it. +function freshCommentableLines(): readonly number[] { + return [10, 11, 12, 13, 14, 15, 16] +} + +// Root teardown is deferred through queueMicrotask, so drain before asserting on unmount counts. +async function flushDeferredUnmounts(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +// Asserted as one object so a failure reports every lifecycle number at once. +function lifecycleTotals(fake: FakeEditor): Record { + return { + createRootCalls: rootCounts.created, + rootUnmounts: rootCounts.unmounted, + monacoViewZones: fake.zones.size + } +} + +function isAddButtonVisible(domNode: HTMLElement): boolean { + const button = domNode.querySelector('.orca-diff-comment-add-btn') + return button != null && button.style.display !== 'none' +} + +type DecoratorProps = { + commentableLineNumbers: readonly number[] + comments: readonly DecoratedDiffComment[] +} + +function renderDecorator(fake: FakeEditor, initialProps: DecoratorProps) { + return renderHook( + ({ commentableLineNumbers, comments }: DecoratorProps) => + useDiffCommentDecorator({ + editor: fake.editor, + filePath: FILE_PATH, + worktreeId: REVIEW_SURFACE_ID, + comments, + commentableLineNumbers, + onAddCommentClick: vi.fn(), + onDeleteComment: vi.fn() + }), + { initialProps } + ) +} + +beforeEach(() => { + rootCounts.created = 0 + rootCounts.unmounted = 0 +}) + +afterEach(() => { + document.body.replaceChildren() + vi.clearAllMocks() +}) + +describe('useDiffCommentDecorator commentable-line churn', () => { + it('keeps every comment root and view zone alive across value-equal review refreshes', async () => { + const fake = createFakeEditor() + const comments = [reviewNote(1), reviewNote(2), reviewNote(3)] + const hook = renderDecorator(fake, { + commentableLineNumbers: freshCommentableLines(), + comments + }) + + expect(rootCounts.created).toBe(3) + expect(fake.zones.size).toBe(3) + + for (let refresh = 0; refresh < 5; refresh += 1) { + hook.rerender({ commentableLineNumbers: freshCommentableLines(), comments }) + } + await flushDeferredUnmounts() + + expect(lifecycleTotals(fake)).toEqual({ + createRootCalls: 3, + rootUnmounts: 0, + monacoViewZones: 3 + }) + + // Still tracked, so dropping a note reclaims its vertical space instead of leaving a blank gap. + hook.rerender({ commentableLineNumbers: freshCommentableLines(), comments: comments.slice(1) }) + expect(fake.zones.size).toBe(2) + }) + + it('does not accumulate orphan zones when refreshes interleave with new review comments', async () => { + const fake = createFakeEditor() + let comments = [reviewNote(1)] + const hook = renderDecorator(fake, { + commentableLineNumbers: freshCommentableLines(), + comments + }) + + for (let refresh = 2; refresh <= 6; refresh += 1) { + comments = [...comments, reviewNote(refresh)] + hook.rerender({ commentableLineNumbers: freshCommentableLines(), comments }) + } + await flushDeferredUnmounts() + + // 6 live notes => 6 roots, 6 zones, nothing stranded. + expect(lifecycleTotals(fake)).toEqual({ + createRootCalls: 6, + rootUnmounts: 0, + monacoViewZones: 6 + }) + }) + + it('rebuilds the add-button overlay when the commentable lines really change', async () => { + const fake = createFakeEditor() + const comments = [reviewNote(1)] + const hook = renderDecorator(fake, { + commentableLineNumbers: [10, 11, 12] as readonly number[], + comments + }) + + fake.emitMouseMove(40) + expect(isAddButtonVisible(fake.domNode)).toBe(false) + + hook.rerender({ commentableLineNumbers: [10, 11, 12, 40], comments }) + await flushDeferredUnmounts() + + // Decorator is not stale: the widened set is live... + fake.emitMouseMove(40) + expect(isAddButtonVisible(fake.domNode)).toBe(true) + // ...and the existing note's zone/root was neither orphaned nor rebuilt. + expect(lifecycleTotals(fake)).toEqual({ + createRootCalls: 1, + rootUnmounts: 0, + monacoViewZones: 1 + }) + }) + + it('removes its zones from Monaco when the model swaps under a retained editor', async () => { + const fake = createFakeEditor() + const comments = [reviewNote(1)] + const hook = renderHook( + ({ monacoModelIdentity }) => + useDiffCommentDecorator({ + editor: fake.editor, + monacoModelIdentity, + filePath: FILE_PATH, + worktreeId: REVIEW_SURFACE_ID, + comments, + commentableLineNumbers: freshCommentableLines(), + onAddCommentClick: vi.fn(), + onDeleteComment: vi.fn() + }), + { initialProps: { monacoModelIdentity: 'modified-v1' } } + ) + const firstZoneIds = [...fake.zones.keys()] + + hook.rerender({ monacoModelIdentity: 'modified-v2' }) + await flushDeferredUnmounts() + + // Stale zone ids are gone rather than left as untracked blank gaps, and the note was rebuilt. + expect(fake.zones.size).toBe(1) + expect([...fake.zones.keys()]).not.toEqual(firstZoneIds) + expect(rootCounts.created).toBe(2) + expect(rootCounts.unmounted).toBe(1) + }) +}) diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx index 8e7c484bb09..d1f26d24765 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx @@ -80,11 +80,17 @@ export function useDiffCommentDecorator({ scrollToZoneFrameRef.current = null }, []) + // Key on the values, not the array identity: review surfaces re-fetch PR/MR file data on every + // refresh and hand us a fresh-but-equal number[], which would otherwise churn every consumer. + const commentableLineKey = commentableLineNumbers?.join(',') const commentableLineSet = useMemo( () => (commentableLineNumbers ? new Set(commentableLineNumbers) : null), - [commentableLineNumbers] + // eslint-disable-next-line react-hooks/exhaustive-deps + [commentableLineKey] ) + // Add-button overlay only: it captures commentableLineSet/addButtonLabel, so it must be rebuilt when + // either changes. Kept apart from the zone teardown below, whose deps must mirror the zone-creating effect. useEffect(() => { if (!editor) { return @@ -95,8 +101,7 @@ export function useDiffCommentDecorator({ return } - const zones = zonesRef.current - const disposeAddButtonOverlay = installDiffCommentAddButtonOverlay({ + return installDiffCommentAddButtonOverlay({ editor, editorDomNode, addButtonLabel, @@ -105,15 +110,33 @@ export function useDiffCommentDecorator({ disposablesRef, onAddCommentClickRef }) + }, [addButtonLabel, commentableLineSet, editor, monacoModelIdentity]) + + // Deps must stay a subset of the zone-creating effect's, or a teardown here is never followed by a rebuild. + useEffect(() => { + if (!editor) { + return + } + + const zones = zonesRef.current return () => { - disposeAddButtonOverlay() // Editor swapped/torn down: unmount roots and clear tracking so the next mount starts known-empty. // Defer unmount via queueMicrotask: a sync unmount during React's commit triggers React 19's "unmount while rendering" warning; clear zones synchronously. const rootsToUnmount = Array.from(zones.values(), (z) => { z.disposeMouseDownStopper() return z.root }) + // Drop the zones from Monaco too: clearing our map alone would strand them as untracked blank gaps + // in a still-live editor. No-op when the model already swapped (Monaco dropped them) or the editor is disposed. + if (zones.size > 0) { + const zoneIds = Array.from(zones.values(), (z) => z.zoneId) + editor.changeViewZones((accessor) => { + for (const zoneId of zoneIds) { + accessor.removeZone(zoneId) + } + }) + } zones.clear() if (rootsToUnmount.length > 0) { queueMicrotask(() => { @@ -127,7 +150,7 @@ export function useDiffCommentDecorator({ pendingScrollRef.current = null scrollToZoneRef.current = null } - }, [addButtonLabel, cancelScrollToZoneFrame, commentableLineSet, editor, monacoModelIdentity]) + }, [cancelScrollToZoneFrame, editor, monacoModelIdentity]) useEffect(() => { if (!editor) { From 3d67f2be2c5d5fb308c426964470133729241b20 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:56:26 -0700 Subject: [PATCH 03/25] perf(editor,source-control): batch closed-tab model sweeps, drop 40 store subscriptions (#18144) Closing N diff tabs scanned the global Monaco model registry 2N times and rendered both URI forms for every retained model on each scan. The Source Control panel opened 42 store subscriptions from one hook, 40 of which watched action identities that are fixed at store construction and can never change. --- .../closed-editor-tab-cache-sweep.test.ts | 10 +- .../editor/closed-editor-tab-cache-sweep.ts | 45 +++- .../editor/closed-editor-tab-disposal.test.ts | 235 ++++++++++++++++++ .../editor/closed-editor-tab-disposal.ts | 86 +++++++ .../editor/diff-monaco-model-disposal.ts | 69 ++++- .../editor/useClosedEditorTabCleanup.ts | 70 +----- ...store-actions.store-subscriptions.test.tsx | 166 +++++++++++++ .../listing/use-store-actions.ts | 153 +++++------- 8 files changed, 654 insertions(+), 180 deletions(-) create mode 100644 src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts create mode 100644 src/renderer/src/components/editor/closed-editor-tab-disposal.ts create mode 100644 src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx diff --git a/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts b/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts index 1b975d81339..726795a31dd 100644 --- a/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts +++ b/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts @@ -7,7 +7,7 @@ const position = (pageNumber: number): PdfViewPosition => ({ pageNumber, top: 0, describe('sweepClosedPdfViewPositions', () => { it('deletes the unscoped :pdf entry', () => { const cache = new Map([['/a.pdf:pdf', position(4)]]) - sweepClosedPdfViewPositions(cache, '/a.pdf') + sweepClosedPdfViewPositions(cache, ['/a.pdf']) expect(cache.size).toBe(0) }) @@ -17,7 +17,7 @@ describe('sweepClosedPdfViewPositions', () => { ['/a.pdf::tab-2:pdf', position(9)], ['/a.pdf::tab-3:pdf', position(11)] ]) - sweepClosedPdfViewPositions(cache, '/a.pdf') + sweepClosedPdfViewPositions(cache, ['/a.pdf']) expect(cache.size).toBe(0) }) @@ -27,7 +27,7 @@ describe('sweepClosedPdfViewPositions', () => { ['/b.pdf:pdf', position(7)], ['/b.pdf::tab-2:pdf', position(8)] ]) - sweepClosedPdfViewPositions(cache, '/a.pdf') + sweepClosedPdfViewPositions(cache, ['/a.pdf']) expect([...cache.keys()]).toEqual(['/b.pdf:pdf', '/b.pdf::tab-2:pdf']) }) @@ -36,13 +36,13 @@ describe('sweepClosedPdfViewPositions', () => { ['/report.pdf:pdf', position(2)], ['/report.pdf.bak:pdf', position(3)] ]) - sweepClosedPdfViewPositions(cache, '/report.pdf') + sweepClosedPdfViewPositions(cache, ['/report.pdf']) expect([...cache.keys()]).toEqual(['/report.pdf.bak:pdf']) }) it('is a no-op when the file has no cached position', () => { const cache = new Map([['/b.pdf:pdf', position(7)]]) - sweepClosedPdfViewPositions(cache, '/a.pdf') + sweepClosedPdfViewPositions(cache, ['/a.pdf']) expect(cache.size).toBe(1) }) }) diff --git a/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.ts b/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.ts index d53f0212723..b9769fb9dc8 100644 --- a/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.ts +++ b/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.ts @@ -1,23 +1,50 @@ import type { PdfViewPosition } from '@/lib/scroll-cache' -function deleteCacheEntriesByPrefix(cache: Map, prefix: string): void { +/** + * Drops every pane-scoped (`::…`) entry belonging to any of `owners` in one pass over + * the cache, rather than one pass per owner. Split out from the cleanup hook so it is testable + * without pulling in the hook's `monaco-editor` import. + */ +export function deletePaneScopedCacheEntries( + cache: Map, + owners: readonly string[] +): void { + if (owners.length === 0) { + return + } + + const ownerSet = new Set(owners) for (const key of cache.keys()) { - if (key.startsWith(prefix)) { + if (hasPaneScopeOwner(key, ownerSet)) { cache.delete(key) } } } -/** - * Release the PDF positions a closed edit tab owns. Split out from the cleanup - * hook so it is testable without pulling in the hook's `monaco-editor` import. - */ +/** Equivalent to `key.startsWith(`${owner}::`)` for any owner in the set, probing `::` boundaries. */ +function hasPaneScopeOwner(key: string, owners: ReadonlySet): boolean { + for ( + let boundary = key.indexOf('::'); + boundary !== -1; + boundary = key.indexOf('::', boundary + 1) + ) { + if (owners.has(key.slice(0, boundary))) { + return true + } + } + + return false +} + +/** Release the PDF positions closed edit tabs own. */ export function sweepClosedPdfViewPositions( cache: Map, - filePath: string + filePaths: readonly string[] ): void { // Why: the `::`-scoped sweep does not cover the single-colon suffix, so the // unscoped key needs its own delete (same shape as :rich / :preview). - cache.delete(`${filePath}:pdf`) - deleteCacheEntriesByPrefix(cache, `${filePath}::`) + for (const filePath of filePaths) { + cache.delete(`${filePath}:pdf`) + } + deletePaneScopedCacheEntries(cache, filePaths) } diff --git a/src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts b/src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts new file mode 100644 index 00000000000..0223a4394b0 --- /dev/null +++ b/src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts @@ -0,0 +1,235 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + diffViewStateCache, + editorSelectionCache, + pdfViewPositionCache, + scrollTopCache +} from '@/lib/scroll-cache' +import type { OpenFile } from '@/store/slices/editor' +import { disposeClosedEditorTabs } from './closed-editor-tab-disposal' +import { + getDiffViewerMonacoModelPaths, + getDiffViewerMonacoModelPathPrefixes, + type MonacoModelRegistry +} from './diff-monaco-model-disposal' + +const CLOSED_DIFF_TAB_COUNT = 100 +const RETAINED_MODEL_COUNT = 320 + +type FakeModel = { + path: string + attached: boolean + disposed: boolean + dispose: () => void + isAttachedToEditor: () => boolean + uri: { toString: (skipEncoding?: boolean) => string } +} + +type FakeRegistry = MonacoModelRegistry & { + models: FakeModel[] + counters: { getModelsCalls: number; uriToStringCalls: number } +} + +function createRegistry(models: FakeModel[]): FakeRegistry { + const counters = { getModelsCalls: 0, uriToStringCalls: 0 } + const byPath = new Map(models.map((model) => [model.path, model])) + for (const model of models) { + model.uri.toString = () => { + counters.uriToStringCalls += 1 + return model.path + } + } + return { + models, + counters, + Uri: { parse: (value: string) => value }, + editor: { + getModel: (uri: unknown) => byPath.get(String(uri)) ?? null, + getModels: () => { + counters.getModelsCalls += 1 + return models + } + } + } +} + +function createModel(path: string, attached = false): FakeModel { + const model: FakeModel = { + path, + attached, + disposed: false, + dispose: () => { + model.disposed = true + }, + isAttachedToEditor: () => model.attached, + uri: { toString: () => path } + } + return model +} + +function diffTab(id: string): OpenFile { + return { id, mode: 'diff', filePath: `/repo/${id}.ts` } as OpenFile +} + +/** The pre-fix shape: one full registry scan, with both URI renderings, per owned prefix. */ +function disposeByPrefixPerTab(registry: FakeRegistry, prefixes: readonly string[]): void { + for (const prefix of prefixes) { + for (const model of registry.editor.getModels()) { + const uriString = model.uri.toString(true) + const encodedUriString = model.uri.toString() + if ( + uriString === prefix || + uriString.startsWith(`${prefix}:`) || + encodedUriString === prefix || + encodedUriString.startsWith(`${prefix}:`) + ) { + if (!model.isAttachedToEditor()) { + model.dispose() + } + } + } + } +} + +/** + * 100 closed diff tabs, of which 60 still hold retained models (some with a large-diff generation + * suffix, some attached), plus 200 unrelated retained models from other tabs. + */ +function buildScenario(): { + closedTabs: OpenFile[] + models: FakeModel[] + prefixes: string[] +} { + const closedTabs = Array.from({ length: CLOSED_DIFF_TAB_COUNT }, (_, i) => diffTab(`tab-${i}`)) + const models: FakeModel[] = [] + + for (let i = 0; i < 60; i += 1) { + const base = getDiffViewerMonacoModelPaths({ + modelKey: `tab-${i}`, + generationSuffix: '' + }) + models.push(createModel(base.originalModelPath, i % 10 === 0)) + models.push(createModel(base.modifiedModelPath)) + if (i % 3 === 0) { + const regenerated = getDiffViewerMonacoModelPaths({ + modelKey: `tab-${i}`, + generationSuffix: ':large-diff-generation:2' + }) + models.push(createModel(regenerated.originalModelPath)) + } + } + + // Still-open tabs and plain edit models the sweep must not touch. + for (let i = 0; models.length < RETAINED_MODEL_COUNT; i += 1) { + const stillOpen = getDiffViewerMonacoModelPaths({ + modelKey: `open-tab-${i}`, + generationSuffix: '' + }) + models.push(createModel(stillOpen.originalModelPath)) + models.push(createModel(`/repo/src/file-${i}.ts`)) + } + + const prefixes = closedTabs.flatMap((tab) => { + const { originalModelPathPrefix, modifiedModelPathPrefix } = + getDiffViewerMonacoModelPathPrefixes(tab.id) + return [originalModelPathPrefix, modifiedModelPathPrefix] + }) + + return { closedTabs, models, prefixes } +} + +beforeEach(() => { + scrollTopCache.clear() + editorSelectionCache.clear() + diffViewStateCache.clear() + pdfViewPositionCache.clear() +}) + +describe('disposeClosedEditorTabs', () => { + it('scans the model registry once per batch instead of twice per closed diff tab', () => { + const batched = buildScenario() + const batchedRegistry = createRegistry(batched.models) + disposeClosedEditorTabs(batchedRegistry, batched.closedTabs) + + const perTab = buildScenario() + const perTabRegistry = createRegistry(perTab.models) + disposeByPrefixPerTab(perTabRegistry, perTab.prefixes) + + // Pre-fix: 2 scans per closed tab, each rendering both URI forms for every retained model. + expect(perTabRegistry.counters.getModelsCalls).toBe(CLOSED_DIFF_TAB_COUNT * 2) + expect(perTabRegistry.counters.uriToStringCalls).toBe( + CLOSED_DIFF_TAB_COUNT * 2 * perTab.models.length * 2 + ) + + expect(batchedRegistry.counters.getModelsCalls).toBe(1) + expect(batchedRegistry.counters.uriToStringCalls).toBeLessThanOrEqual(batched.models.length * 2) + }) + + it('disposes exactly the models the per-tab sweep disposed', () => { + const batched = buildScenario() + disposeClosedEditorTabs(createRegistry(batched.models), batched.closedTabs) + + const perTab = buildScenario() + disposeByPrefixPerTab(createRegistry(perTab.models), perTab.prefixes) + + const disposedPaths = (models: FakeModel[]): string[] => + models + .filter((m) => m.disposed) + .map((m) => m.path) + .sort() + + expect(disposedPaths(batched.models)).toEqual(disposedPaths(perTab.models)) + expect(disposedPaths(batched.models).length).toBeGreaterThan(0) + // Attached models survive, as does everything owned by a still-open tab. + expect(batched.models.filter((m) => m.attached).every((m) => !m.disposed)).toBe(true) + expect( + batched.models.filter((m) => m.path.includes('open-tab-')).every((m) => !m.disposed) + ).toBe(true) + }) + + it('sweeps pane-scoped cache entries for closed edit tabs in one pass per cache', () => { + scrollTopCache.set('/repo/a.ts', 10) + scrollTopCache.set('/repo/a.ts::pane-1', 20) + scrollTopCache.set('/repo/a.ts:rich', 30) + scrollTopCache.set('/repo/b.ts::pane-1', 40) + editorSelectionCache.set('/repo/a.ts::pane-2', [] as never) + pdfViewPositionCache.set('/repo/a.ts:pdf', { + pageNumber: 1, + top: 0, + left: 0 + }) + pdfViewPositionCache.set('/repo/a.ts::pane-1:pdf', { + pageNumber: 2, + top: 0, + left: 0 + }) + + disposeClosedEditorTabs(createRegistry([]), [ + { id: '/repo/a.ts', mode: 'edit', filePath: '/repo/a.ts' } as OpenFile + ]) + + expect([...scrollTopCache.keys()]).toEqual(['/repo/b.ts::pane-1']) + expect(editorSelectionCache.size).toBe(0) + expect(pdfViewPositionCache.size).toBe(0) + }) + + it('drops diff view state and preview scroll entries for closed diff tabs', () => { + diffViewStateCache.set('tab-1', {} as never) + diffViewStateCache.set('tab-1::pane-1', {} as never) + diffViewStateCache.set('tab-10', {} as never) + scrollTopCache.set('tab-1:preview', 5) + scrollTopCache.set('tab-1::pane-1', 6) + + disposeClosedEditorTabs(createRegistry([]), [diffTab('tab-1')]) + + expect([...diffViewStateCache.keys()]).toEqual(['tab-10']) + expect(scrollTopCache.size).toBe(0) + }) + + it('is a no-op when nothing closed', () => { + const registry = createRegistry([createModel('diff:original:tab-1:tab-1')]) + disposeClosedEditorTabs(registry, []) + expect(registry.counters.getModelsCalls).toBe(0) + expect(registry.models[0].disposed).toBe(false) + }) +}) diff --git a/src/renderer/src/components/editor/closed-editor-tab-disposal.ts b/src/renderer/src/components/editor/closed-editor-tab-disposal.ts new file mode 100644 index 00000000000..ddde3a74502 --- /dev/null +++ b/src/renderer/src/components/editor/closed-editor-tab-disposal.ts @@ -0,0 +1,86 @@ +import type { OpenFile } from '@/store/slices/editor' +import { + editorSelectionCache, + diffViewStateCache, + pdfViewPositionCache, + scrollTopCache +} from '@/lib/scroll-cache' +import { + disposeUnattachedMonacoModelsByPathPrefixes, + getDiffViewerMonacoModelPathPrefixes, + type MonacoModelRegistry +} from './diff-monaco-model-disposal' +import { + deletePaneScopedCacheEntries, + sweepClosedPdfViewPositions +} from './closed-editor-tab-cache-sweep' + +/** + * Releases the Monaco models and view-state cache entries owned by a batch of closed tabs. + * + * Why the batch shape: every prefix sweep here is a full scan of a shared registry or cache, so + * doing one per closed tab makes "close all"/worktree-switch quadratic in retained models. Takes + * the monaco namespace as an argument so it stays testable without importing `monaco-editor`. + */ +export function disposeClosedEditorTabs( + monacoRegistry: MonacoModelRegistry, + closedFiles: readonly OpenFile[] +): void { + if (closedFiles.length === 0) { + return + } + + const diffModelPathPrefixes: string[] = [] + const scrollTopOwners: string[] = [] + const editorSelectionOwners: string[] = [] + const diffViewStateOwners: string[] = [] + const closedPdfFilePaths: string[] = [] + + for (const closedFile of closedFiles) { + switch (closedFile.mode) { + case 'edit': + // Why: the edit model URI is constructed via monaco.Uri.parse(filePath) + // to match @monaco-editor/react's `path` prop convention. + monacoRegistry.editor.getModel(monacoRegistry.Uri.parse(closedFile.filePath))?.dispose() + scrollTopCache.delete(closedFile.filePath) + // Why: markdown and mermaid surfaces keep mode-scoped scroll positions. + scrollTopCache.delete(`${closedFile.filePath}:rich`) + scrollTopCache.delete(`${closedFile.filePath}:preview`) + scrollTopCache.delete(`${closedFile.filePath}:mermaid-diagram`) + editorSelectionCache.delete(closedFile.filePath) + scrollTopOwners.push(closedFile.filePath) + editorSelectionOwners.push(closedFile.filePath) + // Why: only 'edit' tabs ever get a PDF scroll key (see EditorContent). + closedPdfFilePaths.push(closedFile.filePath) + break + case 'markdown-preview': + // Why: preview tabs own pane-scoped preview scroll cache entries even + // though they do not retain Monaco models. + scrollTopCache.delete(`${closedFile.id}:preview`) + scrollTopOwners.push(closedFile.id) + break + case 'diff': { + // Why: kept diff models are keyed by tab id, and fallback recovery can + // append generation suffixes; closing the tab owns that whole namespace. + const { originalModelPathPrefix, modifiedModelPathPrefix } = + getDiffViewerMonacoModelPathPrefixes(closedFile.id) + diffModelPathPrefixes.push(originalModelPathPrefix, modifiedModelPathPrefix) + diffViewStateCache.delete(closedFile.id) + diffViewStateOwners.push(closedFile.id) + scrollTopCache.delete(`${closedFile.id}:preview`) + scrollTopOwners.push(closedFile.id) + break + } + case 'conflict-review': + break + case 'check-details': + break + } + } + + disposeUnattachedMonacoModelsByPathPrefixes(monacoRegistry, diffModelPathPrefixes) + deletePaneScopedCacheEntries(scrollTopCache, scrollTopOwners) + deletePaneScopedCacheEntries(editorSelectionCache, editorSelectionOwners) + deletePaneScopedCacheEntries(diffViewStateCache, diffViewStateOwners) + sweepClosedPdfViewPositions(pdfViewPositionCache, closedPdfFilePaths) +} diff --git a/src/renderer/src/components/editor/diff-monaco-model-disposal.ts b/src/renderer/src/components/editor/diff-monaco-model-disposal.ts index 8252abc8381..f833e53ec5b 100644 --- a/src/renderer/src/components/editor/diff-monaco-model-disposal.ts +++ b/src/renderer/src/components/editor/diff-monaco-model-disposal.ts @@ -16,7 +16,7 @@ type DisposableMonacoModel = Pick, + bounds: { shortestPrefixLength: number; longestPrefixLength: number } +): boolean { + if (ownedPrefixes.has(uriString)) { + return true + } + + for ( + let boundary = uriString.indexOf(':'); + boundary !== -1 && boundary <= bounds.longestPrefixLength; + boundary = uriString.indexOf(':', boundary + 1) + ) { + if ( + boundary >= bounds.shortestPrefixLength && + ownedPrefixes.has(uriString.slice(0, boundary)) + ) { + return true + } + } + + return false +} + function disposeUnattachedMonacoModel(model: DisposableMonacoModel | null): void { if (!model || model.isAttachedToEditor()) { return diff --git a/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts b/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts index 56e25045353..e86fbbc43f6 100644 --- a/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts +++ b/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts @@ -1,80 +1,22 @@ import { useEffect, useRef } from 'react' import * as monaco from 'monaco-editor' import type { OpenFile } from '@/store/slices/editor' -import { - editorSelectionCache, - diffViewStateCache, - pdfViewPositionCache, - scrollTopCache -} from '@/lib/scroll-cache' -import { - disposeUnattachedMonacoModelsByPathPrefix, - getDiffViewerMonacoModelPathPrefixes -} from './diff-monaco-model-disposal' -import { sweepClosedPdfViewPositions } from './closed-editor-tab-cache-sweep' - -function deleteCacheEntriesByPrefix(cache: Map, prefix: string): void { - for (const key of cache.keys()) { - if (key.startsWith(prefix)) { - cache.delete(key) - } - } -} +import { disposeClosedEditorTabs } from './closed-editor-tab-disposal' export function useClosedEditorTabCleanup(openFiles: OpenFile[]): void { const prevOpenFilesRef = useRef>(new Map()) useEffect(() => { const currentFilesById = new Map(openFiles.map((f) => [f.id, f])) + const closedFiles: OpenFile[] = [] for (const [prevId, prevFile] of prevOpenFilesRef.current) { if (!currentFilesById.has(prevId)) { - disposeClosedEditorTab(prevId, prevFile) + closedFiles.push(prevFile) } } + // Why one call for the whole removal batch: each sweep scans a shared registry/cache, so + // per-tab sweeps make a "close all" quadratic in retained models. + disposeClosedEditorTabs(monaco, closedFiles) prevOpenFilesRef.current = currentFilesById }, [openFiles]) } - -function disposeClosedEditorTab(prevId: string, prevFile: OpenFile): void { - switch (prevFile.mode) { - case 'edit': - // Why: the edit model URI is constructed via monaco.Uri.parse(filePath) - // to match @monaco-editor/react's `path` prop convention. - monaco.editor.getModel(monaco.Uri.parse(prevFile.filePath))?.dispose() - scrollTopCache.delete(prevFile.filePath) - deleteCacheEntriesByPrefix(scrollTopCache, `${prevFile.filePath}::`) - // Why: markdown and mermaid surfaces keep mode-scoped scroll positions. - scrollTopCache.delete(`${prevFile.filePath}:rich`) - scrollTopCache.delete(`${prevFile.filePath}:preview`) - scrollTopCache.delete(`${prevFile.filePath}:mermaid-diagram`) - editorSelectionCache.delete(prevFile.filePath) - deleteCacheEntriesByPrefix(editorSelectionCache, `${prevFile.filePath}::`) - // Why: only 'edit' tabs ever get a PDF scroll key (see EditorContent). - sweepClosedPdfViewPositions(pdfViewPositionCache, prevFile.filePath) - break - case 'markdown-preview': - // Why: preview tabs own pane-scoped preview scroll cache entries even - // though they do not retain Monaco models. - scrollTopCache.delete(`${prevFile.id}:preview`) - deleteCacheEntriesByPrefix(scrollTopCache, `${prevFile.id}::`) - break - case 'diff': - // Why: kept diff models are keyed by tab id, and fallback recovery can - // append generation suffixes; closing the tab owns that whole namespace. - { - const { originalModelPathPrefix, modifiedModelPathPrefix } = - getDiffViewerMonacoModelPathPrefixes(prevId) - disposeUnattachedMonacoModelsByPathPrefix(monaco, originalModelPathPrefix) - disposeUnattachedMonacoModelsByPathPrefix(monaco, modifiedModelPathPrefix) - } - diffViewStateCache.delete(prevId) - deleteCacheEntriesByPrefix(diffViewStateCache, `${prevId}::`) - scrollTopCache.delete(`${prevId}:preview`) - deleteCacheEntriesByPrefix(scrollTopCache, `${prevId}::`) - break - case 'conflict-review': - break - case 'check-details': - break - } -} diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx new file mode 100644 index 00000000000..dca92f903f1 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment happy-dom + +import { act, useState, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { readStoreListenerCount } from '@/store/store-listener-census' +import { useSourceControlStoreActions, type SourceControlStoreActions } from './use-store-actions' + +const originalState = useAppStore.getState() + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function mount(node: ReactNode): void { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root?.render(node)) +} + +function unmount(): void { + if (root) { + act(() => root?.unmount()) + } + root = null + container?.remove() + container = null +} + +function listenerCount(): number { + const count = readStoreListenerCount() + if (count === null) { + throw new Error('store listener census unavailable') + } + return count +} + +afterEach(() => { + unmount() + useAppStore.setState(originalState, true) +}) + +/** Everything the hook returns that is a store action rather than subscribed state. */ +const ACTION_KEYS = Object.keys(originalState).filter( + (key) => typeof (originalState as Record)[key] === 'function' +) + +describe('useSourceControlStoreActions store subscriptions', () => { + it('keeps only the two generation-record maps subscribed', () => { + const baseline = listenerCount() + + function Probe(): null { + useSourceControlStoreActions() + return null + } + mount() + + // Why 2: `pullRequestGenerationRecords` and `commitMessageGenerationRecords` are the only + // entries that are state; the other 40 are actions read through getState(). + expect(listenerCount() - baseline).toBe(2) + + unmount() + expect(listenerCount()).toBe(baseline) + }) + + it('returns the same object across an unrelated store write and re-render', () => { + let latest: SourceControlStoreActions | null = null + let rerender: (() => void) | null = null + + function Probe(): null { + const [, setTick] = useState(0) + rerender = () => setTick((t) => t + 1) + latest = useSourceControlStoreActions() + return null + } + mount() + + const first = latest + expect(first).not.toBeNull() + + act(() => { + useAppStore.setState({ + rightSidebarOpen: !originalState.rightSidebarOpen + }) + }) + act(() => rerender?.()) + + expect(latest).toBe(first) + }) + + it('still tracks the generation-record maps it subscribes to', () => { + let latest: SourceControlStoreActions | null = null + function Probe(): null { + latest = useSourceControlStoreActions() + return null + } + function read(): SourceControlStoreActions { + if (!latest) { + throw new Error('probe did not render') + } + return latest + } + mount() + + const before = read() + const record = { status: 'pending' } as never + act(() => { + useAppStore.setState({ + pullRequestGenerationRecords: { 'wt-1': record } + }) + }) + + expect(read()).not.toBe(before) + expect(read().prGenerationRecords).toEqual({ 'wt-1': record }) + + const afterPr = read() + act(() => { + useAppStore.setState({ + commitMessageGenerationRecords: { 'wt-1': record } + }) + }) + expect(read()).not.toBe(afterPr) + expect(read().commitMessageGenerationRecords).toEqual({ 'wt-1': record }) + }) + + it('hands back the live store action references', () => { + let latest: SourceControlStoreActions | null = null + function Probe(): null { + latest = useSourceControlStoreActions() + return null + } + mount() + + const state = useAppStore.getState() as unknown as Record + const returned = latest as unknown as Record + const returnedActionKeys = Object.keys(returned).filter( + (key) => typeof returned[key] === 'function' + ) + + expect(returnedActionKeys.length).toBe(40) + for (const key of returnedActionKeys) { + expect(returned[key]).toBe(state[key]) + } + }) + + it('never reassigns a store action, which is what makes getState() safe here', () => { + const before = useAppStore.getState() as unknown as Record + const snapshot = new Map(ACTION_KEYS.map((key) => [key, before[key]])) + + // Drive real writes through several slices, then confirm no action identity moved. + act(() => { + useAppStore.getState().setRightSidebarOpen(true) + useAppStore.getState().setRightSidebarTab('source-control') + useAppStore.getState().allocatePullRequestGenerationRequestId() + useAppStore.getState().setPullRequestGenerationRecord('wt-1', { status: 'pending' } as never) + useAppStore.getState().setCommitMessageGenerationRecord('wt-1', { + status: 'pending' + } as never) + }) + + const after = useAppStore.getState() as unknown as Record + const moved = ACTION_KEYS.filter((key) => after[key] !== snapshot.get(key)) + expect(moved).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.ts index 230cc4aebeb..d85c7cd04d8 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.ts @@ -1,105 +1,70 @@ +import { useMemo } from 'react' import { useAppStore } from '@/store' /** - * Binds every store action the Source Control panel dispatches. Each entry keeps its own selector so - * the returned references stay stable and can be used directly in downstream dependency arrays. + * Binds every store action the Source Control panel dispatches. + * + * Why `getState()` and not one selector each: zustand action identities are fixed when the store is + * built and no slice ever puts one in a `set()` payload, so subscribing to them can never fire. The + * 40 action subscriptions only added 40 live listeners and 40 selector runs to every store write + * while the panel was mounted. The two generation-record maps are real state, so they stay + * subscribed. + * + * Reference stability is preserved and slightly stronger than before: each action keeps the single + * identity it was created with, and the returned object itself is now stable until one of the two + * subscribed maps changes, so downstream dependency arrays keep working. */ export function useSourceControlStoreActions() { - const updateSettings = useAppStore((s) => s.updateSettings) - const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) - const openSettingsPage = useAppStore((s) => s.openSettingsPage) - const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch) - const getHostedReviewCreationEligibility = useAppStore( - (s) => s.getHostedReviewCreationEligibility - ) - const createHostedReview = useAppStore((s) => s.createHostedReview) - const createStackedHostedReview = useAppStore((s) => s.createStackedHostedReview) - const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) - const openModal = useAppStore((s) => s.openModal) - const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch) - const enqueueGitHubPRRefresh = useAppStore((s) => s.enqueueGitHubPRRefresh) - const updateRepo = useAppStore((s) => s.updateRepo) - const setGitStatus = useAppStore((s) => s.setGitStatus) - const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity) - const beginGitBranchCompareRequest = useAppStore((s) => s.beginGitBranchCompareRequest) - const setGitBranchCompareResult = useAppStore((s) => s.setGitBranchCompareResult) - const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus) - const ensureHostedReviewPushTarget = useAppStore((s) => s.ensureHostedReviewPushTarget) - const setUpstreamStatus = useAppStore((s) => s.setUpstreamStatus) - const pushBranch = useAppStore((s) => s.pushBranch) - const pullBranch = useAppStore((s) => s.pullBranch) - const fastForwardBranch = useAppStore((s) => s.fastForwardBranch) - const syncBranch = useAppStore((s) => s.syncBranch) - const rebaseFromBase = useAppStore((s) => s.rebaseFromBase) - const fetchBranch = useAppStore((s) => s.fetchBranch) - const revealInExplorer = useAppStore((s) => s.revealInExplorer) - const openConflictReview = useAppStore((s) => s.openConflictReview) - const openAllDiffs = useAppStore((s) => s.openAllDiffs) - const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs) - const deleteDiffComment = useAppStore((s) => s.deleteDiffComment) - const clearDiffComments = useAppStore((s) => s.clearDiffComments) - const clearDiffCommentsForFile = useAppStore((s) => s.clearDiffCommentsForFile) - const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen) - const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords) - const allocatePullRequestGenerationRequestId = useAppStore( - (s) => s.allocatePullRequestGenerationRequestId - ) - const setPullRequestGenerationRecord = useAppStore((s) => s.setPullRequestGenerationRecord) - const updatePullRequestGenerationRecord = useAppStore((s) => s.updatePullRequestGenerationRecord) const commitMessageGenerationRecords = useAppStore((s) => s.commitMessageGenerationRecords) - const allocateCommitMessageGenerationRequestId = useAppStore( - (s) => s.allocateCommitMessageGenerationRequestId - ) - const setCommitMessageGenerationRecord = useAppStore((s) => s.setCommitMessageGenerationRecord) - const updateCommitMessageGenerationRecord = useAppStore( - (s) => s.updateCommitMessageGenerationRecord - ) - return { - allocateCommitMessageGenerationRequestId, - allocatePullRequestGenerationRequestId, - beginGitBranchCompareRequest, - clearDiffComments, - clearDiffCommentsForFile, - commitMessageGenerationRecords, - createHostedReview, - createStackedHostedReview, - deleteDiffComment, - enqueueGitHubPRRefresh, - ensureHostedReviewPushTarget, - fastForwardBranch, - fetchBranch, - fetchHostedReviewForBranch, - fetchPRForBranch, - fetchUpstreamStatus, - getHostedReviewCreationEligibility, - openAllDiffs, - openBranchAllDiffs, - openConflictReview, - openModal, - openSettingsPage, - openSettingsTarget, - prGenerationRecords, - pullBranch, - pushBranch, - rebaseFromBase, - revealInExplorer, - setCommitMessageGenerationRecord, - setGitBranchCompareResult, - setGitStatus, - setPullRequestGenerationRecord, - setRightSidebarOpen, - setRightSidebarTab, - setUpstreamStatus, - syncBranch, - updateCommitMessageGenerationRecord, - updatePullRequestGenerationRecord, - updateRepo, - updateSettings, - updateWorktreeGitIdentity, - updateWorktreeMeta - } + return useMemo(() => { + const state = useAppStore.getState() + return { + allocateCommitMessageGenerationRequestId: state.allocateCommitMessageGenerationRequestId, + allocatePullRequestGenerationRequestId: state.allocatePullRequestGenerationRequestId, + beginGitBranchCompareRequest: state.beginGitBranchCompareRequest, + clearDiffComments: state.clearDiffComments, + clearDiffCommentsForFile: state.clearDiffCommentsForFile, + commitMessageGenerationRecords, + createHostedReview: state.createHostedReview, + createStackedHostedReview: state.createStackedHostedReview, + deleteDiffComment: state.deleteDiffComment, + enqueueGitHubPRRefresh: state.enqueueGitHubPRRefresh, + ensureHostedReviewPushTarget: state.ensureHostedReviewPushTarget, + fastForwardBranch: state.fastForwardBranch, + fetchBranch: state.fetchBranch, + fetchHostedReviewForBranch: state.fetchHostedReviewForBranch, + fetchPRForBranch: state.fetchPRForBranch, + fetchUpstreamStatus: state.fetchUpstreamStatus, + getHostedReviewCreationEligibility: state.getHostedReviewCreationEligibility, + openAllDiffs: state.openAllDiffs, + openBranchAllDiffs: state.openBranchAllDiffs, + openConflictReview: state.openConflictReview, + openModal: state.openModal, + openSettingsPage: state.openSettingsPage, + openSettingsTarget: state.openSettingsTarget, + prGenerationRecords, + pullBranch: state.pullBranch, + pushBranch: state.pushBranch, + rebaseFromBase: state.rebaseFromBase, + revealInExplorer: state.revealInExplorer, + setCommitMessageGenerationRecord: state.setCommitMessageGenerationRecord, + setGitBranchCompareResult: state.setGitBranchCompareResult, + setGitStatus: state.setGitStatus, + setPullRequestGenerationRecord: state.setPullRequestGenerationRecord, + setRightSidebarOpen: state.setRightSidebarOpen, + setRightSidebarTab: state.setRightSidebarTab, + setUpstreamStatus: state.setUpstreamStatus, + syncBranch: state.syncBranch, + updateCommitMessageGenerationRecord: state.updateCommitMessageGenerationRecord, + updatePullRequestGenerationRecord: state.updatePullRequestGenerationRecord, + updateRepo: state.updateRepo, + updateSettings: state.updateSettings, + updateWorktreeGitIdentity: state.updateWorktreeGitIdentity, + updateWorktreeMeta: state.updateWorktreeMeta + } + }, [commitMessageGenerationRecords, prGenerationRecords]) } export type SourceControlStoreActions = ReturnType From f03043544a12511b8d56e73b8bb998209c7272db Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:59:42 -0700 Subject: [PATCH 04/25] perf(keybindings): stop recomputing shortcut labels on every render (#18145) Shortcut labels were rebuilt from scratch in the render body of every component that shows one, which kept parseKeybinding running ~120x/sec in a fully idle app. - Cache the label layer per overrides object (WeakMap), so a keybinding edit hands out a new object and therefore a fresh cache. - Memoize parseKeybinding behind a bounded cache; binding strings come from a fixed definition set plus user overrides. - Hoist the per-call token/label object literals in normalizeKeyToken and formatKeyToken to module constants. --- .../src/hooks/shortcut-label-cache.test.tsx | 174 ++++++++++++++++++ src/renderer/src/hooks/useShortcutLabel.ts | 69 +++++-- src/shared/keybindings-parse-cache.test.ts | 95 ++++++++++ src/shared/keybindings/formatting.ts | 65 +++---- src/shared/keybindings/parser.ts | 71 ++++--- 5 files changed, 403 insertions(+), 71 deletions(-) create mode 100644 src/renderer/src/hooks/shortcut-label-cache.test.tsx create mode 100644 src/shared/keybindings-parse-cache.test.ts diff --git a/src/renderer/src/hooks/shortcut-label-cache.test.tsx b/src/renderer/src/hooks/shortcut-label-cache.test.tsx new file mode 100644 index 00000000000..9c7f32a632e --- /dev/null +++ b/src/renderer/src/hooks/shortcut-label-cache.test.tsx @@ -0,0 +1,174 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, render, screen } from '@testing-library/react' +import type * as KeybindingsModule from '../../../shared/keybindings' +import type { KeybindingOverrides } from '../../../shared/keybindings' + +const counters = vi.hoisted(() => ({ effective: 0, formatList: 0, formatBinding: 0 })) +const platformRef = vi.hoisted(() => ({ current: 'darwin' as NodeJS.Platform })) + +vi.mock('../lib/shortcut-platform', () => ({ + getShortcutPlatform: () => platformRef.current +})) + +vi.mock('../../../shared/keybindings', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getEffectiveKeybindingsForAction: ( + ...args: Parameters + ) => { + counters.effective++ + return actual.getEffectiveKeybindingsForAction(...args) + }, + formatKeybindingList: (...args: Parameters) => { + counters.formatList++ + return actual.formatKeybindingList(...args) + }, + formatKeybinding: (...args: Parameters) => { + counters.formatBinding++ + return actual.formatKeybinding(...args) + } + } +}) + +const { + formatOptionalShortcutLabel, + formatPrimaryShortcutLabel, + formatShortcutKeyComboDetails, + formatShortcutLabel, + useShortcutLabel +} = await import('./useShortcutLabel') +const { useAppStore } = await import('@/store') + +function resetCounters(): void { + counters.effective = 0 + counters.formatList = 0 + counters.formatBinding = 0 +} + +// A fresh overrides object per test keeps each case on its own cache entry, exactly as a real edit does. +function overridesFor(binding: string): KeybindingOverrides { + return { 'tab.close': [binding] } +} + +describe('shortcut label memoization', () => { + beforeEach(() => { + platformRef.current = 'darwin' + resetCounters() + }) + + it('computes a label once no matter how many times it is asked for', () => { + const overrides = overridesFor('Mod+Shift+K') + const first = formatShortcutLabel('tab.close', overrides) + resetCounters() + for (let index = 0; index < 200; index++) { + expect(formatShortcutLabel('tab.close', overrides)).toBe(first) + } + expect(counters.effective).toBe(0) + expect(counters.formatList).toBe(0) + }) + + it('memoizes each label shape separately and keeps their values correct', () => { + const overrides = overridesFor('Mod+Shift+K') + expect(formatShortcutLabel('tab.close', overrides)).toBe( + formatShortcutLabel('tab.close', overrides) + ) + expect(formatPrimaryShortcutLabel('tab.close', overrides)).toBe( + formatPrimaryShortcutLabel('tab.close', overrides) + ) + expect(formatOptionalShortcutLabel('tab.close', overrides)).toBe( + formatOptionalShortcutLabel('tab.close', overrides) + ) + expect(formatShortcutKeyComboDetails('tab.close', overrides)).toBe( + formatShortcutKeyComboDetails('tab.close', overrides) + ) + expect(formatShortcutKeyComboDetails('tab.close', overrides)[0]?.keys).toEqual(['⌘', '⇧', 'K']) + expect(formatShortcutLabel('tab.close', overrides)).toBe('⌘⇧K') + }) + + it('returns null rather than a cached sentinel for a disabled action', () => { + const overrides: KeybindingOverrides = { 'tab.close': [] } + expect(formatOptionalShortcutLabel('tab.close', overrides)).toBe(null) + expect(formatOptionalShortcutLabel('tab.close', overrides)).toBe(null) + expect(formatShortcutLabel('tab.close', overrides)).toBe('Unassigned') + expect(formatPrimaryShortcutLabel('tab.close', overrides)).toBe('Unassigned') + }) + + it('recomputes as soon as a different overrides object arrives', () => { + expect(formatShortcutLabel('tab.close', overridesFor('Mod+Shift+K'))).toBe('⌘⇧K') + expect(formatShortcutLabel('tab.close', overridesFor('Mod+Shift+L'))).toBe('⌘⇧L') + expect(formatShortcutLabel('tab.close', overridesFor('Mod+Shift+K'))).toBe('⌘⇧K') + }) + + it('does not let one action id serve another', () => { + const overrides = overridesFor('Mod+Shift+K') + expect(formatShortcutLabel('tab.close', overrides)).toBe('⌘⇧K') + expect(formatShortcutLabel('tab.rename', overrides)).not.toBe('⌘⇧K') + }) + + it('keys the cache by platform so Mac and Windows glyphs never cross', () => { + const overrides = overridesFor('Mod+Shift+K') + expect(formatShortcutLabel('tab.close', overrides)).toBe('⌘⇧K') + platformRef.current = 'win32' + expect(formatShortcutLabel('tab.close', overrides)).toBe('Ctrl+Shift+K') + platformRef.current = 'darwin' + expect(formatShortcutLabel('tab.close', overrides)).toBe('⌘⇧K') + }) + + it('caches the undefined-overrides case without leaking into the override case', () => { + const defaultLabel = formatShortcutLabel('tab.close') + expect(formatShortcutLabel('tab.close')).toBe(defaultLabel) + expect(formatShortcutLabel('tab.close', overridesFor('Mod+Shift+K'))).toBe('⌘⇧K') + expect(formatShortcutLabel('tab.close')).toBe(defaultLabel) + }) +}) + +function CloseLabel(): React.JSX.Element { + return {useShortcutLabel('tab.close')} +} + +describe('useShortcutLabel', () => { + beforeEach(() => { + platformRef.current = 'darwin' + resetCounters() + }) + + afterEach(() => { + cleanup() + useAppStore.setState({ keybindings: {} }) + }) + + it('does not recompute the label on re-render', () => { + useAppStore.setState({ keybindings: overridesFor('Mod+Shift+K') }) + const { rerender } = render() + expect(screen.getByTestId('close-label').textContent).toBe('⌘⇧K') + resetCounters() + for (let index = 0; index < 25; index++) { + rerender() + } + expect(screen.getByTestId('close-label').textContent).toBe('⌘⇧K') + expect(counters.effective).toBe(0) + expect(counters.formatList).toBe(0) + }) + + it('shows an edited keybinding immediately, with no stale-cache window', () => { + useAppStore.setState({ keybindings: overridesFor('Mod+Shift+K') }) + render() + expect(screen.getByTestId('close-label').textContent).toBe('⌘⇧K') + + // Mirrors the store update a Settings edit performs: a brand new overrides object. + act(() => useAppStore.setState({ keybindings: overridesFor('Mod+Shift+L') })) + expect(screen.getByTestId('close-label').textContent).toBe('⌘⇧L') + + act(() => useAppStore.setState({ keybindings: { 'tab.close': ['Mod+Alt+Backspace'] } })) + expect(screen.getByTestId('close-label').textContent).toBe('⌘⌥⌫') + + act(() => useAppStore.setState({ keybindings: { 'tab.close': [] } })) + expect(screen.getByTestId('close-label').textContent).toBe('Unassigned') + + // Back to the first binding: a revert must not resurrect the entry cached for the old object. + act(() => useAppStore.setState({ keybindings: overridesFor('Mod+Shift+K') })) + expect(screen.getByTestId('close-label').textContent).toBe('⌘⇧K') + }) +}) diff --git a/src/renderer/src/hooks/useShortcutLabel.ts b/src/renderer/src/hooks/useShortcutLabel.ts index 1d96ad8745a..fe78739f687 100644 --- a/src/renderer/src/hooks/useShortcutLabel.ts +++ b/src/renderer/src/hooks/useShortcutLabel.ts @@ -16,14 +16,48 @@ export type ShortcutKeyComboDetails = { doubleTap: boolean } +// Why: these run in render bodies of components that re-render constantly, and every call is two full keybinding parses. +// The store hands out a new overrides object on every keybinding edit, so keying the cache on that object gives exact +// invalidation: an edit can never be served from a stale entry, and the old entry is dropped with the old object. +const cachesByOverrides = new WeakMap>() +const defaultOverridesCache = new Map() + +function labelCache(overrides: KeybindingOverrides | undefined): Map { + if (!overrides) { + return defaultOverridesCache + } + let cache = cachesByOverrides.get(overrides) + if (!cache) { + cache = new Map() + cachesByOverrides.set(overrides, cache) + } + return cache +} + +function memoizeShortcut( + kind: string, + actionId: KeybindingActionId, + platform: NodeJS.Platform, + overrides: KeybindingOverrides | undefined, + compute: () => T +): T { + const cache = labelCache(overrides) + const key = `${platform}\u0000${kind}\u0000${actionId}` + if (cache.has(key)) { + return cache.get(key) as T + } + const value = compute() + cache.set(key, value) + return value +} + export function formatShortcutLabel( actionId: KeybindingActionId, overrides?: KeybindingOverrides ): string { const platform = getShortcutPlatform() - return formatKeybindingList( - getEffectiveKeybindingsForAction(actionId, platform, overrides), - platform + return memoizeShortcut('label', actionId, platform, overrides, () => + formatKeybindingList(getEffectiveKeybindingsForAction(actionId, platform, overrides), platform) ) } @@ -32,8 +66,10 @@ export function formatPrimaryShortcutLabel( overrides?: KeybindingOverrides ): string { const platform = getShortcutPlatform() - const [binding] = getEffectiveKeybindingsForAction(actionId, platform, overrides) - return binding ? formatKeybindingList([binding], platform) : 'Unassigned' + return memoizeShortcut('primary', actionId, platform, overrides, () => { + const [binding] = getEffectiveKeybindingsForAction(actionId, platform, overrides) + return binding ? formatKeybindingList([binding], platform) : 'Unassigned' + }) } export function useShortcutLabel(actionId: KeybindingActionId): string { @@ -49,11 +85,13 @@ export function formatOptionalShortcutLabel( overrides?: KeybindingOverrides ): string | null { const platform = getShortcutPlatform() - const bindings = getEffectiveKeybindingsForAction(actionId, platform, overrides) - if (bindings.length === 0) { - return null - } - return formatKeybindingList(bindings, platform) + return memoizeShortcut('optional', actionId, platform, overrides, () => { + const bindings = getEffectiveKeybindingsForAction(actionId, platform, overrides) + if (bindings.length === 0) { + return null + } + return formatKeybindingList(bindings, platform) + }) } export function useOptionalShortcutLabel(actionId: KeybindingActionId): string | null { @@ -66,10 +104,13 @@ export function formatShortcutKeyComboDetails( overrides?: KeybindingOverrides ): ShortcutKeyComboDetails[] { const platform = getShortcutPlatform() - return getEffectiveKeybindingsForAction(actionId, platform, overrides).map((binding) => ({ - keys: formatKeybinding(binding, platform), - doubleTap: isDoubleTapBinding(binding) - })) + // The returned array is shared across callers now, so treat it as read-only (every current caller does). + return memoizeShortcut('combo', actionId, platform, overrides, () => + getEffectiveKeybindingsForAction(actionId, platform, overrides).map((binding) => ({ + keys: formatKeybinding(binding, platform), + doubleTap: isDoubleTapBinding(binding) + })) + ) } export function useShortcutKeyComboDetails( diff --git a/src/shared/keybindings-parse-cache.test.ts b/src/shared/keybindings-parse-cache.test.ts new file mode 100644 index 00000000000..d2eb899b309 --- /dev/null +++ b/src/shared/keybindings-parse-cache.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { KEYBINDING_DEFINITIONS } from './keybindings/definitions' +import { getDefaultBindings } from './keybindings/effective' +import { formatKeybindingList } from './keybindings/formatting' +import { normalizeKeyToken, parseKeybinding } from './keybindings/parser' + +describe('parseKeybinding memoization', () => { + it('reuses the parsed result for a repeated binding string', () => { + const first = parseKeybinding('Mod+Shift+K') + const second = parseKeybinding('Mod+Shift+K') + expect(first).not.toBeNull() + expect(second).toBe(first) + }) + + it('caches rejections without re-parsing', () => { + expect(parseKeybinding('K+J')).toBeNull() + expect(parseKeybinding('K+J')).toBeNull() + }) + + it('never hands out an entry a caller can corrupt', () => { + const parsed = parseKeybinding('Mod+P') + expect(Object.isFrozen(parsed)).toBe(true) + expect(parseKeybinding('Mod+P')?.key).toBe('P') + }) + + it('stays bounded and correct when fed far more strings than the cache holds', () => { + for (let index = 0; index < 2000; index++) { + expect(parseKeybinding(`Mod+Alt+F${(index % 24) + 1}`)?.key).toBe(`F${(index % 24) + 1}`) + } + // A cleared cache must still return the right answer, not a stale neighbour. + expect(parseKeybinding('Mod+Shift+K')?.key).toBe('K') + expect(parseKeybinding('DoubleTap+Shift')?.doubleTapModifier).toBe('Shift') + }) + + it('parses every distinct token form identically across repeat calls', () => { + const tokens = [ + ' ', + 'a', + '7', + 'f7', + '[', + '}', + '-', + '_', + '=', + '+', + ',', + '.', + '/', + '\\', + ';', + "'", + '`', + 'return', + 'esc', + 'spacebar', + 'pgup', + 'pgdn', + 'arrowleft', + 'left', + 'down', + 'backspace', + 'del', + 'ins', + 'numpadadd', + 'subtract', + 'nonsense', + '' + ] + for (const token of tokens) { + expect(normalizeKeyToken(token)).toBe(normalizeKeyToken(token)) + } + expect(normalizeKeyToken(' ')).toBe('Space') + expect(normalizeKeyToken('pgdn')).toBe('PageDown') + expect(normalizeKeyToken('subtract')).toBe('NumpadSubtract') + expect(normalizeKeyToken('nonsense')).toBe(null) + expect(normalizeKeyToken('')).toBe(null) + // Object.prototype keys must not leak through the token table. + expect(normalizeKeyToken('constructor')).toBe(null) + expect(normalizeKeyToken('__proto__')).toBe(null) + }) +}) + +describe('shortcut label output', () => { + it('formats every default binding identically on repeat calls, on both glyph platforms', () => { + for (const platform of ['darwin', 'win32'] as const) { + for (const definition of KEYBINDING_DEFINITIONS) { + const bindings = getDefaultBindings(definition, platform) + const label = formatKeybindingList(bindings, platform) + expect(formatKeybindingList(bindings, platform)).toBe(label) + expect(label.length).toBeGreaterThan(0) + } + } + }) +}) diff --git a/src/shared/keybindings/formatting.ts b/src/shared/keybindings/formatting.ts index 1c3c75ba8fd..c9e2b35ac43 100644 --- a/src/shared/keybindings/formatting.ts +++ b/src/shared/keybindings/formatting.ts @@ -90,38 +90,41 @@ export function findKeybindingActionsForBinding( ).map((definition) => definition.id) } +const KEY_TOKEN_LABELS: Record = { + BracketLeft: '[', + BracketRight: ']', + Minus: '-', + Underscore: '_', + Equal: '=', + Plus: '+', + ArrowLeft: '←', + ArrowRight: '→', + ArrowUp: '↑', + ArrowDown: '↓', + PageUp: 'PageUp', + PageDown: 'PageDown', + NumpadAdd: 'Numpad +', + NumpadSubtract: 'Numpad -', + Comma: ',', + Period: '.', + Slash: '/', + Backslash: '\\', + Semicolon: ';', + Quote: "'", + Backquote: '`', + Enter: 'Enter', + Backspace: 'Backspace', + Delete: 'Delete', + Insert: 'Insert', + Tab: 'Tab', + Escape: 'Esc', + Space: 'Space' +} + +const MAC_KEY_TOKEN_LABELS: Record = { ...KEY_TOKEN_LABELS, Backspace: '⌫' } + function formatKeyToken(token: string, isMac: boolean): string { - const labels: Record = { - BracketLeft: '[', - BracketRight: ']', - Minus: '-', - Underscore: '_', - Equal: '=', - Plus: '+', - ArrowLeft: '←', - ArrowRight: '→', - ArrowUp: '↑', - ArrowDown: '↓', - PageUp: 'PageUp', - PageDown: 'PageDown', - NumpadAdd: 'Numpad +', - NumpadSubtract: 'Numpad -', - Comma: ',', - Period: '.', - Slash: '/', - Backslash: '\\', - Semicolon: ';', - Quote: "'", - Backquote: '`', - Enter: 'Enter', - Backspace: isMac ? '⌫' : 'Backspace', - Delete: 'Delete', - Insert: 'Insert', - Tab: 'Tab', - Escape: 'Esc', - Space: 'Space' - } - return labels[token] ?? token + return (isMac ? MAC_KEY_TOKEN_LABELS : KEY_TOKEN_LABELS)[token] ?? token } export function findKeybindingConflicts( diff --git a/src/shared/keybindings/parser.ts b/src/shared/keybindings/parser.ts index 022aa4bec29..0c3180d9d00 100644 --- a/src/shared/keybindings/parser.ts +++ b/src/shared/keybindings/parser.ts @@ -16,31 +16,8 @@ export function hasModifier( return Boolean(input.shift ?? input.shiftKey) } -function isFunctionKeyToken(key: string): boolean { - return /^F([1-9]|1[0-9]|2[0-4])$/.test(key) -} - -export function normalizeKeyToken(token: string): string | null { - if (token === ' ') { - return 'Space' - } - const trimmed = token.trim() - if (!trimmed) { - return null - } - const upper = trimmed.toUpperCase() - if (upper.length === 1 && upper >= 'A' && upper <= 'Z') { - return upper - } - if (upper.length === 1 && upper >= '0' && upper <= '9') { - return upper - } - // Function keys F1–F24 (event.key/event.code report them verbatim, e.g. F7). - if (isFunctionKeyToken(upper)) { - return upper - } - - const simple: Record = { +const SIMPLE_KEY_TOKENS = new Map( + Object.entries({ '[': 'BracketLeft', ']': 'BracketRight', '{': 'BracketLeft', @@ -97,9 +74,34 @@ export function normalizeKeyToken(token: string): string | null { SEMICOLON: 'Semicolon', QUOTE: 'Quote', BACKQUOTE: 'Backquote' + }) +) + +function isFunctionKeyToken(key: string): boolean { + return /^F([1-9]|1[0-9]|2[0-4])$/.test(key) +} + +export function normalizeKeyToken(token: string): string | null { + if (token === ' ') { + return 'Space' + } + const trimmed = token.trim() + if (!trimmed) { + return null + } + const upper = trimmed.toUpperCase() + if (upper.length === 1 && upper >= 'A' && upper <= 'Z') { + return upper + } + if (upper.length === 1 && upper >= '0' && upper <= '9') { + return upper + } + // Function keys F1–F24 (event.key/event.code report them verbatim, e.g. F7). + if (isFunctionKeyToken(upper)) { + return upper } - return simple[upper] ?? null + return SIMPLE_KEY_TOKENS.get(upper) ?? null } export function parseModifierToken(rawPart: string): ModifierToken | null { @@ -177,7 +179,24 @@ export function parseDoubleTapKeybinding(rawParts: string[]): ParsedKeybinding | return parsed } +// Binding strings come from a fixed definition set plus user overrides, so the live set is tiny; the cap only guards a caller feeding arbitrary strings. +const PARSE_CACHE_LIMIT = 512 +const parseCache = new Map() + export function parseKeybinding(binding: string): ParsedKeybinding | null { + if (parseCache.has(binding)) { + return parseCache.get(binding) ?? null + } + const parsed = parseKeybindingUncached(binding) + if (parseCache.size >= PARSE_CACHE_LIMIT) { + parseCache.clear() + } + // Frozen so a caller can never corrupt the shared entry; every current caller spread-copies before changing a field. + parseCache.set(binding, parsed ? Object.freeze(parsed) : null) + return parsed +} + +function parseKeybindingUncached(binding: string): ParsedKeybinding | null { const rawParts = binding .split('+') .map((part) => part.trim()) From c0e5b189aa08844aa71f855edbc9d1be5765d17b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:59:46 -0700 Subject: [PATCH 05/25] perf(sidebar,terminal): memoize terminal-title agent classification and lineage projections (#18148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idle-app CPU profiling showed `titleHasAgentName` running 11,771x/sec and the legacy any-agent regex 4,399x/sec, roughly once per zustand subscriber notify. The regexes were already precompiled; the problem was call volume — every store write re-classified every unchanged pane title through the whole agent-name ladder. Every title classifier is pure in the title string, so memoize them on it (bounded FIFO, 1024 entries). A new title is a new key, so there is no staleness window. The same profile showed the sidebar lineage projection re-scanning all worktrees several times per pass; cache it on the identity pair of its two immutable inputs, mirroring store/worktree-repo-index.ts. --- .../worktree-lineage-projection.test.ts | 126 ++++++++ .../sidebar/worktree-lineage-projection.ts | 48 ++- src/shared/agent-title-core.ts | 8 +- src/shared/agent-title-identity.ts | 13 +- src/shared/agent-title-status.ts | 10 +- src/shared/terminal-title-agent-type.ts | 19 +- ...rminal-title-classification-corpus.test.ts | 293 ++++++++++++++++++ .../terminal-title-classification-corpus.ts | 86 +++++ ...terminal-title-classification-memo.test.ts | 98 ++++++ .../terminal-title-classification-memo.ts | 43 +++ 10 files changed, 736 insertions(+), 8 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts create mode 100644 src/shared/terminal-title-classification-corpus.test.ts create mode 100644 src/shared/terminal-title-classification-corpus.ts create mode 100644 src/shared/terminal-title-classification-memo.test.ts create mode 100644 src/shared/terminal-title-classification-memo.ts diff --git a/src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts new file mode 100644 index 00000000000..8d2ebcdb517 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { + getCyclicProjectedWorktreeLineageIds, + getLineageRenderInfo, + getProjectedWorktreeLineageChildrenByParentId +} from './worktree-lineage-projection' + +function makeWorktree(id: string): Worktree { + return { + id, + repoId: 'repo-1', + instanceId: `${id}-instance`, + path: `/tmp/${id}`, + branch: id, + isMainWorktree: false + } as unknown as Worktree +} + +function makeLineage(childId: string, parentId: string): WorktreeLineage { + return { + worktreeId: childId, + worktreeInstanceId: `${childId}-instance`, + parentWorktreeId: parentId, + parentWorktreeInstanceId: `${parentId}-instance` + } as unknown as WorktreeLineage +} + +/** + * A sidebar-scale fixture: one root with many children, mirroring the shape the + * row builder scans on every store write. + */ +function buildFixture(childCount: number): { + lineageById: Record + worktreeMap: Map +} { + const worktreeMap = new Map() + const lineageById: Record = {} + worktreeMap.set('root', makeWorktree('root')) + for (let index = 0; index < childCount; index += 1) { + const id = `child-${index}` + worktreeMap.set(id, makeWorktree(id)) + lineageById[id] = makeLineage(id, 'root') + } + return { lineageById, worktreeMap } +} + +describe('worktree lineage projection cache', () => { + it('reuses the cyclic-id scan for an unchanged input pair', () => { + const { lineageById, worktreeMap } = buildFixture(8) + const first = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + const second = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + expect(second).toBe(first) + }) + + it('reuses the children projection for an unchanged input pair', () => { + const { lineageById, worktreeMap } = buildFixture(8) + const first = getProjectedWorktreeLineageChildrenByParentId(lineageById, worktreeMap) + const second = getProjectedWorktreeLineageChildrenByParentId(lineageById, worktreeMap) + expect(second).toBe(first) + expect(first.get('root')?.map((worktree) => worktree.id)).toEqual([ + 'child-0', + 'child-1', + 'child-2', + 'child-3', + 'child-4', + 'child-5', + 'child-6', + 'child-7' + ]) + }) + + it('rescans when either input is replaced', () => { + const { lineageById, worktreeMap } = buildFixture(4) + const baseline = getProjectedWorktreeLineageChildrenByParentId(lineageById, worktreeMap) + + const replacedLineage = { ...lineageById } + expect(getProjectedWorktreeLineageChildrenByParentId(replacedLineage, worktreeMap)).not.toBe( + baseline + ) + + const replacedWorktrees = new Map(worktreeMap) + expect(getProjectedWorktreeLineageChildrenByParentId(lineageById, replacedWorktrees)).not.toBe( + baseline + ) + }) + + it('reflects a removed lineage edge as soon as the record is replaced', () => { + const { lineageById, worktreeMap } = buildFixture(2) + expect( + getProjectedWorktreeLineageChildrenByParentId(lineageById, worktreeMap).get('root') + ).toHaveLength(2) + + const withoutFirstChild = { ...lineageById } + delete withoutFirstChild['child-0'] + const reprojected = getProjectedWorktreeLineageChildrenByParentId( + withoutFirstChild, + worktreeMap + ) + expect(reprojected.get('root')?.map((worktree) => worktree.id)).toEqual(['child-1']) + expect( + getLineageRenderInfo( + worktreeMap.get('child-0') as Worktree, + withoutFirstChild, + worktreeMap, + getCyclicProjectedWorktreeLineageIds(withoutFirstChild, worktreeMap) + ).state + ).toBe('none') + }) + + it('still reports cycles from the cached scan', () => { + const worktreeMap = new Map([ + ['a', makeWorktree('a')], + ['b', makeWorktree('b')] + ]) + const lineageById: Record = { + a: makeLineage('a', 'b'), + b: makeLineage('b', 'a') + } + const cyclic = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + expect([...cyclic].sort()).toEqual(['a', 'b']) + expect(getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)).toBe(cyclic) + expect(getProjectedWorktreeLineageChildrenByParentId(lineageById, worktreeMap).size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-lineage-projection.ts b/src/renderer/src/components/sidebar/worktree-lineage-projection.ts index 337140f586c..b8748f287c5 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-projection.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-projection.ts @@ -22,10 +22,49 @@ export function getProjectedWorktreeLineage( return (worktree as WorktreeWithResolvedLineage).lineage } +type LineageProjection = { + cyclicLineageIds?: Set + childrenByParentId?: Map +} + +/** + * Why: both projections are O(worktrees) scans that the sidebar row builder and + * the pinned/attached-children readers re-run several times per pass, and + * zustand re-runs those on every store write. Both are pure in the two inputs, + * and both inputs are immutable store-derived collections that are REPLACED + * rather than mutated, so their identity pair is a sound cache key. Weak on both + * levels so a superseded lineage record or worktree index is not pinned. + */ +const projectionByLineageAndWorktreeMap = new WeakMap< + Readonly>, + WeakMap, LineageProjection> +>() + +function getLineageProjection( + lineageById: Readonly>, + worktreeMap: ReadonlyMap +): LineageProjection { + let byWorktreeMap = projectionByLineageAndWorktreeMap.get(lineageById) + if (!byWorktreeMap) { + byWorktreeMap = new WeakMap() + projectionByLineageAndWorktreeMap.set(lineageById, byWorktreeMap) + } + let projection = byWorktreeMap.get(worktreeMap) + if (!projection) { + projection = {} + byWorktreeMap.set(worktreeMap, projection) + } + return projection +} + export function getCyclicProjectedWorktreeLineageIds( lineageById: Readonly>, worktreeMap: ReadonlyMap ): Set { + const projection = getLineageProjection(lineageById, worktreeMap) + if (projection.cyclicLineageIds) { + return projection.cyclicLineageIds + } const validLineageByChildId = new Map() for (const worktree of worktreeMap.values()) { const lineage = getProjectedWorktreeLineage(worktree, lineageById) @@ -37,7 +76,9 @@ export function getCyclicProjectedWorktreeLineageIds( validLineageByChildId.set(worktree.id, lineage) } } - return getCyclicWorktreeLineageChildIds(validLineageByChildId) + const cyclicLineageIds = getCyclicWorktreeLineageChildIds(validLineageByChildId) + projection.cyclicLineageIds = cyclicLineageIds + return cyclicLineageIds } export function getLineageRenderInfo( @@ -65,6 +106,10 @@ export function getProjectedWorktreeLineageChildrenByParentId( lineageById: Readonly>, worktreeMap: ReadonlyMap ): Map { + const projection = getLineageProjection(lineageById, worktreeMap) + if (projection.childrenByParentId) { + return projection.childrenByParentId + } const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) const childrenByParentId = new Map() for (const worktree of worktreeMap.values()) { @@ -76,6 +121,7 @@ export function getProjectedWorktreeLineageChildrenByParentId( children.push(worktree) childrenByParentId.set(lineage.parent.id, children) } + projection.childrenByParentId = childrenByParentId return childrenByParentId } diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts index 5d2c688b7d2..3d0bdbc0feb 100644 --- a/src/shared/agent-title-core.ts +++ b/src/shared/agent-title-core.ts @@ -7,6 +7,7 @@ import { } from './agent-name-token-match' import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration' import { isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title' +import { memoizeTitleClassification } from './terminal-title-classification-memo' import { getWrapperTitleSegments } from './terminal-title-wrapper-segments' export { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, HERMES_AGENT_NAME_RE, titleHasAgentName } @@ -54,7 +55,7 @@ export const BRAILLE_SPINNER_RE = /[\u2800-\u28ff]/g // Reserve the whole quarter-circle block so a later frame addition cannot regress this. export const QUARTER_CIRCLE_SPINNER_RE = /[\u25d0-\u25d3]/g -export function isGeminiTerminalTitle(title: string): boolean { +function computeIsGeminiTerminalTitle(title: string): boolean { // Why: Gemini OSC glyphs are stronger evidence than any cwd/session text. if ( title.includes(GEMINI_PERMISSION) || @@ -80,6 +81,11 @@ export function isGeminiTerminalTitle(title: string): boolean { return titleHasAgentName(title, 'gemini') } +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const isGeminiTerminalTitle: (title: string) => boolean = memoizeTitleClassification( + computeIsGeminiTerminalTitle +) + export function isPiTerminalTitle(title: string): boolean { return isLegacyPiCompatibleTitle(title) && !containsBrailleSpinner(title) } diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts index cdfc6946777..2b5194bfda8 100644 --- a/src/shared/agent-title-identity.ts +++ b/src/shared/agent-title-identity.ts @@ -12,12 +12,13 @@ import { } from './agent-title-core' import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title' +import { memoizeTitleClassification } from './terminal-title-classification-memo' /** * Returns true when the terminal title matches Claude Code's title conventions. * Used to scope prompt-cache-timer behavior to Claude sessions only. */ -export function isClaudeAgent(title: string): boolean { +function computeIsClaudeAgent(title: string): boolean { if (!title || isClaudeManagementTitle(title) || isOpenCodeNativeTitle(title)) { return false } @@ -43,7 +44,11 @@ export function isClaudeAgent(title: string): boolean { ) } -export function getAgentLabel(title: string): string | null { +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const isClaudeAgent: (title: string) => boolean = + memoizeTitleClassification(computeIsClaudeAgent) + +function computeAgentLabel(title: string): string | null { if (isClaudeManagementTitle(title)) { return null } @@ -119,3 +124,7 @@ export function getAgentLabel(title: string): string | null { return null } + +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const getAgentLabel: (title: string) => string | null = + memoizeTitleClassification(computeAgentLabel) diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 423af759281..fa1e35652e2 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -32,6 +32,7 @@ import { import { clearPiStateWorkingMarker, getPiStateTitleStatus } from './pi-state-title-marker' import { getWrapperTitleSegments } from './terminal-title-wrapper-segments' import { isGrokRotatingWorkingTitle } from './terminal-title-agent-type' +import { memoizeTitleClassification } from './terminal-title-classification-memo' /** * Strip working-status indicators so stale exit titles stop reporting working. @@ -178,7 +179,7 @@ function canonicalizeBrailleSpinnerFrame(title: string): string { return canonical } -export function detectAgentStatusFromTitle(title: string): AgentStatus | null { +function computeAgentStatusFromTitle(title: string): AgentStatus | null { if (!title || isClaudeManagementTitle(title)) { return null } @@ -262,6 +263,13 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { return 'idle' } +/** + * Pure in `title`, so it is memoized on the title string: sidebar/tab selectors + * re-ask for the same unchanged titles on every store write. + */ +export const detectAgentStatusFromTitle: (title: string) => AgentStatus | null = + memoizeTitleClassification(computeAgentStatusFromTitle) + /** * True when a quarter-circle spinner frame is the only agent evidence a title carries. * Any TUI animates those glyphs, so they prove activity, not identity — callers that diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index 4a078fde877..cdbce788806 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -10,6 +10,7 @@ import { getPiCompatibleSyntheticAgentLabel, isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title' +import { memoizeTitleClassification } from './terminal-title-classification-memo' import type { TuiAgent } from './tui-agent' export const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix) @@ -84,7 +85,7 @@ export function isPiAgentTitle(title: string): boolean { * Used to scope prompt-cache-timer behavior to Claude sessions only — other * agents have different (or no) caching semantics. */ -export function isClaudeAgent(title: string): boolean { +function computeIsClaudeAgent(title: string): boolean { if (!title || isClaudeManagementTitle(title) || isOpenCodeNativeTitle(title)) { return false } @@ -121,11 +122,15 @@ export function isClaudeAgent(title: string): boolean { return false } +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const isClaudeAgent: (title: string) => boolean = + memoizeTitleClassification(computeIsClaudeAgent) + export function isClaudeManagementTitle(title: string): boolean { return CLAUDE_MANAGEMENT_TITLE_RE.test(title) } -export function getAgentLabel(title: string): string | null { +function computeAgentLabel(title: string): string | null { if (isClaudeManagementTitle(title)) { return null } @@ -235,6 +240,10 @@ const TITLE_LABEL_TO_AGENT: Partial> = { OMP: 'omp' } +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const getAgentLabel: (title: string) => string | null = + memoizeTitleClassification(computeAgentLabel) + function hasGenericClaudeStatusPrefix(title: string): boolean { return ( containsAgentSpinnerGlyph(title) || @@ -266,10 +275,14 @@ export function resolveTerminalTitleAgentType(title: string): TuiAgent | null { * that something is running, not proof the agent is Claude — so a task or * worktree title cannot become Claude without an explicit "Claude Code" name. */ -export function resolveExplicitTerminalTitleAgentType(title: string): TuiAgent | null { +function computeExplicitTerminalTitleAgentType(title: string): TuiAgent | null { const titleAgent = resolveTerminalTitleAgentType(title) if (isGenericClaudeStatusClaim(title, titleAgent)) { return null } return titleAgent } + +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const resolveExplicitTerminalTitleAgentType: (title: string) => TuiAgent | null = + memoizeTitleClassification(computeExplicitTerminalTitleAgentType) diff --git a/src/shared/terminal-title-classification-corpus.test.ts b/src/shared/terminal-title-classification-corpus.test.ts new file mode 100644 index 00000000000..ae41d451b20 --- /dev/null +++ b/src/shared/terminal-title-classification-corpus.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from 'vitest' +import { isGeminiTerminalTitle } from './agent-title-core' +import { getAgentLabel, isClaudeAgent } from './agent-title-identity' +import { detectAgentStatusFromTitle } from './agent-title-status' +import { TERMINAL_TITLE_CLASSIFICATION_CORPUS } from './terminal-title-classification-corpus' +import { + getAgentLabel as getExplicitAgentLabel, + isClaudeAgent as isExplicitClaudeAgent, + resolveExplicitTerminalTitleAgentType, + resolveTerminalTitleAgentType +} from './terminal-title-agent-type' + +/** + * Pins the exact verdict every title classifier returns for a realistic corpus. + * + * Why: these classifiers are now memoized on the title string, and a caching bug + * here would repaint a pane under the wrong agent. This table is the proof that + * memoization is transparent — it was generated from the pre-memo implementation + * and must keep matching byte-for-byte. + */ +type PinnedRow = [ + title: string, + status: string | null, + label: string | null, + claude: boolean, + gemini: boolean, + explicitLabel: string | null, + explicitClaude: boolean, + titleAgent: string | null, + explicitTitleAgent: string | null +] + +const PINNED_CLASSIFICATIONS: readonly PinnedRow[] = [ + ['', null, null, false, false, null, false, null, null], + ['zsh', null, null, false, false, null, false, null, null], + ['bash', null, null, false, false, null, false, null, null], + ['nwparker@mac: ~/orca', null, null, false, false, null, false, null, null], + ['npm run dev', null, null, false, false, null, false, null, null], + ['opencode-blinker', null, null, false, false, null, false, null, null], + [ + 'openclaude', + 'idle', + 'OpenClaude', + false, + false, + 'OpenClaude', + false, + 'openclaude', + 'openclaude' + ], + ['openclaude-scratch', null, null, false, false, null, false, null, null], + ['claude-scratch', null, null, false, false, null, false, null, null], + ['~/codex/ready', null, null, false, false, null, false, null, null], + ['review-14600-codex', null, null, false, false, null, false, null, null], + ['timestamp ready', null, null, false, false, null, false, null, null], + ['android build running', null, null, false, false, null, false, null, null], + ['~/hermes/working', null, null, false, false, null, false, null, null], + ['C:\\tools\\codex\\run', null, null, false, false, null, false, null, null], + ['/usr/local/bin/claude/notes', null, null, false, false, null, false, null, null], + ['agy-nightly', null, null, false, false, null, false, null, null], + ['codex.exe', 'idle', 'Codex', false, false, 'Codex', false, 'codex', 'codex'], + [ + 'openclaude.cmd', + 'idle', + 'OpenClaude', + false, + false, + 'OpenClaude', + false, + 'openclaude', + 'openclaude' + ], + [ + 'claude.bat working', + 'working', + 'Claude Code', + true, + false, + 'Claude Code', + true, + 'claude', + 'claude' + ], + ['aider.ps1 ready', 'idle', 'Aider', false, false, 'Aider', false, 'aider', 'aider'], + [ + 'copilot.exe - action required', + 'permission', + 'GitHub Copilot', + false, + false, + 'GitHub Copilot', + false, + 'copilot', + 'copilot' + ], + ['droid.exe', null, null, false, false, null, false, null, null], + ['\u2733', 'idle', 'Claude Code', true, false, 'Claude Code', true, 'claude', null], + [ + '\u2733 Claude Code', + 'idle', + 'Claude Code', + true, + false, + 'Claude Code', + true, + 'claude', + 'claude' + ], + ['\u2733 ready', 'idle', 'Claude Code', true, false, 'Claude Code', true, 'claude', null], + ['. building the parser', null, 'Claude Code', true, false, 'Claude Code', true, 'claude', null], + ['* done', null, 'Claude Code', true, false, 'Claude Code', true, 'claude', null], + ['Claude Code', 'idle', 'Claude Code', true, false, 'Claude Code', true, 'claude', 'claude'], + [ + 'claude - action required', + 'permission', + 'Claude Code', + true, + false, + 'Claude Code', + true, + 'claude', + 'claude' + ], + ['Claude ready', 'idle', 'Claude Code', true, false, 'Claude Code', true, 'claude', 'claude'], + ['claude agents', null, null, false, false, null, false, null, null], + ['"/usr/local/bin/claude" agents', null, null, false, false, null, false, null, null], + [ + '\u280b Claude Code', + 'working', + 'Claude Code', + true, + false, + 'Claude Code', + true, + 'claude', + 'claude' + ], + [ + '\u2809 Codex \u2014 refactoring', + 'working', + 'Codex', + true, + false, + 'Codex', + true, + 'codex', + 'codex' + ], + ['\u25d0 working', 'working', 'Claude Code', true, false, 'Claude Code', true, 'claude', null], + ['\u25d3 Grok', 'working', 'Grok', true, false, 'Grok', true, 'grok', 'grok'], + ['\u280b Cursor Agent', 'working', 'Cursor', false, false, 'Cursor', false, 'cursor', 'cursor'], + ['\u280b Droid', 'working', 'Droid', true, false, 'Droid', true, 'droid', 'droid'], + ['\u280b Hermes', 'working', 'Hermes', true, false, 'Hermes', true, 'hermes', 'hermes'], + ['\u2726 gemini', 'working', 'Gemini CLI', false, true, 'Gemini CLI', false, 'gemini', 'gemini'], + [ + '\u23f2 Gemini CLI', + 'working', + 'Gemini CLI', + false, + true, + 'Gemini CLI', + false, + 'gemini', + 'gemini' + ], + ['\u25c7 Gemini CLI', 'idle', 'Gemini CLI', false, true, 'Gemini CLI', false, 'gemini', 'gemini'], + [ + '\u270b Gemini CLI', + 'permission', + 'Gemini CLI', + false, + true, + 'Gemini CLI', + false, + 'gemini', + 'gemini' + ], + ['gemini', 'idle', 'Gemini CLI', false, true, 'Gemini CLI', false, 'gemini', 'gemini'], + [ + 'antigravity gemini 3 pro', + 'idle', + 'Antigravity', + false, + false, + 'Antigravity', + false, + 'antigravity', + 'antigravity' + ], + [ + 'agy - gemini 2 flash', + 'idle', + 'Antigravity', + false, + false, + 'Antigravity', + false, + 'antigravity', + 'antigravity' + ], + ['codex working', 'working', 'Codex', false, false, 'Codex', false, 'codex', 'codex'], + ['codex ready', 'idle', 'Codex', false, false, 'Codex', false, 'codex', 'codex'], + [ + 'copilot waiting', + 'permission', + 'GitHub Copilot', + false, + false, + 'GitHub Copilot', + false, + 'copilot', + 'copilot' + ], + ['devin thinking', 'working', 'Devin', false, false, 'Devin', false, 'devin', 'devin'], + ['mimo idle', 'idle', 'MiMo Code', false, false, 'MiMo Code', false, 'mimo-code', 'mimo-code'], + ['aider running', 'working', 'Aider', false, false, 'Aider', false, 'aider', 'aider'], + ['grok done', 'idle', 'Grok', false, false, 'Grok', false, 'grok', 'grok'], + ['opencode ready', 'idle', 'OpenCode', false, false, 'OpenCode', false, 'opencode', 'opencode'], + ['hermes ready', 'idle', 'Hermes', false, false, 'Hermes', false, 'hermes', 'hermes'], + ['droid ready', 'idle', 'Droid', false, false, 'Droid', false, 'droid', 'droid'], + ['cursor agent', null, 'Cursor', false, false, 'Cursor', false, 'cursor', 'cursor'], + ['cursor ready', 'idle', 'Cursor', false, false, 'Cursor', false, 'cursor', 'cursor'], + [ + 'cursor - action required', + 'permission', + 'Cursor', + false, + false, + 'Cursor', + false, + 'cursor', + 'cursor' + ], + ['cursor position reset', 'idle', null, false, false, null, false, null, null], + ['\u03c0 > session - ~/orca', 'idle', 'Pi', false, false, 'Pi', false, 'pi', 'pi'], + ['\u03c0 ! blocked-session', 'permission', 'Pi', false, false, 'Pi', false, 'pi', 'pi'], + ['\u280b \u03c0 - session - ~/orca', 'working', 'Pi', true, false, 'Pi', true, 'pi', 'pi'], + ['zsh | \u280b Codex', 'working', 'Codex', true, false, 'Codex', true, 'codex', 'codex'], + ['tmux | claude - action required', 'permission', null, false, false, null, false, null, null], + [ + 'ssh host | opencode ready', + 'idle', + 'OpenCode', + false, + false, + 'OpenCode', + false, + 'opencode', + 'opencode' + ] +] + +describe('terminal title classification', () => { + it('covers every corpus title exactly once', () => { + expect(PINNED_CLASSIFICATIONS.map(([title]) => title)).toEqual([ + ...TERMINAL_TITLE_CLASSIFICATION_CORPUS + ]) + }) + + it.each(PINNED_CLASSIFICATIONS)( + 'classifies %j identically', + ( + title, + status, + label, + claude, + gemini, + explicitLabel, + explicitClaude, + titleAgent, + explicitTitleAgent + ) => { + expect(detectAgentStatusFromTitle(title)).toBe(status) + expect(getAgentLabel(title)).toBe(label) + expect(isClaudeAgent(title)).toBe(claude) + expect(isGeminiTerminalTitle(title)).toBe(gemini) + expect(getExplicitAgentLabel(title)).toBe(explicitLabel) + expect(isExplicitClaudeAgent(title)).toBe(explicitClaude) + expect(resolveTerminalTitleAgentType(title)).toBe(titleAgent) + expect(resolveExplicitTerminalTitleAgentType(title)).toBe(explicitTitleAgent) + } + ) + + it('returns the same verdict on the second read of every title', () => { + for (const title of TERMINAL_TITLE_CLASSIFICATION_CORPUS) { + expect(detectAgentStatusFromTitle(title)).toBe(detectAgentStatusFromTitle(title)) + expect(getAgentLabel(title)).toBe(getAgentLabel(title)) + expect(resolveExplicitTerminalTitleAgentType(title)).toBe( + resolveExplicitTerminalTitleAgentType(title) + ) + } + }) +}) diff --git a/src/shared/terminal-title-classification-corpus.ts b/src/shared/terminal-title-classification-corpus.ts new file mode 100644 index 00000000000..e1eb6e833a7 --- /dev/null +++ b/src/shared/terminal-title-classification-corpus.ts @@ -0,0 +1,86 @@ +/** + * Realistic terminal-title corpus for pinning agent classification. + * + * Why a shared const: the corpus is the contract the memoized classifiers must + * reproduce byte-for-byte, so the pinning test and the memo regression test + * read the same titles. + */ +export const TERMINAL_TITLE_CLASSIFICATION_CORPUS: readonly string[] = [ + // Plain shell / directory titles — must classify as nothing. + '', + 'zsh', + 'bash', + 'nwparker@mac: ~/orca', + 'npm run dev', + // Boundary-guard cases from agent-name-token-match.ts's header comment. + 'opencode-blinker', + 'openclaude', + 'openclaude-scratch', + 'claude-scratch', + '~/codex/ready', + 'review-14600-codex', + 'timestamp ready', + 'android build running', + '~/hermes/working', + 'C:\\tools\\codex\\run', + '/usr/local/bin/claude/notes', + 'agy-nightly', + // Windows launcher suffixes. + 'codex.exe', + 'openclaude.cmd', + 'claude.bat working', + 'aider.ps1 ready', + 'copilot.exe - action required', + 'droid.exe', + // Claude Code prefixes and identity frames. + '\u2733', + '\u2733 Claude Code', + '\u2733 ready', + '. building the parser', + '* done', + 'Claude Code', + 'claude - action required', + 'Claude ready', + 'claude agents', + '"/usr/local/bin/claude" agents', + // Leading spinner glyphs (braille + quarter circle). + '\u280b Claude Code', + '\u2809 Codex \u2014 refactoring', + '\u25d0 working', + '\u25d3 Grok', + '\u280b Cursor Agent', + '\u280b Droid', + '\u280b Hermes', + // Gemini glyph vocabulary. + '\u2726 gemini', + '\u23f2 Gemini CLI', + '\u25c7 Gemini CLI', + '\u270b Gemini CLI', + 'gemini', + 'antigravity gemini 3 pro', + 'agy - gemini 2 flash', + // Named agents with status words. + 'codex working', + 'codex ready', + 'copilot waiting', + 'devin thinking', + 'mimo idle', + 'aider running', + 'grok done', + 'opencode ready', + 'hermes ready', + 'droid ready', + // Cursor's closed identity set. + 'cursor agent', + 'cursor ready', + 'cursor - action required', + 'cursor position reset', + // Pi / OMP compatible titles. + '\u03c0 > session - ~/orca', + '\u03c0 ! blocked-session', + '\u280b \u03c0 - session - ~/orca', + // Wrapper/multiplexer prefixes. + 'zsh | \u280b Codex', + 'tmux | claude - action required', + 'ssh host | opencode ready' +] diff --git a/src/shared/terminal-title-classification-memo.test.ts b/src/shared/terminal-title-classification-memo.test.ts new file mode 100644 index 00000000000..e2606a58291 --- /dev/null +++ b/src/shared/terminal-title-classification-memo.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as AgentNameTokenMatchModule from './agent-name-token-match' +import { getAgentLabel } from './agent-title-identity' +import { detectAgentStatusFromTitle } from './agent-title-status' +import { memoizeTitleClassification } from './terminal-title-classification-memo' +import { resolveExplicitTerminalTitleAgentType } from './terminal-title-agent-type' + +// Why a module mock: `titleHasAgentName` is the leaf regex test every title +// classifier funnels into, so counting its invocations is the direct measure of +// what one store write costs when no title has changed. +vi.mock('./agent-name-token-match', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, titleHasAgentName: vi.fn(actual.titleHasAgentName) } +}) + +const classifierCalls = vi.mocked(AgentNameTokenMatchModule.titleHasAgentName) + +// Titles a real sidebar holds steady while unrelated agent-status writes churn. +const UNCHANGED_TITLES = [ + 'codex working', + 'opencode-blinker', + 'zsh', + '✳ Claude Code', + 'copilot.exe - action required', + 'gemini', + 'cursor agent' +] +const STORE_WRITES = 50 + +function classifyEveryTitle(): void { + for (const title of UNCHANGED_TITLES) { + getAgentLabel(title) + detectAgentStatusFromTitle(title) + resolveExplicitTerminalTitleAgentType(title) + } +} + +describe('terminal title classification memo', () => { + beforeEach(() => { + classifierCalls.mockClear() + }) + + it('classifies each distinct title once across repeated store writes', () => { + // Warm the caches the way the first render would, then measure steady state. + classifyEveryTitle() + classifierCalls.mockClear() + + for (let write = 0; write < STORE_WRITES; write += 1) { + classifyEveryTitle() + } + + // Unmemoized this is STORE_WRITES x titles x the whole regex ladder — 4,350 + // leaf matches for this fixture. Memoized, an unchanged title costs nothing. + expect(classifierCalls).not.toHaveBeenCalled() + }) + + it('classifies a title once no matter how many readers ask', () => { + const title = 'aider running' + getAgentLabel(title) + const firstReadCalls = classifierCalls.mock.calls.length + expect(firstReadCalls).toBeGreaterThan(0) + + for (let read = 0; read < 20; read += 1) { + getAgentLabel(title) + } + expect(classifierCalls.mock.calls.length).toBe(firstReadCalls) + }) + + it('reclassifies as soon as the title changes', () => { + expect(getAgentLabel('codex ready')).toBe('Codex') + expect(getAgentLabel('grok ready')).toBe('Grok') + expect(detectAgentStatusFromTitle('codex ready')).toBe('idle') + expect(detectAgentStatusFromTitle('codex working')).toBe('working') + }) + + it('caches null and false verdicts, not just truthy ones', () => { + const classify = vi.fn((): string | null => null) + const memoized = memoizeTitleClassification(classify) + expect(memoized('zsh')).toBeNull() + expect(memoized('zsh')).toBeNull() + expect(classify).toHaveBeenCalledTimes(1) + }) + + it('evicts oldest entries instead of growing without bound', () => { + const classify = vi.fn((title: string) => title.length) + const memoized = memoizeTitleClassification(classify) + // Cap is 1024; overflow it and confirm the newest key still hits while the + // oldest was evicted. + for (let index = 0; index < 1030; index += 1) { + memoized(`title-${index}`) + } + const afterFill = classify.mock.calls.length + memoized('title-1029') + expect(classify.mock.calls.length).toBe(afterFill) + memoized('title-0') + expect(classify.mock.calls.length).toBe(afterFill + 1) + }) +}) diff --git a/src/shared/terminal-title-classification-memo.ts b/src/shared/terminal-title-classification-memo.ts new file mode 100644 index 00000000000..c712ca65c96 --- /dev/null +++ b/src/shared/terminal-title-classification-memo.ts @@ -0,0 +1,43 @@ +/** + * Bounded memo for pure `(title: string) => T` terminal-title classifiers. + * + * Why: the sidebar cards and tab strip re-derive agent identity/status from + * every pane title inside zustand selectors and render bodies, so an UNCHANGED + * title was re-tested against every agent-name regex on every store write — + * thousands of classifications per second while the app sat idle. Every + * classifier below depends on nothing but the title string, so the verdict is + * reusable until the title itself changes; a new title is simply a new key, so + * there is no staleness window and no invalidation signal to miss. + */ + +/** + * Cap: comfortably above the live working set (one title per open pane plus + * retained rows) so steady-state hit rate stays ~100%, small enough that the + * map cannot grow with session length. Entries hold a reference to a string the + * store already retains, so the marginal cost is the map entry itself. + */ +const MAX_MEMOIZED_TITLES = 1024 + +export function memoizeTitleClassification( + classify: (title: string) => T +): (title: string) => T { + // Boxed values so `undefined`/`null` verdicts are still cache hits. + const cache = new Map() + return (title: string): T => { + const cached = cache.get(title) + if (cached) { + return cached.value + } + const value = classify(title) + // Insertion-ordered FIFO eviction: a pane's superseded title frames are the + // oldest keys and the least likely to be asked for again. + if (cache.size >= MAX_MEMOIZED_TITLES) { + const oldest = cache.keys().next() + if (!oldest.done) { + cache.delete(oldest.value) + } + } + cache.set(title, { value }) + return value + } +} From 93cb1074b74ea1d6d177f1cf63529949fd7c3cd9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:04:02 -0700 Subject: [PATCH 06/25] perf(renderer): stop the mobile sync key rehashing every dirty file on each keystroke (#18154) `useRuntimeGraphSync` is mounted unconditionally, and its projection layer runs on every store write. Four of those projections did work proportional to the whole slice rather than to what changed: - `buildRuntimeMobileEditorDraftsProjection` FNV-hashed every open dirty draft on every `setEditorDraft`, which Monaco fires per keystroke with no debounce. - `buildRuntimeMobileOpenFilesProjection` and the browser projection rebuilt and re-stringified everything on any `isDirty`/title/url/loading change. - The agent-status sort built an ICU collation per comparison for a string that is only ever compared with `===`. Each now memoizes per entry against the previous build, mirroring the tabs and agent-status projections that already did. The duplicated draft-hash loop in `mobile-session-inputs` is gone; both consumers share one memo. The session-write subscriber also identity-scans SESSION_RELEVANT_FIELDS before allocating its 35-field snapshot and changed-field array. Projections are byte-identical apart from the agent-status sort order, which is never displayed. --- ...ession-write-subscriber-allocation.test.ts | 196 ++++++ .../src/lib/session-write-subscriber.ts | 49 ++ ...time-graph-agent-status-projection.test.ts | 33 +- ...-runtime-graph-projection-hot-path.test.ts | 576 ++++++++++++++++++ .../src/runtime/sync-runtime-graph.ts | 2 + .../agent-status-projection.ts | 5 +- .../sync-runtime-graph/editor-draft-hash.ts | 15 + .../runtime/sync-runtime-graph/graph-state.ts | 10 +- .../mobile-session-inputs.ts | 23 - .../mobile-session-snapshots.ts | 2 +- .../sync-runtime-graph/sync-projections.ts | 244 ++++++-- .../src/runtime/sync-runtime-graph/types.ts | 42 ++ 12 files changed, 1108 insertions(+), 89 deletions(-) create mode 100644 src/renderer/src/lib/session-write-subscriber-allocation.test.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts create mode 100644 src/renderer/src/runtime/sync-runtime-graph/editor-draft-hash.ts diff --git a/src/renderer/src/lib/session-write-subscriber-allocation.test.ts b/src/renderer/src/lib/session-write-subscriber-allocation.test.ts new file mode 100644 index 00000000000..9681af5a973 --- /dev/null +++ b/src/renderer/src/lib/session-write-subscriber-allocation.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import { createSessionWriteSubscriber } from './session-write-subscriber' +import { SESSION_RELEVANT_FIELDS } from './workspace-session' +import { buildWorkspaceSessionPatch } from './workspace-session-patch' + +/** + * Why a hand-built store: the subscriber allocates a 35-field snapshot plus a changed-field array + * on every fire that reaches its body, and both are invisible from outside. Driving it through an + * injected store lets `Array.prototype.filter` stand in as the allocation counter — the changed + * list is built 1:1 with the snapshot, in the same block, so one count measures both. + */ +function makeSessionState(overrides: Partial = {}): AppState { + const base: Record = { + workspaceSessionReady: true, + hydrationSucceeded: true, + // Non-session state the subscriber must ignore. + agentStatusByPaneKey: {}, + runtimePaneTitlesByTabId: {} + } + const arrayFields = new Set([ + 'repos', + 'openFiles', + 'browserUrlHistory', + 'workspaceDocHistory' + ]) + const mapFields = new Set(['sshConnectionStates']) + for (const key of SESSION_RELEVANT_FIELDS) { + base[key] = arrayFields.has(key) ? [] : mapFields.has(key) ? new Map() : {} + } + base.activeRepoId = null + base.activeWorkspaceKey = null + base.activeWorktreeId = null + base.activeTabId = null + base.activeWorkspaceExecutionHostId = null + return { ...base, ...overrides } as AppState +} + +function createHarness() { + let state = makeSessionState() + const listeners: ((next: AppState) => void)[] = [] + const persisted: unknown[] = [] + const dispose = createSessionWriteSubscriber({ + store: { + subscribe: (listener) => { + listeners.push(listener) + return () => { + listeners.splice(listeners.indexOf(listener), 1) + } + }, + getState: () => state + }, + persist: (payload) => persisted.push(payload) + }) + return { + dispose, + persisted, + write(mutate?: (previous: AppState) => Partial) { + state = { ...state, ...mutate?.(state) } as AppState + for (const listener of listeners.slice()) { + listener(state) + } + } + } +} + +function countFilterCalls(run: () => T): number { + const original = Array.prototype.filter + let calls = 0 + const spy = vi.spyOn(Array.prototype, 'filter').mockImplementation(function filterCounting( + this: unknown[], + ...args: never[] + ) { + calls += 1 + return (original as (...a: never[]) => unknown[]).apply(this, args) + } as typeof Array.prototype.filter) + try { + run() + return calls + } finally { + spy.mockRestore() + } +} + +beforeEach(() => { + vi.useFakeTimers() +}) +afterEach(() => { + vi.useRealTimers() +}) + +describe('session write subscriber allocation', () => { + it('allocates nothing for store writes that touch no session field', () => { + const harness = createHarness() + try { + // Prime `prev` on the first fire. + harness.write() + vi.advanceTimersByTime(500) + + const writes = 200 + const calls = countFilterCalls(() => { + for (let write = 0; write < writes; write += 1) { + harness.write((previous) => ({ + agentStatusByPaneKey: { ...previous.agentStatusByPaneKey, [`p-${write}`]: {} } as never + })) + } + }) + // Before: one changed-field array (and one 35-field snapshot) per write. + expect(calls).toBe(0) + } finally { + harness.dispose() + } + }) + + it('still allocates and persists when a session field really changes', () => { + const harness = createHarness() + try { + harness.write() + vi.advanceTimersByTime(500) + harness.persisted.length = 0 + + const writes = 20 + const calls = countFilterCalls(() => { + for (let write = 0; write < writes; write += 1) { + harness.write(() => ({ activeTabId: `tab-${write}` })) + } + }) + expect(calls).toBe(writes) + vi.advanceTimersByTime(500) + expect(harness.persisted).toHaveLength(1) + } finally { + harness.dispose() + } + }) + + it('still wakes a deferred write when an unrelated field changes', () => { + let state = makeSessionState() + const listeners: ((next: AppState) => void)[] = [] + const persisted: unknown[] = [] + let gateOpen = false + const dispose = createSessionWriteSubscriber({ + store: { + subscribe: (listener) => { + listeners.push(listener) + return () => {} + }, + getState: () => state + }, + persist: (payload) => persisted.push(payload), + shouldSchedulePersist: () => gateOpen, + subscribeToPersistGateOpen: () => () => {} + }) + const write = (patch: Partial): void => { + state = { ...state, ...patch } as AppState + for (const listener of listeners.slice()) { + listener(state) + } + } + try { + // A real session change lands while the gate is closed, so the write is owed but deferred. + write({ activeTabId: 'tab-1' }) + vi.advanceTimersByTime(500) + expect(persisted).toHaveLength(0) + + // An unrelated write with the gate open must re-arm it even though nothing session-relevant + // moved — the identity-scan fast path must not swallow this wake-up. + gateOpen = true + write({ agentStatusByPaneKey: { a: {} } as never }) + vi.advanceTimersByTime(500) + expect(persisted).toHaveLength(1) + } finally { + dispose() + } + }) + + it('gates on a strict superset of the fields the patch builder reads', () => { + // A gate that misses a projection input persists stale state, so this is a correctness lock, + // not a perf one. Record what the builder actually touches rather than trusting the types. + const read = new Set() + const snapshot = new Proxy(makeSessionState() as unknown as Record, { + get(target, property, receiver) { + if (typeof property === 'string') { + read.add(property) + } + return Reflect.get(target, property, receiver) + } + }) + buildWorkspaceSessionPatch( + snapshot as never, + SESSION_RELEVANT_FIELDS as unknown as Iterable + ) + const gated = new Set(SESSION_RELEVANT_FIELDS) + expect([...read].filter((field) => !gated.has(field))).toEqual([]) + expect(read.size).toBeGreaterThan(0) + }) +}) diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index f01b7b25011..e0e366ef6ba 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -124,6 +124,10 @@ export function createSessionWriteSubscriber({ // reuse the prior identity while a real session change keeps fresh tabs for // the eventual getState() patch build. `null` makes the first fire proceed. let prev: Record | null = null + // Why held separately from `prev`: `prev` stores the *projected* tab maps, so the raw slice + // identity is the only thing the pre-allocation scan below can compare them against. + let prevTabsSource: TabsByWorktree | null = null + let prevUnifiedTabsSource: UnifiedTabsByWorktree | null = null // Why: this set is the only record that a mutation still owes a write — `prev` has already // advanced past it, and change detection is identity-based, so a field dropped from here can // never be re-detected. It is retired only by a flush that reached `persist` (or found nothing @@ -168,10 +172,53 @@ export function createSessionWriteSubscriber({ timer = setTimeout(flushPendingWrite, debounceMs) } + /** + * Identity-only scan over exactly SESSION_RELEVANT_FIELDS, allocating nothing. + * + * Why sound: for the two projected fields an unchanged raw slice is strictly stronger than an + * unchanged projection (the projection is a function of the slice), so a `false` here always + * implies the full comparison below would have found no changed field. A changed raw slice + * falls through to that comparison, where the projection can still collapse it. + */ + const hasSessionFieldIdentityChange = (state: AppState): boolean => { + if (prev === null) { + return true + } + for (const key of SESSION_RELEVANT_FIELDS) { + const unchanged = + key === 'tabsByWorktree' + ? state.tabsByWorktree === prevTabsSource + : key === 'unifiedTabsByWorktree' + ? state.unifiedTabsByWorktree === prevUnifiedTabsSource + : prev[key] === state[key] + if (!unchanged) { + return true + } + } + return false + } + const unsub = store.subscribe((state) => { if (!shouldPersistWorkspaceSession(state)) { return } + // Why: this fires on every store write and almost none of them touch a session field. Scan + // identities first so the common case never allocates the 35-field snapshot or the changed + // list; only a real identity change pays for them. + if (!hasSessionFieldIdentityChange(state)) { + if (pendingChangedFields.size === 0) { + return + } + if (shouldSchedulePersist && !shouldSchedulePersist()) { + return + } + // An unrelated update may wake a deferred write but must never reset an armed debounce. + if (timer !== null) { + return + } + armFlushTimer() + return + } const next: Record = {} for (const key of SESSION_RELEVANT_FIELDS) { const value = state[key] @@ -190,6 +237,8 @@ export function createSessionWriteSubscriber({ return } prev = next + prevTabsSource = state.tabsByWorktree + prevUnifiedTabsSource = state.unifiedTabsByWorktree for (const field of changedFields) { pendingChangedFields.add(field) } diff --git a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts index 724b8743dde..3ae6769bb15 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts @@ -6,14 +6,17 @@ import { resetRuntimeMobileAgentStatusProjectionCacheForTests } from './sync-runtime-graph' -// Reference: the pre-change whole-array serialization, kept verbatim. The bucket -// width is read from the module under test so a drifted constant cannot make this -// reference silently disagree for a reason unrelated to the change. +// Reference: the pre-change whole-array serialization, kept verbatim apart from the sort, which +// is now a code-unit compare — the projection is only ever `===`-compared, never displayed, so it +// needs to be deterministic rather than locale-correct. `localeCompareOrderedEntries` below pins +// that the two orders still serialize the same entries. The bucket width is read from the module +// under test so a drifted constant cannot make this reference silently disagree for a reason +// unrelated to the change. const BUCKET_MS = AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS function referenceProjection(map: AppState['agentStatusByPaneKey']): string { return JSON.stringify( Object.entries(map) - .sort(([a], [b]) => a.localeCompare(b)) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .map(([paneKey, entry]) => ({ paneKey, entryPaneKey: entry.paneKey, @@ -120,4 +123,26 @@ describe('mobile agent-status projection equivalence', () => { }).toEqual({ round, projection: referenceProjection(current) }) } }) + + it('serializes the same entries the localeCompare order did', () => { + resetRuntimeMobileAgentStatusProjectionCacheForTests() + // Keys where locale and code-unit order disagree: 'tab-1:' sorts after 'tab-10:' by code unit + // (':' > '0') and before it by locale, and case/accent handling differs too. + const map: AppState['agentStatusByPaneKey'] = {} + for (const paneKey of ['tab-1:leaf-0', 'tab-10:leaf-0', 'B:leaf-0', 'a:leaf-0', 'á:leaf-0']) { + map[paneKey] = makeEntry(0, { paneKey }) + } + const localeCompareOrderedEntries = JSON.parse( + JSON.stringify( + Object.entries(map) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([paneKey]) => paneKey) + ) + ) as string[] + const projected = ( + JSON.parse(buildRuntimeMobileAgentStatusProjectionForTests(map)) as { paneKey: string }[] + ).map((entry) => entry.paneKey) + expect([...projected].sort()).toEqual([...localeCompareOrderedEntries].sort()) + expect(projected).toHaveLength(localeCompareOrderedEntries.length) + }) }) diff --git a/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts b/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts new file mode 100644 index 00000000000..ed1991e60d2 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts @@ -0,0 +1,576 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '../store/types' +import { makeAgentStatusEntry, makeState } from './sync-runtime-graph-test-harness' +import type * as EditorDraftHashModule from './sync-runtime-graph/editor-draft-hash' + +// Why the mock: the draft hash is the only per-keystroke cost that scales with file size, so the +// regression this file guards is "how many characters were hashed", not "how long did it take". +// Instrumenting the real function through its own module keeps the counter out of shipped code. +const draftHashCounter = { calls: 0, chars: 0 } +vi.mock('./sync-runtime-graph/editor-draft-hash', async (importOriginal) => { + const actual = await importOriginal() + return { + stableHashString: (value: string): string => { + draftHashCounter.calls += 1 + draftHashCounter.chars += value.length + return actual.stableHashString(value) + } + } +}) + +const { stableHashString } = await import('./sync-runtime-graph/editor-draft-hash') +const { + buildRuntimeMobileAgentStatusProjectionForTests, + getRuntimeMobileSessionSyncKey, + resetRuntimeMobileAgentStatusProjectionCacheForTests, + resetRuntimeMobileSyncProjectionCachesForTests, + runtimeMobileSessionSyncKeysEqual +} = await import('./sync-runtime-graph') +const { + buildRuntimeMobileBrowserProjection, + buildRuntimeMobileEditorDraftsProjection, + buildRuntimeMobileOpenFilesProjection +} = await import('./sync-runtime-graph/sync-projections') +const { AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS } = await import('./sync-runtime-graph/graph-state') + +// ── Reference implementations: the pre-change bodies, kept verbatim ──────────────────── + +function referenceEditorDraftsProjection(editorDrafts: AppState['editorDrafts']): string { + return JSON.stringify( + Object.fromEntries( + Object.entries(editorDrafts).map(([fileId, content]) => [fileId, stableHashString(content)]) + ) + ) +} + +function referenceOpenFilesProjection(openFiles: AppState['openFiles']): string { + return JSON.stringify( + openFiles.map((file) => ({ + id: file.id, + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + language: file.language, + mode: file.mode, + diffSource: file.diffSource, + isDirty: file.isDirty, + isUntitled: file.isUntitled, + deleteUntouchedOnClose: file.deleteUntouchedOnClose, + markdownPreviewSourceFileId: file.markdownPreviewSourceFileId + })) + ) +} + +function referenceBrowserProjection(state: AppState): string { + return JSON.stringify({ + workspacesByWorktree: Object.fromEntries( + Object.entries(state.browserTabsByWorktree ?? {}).map(([worktreeId, workspaces]) => [ + worktreeId, + workspaces.map((workspace) => ({ + id: workspace.id, + activePageId: workspace.activePageId, + title: workspace.title, + url: workspace.url, + loading: workspace.loading, + canGoBack: workspace.canGoBack, + canGoForward: workspace.canGoForward + })) + ]) + ), + pagesByWorkspace: Object.fromEntries( + Object.entries(state.browserPagesByWorkspace ?? {}).map(([workspaceId, pages]) => [ + workspaceId, + pages.map((page) => ({ + id: page.id, + title: page.title, + url: page.url, + loading: page.loading, + canGoBack: page.canGoBack, + canGoForward: page.canGoForward + })) + ]) + ) + }) +} + +/** The pre-change agent-status serialization, including its `localeCompare` sort. */ +function referenceAgentStatusProjection(map: AppState['agentStatusByPaneKey']): string { + return JSON.stringify( + Object.entries(map) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([paneKey, entry]) => ({ + paneKey, + entryPaneKey: entry.paneKey, + state: entry.state, + workingMode: entry.workingMode ?? null, + prompt: entry.prompt, + updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS), + stateStartedAt: entry.stateStartedAt, + agentType: entry.agentType ?? null, + terminalTitle: entry.terminalTitle ?? null, + stateHistory: entry.stateHistory.map((history) => ({ + state: history.state, + prompt: history.prompt, + startedAt: history.startedAt, + interrupted: history.interrupted ?? null + })), + toolName: entry.toolName ?? null, + toolInput: entry.toolInput ?? null, + interactivePrompt: entry.interactivePrompt ?? null, + lastAssistantMessage: entry.lastAssistantMessage ?? null, + lastAssistantMessageIsToolOutput: entry.lastAssistantMessageIsToolOutput ?? null, + interrupted: entry.interrupted ?? null + })) + ) +} + +/** Same entries, code-unit ordered — proves the new sort changes order only, never content. */ +function sortedProjectionEntries(projection: string): unknown[] { + return (JSON.parse(projection) as unknown[]).slice().sort((a, b) => { + const left = JSON.stringify(a) + const right = JSON.stringify(b) + return left < right ? -1 : left > right ? 1 : 0 + }) +} + +// ── Fixtures ────────────────────────────────────────────────────────────────────────── + +const DRAFT_FILE_COUNT = 5 +const DRAFT_CHARS_PER_FILE = 40_000 + +function makeDrafts(): Record { + const drafts: Record = {} + for (let index = 0; index < DRAFT_FILE_COUNT; index += 1) { + drafts[`file-${index}`] = 'x'.repeat(DRAFT_CHARS_PER_FILE) + } + return drafts +} + +function makeOpenFile(index: number, overrides: Record = {}): never { + return { + id: `file-${index}`, + filePath: `/repo/src/file-${index}.ts`, + relativePath: `src/file-${index}.ts`, + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit', + isDirty: false, + isUntitled: false, + ...overrides + } as never +} + +function makeBrowserWorkspace(index: number, overrides: Record = {}): never { + return { + id: `ws-${index}`, + activePageId: `page-${index}`, + title: `tab ${index}`, + url: `https://example.test/${index}`, + loading: false, + canGoBack: false, + canGoForward: false, + ...overrides + } as never +} + +function makeBrowserPage(index: number, overrides: Record = {}): never { + return { + id: `page-${index}`, + title: `page ${index}`, + url: `https://example.test/${index}`, + loading: false, + canGoBack: false, + canGoForward: false, + ...overrides + } as never +} + +/** + * Characters handed back by `JSON.stringify`, which is the allocation these projections dominate. + * Counting bytes rather than calls keeps the comparison fair: the old code made one big call per + * rebuild, the new code makes one small call per changed entry. + */ +function countSerializedChars(run: () => void): number { + const original = JSON.stringify + let chars = 0 + const spy = vi.spyOn(JSON, 'stringify').mockImplementation(((...args: never[]) => { + const serialized = (original as (...a: never[]) => string)(...args) + chars += serialized?.length ?? 0 + return serialized + }) as typeof JSON.stringify) + try { + run() + return chars + } finally { + spy.mockRestore() + } +} + +beforeEach(() => { + draftHashCounter.calls = 0 + draftHashCounter.chars = 0 + resetRuntimeMobileSyncProjectionCachesForTests() + resetRuntimeMobileAgentStatusProjectionCacheForTests() +}) + +describe('editor draft projection on the typing path', () => { + it('hashes only the edited file per keystroke, not every open dirty file', () => { + const typedCharacters = 100 + const totalDraftChars = DRAFT_FILE_COUNT * DRAFT_CHARS_PER_FILE + + // Baseline: the pre-change uncached projection, driven by the same counter. + let drafts = makeDrafts() + referenceEditorDraftsProjection(drafts) + draftHashCounter.calls = 0 + draftHashCounter.chars = 0 + for (let keystroke = 0; keystroke < typedCharacters; keystroke += 1) { + drafts = { ...drafts, 'file-0': `${drafts['file-0']}a` } + referenceEditorDraftsProjection(drafts) + } + const before = { calls: draftHashCounter.calls, chars: draftHashCounter.chars } + + resetRuntimeMobileSyncProjectionCachesForTests() + let memoDrafts = makeDrafts() + buildRuntimeMobileEditorDraftsProjection(memoDrafts) + draftHashCounter.calls = 0 + draftHashCounter.chars = 0 + for (let keystroke = 0; keystroke < typedCharacters; keystroke += 1) { + memoDrafts = { ...memoDrafts, 'file-0': `${memoDrafts['file-0']}a` } + buildRuntimeMobileEditorDraftsProjection(memoDrafts) + } + const after = { calls: draftHashCounter.calls, chars: draftHashCounter.chars } + + // Keystroke k has grown file-0 by k characters, so the exact totals are closed form. + const growth = (typedCharacters * (typedCharacters + 1)) / 2 + // Before: every keystroke rehashes all five drafts. + expect(before).toEqual({ + calls: typedCharacters * DRAFT_FILE_COUNT, + chars: typedCharacters * totalDraftChars + growth + }) + // After: one hash of one draft per keystroke. + expect(after).toEqual({ + calls: typedCharacters, + chars: typedCharacters * DRAFT_CHARS_PER_FILE + growth + }) + expect(before.chars / after.chars).toBeGreaterThan(DRAFT_FILE_COUNT - 0.1) + }) + + it('matches the uncached projection byte for byte across draft shapes', () => { + const shapes: Record[] = [ + {}, + { 'file-a': '' }, + { 'file-a': 'hello' }, + { 'file-a': 'hello', 'file-b': 'world' }, + { 'file-b': 'world', 'file-a': 'hello' }, + { '2': 'numeric-like key', 'file-a': 'hello', '1': 'other' }, + { 'quote"and\\slash': 'body with "quotes" and \\ and \u{1f389}' }, + { 'file-a': 'hello', 'file-b': 'world', 'file-c': 'third' }, + { 'file-a': 'HELLO', 'file-c': 'third' } + ] + for (const [index, shape] of shapes.entries()) { + expect({ index, projection: buildRuntimeMobileEditorDraftsProjection(shape) }).toEqual({ + index, + projection: referenceEditorDraftsProjection(shape) + }) + } + }) +}) + +describe('agent-status projection sort', () => { + it('constructs no ICU collator, and keeps the same entries as the localeCompare order', () => { + // MAX_LIVE_AGENT_STATUSES — the cap a busy session actually reaches. Real pane keys are + // `:`, so model them as unordered hex rather than a sorted + // `tab-` run that would let TimSort skip most comparisons. + let seed = 0x2f6e2b1 + const nextHex = (): string => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff + return seed.toString(16).padStart(8, '0') + } + const paneKeys = Array.from({ length: 500 }, () => `${nextHex()}-${nextHex()}:${nextHex()}`) + const map: AppState['agentStatusByPaneKey'] = {} + for (const [index, paneKey] of paneKeys.entries()) { + map[paneKey] = makeAgentStatusEntry({ paneKey, prompt: `prompt ${index}` }) + } + // One ping replaces one entry and re-spreads the map, so the sort runs in full again. + const pinged = { ...map, [paneKeys[0]]: makeAgentStatusEntry({ paneKey: paneKeys[0] }) } + + const localeCompareSpy = vi.spyOn(String.prototype, 'localeCompare') + let projection = '' + let beforeCalls = 0 + let afterCalls = 0 + try { + referenceAgentStatusProjection(map) + beforeCalls = localeCompareSpy.mock.calls.length + localeCompareSpy.mockClear() + resetRuntimeMobileAgentStatusProjectionCacheForTests() + projection = buildRuntimeMobileAgentStatusProjectionForTests(map) + buildRuntimeMobileAgentStatusProjectionForTests(pinged) + afterCalls = localeCompareSpy.mock.calls.length + } finally { + // `mockRestore` clears the recorded calls, so read the counts first. + localeCompareSpy.mockRestore() + } + // Before: thousands of ICU collator comparisons for a single ping. + expect(beforeCalls).toBeGreaterThan(3000) + expect(afterCalls).toBe(0) + + // The projection is identical up to ordering, and ordering is only ever `===`-compared. + expect(sortedProjectionEntries(projection)).toEqual( + sortedProjectionEntries(referenceAgentStatusProjection(map)) + ) + }) + + it('is deterministic for keys where locale and code-unit order disagree', () => { + const map: AppState['agentStatusByPaneKey'] = {} + for (const paneKey of ['b:leaf', 'A:leaf', 'a:leaf', 'á:leaf', 'B:leaf']) { + map[paneKey] = makeAgentStatusEntry({ paneKey }) + } + resetRuntimeMobileAgentStatusProjectionCacheForTests() + const first = buildRuntimeMobileAgentStatusProjectionForTests(map) + resetRuntimeMobileAgentStatusProjectionCacheForTests() + const second = buildRuntimeMobileAgentStatusProjectionForTests({ ...map }) + expect(second).toBe(first) + expect(sortedProjectionEntries(first)).toEqual( + sortedProjectionEntries(referenceAgentStatusProjection(map)) + ) + }) +}) + +describe('open-files and browser projections', () => { + it('re-serializes only the changed entry per store write', () => { + const files = Array.from({ length: 20 }, (_value, index) => makeOpenFile(index)) + const writes = 50 + const driveWrites = (project: (openFiles: AppState['openFiles']) => string): void => { + let openFiles = files as unknown as AppState['openFiles'] + project(openFiles) + for (let write = 0; write < writes; write += 1) { + const next = [...openFiles] + next[0] = makeOpenFile(0, { isDirty: write % 2 === 0 }) + openFiles = next as unknown as AppState['openFiles'] + project(openFiles) + } + } + + const before = countSerializedChars(() => { + driveWrites(referenceOpenFilesProjection) + }) + resetRuntimeMobileSyncProjectionCachesForTests() + const after = countSerializedChars(() => { + driveWrites(buildRuntimeMobileOpenFilesProjection) + }) + // Only the flipped file re-serializes; the other 19 are reused by identity. + expect(before / after).toBeGreaterThan(14) + }) + + it('re-serializes only the changed browser bucket per store write', () => { + const workspaces = Array.from({ length: 8 }, (_value, index) => makeBrowserWorkspace(index)) + const pagesByWorkspace: Record = {} + for (let index = 0; index < 8; index += 1) { + pagesByWorkspace[`ws-${index}`] = [makeBrowserPage(index)] as never[] + } + const initial = makeState({ + browserTabsByWorktree: { 'wt-1': workspaces, 'wt-2': workspaces } as never, + browserPagesByWorkspace: pagesByWorkspace as never + }) + const writes = 50 + const driveWrites = (project: (state: AppState) => string): void => { + let state = initial + project(state) + for (let write = 0; write < writes; write += 1) { + const nextWorkspaces = [...(state.browserTabsByWorktree['wt-1'] ?? [])] + nextWorkspaces[0] = makeBrowserWorkspace(0, { title: `tab 0 (${write})` }) + state = makeState({ + ...state, + browserTabsByWorktree: { + ...state.browserTabsByWorktree, + 'wt-1': nextWorkspaces + } as never + }) + project(state) + } + } + + const before = countSerializedChars(() => { + driveWrites(referenceBrowserProjection) + }) + resetRuntimeMobileSyncProjectionCachesForTests() + const after = countSerializedChars(() => { + driveWrites(buildRuntimeMobileBrowserProjection) + }) + // Only the 'wt-1' bucket re-serializes; 'wt-2' and every page bucket are reused. + expect(before / after).toBeGreaterThan(2.5) + }) + + it('matches the uncached projections byte for byte across shapes', () => { + const openFileShapes: AppState['openFiles'][] = [ + [] as unknown as AppState['openFiles'], + [makeOpenFile(0)] as unknown as AppState['openFiles'], + [makeOpenFile(0, { isDirty: true })] as unknown as AppState['openFiles'], + [ + makeOpenFile(0, { isDirty: true, diffSource: 'working' }), + makeOpenFile(1, { mode: 'diff', markdownPreviewSourceFileId: 'file-0' }), + makeOpenFile(2, { isUntitled: true, deleteUntouchedOnClose: true, language: undefined }) + ] as unknown as AppState['openFiles'] + ] + for (const [index, shape] of openFileShapes.entries()) { + expect({ index, projection: buildRuntimeMobileOpenFilesProjection(shape) }).toEqual({ + index, + projection: referenceOpenFilesProjection(shape) + }) + } + + const browserShapes: AppState[] = [ + makeState({}), + makeState({ browserTabsByWorktree: { 'wt-1': [makeBrowserWorkspace(0)] } as never }), + makeState({ + browserTabsByWorktree: { + 'wt-1': [makeBrowserWorkspace(0, { title: undefined, loading: true })], + '3': [makeBrowserWorkspace(1)] + } as never, + browserPagesByWorkspace: { + 'ws-0': [makeBrowserPage(0), makeBrowserPage(1, { canGoBack: true })], + 'ws-1': [] + } as never + }), + makeState({ + browserTabsByWorktree: {} as never, + browserPagesByWorkspace: { 'ws-9': [makeBrowserPage(9, { url: 'a"b\\c' })] } as never + }) + ] + for (const [index, shape] of browserShapes.entries()) { + expect({ index, projection: buildRuntimeMobileBrowserProjection(shape) }).toEqual({ + index, + projection: referenceBrowserProjection(shape) + }) + } + }) +}) + +describe('sync key transitions', () => { + it('fires on exactly the transitions the uncached projections would have fired on', () => { + const drafts = { 'file-0': 'aaa', 'file-1': 'bbb' } + const files = [makeOpenFile(0), makeOpenFile(1)] as unknown as AppState['openFiles'] + const workspaces = [makeBrowserWorkspace(0)] as never + const status = { + 'tab-0:leaf-0': makeAgentStatusEntry({ paneKey: 'tab-0:leaf-0' }) + } as AppState['agentStatusByPaneKey'] + + const base = makeState({ + editorDrafts: drafts, + openFiles: files, + browserTabsByWorktree: { 'wt-1': workspaces } as never, + browserPagesByWorkspace: { 'ws-0': [makeBrowserPage(0)] } as never, + agentStatusByPaneKey: status + }) + + // Each step returns the next state; the flag is whether a mobile-visible input really moved. + const steps: { name: string; next: (from: AppState) => AppState }[] = [ + { name: 'no-op re-spread', next: (from) => makeState({ ...from }) }, + { + name: 'keystroke in one draft', + next: (from) => + makeState({ ...from, editorDrafts: { ...from.editorDrafts, 'file-0': 'aaab' } }) + }, + { + name: 'draft reverted to the same text', + next: (from) => + makeState({ ...from, editorDrafts: { ...from.editorDrafts, 'file-0': 'aaab' } }) + }, + { + name: 'draft removed', + next: (from) => makeState({ ...from, editorDrafts: { 'file-1': 'bbb' } }) + }, + { + name: 'isDirty flip', + next: (from) => + makeState({ + ...from, + openFiles: [makeOpenFile(0, { isDirty: true }), from.openFiles[1]] as never + }) + }, + { + name: 'open-files re-spread with identical content', + next: (from) => makeState({ ...from, openFiles: [...from.openFiles] as never }) + }, + { + name: 'browser title tick', + next: (from) => + makeState({ + ...from, + browserTabsByWorktree: { + 'wt-1': [makeBrowserWorkspace(0, { title: 'new title' })] + } as never + }) + }, + { + name: 'browser page loading flip', + next: (from) => + makeState({ + ...from, + browserPagesByWorkspace: { + 'ws-0': [makeBrowserPage(0, { loading: true })] + } as never + }) + }, + { + name: 'agent-status ping with an unchanged payload', + next: (from) => + makeState({ + ...from, + agentStatusByPaneKey: { + 'tab-0:leaf-0': makeAgentStatusEntry({ paneKey: 'tab-0:leaf-0' }) + } as never + }) + }, + { + name: 'agent-status prompt change', + next: (from) => + makeState({ + ...from, + agentStatusByPaneKey: { + 'tab-0:leaf-0': makeAgentStatusEntry({ paneKey: 'tab-0:leaf-0', prompt: 'new' }) + } as never + }) + }, + { + name: 'second agent added', + next: (from) => + makeState({ + ...from, + agentStatusByPaneKey: { + ...from.agentStatusByPaneKey, + 'tab-1:leaf-0': makeAgentStatusEntry({ paneKey: 'tab-1:leaf-0' }) + } as never + }) + } + ] + + const referenceTuple = (state: AppState): string[] => [ + referenceEditorDraftsProjection(state.editorDrafts), + referenceOpenFilesProjection(state.openFiles), + referenceBrowserProjection(state), + referenceAgentStatusProjection(state.agentStatusByPaneKey ?? {}) + ] + + resetRuntimeMobileSyncProjectionCachesForTests() + resetRuntimeMobileAgentStatusProjectionCacheForTests() + let previousState = base + let previousKey = getRuntimeMobileSessionSyncKey(base, undefined, undefined, false) + let previousReference = referenceTuple(base) + + for (const step of steps) { + const state = step.next(previousState) + const key = getRuntimeMobileSessionSyncKey(state, previousState, previousKey, false) + const reference = referenceTuple(state) + const referenceChanged = reference.some((part, index) => part !== previousReference[index]) + const keyChanged = !runtimeMobileSessionSyncKeysEqual(key, previousKey) + expect({ step: step.name, changed: keyChanged }).toEqual({ + step: step.name, + changed: referenceChanged + }) + previousState = state + previousKey = key + previousReference = reference + } + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index dd1bdb58d5f..b836a037134 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -21,6 +21,7 @@ import { runtimeMobileSessionSyncKeysEqual } from './sync-runtime-graph/sync-key' import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots' +import { resetRuntimeMobileSyncProjectionCachesForTests } from './sync-runtime-graph/sync-projections' import type { RegisteredTerminalTab } from './sync-runtime-graph/types' export type { RegisteredTerminalTab, RuntimeMobileSessionSyncKey } from './sync-runtime-graph/types' @@ -28,6 +29,7 @@ export { AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS, buildRuntimeMobileAgentStatusProjectionForTests, resetRuntimeMobileAgentStatusProjectionCacheForTests, + resetRuntimeMobileSyncProjectionCachesForTests, canSkipRuntimeMobileSessionSyncKeyBuild, getRuntimeMobileSessionSyncKey, runtimeMobileSessionSyncKeysEqual, diff --git a/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts index f11e58a388f..5e14bd4a8ae 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts @@ -43,8 +43,11 @@ export function buildRuntimeMobileAgentStatusProjection( // A status ping replaces one entry and re-spreads the map; reuse every other entry. const entries = new Map() const parts: string[] = [] + // Code-unit order, not `localeCompare`: this projection is only ever compared with `===`, so it + // must be deterministic, not locale-correct — and an ICU collator per comparison is ~4.5k calls + // per ping at the 500-entry cap. for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey).sort(([a], [b]) => - a.localeCompare(b) + a < b ? -1 : a > b ? 1 : 0 )) { const previous = cached?.entries.get(paneKey) const entryCache = diff --git a/src/renderer/src/runtime/sync-runtime-graph/editor-draft-hash.ts b/src/renderer/src/runtime/sync-runtime-graph/editor-draft-hash.ts new file mode 100644 index 00000000000..a38ebcf9f35 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/editor-draft-hash.ts @@ -0,0 +1,15 @@ +/** + * FNV-1a stamp for one editor draft's text. + * + * Why its own module: it is the single hash shared by the mobile sync key and the mobile session + * snapshot, and its cost is proportional to the draft's length — so callers must memoize it per + * file rather than re-running it whenever the drafts record is re-spread. + */ +export function stableHashString(value: string): string { + let hash = 2166136261 + for (let i = 0; i < value.length; i += 1) { + hash ^= value.charCodeAt(i) + hash = Math.imul(hash, 16777619) + } + return `draft:${value.length}:${(hash >>> 0).toString(16)}` +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts b/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts index 8aa0d87064a..4869479bfaa 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts @@ -7,8 +7,12 @@ import type { } from '../../../../shared/runtime-types' import type { AgentStatusProjectionCache, + BrowserPagesProjectionCache, + BrowserWorkspacesProjectionCache, + EditorDraftHashCache, MobileSessionWorktreeInputs, OpenFileIndexes, + OpenFilesProjectionCache, RegisteredTerminalTab, TabsProjectionCache } from './types' @@ -54,10 +58,12 @@ export const graphState = { publishedMobileSessionSnapshotByWorktree: new Map(), cachedTabsProjection: null as TabsProjectionCache | null, cachedAgentStatusProjection: null as AgentStatusProjectionCache | null, + cachedOpenFilesProjection: null as OpenFilesProjectionCache | null, + cachedBrowserWorkspacesProjection: null as BrowserWorkspacesProjectionCache | null, + cachedBrowserPagesProjection: null as BrowserPagesProjectionCache | null, cachedOpenFileIndexesSource: null as AppState['openFiles'] | null, cachedOpenFileIndexes: null as OpenFileIndexes | null, - cachedEditorDraftsSource: null as AppState['editorDrafts'] | null, - cachedEditorDraftVersionByFileId: null as Map | null, + cachedEditorDraftHashes: null as EditorDraftHashCache | null, cachedMobileTerminalThemeSettings: null as AppState['settings'] | null, cachedMobileTerminalThemeSystemPrefersDark: null as boolean | null, cachedMobileTerminalTheme: undefined as RuntimeMobileTerminalTheme | undefined, diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts index 0d6157fe75d..d692a5b83d3 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts @@ -51,29 +51,6 @@ export function getOpenFileIndexes(openFiles: AppState['openFiles']): OpenFileIn return graphState.cachedOpenFileIndexes } -export function getEditorDraftVersionByFileId( - editorDrafts: AppState['editorDrafts'] -): Map { - if ( - graphState.cachedEditorDraftsSource === editorDrafts && - graphState.cachedEditorDraftVersionByFileId - ) { - return graphState.cachedEditorDraftVersionByFileId - } - const versions = new Map() - for (const [fileId, content] of Object.entries(editorDrafts)) { - let hash = 2166136261 - for (let index = 0; index < content.length; index += 1) { - hash ^= content.charCodeAt(index) - hash = Math.imul(hash, 16777619) - } - versions.set(fileId, `draft:${content.length}:${(hash >>> 0).toString(16)}`) - } - graphState.cachedEditorDraftsSource = editorDrafts - graphState.cachedEditorDraftVersionByFileId = versions - return versions -} - export function buildMobileSessionAgentStatusByWorktree( agentStatusByPaneKey: AppState['agentStatusByPaneKey'], tabsByWorktree: AppState['tabsByWorktree'] diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts index f6ba7f49cfb..7dc106a6cd4 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts @@ -14,9 +14,9 @@ import { import { buildMobileSessionAgentStatusByWorktree, buildMobileSessionWorktreeInputs, - getEditorDraftVersionByFileId, getOpenFileIndexes } from './mobile-session-inputs' +import { getEditorDraftVersionByFileId } from './sync-projections' import { getMobileTerminalTheme } from './mobile-terminal-theme' import { isMobilePublishableBrowserWorkspace, diff --git a/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts index 0627bccee06..44a1606b056 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts @@ -1,11 +1,19 @@ import type { AppState } from '@/store/types' import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' +import { stableHashString } from './editor-draft-hash' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { EMPTY_BROWSER_PAGES_BY_WORKSPACE, EMPTY_BROWSER_TABS_BY_WORKTREE, graphState } from './graph-state' +import type { + BrowserPagesProjectionCacheEntry, + BrowserWorkspacesProjectionCacheEntry, + EditorDraftHashCache, + EditorDraftHashCacheEntry, + OpenFilesProjectionCacheEntry +} from './types' export function getBrowserTabsByWorktree(state: AppState): AppState['browserTabsByWorktree'] { // Some callers/tests build partial pre-browser states; treat missing slices as empty. @@ -76,72 +84,192 @@ export function buildRuntimeMobileTabsProjection( } export function buildRuntimeMobileOpenFilesProjection(openFiles: AppState['openFiles']): string { - return JSON.stringify( - openFiles.map((file) => ({ - id: file.id, - filePath: file.filePath, - relativePath: file.relativePath, - worktreeId: file.worktreeId, - language: file.language, - mode: file.mode, - diffSource: file.diffSource, - isDirty: file.isDirty, - isUntitled: file.isUntitled, - deleteUntouchedOnClose: file.deleteUntouchedOnClose, - markdownPreviewSourceFileId: file.markdownPreviewSourceFileId - })) - ) + const cached = graphState.cachedOpenFilesProjection + if (cached?.source === openFiles) { + return cached.projection + } + + // An isDirty flip replaces one file and re-spreads the array; reuse every other file. + const previousEntries = cached?.entries + const entries = new Map() + const parts: string[] = [] + for (const file of openFiles) { + const previous = previousEntries?.get(file.id) + const entry = + previous?.file === file + ? previous + : { + file, + projection: JSON.stringify({ + id: file.id, + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + language: file.language, + mode: file.mode, + diffSource: file.diffSource, + isDirty: file.isDirty, + isUntitled: file.isUntitled, + deleteUntouchedOnClose: file.deleteUntouchedOnClose, + markdownPreviewSourceFileId: file.markdownPreviewSourceFileId + }) + } + entries.set(file.id, entry) + parts.push(entry.projection) + } + const projection = `[${parts.join(',')}]` + graphState.cachedOpenFilesProjection = { source: openFiles, entries, projection } + return projection +} + +function buildBrowserWorkspacesProjection( + browserTabsByWorktree: AppState['browserTabsByWorktree'] +): string { + const cached = graphState.cachedBrowserWorkspacesProjection + if (cached?.source === browserTabsByWorktree) { + return cached.projection + } + + const previousEntries = cached?.entries + const entries = new Map() + const parts: string[] = [] + for (const [worktreeId, workspaces] of Object.entries(browserTabsByWorktree)) { + const previous = previousEntries?.get(worktreeId) + const entry = + previous?.workspaces === workspaces + ? previous + : { + workspaces, + keyJson: previous?.keyJson ?? JSON.stringify(worktreeId), + projection: JSON.stringify( + workspaces.map((workspace) => ({ + id: workspace.id, + activePageId: workspace.activePageId, + title: workspace.title, + url: workspace.url, + loading: workspace.loading, + canGoBack: workspace.canGoBack, + canGoForward: workspace.canGoForward + })) + ) + } + entries.set(worktreeId, entry) + parts.push(`${entry.keyJson}:${entry.projection}`) + } + const projection = `{${parts.join(',')}}` + graphState.cachedBrowserWorkspacesProjection = { + source: browserTabsByWorktree, + entries, + projection + } + return projection +} + +function buildBrowserPagesProjection( + browserPagesByWorkspace: AppState['browserPagesByWorkspace'] +): string { + const cached = graphState.cachedBrowserPagesProjection + if (cached?.source === browserPagesByWorkspace) { + return cached.projection + } + + const previousEntries = cached?.entries + const entries = new Map() + const parts: string[] = [] + for (const [workspaceId, pages] of Object.entries(browserPagesByWorkspace)) { + const previous = previousEntries?.get(workspaceId) + const entry = + previous?.pages === pages + ? previous + : { + pages, + keyJson: previous?.keyJson ?? JSON.stringify(workspaceId), + projection: JSON.stringify( + pages.map((page) => ({ + id: page.id, + title: page.title, + url: page.url, + loading: page.loading, + canGoBack: page.canGoBack, + canGoForward: page.canGoForward + })) + ) + } + entries.set(workspaceId, entry) + parts.push(`${entry.keyJson}:${entry.projection}`) + } + const projection = `{${parts.join(',')}}` + graphState.cachedBrowserPagesProjection = { + source: browserPagesByWorkspace, + entries, + projection + } + return projection } export function buildRuntimeMobileBrowserProjection(state: AppState): string { - const browserTabsByWorktree = getBrowserTabsByWorktree(state) - const browserPagesByWorkspace = getBrowserPagesByWorkspace(state) - return JSON.stringify({ - workspacesByWorktree: Object.fromEntries( - Object.entries(browserTabsByWorktree).map(([worktreeId, workspaces]) => [ - worktreeId, - workspaces.map((workspace) => ({ - id: workspace.id, - activePageId: workspace.activePageId, - title: workspace.title, - url: workspace.url, - loading: workspace.loading, - canGoBack: workspace.canGoBack, - canGoForward: workspace.canGoForward - })) - ]) - ), - pagesByWorkspace: Object.fromEntries( - Object.entries(browserPagesByWorkspace).map(([workspaceId, pages]) => [ - workspaceId, - pages.map((page) => ({ - id: page.id, - title: page.title, - url: page.url, - loading: page.loading, - canGoBack: page.canGoBack, - canGoForward: page.canGoForward - })) - ]) - ) - }) + // A title/url/loading tick replaces one worktree or workspace bucket; reuse the rest. + return `{"workspacesByWorktree":${buildBrowserWorkspacesProjection( + getBrowserTabsByWorktree(state) + )},"pagesByWorkspace":${buildBrowserPagesProjection(getBrowserPagesByWorkspace(state))}}` +} + +/** + * Why memoized per file id: `setEditorDraft` fires on every Monaco keystroke and re-spreads + * `editorDrafts`, so an unmemoized rebuild re-hashed every open dirty file's full text on the + * input path. Only the typed file's draft string changes identity, so only it needs rehashing. + */ +function getEditorDraftHashCache(editorDrafts: AppState['editorDrafts']): EditorDraftHashCache { + const cached = graphState.cachedEditorDraftHashes + if (cached?.source === editorDrafts) { + return cached + } + + const previousEntries = cached?.entries + const entries = new Map() + const hashByFileId = new Map() + const parts: string[] = [] + for (const [fileId, content] of Object.entries(editorDrafts)) { + const previous = previousEntries?.get(fileId) + let entry: EditorDraftHashCacheEntry + if (previous?.content === content) { + entry = previous + } else { + const fileIdJson = previous?.fileIdJson ?? JSON.stringify(fileId) + const hash = stableHashString(content) + entry = { content, hash, fileIdJson, projection: `${fileIdJson}:${JSON.stringify(hash)}` } + } + entries.set(fileId, entry) + hashByFileId.set(fileId, entry.hash) + parts.push(entry.projection) + } + const next: EditorDraftHashCache = { + source: editorDrafts, + entries, + hashByFileId, + projection: `{${parts.join(',')}}` + } + graphState.cachedEditorDraftHashes = next + return next } export function buildRuntimeMobileEditorDraftsProjection( editorDrafts: AppState['editorDrafts'] ): string { - return JSON.stringify( - Object.fromEntries( - Object.entries(editorDrafts).map(([fileId, content]) => [fileId, stableHashString(content)]) - ) - ) + return getEditorDraftHashCache(editorDrafts).projection } -export function stableHashString(value: string): string { - let hash = 2166136261 - for (let i = 0; i < value.length; i += 1) { - hash ^= value.charCodeAt(i) - hash = Math.imul(hash, 16777619) - } - return `draft:${value.length}:${(hash >>> 0).toString(16)}` +/** Per-file draft version stamps for the mobile session snapshot; shares the keystroke memo. */ +export function getEditorDraftVersionByFileId( + editorDrafts: AppState['editorDrafts'] +): ReadonlyMap { + return getEditorDraftHashCache(editorDrafts).hashByFileId +} + +export function resetRuntimeMobileSyncProjectionCachesForTests(): void { + graphState.cachedTabsProjection = null + graphState.cachedOpenFilesProjection = null + graphState.cachedBrowserWorkspacesProjection = null + graphState.cachedBrowserPagesProjection = null + graphState.cachedEditorDraftHashes = null } diff --git a/src/renderer/src/runtime/sync-runtime-graph/types.ts b/src/renderer/src/runtime/sync-runtime-graph/types.ts index b51b8cd8afa..448a3652a5d 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/types.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/types.ts @@ -60,6 +60,48 @@ export type TabsProjectionCache = { entries: Map projection: string } +export type OpenFilesProjectionCacheEntry = { + file: AppState['openFiles'][number] + projection: string +} +export type OpenFilesProjectionCache = { + source: AppState['openFiles'] + entries: Map + projection: string +} +export type BrowserWorkspacesProjectionCacheEntry = { + workspaces: NonNullable + keyJson: string + projection: string +} +export type BrowserWorkspacesProjectionCache = { + source: AppState['browserTabsByWorktree'] + entries: Map + projection: string +} +export type BrowserPagesProjectionCacheEntry = { + pages: NonNullable + keyJson: string + projection: string +} +export type BrowserPagesProjectionCache = { + source: AppState['browserPagesByWorkspace'] + entries: Map + projection: string +} +/** One dirty file's FNV draft stamp plus its pre-serialized projection fragment. */ +export type EditorDraftHashCacheEntry = { + content: string + hash: string + fileIdJson: string + projection: string +} +export type EditorDraftHashCache = { + source: AppState['editorDrafts'] + entries: Map + hashByFileId: Map + projection: string +} export type AgentStatusProjectionCacheEntry = { entry: AppState['agentStatusByPaneKey'][string] projection: string From 21a706a9323a9b527b1e5bdff52262df2226548f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:04:06 -0700 Subject: [PATCH 07/25] perf(terminal): stop shipping every agent spinner title frame to the renderer (#18155) Main re-asserts a working OSC title per pane every 80ms (12.5/sec) while an agent works, and every frame became its own pty:sideEffect IPC message. Both renderer store writes already discard those frames via isDecorativeAgentTitleFrameChange, and paired remote clients already never see them (RuntimeClientEventBus's per-listener title gate). Only the local desktop renderer was still paying for them. Apply the same decorative gate main already computes for the mobile fan-out one hop earlier, keeping a 500ms heartbeat so the renderer's 1500ms hook-done quiet window still sees a working title and can cancel a Pi/OMP milestone 'done'. --- .../decorative-title-fact-emission.test.ts | 49 ++++++++ .../runtime/decorative-title-fact-emission.ts | 38 ++++++ ...e-get-unpersisted-tracked-title-for-pty.ts | 34 ++++-- .../decorative-title-fact-throttle.spec.ts | 110 ++++++++++++++++++ .../terminal-side-effect-facts.spec.ts | 102 +++++++++------- src/main/runtime/orca-runtime.test.ts | 1 + .../runtime/runtime-terminal-state-records.ts | 2 + 7 files changed, 284 insertions(+), 52 deletions(-) create mode 100644 src/main/runtime/decorative-title-fact-emission.test.ts create mode 100644 src/main/runtime/decorative-title-fact-emission.ts create mode 100644 src/main/runtime/orca-runtime-tests/decorative-title-fact-throttle.spec.ts diff --git a/src/main/runtime/decorative-title-fact-emission.test.ts b/src/main/runtime/decorative-title-fact-emission.test.ts new file mode 100644 index 00000000000..609f2e06084 --- /dev/null +++ b/src/main/runtime/decorative-title-fact-emission.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { + DECORATIVE_TITLE_FACT_HEARTBEAT_MS, + shouldEmitTitleFactForFrame +} from './decorative-title-fact-emission' + +const base = { + decorativeOnly: true, + staleWorkingTitleClear: false, + lastEmittedAtMs: 1_000, + nowMs: 1_000 +} + +describe('shouldEmitTitleFactForFrame', () => { + it('always emits a frame that is not a decorative repeat', () => { + expect(shouldEmitTitleFactForFrame({ ...base, decorativeOnly: false })).toBe(true) + }) + + it('emits the first frame of a pane', () => { + expect(shouldEmitTitleFactForFrame({ ...base, lastEmittedAtMs: null })).toBe(true) + }) + + it('suppresses a decorative repeat inside the heartbeat window', () => { + expect( + shouldEmitTitleFactForFrame({ ...base, nowMs: 1_000 + DECORATIVE_TITLE_FACT_HEARTBEAT_MS - 1 }) + ).toBe(false) + }) + + it('lets a decorative repeat through once the heartbeat window elapses', () => { + expect( + shouldEmitTitleFactForFrame({ ...base, nowMs: 1_000 + DECORATIVE_TITLE_FACT_HEARTBEAT_MS }) + ).toBe(true) + }) + + it('never throttles a timer-synthesized stale-working clear', () => { + // Why: it carries a staleWorkingTitleClear flag no earlier repeat can stand in for. + expect(shouldEmitTitleFactForFrame({ ...base, staleWorkingTitleClear: true })).toBe(true) + }) + + it('emits after a backwards clock step instead of parking until it catches up', () => { + expect(shouldEmitTitleFactForFrame({ ...base, nowMs: 900 })).toBe(true) + }) + + it('keeps at least three frames inside the renderer hook-done quiet window', () => { + // Why: observeTitle's arriving working title is what cancels a Pi/OMP milestone `done` + // scheduled with HOOK_DONE_QUIET_MS = 1500. Losing that would mint a false completion. + expect(DECORATIVE_TITLE_FACT_HEARTBEAT_MS * 3).toBeLessThanOrEqual(1_500) + }) +}) diff --git a/src/main/runtime/decorative-title-fact-emission.ts b/src/main/runtime/decorative-title-fact-emission.ts new file mode 100644 index 00000000000..d8248dc12c5 --- /dev/null +++ b/src/main/runtime/decorative-title-fact-emission.ts @@ -0,0 +1,38 @@ +/** + * Why: an agent spinner re-emits a semantically identical OSC title ~12.5x/sec (Orca's own + * synthetic frame timer, Pi/OMP, Claude Code, Grok), and main ships every frame to the renderer + * as its own `pty:sideEffect` message. Both renderer store writes already discard those frames + * via `isDecorativeAgentTitleFrameChange`, so the message is pure cross-process cost. + * + * Why a heartbeat and not a hard drop: `agentCompletionCoordinator.observeTitle` treats an + * arriving *working* title as "still working" and cancels a scheduled hook-`done` completion + * inside `HOOK_DONE_QUIET_MS` (1500ms). That is exactly how a Pi/OMP milestone `done` emitted + * mid-turn is stopped from minting a completion notification, and the frames that carry it are + * decorative repeats. 500ms keeps 3 frames inside that window. + */ +export const DECORATIVE_TITLE_FACT_HEARTBEAT_MS = 500 + +export type DecorativeTitleFactEmissionInput = { + /** The frame's decorative gate key matches the previous frame's. */ + decorativeOnly: boolean + /** Timer-synthesized stale-working clear — carries a flag no repeat can stand in for. */ + staleWorkingTitleClear: boolean + lastEmittedAtMs: number | null + nowMs: number +} + +export function shouldEmitTitleFactForFrame({ + decorativeOnly, + staleWorkingTitleClear, + lastEmittedAtMs, + nowMs +}: DecorativeTitleFactEmissionInput): boolean { + if (!decorativeOnly || staleWorkingTitleClear) { + return true + } + if (lastEmittedAtMs === null) { + return true + } + // A backwards clock step must not park the heartbeat until it catches up. + return nowMs < lastEmittedAtMs || nowMs - lastEmittedAtMs >= DECORATIVE_TITLE_FACT_HEARTBEAT_MS +} diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index 38bae372176..3ece144bc24 100644 --- a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts +++ b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts @@ -1,6 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithEmitDaemonPtyTransientFact } from './orca-runtime-emit-daemon-pty-transient-fact' import { getDecorativeAgentTitleSignature } from '../../shared/agent-decorative-title-signature' +import { shouldEmitTitleFactForFrame } from './decorative-title-fact-emission' import type { RuntimePtyTitleTrackerEntry } from './runtime-terminal-state-records' import { createTerminalTitleTracker } from '../../shared/terminal-output-side-effects' import { detectAgentStatusFromTitle } from '../../shared/agent-detection' @@ -64,20 +65,36 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime const tracker = createTerminalTitleTracker( { onTitle: (normalizedTitle, rawTitle, meta) => { - this.recordTerminalSideEffectFact(ptyId, { - kind: 'title', - normalizedTitle, - rawTitle, - ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) - }) - const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta) - const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) const live = this.ptyTitleTrackersByPtyId.get(ptyId) const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle) const decorativeOnly = live?.lastMobileTitleGateKey === gateKey if (live) { live.lastMobileTitleGateKey = gateKey } + // Why: the same gate the mobile fan-out below already uses, applied one hop earlier — + // a spinner frame the renderer store discards should not cost a pty:sideEffect message + // at all. See decorative-title-fact-emission.ts for why repeats still heartbeat. + const nowMs = Date.now() + if ( + shouldEmitTitleFactForFrame({ + decorativeOnly, + staleWorkingTitleClear: meta?.staleWorkingTitleClear === true, + lastEmittedAtMs: live?.lastTitleFactAtMs ?? null, + nowMs + }) + ) { + if (live) { + live.lastTitleFactAtMs = nowMs + } + this.recordTerminalSideEffectFact(ptyId, { + kind: 'title', + normalizedTitle, + rawTitle, + ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) + }) + } + const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta) + const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) const tracksReplicatedStatus = live?.applyingChunk === true && this.mobileSessionTabListeners.size > 0 const titleStatus = tracksReplicatedStatus ? detectAgentStatusFromTitle(rawTitle) : null @@ -151,6 +168,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime tracker, applyingChunk: false, lastMobileTitleGateKey: null, + lastTitleFactAtMs: null, chunkTouchedSessionTabs: false, pendingFacts: [], // Why: command-code facts exist only for the pty:sideEffect channel — diff --git a/src/main/runtime/orca-runtime-tests/decorative-title-fact-throttle.spec.ts b/src/main/runtime/orca-runtime-tests/decorative-title-fact-throttle.spec.ts new file mode 100644 index 00000000000..a2e35baa4aa --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/decorative-title-fact-throttle.spec.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSideEffectBatch } from '../../../shared/terminal-side-effect-facts' +import { syncSinglePty } from '../orca-runtime-test-fixtures.spec' +import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec' +import { DECORATIVE_TITLE_FACT_HEARTBEAT_MS } from '../decorative-title-fact-emission' + +// Orca's own synthetic agent spinner: one frame per pane every 80ms while an agent works. +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +const SPINNER_INTERVAL_MS = 80 +const EPOCH = 1_700_000_000_000 + +type TitleFact = { kind: 'title'; normalizedTitle: string; rawTitle: string } + +function titleFacts(batches: TerminalSideEffectBatch[]): TitleFact[] { + return batches.flatMap((batch) => + batch.facts.filter((fact): fact is TitleFact => fact.kind === 'title') + ) +} + +describe('decorative title fact throttle', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date(EPOCH)) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('collapses spinner ticks with an unchanged underlying title to the heartbeat rate', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + const ticks = 125 // 10s of Orca's 80ms synthetic spinner timer + for (let tick = 0; tick < ticks; tick += 1) { + vi.setSystemTime(new Date(EPOCH + tick * SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame( + 'pty-1', + `\x1b]0;${SPINNER_FRAMES[tick % SPINNER_FRAMES.length]} Claude Code\x07` + ) + } + + const facts = titleFacts(batches) + // Every frame carried the same underlying title, so the renderer learns nothing new past + // the heartbeat: 125 pty:sideEffect messages collapse to one per heartbeat window. + const elapsedMs = ticks * SPINNER_INTERVAL_MS + expect(facts.length).toBeLessThanOrEqual( + Math.ceil(elapsedMs / DECORATIVE_TITLE_FACT_HEARTBEAT_MS) + ) + expect(facts.length).toBeLessThan(ticks / 5) + // The heartbeat must not thin out below what the renderer's 1500ms hook-done quiet window + // needs to cancel a milestone `done` — three working frames per window. + expect(facts.length).toBeGreaterThanOrEqual(Math.floor(elapsedMs / 1_500) * 3) + for (const fact of facts) { + expect(fact.normalizedTitle.endsWith('Claude Code')).toBe(true) + } + }) + + it('propagates a real title change on the tick it arrives, mid-heartbeat', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07') + // Two more decorative ticks — still well inside the heartbeat window, so they are dropped. + vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Claude Code\x07') + vi.setSystemTime(new Date(EPOCH + 2 * SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠹ Claude Code\x07') + expect(titleFacts(batches)).toHaveLength(1) + + const beforeChange = batches.length + vi.setSystemTime(new Date(EPOCH + 3 * SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;✳ Claude Code\x07') + + expect(batches.length).toBeGreaterThan(beforeChange) + expect(titleFacts(batches.slice(beforeChange))).toEqual([ + { kind: 'title', normalizedTitle: '✳ Claude Code', rawTitle: '✳ Claude Code' } + ]) + }) + + it('propagates a changed working label immediately even while the spinner rotates', () => { + // Why: only the spinner glyph is decoration. Grok/Pi-style label churn is real content. + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07') + vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Reviewing diff — Claude Code\x07') + + expect(titleFacts(batches).map((fact) => fact.rawTitle)).toEqual([ + '⠋ Claude Code', + '⠙ Reviewing diff — Claude Code' + ]) + }) + + it('keeps main-side tracked title state current for every suppressed frame', () => { + // Why: mobile/remote snapshots read the tracked record, not the fact stream — suppressing + // the fact must not freeze what a phone or a paired client is shown. + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07') + vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Claude Code\x07') + + expect(runtime.getTerminalSideEffectSnapshot('pty-1')?.facts).toEqual([ + { kind: 'title', normalizedTitle: '⠙ Claude Code', rawTitle: '⠙ Claude Code' } + ]) + }) +}) diff --git a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts index fb07f8a3d0e..79feca3268c 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts @@ -8,6 +8,7 @@ import { syncSinglePty } from '../orca-runtime-test-fixtures.spec' import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec' +import { DECORATIVE_TITLE_FACT_HEARTBEAT_MS } from '../decorative-title-fact-emission' describe('terminal side-effect fact channel', () => { it('defers desktop-only output scanners until a headless runtime is promoted', () => { @@ -70,53 +71,66 @@ describe('terminal side-effect fact channel', () => { expect(events).toHaveLength(1) }) - it('bounds decorative title delivery per paired client without reducing local frames', () => { - const { runtime, batches } = createSideEffectRuntime() - const firstClientEvents: RuntimeClientEvent[] = [] - runtime.attachWindow(1) - runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) - runtime.onClientEvent((event) => firstClientEvents.push(event)) + it('bounds decorative title delivery per paired client below the local heartbeat', () => { + // Why the clock steps: main throttles decorative repeats on the local fact stream, so each + // round must clear that heartbeat for the per-client gate to be what collapses them here. + vi.useFakeTimers({ toFake: ['Date'] }) + try { + const { runtime, batches } = createSideEffectRuntime() + const firstClientEvents: RuntimeClientEvent[] = [] + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.onClientEvent((event) => firstClientEvents.push(event)) - const ptyIds = Array.from({ length: 64 }, (_, index) => `pty-remote-${index}`) - const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] - for (const ptyId of ptyIds) { - runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`) - } - firstClientEvents.length = 0 - - for (const frame of frames.slice(1)) { - for (const ptyId of ptyIds) { - runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frame} Cursor Agent\x07`) + const ptyIds = Array.from({ length: 64 }, (_, index) => `pty-remote-${index}`) + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + const stepPastHeartbeat = (): void => { + vi.setSystemTime(new Date(Date.now() + DECORATIVE_TITLE_FACT_HEARTBEAT_MS)) } + for (const ptyId of ptyIds) { + runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`) + } + firstClientEvents.length = 0 + + for (const frame of frames.slice(1)) { + stepPastHeartbeat() + for (const ptyId of ptyIds) { + runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frame} Cursor Agent\x07`) + } + } + + expect(firstClientEvents).toEqual([]) + expect(batches).toHaveLength(ptyIds.length * frames.length) + + const bellChunk = `\x1b]0;${frames.at(-1)} Cursor Agent\x07\x07` + runtime.onPtyData(ptyIds[0], bellChunk, 1) + expect(firstClientEvents).toEqual([ + expect.objectContaining({ + type: 'terminalSideEffects', + batch: expect.objectContaining({ facts: [{ kind: 'bell' }] }) + }) + ]) + firstClientEvents.length = 0 + + const secondClientEvents: RuntimeClientEvent[] = [] + runtime.onClientEvent((event) => secondClientEvents.push(event)) + stepPastHeartbeat() + for (const ptyId of ptyIds) { + runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`) + } + + expect(firstClientEvents).toEqual([]) + expect(secondClientEvents).toHaveLength(ptyIds.length) + + // A real title change is never throttled — no clock step needed. + for (const ptyId of ptyIds) { + runtime.ingestSyntheticTitleFrame(ptyId, '\x1b]0;Cursor ready\x07') + } + expect(firstClientEvents).toHaveLength(ptyIds.length) + expect(secondClientEvents).toHaveLength(ptyIds.length * 2) + } finally { + vi.useRealTimers() } - - expect(firstClientEvents).toEqual([]) - expect(batches).toHaveLength(ptyIds.length * frames.length) - - const bellChunk = `\x1b]0;${frames.at(-1)} Cursor Agent\x07\x07` - runtime.onPtyData(ptyIds[0], bellChunk, 1) - expect(firstClientEvents).toEqual([ - expect.objectContaining({ - type: 'terminalSideEffects', - batch: expect.objectContaining({ facts: [{ kind: 'bell' }] }) - }) - ]) - firstClientEvents.length = 0 - - const secondClientEvents: RuntimeClientEvent[] = [] - runtime.onClientEvent((event) => secondClientEvents.push(event)) - for (const ptyId of ptyIds) { - runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`) - } - - expect(firstClientEvents).toEqual([]) - expect(secondClientEvents).toHaveLength(ptyIds.length) - - for (const ptyId of ptyIds) { - runtime.ingestSyntheticTitleFrame(ptyId, '\x1b]0;Cursor ready\x07') - } - expect(firstClientEvents).toHaveLength(ptyIds.length) - expect(secondClientEvents).toHaveLength(ptyIds.length * 2) }) it('omits terminalSideEffects from non-consuming listeners while other events still flow', () => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 054d7f09316..f8b12ba4a9d 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -30,6 +30,7 @@ await import('./orca-runtime-tests/pty-title-status.spec') await import('./orca-runtime-tests/terminal-side-effect-facts.spec') await import('./orca-runtime-tests/terminal-side-effect-facts-part-02.spec') await import('./orca-runtime-tests/terminal-side-effect-facts-part-03.spec') +await import('./orca-runtime-tests/decorative-title-fact-throttle.spec') await import('./orca-runtime-tests/headless-snapshots.spec') await import('./orca-runtime-tests/headless-snapshots-part-02.spec') await import('./orca-runtime-tests/agent-status-and-waits.spec') diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 68be05b8ad1..6ccb4ed82bb 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -89,6 +89,8 @@ export type RuntimePtyTitleTrackerEntry = { tracker: TerminalTitleTracker applyingChunk: boolean lastMobileTitleGateKey: string | null + /** When the last title fact was emitted — throttles decorative-only repeats. */ + lastTitleFactAtMs: number | null chunkTouchedSessionTabs: boolean pendingFacts: TerminalSideEffectFact[] commandCodeDetector: { observe: (data: string) => boolean } | null From f19a860a0f611453ffa34a637dcfa35f9fa1705b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:04:22 -0700 Subject: [PATCH 08/25] perf(terminal): sweep the parked-watcher registries once per pass, not once per workspace (#18157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The always-mounted terminal controller looped every workspace surface (423 on a large profile) and called syncParkedTerminalTabWatchers per surface; that function scans both module-level registries in full, so one effect fire cost surfaces x registry — 323,172 map-row visits at 423 workspaces / 382 tabs. Add syncParkedTerminalTabWatchersForWorkspaces, which walks each registry once and then runs the per-tab start/reconcile pass; the single-worktree entry point delegates to it. Registry rows are tab-id keyed and a tab belongs to exactly one worktree, so hoisting the dispose and capture sweeps ahead of the start passes only reorders work across disjoint tab sets. Also derive workspaceSurfaceIds/workspaceSurfaceIdSet once in the workspace foundation (through the existing useReusedArrayIdentity) and key the watcher, parking and browser-retention effects on the id array instead of the surface array, which is re-identified on every worktree write. And pass the sidebar's already-computed defaultHostId into useVisibleSidebarWorktrees so an unrelated settings write stops re-running the 423-worktree visibility scan. --- .../src/components/sidebar/WorktreeList.tsx | 2 +- .../listing/use-visible-worktrees.test.tsx | 78 ++- .../listing/use-visible-worktrees.ts | 13 +- .../components/terminal-cold-activation.ts | 8 +- ...nal-parked-tab-watchers-batch-sync.test.ts | 540 ++++++++++++++++++ .../terminal-parked-tab-watchers.ts | 132 ++++- .../terminal-parking-pass-candidates.ts | 9 +- .../terminal-workspace-surface-ids.test.tsx | 184 ++++++ .../use-terminal-browser-retention.ts | 10 +- .../components/use-terminal-parking-pass.ts | 4 +- .../use-terminal-watcher-effects.ts | 43 +- .../use-terminal-workspace-foundation.ts | 14 + 12 files changed, 958 insertions(+), 79 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers-batch-sync.test.ts create mode 100644 src/renderer/src/components/terminal-workspace-surface-ids.test.tsx diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 5d27ec0f23f..9153ff4e39e 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -116,7 +116,7 @@ const WorktreeList = React.memo(function WorktreeList({ sortedIds, repoMap, worktreeLineageById, - settings, + defaultHostId, agentSendTargetWorktreeId }) const effectiveCollapsedGroups = useEffectiveCollapsedGroups({ diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.test.tsx b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.test.tsx index d385f29d6c4..422a14b3693 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.test.tsx @@ -1,11 +1,27 @@ // @vitest-environment happy-dom -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, renderHook } from '@testing-library/react' import { useAppStore } from '@/store' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../../../shared/execution-host' import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity' import { makeRepo, makeWorktree } from '../../../worktree-jump-palette-test-fixtures' import { useVisibleSidebarWorktrees } from './use-visible-worktrees' +import type * as visibleWorktreesModule from '../../visible-worktrees' + +const computeVisibleWorktreesCalls = { count: 0 } +vi.mock('../../visible-worktrees', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + computeVisibleWorktrees: ( + ...args: Parameters + ): ReturnType => { + computeVisibleWorktreesCalls.count += 1 + return actual.computeVisibleWorktrees(...args) + } + } +}) const initialState = useAppStore.getInitialState() @@ -43,7 +59,7 @@ describe('useVisibleSidebarWorktrees', () => { sortedIds: [local.id, ssh.id], repoMap: new Map([[repo.id, repo]]), worktreeLineageById: {}, - settings: useAppStore.getState().settings, + defaultHostId: LOCAL_EXECUTION_HOST_ID, agentSendTargetWorktreeId: null }) ) @@ -78,7 +94,7 @@ describe('useVisibleSidebarWorktrees', () => { sortedIds: [local.id, ssh.id], repoMap: new Map([[repo.id, repo]]), worktreeLineageById: {}, - settings: useAppStore.getState().settings, + defaultHostId: LOCAL_EXECUTION_HOST_ID, agentSendTargetWorktreeId: null }) ) @@ -87,4 +103,60 @@ describe('useVisibleSidebarWorktrees', () => { getWorktreeHostIdentity(ssh) ]) }) + it('does not rescan every worktree when a settings write leaves the focused host unchanged', () => { + const repo = makeRepo() + const worktree = makeWorktree('alpha', 'Alpha workspace', { hostId: 'local' }) + useAppStore.setState({ worktreesByRepo: { [repo.id]: [worktree] } }) + + const baseArgs = { + filterState: { + showSleepingWorkspaces: true, + filterRepoIds: [], + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + hideCliCreatedWorkspaces: false, + hideDetachedHeadWorkspaces: false, + hideWorkspacesFromOtherDevices: false, + alwaysShowDefaultBranchWorkspace: true, + visibleWorkspaceHostIds: null, + workspaceHostScope: 'all' + }, + sortBy: 'recent', + sortedIds: [worktree.id], + repoMap: new Map([[repo.id, repo]]), + worktreeLineageById: {}, + defaultHostId: LOCAL_EXECUTION_HOST_ID, + agentSendTargetWorktreeId: null + } as Parameters[0] + // Why the extra `settings`: it is the pre-fix memo key. Passing it keeps + // this test red against the old hook, which re-keyed the whole scan on the + // settings object identity. + const withSettings = ( + settings: ReturnType['settings'] + ): Parameters[0] => Object.assign({}, baseArgs, { settings }) + + computeVisibleWorktreesCalls.count = 0 + const { result, rerender } = renderHook( + (args: Parameters[0]) => useVisibleSidebarWorktrees(args), + { initialProps: withSettings(useAppStore.getState().settings) } + ) + const initialVisible = result.current.visibleWorktrees + const callsAfterFirstRender = computeVisibleWorktreesCalls.count + expect(callsAfterFirstRender).toBe(1) + + // A settings write that does not move the focused execution host. + const nextSettings = { + ...useAppStore.getState().settings, + sidebarWidth: 321 + } as ReturnType['settings'] + useAppStore.setState({ settings: nextSettings }) + rerender(withSettings(nextSettings)) + + expect(computeVisibleWorktreesCalls.count).toBe(callsAfterFirstRender) + expect(result.current.visibleWorktrees).toBe(initialVisible) + + // A write that does move it still recomputes. + rerender(Object.assign({}, withSettings(nextSettings), { defaultHostId: 'runtime:other' })) + expect(computeVisibleWorktreesCalls.count).toBe(callsAfterFirstRender + 1) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts index e04d57ccff9..804d5118605 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts @@ -2,10 +2,9 @@ import { useMemo } from 'react' import { useAppStore } from '@/store' import { getAgentStatusEpochNow } from '@/lib/agent-status-epoch-clock' import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state' -import type { AppState } from '@/store/types' import type { Repo } from '../../../../../../shared/repo-types' import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types' -import { getSettingsFocusedExecutionHostId } from '../../../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { computeVisibleWorktrees } from '../../visible-worktrees' import { EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, @@ -29,10 +28,12 @@ export function useVisibleSidebarWorktrees(args: { sortedIds: string[] repoMap: Map worktreeLineageById: Record - settings: AppState['settings'] + /** Pre-derived focused host; the whole `settings` object would re-key this + * 423-workspace scan on every unrelated settings write. */ + defaultHostId: ExecutionHostId agentSendTargetWorktreeId: string | null }) { - const { filterState, sortBy, sortedIds, repoMap, worktreeLineageById, settings } = args + const { filterState, sortBy, sortedIds, repoMap, worktreeLineageById, defaultHostId } = args const { showSleepingWorkspaces, filterRepoIds, @@ -98,7 +99,7 @@ export function useVisibleSidebarWorktrees(args: { repoMap, workspaceHostScope, visibleWorkspaceHostIds, - defaultHostId: getSettingsFocusedExecutionHostId(settings), + defaultHostId, worktreeLineageById, forcedVisibleWorktreeIds: args.agentSendTargetWorktreeId ? [args.agentSendTargetWorktreeId] @@ -118,7 +119,7 @@ export function useVisibleSidebarWorktrees(args: { alwaysShowDefaultBranchWorkspace, workspaceHostScope, visibleWorkspaceHostIds, - settings, + defaultHostId, repoMap, tabsByWorktree, ptyIdsByTabId, diff --git a/src/renderer/src/components/terminal-cold-activation.ts b/src/renderer/src/components/terminal-cold-activation.ts index 8273e1536e6..d57cb82d766 100644 --- a/src/renderer/src/components/terminal-cold-activation.ts +++ b/src/renderer/src/components/terminal-cold-activation.ts @@ -36,7 +36,8 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio terminalParkingEnabled, terminalTitleSnapshotAuthorityEnabled, workspaceSessionReady, - workspaceSurfaces + workspaceSurfaceIds, + workspaceSurfaceIdSet } = controller if ( renderedActiveWorktreeId && @@ -150,16 +151,15 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio tabsByWorktree, activationDeferredMountTabIdsByWorktreeRef.current ) - const allWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id)) for (const id of mountedWorktreeIdsRef.current) { - if (!allWorktreeIds.has(id)) { + if (!workspaceSurfaceIdSet.has(id)) { mountedWorktreeIdsRef.current.delete(id) backgroundMountTabIdsByWorktreeRef.current.delete(id) activationDeferredMountTabIdsByWorktreeRef.current.delete(id) } } const anyMountedWorktreeHasLayout = computeAnyMountedWorktreeHasLayout( - workspaceSurfaces.map((workspace) => workspace.id), + workspaceSurfaceIds, mountedWorktreeIdsRef.current, layoutByWorktree, groupsByWorktree, diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers-batch-sync.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers-batch-sync.test.ts new file mode 100644 index 00000000000..8bb0dca14ff --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers-batch-sync.test.ts @@ -0,0 +1,540 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher' + +type StartEvent = { + kind: 'start' + worktreeId: string + tabId: string + ptyId: string + paneId: number + leafId: string + drivesTabTitle: boolean + restoreTitleOnRegister: boolean +} +type DisposeEvent = { kind: 'dispose'; worktreeId: string; tabId: string; ptyId: string } +type ClearTitleEvent = { kind: 'clearTitle'; tabId: string; paneId: number } +type DiscardEvent = { kind: 'discard'; ptyId: string } +type WatcherEvent = StartEvent | DisposeEvent | ClearTitleEvent | DiscardEvent + +let events: WatcherEvent[] = [] + +const startParkedTerminalByteWatcher = vi.fn((options: ParkedTerminalByteWatcherOptions) => { + events.push({ + kind: 'start', + worktreeId: options.worktreeId, + tabId: options.tabId, + ptyId: options.ptyId, + paneId: options.paneId, + leafId: options.leafId, + drivesTabTitle: options.drivesTabTitle === true, + restoreTitleOnRegister: options.restoreTitleOnRegister === true + }) + return () => { + events.push({ + kind: 'dispose', + worktreeId: options.worktreeId, + tabId: options.tabId, + ptyId: options.ptyId + }) + } +}) + +vi.mock('./parked-terminal-byte-watcher', () => ({ + startParkedTerminalByteWatcher: (options: ParkedTerminalByteWatcherOptions) => + startParkedTerminalByteWatcher(options) +})) + +vi.mock('./pty-dispatcher', () => ({ + subscribeToPtyExit: () => () => {} +})) + +vi.mock('./pty-pre-handler-buffer', () => ({ + discardPreHandlerPtyState: (ptyId: string) => { + events.push({ kind: 'discard', ptyId }) + }, + hasPreHandlerPtyExit: () => false +})) + +vi.mock('../terminal/terminal-tab-actions', () => ({ + closeTerminalTab: () => {} +})) + +type TabModel = { id: string; ptyId: string | null } +type MockStoreState = { + tabsByWorktree: Record + terminalLayoutsByTabId: Record< + string, + { + root: unknown + activeLeafId: string | null + expandedLeafId: string | null + ptyIdsByLeafId?: Record + } + > + runtimePaneTitlesByTabId: Record> + settings: { terminalSshViewParking?: boolean } | null + runtimeStatusByEnvironmentId: Map + clearRuntimePaneTitle: (tabId: string, paneId: number) => void + setRuntimePaneTitle: () => void + clearTabLaunchAgent: () => void + setTabLayout: () => void + updateTabTitle: () => void + markUnverifiedPtyLoss: () => void + isPtyShutdownPending: () => boolean + suppressedPtyExitIds: Record +} + +let mockStoreState: MockStoreState + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => mockStoreState } +})) + +import { + clearTerminalProviderSnapshotCapabilities, + synchronizeTerminalProviderSnapshotCapabilities +} from '../terminal/terminal-provider-snapshot-capability' +import { + captureParkedTerminalPaneCandidates, + pruneParkedTerminalWatchers, + syncParkedTerminalTabWatchers, + syncParkedTerminalTabWatchersForWorkspaces, + type ParkedTerminalTabWatcherSyncEntry +} from './terminal-parked-tab-watchers' +import { capturedPanesByTabId, parkedWatchersByTabId } from './terminal-parked-watcher-registry' + +const leafId = (index: number): string => + `${index.toString(16).padStart(8, '0')}-1111-4111-8111-111111111111` + +function makeStore(): MockStoreState { + return { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + runtimePaneTitlesByTabId: {}, + settings: null, + runtimeStatusByEnvironmentId: new Map(), + clearRuntimePaneTitle: (tabId: string, paneId: number) => { + events.push({ kind: 'clearTitle', tabId, paneId }) + }, + setRuntimePaneTitle: () => {}, + clearTabLaunchAgent: () => {}, + setTabLayout: () => {}, + updateTabTitle: () => {}, + markUnverifiedPtyLoss: () => {}, + isPtyShutdownPending: () => false, + suppressedPtyExitIds: {} + } +} + +/** Counts entries visited by `for...of` over the module-level registries. */ +function instrumentRegistryIteration(): { count: number; restore: () => void } { + const counter = { count: 0, restore: () => {} } + const patched: Map[] = [ + parkedWatchersByTabId as Map, + capturedPanesByTabId as Map + ] + for (const map of patched) { + Object.defineProperty(map, Symbol.iterator, { + configurable: true, + writable: true, + value: function* (this: Map) { + for (const entry of Map.prototype.entries.call(this)) { + counter.count += 1 + yield entry + } + } + }) + } + counter.restore = () => { + for (const map of patched) { + Reflect.deleteProperty(map, Symbol.iterator) + } + } + return counter +} + +describe('parked terminal watcher batch synchronization', () => { + beforeEach(() => { + events = [] + mockStoreState = makeStore() + clearTerminalProviderSnapshotCapabilities() + }) + + afterEach(() => { + pruneParkedTerminalWatchers(new Set()) + capturedPanesByTabId.clear() + events = [] + vi.clearAllMocks() + clearTerminalProviderSnapshotCapabilities() + }) + + describe('registry scan cost at the real-profile scale', () => { + // The user's profile: 423 workspace surfaces, 382 terminal tabs. + const WORKSPACE_COUNT = 423 + const TAB_COUNT = 382 + + function seedRegistries(): { + workspaceIds: string[] + tabsByWorktreeId: Map + } { + const workspaceIds = Array.from( + { length: WORKSPACE_COUNT }, + (_, index) => `repo::/worktree-${index}` + ) + const tabsByWorktreeId = new Map( + workspaceIds.map((workspaceId) => [workspaceId, [] as TabModel[]]) + ) + for (let index = 0; index < TAB_COUNT; index += 1) { + const worktreeId = workspaceIds[index % WORKSPACE_COUNT] + const tabId = `tab-${index}` + const ptyId = `${worktreeId}@@session-${index}` + tabsByWorktreeId.get(worktreeId)!.push({ id: tabId, ptyId }) + // A live, already-parked tab: present in both registries, nothing to + // dispose and nothing to start, so the pass is a pure registry scan. + parkedWatchersByTabId.set(tabId, { + worktreeId, + tabPtyId: ptyId, + paneIdByPtyId: new Map([[ptyId, 1]]), + disposersByPtyId: new Map() + }) + captureParkedTerminalPaneCandidates(tabId, worktreeId, [ + { ptyId, paneId: 1, leafId: leafId(index), drivesTabTitle: true } + ]) + } + return { workspaceIds, tabsByWorktreeId } + } + + it('collapses surfaces x registry scans into one scan of each registry', () => { + const { workspaceIds, tabsByWorktreeId } = seedRegistries() + const registryRows = parkedWatchersByTabId.size + capturedPanesByTabId.size + expect(registryRows).toBe(TAB_COUNT * 2) + + const perSurface = instrumentRegistryIteration() + for (const workspaceId of workspaceIds) { + syncParkedTerminalTabWatchers({ + worktreeId: workspaceId, + tabs: tabsByWorktreeId.get(workspaceId)!, + parkedTabIds: new Set() + }) + } + const perSurfaceVisits = perSurface.count + perSurface.restore() + + const batched = instrumentRegistryIteration() + const entries = new Map( + workspaceIds.map((workspaceId) => [ + workspaceId, + { tabs: tabsByWorktreeId.get(workspaceId)!, parkedTabIds: new Set() } + ]) + ) + syncParkedTerminalTabWatchersForWorkspaces(entries) + const batchedVisits = batched.count + batched.restore() + + // Old shape: every surface re-walks both registries in full. + expect(perSurfaceVisits).toBe(WORKSPACE_COUNT * registryRows) + // New shape: each registry is walked exactly once for the whole pass. + expect(batchedVisits).toBe(registryRows) + expect(perSurfaceVisits / batchedVisits).toBeGreaterThan(400) + }) + }) + + describe('start/dispose decisions match the per-surface path', () => { + type Scenario = { + name: string + workspaces: { + worktreeId: string + tabs: TabModel[] + parkedTabIds: string[] + restoreTitleOnStartTabIds?: string[] + }[] + /** Watcher rows already in the registry when the pass runs. */ + preParkedTabs: { worktreeId: string; tabId: string; ptyId: string; withDisposer: boolean }[] + /** Captures for tabs that may or may not still be live. */ + preCapturedTabs: { worktreeId: string; tabId: string; ptyId: string | null }[] + } + + const WORKTREE_A = 'repo::/alpha' + const WORKTREE_B = 'repo::/beta' + const WORKTREE_C = 'repo::/gamma' + + const pty = (worktreeId: string, index: number): string => `${worktreeId}@@session-${index}` + + const scenarios: Scenario[] = [ + { + name: 'cold start: nothing parked yet, two workspaces park every tab', + workspaces: [ + { + worktreeId: WORKTREE_A, + tabs: [ + { id: 'a1', ptyId: pty(WORKTREE_A, 1) }, + { id: 'a2', ptyId: pty(WORKTREE_A, 2) } + ], + parkedTabIds: ['a1', 'a2'] + }, + { + worktreeId: WORKTREE_B, + tabs: [{ id: 'b1', ptyId: pty(WORKTREE_B, 1) }], + parkedTabIds: ['b1'] + }, + { worktreeId: WORKTREE_C, tabs: [], parkedTabIds: [] } + ], + preParkedTabs: [], + preCapturedTabs: [] + }, + { + name: 'reveal: a parked workspace drops out of the parked set', + workspaces: [ + { + worktreeId: WORKTREE_A, + tabs: [ + { id: 'a1', ptyId: pty(WORKTREE_A, 1) }, + { id: 'a2', ptyId: pty(WORKTREE_A, 2) } + ], + parkedTabIds: [] + }, + { + worktreeId: WORKTREE_B, + tabs: [{ id: 'b1', ptyId: pty(WORKTREE_B, 1) }], + parkedTabIds: ['b1'] + } + ], + preParkedTabs: [ + { worktreeId: WORKTREE_A, tabId: 'a1', ptyId: pty(WORKTREE_A, 1), withDisposer: true }, + { worktreeId: WORKTREE_A, tabId: 'a2', ptyId: pty(WORKTREE_A, 2), withDisposer: true } + ], + preCapturedTabs: [ + { worktreeId: WORKTREE_A, tabId: 'a1', ptyId: pty(WORKTREE_A, 1) }, + { worktreeId: WORKTREE_A, tabId: 'a2', ptyId: pty(WORKTREE_A, 2) } + ] + }, + { + name: 'closed tabs: registry rows and captures outlive their tabs', + workspaces: [ + { + worktreeId: WORKTREE_A, + tabs: [{ id: 'a1', ptyId: pty(WORKTREE_A, 1) }], + parkedTabIds: ['a1'] + }, + { worktreeId: WORKTREE_B, tabs: [], parkedTabIds: [] } + ], + preParkedTabs: [ + { + worktreeId: WORKTREE_A, + tabId: 'a-closed', + ptyId: pty(WORKTREE_A, 9), + withDisposer: true + }, + { + worktreeId: WORKTREE_B, + tabId: 'b-closed', + ptyId: pty(WORKTREE_B, 9), + withDisposer: true + } + ], + preCapturedTabs: [ + { worktreeId: WORKTREE_A, tabId: 'a-closed', ptyId: pty(WORKTREE_A, 9) }, + { worktreeId: WORKTREE_B, tabId: 'b-closed', ptyId: pty(WORKTREE_B, 9) }, + { worktreeId: WORKTREE_A, tabId: 'a1', ptyId: pty(WORKTREE_A, 1) } + ] + }, + { + name: 're-minted pty: a parked tab wakes with a fresh pty id', + workspaces: [ + { + worktreeId: WORKTREE_A, + tabs: [{ id: 'a1', ptyId: pty(WORKTREE_A, 2) }], + parkedTabIds: ['a1'], + restoreTitleOnStartTabIds: ['a1'] + }, + { + worktreeId: WORKTREE_B, + tabs: [{ id: 'b1', ptyId: pty(WORKTREE_B, 1) }], + parkedTabIds: ['b1'] + } + ], + preParkedTabs: [ + { worktreeId: WORKTREE_A, tabId: 'a1', ptyId: pty(WORKTREE_A, 1), withDisposer: true } + ], + preCapturedTabs: [{ worktreeId: WORKTREE_A, tabId: 'a1', ptyId: pty(WORKTREE_A, 2) }] + }, + { + name: 'unknown worktree rows: registry holds a workspace absent from this pass', + workspaces: [ + { + worktreeId: WORKTREE_A, + tabs: [{ id: 'a1', ptyId: pty(WORKTREE_A, 1) }], + parkedTabIds: ['a1'] + } + ], + preParkedTabs: [ + { worktreeId: WORKTREE_C, tabId: 'c1', ptyId: pty(WORKTREE_C, 1), withDisposer: true } + ], + preCapturedTabs: [{ worktreeId: WORKTREE_C, tabId: 'c1', ptyId: pty(WORKTREE_C, 1) }] + }, + { + name: 'tombstone rows: a pinned-close entry with no live disposers', + workspaces: [ + { + worktreeId: WORKTREE_A, + tabs: [{ id: 'a1', ptyId: pty(WORKTREE_A, 1) }], + parkedTabIds: [] + }, + { + worktreeId: WORKTREE_B, + tabs: [{ id: 'b1', ptyId: pty(WORKTREE_B, 1) }], + parkedTabIds: ['b1'] + } + ], + preParkedTabs: [ + { worktreeId: WORKTREE_A, tabId: 'a1', ptyId: pty(WORKTREE_A, 1), withDisposer: false } + ], + preCapturedTabs: [{ worktreeId: WORKTREE_B, tabId: 'b1', ptyId: pty(WORKTREE_B, 1) }] + } + ] + + type Outcome = { + eventsByWorktree: Record + registry: readonly (readonly [ + string, + { worktreeId: string; tabPtyId: string | null; ptyIds: string[] } + ])[] + captures: string[] + } + + async function seedAndRun( + scenario: Scenario, + run: (scenario: Scenario) => void + ): Promise { + pruneParkedTerminalWatchers(new Set()) + capturedPanesByTabId.clear() + clearTerminalProviderSnapshotCapabilities() + mockStoreState = makeStore() + const allPtyIds = new Set() + for (const workspace of scenario.workspaces) { + for (const tab of workspace.tabs) { + if (tab.ptyId) { + allPtyIds.add(tab.ptyId) + } + } + } + for (const row of [...scenario.preParkedTabs, ...scenario.preCapturedTabs]) { + if (row.ptyId) { + allPtyIds.add(row.ptyId) + } + } + await synchronizeTerminalProviderSnapshotCapabilities(Array.from(allPtyIds), async (ids) => + ids.map((id) => ({ id, authoritative: true })) + ) + let paneOrdinal = 0 + for (const row of scenario.preParkedTabs) { + paneOrdinal += 1 + parkedWatchersByTabId.set(row.tabId, { + worktreeId: row.worktreeId, + tabPtyId: row.ptyId, + paneIdByPtyId: new Map([[row.ptyId, paneOrdinal]]), + disposersByPtyId: row.withDisposer + ? new Map([ + [ + row.ptyId, + () => { + events.push({ + kind: 'dispose', + worktreeId: row.worktreeId, + tabId: row.tabId, + ptyId: row.ptyId + }) + } + ] + ]) + : new Map() + }) + } + for (const [index, row] of scenario.preCapturedTabs.entries()) { + captureParkedTerminalPaneCandidates(row.tabId, row.worktreeId, [ + { ptyId: row.ptyId, paneId: 100 + index, leafId: leafId(index + 1), drivesTabTitle: true } + ]) + } + events = [] + run(scenario) + + const eventsByWorktree: Record = {} + for (const event of events) { + const key = 'worktreeId' in event ? event.worktreeId : 'shared' + ;(eventsByWorktree[key] ??= []).push(event) + } + // Why per-worktree, not one global sequence: batching deliberately hoists + // every workspace's dispose sweep ahead of every workspace's start pass. + // Registry rows are tab-id keyed and a tab belongs to exactly one + // worktree, so that reorder crosses only disjoint tab sets. `shared` + // events (pane-title clears, pre-handler discards) are compared as a + // multiset for the same reason. + for (const key of Object.keys(eventsByWorktree)) { + if (key === 'shared') { + eventsByWorktree[key] = [...eventsByWorktree[key]].sort((left, right) => + JSON.stringify(left).localeCompare(JSON.stringify(right)) + ) + } + } + return { + eventsByWorktree, + registry: Array.from( + parkedWatchersByTabId, + ([tabId, entry]) => + [ + tabId, + { + worktreeId: entry.worktreeId, + tabPtyId: entry.tabPtyId, + ptyIds: Array.from(entry.disposersByPtyId.keys()).sort() + } + ] as const + ).sort((left, right) => left[0].localeCompare(right[0])), + captures: Array.from(capturedPanesByTabId.keys()).sort() + } + } + + const runPerSurface = (scenario: Scenario): void => { + for (const workspace of scenario.workspaces) { + syncParkedTerminalTabWatchers({ + worktreeId: workspace.worktreeId, + tabs: workspace.tabs, + parkedTabIds: new Set(workspace.parkedTabIds), + ...(workspace.restoreTitleOnStartTabIds + ? { restoreTitleOnStartTabIds: new Set(workspace.restoreTitleOnStartTabIds) } + : {}) + }) + } + } + + const runBatched = (scenario: Scenario): void => { + syncParkedTerminalTabWatchersForWorkspaces( + new Map( + scenario.workspaces.map((workspace) => [ + workspace.worktreeId, + { + tabs: workspace.tabs, + parkedTabIds: new Set(workspace.parkedTabIds), + ...(workspace.restoreTitleOnStartTabIds + ? { restoreTitleOnStartTabIds: new Set(workspace.restoreTitleOnStartTabIds) } + : {}) + } + ]) + ) + ) + } + + for (const scenario of scenarios) { + it(`decides identically — ${scenario.name}`, async () => { + const perSurface = await seedAndRun(scenario, runPerSurface) + const batched = await seedAndRun(scenario, runBatched) + expect(batched).toEqual(perSurface) + // Guard against a vacuous comparison of two empty outcomes. + expect( + Object.values(perSurface.eventsByWorktree).some((list) => list.length > 0) || + perSurface.registry.length > 0 + ).toBe(true) + }) + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index f834944607b..d088fbe41ad 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -244,6 +244,93 @@ function disposeClosedParkedTabWatchers( disposeParkedTabWatchers(tabId) } +/** One workspace's rendered parked verdict, as the batch pass consumes it. */ +export type ParkedTerminalTabWatcherSyncEntry = { + tabs: readonly ParkableTerminalTabModel[] + parkedTabIds: ReadonlySet + /** Parked-equivalent tabs whose pane has not restored the current title. */ + restoreTitleOnStartTabIds?: ReadonlySet +} + +function startOrReconcileParkedTabWatchers( + worktreeId: string, + entry: ParkedTerminalTabWatcherSyncEntry +): void { + for (const tab of entry.tabs) { + if (!entry.parkedTabIds.has(tab.id)) { + continue + } + const watcherEntry = parkedWatchersByTabId.get(tab.id) + const restoreTitleOnRegister = entry.restoreTitleOnStartTabIds?.has(tab.id) === true + if (watcherEntry) { + reconcileParkedTabWatchers(worktreeId, tab, watcherEntry, restoreTitleOnRegister) + } else { + startParkedTabWatchers(worktreeId, tab, restoreTitleOnRegister) + } + } +} + +/** + * Reconciles watchers for every rendered workspace in one pass. + * + * Why batched: the per-worktree entry point scans both registries in full, so + * the terminal host calling it once per workspace made a single effect fire + * cost surfaces x registry map iterations. Walking each registry once and then + * doing the per-tab start/reconcile pass is O(registry + tabs) instead. + * + * The dispose/start decisions are identical: registry entries are keyed by tab + * id and every tab belongs to exactly one worktree, so hoisting the dispose and + * capture-cleanup sweeps ahead of every start only reorders work across + * disjoint tab sets. + */ +export function syncParkedTerminalTabWatchersForWorkspaces( + entriesByWorktreeId: ReadonlyMap +): void { + if (entriesByWorktreeId.size === 0) { + return + } + // Why lazy: only worktrees that actually own a registry row need the id set, + // so an idle profile allocates none of them. + const liveTabIdsByWorktreeId = new Map>() + const liveTabIdsFor = (worktreeId: string): ReadonlySet | null => { + const cached = liveTabIdsByWorktreeId.get(worktreeId) + if (cached) { + return cached + } + const entry = entriesByWorktreeId.get(worktreeId) + if (!entry) { + return null + } + const liveTabIds = new Set(entry.tabs.map((tab) => tab.id)) + liveTabIdsByWorktreeId.set(worktreeId, liveTabIds) + return liveTabIds + } + for (const [tabId, watcherEntry] of parkedWatchersByTabId) { + const entry = entriesByWorktreeId.get(watcherEntry.worktreeId) + if (!entry) { + continue + } + const liveTabIds = liveTabIdsFor(watcherEntry.worktreeId) + if (!liveTabIds?.has(tabId)) { + disposeClosedParkedTabWatchers(tabId, watcherEntry) + continue + } + if (!entry.parkedTabIds.has(tabId) && watcherEntry.disposersByPtyId.size > 0) { + disposeParkedTabWatchers(tabId) + } + } + // Why: closed tabs never park/reveal again; drop captures to keep the registry bounded. + for (const [tabId, capture] of capturedPanesByTabId) { + const liveTabIds = liveTabIdsFor(capture.worktreeId) + if (liveTabIds && !liveTabIds.has(tabId)) { + capturedPanesByTabId.delete(tabId) + } + } + for (const [worktreeId, entry] of entriesByWorktreeId) { + startOrReconcileParkedTabWatchers(worktreeId, entry) + } +} + /** * Reconciles watchers for one worktree against its rendered parked set. * Run from an effect keyed on committed render state so disposal shares the @@ -256,35 +343,18 @@ export function syncParkedTerminalTabWatchers(args: { /** Parked-equivalent tabs whose pane has not restored the current title. */ restoreTitleOnStartTabIds?: ReadonlySet }): void { - const liveTabIds = new Set(args.tabs.map((tab) => tab.id)) - for (const [tabId, entry] of parkedWatchersByTabId) { - if (entry.worktreeId !== args.worktreeId) { - continue - } - if (!liveTabIds.has(tabId)) { - disposeClosedParkedTabWatchers(tabId, entry) - continue - } - if (!args.parkedTabIds.has(tabId) && entry.disposersByPtyId.size > 0) { - disposeParkedTabWatchers(tabId) - } - } - // Why: closed tabs never park/reveal again; drop captures to keep the registry bounded. - for (const [tabId, capture] of capturedPanesByTabId) { - if (capture.worktreeId === args.worktreeId && !liveTabIds.has(tabId)) { - capturedPanesByTabId.delete(tabId) - } - } - for (const tab of args.tabs) { - if (!args.parkedTabIds.has(tab.id)) { - continue - } - const entry = parkedWatchersByTabId.get(tab.id) - const restoreTitleOnRegister = args.restoreTitleOnStartTabIds?.has(tab.id) === true - if (entry) { - reconcileParkedTabWatchers(args.worktreeId, tab, entry, restoreTitleOnRegister) - } else { - startParkedTabWatchers(args.worktreeId, tab, restoreTitleOnRegister) - } - } + syncParkedTerminalTabWatchersForWorkspaces( + new Map([ + [ + args.worktreeId, + { + tabs: args.tabs, + parkedTabIds: args.parkedTabIds, + ...(args.restoreTitleOnStartTabIds + ? { restoreTitleOnStartTabIds: args.restoreTitleOnStartTabIds } + : {}) + } + ] + ]) + ) } diff --git a/src/renderer/src/components/terminal-parking-pass-candidates.ts b/src/renderer/src/components/terminal-parking-pass-candidates.ts index 1f7d85d89de..98f9ff5e5cd 100644 --- a/src/renderer/src/components/terminal-parking-pass-candidates.ts +++ b/src/renderer/src/components/terminal-parking-pass-candidates.ts @@ -27,7 +27,8 @@ export function collectTerminalParkingPassCandidates(controller: TerminalParking terminalWorktreeHiddenSinceRef, terminalWorktreeParkCooldownUntilRef, terminalWorktreeParkingTimersRef, - workspaceSurfaces + workspaceSurfaceIds, + workspaceSurfaceIdSet } = controller const parkingTimers = terminalWorktreeParkingTimersRef.current for (const timer of parkingTimers.values()) { @@ -38,9 +39,8 @@ export function collectTerminalParkingPassCandidates(controller: TerminalParking const nowMs = Date.now() const overrides = getTerminalParkingPolicyOverrides() const portalWorktreeIds = new Set(activityTerminalPortals.map((portal) => portal.worktreeId)) - const currentWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id)) for (const worktreeId of Array.from(terminalWorktreeHiddenSinceRef.current.keys())) { - if (!currentWorktreeIds.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) { + if (!workspaceSurfaceIdSet.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) { terminalWorktreeHiddenSinceRef.current.delete(worktreeId) measuringTerminalWorktreeIdsRef.current.delete(worktreeId) terminalWorktreeParkCooldownUntilRef.current.delete(worktreeId) @@ -48,8 +48,7 @@ export function collectTerminalParkingPassCandidates(controller: TerminalParking } const retentionCandidates: TerminalWorktreeColdParkCandidate[] = [] - for (const workspace of workspaceSurfaces) { - const worktreeId = workspace.id + for (const worktreeId of workspaceSurfaceIds) { if (!mountedWorktreeIdsRef.current.has(worktreeId)) { terminalWorktreeHiddenSinceRef.current.delete(worktreeId) measuringTerminalWorktreeIdsRef.current.delete(worktreeId) diff --git a/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx new file mode 100644 index 00000000000..770b1cfc47b --- /dev/null +++ b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, renderHook } from '@testing-library/react' +import { useEffect } from 'react' +import { useAppStore } from '@/store' +import { makeRepo, makeWorktree } from './worktree-jump-palette-test-fixtures' +import { useTerminalWorkspaceFoundation } from './use-terminal-workspace-foundation' +import { applyTerminalColdActivation } from './terminal-cold-activation' +import { collectTerminalParkingPassCandidates } from './terminal-parking-pass-candidates' +import type { WorkspaceSurface } from './workspace-surface-projection' +import type { TerminalParkingFoundation } from './use-terminal-parking-foundation' + +const initialState = useAppStore.getInitialState() + +/** An id-only surface array that reports how often a caller re-derived its ids. */ +function countingSurfaces(ids: string[]): { surfaces: WorkspaceSurface[]; mapCalls: () => number } { + let mapCalls = 0 + const surfaces = ids.map((id) => ({ id, path: `/tmp/${id}` })) + Object.defineProperty(surfaces, 'map', { + configurable: true, + value(this: WorkspaceSurface[], ...args: Parameters) { + mapCalls += 1 + return Array.prototype.map.apply(this, args) + } + }) + return { surfaces, mapCalls: () => mapCalls } +} + +describe('workspace surface ids', () => { + beforeEach(() => { + useAppStore.setState(initialState, true) + }) + + afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) + vi.restoreAllMocks() + }) + + it('keeps the id array and id set stable when a worktree write re-identifies the surfaces', () => { + const repo = makeRepo() + const first = makeWorktree('alpha', 'Alpha') + const second = makeWorktree('beta', 'Beta') + useAppStore.setState({ worktreesByRepo: { [repo.id]: [first, second] } }) + + const { result, rerender } = renderHook(() => useTerminalWorkspaceFoundation()) + const initialSurfaces = result.current.workspaceSurfaces + const initialIds = result.current.workspaceSurfaceIds + const initialIdSet = result.current.workspaceSurfaceIdSet + expect(initialIds).toEqual([first.id, second.id]) + + // A worktree write that changes no id: fresh row objects, fresh array. + useAppStore.setState({ + worktreesByRepo: { + [repo.id]: [ + { ...first, lastActivityAt: Date.now() }, + { ...second, lastActivityAt: Date.now() } + ] + } + }) + rerender() + + expect(result.current.workspaceSurfaces).not.toBe(initialSurfaces) + expect(result.current.workspaceSurfaceIds).toBe(initialIds) + expect(result.current.workspaceSurfaceIdSet).toBe(initialIdSet) + + // A real membership change still re-identifies both. + useAppStore.setState({ worktreesByRepo: { [repo.id]: [first] } }) + rerender() + expect(result.current.workspaceSurfaceIds).not.toBe(initialIds) + expect(result.current.workspaceSurfaceIdSet).not.toBe(initialIdSet) + expect(result.current.workspaceSurfaceIds).toEqual([first.id]) + }) + + it('does not rebuild a surface-id array or set on a cold-activation render', () => { + const ids = Array.from({ length: 423 }, (_, index) => `repo::/worktree-${index}`) + const { surfaces, mapCalls } = countingSurfaces(ids) + const controller = { + activationDeferredMountTabIdsByWorktreeRef: { current: new Map() }, + activeGroupIdByWorktree: {}, + activeTabId: null, + activeTabIdByWorktree: {}, + activeWorktreeDeferralHostId: null, + activityTerminalPortals: [], + backgroundMountTabIdsByWorktreeRef: { current: new Map() }, + groupsByWorktree: {}, + hydrationSucceeded: false, + lastActivationWorktreeIdRef: { current: null }, + layoutByWorktree: {}, + mountedWorktreeIdsRef: { current: new Set(['repo::/worktree-0', 'repo::/gone']) }, + pairedRuntimeParkingEnvironmentIds: new Set(), + pendingStartupByTabId: {}, + renderedActiveWorktreeId: null, + startupWorktreeRefreshCompleted: false, + tabsByWorktree: {}, + terminalParkingEnabled: true, + terminalTitleSnapshotAuthorityEnabled: true, + workspaceSessionReady: false, + workspaceSurfaces: surfaces, + workspaceSurfaceIds: ids, + workspaceSurfaceIdSet: new Set(ids) + } as unknown as TerminalParkingFoundation + + applyTerminalColdActivation(controller) + applyTerminalColdActivation(controller) + + // Pre-fix this was two 423-element id arrays (plus a 423-entry Set) per render. + expect(mapCalls()).toBe(0) + expect(controller.mountedWorktreeIdsRef.current.has('repo::/gone')).toBe(false) + expect(controller.mountedWorktreeIdsRef.current.has('repo::/worktree-0')).toBe(true) + }) + + it('does not rebuild a surface-id array or set on a parking pass', () => { + const ids = Array.from({ length: 423 }, (_, index) => `repo::/worktree-${index}`) + const { surfaces, mapCalls } = countingSurfaces(ids) + const hiddenSince = new Map([ + ['repo::/worktree-0', 1], + ['repo::/stale', 2] + ]) + const controller = { + activeView: 'terminal', + activityTerminalPortals: [], + measurableBackgroundWorktreeIdsRef: { current: new Set() }, + measuringTerminalWorktreeIdsRef: { current: new Set() }, + mountedWorktreeIdsRef: { current: new Set(['repo::/worktree-0']) }, + pairedRuntimeParkingEnvironmentIds: new Set(), + pendingStartupByTabId: {}, + renderedActiveWorktreeId: 'repo::/worktree-0', + tabsByWorktree: {}, + terminalParkingEnabled: true, + terminalSshParkingEnabled: true, + terminalWorktreeHiddenSinceRef: { current: hiddenSince }, + terminalWorktreeParkCooldownUntilRef: { current: new Map() }, + terminalWorktreeParkingTimersRef: { current: new Map() }, + workspaceSurfaces: surfaces, + workspaceSurfaceIds: ids, + workspaceSurfaceIdSet: new Set(ids) + } as unknown as TerminalParkingFoundation + + const pass = collectTerminalParkingPassCandidates(controller) + + // Pre-fix this allocated a 423-element id array and a 423-entry Set per fire. + expect(mapCalls()).toBe(0) + expect(pass.retentionCandidates.map((candidate) => candidate.worktreeId)).toEqual([ + 'repo::/worktree-0' + ]) + expect(hiddenSince.has('repo::/stale')).toBe(false) + }) + it('stops idle worktree writes from re-firing surface-keyed terminal effects', () => { + const repo = makeRepo() + const worktrees = Array.from({ length: 20 }, (_, index) => + makeWorktree(`wt-${index}`, `Workspace ${index}`) + ) + useAppStore.setState({ worktreesByRepo: { [repo.id]: worktrees } }) + + const fires = { surfaceKeyed: 0, idKeyed: 0 } + renderHook(() => { + const foundation = useTerminalWorkspaceFoundation() + useEffect(() => { + fires.surfaceKeyed += 1 + }, [foundation.workspaceSurfaces]) + useEffect(() => { + fires.idKeyed += 1 + }, [foundation.workspaceSurfaceIds]) + }) + expect(fires).toEqual({ surfaceKeyed: 1, idKeyed: 1 }) + + // 100 worktree writes that touch no workspace id — the idle shape (activity + // timestamps, status refreshes) that dominates a large profile. + for (let write = 0; write < 100; write += 1) { + act(() => { + useAppStore.setState({ + worktreesByRepo: { + [repo.id]: worktrees.map((worktree) => ({ ...worktree, lastActivityAt: write })) + } + }) + }) + } + + expect(fires.surfaceKeyed).toBe(101) + expect(fires.idKeyed).toBe(1) + }) +}) diff --git a/src/renderer/src/components/use-terminal-browser-retention.ts b/src/renderer/src/components/use-terminal-browser-retention.ts index cbc2dff078c..34d3b0e1a80 100644 --- a/src/renderer/src/components/use-terminal-browser-retention.ts +++ b/src/renderer/src/components/use-terminal-browser-retention.ts @@ -20,7 +20,8 @@ export function useTerminalBrowserRetention(controller: TerminalParkingFoundatio mountedWorktreeIdsRef, renderedActiveWorktreeId, setBrowserGuestRetentionRevision, - workspaceSurfaces + workspaceSurfaceIds, + workspaceSurfaceIdSet } = controller useEffect(() => { @@ -42,9 +43,8 @@ export function useTerminalBrowserRetention(controller: TerminalParkingFoundatio } const recency = browserGuestWorktreeRecencyRef.current touchBrowserGuestWorktreeRecency(recency, renderedActiveWorktreeId) - const surfaceIds = new Set(workspaceSurfaces.map((workspace) => workspace.id)) for (let index = recency.length - 1; index >= 0; index--) { - if (!surfaceIds.has(recency[index])) { + if (!workspaceSurfaceIdSet.has(recency[index])) { recency.splice(index, 1) } } @@ -55,7 +55,7 @@ export function useTerminalBrowserRetention(controller: TerminalParkingFoundatio const recencyIds = new Set(recency) const orderedWorktreeIds = [ ...recency, - ...workspaceSurfaces.map((workspace) => workspace.id).filter((id) => !recencyIds.has(id)) + ...workspaceSurfaceIds.filter((id) => !recencyIds.has(id)) ] const evictedWorktreeIds = selectBrowserGuestEvictionWorktreeIds({ orderedWorktreeIds, @@ -82,7 +82,7 @@ export function useTerminalBrowserRetention(controller: TerminalParkingFoundatio // oxlint-disable-next-line react-hooks/exhaustive-deps -- controller refs preserve their original stable identities. }, [ renderedActiveWorktreeId, - workspaceSurfaces, + workspaceSurfaceIds, browserGuestRetentionBudgetEnabled, browserGuestRetentionRevision ]) diff --git a/src/renderer/src/components/use-terminal-parking-pass.ts b/src/renderer/src/components/use-terminal-parking-pass.ts index 01733c3c09c..1ce5eab03ff 100644 --- a/src/renderer/src/components/use-terminal-parking-pass.ts +++ b/src/renderer/src/components/use-terminal-parking-pass.ts @@ -41,7 +41,7 @@ export function useTerminalParkingPass(controller: TerminalParkingFoundation): v terminalProviderSnapshotCapabilityRevision, terminalRetentionBudgetEnabled, terminalSshParkingEnabled, - workspaceSurfaces + workspaceSurfaceIds } = controller useEffect(() => { @@ -182,6 +182,6 @@ export function useTerminalParkingPass(controller: TerminalParkingFoundation): v terminalProviderSnapshotCapabilityRevision, terminalRetentionBudgetEnabled, terminalSshParkingEnabled, - workspaceSurfaces + workspaceSurfaceIds ]) } diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts index 9b80e964747..9e82b821c31 100644 --- a/src/renderer/src/components/use-terminal-watcher-effects.ts +++ b/src/renderer/src/components/use-terminal-watcher-effects.ts @@ -5,8 +5,9 @@ import { canWatcherCoverParkedTerminalTab, disposeAllParkedTerminalWatchers, pruneParkedTerminalWatchers, - syncParkedTerminalTabWatchers, - terminalWatcherLiveWorkspaceIds + syncParkedTerminalTabWatchersForWorkspaces, + terminalWatcherLiveWorkspaceIds, + type ParkedTerminalTabWatcherSyncEntry } from './terminal-pane/terminal-parked-tab-watchers' import { useAppStore } from '@/store' import { gateWorktreeAgentActivation } from '@/lib/worktree-agent-activation-gate' @@ -40,36 +41,35 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont terminalStartupRestorationReady, terminalTitleSnapshotAuthorityEnabled, workspaceSessionReady, - workspaceSurfaces + workspaceSurfaceIds } = controller useEffect(() => { - pruneParkedTerminalWatchers( - terminalWatcherLiveWorkspaceIds(workspaceSurfaces.map((workspace) => workspace.id)) - ) - for (const workspace of workspaceSurfaces) { + pruneParkedTerminalWatchers(terminalWatcherLiveWorkspaceIds(workspaceSurfaceIds)) + const syncEntriesByWorktreeId = new Map() + for (const workspaceId of workspaceSurfaceIds) { if ( anyMountedWorktreeHasLayout && - mountedWorktreeIdsRef.current.has(workspace.id) && - getEffectiveLayoutForWorktree(workspace.id) + mountedWorktreeIdsRef.current.has(workspaceId) && + getEffectiveLayoutForWorktree(workspaceId) ) { continue } - const tabs = tabsByWorktree[workspace.id] ?? [] + const tabs = tabsByWorktree[workspaceId] ?? [] const parkedTabIds = new Set() let deferredTabIds: ReadonlySet | null = null - if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspace.id)) { - const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId + if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspaceId)) { + const isVisible = activeView === 'terminal' && workspaceId === renderedActiveWorktreeId const shouldMeasureHiddenWorktree = - !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) + !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspaceId) const parked = !isVisible && !shouldMeasureHiddenWorktree && - effectiveParkedTerminalWorktreeIds.has(workspace.id) + effectiveParkedTerminalWorktreeIds.has(workspaceId) if (parked) { for (const tab of tabs) { const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { - worktreeId: workspace.id, + worktreeId: workspaceId, tabId: tab.id }) if (!activityTerminalPortal && !evictionExemptTerminalTabIds.has(tab.id)) { @@ -77,15 +77,14 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont } } } - deferredTabIds = - activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null + deferredTabIds = activationDeferredMountTabIdsByWorktreeRef.current.get(workspaceId) ?? null for (const tab of tabs) { if ( deferredTabIds?.has(tab.id) && !parkedTabIds.has(tab.id) && - canWatcherCoverParkedTerminalTab(workspace.id, tab) && + canWatcherCoverParkedTerminalTab(workspaceId, tab) && !findActivityTerminalPortal(activityTerminalPortals, { - worktreeId: workspace.id, + worktreeId: workspaceId, tabId: tab.id }) ) { @@ -93,13 +92,13 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont } } } - syncParkedTerminalTabWatchers({ - worktreeId: workspace.id, + syncEntriesByWorktreeId.set(workspaceId, { tabs, parkedTabIds, ...(deferredTabIds ? { restoreTitleOnStartTabIds: deferredTabIds } : {}) }) } + syncParkedTerminalTabWatchersForWorkspaces(syncEntriesByWorktreeId) // oxlint-disable-next-line react-hooks/exhaustive-deps -- controller refs preserve their original stable identities. }, [ activeTabId, @@ -118,7 +117,7 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont terminalParkingEnabled, terminalTitleSnapshotAuthorityEnabled, workspaceSessionReady, - workspaceSurfaces + workspaceSurfaceIds ]) useEffect(() => () => disposeAllParkedTerminalWatchers(), []) diff --git a/src/renderer/src/components/use-terminal-workspace-foundation.ts b/src/renderer/src/components/use-terminal-workspace-foundation.ts index 9b365e9d885..61726fa9643 100644 --- a/src/renderer/src/components/use-terminal-workspace-foundation.ts +++ b/src/renderer/src/components/use-terminal-workspace-foundation.ts @@ -6,6 +6,7 @@ import { useWorktreeMap } from '../store/selectors' import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' import type { WorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' import { projectWorkspaceSurfaces } from './workspace-surface-projection' +import { useReusedArrayIdentity } from './sidebar/worktree-list/listing/use-reused-array-identity' import { selectPairedRuntimeParkingEnvironmentIds } from './terminal-pane/terminal-hidden-view-parking' import { createTerminalWorktreeTopologyProjection } from './terminal-pane/terminal-hidden-worktree-retention' import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler' @@ -46,6 +47,17 @@ export function useTerminalWorkspaceFoundation() { }), [worktreesById, folderWorkspaces, renderedActiveWorktreeId, activeFolderSurfaceHostId] ) + // Why split the ids out: every mount/park/activation pass reads only `.id`, but + // the surface array is re-identified on any worktree write. Reusing the previous + // id-array identity keeps those effects and their per-fire Sets from re-firing + // when the workspace set itself did not change. + const workspaceSurfaceIds = useReusedArrayIdentity( + useMemo(() => workspaceSurfaces.map((workspace) => workspace.id), [workspaceSurfaces]) + ) + const workspaceSurfaceIdSet = useMemo>( + () => new Set(workspaceSurfaceIds), + [workspaceSurfaceIds] + ) const activeView = useAppStore((state) => state.activeView) // Why: terminal titles are leaf chrome. The root host only subscribes to // mount/parking semantics; a real transition publishes fresh tab objects, @@ -88,6 +100,8 @@ export function useTerminalWorkspaceFoundation() { terminalWorktreeParkingTimersRef, folderWorkspaces, workspaceSurfaces, + workspaceSurfaceIds, + workspaceSurfaceIdSet, activeWorktreeId, renderedActiveWorktreeId, activeWorktreeDeferralHostId, From 084dbbc3b3d0b29e5edc8b7a0a344d9d0508bd01 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:05:16 -0700 Subject: [PATCH 09/25] perf(persistence): build the state file once per save instead of seven times (#18161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every debounced save stringified the full persisted state, then ran two `String.replace` passes per secret sentinel — one for the on-disk payload, one for the guard hash. Each replace returns a rope the next one has to flatten before it can search, so three sentinels cost seven flattened copies of a 4.65 MB state (a two-byte V8 string, ~8.9 MB each), and the state was then UTF-8 encoded twice more: once inside `sha1.update(string)` and again inside `handle.writeFile(payload, 'utf-8')`. `applySecretSentinelSubstitutions` walks the state once with a single alternation regex, encodes each literal run to a Buffer exactly once, and feeds those same buffers to both the payload and the hash. Measured on the author's 4.65 MB store with three live secret slots: 48.8 MB -> 17.9 MB allocated per save, 26.6 MB -> 0 of large_object_space churn, and 22.1 -> 15.1 ms (min) / 32.3 -> 16.9 ms (median) for build+hash+encode. Bytes on disk and the guard hash are proven identical to the previous loop. Separately, non-local host session partitions carried stale replicas of the `browserUrlHistory` global — 589,807 bytes, 12.7% of the file — that neither the split (which writes globals only to 'local') nor the merge (which reads them only from 'local' unless local has none) can ever reach. The load path now drops them when the local slice already holds the field. Only the two history globals are dropped: the rest are read out of every partition by the worktree ownership sweep or the mobile/runtime projections. --- src/main/durable-file-write.ts | 9 +- .../normalize-loaded-profile-state.ts | 11 +- .../normalize-loaded-state-collections.ts | 4 +- .../loading-store/primary-state-writes.ts | 3 +- .../secret-sentinel-substitution.test.ts | 184 ++++++++++++++++++ .../secret-sentinel-substitution.ts | 78 ++++++++ .../state-serialization-secret-handling.ts | 33 ++-- .../state-write-round-trip.test.ts | 131 +++++++++++++ .../workspace-session-partitions.test.ts | 141 ++++++++++++++ .../workspace-session-partitions.ts | 41 +++- .../lib/workspace-session-host-contention.ts | 2 +- .../lib/workspace-session-host-split.test.ts | 43 ++++ .../src/lib/workspace-session-host-split.ts | 2 +- .../workspace-session-host-field-ownership.ts | 2 +- 14 files changed, 656 insertions(+), 28 deletions(-) create mode 100644 src/main/persistence/loading-store/secret-sentinel-substitution.test.ts create mode 100644 src/main/persistence/loading-store/secret-sentinel-substitution.ts create mode 100644 src/main/persistence/loading-store/state-write-round-trip.test.ts create mode 100644 src/main/persistence/loading-store/workspace-session-partitions.test.ts rename src/{renderer/src/lib => shared}/workspace-session-host-field-ownership.ts (97%) diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index 2f40b0881e8..83b0eabc5ed 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -187,10 +187,15 @@ export async function removeStaleDurableWriteTempFiles( } /** Synchronous counterpart for quit and crash paths that cannot await. */ -export function writeFileDurableSync(tmpPath: string, finalPath: string, payload: string): void { +export function writeFileDurableSync( + tmpPath: string, + finalPath: string, + payload: string | Uint8Array +): void { let renamed = false try { - writeFileSync(tmpPath, payload, 'utf-8') + // A Uint8Array payload is written verbatim; a string still defaults to UTF-8. + writeFileSync(tmpPath, payload) const fd = openSync(tmpPath, 'r+') try { fsyncSync(fd) diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts index 2286e40d54b..39d625c525a 100644 --- a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -35,6 +35,8 @@ export function normalizeLoadedProfileState( const { defaults, migratedExternalVisibility, osc52ClipboardNoticePending } = terminal const { normalizedOnboarding, normalizedProjectGroups, loadedCompactWorktreeCards } = profile const projectCatalog = normalizeLoadedProjectCatalog(parsed, markNeedsSave) + // Ordered: the host partitions drop the global fields this slice already owns. + const workspaceSession = normalizeLoadedLocalSession(parsed, defaults, markNeedsSave) return { ...defaults, @@ -69,9 +71,14 @@ export function normalizeLoadedProfileState( markNeedsSave ), // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. - workspaceSession: normalizeLoadedLocalSession(parsed, defaults, markNeedsSave), + workspaceSession, // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. - workspaceSessionsByHostId: normalizeLoadedHostSessions(parsed, defaults, markNeedsSave), + workspaceSessionsByHostId: normalizeLoadedHostSessions( + parsed, + defaults, + workspaceSession, + markNeedsSave + ), sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) ? parsed.deletedSshConfigAliases.filter((alias): alias is string => typeof alias === 'string') diff --git a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts index 2b133d486b7..16c5b424def 100644 --- a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts +++ b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts @@ -41,11 +41,13 @@ export function normalizeLoadedLocalSession( export function normalizeLoadedHostSessions( parsed: PersistedState, defaults: PersistedState, + localSession: WorkspaceSessionState, markNeedsSave: () => void ): PersistedState['workspaceSessionsByHostId'] { const { partitions, repaired } = parseWorkspaceSessionsByHostId( parsed.workspaceSessionsByHostId, - defaults.workspaceSession + defaults.workspaceSession, + localSession ) if (repaired) { // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index 9d6e476ebe5..c112b4a9ba0 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -161,7 +161,8 @@ export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Prom // Why: fsync before rename, then fsync the directory; see writeFileDurable. const handle = await open(tmpFile, 'w') try { - await handle.writeFile(payload, 'utf-8') + // Already UTF-8 bytes: passing the string here would re-encode the whole state on the main thread. + await handle.writeFile(payload) await handle.sync() } finally { await handle.close() diff --git a/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts new file mode 100644 index 00000000000..e58d0280bae --- /dev/null +++ b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts @@ -0,0 +1,184 @@ +/** + * The bar for this change is "the bytes on disk did not move". Every case below runs the exact + * loop `applySecretSentinelSubstitutions` replaced — reproduced in `previousImplementation` — and + * compares payload bytes and guard hash, because a drifting hash silently disables the no-op write + * guard and a drifting payload is corrupted persisted state. + */ +import { createHash, randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { + applySecretSentinelSubstitutions, + type SecretSentinelSubstitution +} from './secret-sentinel-substitution' + +/** Verbatim from state-serialization-secret-handling.ts before this change. */ +function previousImplementation( + serialized: string, + secretSubs: readonly SecretSentinelSubstitution[], + degradedPrefix: string +): { payload: Buffer; stateHash: string } { + let payload = serialized + let hashInput = serialized + for (const { sentinel, blob, hashValue } of secretSubs) { + const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) + payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) + hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) + } + const stateHash = createHash('sha1').update(degradedPrefix).update(hashInput).digest('hex') + // `handle.writeFile(payload, 'utf-8')` is what turned the string into bytes. + return { payload: Buffer.from(payload, 'utf8'), stateHash } +} + +function expectIdenticalToPrevious( + serialized: string, + subs: readonly SecretSentinelSubstitution[], + degradedPrefix = '' +): void { + const before = previousImplementation(serialized, subs, degradedPrefix) + const after = applySecretSentinelSubstitutions(serialized, subs, degradedPrefix) + expect(after.payload.equals(before.payload)).toBe(true) + expect(after.stateHash).toBe(before.stateHash) +} + +function sentinel(): string { + return `orca-secret-slot-${randomUUID()}` +} + +describe('applySecretSentinelSubstitutions', () => { + it('produces bytes and a hash identical to the previous implementation', () => { + const subs: SecretSentinelSubstitution[] = [ + { sentinel: sentinel(), blob: 'djEwY2lwaGVy', hashValue: 'cookie-value' }, + { + sentinel: sentinel(), + // Regex-special *and* JSON-escapable, which is the pair that breaks a naive rewrite: + // `$&` would splice the match back in under string-form replace, and the backslash and + // quote have to survive `JSON.stringify(...).slice(1, -1)` unchanged. + blob: 'A+/=$&$1$`\\x "quoted" |.*?[](){}^', + hashValue: 'http://proxy.example:8080/?a=b&c=$&' + }, + { sentinel: sentinel(), blob: '', hashValue: 'https://kagi.com/session?t=abc' } + ] + const state = { + settings: { opencodeSessionCookie: subs[0].sentinel, httpProxyUrl: subs[1].sentinel }, + ui: { browserKagiSessionLink: subs[2].sentinel }, + // Adjacent content that must not shift: a near-miss prefix, and JSON escapes either side. + noise: ['orca-secret-slot-', 'a\\b"c\n\t', subs[0].sentinel.slice(0, -1)] + } + expectIdenticalToPrevious(JSON.stringify(state), subs) + }) + + it('stays identical when the state holds multi-byte and escaped characters', () => { + const subs: SecretSentinelSubstitution[] = [ + { sentinel: sentinel(), blob: 'blob-é', hashValue: 'plain-é' }, + { sentinel: sentinel(), blob: '😀', hashValue: '中文' } + ] + const state = { + // Segment boundaries land next to these, so a wrong split would corrupt the encode. + before: 'é中文😀', + a: subs[0].sentinel, + between: '😀

', + b: subs[1].sentinel, + after: '😀' + } + expectIdenticalToPrevious(JSON.stringify(state), subs) + }) + + it('stays identical with no substitutions and with the degraded-storage prefix', () => { + const state = JSON.stringify({ settings: { httpProxyUrl: '' }, big: 'x'.repeat(4096) }) + expectIdenticalToPrevious(state, []) + expectIdenticalToPrevious(state, [], 'safeStorage-degraded\0') + + const subs = [{ sentinel: sentinel(), blob: 'b', hashValue: 'h' }] + expectIdenticalToPrevious( + JSON.stringify({ s: subs[0].sentinel }), + subs, + 'safeStorage-degraded\0' + ) + }) + + it('escapes regex metacharacters in the sentinel itself', () => { + // Not reachable from a UUID sentinel, but the alternation must not be able to become a pattern. + const subs = [{ sentinel: 'a.b*c(d)|e[f]', blob: 'BLOB', hashValue: 'HASH' }] + const serialized = JSON.stringify({ real: subs[0].sentinel, decoy: 'axbxxcXdX_eXfX' }) + expectIdenticalToPrevious(serialized, subs) + expect( + applySecretSentinelSubstitutions(serialized, subs, '').payload.toString('utf8') + ).toContain('axbxxcXdX_eXfX') + }) + + it('substitutes every occurrence when a sentinel repeats', () => { + // Cannot happen today (a sentinel is a UUID minted after the state is assembled, so it appears + // exactly once), but the old first-match-only `String.replace` would have written a raw + // sentinel to disk in place of a secret if it ever did. The alternation is global instead. + const subs = [{ sentinel: sentinel(), blob: 'CIPHER', hashValue: 'PLAIN' }] + const serialized = JSON.stringify({ a: subs[0].sentinel, b: subs[0].sentinel }) + const { payload } = applySecretSentinelSubstitutions(serialized, subs, '') + expect(payload.toString('utf8')).toBe(JSON.stringify({ a: 'CIPHER', b: 'CIPHER' })) + expect(payload.toString('utf8')).not.toContain(subs[0].sentinel) + }) + + it('copies and UTF-8 encodes the full state once, not once per sentinel per side', () => { + const subs: SecretSentinelSubstitution[] = Array.from({ length: 3 }, () => ({ + sentinel: sentinel(), + blob: 'CIPHERTEXT', + hashValue: 'plaintext' + })) + const serialized = JSON.stringify({ + pad: 'x'.repeat(200_000), + a: subs[0].sentinel, + b: subs[1].sentinel, + c: subs[2].sentinel + }) + const FULL_STATE = 100_000 + + // Both costs are observable at their sources: a `String.replace` whose receiver is the whole + // state allocates another copy of it, and every string handed to `Buffer.from` or `hash.update` + // is one full UTF-8 encode pass on the main thread. + const counted = (run: () => unknown): { fullStateReplaces: number; encodedChars: number } => { + const realReplace = String.prototype.replace + const realBufferFrom = Buffer.from + const hashProto = Object.getPrototypeOf(createHash('sha1')) as { + update: (...args: unknown[]) => unknown + } + const realUpdate = hashProto.update + const counts = { fullStateReplaces: 0, encodedChars: 0 } + String.prototype.replace = function (this: string, ...args: unknown[]) { + if (this.length >= FULL_STATE) { + counts.fullStateReplaces++ + } + return realReplace.apply(this, args as never) + } as typeof String.prototype.replace + Buffer.from = function (...args: unknown[]) { + if (typeof args[0] === 'string') { + counts.encodedChars += args[0].length + } + return (realBufferFrom as (...a: unknown[]) => Buffer).apply(Buffer, args) + } as typeof Buffer.from + hashProto.update = function (this: unknown, ...args: unknown[]) { + if (typeof args[0] === 'string') { + counts.encodedChars += args[0].length + } + return realUpdate.apply(this, args) + } + try { + run() + } finally { + String.prototype.replace = realReplace + Buffer.from = realBufferFrom + hashProto.update = realUpdate + } + return counts + } + + const before = counted(() => previousImplementation(serialized, subs, '')) + const after = counted(() => applySecretSentinelSubstitutions(serialized, subs, '')) + + // Two `String.replace` calls over the whole state per sentinel — payload and hash input. + expect(before.fullStateReplaces).toBe(subs.length * 2) + expect(after.fullStateReplaces).toBe(0) + // The old path encoded the state twice: once for sha1, once for the file write. + expect(before.encodedChars).toBeGreaterThan(serialized.length * 1.9) + expect(after.encodedChars).toBeLessThan(serialized.length * 1.1) + expect(after.encodedChars).toBeGreaterThan(serialized.length * 0.9) + }) +}) diff --git a/src/main/persistence/loading-store/secret-sentinel-substitution.ts b/src/main/persistence/loading-store/secret-sentinel-substitution.ts new file mode 100644 index 00000000000..afcdddafa77 --- /dev/null +++ b/src/main/persistence/loading-store/secret-sentinel-substitution.ts @@ -0,0 +1,78 @@ +import { createHash } from 'node:crypto' +import { escapeRegex } from '../../../shared/string-utils' + +export type SecretSentinelSubstitution = { + /** The `orca-secret-slot-` placeholder standing in the serialized state. */ + sentinel: string + /** What the on-disk payload gets: the ciphertext. */ + blob: string + /** What the guard hash gets: a value stable across non-deterministic encryption. */ + hashValue: string +} + +/** + * Replace every secret sentinel in `serialized` in ONE pass, producing the on-disk bytes and the + * guard hash from the same encoded segments. + * + * Why not the obvious `payload.replace(...)` / `hashInput.replace(...)` loop it replaces: each + * `String.replace` returns a rope that the *next* `replace` has to flatten before it can search, so + * N sentinels cost 2N-1 flattened copies of the whole multi-MB state, plus one more per side when + * `hash.update` and the file write finally consume them. Measured on a 4.65 MB store with three + * sentinels: 7 full-state string allocations, 62 MB of V8 heap, 27 MB of it in large_object_space. + * + * Here the state is walked once, each literal run is UTF-8 encoded exactly once, and those same + * buffers feed both the payload and the hash — 1 full-state string, 1 encode. + * + * Byte-for-byte identical output to the loop: both sides read the sentinel in its JSON-escaped + * form, the replacements are the JSON-escaped `blob`/`hashValue`, and the hash sees the same byte + * sequence it saw when it was handed one concatenated string. + */ +export function applySecretSentinelSubstitutions( + serialized: string, + substitutions: readonly SecretSentinelSubstitution[], + degradedPrefix: string +): { payload: Buffer; stateHash: string } { + const hash = createHash('sha1').update(degradedPrefix) + if (substitutions.length === 0) { + const payload = Buffer.from(serialized, 'utf8') + return { payload, stateHash: hash.update(payload).digest('hex') } + } + + const replacementBySentinel = new Map() + const alternatives: string[] = [] + for (const { sentinel, blob, hashValue } of substitutions) { + // Preserved from the loop this replaces: both the search key and the replacements are the + // JSON-escaped forms, because that is what `serialized` actually contains. + const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) + if (replacementBySentinel.has(escapedSentinel)) { + continue + } + alternatives.push(escapeRegex(escapedSentinel)) + replacementBySentinel.set(escapedSentinel, { + blob: Buffer.from(JSON.stringify(blob).slice(1, -1), 'utf8'), + hashValue: Buffer.from(JSON.stringify(hashValue).slice(1, -1), 'utf8') + }) + } + + // Global, though a sentinel is a UUID minted after the state was assembled and so occurs exactly + // once: a single pass that substitutes every occurrence cannot leave one behind on disk. + const pattern = new RegExp(alternatives.join('|'), 'g') + const chunks: Buffer[] = [] + let cursor = 0 + let match: RegExpExecArray | null + while ((match = pattern.exec(serialized)) !== null) { + // Non-null: the alternation is built from exactly the map's keys. + const replacement = replacementBySentinel.get(match[0])! + // A sliced substring, so this does not copy the state; the encode below is its only pass. + const literal = Buffer.from(serialized.slice(cursor, match.index), 'utf8') + chunks.push(literal, replacement.blob) + hash.update(literal) + hash.update(replacement.hashValue) + cursor = match.index + match[0].length + } + const tail = Buffer.from(serialized.slice(cursor), 'utf8') + chunks.push(tail) + hash.update(tail) + + return { payload: Buffer.concat(chunks), stateHash: hash.digest('hex') } +} diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index c9d81f071a5..61689ea0f13 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from 'node:crypto' +import { randomUUID } from 'node:crypto' import type { PersistedState } from '../../../shared/persisted-state-types' import { collectFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments' import { @@ -8,6 +8,10 @@ import { } from '../../protected-secret-persistence' import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' +import { + applySecretSentinelSubstitutions, + type SecretSentinelSubstitution +} from './secret-sentinel-substitution' import type { StoreRuntimeState } from './store-runtime-state' type StateSerializationSecretHandlingOperationsRuntime = Pick< @@ -24,7 +28,7 @@ export class StateSerializationSecretHandlingOperations { } buildStateToSave(): { - payload: string + payload: Buffer stateHash: string protectedSecretUpdates: ProtectedSecretRetentionUpdate[] } { @@ -37,7 +41,7 @@ export class StateSerializationSecretHandlingOperations { // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot // random UUID can't occur anywhere else in the serialized state (the user // sets their data before it is minted), so it appears exactly once. - const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] + const secretSubs: SecretSentinelSubstitution[] = [] const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] let protectedStorageDegraded = false const encryptToSentinel = (slot: string, plaintext: string): string => { @@ -105,21 +109,14 @@ export class StateSerializationSecretHandlingOperations { // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. // One full-state stringify; secret slots currently hold sentinels. const serialized = JSON.stringify(stateToSave) - // Substitute each unique sentinel exactly once: ciphertext for the on-disk - // payload, a stable normalized value for the guard hash. Function-form - // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped - // in `serialized`, so each replace is byte-for-byte position-exact. - let payload = serialized - let hashInput = serialized - for (const { sentinel, blob, hashValue } of secretSubs) { - const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) - payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) - hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) - } - const stateHash = createHash('sha1') - .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') - .update(hashInput) - .digest('hex') + // Substitute each unique sentinel: ciphertext for the on-disk payload, a stable normalized + // value for the guard hash. One pass builds both, so the multi-MB state is never copied per + // sentinel and never encoded twice. + const { payload, stateHash } = applySecretSentinelSubstitutions( + serialized, + secretSubs, + protectedStorageDegraded ? 'safeStorage-degraded\0' : '' + ) return { payload, stateHash, protectedSecretUpdates } } } diff --git a/src/main/persistence/loading-store/state-write-round-trip.test.ts b/src/main/persistence/loading-store/state-write-round-trip.test.ts new file mode 100644 index 00000000000..ee7f26feb3e --- /dev/null +++ b/src/main/persistence/loading-store/state-write-round-trip.test.ts @@ -0,0 +1,131 @@ +/** + * The write path now hands the file a Buffer it built in one pass instead of a string it rebuilt + * per secret. Drives the real `Store` end to end — encrypted settings, a local session and a remote + * host partition — and reloads from the file it actually wrote, because the failure this guards + * against (a mis-sliced segment, a re-encoded payload, a dropped sentinel) is invisible until + * something reads the bytes back. + */ +import { mkdtempSync, readFileSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + // Encryption ON, so the secret slots really do mint sentinels and the substitution pass runs. + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`enc:${value}`), + decryptString: (value: Buffer) => value.toString().slice(4) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +const { Store } = await import('./store') + +const HOST_ID = 'ssh:user@host' + +const stores: InstanceType[] = [] +afterEach(() => { + for (const store of stores.splice(0)) { + store.flush() + } + vi.restoreAllMocks() +}) + +function openStore(dataFile: string): InstanceType { + const store = new Store({ dataFile }) + stores.push(store) + return store +} + +function session(activeTabId: string): WorkspaceSessionState { + return { + activeRepoId: 'repo-1', + // Left null: the load path's deregistered-repo sweep nulls an active worktree whose repo is + // not registered, which would mask what this test is actually about. + activeWorktreeId: null, + activeTabId, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + // Non-ASCII on purpose: a byte-offset mistake in the encode shows up here first. + browserUrlHistory: [ + { + url: 'https://example.test/é😀', + normalizedUrl: 'https://example.test/é😀', + title: '中文 title', + lastVisitedAt: 17, + visitCount: 3 + } + ] + } as WorkspaceSessionState +} + +describe('persisted state survives a save/load round trip', () => { + it('reloads settings, secrets and both session partitions unchanged', () => { + const dataFile = join( + realpathSync(mkdtempSync(join(tmpdir(), 'orca-store-round-trip-'))), + 'orca-data.json' + ) + const written = openStore(dataFile) + written.updateSettings({ + // Three secret slots, i.e. three sentinels in one save — the case the old loop paid 7 copies for. + opencodeSessionCookie: 'cookie-é-value', + httpProxyUrl: 'http://proxy.example:8080/?a=b&c=$&' + }) + written.updateUI({ browserKagiSessionLink: 'https://kagi.com/session?t=abc' }) + written.setWorkspaceSession(session('local-tab')) + written.setWorkspaceSession(session('remote-tab'), HOST_ID) + written.flush() + + const before = { + settings: written.getSettings(), + ui: written.getUI(), + local: written.getWorkspaceSession(), + remote: written.getWorkspaceSession(HOST_ID) + } + + // The file is valid UTF-8 JSON and holds ciphertext, not the plaintext secrets. + const bytes = readFileSync(dataFile) + const onDisk = JSON.parse(bytes.toString('utf8')) + expect(onDisk.settings.opencodeSessionCookie).not.toBe('cookie-é-value') + expect(Buffer.from(onDisk.settings.opencodeSessionCookie, 'base64').toString('utf8')).toContain( + 'cookie-é-value' + ) + expect(bytes.toString('utf8')).not.toContain('orca-secret-slot-') + + const reloaded = openStore(dataFile) + expect(reloaded.getSettings().opencodeSessionCookie).toBe(before.settings.opencodeSessionCookie) + expect(reloaded.getSettings().httpProxyUrl).toBe(before.settings.httpProxyUrl) + expect(reloaded.getUI().browserKagiSessionLink).toBe(before.ui.browserKagiSessionLink) + // `toMatchObject`: the load path spreads session defaults over what was written, so the + // reloaded slice is a superset. Exact deep equality is asserted on the second trip below. + expect(reloaded.getWorkspaceSession()).toMatchObject(before.local) + // The remote partition keeps everything it owns; only globals local already holds are dropped, + // and `browserUrlHistory` comes back at its default from the same spread as before. + expect(reloaded.getWorkspaceSession(HOST_ID).activeTabId).toBe('remote-tab') + expect(reloaded.getWorkspaceSession(HOST_ID).browserUrlHistory).toEqual([]) + + // Deep equality of the whole reloaded state, taken across a second round trip so the assertion + // is not comparing against the first load's one-time settings migrations. + reloaded.flush() + const bytesAfterReload = readFileSync(dataFile) + const again = openStore(dataFile) + expect(again.getSettings()).toEqual(reloaded.getSettings()) + expect(again.getUI()).toEqual(reloaded.getUI()) + expect(again.getWorkspaceSession()).toEqual(reloaded.getWorkspaceSession()) + expect(again.getWorkspaceSession(HOST_ID)).toEqual(reloaded.getWorkspaceSession(HOST_ID)) + // ...and the bytes are stable, so a quiet app is not rewriting a 4 MB file with new content. + again.flush() + expect(readFileSync(dataFile).equals(bytesAfterReload)).toBe(true) + }) +}) diff --git a/src/main/persistence/loading-store/workspace-session-partitions.test.ts b/src/main/persistence/loading-store/workspace-session-partitions.test.ts new file mode 100644 index 00000000000..f9697f77997 --- /dev/null +++ b/src/main/persistence/loading-store/workspace-session-partitions.test.ts @@ -0,0 +1,141 @@ +/** + * Global session fields live in the 'local' slice. Copies of them inside a non-local host partition + * are legacy residue: the split never writes them there and the merge never reads them from there + * unless local has nothing. These tests pin the drop to exactly that condition, keep the renderer's + * merge landing on the same value either way, and re-check the two safety gates that decide which + * global fields may be dropped at all. + */ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { BrowserHistoryEntry } from '../../../shared/browser-workspace-types' +import type { WorkspaceDocHistoryEntry } from '../../../shared/workspace-doc-history' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership' +import { WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND } from '../restoring-sessions/session-worktree-ownership' +import { + HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS, + parseWorkspaceSessionsByHostId +} from './workspace-session-partitions' + +const HOST = 'ssh:target-1' + +function history(url: string): BrowserHistoryEntry[] { + return [{ url, normalizedUrl: url, title: url, lastVisitedAt: 1, visitCount: 1 }] +} + +function docEntry(filePath: string): WorkspaceDocHistoryEntry { + return { + docLocation: { kind: 'workspace-doc', worktreeId: 'repo-1::/tmp/a', filePath }, + title: filePath, + lastVisitedAt: 2, + visitCount: 1 + } +} + +function localSession(overrides: Partial): WorkspaceSessionState { + return { ...getDefaultWorkspaceSession(), ...overrides } +} + +function parse( + raw: Record, + local?: WorkspaceSessionState +): Partial> { + return parseWorkspaceSessionsByHostId(raw, getDefaultWorkspaceSession(), local).partitions +} + +describe('HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS', () => { + it('only lists fields that are global AND that no worktree-ownership pass follows', () => { + for (const field of HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS) { + // Gate 1: the renderer's split/merge treat it as local-owned, so a non-local copy is dead. + expect(WORKSPACE_SESSION_FIELD_OWNERSHIP[field]).toBe('global') + // Gate 2: `collectPersistedSessionWorktreeOwners` and the deregistered-repo residue sweep + // walk EVERY partition through this table. Anything but 'none' means dropping the field + // could un-own a worktree and get its metadata pruned. + expect(WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND[field]).toBe('none') + } + }) +}) + +describe('parseWorkspaceSessionsByHostId global-field residue', () => { + it('drops a non-local global field the local slice already owns', () => { + const local = localSession({ browserUrlHistory: history('https://local.test') }) + const partitions = parse( + { + [HOST]: { + ...getDefaultWorkspaceSession(), + browserUrlHistory: history('https://stale.test') + } + }, + local + ) + // Back to the default from the spread, not the 65 KB stale replica. The merge reads this field + // from local whenever local has it, so the renderer still sees `https://local.test` + // (`workspace-session-host-split.test.ts` pins that half of the contract). + expect(partitions[HOST]?.browserUrlHistory).toEqual([]) + }) + + it('retains a non-local global field the local slice does NOT have', () => { + // `workspaceDocHistory` is optional and absent from the defaults, so local can genuinely lack + // it and the merge's fallback to another slice is live. + const local = localSession({}) + expect(local.workspaceDocHistory).toBeUndefined() + const docs = [docEntry('/repo/remote.md')] + const partitions = parse( + { [HOST]: { ...getDefaultWorkspaceSession(), workspaceDocHistory: docs } }, + local + ) + // Retained, so the merge's "fall back to any slice that has it" path still finds a value. + expect(partitions[HOST]?.workspaceDocHistory).toEqual(docs) + }) + + it('drops that same field once the local slice does have it', () => { + const localDocs = [docEntry('/repo/local.md')] + const local = localSession({ workspaceDocHistory: localDocs }) + const partitions = parse( + { + [HOST]: { + ...getDefaultWorkspaceSession(), + workspaceDocHistory: [docEntry('/repo/stale.md')] + } + }, + local + ) + expect(partitions[HOST]).not.toHaveProperty('workspaceDocHistory') + expect(local.workspaceDocHistory).toEqual(localDocs) + }) + + it('leaves worktree-referencing globals and host-owned fields alone', () => { + const local = localSession({ + browserUrlHistory: history('https://local.test'), + activeWorktreeId: 'repo-1::/tmp/local', + activeTabId: 'local-tab' + }) + const tabs = { 'repo-1::/tmp/a': [] } + const partitions = parse( + { + [HOST]: { + ...getDefaultWorkspaceSession(), + // A `'direct'` worktree reference the residue sweep reads out of every partition. + activeWorktreeId: 'repo-1::/tmp/a', + // Read on a partition by the mobile terminal projection. + activeTabId: 'remote-tab', + tabsByWorktree: tabs, + terminalTopologyRevisionByRepoId: { 'repo-1': 4 } + } + }, + local + ) + expect(partitions[HOST]?.activeWorktreeId).toBe('repo-1::/tmp/a') + expect(partitions[HOST]?.activeTabId).toBe('remote-tab') + expect(partitions[HOST]?.tabsByWorktree).toEqual(tabs) + expect(partitions[HOST]?.terminalTopologyRevisionByRepoId).toEqual({ 'repo-1': 4 }) + }) + + it('is a no-op when no local slice is supplied', () => { + const stale = history('https://stale.test') + const partitions = parse({ + [HOST]: { ...getDefaultWorkspaceSession(), browserUrlHistory: stale } + }) + expect(partitions[HOST]?.browserUrlHistory).toEqual(stale) + }) +}) diff --git a/src/main/persistence/loading-store/workspace-session-partitions.ts b/src/main/persistence/loading-store/workspace-session-partitions.ts index 50e9d4ae8cc..b6f78479dbf 100644 --- a/src/main/persistence/loading-store/workspace-session-partitions.ts +++ b/src/main/persistence/loading-store/workspace-session-partitions.ts @@ -17,11 +17,49 @@ export function workspaceSessionSalvageLogDetails(result: { } } +/** + * Global fields belong to the 'local' slice: the split writes them only there and the merge reads + * them only from there. A copy inside a non-local partition is legacy residue no read can reach — + * stale `browserUrlHistory` replicas alone were 589 KB, 12.7% of a 4.65 MB store, rewritten on + * every save and reparsed on every launch. + * + * Deliberately NOT every field in `GLOBAL_WORKSPACE_SESSION_FIELDS`. Two separate gates disqualify + * the rest, and both are load-bearing: + * - `activeWorktreeId` and `activeWorkspaceKey` are `'direct'` in + * `WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND`, and both `collectPersistedSessionWorktreeOwners` + * and the deregistered-repo residue sweep read them out of EVERY partition. Dropping one + * un-owns a worktree, and an un-owned worktree gets its metadata pruned. + * - `activeTabId`, `activeConnectionIdsAtShutdown` and `activeRepoId` have live main-side readers + * on a partition: `isPersistedTerminalLeafActive` falls back to `activeTabId` for the mobile + * projection, and the runtime attach-window handoff unions `activeConnectionIdsAtShutdown`. + * + * `workspace-session-partitions.test.ts` re-checks both gates for every field listed here. + */ +export const HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS = [ + 'browserUrlHistory', + 'workspaceDocHistory' +] as const satisfies readonly (keyof WorkspaceSessionState)[] + +/** Dropped only where local already holds the field — exactly when the merge's fallback to another + * slice cannot fire. Runs before the defaults spread, so a field the type requires comes back at + * its default rather than going missing. */ +function dropRedundantGlobalFields( + slice: Partial, + local: WorkspaceSessionState | undefined +): void { + for (const field of HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS) { + if (local?.[field] !== undefined) { + delete slice[field] + } + } +} + /** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ export function parseWorkspaceSessionsByHostId( raw: unknown, - defaults: WorkspaceSessionState + defaults: WorkspaceSessionState, + localSession?: WorkspaceSessionState ): { partitions: Partial>; repaired: boolean } { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return { partitions: {}, repaired: raw !== undefined } @@ -50,6 +88,7 @@ export function parseWorkspaceSessionsByHostId( ) repaired = true } + dropRedundantGlobalFields(result.value, localSession) partitions[hostId] = { ...defaults, ...result.value } } return { partitions, repaired } diff --git a/src/renderer/src/lib/workspace-session-host-contention.ts b/src/renderer/src/lib/workspace-session-host-contention.ts index 51d09027200..652678e24a1 100644 --- a/src/renderer/src/lib/workspace-session-host-contention.ts +++ b/src/renderer/src/lib/workspace-session-host-contention.ts @@ -10,7 +10,7 @@ import { getWorktreeIdFromHostIdentity, isWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' -import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from './workspace-session-host-field-ownership' +import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership' import { isWorkspaceSessionRecord, type WorkspaceSessionRecord diff --git a/src/renderer/src/lib/workspace-session-host-split.test.ts b/src/renderer/src/lib/workspace-session-host-split.test.ts index de359cfb12e..ad540d2af04 100644 --- a/src/renderer/src/lib/workspace-session-host-split.test.ts +++ b/src/renderer/src/lib/workspace-session-host-split.test.ts @@ -392,3 +392,46 @@ describe('split → merge round trip', () => { expect(roundTrip(state)).toEqual(state) }) }) + +/** + * The main-process load path drops a global field from a non-local partition when the local slice + * already has it, on the strength of exactly these two rules. If either moves, that prune starts + * discarding a value the renderer would otherwise have read. + */ +describe('mergeWorkspaceSessionsFromHosts global-field precedence', () => { + const localEntry = { + url: 'local', + normalizedUrl: 'local', + title: 'l', + lastVisitedAt: 2, + visitCount: 1 + } + const hostEntry = { + url: 'host', + normalizedUrl: 'host', + title: 'h', + lastVisitedAt: 1, + visitCount: 1 + } + + it("takes a global field from 'local' whenever local has one, ignoring every other slice", () => { + const merged = mergeWorkspaceSessionsFromHosts({ + [LOCAL_EXECUTION_HOST_ID]: { + ...getDefaultWorkspaceSession(), + browserUrlHistory: [localEntry] + }, + [RUNTIME_A]: { ...getDefaultWorkspaceSession(), browserUrlHistory: [hostEntry] } + }) + expect(merged.browserUrlHistory).toEqual([localEntry]) + }) + + it('falls back to another slice only when local does not have the field', () => { + const local = getDefaultWorkspaceSession() + delete local.browserUrlHistory + const merged = mergeWorkspaceSessionsFromHosts({ + [LOCAL_EXECUTION_HOST_ID]: local, + [RUNTIME_A]: { ...getDefaultWorkspaceSession(), browserUrlHistory: [hostEntry] } + }) + expect(merged.browserUrlHistory).toEqual([hostEntry]) + }) +}) diff --git a/src/renderer/src/lib/workspace-session-host-split.ts b/src/renderer/src/lib/workspace-session-host-split.ts index 817b87eebeb..ea03cc6f3f8 100644 --- a/src/renderer/src/lib/workspace-session-host-split.ts +++ b/src/renderer/src/lib/workspace-session-host-split.ts @@ -8,7 +8,7 @@ import { isWorktreeHostIdentity } from '../../../shared/worktree/host-qualified- import { GLOBAL_WORKSPACE_SESSION_FIELDS, WORKSPACE_SESSION_FIELD_OWNERSHIP -} from './workspace-session-host-field-ownership' +} from '../../../shared/workspace-session-host-field-ownership' import { buildWorktreeIdByFileId, buildWorktreeIdByTabId, diff --git a/src/renderer/src/lib/workspace-session-host-field-ownership.ts b/src/shared/workspace-session-host-field-ownership.ts similarity index 97% rename from src/renderer/src/lib/workspace-session-host-field-ownership.ts rename to src/shared/workspace-session-host-field-ownership.ts index a44c7669bda..6397c2ad239 100644 --- a/src/renderer/src/lib/workspace-session-host-field-ownership.ts +++ b/src/shared/workspace-session-host-field-ownership.ts @@ -1,4 +1,4 @@ -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { WorkspaceSessionState } from './workspace-session-state-types' export type WorkspaceSessionFieldOwnership = | 'global' From ae1dab40d6a7b290a59fbbcb94ac26fcabdf7b4e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:05:24 -0700 Subject: [PATCH 10/25] Sta 6308 add copy session id option to terminal tab context menu (#18070) * Move Copy Session ID from tab to terminal pane context menu - Relocates session ID copy to the exact pane that owns it, not the tab's active pane - Adds support for durable sleeping agent sessions as fallback for cleared live status - Generalizes copy-rejection guards to handle any identity type, not just pane IDs - Updates e2e test to verify pane-specific session ID copying * Gate session ID liveness by shell foreground state Once OSC 133;D proves a pane is back at the shell, don't return the session ID even if a durable record survived the exit. This prevents treating exited sessions as still active when the user is typing at the prompt. * Update hook order parity test for session-ID projection hook The pane session-ID projection adds a render hook to TerminalPane. Update the expected hook count from 229 to 230 and the corresponding SHA256 hash. --- .../use-native-chat-context-menu.tsx | 13 ++ .../tab-bar/SortableTabContextMenu.test.tsx | 56 ------ .../tab-bar/SortableTabContextMenu.tsx | 7 - .../TabAgentSessionIdMenuItem.test.tsx | 79 --------- .../tab-bar/TabAgentSessionIdMenuItem.tsx | 48 ------ .../tab-bar/tab-agent-session-id.test.ts | 159 ------------------ .../tab-bar/tab-agent-session-id.ts | 32 ---- .../tab-context-menu-consistency.test.tsx | 1 - .../TerminalContextMenu.test.tsx | 25 +++ .../terminal-pane/TerminalContextMenu.tsx | 15 +- .../TerminalPaneNativeChatPortal.tsx | 10 ++ .../terminal-pane/TerminalPaneSurface.tsx | 3 + .../pane-agent-session-id.test.ts | 110 ++++++++++++ .../terminal-pane/pane-agent-session-id.ts | 27 +++ .../terminal-copy-rejection-guards.ts | 10 +- .../terminal-copy-rejection-handling.test.ts | 12 +- .../terminal-pane-hook-order-parity.test.ts | 6 +- .../terminal-pane-menu-copy-actions.ts | 38 ++++- .../use-terminal-pane-context-menu.ts | 14 ++ .../use-terminal-pane-projection.ts | 10 ++ src/renderer/src/i18n/locales/en.json | 12 +- src/renderer/src/i18n/locales/es.json | 12 +- src/renderer/src/i18n/locales/ja.json | 12 +- src/renderer/src/i18n/locales/ko.json | 12 +- src/renderer/src/i18n/locales/zh.json | 12 +- ... terminal-context-menu-session-id.spec.ts} | 37 ++-- 26 files changed, 335 insertions(+), 437 deletions(-) delete mode 100644 src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx delete mode 100644 src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx delete mode 100644 src/renderer/src/components/tab-bar/tab-agent-session-id.test.ts delete mode 100644 src/renderer/src/components/tab-bar/tab-agent-session-id.ts create mode 100644 src/renderer/src/components/terminal-pane/pane-agent-session-id.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pane-agent-session-id.ts rename tests/e2e/{tab-context-menu-session-id.spec.ts => terminal-context-menu-session-id.spec.ts} (59%) diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index 9aed41a0cdc..8e939401a47 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -56,6 +56,8 @@ export type NativeChatContextMenuActions = { onSetTitle: () => void onCopyTerminalId: () => void onCopyPaneId: () => void + canCopyAgentSessionId: boolean + onCopyAgentSessionId: () => void canClosePane: boolean onClosePane: () => void } @@ -75,6 +77,8 @@ export const emptyNativeChatContextMenuActions: Omit {}, onCopyTerminalId: () => {}, onCopyPaneId: () => {}, + canCopyAgentSessionId: false, + onCopyAgentSessionId: () => {}, canClosePane: false, onClosePane: () => {} } @@ -219,6 +223,15 @@ export function useNativeChatContextMenu({ rootRef, actions }: UseNativeChatCont 'Set Title…' )} + {actions.canCopyAgentSessionId ? ( + + + {translate( + 'components.terminalPane.TerminalContextMenu.copySessionId', + 'Copy Session ID' + )} + + ) : null} {translate( diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index e51c599f669..9d3e506e3f1 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -292,60 +292,4 @@ describe('SortableTabContextMenu', () => { expect(container.textContent).not.toContain('Move Tab to Split') expect(container.textContent).toContain('Split terminal right') }) - - describe('copy session id', () => { - const LEAF = '11111111-1111-4111-8111-111111111111' - - function withLiveAgent(sessionId: string | null): void { - storeMock.state = { - ...storeMock.state, - terminalLayoutsByTabId: { - 'term-1': { root: { type: 'leaf', leafId: LEAF }, activeLeafId: LEAF } - }, - agentStatusByPaneKey: { - [`term-1:${LEAF}`]: { - state: 'done', - prompt: '', - updatedAt: 1, - stateStartedAt: 1, - paneKey: `term-1:${LEAF}`, - agentType: 'claude', - stateHistory: [], - ...(sessionId ? { providerSession: { key: 'session_id', id: sessionId } } : {}) - } - }, - paneForegroundAgentByPaneKey: {} - } - } - - it('omits the item for a tab with no agent', () => { - const { container } = renderMenu() - - expect(container.textContent).not.toContain('Copy Session ID') - }) - - it('omits the item until the active agent reports a session id', () => { - withLiveAgent(null) - const { container } = renderMenu() - - expect(container.textContent).not.toContain('Copy Session ID') - }) - - it('copies the active pane session id', async () => { - const writeClipboardText = vi.fn().mockResolvedValue(undefined) - Object.assign(window, { api: { ui: { writeClipboardText } } }) - withLiveAgent('session-abc') - const { container } = renderMenu() - - act(() => getButton(container, 'Copy Session ID').click()) - await vi.waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith('session-abc')) - }) - - it('does not resolve a session id while the menu is closed', () => { - withLiveAgent('session-abc') - const { container } = renderMenu({ open: false }) - - expect(container.textContent).not.toContain('Copy Session ID') - }) - }) }) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index b298072f9f6..53b31012d39 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -11,8 +11,6 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { useAppStore } from '../../store' import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' -import { TabAgentSessionIdMenuItem } from './TabAgentSessionIdMenuItem' -import { resolveTabAgentSessionId } from './tab-agent-session-id' import { TerminalTabSplitMenuSection } from './TerminalTabSplitMenuSection' import { TAB_CONTEXT_MENU_CONTENT_CLASS } from './tab-context-menu-sizing' @@ -123,10 +121,6 @@ export function SortableTabContextMenu({ onTogglePin }: SortableTabContextMenuProps): React.JSX.Element { const keybindings = useAppStore((state) => state.keybindings) - // The id is a primitive, so unchanged sessions stay referentially stable without a cache. - const agentSessionId = useAppStore((state) => - open ? resolveTabAgentSessionId(state, tab.id) : null - ) const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings) const splitDownShortcut = formatShortcutLabel('terminal.splitDown', keybindings) @@ -194,7 +188,6 @@ export function SortableTabContextMenu({ {translate('auto.components.tab.bar.SortableTabContextMenu.2f697b3c31', 'Change Title')} {renameShortcut ? {renameShortcut} : null} -
{translate('auto.components.tab.bar.SortableTabContextMenu.35e8892fd0', 'Tab Color')} diff --git a/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx b/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx deleted file mode 100644 index da857ec57bf..00000000000 --- a/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @vitest-environment happy-dom - */ -import { act, type ReactNode } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { TabAgentSessionIdMenuItem } from './TabAgentSessionIdMenuItem' - -const toastMock = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })) - -vi.mock('@/components/ui/dropdown-menu', () => ({ - DropdownMenuItem: ({ - children, - disabled, - onSelect, - 'aria-label': ariaLabel - }: { - children?: ReactNode - disabled?: boolean - onSelect?: () => void - 'aria-label'?: string - }) => ( - - ) -})) - -vi.mock('lucide-react', () => ({ Copy: () => null })) -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -vi.mock('sonner', () => ({ toast: toastMock })) - -const mounted: { container: HTMLDivElement; root: Root }[] = [] - -function render(sessionId: string | null): HTMLDivElement { - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - act(() => root.render()) - mounted.push({ container, root }) - return container -} - -afterEach(() => { - for (const { container, root } of mounted.splice(0)) { - act(() => root.unmount()) - container.remove() - } - toastMock.success.mockReset() - toastMock.error.mockReset() -}) - -describe('TabAgentSessionIdMenuItem', () => { - it('renders nothing when no session id is available', () => { - expect(render(null).textContent).toBe('') - }) - - it('copies on select when an id is known', async () => { - const writeClipboardText = vi.fn().mockResolvedValue(undefined) - Object.assign(window, { api: { ui: { writeClipboardText } } }) - const container = render('abc-123') - - const button = container.querySelector('button') - expect(button?.disabled).toBe(false) - act(() => button?.click()) - await vi.waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith('abc-123')) - }) - - it('reports clipboard failures', async () => { - const writeClipboardText = vi.fn().mockRejectedValue(new Error('clipboard unavailable')) - Object.assign(window, { api: { ui: { writeClipboardText } } }) - const button = render('abc-123').querySelector('button') - - act(() => button?.click()) - await vi.waitFor(() => - expect(toastMock.error).toHaveBeenCalledWith('Failed to copy Session ID') - ) - }) -}) diff --git a/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx b/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx deleted file mode 100644 index 73f3f128e5d..00000000000 --- a/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Copy } from 'lucide-react' -import { toast } from 'sonner' -import { DropdownMenuItem } from '@/components/ui/dropdown-menu' -import { translate } from '@/i18n/i18n' - -async function copySessionId(sessionId: string): Promise { - try { - await window.api.ui.writeClipboardText(sessionId) - toast.success( - translate( - 'components.tab.bar.SortableTabContextMenu.copySessionIdSuccess', - 'Session ID copied' - ) - ) - } catch { - toast.error( - translate( - 'components.tab.bar.SortableTabContextMenu.copySessionIdError', - 'Failed to copy Session ID' - ) - ) - } -} - -/** Copies the active pane's provider session id when one is available. */ -export function TabAgentSessionIdMenuItem({ - sessionId -}: { - sessionId: string | null -}): React.JSX.Element | null { - if (sessionId === null) { - return null - } - const label = translate( - 'components.tab.bar.SortableTabContextMenu.copySessionId', - 'Copy Session ID' - ) - return ( - { - void copySessionId(sessionId) - }} - > - - {label} - - ) -} diff --git a/src/renderer/src/components/tab-bar/tab-agent-session-id.test.ts b/src/renderer/src/components/tab-bar/tab-agent-session-id.test.ts deleted file mode 100644 index 2f06f0a8db7..00000000000 --- a/src/renderer/src/components/tab-bar/tab-agent-session-id.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import { resolveTabAgentSessionId, type TabAgentSessionIdState } from './tab-agent-session-id' - -const LEAF_A = '11111111-1111-4111-8111-111111111111' -const LEAF_B = '22222222-2222-4222-8222-222222222222' - -function entry(overrides: Partial = {}): AgentStatusEntry { - return { - state: 'done', - prompt: '', - updatedAt: 1, - stateStartedAt: 1, - paneKey: `tab-1:${LEAF_A}`, - agentType: 'claude', - stateHistory: [], - ...overrides - } -} - -function state(overrides: Partial = {}): TabAgentSessionIdState { - return { - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF_A }, - activeLeafId: LEAF_A, - expandedLeafId: null - } - }, - agentStatusByPaneKey: {}, - paneForegroundAgentByPaneKey: {}, - ...overrides - } -} - -describe('resolveTabAgentSessionId', () => { - it('is absent when the pane has no agent row', () => { - expect(resolveTabAgentSessionId(state(), 'tab-1')).toBeNull() - }) - - it('is absent for a tab with no layout', () => { - expect(resolveTabAgentSessionId(state(), 'tab-missing')).toBeNull() - }) - - it('reads the id reported by the active pane', () => { - const resolved = resolveTabAgentSessionId( - state({ - agentStatusByPaneKey: { - [`tab-1:${LEAF_A}`]: entry({ providerSession: { key: 'session_id', id: 'abc-123' } }) - } - }), - 'tab-1' - ) - expect(resolved).toBe('abc-123') - }) - - it('is absent until the agent reports an id', () => { - const resolved = resolveTabAgentSessionId( - state({ agentStatusByPaneKey: { [`tab-1:${LEAF_A}`]: entry() } }), - 'tab-1' - ) - expect(resolved).toBeNull() - }) - - describe('liveness', () => { - it('is absent for a hydrated row with no live hook since restore', () => { - const resolved = resolveTabAgentSessionId( - state({ - agentStatusByPaneKey: { - [`tab-1:${LEAF_A}`]: entry({ - restoredUnconfirmed: true, - providerSession: { key: 'session_id', id: 'abc-123' } - }) - } - }), - 'tab-1' - ) - expect(resolved).toBeNull() - }) - - it('is absent once the pane is proven back at the shell', () => { - const resolved = resolveTabAgentSessionId( - state({ - agentStatusByPaneKey: { - [`tab-1:${LEAF_A}`]: entry({ providerSession: { key: 'session_id', id: 'abc-123' } }) - }, - paneForegroundAgentByPaneKey: { - [`tab-1:${LEAF_A}`]: { agent: null, shellForeground: true } - } - }), - 'tab-1' - ) - expect(resolved).toBeNull() - }) - - it('keeps a session whose foreground evidence is only that an agent runs', () => { - const resolved = resolveTabAgentSessionId( - state({ - agentStatusByPaneKey: { - [`tab-1:${LEAF_A}`]: entry({ providerSession: { key: 'session_id', id: 'abc-123' } }) - }, - paneForegroundAgentByPaneKey: { - [`tab-1:${LEAF_A}`]: { agent: 'claude', shellForeground: false } - } - }), - 'tab-1' - ) - expect(resolved).toBe('abc-123') - }) - - it('keeps a working session that reported a session boundary', () => { - // Why: sessionBoundary marks a resume/clear landing idle — a session start, - // not a session end, and exactly when the first id arrives. - const resolved = resolveTabAgentSessionId( - state({ - agentStatusByPaneKey: { - [`tab-1:${LEAF_A}`]: entry({ - sessionBoundary: true, - providerSession: { key: 'session_id', id: 'fresh-1' } - }) - } - }), - 'tab-1' - ) - expect(resolved).toBe('fresh-1') - }) - }) - - describe('split tabs', () => { - const splitState = (activeLeafId: string): TabAgentSessionIdState => - state({ - terminalLayoutsByTabId: { - 'tab-1': { - root: { - type: 'split', - direction: 'vertical', - first: { type: 'leaf', leafId: LEAF_A }, - second: { type: 'leaf', leafId: LEAF_B } - }, - activeLeafId, - expandedLeafId: null - } - }, - agentStatusByPaneKey: { - [`tab-1:${LEAF_A}`]: entry({ providerSession: { key: 'session_id', id: 'left' } }), - [`tab-1:${LEAF_B}`]: entry({ providerSession: { key: 'session_id', id: 'right' } }) - } - }) - - it('reads the active pane, not a sibling', () => { - expect(resolveTabAgentSessionId(splitState(LEAF_B), 'tab-1')).toBe('right') - }) - - it('is absent when the active leaf id no longer exists in the layout', () => { - const stale = '33333333-3333-4333-8333-333333333333' - expect(resolveTabAgentSessionId(splitState(stale), 'tab-1')).toBeNull() - }) - }) -}) diff --git a/src/renderer/src/components/tab-bar/tab-agent-session-id.ts b/src/renderer/src/components/tab-bar/tab-agent-session-id.ts deleted file mode 100644 index e0d804bc4c5..00000000000 --- a/src/renderer/src/components/tab-bar/tab-agent-session-id.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' -import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent' -import { resolveNativeChatActiveLayoutLeafId } from '../native-chat/native-chat-leaf-routing' - -export type TabAgentSessionIdState = { - agentStatusByPaneKey?: Record - terminalLayoutsByTabId?: Record - paneForegroundAgentByPaneKey?: Record -} - -/** Returns the active pane's provider session id when its agent is still live. */ -export function resolveTabAgentSessionId( - state: TabAgentSessionIdState, - tabId: string -): string | null { - const leafId = resolveNativeChatActiveLayoutLeafId(state.terminalLayoutsByTabId?.[tabId]) - if (!leafId) { - return null - } - const paneKey = `${tabId}:${leafId}` - const entry = state.agentStatusByPaneKey?.[paneKey] - // Hydrated rows may describe a session that ended while no receiver was up. - if (!entry?.agentType || entry.restoredUnconfirmed === true) { - return null - } - // OSC 133;D proves the pane is back at the shell, regardless of the last hook state. - if (state.paneForegroundAgentByPaneKey?.[paneKey]?.shellForeground === true) { - return null - } - return entry.providerSession?.id ?? null -} diff --git a/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx b/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx index 54f762d4611..c81eae348dd 100644 --- a/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx @@ -13,7 +13,6 @@ import { const TAB_MENU_SOURCES = [ 'EditorFileTabContextMenu.tsx', 'SortableTabContextMenu.tsx', - 'TabAgentSessionIdMenuItem.tsx', 'BrowserTab.tsx', 'TabWorkspaceLayoutMenuSection.tsx', 'TerminalTabSplitMenuSection.tsx' diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index bd2ca330f5c..d8dc54c954b 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -92,6 +92,8 @@ function renderMenu(overrides: Record = {}): string { canClearPaneTitle: false, onCopyTerminalId: vi.fn(), onCopyPaneId: vi.fn(), + canCopyAgentSessionId: false, + onCopyAgentSessionId: vi.fn(), ...overrides } return renderToStaticMarkup(React.createElement(TerminalContextMenu, props)) @@ -146,6 +148,29 @@ describe('TerminalContextMenu', () => { expect(items.list.some((item) => childrenText(item.children).includes('Switch to'))).toBe(false) }) + it('shows Copy Session ID only for panes with provider identity', () => { + const onCopyAgentSessionId = vi.fn() + renderMenu({ canCopyAgentSessionId: true, onCopyAgentSessionId }) + + const item = items.list.find( + (candidate) => childrenText(candidate.children) === 'Copy Session ID' + ) + expect(item).toBeDefined() + expect( + items.list + .map((candidate) => childrenText(candidate.children)) + .filter((label) => ['Copy Session ID', 'Copy Terminal ID', 'Copy Pane ID'].includes(label)) + ).toEqual(['Copy Session ID', 'Copy Terminal ID', 'Copy Pane ID']) + item?.onSelect?.() + expect(onCopyAgentSessionId).toHaveBeenCalledTimes(1) + + items.list = [] + renderMenu({ canCopyAgentSessionId: false }) + expect( + items.list.some((candidate) => childrenText(candidate.children) === 'Copy Session ID') + ).toBe(false) + }) + it('shows one shortcut per terminal menu action on Windows', () => { vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 178ab0a3247..8311fb3df9d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -66,6 +66,8 @@ type TerminalContextMenuProps = { canClearPaneTitle: boolean onCopyTerminalId: () => void onCopyPaneId: () => void + canCopyAgentSessionId: boolean + onCopyAgentSessionId: () => void } export default function TerminalContextMenu({ @@ -101,7 +103,9 @@ export default function TerminalContextMenu({ onClearPaneTitle, canClearPaneTitle, onCopyTerminalId, - onCopyPaneId + onCopyPaneId, + canCopyAgentSessionId, + onCopyAgentSessionId }: TerminalContextMenuProps): React.JSX.Element { // Why: one primary binding prevents Windows/Linux shortcut labels from forcing row wraps. const shortcuts = useMemo( @@ -276,6 +280,15 @@ export default function TerminalContextMenu({ ) : null} ) : null} + {canCopyAgentSessionId ? ( + + + {translate( + 'components.terminalPane.TerminalContextMenu.copySessionId', + 'Copy Session ID' + )} + + ) : null} {translate( diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx index 73c4220dec5..1b5e94b4a11 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx @@ -3,6 +3,8 @@ import NativeChatView from '../native-chat/NativeChatView' import { makePaneKey } from '../../../../shared/stable-pane-id' import { canContinueAgentSessionInNewSession } from './terminal-agent-session-continuation' import type { TerminalPaneController } from './use-terminal-pane-controller' +import { useAppStore } from '@/store' +import { resolvePaneAgentSessionId } from './pane-agent-session-id' export function TerminalPaneNativeChatPortal({ controller @@ -30,6 +32,11 @@ export function TerminalPaneNativeChatPortal({ tabId, unifiedTabId } = controller + const chatPaneSessionId = useAppStore((state) => + effectiveChatViewMode && chatPane + ? resolvePaneAgentSessionId(state, makePaneKey(tabId, chatPane.leafId)) + : null + ) if (!effectiveChatViewMode || !chatPane?.container) { return null } @@ -78,6 +85,9 @@ export function TerminalPaneNativeChatPortal({ onCopyTerminalId: () => void contextMenu.runForPane(chatPane.id, contextMenu.onCopyTerminalId), onCopyPaneId: () => void contextMenu.runForPane(chatPane.id, contextMenu.onCopyPaneId), + canCopyAgentSessionId: chatPaneSessionId !== null, + onCopyAgentSessionId: () => + void contextMenu.runForPane(chatPane.id, contextMenu.onCopyAgentSessionId), canClosePane: managedPanes.length > 1, onClosePane: () => contextMenu.runForPane(chatPane.id, contextMenu.onClosePane) }} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx index fff316dabce..fc33cab3f18 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx @@ -59,6 +59,7 @@ export function TerminalPaneSurface({ keybindings, managedPanes, managerRef, + menuAgentSessionId, menuPaneHasCustomTitle, openDiskSpaceAnalyzer, openQuickCommandEditor, @@ -247,6 +248,8 @@ export function TerminalPaneSurface({ canClearPaneTitle={menuPaneHasCustomTitle} onCopyTerminalId={() => void contextMenu.onCopyTerminalId()} onCopyPaneId={contextMenu.onCopyPaneId} + canCopyAgentSessionId={menuAgentSessionId !== null} + onCopyAgentSessionId={() => void contextMenu.onCopyAgentSessionId()} /> { + it('returns the live provider session for the exact pane', () => { + expect(resolvePaneAgentSessionId(state(live('live-session')), PANE_KEY)).toBe('live-session') + }) + + it('returns the pane-owned durable session after its live status row is cleared', () => { + expect( + resolvePaneAgentSessionId(state(undefined, sleeping('sleeping-session')), PANE_KEY) + ).toBe('sleeping-session') + }) + + it('does not reuse an older durable session while a newer live row lacks identity', () => { + expect(resolvePaneAgentSessionId(state(live(), sleeping('old-session')), PANE_KEY)).toBeNull() + }) + + it('falls back from an unconfirmed restored row to durable pane identity', () => { + expect( + resolvePaneAgentSessionId( + state(live('unconfirmed-session', true), sleeping('confirmed-session')), + PANE_KEY + ) + ).toBe('confirmed-session') + }) + + describe('liveness', () => { + it('is absent once the pane is proven back at the shell', () => { + expect( + resolvePaneAgentSessionId(state(live('live-session'), undefined, true), PANE_KEY) + ).toBe(null) + }) + + it('is absent at the shell even when a durable record survives the exit', () => { + expect( + resolvePaneAgentSessionId(state(undefined, sleeping('sleeping-session'), true), PANE_KEY) + ).toBeNull() + }) + + it('keeps a session whose foreground evidence is only that an agent runs', () => { + expect( + resolvePaneAgentSessionId(state(live('live-session'), undefined, false), PANE_KEY) + ).toBe('live-session') + }) + + it('keeps a session for a pane with no foreground evidence at all', () => { + expect( + resolvePaneAgentSessionId( + { + agentStatusByPaneKey: { [PANE_KEY]: live('live-session') }, + sleepingAgentSessionsByPaneKey: {}, + paneForegroundAgentByPaneKey: {} + }, + PANE_KEY + ) + ).toBe('live-session') + }) + }) + + it('does not read identity from a sibling pane', () => { + const sibling = 'tab-1:22222222-2222-4222-8222-222222222222' + expect(resolvePaneAgentSessionId(state(undefined, sleeping('session-1')), sibling)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pane-agent-session-id.ts b/src/renderer/src/components/terminal-pane/pane-agent-session-id.ts new file mode 100644 index 00000000000..59ab6f38e86 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-agent-session-id.ts @@ -0,0 +1,27 @@ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent' + +export type PaneAgentSessionIdState = { + agentStatusByPaneKey: Record + sleepingAgentSessionsByPaneKey: Record + paneForegroundAgentByPaneKey: Record +} + +/** Resolves the provider session owned by one exact terminal pane, while its agent is still live. */ +export function resolvePaneAgentSessionId( + state: PaneAgentSessionIdState, + paneKey: string +): string | null { + // OSC 133;D proves the pane is back at the shell. The durable record outlives that exit on + // purpose (cold restore resumes from it), so gate it here too — otherwise the gate would only + // hold for panes whose agent has no resumable record. + if (state.paneForegroundAgentByPaneKey[paneKey]?.shellForeground === true) { + return null + } + const live = state.agentStatusByPaneKey[paneKey] + if (live && live.restoredUnconfirmed !== true) { + return live.providerSession?.id ?? null + } + return state.sleepingAgentSessionsByPaneKey[paneKey]?.providerSession.id ?? null +} diff --git a/src/renderer/src/components/terminal-pane/terminal-copy-rejection-guards.ts b/src/renderer/src/components/terminal-pane/terminal-copy-rejection-guards.ts index 6debb3e3d5f..9a13d026d08 100644 --- a/src/renderer/src/components/terminal-pane/terminal-copy-rejection-guards.ts +++ b/src/renderer/src/components/terminal-pane/terminal-copy-rejection-guards.ts @@ -1,7 +1,7 @@ // Why: writeClipboardText resolved unconditionally in the web client until the // insecure-context copy fallback landed. It can now reject (insecure origin with -// no live user gesture), so these two menu actions need explicit outcomes: -// the copy must never leave the pane unfocused, and Copy Pane ID must not toast +// no live user gesture), so identity-copy actions need explicit outcomes: +// the copy must never leave the pane unfocused, and identity actions must not toast // success for a copy that did not happen. Extracted so both are testable without // mounting the whole context-menu hook. @@ -22,15 +22,15 @@ export async function runTerminalCopy(args: { } } -export async function runCopyPaneId(args: { - paneKey: string +export async function runTerminalIdentityCopy(args: { + text: string writeClipboardText: (text: string) => Promise onSuccess: () => void onError: () => void focus: () => void }): Promise { try { - await args.writeClipboardText(args.paneKey) + await args.writeClipboardText(args.text) args.onSuccess() } catch { args.onError() diff --git a/src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts b/src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts index d6aac273ed8..1b14b379f2a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { runTerminalCopy, runCopyPaneId } from './terminal-copy-rejection-guards' +import { runTerminalCopy, runTerminalIdentityCopy } from './terminal-copy-rejection-guards' // Why this file exists: web-preload-api's writeClipboardText used to resolve // unconditionally, so the terminal copy surfaces call it without a rejection @@ -42,15 +42,15 @@ describe('runTerminalCopy', () => { }) }) -describe('runCopyPaneId', () => { +describe('runTerminalIdentityCopy', () => { it('reports failure instead of claiming success when the write rejects', async () => { const onSuccess = vi.fn() const onError = vi.fn() const focus = vi.fn() await expect( - runCopyPaneId({ - paneKey: 'tab:leaf', + runTerminalIdentityCopy({ + text: 'tab:leaf', writeClipboardText: vi.fn().mockRejectedValue(REJECTION), onSuccess, onError, @@ -68,8 +68,8 @@ describe('runCopyPaneId', () => { const onError = vi.fn() const focus = vi.fn() - await runCopyPaneId({ - paneKey: 'tab:leaf', + await runTerminalIdentityCopy({ + text: 'tab:leaf', writeClipboardText: vi.fn().mockResolvedValue(undefined), onSuccess, onError, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts index 206a31e7177..c42bbd31919 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts @@ -8,9 +8,9 @@ import { describe, expect, it } from 'vitest' const TERMINAL_PANE_HOOK_SOURCE_PATTERN = /^(?:TerminalPane\.tsx|use-terminal-pane-(?:chat-state|close-actions|context-actions|controller|foundation|global-listeners|layout-bindings|layout-persistence|lifecycle-stage|mobile-actions|paste-listeners|process-exit-actions|projection|reconciliation|startup-actions|store-bindings|title-effects|title-state)\.ts)$/ // Rebased onto main after the workbench surface-per-workspace and deferred -// split-cwd changes; this hash is from that main's pre-split TerminalPane (229 hooks). +// split-cwd changes; the pane session-ID projection adds one render hook (230 hooks). const PRE_REFACTOR_HOOK_ORDER_SHA256 = - 'be2366fffb992e082fd9e4641b7543a0db75cb7bc4ef6e0eb0deda41beaac358' + '77adcf8272ddc6f903920f12b2b652ec26c570987f452076ae6460c67d3741f3' const sourceFiles = readdirSync(__dirname) .filter((name) => TERMINAL_PANE_HOOK_SOURCE_PATTERN.test(name)) @@ -75,7 +75,7 @@ function readFlattenedHookOrder(): string[] { describe('TerminalPane refactor hook parity', () => { it('preserves the recursively flattened render hook order', () => { const hooks = readFlattenedHookOrder() - expect(hooks).toHaveLength(229) + expect(hooks).toHaveLength(230) expect(hooks.filter((hook) => hook === 'useMemo')).toHaveLength(4) expect(createHash('sha256').update(hooks.join('\n')).digest('hex')).toBe( PRE_REFACTOR_HOOK_ORDER_SHA256 diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts b/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts index 62051f84f26..417f920af5c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts @@ -3,7 +3,7 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { makePaneKey } from '../../../../shared/stable-pane-id' import { translate } from '@/i18n/i18n' import { copyTerminalHandleForPane } from './terminal-handle-copy' -import { runCopyPaneId, runTerminalCopy } from './terminal-copy-rejection-guards' +import { runTerminalCopy, runTerminalIdentityCopy } from './terminal-copy-rejection-guards' export const copyTerminalPaneMenuSelection = async (pane: ManagedPane | null): Promise => { if (!pane) { @@ -27,10 +27,10 @@ export const copyTerminalPaneMenuPaneId = async ( if (!pane) { return } - await runCopyPaneId({ + await runTerminalIdentityCopy({ // Why: orchestration targets use ORCA_PANE_KEY, which survives renderer // remounts; the numeric PaneManager id is only a local runtime handle. - paneKey: makePaneKey(tabId, pane.leafId), + text: makePaneKey(tabId, pane.leafId), writeClipboardText: window.api.ui.writeTerminalClipboardText, onSuccess: () => toast.success( @@ -83,3 +83,35 @@ export const copyTerminalPaneMenuTerminalId = async ( pane.terminal.focus() } } + +export const copyTerminalPaneMenuAgentSessionId = async ( + pane: ManagedPane | null, + sessionId: string | null +): Promise => { + if (!pane) { + return + } + if (!sessionId) { + pane.terminal.focus() + return + } + await runTerminalIdentityCopy({ + text: sessionId, + writeClipboardText: window.api.ui.writeTerminalClipboardText, + onSuccess: () => + toast.success( + translate( + 'components.terminalPane.TerminalContextMenu.copySessionIdSuccess', + 'Session ID copied' + ) + ), + onError: () => + toast.error( + translate( + 'components.terminalPane.TerminalContextMenu.copySessionIdError', + 'Unable to copy session ID' + ) + ), + focus: () => pane.terminal.focus() + }) +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index a6f04fca5f3..155482f2163 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -11,6 +11,7 @@ import type { PreparedAgentSessionFork } from './terminal-agent-session-fork' import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation' import { pasteTerminalPaneMenuClipboard } from './terminal-pane-menu-paste' import { + copyTerminalPaneMenuAgentSessionId, copyTerminalPaneMenuPaneId, copyTerminalPaneMenuSelection, copyTerminalPaneMenuTerminalId @@ -22,6 +23,9 @@ import { } from './terminal-pane-menu-agent-session-actions' import { useTerminalPaneSplitActions } from './use-terminal-pane-split-actions' import { useTerminalContextMenuTrigger } from './use-terminal-context-menu-trigger' +import { useAppStore } from '@/store' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { resolvePaneAgentSessionId } from './pane-agent-session-id' type UseTerminalPaneContextMenuDeps = { managerRef: React.RefObject @@ -57,6 +61,7 @@ type TerminalMenuState = { onSelectAll: () => void onCopyTerminalId: () => Promise onCopyPaneId: () => Promise + onCopyAgentSessionId: () => Promise onPaste: () => Promise onSplitRight: () => void onSplitDown: () => void @@ -170,6 +175,14 @@ export function useTerminalPaneContextMenu({ const onCopyTerminalId = async (): Promise => copyTerminalPaneMenuTerminalId(resolveMenuPane(), tabId) + const onCopyAgentSessionId = async (): Promise => { + const pane = resolveMenuPane() + const sessionId = pane + ? resolvePaneAgentSessionId(useAppStore.getState(), makePaneKey(tabId, pane.leafId)) + : null + return copyTerminalPaneMenuAgentSessionId(pane, sessionId) + } + const onPaste = async (): Promise => pasteResolvedPane('context-menu') const onEqualizePaneSizes = (): void => { @@ -275,6 +288,7 @@ export function useTerminalPaneContextMenu({ onSelectAll, onCopyTerminalId, onCopyPaneId, + onCopyAgentSessionId, onPaste, onSplitRight, onSplitDown, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts index 3e4ff6f6efc..ea179b6925e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts @@ -16,6 +16,9 @@ import { } from '../native-chat/native-chat-leaf-routing' import { canContinueAgentSessionInNewSession } from './terminal-agent-session-continuation' import type { TerminalPaneMobileController } from './use-terminal-pane-mobile-actions' +import { useAppStore } from '@/store' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { resolvePaneAgentSessionId } from './pane-agent-session-id' export function useTerminalPaneProjection(controller: TerminalPaneMobileController) { const { @@ -40,6 +43,7 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll shouldMeasureHiddenStartup, structuredSessionAgent, structuredSessionId, + tabId, sshReconnectOwnsTerminalErrors, systemPrefersDark, tabAgentTypeByLeaf, @@ -100,6 +104,11 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll ) const menuPaneHasCustomTitle = contextMenu.menuPaneId !== null && Boolean(paneTitles[contextMenu.menuPaneId]) + const menuAgentSessionId = useAppStore((state) => + contextMenu.open && contextMenuLeafId + ? resolvePaneAgentSessionId(state, makePaneKey(tabId, contextMenuLeafId)) + : null + ) const chatLeafStillMounted = chatLeafId ? managedPanes.some((pane) => pane.leafId === chatLeafId) : false @@ -181,6 +190,7 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll showSshReconnectOverlay, visibleTerminalError, menuPaneHasCustomTitle, + menuAgentSessionId, chatLeafStillMounted, chatPane, chatPanePtyId, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7d621d77751..f1489d6754d 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16781,10 +16781,7 @@ "SortableTabContextMenu": { "switchToTerminalView": "Switch to terminal view", "switchToChatView": "Switch to chat view", - "closeTabsToLeft": "Close Tabs To The Left", - "copySessionId": "Copy Session ID", - "copySessionIdSuccess": "Session ID copied", - "copySessionIdError": "Failed to copy Session ID" + "closeTabsToLeft": "Close Tabs To The Left" }, "BrowserTab": { "closeOthers": "Close Others", @@ -17073,6 +17070,13 @@ "days": "{{value0}}d", "underOneMinute": "<1m" } + }, + "terminalPane": { + "TerminalContextMenu": { + "copySessionId": "Copy Session ID", + "copySessionIdSuccess": "Session ID copied", + "copySessionIdError": "Unable to copy session ID" + } } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4736cdbbbd4..79731f3b096 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14652,10 +14652,7 @@ "SortableTabContextMenu": { "switchToTerminalView": "Cambiar a la vista de terminal", "switchToChatView": "Cambiar a vista de chat", - "closeTabsToLeft": "Cerrar pestañas a la izquierda", - "copySessionId": "Copiar ID de sesión", - "copySessionIdSuccess": "ID de sesión copiado", - "copySessionIdError": "No se pudo copiar el ID de sesión" + "closeTabsToLeft": "Cerrar pestañas a la izquierda" }, "BrowserTab": { "closeOthers": "Cerrar otras", @@ -14703,6 +14700,13 @@ "sent": "El contexto de la sesión se envió a {{agent}} en una sesión nueva.", "deliveryFailed": "La nueva sesión de {{agent}} se inició, pero no se pudo enviar su contexto.", "launchFailed": "No se pudo iniciar una sesión nueva de {{agent}}." + }, + "terminalPane": { + "TerminalContextMenu": { + "copySessionId": "Copiar ID de sesión", + "copySessionIdSuccess": "ID de sesión copiado", + "copySessionIdError": "No se pudo copiar el ID de sesión" + } } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 315e5775442..6afc506b12b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14652,10 +14652,7 @@ "SortableTabContextMenu": { "switchToTerminalView": "ターミナルビューに切り替える", "switchToChatView": "チャットビューに切り替える", - "closeTabsToLeft": "左側のタブを閉じる", - "copySessionId": "セッション ID をコピー", - "copySessionIdSuccess": "セッション ID をコピーしました", - "copySessionIdError": "セッション ID のコピーに失敗しました" + "closeTabsToLeft": "左側のタブを閉じる" }, "BrowserTab": { "closeOthers": "その他を閉じる", @@ -14703,6 +14700,13 @@ "sent": "セッションコンテキストを新規 {{agent}} セッションに送信しました。", "deliveryFailed": "新規 {{agent}} セッションは開始しましたが、コンテキストを送信できませんでした。", "launchFailed": "新規 {{agent}} セッションを開始できませんでした。" + }, + "terminalPane": { + "TerminalContextMenu": { + "copySessionId": "セッション ID をコピー", + "copySessionIdSuccess": "セッション ID をコピーしました", + "copySessionIdError": "セッション ID のコピーに失敗しました" + } } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4a196c62304..c2836df4c40 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14708,10 +14708,7 @@ "SortableTabContextMenu": { "switchToTerminalView": "terminal 보기로 전환", "switchToChatView": "채팅 보기로 전환", - "closeTabsToLeft": "왼쪽으로 탭 닫기", - "copySessionId": "세션 ID 복사", - "copySessionIdSuccess": "세션 ID를 복사했습니다", - "copySessionIdError": "세션 ID를 복사하지 못했습니다" + "closeTabsToLeft": "왼쪽으로 탭 닫기" }, "BrowserTab": { "closeOthers": "다른 탭 닫기", @@ -14820,6 +14817,13 @@ "sent": "세션 컨텍스트를 새 {{agent}} 세션으로 보냈습니다.", "deliveryFailed": "새 {{agent}} 세션은 시작되었지만 컨텍스트를 보내지 못했습니다.", "launchFailed": "새 {{agent}} 세션을 시작할 수 없습니다." + }, + "terminalPane": { + "TerminalContextMenu": { + "copySessionId": "세션 ID 복사", + "copySessionIdSuccess": "세션 ID를 복사했습니다", + "copySessionIdError": "세션 ID를 복사하지 못했습니다" + } } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index edbd96e2623..77f56e805ae 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14708,10 +14708,7 @@ "SortableTabContextMenu": { "switchToTerminalView": "切换到终端视图", "switchToChatView": "切换到聊天视图", - "closeTabsToLeft": "关闭左侧的选项卡", - "copySessionId": "复制会话 ID", - "copySessionIdSuccess": "已复制会话 ID", - "copySessionIdError": "复制会话 ID 失败" + "closeTabsToLeft": "关闭左侧的选项卡" }, "BrowserTab": { "closeOthers": "关闭其他", @@ -14820,6 +14817,13 @@ "sent": "已将会话上下文发送到新的 {{agent}} 会话。", "deliveryFailed": "新的 {{agent}} 会话已启动,但无法发送上下文。", "launchFailed": "无法启动新的 {{agent}} 会话。" + }, + "terminalPane": { + "TerminalContextMenu": { + "copySessionId": "复制会话 ID", + "copySessionIdSuccess": "已复制会话 ID", + "copySessionIdError": "复制会话 ID 失败" + } } }, "dashboardPopout": { diff --git a/tests/e2e/tab-context-menu-session-id.spec.ts b/tests/e2e/terminal-context-menu-session-id.spec.ts similarity index 59% rename from tests/e2e/tab-context-menu-session-id.spec.ts rename to tests/e2e/terminal-context-menu-session-id.spec.ts index cab18bace32..dc58567cefa 100644 --- a/tests/e2e/tab-context-menu-session-id.spec.ts +++ b/tests/e2e/terminal-context-menu-session-id.spec.ts @@ -1,7 +1,4 @@ -/** - * E2E coverage for copying an agent provider session ID from a terminal tab's - * context menu. - */ +/** E2E coverage for copying provider identity from the exact terminal pane. */ import { test, expect } from './helpers/orca-app' import { @@ -11,10 +8,11 @@ import { waitForSessionReady } from './helpers/store' import { waitForPaneIdentitySnapshot } from './helpers/terminal' +import { openTerminalContextMenu } from './helpers/terminal-pane-title-actions' -const SESSION_ID = 'e2e-terminal-tab-session' +const SESSION_ID = 'e2e-terminal-pane-session' -test('terminal tab context menu copies the active agent session ID', async ({ orcaPage }) => { +test('terminal pane context menu copies its agent session ID', async ({ orcaPage }) => { await waitForSessionReady(orcaPage) const worktreeId = await waitForActiveWorktree(orcaPage) await ensureTerminalVisible(orcaPage) @@ -30,21 +28,20 @@ test('terminal tab context menu copies the active agent session ID', async ({ or } const paneKey = `${tabId}:${leafId}` - // Seed the same renderer state a live agent hook produces while keeping the - // test independent of an installed provider CLI. + // Keep this independent of an installed provider CLI while exercising the + // durable pane identity used when transient live status has been cleared. await orcaPage.evaluate( ({ paneKey, tabId, worktreeId, sessionId }) => { const state = window.__store?.getState() if (!state) { throw new Error('Store unavailable') } - state.setAgentStatus( + state.recordAgentProviderSession( paneKey, - { state: 'working', prompt: 'copy session id', agentType: 'claude' }, - 'Claude', + 'claude', + { key: 'session_id', id: sessionId }, undefined, - { tabId, worktreeId }, - { providerSession: { key: 'session_id', id: sessionId } } + { tabId, worktreeId } ) }, { paneKey, tabId, worktreeId, sessionId: SESSION_ID } @@ -55,16 +52,22 @@ test('terminal tab context menu copies the active agent session ID', async ({ or () => orcaPage.evaluate( ({ paneKey }) => - window.__store?.getState().agentStatusByPaneKey[paneKey]?.providerSession?.id, + window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey]?.providerSession.id, { paneKey } ), { timeout: 3_000 } ) .toBe(SESSION_ID) - const tab = orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${tabId}"]`) - await expect(tab).toBeVisible() - await tab.click({ button: 'right' }) + await openTerminalContextMenu(orcaPage) + + const identityItems = await orcaPage.getByRole('menuitem').allInnerTexts() + const sessionIdIndex = identityItems.indexOf('Copy Session ID') + expect(identityItems.slice(sessionIdIndex, sessionIdIndex + 3)).toEqual([ + 'Copy Session ID', + 'Copy Terminal ID', + 'Copy Pane ID' + ]) const copyItem = orcaPage.getByRole('menuitem', { name: 'Copy Session ID', exact: true }) await expect(copyItem).toBeVisible() From 28cb372559a9a1be8076198569457c9f4049e46a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:05:30 -0700 Subject: [PATCH 11/25] perf(platform): resolve the immutable platform payload once (#18135) `window.api.platform.get()` runs ~19x/sec while the app is idle. Every call recomputed a payload whose fields are all fixed for the process lifetime (`process.platform`, `process.getSystemVersion()`, `process.arch`, the shell env vars, and the env-derived Linux display server), allocated a fresh object, and crossed the context bridge. Memoize the payload lazily at preload module scope and freeze it, and cache the resolved platform in `getRendererAppPlatform()` so the 32 renderer call sites stop crossing the bridge on every render. The user-agent fallback stays uncached because the web client installs its platform API after boot. --- src/preload/api/platform-bridge.test.ts | 55 +++++++++++++++++++ src/preload/api/platform-bridge.ts | 15 ++++- ...ebar-titlebar-drag-regions.render.test.tsx | 2 + .../settings/AppearancePane.test.tsx | 2 + .../src/lib/renderer-app-platform.test.ts | 52 ++++++++++++++++++ src/renderer/src/lib/renderer-app-platform.ts | 14 +++++ .../src/store/slices/preflight.test.ts | 2 + 7 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 src/preload/api/platform-bridge.test.ts create mode 100644 src/renderer/src/lib/renderer-app-platform.test.ts diff --git a/src/preload/api/platform-bridge.test.ts b/src/preload/api/platform-bridge.test.ts new file mode 100644 index 00000000000..5c8bdde34f9 --- /dev/null +++ b/src/preload/api/platform-bridge.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { platformApi } from './platform-bridge' + +const mocks = vi.hoisted(() => ({ getLinuxDisplayServer: vi.fn(() => null) })) + +vi.mock('../preload-runtime-support', () => ({ + getLinuxDisplayServer: mocks.getLinuxDisplayServer +})) + +// Electron declares getSystemVersion as required on NodeJS.Process; Node does not have it. +const mutableProcess = process as unknown as { getSystemVersion?: () => string } + +async function loadPlatformApi(): Promise { + vi.resetModules() + return (await import('./platform-bridge')).platformApi +} + +describe('platformApi.get', () => { + beforeEach(() => { + mocks.getLinuxDisplayServer.mockClear() + }) + + afterEach(() => { + delete mutableProcess.getSystemVersion + }) + + it('resolves the immutable payload once and returns the identical object', async () => { + const platformApi = await loadPlatformApi() + const getSystemVersion = vi.fn(() => '25.3.0') + mutableProcess.getSystemVersion = getSystemVersion + + const first = platformApi.get() + for (let index = 0; index < 100; index += 1) { + expect(platformApi.get()).toBe(first) + } + + expect(getSystemVersion).toHaveBeenCalledTimes(1) + expect(mocks.getLinuxDisplayServer).toHaveBeenCalledTimes(1) + expect(first.platform).toBe(process.platform) + expect(first.arch).toBe(process.arch) + expect(first.osRelease).toBe('25.3.0') + }) + + it('freezes the payload so no consumer can corrupt the shared instance', async () => { + const platformApi = await loadPlatformApi() + + expect(Object.isFrozen(platformApi.get())).toBe(true) + }) + + it('resolves nothing before the first get, keeping preload startup free', async () => { + await loadPlatformApi() + + expect(mocks.getLinuxDisplayServer).not.toHaveBeenCalled() + }) +}) diff --git a/src/preload/api/platform-bridge.ts b/src/preload/api/platform-bridge.ts index 8e47a4386cf..fb1aad5de4e 100644 --- a/src/preload/api/platform-bridge.ts +++ b/src/preload/api/platform-bridge.ts @@ -1,8 +1,14 @@ import { getLinuxDisplayServer } from '../preload-runtime-support' import type { PreloadApi } from '../api-types' -export const platformApi = { - get: () => ({ +type PlatformInfo = ReturnType + +// Why: the renderer reads this on its render cadence, and every field below is fixed +// for the process lifetime, so resolve once and hand back the same frozen payload. +let platformInfo: PlatformInfo | undefined + +function resolvePlatformInfo(): PlatformInfo { + return Object.freeze({ platform: process.platform, // Why: sandboxed preload cannot require node:os; Electron exposes the OS // version on process.getSystemVersion when available. @@ -14,4 +20,9 @@ export const platformApi = { shell: process.env.SHELL?.trim() || process.env.ComSpec?.trim() || '', displayServer: getLinuxDisplayServer() }) +} + +export const platformApi = { + // Why: resolved lazily so preload startup keeps paying nothing for it. + get: () => (platformInfo ??= resolvePlatformInfo()) } satisfies PreloadApi['platform'] diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx index 6869daaa89c..40a3966047f 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx +++ b/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx @@ -11,6 +11,7 @@ import { RIGHT_SIDEBAR_WINDOWS_TOP_ACTIVITY_STRIP_CLASS_NAME } from './right-sidebar-titlebar-drag-regions' import type { ActiveRightSidebarTab } from '@/store/slices/editor' +import { resetRendererAppPlatformCacheForTests } from '@/lib/renderer-app-platform' const mockAppState = vi.hoisted(() => ({ rightSidebarOpen: true, @@ -198,6 +199,7 @@ function expectNoDrag(tag: string): void { } function setRendererPlatform(platform: NodeJS.Platform): void { + resetRendererAppPlatformCacheForTests() Object.defineProperty(window, 'api', { configurable: true, value: { diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index 4a89d66cd03..01573d65b49 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { I18nextProvider } from 'react-i18next' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { i18n } from '@/i18n/i18n' +import { resetRendererAppPlatformCacheForTests } from '@/lib/renderer-app-platform' import { getDefaultSettings } from '../../../../shared/constants' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { StatusBarItem } from '../../../../shared/ui-chrome-types' @@ -216,6 +217,7 @@ describe('AppearancePane', () => { beforeEach(() => { vi.clearAllMocks() + resetRendererAppPlatformCacheForTests() mocks.state.availableStatusBarToggles = [] mocks.state.appPlatform = 'linux' mocks.state.settingsSearchQuery = 'automations' diff --git a/src/renderer/src/lib/renderer-app-platform.test.ts b/src/renderer/src/lib/renderer-app-platform.test.ts new file mode 100644 index 00000000000..22c0388084a --- /dev/null +++ b/src/renderer/src/lib/renderer-app-platform.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getRendererAppPlatform, + resetRendererAppPlatformCacheForTests +} from './renderer-app-platform' + +function stubPlatformApi(get: () => { platform: NodeJS.Platform }): void { + ;(window as unknown as { api: unknown }).api = { platform: { get } } +} + +describe('getRendererAppPlatform', () => { + beforeEach(() => { + resetRendererAppPlatformCacheForTests() + }) + + afterEach(() => { + delete (window as unknown as { api?: unknown }).api + resetRendererAppPlatformCacheForTests() + }) + + it('crosses the preload bridge once no matter how many renders ask', () => { + const get = vi.fn(() => ({ platform: 'darwin' as NodeJS.Platform })) + stubPlatformApi(get) + + for (let index = 0; index < 500; index += 1) { + expect(getRendererAppPlatform()).toBe('darwin') + } + + expect(get).toHaveBeenCalledTimes(1) + }) + + it.each(['darwin', 'win32', 'linux'] as const)( + 'reports the preload platform verbatim on %s', + (platform) => { + stubPlatformApi(() => ({ platform })) + + expect(getRendererAppPlatform()).toBe(platform) + } + ) + + // Why: the web client injects its platform API after boot, so an early caller must + // not pin the user-agent guess for the rest of the session. + it('does not cache the user-agent fallback', () => { + // 'freebsd' is never a user-agent fallback answer, so the swap is unambiguous. + expect(getRendererAppPlatform()).not.toBe('freebsd') + + stubPlatformApi(() => ({ platform: 'freebsd' })) + + expect(getRendererAppPlatform()).toBe('freebsd') + }) +}) diff --git a/src/renderer/src/lib/renderer-app-platform.ts b/src/renderer/src/lib/renderer-app-platform.ts index e036d0690e3..f94b77ed2e9 100644 --- a/src/renderer/src/lib/renderer-app-platform.ts +++ b/src/renderer/src/lib/renderer-app-platform.ts @@ -1,7 +1,16 @@ +// Why: hot render paths call this per render; the preload answer never changes, so +// cache it. The user-agent fallback stays uncached because window.api can still be +// installing (the web client injects its own platform API after boot). +let cachedAppPlatform: NodeJS.Platform | undefined + export function getRendererAppPlatform(): NodeJS.Platform { + if (cachedAppPlatform) { + return cachedAppPlatform + } const preloadPlatform = typeof window === 'undefined' ? undefined : window.api?.platform?.get?.()?.platform if (preloadPlatform) { + cachedAppPlatform = preloadPlatform return preloadPlatform } const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent @@ -16,3 +25,8 @@ export function getRendererAppPlatform(): NodeJS.Platform { } return 'win32' } + +/** Tests swap the window.api platform stub between cases; the real value never changes. */ +export function resetRendererAppPlatformCacheForTests(): void { + cachedAppPlatform = undefined +} diff --git a/src/renderer/src/store/slices/preflight.test.ts b/src/renderer/src/store/slices/preflight.test.ts index 386bdfdc59a..168f0099fcc 100644 --- a/src/renderer/src/store/slices/preflight.test.ts +++ b/src/renderer/src/store/slices/preflight.test.ts @@ -6,6 +6,7 @@ import type { Worktree } from '../../../../shared/worktree/types' import type { AppState } from '../types' import { createPreflightSlice } from './preflight' import { createRuntimeStatusSlice } from './runtime-status' +import { resetRendererAppPlatformCacheForTests } from '@/lib/renderer-app-platform' const preflightCheck = vi.fn() const callRuntimeRpc = vi.fn() @@ -62,6 +63,7 @@ function resetPreflightMocks(): void { preflightCheck.mockReset() callRuntimeRpc.mockReset() platformGet.mockReset().mockReturnValue({ platform: 'linux' }) + resetRendererAppPlatformCacheForTests() } function makeStatus(glabInstalled: boolean): PreflightStatus { From dff7f914016cde2f27174a7bedddd9e9602b05af Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:05:35 -0700 Subject: [PATCH 12/25] perf(sidebar): stop rebuilding per-card selector records on every store write (#18133) Zustand re-runs every mounted subscriber's selector on every store write. The per-worktree sidebar selectors built a fresh Record per call, so 15 visible cards x 6 reads x every write allocated a record each time even when nothing they read had changed. - Add createWorktreeRecordSelector: gates the build on the source slice identities, memoizes per worktree id, and carries the previous generation forward so a rebuild with equal contents keeps its reference. - Route the pane-title, live-PTY, layout-root and terminal-layout selectors through it, and return a shared frozen empty when a worktree has no tabs. - Swap useWorktreeAgentRows' inactive-branch `[]`/`{}` literals for the shared frozen constants so the `active` gate actually short-circuits on identity. - Identity-cache the sidebar pending-worktree-creation key list, which ran Object.values(...).map(...) from an always-mounted subscriber. - Drop `key={text}` from TruncatedSidebarLabel so a label change remeasures in place instead of remounting the span and rebuilding its ResizeObserver. - Remove the non-compositable `width` from the board drop indicator's will-change hint. --- src/renderer/src/assets/main.css | 4 +- .../sidebar/truncated-sidebar-label.test.tsx | 36 ++++++ .../sidebar/truncated-sidebar-label.tsx | 19 +-- .../sidebar/useWorktreeAgentRows.ts | 47 ++++++-- .../worktree-agent-row-selectors.test.ts | 113 ++++++++++++++++-- .../sidebar/worktree-agent-row-selectors.ts | 38 ++++-- .../worktree-card-status-inputs.test.ts | 74 ++++++++++++ .../sidebar/worktree-card-status-inputs.ts | 84 ++++++++----- .../pending-worktree-creation-keys.test.ts | 53 ++++++++ .../listing/pending-worktree-creation-keys.ts | 39 ++++++ .../worktree-list/listing/use-section-rows.ts | 13 +- .../sidebar/worktree-record-selector-cache.ts | 63 ++++++++++ 12 files changed, 507 insertions(+), 76 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.ts create mode 100644 src/renderer/src/components/sidebar/worktree-record-selector-cache.ts diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 1b5f40ccdb4..8187fe496c7 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1956,7 +1956,9 @@ html.native-shell .app-layout { transform 120ms cubic-bezier(0.2, 0.8, 0.2, 1), width 120ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 80ms ease-out; - will-change: transform, width, opacity; + /* Why no `width`: it is not compositable, so hinting it only pins a layer that + has to be re-rastered every frame of the transition anyway. */ + will-change: transform, opacity; } [data-workspace-board-card-drop-indicator='true']::before, diff --git a/src/renderer/src/components/sidebar/truncated-sidebar-label.test.tsx b/src/renderer/src/components/sidebar/truncated-sidebar-label.test.tsx index 27ed816c53a..1de1fac0140 100644 --- a/src/renderer/src/components/sidebar/truncated-sidebar-label.test.tsx +++ b/src/renderer/src/components/sidebar/truncated-sidebar-label.test.tsx @@ -105,4 +105,40 @@ describe('TruncatedSidebarLabel', () => { expect(container.textContent).toContain('feature/really-long-branch-name') expect(container.querySelector('[data-tooltip-content]')).toBeNull() }) + + // Why: worktree titles change on the hot store-write path. Remounting the + // span per text change tore down and rebuilt its ResizeObserver every time. + it('keeps one ResizeObserver across a label text change', async () => { + const originalResizeObserver = globalThis.ResizeObserver + let constructed = 0 + let disconnected = 0 + class CountingResizeObserver { + constructor(_callback: ResizeObserverCallback) { + constructed += 1 + } + observe(): void {} + unobserve(): void {} + disconnect(): void { + disconnected += 1 + } + } + globalThis.ResizeObserver = CountingResizeObserver as unknown as typeof ResizeObserver + + try { + await act(async () => { + root.render() + }) + expect(constructed).toBe(1) + + await act(async () => { + root.render() + }) + + expect(container.textContent).toBe('fix/short') + expect(constructed).toBe(1) + expect(disconnected).toBe(0) + } finally { + globalThis.ResizeObserver = originalResizeObserver + } + }) }) diff --git a/src/renderer/src/components/sidebar/truncated-sidebar-label.tsx b/src/renderer/src/components/sidebar/truncated-sidebar-label.tsx index c7afaa2e9b0..80955b5bfe8 100644 --- a/src/renderer/src/components/sidebar/truncated-sidebar-label.tsx +++ b/src/renderer/src/components/sidebar/truncated-sidebar-label.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react' +import React, { useCallback, useLayoutEffect, useState } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' @@ -23,6 +23,7 @@ export function TruncatedSidebarLabel({ tooltipSide = 'right', tooltipSideOffset = 8 }: TruncatedSidebarLabelProps): React.JSX.Element { + const nodeRef = React.useRef(null) const resizeObserverRef = React.useRef(null) const removeResizeListenerRef = React.useRef<(() => void) | null>(null) const [truncated, setTruncated] = useState(false) @@ -39,6 +40,7 @@ export function TruncatedSidebarLabel({ removeResizeListenerRef.current?.() removeResizeListenerRef.current = null + nodeRef.current = node if (!node) { measureTruncated(null) return @@ -60,14 +62,15 @@ export function TruncatedSidebarLabel({ [measureTruncated] ) + // Why: ResizeObserver does not fire when only the rendered text changes, but + // scrollWidth can. Remeasure in place rather than remounting the span, which + // would tear down and rebuild the observer on every title update. + useLayoutEffect(() => { + measureTruncated(nodeRef.current) + }, [measureTruncated, text]) + const label = ( - + {text} ) diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index b2f45a8a9a0..366bce5e5ed 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -5,22 +5,33 @@ import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { useAppStore } from '@/store' import { + EMPTY_LIVE_PTY_IDS, + EMPTY_RUNTIME_PANE_TITLES, selectLivePtyIdsForWorktree, selectRuntimePaneTitlesForWorktree } from './worktree-card-status-inputs' import { buildWorktreeAgentRows } from './worktree-agent-rows' import { + EMPTY_LIVE_ENTRIES, + EMPTY_MIGRATION_UNSUPPORTED_ENTRIES, + EMPTY_RETAINED, + EMPTY_TERMINAL_LAYOUTS, selectLiveAgentStatusEntriesForWorktree, selectMigrationUnsupportedEntriesForWorktree, selectRuntimeAgentOrchestrationForWorktree, selectRetainedAgentEntriesForWorktree, selectTerminalLayoutsForWorktree } from './worktree-agent-row-selectors' +import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from './worktree-agent-orchestration-index' +import { EMPTY_TABS } from './WorktreeCardHelpers' import { createWorktreeAgentFreshnessSelector, EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE } from './worktree-agent-freshness-selector' +// Why frozen: shared by every inactive card, and callers only read these rows. +const EMPTY_AGENT_ROWS = Object.freeze([]) as unknown as DashboardAgentRow[] + export { buildWorktreeAgentRows } from './worktree-agent-rows' export { selectLiveAgentStatusEntriesForWorktree, @@ -44,35 +55,51 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa () => createWorktreeAgentFreshnessSelector(worktreeId), [worktreeId] ) - const tabs = useAppStore((s) => (active ? s.tabsByWorktree[worktreeId] : undefined)) + const tabs = useAppStore((s) => (active ? s.tabsByWorktree[worktreeId] : EMPTY_TABS)) // Why: narrow the subscriptions to only THIS worktree's entries via // useShallow. Subscribing to the whole agentStatusByPaneKey map would make // every on-screen card re-render on any agent-status update anywhere — // O(worktrees²) render amplification. Pre-filtering here means the card // only re-renders when something relevant to THIS worktree changes. const liveEntries = useAppStore( - useShallow((s) => (active ? selectLiveAgentStatusEntriesForWorktree(s, worktreeId) : [])) + useShallow((s) => + active ? selectLiveAgentStatusEntriesForWorktree(s, worktreeId) : EMPTY_LIVE_ENTRIES + ) ) // Why: keep the store selector limited to stable raw records. Converting // migration entries creates fresh objects with Date.now(), which breaks // useSyncExternalStore's cached-snapshot contract and can blank Electron. const migrationUnsupported = useAppStore( - useShallow((s) => (active ? selectMigrationUnsupportedEntriesForWorktree(s, worktreeId) : [])) + useShallow((s) => + active + ? selectMigrationUnsupportedEntriesForWorktree(s, worktreeId) + : EMPTY_MIGRATION_UNSUPPORTED_ENTRIES + ) ) const retained = useAppStore( - useShallow((s) => (active ? selectRetainedAgentEntriesForWorktree(s, worktreeId) : [])) + useShallow((s) => + active ? selectRetainedAgentEntriesForWorktree(s, worktreeId) : EMPTY_RETAINED + ) ) const runtimePaneTitlesByTabId = useAppStore( - useShallow((s) => (active ? selectRuntimePaneTitlesForWorktree(s, worktreeId) : {})) + useShallow((s) => + active ? selectRuntimePaneTitlesForWorktree(s, worktreeId) : EMPTY_RUNTIME_PANE_TITLES + ) ) const ptyIdsByTabId = useAppStore( - useShallow((s) => (active ? selectLivePtyIdsForWorktree(s, worktreeId) : {})) + useShallow((s) => (active ? selectLivePtyIdsForWorktree(s, worktreeId) : EMPTY_LIVE_PTY_IDS)) ) const terminalLayoutsByTabId = useAppStore( - useShallow((s) => (active ? selectTerminalLayoutsForWorktree(s, worktreeId) : {})) + useShallow((s) => + active ? selectTerminalLayoutsForWorktree(s, worktreeId) : EMPTY_TERMINAL_LAYOUTS + ) ) const runtimeAgentOrchestrationByPaneKey = useAppStore( - useShallow((s) => (active ? selectRuntimeAgentOrchestrationForWorktree(s, worktreeId) : {})) + useShallow((s) => + active + ? selectRuntimeAgentOrchestrationForWorktree(s, worktreeId) + : EMPTY_WORKTREE_AGENT_ORCHESTRATION + ) ) const agentFreshnessSignature = useAppStore((s) => active ? selectAgentFreshness(s) : EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE @@ -80,7 +107,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa return useMemo(() => { if (!active) { - return [] + return EMPTY_AGENT_ROWS } // Why: Date.now() is read inside the memo so stale-decay recalculates when // this worktree's freshness signature changes, even without new PTY data. @@ -97,7 +124,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa : liveEntries return applyAgentRowLineage( buildWorktreeAgentRows({ - tabs: tabs ?? [], + tabs: tabs ?? EMPTY_TABS, entries, retained, runtimePaneTitlesByTabId, diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts index cbd3da64212..3e4e6defc6d 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts @@ -9,10 +9,15 @@ import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { makePaneKey } from '../../../../shared/stable-pane-id' import { getLiveEntriesFullRebuildCountForTests } from './worktree-agent-live-index-patch' import { + EMPTY_LIVE_ENTRIES, + EMPTY_MIGRATION_UNSUPPORTED_ENTRIES, + EMPTY_RETAINED, + EMPTY_TERMINAL_LAYOUTS, selectLiveAgentStatusEntriesForWorktree, selectMigrationUnsupportedEntriesForWorktree, selectRuntimeAgentOrchestrationForWorktree, - selectRetainedAgentEntriesForWorktree + selectRetainedAgentEntriesForWorktree, + selectTerminalLayoutsForWorktree } from './worktree-agent-row-selectors' const PANE_KEY_1 = makePaneKey('tab-1', '22222222-2222-4222-8222-222222222222') @@ -94,8 +99,14 @@ describe('selectMigrationUnsupportedEntriesForWorktree', () => { describe('selectLiveAgentStatusEntriesForWorktree', () => { it('reuses unaffected worktree arrays when another worktree receives a same-state ping', () => { - const wt1Entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', prompt: 'first' }) - const wt2Entry = makeEntry(PANE_KEY_2, 1000, { state: 'working', prompt: 'first' }) + const wt1Entry = makeEntry(PANE_KEY_1, 1000, { + state: 'working', + prompt: 'first' + }) + const wt2Entry = makeEntry(PANE_KEY_2, 1000, { + state: 'working', + prompt: 'first' + }) const state = { tabsByWorktree: { 'wt-1': [makeTab('tab-1')], @@ -192,8 +203,14 @@ describe('selectLiveAgentStatusEntriesForWorktree', () => { }) it('patches instead of full-rebuilding across within-state pings, and stays correct on transitions', () => { - const wt1Entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', prompt: 'wt1 prompt' }) - const wt2Entry = makeEntry(PANE_KEY_2, 1000, { state: 'working', prompt: 'wt2 prompt' }) + const wt1Entry = makeEntry(PANE_KEY_1, 1000, { + state: 'working', + prompt: 'wt1 prompt' + }) + const wt2Entry = makeEntry(PANE_KEY_2, 1000, { + state: 'working', + prompt: 'wt2 prompt' + }) const baseState = { tabsByWorktree: { 'wt-1': [makeTab('tab-1')], @@ -256,7 +273,10 @@ describe('selectLiveAgentStatusEntriesForWorktree', () => { }) it('falls back to a full rebuild when a within-map update changes worktree attribution', () => { - const entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', worktreeId: 'wt-1' }) + const entry = makeEntry(PANE_KEY_1, 1000, { + state: 'working', + worktreeId: 'wt-1' + }) const state = { // No tab membership: bucketing comes from entry.worktreeId attribution. tabsByWorktree: { 'wt-1': [], 'wt-2': [] }, @@ -276,7 +296,10 @@ describe('selectLiveAgentStatusEntriesForWorktree', () => { }) it('falls back to a full rebuild when a live entry completes with its tab gone', () => { - const entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', worktreeId: 'wt-1' }) + const entry = makeEntry(PANE_KEY_1, 1000, { + state: 'working', + worktreeId: 'wt-1' + }) const state = { tabsByWorktree: { 'wt-1': [] }, agentStatusByPaneKey: { [PANE_KEY_1]: entry }, @@ -427,3 +450,79 @@ describe('selectRetainedAgentEntriesForWorktree', () => { expect(secondWt2[0]?.startedAt).toBe(1100) }) }) + +describe('selectTerminalLayoutsForWorktree', () => { + const layout = { + root: { type: 'leaf', leafId: '44444444-4444-4444-8444-444444444444' }, + activeLeafId: '44444444-4444-4444-8444-444444444444', + expandedLeafId: null, + ptyIdsByLeafId: {} + } as const + + // Why: every visible card re-runs this selector on every store write; a fresh + // record per call is an allocation per card per write. + it('returns one identity per store generation', () => { + const state = { + tabsByWorktree: { 'wt-1': [makeTab('tab-1')] }, + terminalLayoutsByTabId: { 'tab-1': layout } + } + + expect(selectTerminalLayoutsForWorktree(state, 'wt-1')).toBe( + selectTerminalLayoutsForWorktree(state, 'wt-1') + ) + }) + + it('returns the shared frozen empty for a worktree with no tabs', () => { + const state = { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + + expect(selectTerminalLayoutsForWorktree(state, 'missing')).toBe(EMPTY_TERMINAL_LAYOUTS) + }) +}) + +// Why: the inline-agents hook short-circuits on `active`; the off branch has to +// hand back a shared identity or the gate allocates on every store write. +describe('inactive-card empty constants', () => { + it('are frozen and shared', () => { + for (const empty of [ + EMPTY_LIVE_ENTRIES, + EMPTY_MIGRATION_UNSUPPORTED_ENTRIES, + EMPTY_RETAINED, + EMPTY_TERMINAL_LAYOUTS + ]) { + expect(Object.isFrozen(empty)).toBe(true) + } + expect( + selectLiveAgentStatusEntriesForWorktree( + { + agentStatusByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {}, + tabsByWorktree: {} + }, + 'missing' + ) + ).toBe(EMPTY_LIVE_ENTRIES) + expect( + selectMigrationUnsupportedEntriesForWorktree( + { + agentStatusByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {}, + tabsByWorktree: {} + }, + 'missing' + ) + ).toBe(EMPTY_MIGRATION_UNSUPPORTED_ENTRIES) + expect( + selectRetainedAgentEntriesForWorktree( + { + agentStatusByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {}, + tabsByWorktree: {} + }, + 'missing' + ) + ).toBe(EMPTY_RETAINED) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts index 84588c4296c..9fbbd882e03 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts @@ -13,11 +13,18 @@ import { recordLiveEntriesFullRebuild } from './worktree-agent-live-index-patch' import { selectWorktreeAgentOrchestration } from './worktree-agent-orchestration-index' +import { createWorktreeRecordSelector } from './worktree-record-selector-cache' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' -const EMPTY_LIVE_ENTRIES: AgentStatusEntry[] = [] -const EMPTY_MIGRATION_UNSUPPORTED_ENTRIES: MigrationUnsupportedPtyEntry[] = [] -const EMPTY_RETAINED: RetainedAgentEntry[] = [] +// Why frozen and exported: card hooks return these from their inactive branch, +// so the identity has to be shared app-wide and safe from stray writes. +export const EMPTY_LIVE_ENTRIES = Object.freeze([]) as unknown as AgentStatusEntry[] +export const EMPTY_MIGRATION_UNSUPPORTED_ENTRIES = Object.freeze( + [] +) as unknown as MigrationUnsupportedPtyEntry[] +export const EMPTY_RETAINED = Object.freeze([]) as unknown as RetainedAgentEntry[] +export const EMPTY_TERMINAL_LAYOUTS: Record = + Object.freeze({}) // Why: selector unit tests often pass partial store mocks; production state // owns these maps, but missing mock maps should behave like empty slices. const EMPTY_RECORD = {} @@ -280,13 +287,20 @@ export function selectRuntimeAgentOrchestrationForWorktree( return selectWorktreeAgentOrchestration(state, worktreeId) } -export function selectTerminalLayoutsForWorktree( - state: Pick, - worktreeId: string -): Record { - const out: Record = {} - for (const tab of (state.tabsByWorktree ?? EMPTY_RECORD)[worktreeId] ?? []) { - out[tab.id] = (state.terminalLayoutsByTabId ?? EMPTY_RECORD)[tab.id] +export const selectTerminalLayoutsForWorktree = createWorktreeRecordSelector< + Pick, + Record +>({ + readSources: (state) => [ + state.tabsByWorktree ?? EMPTY_RECORD, + state.terminalLayoutsByTabId ?? EMPTY_RECORD + ], + empty: EMPTY_TERMINAL_LAYOUTS, + build: (state, worktreeId) => { + const out: Record = {} + for (const tab of (state.tabsByWorktree ?? EMPTY_RECORD)[worktreeId] ?? []) { + out[tab.id] = (state.terminalLayoutsByTabId ?? EMPTY_RECORD)[tab.id] + } + return out } - return out -} +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts b/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts index 7d366b845e8..59675017b2a 100644 --- a/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts @@ -6,6 +6,9 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { + EMPTY_LIVE_PTY_IDS, + EMPTY_RUNTIME_PANE_TITLES, + EMPTY_TERMINAL_LAYOUT_ROOTS, selectLivePtyIdsForWorktree, selectTerminalLayoutRootsForWorktree, selectTerminalLayoutRootsForWorktrees, @@ -145,4 +148,75 @@ describe('worktree card status input selectors', () => { ) ).toBe(true) }) + + // Why: zustand re-runs every mounted card's selector on every store write, so + // a fresh record per call multiplies by (visible cards x writes/sec). + it('returns one identity per store generation instead of rebuilding per call', () => { + const worktreeId = 'repo1::/path/wt1' + const state: SelectorState & LayoutRootSelectorState = { + tabsByWorktree: { + [worktreeId]: [makeTab('tab-1', worktreeId)] + }, + runtimePaneTitlesByTabId: { 'tab-1': { 0: 'codex [working]' } }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + terminalLayoutsByTabId: { + 'tab-1': makeLayout( + { type: 'leaf', leafId: '11111111-1111-4111-8111-111111111111' }, + 'pty-1' + ) + } + } + + expect(selectRuntimePaneTitlesForWorktree(state, worktreeId)).toBe( + selectRuntimePaneTitlesForWorktree(state, worktreeId) + ) + expect(selectLivePtyIdsForWorktree(state, worktreeId)).toBe( + selectLivePtyIdsForWorktree(state, worktreeId) + ) + expect(selectTerminalLayoutRootsForWorktree(state, worktreeId)).toBe( + selectTerminalLayoutRootsForWorktree(state, worktreeId) + ) + }) + + it('carries the same identity across unrelated pane-title and PTY churn', () => { + const worktreeId = 'repo1::/path/wt1' + const state: SelectorState = { + tabsByWorktree: { + [worktreeId]: [makeTab('tab-1', worktreeId)] + }, + runtimePaneTitlesByTabId: { 'tab-1': { 0: 'codex [working]' } }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } + const unrelatedUpdate: SelectorState = { + ...state, + runtimePaneTitlesByTabId: { + ...state.runtimePaneTitlesByTabId, + 'other-tab': { 0: 'claude [permission]' } + }, + ptyIdsByTabId: { ...state.ptyIdsByTabId, 'other-tab': ['pty-other'] } + } + + expect(selectRuntimePaneTitlesForWorktree(state, worktreeId)).toBe( + selectRuntimePaneTitlesForWorktree(unrelatedUpdate, worktreeId) + ) + expect(selectLivePtyIdsForWorktree(state, worktreeId)).toBe( + selectLivePtyIdsForWorktree(unrelatedUpdate, worktreeId) + ) + }) + + it('returns the shared frozen empty for a worktree with no tabs', () => { + const state: SelectorState & LayoutRootSelectorState = { + tabsByWorktree: {}, + runtimePaneTitlesByTabId: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {} + } + + expect(selectRuntimePaneTitlesForWorktree(state, 'missing')).toBe(EMPTY_RUNTIME_PANE_TITLES) + expect(selectLivePtyIdsForWorktree(state, 'missing')).toBe(EMPTY_LIVE_PTY_IDS) + expect(selectTerminalLayoutRootsForWorktree(state, 'missing')).toBe(EMPTY_TERMINAL_LAYOUT_ROOTS) + expect(Object.isFrozen(EMPTY_RUNTIME_PANE_TITLES)).toBe(true) + expect(Object.isFrozen(EMPTY_LIVE_PTY_IDS)).toBe(true) + expect(Object.isFrozen(EMPTY_TERMINAL_LAYOUT_ROOTS)).toBe(true) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts b/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts index 128afafe03d..cd9f5c4f7c5 100644 --- a/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts @@ -1,9 +1,19 @@ import type { AppState } from '@/store/types' import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import { createWorktreeRecordSelector } from './worktree-record-selector-cache' // Why: these selectors return fresh maps whose top-level values preserve // underlying per-tab references, so callers must compare them shallowly. +// Why frozen: one instance is shared by every card, so a stray write would leak +// across worktrees instead of failing locally. +export const EMPTY_RUNTIME_PANE_TITLES: Record> = Object.freeze({}) +export const EMPTY_LIVE_PTY_IDS: Record = Object.freeze({}) +export const EMPTY_TERMINAL_LAYOUT_ROOTS: Record< + string, + TerminalPaneLayoutNode | null | undefined +> = Object.freeze({}) + type WorktreeCardStatusInputState = Pick & { tabsByWorktree: Record } @@ -12,44 +22,56 @@ type WorktreeCardLayoutRootInputState = Pick tabsByWorktree: Record } -export function selectRuntimePaneTitlesForWorktree( - state: WorktreeCardStatusInputState, - worktreeId: string -): Record> { - const out: Record> = {} - for (const tab of state.tabsByWorktree[worktreeId] ?? []) { - const paneTitles = state.runtimePaneTitlesByTabId[tab.id] - if (paneTitles) { - out[tab.id] = paneTitles +export const selectRuntimePaneTitlesForWorktree = createWorktreeRecordSelector< + WorktreeCardStatusInputState, + Record> +>({ + readSources: (state) => [state.tabsByWorktree, state.runtimePaneTitlesByTabId], + empty: EMPTY_RUNTIME_PANE_TITLES, + build: (state, worktreeId) => { + const out: Record> = {} + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + const paneTitles = state.runtimePaneTitlesByTabId[tab.id] + if (paneTitles) { + out[tab.id] = paneTitles + } } + return out } - return out -} +}) -export function selectLivePtyIdsForWorktree( - state: WorktreeCardStatusInputState, - worktreeId: string -): Record { - const out: Record = {} - for (const tab of state.tabsByWorktree[worktreeId] ?? []) { - const ids = state.ptyIdsByTabId[tab.id] - if (ids && ids.length > 0) { - out[tab.id] = ids +export const selectLivePtyIdsForWorktree = createWorktreeRecordSelector< + WorktreeCardStatusInputState, + Record +>({ + readSources: (state) => [state.tabsByWorktree, state.ptyIdsByTabId], + empty: EMPTY_LIVE_PTY_IDS, + build: (state, worktreeId) => { + const out: Record = {} + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + const ids = state.ptyIdsByTabId[tab.id] + if (ids && ids.length > 0) { + out[tab.id] = ids + } } + return out } - return out -} +}) -export function selectTerminalLayoutRootsForWorktree( - state: WorktreeCardLayoutRootInputState, - worktreeId: string -): Record { - const out: Record = {} - for (const tab of state.tabsByWorktree[worktreeId] ?? []) { - out[tab.id] = state.terminalLayoutsByTabId[tab.id]?.root +export const selectTerminalLayoutRootsForWorktree = createWorktreeRecordSelector< + WorktreeCardLayoutRootInputState, + Record +>({ + readSources: (state) => [state.tabsByWorktree, state.terminalLayoutsByTabId], + empty: EMPTY_TERMINAL_LAYOUT_ROOTS, + build: (state, worktreeId) => { + const out: Record = {} + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + out[tab.id] = state.terminalLayoutsByTabId[tab.id]?.root + } + return out } - return out -} +}) export function selectTerminalLayoutRootsForWorktrees( state: WorktreeCardLayoutRootInputState, diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.test.ts b/src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.test.ts new file mode 100644 index 00000000000..d45a7bf392f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { + EMPTY_PENDING_WORKTREE_CREATION_KEYS, + selectPendingWorktreeCreationKeys +} from './pending-worktree-creation-keys' + +type PendingCreations = AppState['pendingWorktreeCreations'] + +function makePending(creationId: string, repoId: string): PendingCreations[string] { + return { + creationId, + request: { repoId } + } as unknown as PendingCreations[string] +} + +describe('selectPendingWorktreeCreationKeys', () => { + // Why: this runs inside an always-mounted sidebar subscriber, so zustand + // re-evaluates it on every store write in the app. + it('returns the shared frozen empty when nothing is pending', () => { + const empty: PendingCreations = {} + + expect(selectPendingWorktreeCreationKeys(empty)).toBe(EMPTY_PENDING_WORKTREE_CREATION_KEYS) + expect(selectPendingWorktreeCreationKeys({})).toBe(EMPTY_PENDING_WORKTREE_CREATION_KEYS) + expect(selectPendingWorktreeCreationKeys(undefined)).toBe(EMPTY_PENDING_WORKTREE_CREATION_KEYS) + expect(Object.isFrozen(EMPTY_PENDING_WORKTREE_CREATION_KEYS)).toBe(true) + }) + + it('builds the key list once per slice identity', () => { + const pending: PendingCreations = { + 'creation-1': makePending('creation-1', 'repo with space') + } + + const first = selectPendingWorktreeCreationKeys(pending) + expect(first).toEqual(['creation-1 repo with space']) + expect(selectPendingWorktreeCreationKeys(pending)).toBe(first) + }) + + it('rebuilds when the slice is replaced', () => { + const before: PendingCreations = { + 'creation-1': makePending('creation-1', 'repo-1') + } + const after: PendingCreations = { + ...before, + 'creation-2': makePending('creation-2', 'repo-2') + } + + expect(selectPendingWorktreeCreationKeys(after)).toEqual([ + 'creation-1 repo-1', + 'creation-2 repo-2' + ]) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.ts b/src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.ts new file mode 100644 index 00000000000..857edd9c3c9 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.ts @@ -0,0 +1,39 @@ +import type { AppState } from '@/store/types' + +// Why frozen: the sidebar row model is always mounted and this list is empty +// almost always, so one shared identity serves every read. +export const EMPTY_PENDING_WORKTREE_CREATION_KEYS: string[] = Object.freeze( + [] +) as unknown as string[] + +const keysBySource = new WeakMap() + +/** + * Flat `" "` keys for the pending-creation sidebar rows. + * + * Why identity-cached: this runs inside an always-mounted subscriber, so an + * unmemoized `Object.values(...).map(...)` allocated an array plus one template + * string per pending creation on every store write in the app. Keyed on the + * slice reference, so it only rebuilds when the slice itself is replaced. + * + * Split on the first space — creationId is a UUID (no space) so a + * space-containing repoId stays intact. + */ +export function selectPendingWorktreeCreationKeys( + pendingWorktreeCreations: AppState['pendingWorktreeCreations'] | undefined +): string[] { + if (!pendingWorktreeCreations) { + return EMPTY_PENDING_WORKTREE_CREATION_KEYS + } + const cached = keysBySource.get(pendingWorktreeCreations) + if (cached) { + return cached + } + const creations = Object.values(pendingWorktreeCreations) + const keys = + creations.length === 0 + ? EMPTY_PENDING_WORKTREE_CREATION_KEYS + : creations.map((creation) => `${creation.creationId} ${creation.request.repoId}`) + keysBySource.set(pendingWorktreeCreations, keys) + return keys +} diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts index 16899dbc146..ddd91a23653 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts @@ -19,6 +19,7 @@ import { getEmptyProjectPlaceholderRepoIds } from '../../empty-project-placehold import { addHostSectionRows } from '../../host-section-rows' import { orderHostSectionOptions } from '../../host-section-order' import { buildSidebarHostOptions } from '../../sidebar-host-options' +import { selectPendingWorktreeCreationKeys } from './pending-worktree-creation-keys' type SectionRowsArgs = { groupBy: WorktreeGroupBy @@ -96,19 +97,17 @@ export function useSidebarSectionRows(args: SectionRowsArgs) { ) // Why: subscribe on a flat key array (useShallow) so progress ticks don't rebuild the whole row model. - // Split on first space — creationId is a UUID (no space) so a space-containing repoId stays intact. const pendingCreationKeys = useAppStore( - useShallow((s) => - Object.values(s.pendingWorktreeCreations ?? {}).map( - (creation) => `${creation.creationId} ${creation.request.repoId}` - ) - ) + useShallow((s) => selectPendingWorktreeCreationKeys(s.pendingWorktreeCreations)) ) const pendingCreations = useMemo( () => pendingCreationKeys.map((key) => { const separator = key.indexOf(' ') - return { creationId: key.slice(0, separator), repoId: key.slice(separator + 1) } + return { + creationId: key.slice(0, separator), + repoId: key.slice(separator + 1) + } }), [pendingCreationKeys] ) diff --git a/src/renderer/src/components/sidebar/worktree-record-selector-cache.ts b/src/renderer/src/components/sidebar/worktree-record-selector-cache.ts new file mode 100644 index 00000000000..220d2d074bf --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-record-selector-cache.ts @@ -0,0 +1,63 @@ +import { shallow } from 'zustand/shallow' + +type WorktreeRecordGeneration = { + sources: readonly unknown[] + carried: ReadonlyMap | null + byWorktreeId: Map +} + +function sameSources(previous: readonly unknown[], next: readonly unknown[]): boolean { + if (previous.length !== next.length) { + return false + } + for (let index = 0; index < next.length; index += 1) { + if (previous[index] !== next[index]) { + return false + } + } + return true +} + +/** + * Wraps a per-worktree record selector in a store-identity-keyed cache. + * + * Zustand re-runs every mounted subscriber's selector on every store write, so + * an unmemoized build allocates one record per visible card per write even when + * nothing it reads changed. Gating on the source slice identities collapses that + * to one build per worktree per generation; carrying the previous generation + * forward keeps the reference stable when a rebuild produces equal contents, so + * downstream `useShallow`/`useMemo` gates short-circuit on identity. + * + * The returned records are shared by every caller and must never be mutated. + */ +export function createWorktreeRecordSelector(options: { + readSources: (state: TState) => readonly unknown[] + build: (state: TState, worktreeId: string) => TValue + empty: TValue +}): (state: TState, worktreeId: string) => TValue { + let generation: WorktreeRecordGeneration | null = null + return (state, worktreeId) => { + const sources = options.readSources(state) + if (!generation || !sameSources(generation.sources, sources)) { + generation = { + sources, + carried: generation?.byWorktreeId ?? null, + byWorktreeId: new Map() + } + } + const cached = generation.byWorktreeId.get(worktreeId) + if (cached !== undefined) { + return cached + } + const built = options.build(state, worktreeId) + const carried = generation.carried?.get(worktreeId) + let value = built + if (Object.keys(built).length === 0) { + value = options.empty + } else if (carried && shallow(carried, built)) { + value = carried + } + generation.byWorktreeId.set(worktreeId, value) + return value + } +} From 89bd18990dc38bd7aef18a1b60e1ec5ec23fa89d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:05:41 -0700 Subject: [PATCH 13/25] perf(renderer): stop three always-mounted selectors rescanning the store (#18136) Zustand reruns every subscriber's selector on each store write. Three selectors did an O(N) scan of a store collection inside that path, so at 10 repos / 423 worktrees / 382 tabs they were paid thousands of times a second while the app sat idle. - getLocalWorktree / getLocalRuntimeRepoForWorktree now read the shared WeakMap indexes (getIndexedWorktreeById, getIndexedRepoMap) instead of `Object.values(worktreesByRepo).flat().find(...)` and `repos.find(...)`. SidebarTaskNavButton is always mounted and calls this on every write. - selectRepoByIdForActiveWorkspace caches its host-scoped resolution in a WeakMap keyed on the `repos` array, mirroring getIndexedRepoMap. - getProjectRuntimeSessionSummary memoizes per (tabsByWorktree, ptyIdsByTabId, agentStatusByPaneKey, repoId) and reuses the existing identity-cached getTabIdToWorktreeId index. --- .../repository-runtime-session-summary.ts | 45 ++- .../sidebar/worktree-agent-row-selectors.ts | 5 +- .../src/lib/local-preflight-context.ts | 22 +- .../always-mounted-selector-scan-cost.test.ts | 277 ++++++++++++++++++ src/renderer/src/store/selectors.ts | 70 +++-- 5 files changed, 389 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/store/always-mounted-selector-scan-cost.test.ts diff --git a/src/renderer/src/components/settings/repository-runtime-session-summary.ts b/src/renderer/src/components/settings/repository-runtime-session-summary.ts index 5dffcd3b1e2..62bfd7053e8 100644 --- a/src/renderer/src/components/settings/repository-runtime-session-summary.ts +++ b/src/renderer/src/components/settings/repository-runtime-session-summary.ts @@ -1,5 +1,6 @@ import type { AppState } from '../../store/types' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { getTabIdToWorktreeId } from '../sidebar/worktree-agent-row-selectors' export type ProjectRuntimeSessionSummary = { liveTerminalCount: number @@ -11,16 +12,26 @@ type RuntimeSessionSummaryState = Pick< 'tabsByWorktree' | 'ptyIdsByTabId' | 'agentStatusByPaneKey' > +type SessionSummaryCache = { + ptyIdsByTabId: AppState['ptyIdsByTabId'] + agentStatusByPaneKey: AppState['agentStatusByPaneKey'] + byRepoId: Map +} + +// Why: one RepositoryPane per project reruns this on every store write, and each +// run walked every worktree bucket plus every agent-status pane. Key on the three +// input slices so unrelated writes reuse the answer instead of rescanning. +const sessionSummaryCache = new WeakMap() + function getTabIdFromPaneKey(paneKey: string): string | null { const separator = paneKey.indexOf(':') return separator > 0 ? paneKey.slice(0, separator) : null } -export function getProjectRuntimeSessionSummary( +function computeProjectRuntimeSessionSummary( state: RuntimeSessionSummaryState, repoId: string ): ProjectRuntimeSessionSummary { - const tabWorktreeIds = new Map() const projectWorktreeIds = new Set() let liveTerminalCount = 0 @@ -31,7 +42,6 @@ export function getProjectRuntimeSessionSummary( projectWorktreeIds.add(worktreeId) for (const tab of tabs) { - tabWorktreeIds.set(tab.id, worktreeId) const livePtyIds = new Set(state.ptyIdsByTabId[tab.id] ?? []) if (tab.ptyId) { livePtyIds.add(tab.ptyId) @@ -40,6 +50,9 @@ export function getProjectRuntimeSessionSummary( } } + // Rows outside this project resolve to a worktree the checks below reject, so + // the shared index answers the same question the repo-scoped map used to. + const tabWorktreeIds = getTabIdToWorktreeId(state.tabsByWorktree) let activeTaskCount = 0 for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { if (entry.state === 'done') { @@ -57,3 +70,29 @@ export function getProjectRuntimeSessionSummary( return { liveTerminalCount, activeTaskCount } } + +export function getProjectRuntimeSessionSummary( + state: RuntimeSessionSummaryState, + repoId: string +): ProjectRuntimeSessionSummary { + let cache = sessionSummaryCache.get(state.tabsByWorktree) + if ( + !cache || + cache.ptyIdsByTabId !== state.ptyIdsByTabId || + cache.agentStatusByPaneKey !== state.agentStatusByPaneKey + ) { + cache = { + ptyIdsByTabId: state.ptyIdsByTabId, + agentStatusByPaneKey: state.agentStatusByPaneKey, + byRepoId: new Map() + } + sessionSummaryCache.set(state.tabsByWorktree, cache) + } + const cached = cache.byRepoId.get(repoId) + if (cached) { + return cached + } + const summary = computeProjectRuntimeSessionSummary(state, repoId) + cache.byRepoId.set(repoId, summary) + return summary +} diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts index 9fbbd882e03..06ba3979ffe 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts @@ -80,7 +80,10 @@ export function reuseArrayIfEqual(previous: T[] | undefined, next: T[]): T[] return previous } -function getTabIdToWorktreeId( +// Why exported: the Settings -> Repositories runtime summary needs the same +// tab -> worktree index, and rebuilding it there would re-walk every tab bucket +// on each store write. +export function getTabIdToWorktreeId( tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree'] ): Map { if (tabWorktreeIndexCache?.tabsByWorktree === tabsByWorktree) { diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index 98ac6c1059e..7988e4656a7 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -9,6 +9,7 @@ import { import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' +import { getIndexedRepoMap, getIndexedWorktreeById } from '@/store/worktree-repo-index' import { getProviderRuntimeContextKey } from './provider-runtime-context' import { getRendererAppPlatform } from './renderer-app-platform' import { @@ -36,6 +37,11 @@ type LocalProjectRuntimeState = Pick< 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' > +// Why: the shared indexes are WeakMap-keyed on slice identity, so a fresh `{}` +// or `[]` fallback would miss the cache on every read. +const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {} +const EMPTY_REPOS: AppState['repos'] = [] + type LocalProjectRuntimeWslContext = { wslAvailable?: boolean availableWslDistros?: readonly string[] | null @@ -120,7 +126,7 @@ export function getLocalRepoProjectExecutionRuntimeContext( return undefined } - const repo = (state.repos ?? []).find((entry) => entry.id === repoId) + const repo = getIndexedRepoMap(state.repos ?? EMPTY_REPOS).get(repoId) if (!isLocalRuntimeRepo(repo)) { return undefined } @@ -270,7 +276,7 @@ function getLocalRuntimeRepoForWorktree( worktree?: Pick | null ): Pick | undefined { const repoId = worktree?.repoId ?? state.activeRepoId - return repoId ? (state.repos ?? []).find((repo) => repo.id === repoId) : undefined + return repoId ? getIndexedRepoMap(state.repos ?? EMPTY_REPOS).get(repoId) : undefined } function isLocalRuntimeRepo( @@ -302,11 +308,13 @@ function getLocalWorktree( worktreeId?: string | null ): Pick | null { const targetWorktreeId = worktreeId ?? state.activeWorktreeId - return targetWorktreeId - ? (Object.values(state.worktreesByRepo ?? {}) - .flat() - .find((worktree) => worktree.id === targetWorktreeId) ?? null) - : null + if (!targetWorktreeId) { + return null + } + return ( + getIndexedWorktreeById(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO, targetWorktreeId) ?? + null + ) } function getLocalPreflightProjectId( diff --git a/src/renderer/src/store/always-mounted-selector-scan-cost.test.ts b/src/renderer/src/store/always-mounted-selector-scan-cost.test.ts new file mode 100644 index 00000000000..2baeb5958ef --- /dev/null +++ b/src/renderer/src/store/always-mounted-selector-scan-cost.test.ts @@ -0,0 +1,277 @@ +/** + * Zustand reruns every subscriber's selector on every store write, so an O(N) + * scan inside an always-mounted selector is paid thousands of times per second + * while the app is idle. These tests count property reads on the store rows to + * prove each selector builds its index once per snapshot instead of per read. + */ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../shared/repo-types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { Worktree } from '../../../shared/worktree/types' +import { WORKTREE_ID_SEPARATOR } from '../../../shared/worktree/id' +import { getLocalPreflightContext } from '../lib/local-preflight-context' +import { getProjectRuntimeSessionSummary } from '../components/settings/repository-runtime-session-summary' +import type { AppState } from './types' +import { selectRepoByIdForActiveWorkspace } from './selectors' + +// The user scale that motivated this: 10 repos, 423 worktrees, 382 open tabs. +const REPO_COUNT = 10 +const WORKTREES_PER_REPO = 42 +const TABS_PER_WORKTREE = 1 +const STORE_WRITES = 200 + +type ReadCounter = { count: number } + +function makeRepoRows(counter: ReadCounter): Repo[] { + return Array.from({ length: REPO_COUNT }, (_unused, index) => { + const id = `repo-${index}` + return { + get id() { + counter.count += 1 + return id + }, + path: `/tmp/repo-${index}`, + displayName: `repo-${index}`, + badgeColor: '#737373', + addedAt: 100, + kind: 'git' + } as Repo + }) +} + +function makeWorktreesByRepo(counter: ReadCounter): AppState['worktreesByRepo'] { + const worktreesByRepo: Record = {} + for (let repoIndex = 0; repoIndex < REPO_COUNT; repoIndex += 1) { + const repoId = `repo-${repoIndex}` + worktreesByRepo[repoId] = Array.from({ length: WORKTREES_PER_REPO }, (_unused, index) => { + const path = String.raw`\\wsl.localhost\Ubuntu\home\alice\wt-${repoIndex}-${index}` + const id = `${repoId}${WORKTREE_ID_SEPARATOR}${path}` + return { + get id() { + counter.count += 1 + return id + }, + repoId, + path + } as Worktree + }) + } + return worktreesByRepo +} + +/** The worst case for a first-wins linear scan: the last row of the last repo. */ +function lastWorktreeId(worktreesByRepo: AppState['worktreesByRepo']): string { + const lastBucket = Object.values(worktreesByRepo).at(-1) ?? [] + return (lastBucket.at(-1) as Worktree).id +} + +describe('local preflight context worktree lookup', () => { + it('builds the worktree index once instead of rescanning per store write', () => { + const worktreeReads: ReadCounter = { count: 0 } + const repoReads: ReadCounter = { count: 0 } + const worktreesByRepo = makeWorktreesByRepo(worktreeReads) + const activeWorktreeId = lastWorktreeId(worktreesByRepo) + const state = { + activeRepoId: `repo-${REPO_COUNT - 1}`, + activeWorktreeId, + repos: makeRepoRows(repoReads), + worktreesByRepo, + projects: [] + } as unknown as AppState + const rowCount = REPO_COUNT * WORKTREES_PER_REPO + worktreeReads.count = 0 + repoReads.count = 0 + + for (let write = 0; write < STORE_WRITES; write += 1) { + expect(getLocalPreflightContext(state, 'darwin')).toEqual({ + wslDistro: 'Ubuntu' + }) + } + + // One index build per snapshot, not one scan per store write. + expect(worktreeReads.count).toBeLessThanOrEqual(rowCount) + expect(repoReads.count).toBeLessThanOrEqual(REPO_COUNT) + }) + + it('rebuilds against a replacement snapshot', () => { + const counter: ReadCounter = { count: 0 } + const worktreesByRepo = makeWorktreesByRepo(counter) + const repos = makeRepoRows({ count: 0 }) + const activeWorktreeId = lastWorktreeId(worktreesByRepo) + const before = getLocalPreflightContext( + { + activeRepoId: 'repo-0', + activeWorktreeId, + repos, + worktreesByRepo + } as unknown as AppState, + 'darwin' + ) + expect(before).toEqual({ wslDistro: 'Ubuntu' }) + + const movedWorktree = { + id: activeWorktreeId, + repoId: `repo-${REPO_COUNT - 1}`, + path: String.raw`\\wsl.localhost\Debian\home\alice\moved` + } as Worktree + const after = getLocalPreflightContext( + { + activeRepoId: 'repo-0', + activeWorktreeId, + repos, + worktreesByRepo: { [`repo-${REPO_COUNT - 1}`]: [movedWorktree] } + } as unknown as AppState, + 'darwin' + ) + + expect(after).toEqual({ wslDistro: 'Debian' }) + }) +}) + +describe('selectRepoByIdForActiveWorkspace', () => { + function makeActiveWorkspaceState(counter: ReadCounter): AppState { + return { + repos: makeRepoRows(counter), + activeRepoId: 'repo-0', + // No repo row carries this host, so the fallback branch runs every time. + activeWorkspaceExecutionHostId: 'ssh:host-a' + } as unknown as AppState + } + + it('resolves the active-workspace host once per repos snapshot', () => { + const counter: ReadCounter = { count: 0 } + const state = makeActiveWorkspaceState(counter) + counter.count = 0 + + for (let write = 0; write < STORE_WRITES; write += 1) { + expect(selectRepoByIdForActiveWorkspace(state, 'repo-0')).toBeNull() + } + + // Worst case: the id-keyed map build plus one host-filter pass. + expect(counter.count).toBeLessThanOrEqual(REPO_COUNT * 3) + }) + + it('still prefers the row that carries the active workspace host', () => { + const localRepo = { + id: 'repo-0', + path: '/tmp/a', + displayName: 'a' + } as Repo + const sshRepo = { + id: 'repo-0', + path: '/tmp/a', + displayName: 'a', + connectionId: 'host-a' + } as Repo + const state = { + repos: [localRepo, sshRepo], + activeRepoId: 'repo-0', + activeWorkspaceExecutionHostId: 'ssh:host-a' + } as unknown as AppState + + expect(selectRepoByIdForActiveWorkspace(state, 'repo-0')).toBe(sshRepo) + expect(selectRepoByIdForActiveWorkspace(state, 'repo-0')).toBe(sshRepo) + }) + + it('returns an identical result for repeated reads of one snapshot', () => { + const state = makeActiveWorkspaceState({ count: 0 }) + expect(selectRepoByIdForActiveWorkspace(state, 'repo-0')).toBe( + selectRepoByIdForActiveWorkspace(state, 'repo-0') + ) + expect(selectRepoByIdForActiveWorkspace(state, 'repo-1')).toBe( + selectRepoByIdForActiveWorkspace(state, 'repo-1') + ) + }) +}) + +describe('project runtime session summary', () => { + function makeRuntimeSessionState(counter: ReadCounter): AppState { + const tabsByWorktree: Record = {} + for (let repoIndex = 0; repoIndex < REPO_COUNT; repoIndex += 1) { + for (let index = 0; index < WORKTREES_PER_REPO; index += 1) { + const worktreeId = `repo-${repoIndex}${WORKTREE_ID_SEPARATOR}/tmp/wt-${repoIndex}-${index}` + tabsByWorktree[worktreeId] = Array.from( + { length: TABS_PER_WORKTREE }, + (_unused, tabIndex) => { + const id = `tab-${repoIndex}-${index}-${tabIndex}` + return { + get id() { + counter.count += 1 + return id + }, + ptyId: `pty-${id}`, + worktreeId + } as TerminalTab + } + ) + } + } + return { + tabsByWorktree, + ptyIdsByTabId: {}, + agentStatusByPaneKey: {} + } as unknown as AppState + } + + it('reuses the tab index across repos and store writes', () => { + const counter: ReadCounter = { count: 0 } + const state = makeRuntimeSessionState(counter) + const tabCount = REPO_COUNT * WORKTREES_PER_REPO * TABS_PER_WORKTREE + counter.count = 0 + + // One RepositoryPane per project, all re-running on every store write. + for (let write = 0; write < STORE_WRITES; write += 1) { + for (let repoIndex = 0; repoIndex < REPO_COUNT; repoIndex += 1) { + expect(getProjectRuntimeSessionSummary(state, `repo-${repoIndex}`)).toEqual({ + liveTerminalCount: WORKTREES_PER_REPO * TABS_PER_WORKTREE, + activeTaskCount: 0 + }) + } + } + + // The shared tab index plus one pass over each repo's own tabs. + expect(counter.count).toBeLessThanOrEqual(tabCount * 3) + }) + + it('returns an identical summary for repeated reads of one snapshot', () => { + const state = makeRuntimeSessionState({ count: 0 }) + expect(getProjectRuntimeSessionSummary(state, 'repo-0')).toBe( + getProjectRuntimeSessionSummary(state, 'repo-0') + ) + }) + + it('recomputes when a tab slice is replaced', () => { + const state = makeRuntimeSessionState({ count: 0 }) + const first = getProjectRuntimeSessionSummary(state, 'repo-0') + const worktreeId = `repo-0${WORKTREE_ID_SEPARATOR}/tmp/wt-0-0` + const next = getProjectRuntimeSessionSummary( + { + ...state, + tabsByWorktree: { + [worktreeId]: [{ id: 'tab-new', ptyId: 'pty-new', worktreeId } as TerminalTab] + } + } as unknown as AppState, + 'repo-0' + ) + + expect(first.liveTerminalCount).toBe(WORKTREES_PER_REPO * TABS_PER_WORKTREE) + expect(next.liveTerminalCount).toBe(1) + }) + + it('counts running agents against the owning project only', () => { + const state = makeRuntimeSessionState({ count: 0 }) + const summary = getProjectRuntimeSessionSummary( + { + ...state, + agentStatusByPaneKey: { + 'tab-0-0-0:leaf': { state: 'working', tabId: 'tab-0-0-0' }, + 'tab-1-0-0:leaf': { state: 'working', tabId: 'tab-1-0-0' }, + 'tab-0-1-0:leaf': { state: 'done', tabId: 'tab-0-1-0' } + } + } as unknown as AppState, + 'repo-0' + ) + + expect(summary.activeTaskCount).toBe(1) + }) +}) diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index 77c124f562c..b30673a0b39 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -228,33 +228,65 @@ export const useActiveRepo = () => useAppStore(useShallow((s) => selectRepoByIdForActiveWorkspace(s, s.activeRepoId))) export const useRepoMap = () => useAppStore((s) => getCachedRepoMap(s.repos)) +type ActiveWorkspaceRepoState = Pick< + AppState, + 'repos' | 'activeRepoId' | 'activeWorkspaceExecutionHostId' +> + +// Why: mirrors getIndexedRepoMap above — the host-scoped branch re-filtered every +// repo on each store write even though its answer only moves when `repos` or the +// active workspace host does. +const activeWorkspaceRepoCache = new WeakMap>() + +function resolveRepoOnActiveWorkspaceHost( + state: ActiveWorkspaceRepoState, + repoId: string, + activeWorkspaceExecutionHostId: ExecutionHostId +): Repo | null { + const repoCandidates = state.repos.filter((candidate) => candidate.id === repoId) + const hostMatch = repoCandidates.find( + (candidate) => getRepoExecutionHostId(candidate) === activeWorkspaceExecutionHostId + ) + if (hostMatch) { + return hostMatch + } + // Why: withRepoHostOwnership keeps a paired-hub worktree on its own SSH host while the repo + // stays hub-owned, so that one mismatch still names the right repo; every other stays closed. + if (parseExecutionHostId(activeWorkspaceExecutionHostId)?.kind !== 'ssh') { + return null + } + const pairedHubRepos = repoCandidates.filter( + (candidate) => parseExecutionHostId(getRepoExecutionHostId(candidate))?.kind === 'runtime' + ) + return pairedHubRepos.length === 1 ? pairedHubRepos[0] : null +} + export function selectRepoByIdForActiveWorkspace( - state: Pick, + state: ActiveWorkspaceRepoState, repoId: string | null ): Repo | null { if (!repoId) { return null } const repo = getCachedRepoMap(state.repos).get(repoId) ?? null - if (repoId === state.activeRepoId && state.activeWorkspaceExecutionHostId) { - const repoCandidates = state.repos.filter((candidate) => candidate.id === repoId) - const hostMatch = repoCandidates.find( - (candidate) => getRepoExecutionHostId(candidate) === state.activeWorkspaceExecutionHostId - ) - if (hostMatch) { - return hostMatch - } - // Why: withRepoHostOwnership keeps a paired-hub worktree on its own SSH host while the repo - // stays hub-owned, so that one mismatch still names the right repo; every other stays closed. - if (parseExecutionHostId(state.activeWorkspaceExecutionHostId)?.kind !== 'ssh') { - return null - } - const pairedHubRepos = repoCandidates.filter( - (candidate) => parseExecutionHostId(getRepoExecutionHostId(candidate))?.kind === 'runtime' - ) - return pairedHubRepos.length === 1 ? pairedHubRepos[0] : null + const activeWorkspaceExecutionHostId = state.activeWorkspaceExecutionHostId + if (repoId !== state.activeRepoId || !activeWorkspaceExecutionHostId) { + return repo } - return repo + // The branch below only fires for the active repo, so the host id fully keys it. + let byHost = activeWorkspaceRepoCache.get(state.repos) + if (!byHost) { + byHost = new Map() + activeWorkspaceRepoCache.set(state.repos, byHost) + } + const cacheKey = `${activeWorkspaceExecutionHostId}\u0000${repoId}` + const cached = byHost.get(cacheKey) + if (cached !== undefined) { + return cached + } + const resolved = resolveRepoOnActiveWorkspaceHost(state, repoId, activeWorkspaceExecutionHostId) + byHost.set(cacheKey, resolved) + return resolved } export const useRepoById = (repoId: string | null) => From 9377214b4b135fb562775fad8a093a81293dbf70 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:07:58 -0700 Subject: [PATCH 14/25] perf(renderer): reconcile hydrated workspaces in one store write (#18150) * perf(renderer): reconcile hydrated workspaces in one store write Session hydration reconciled each workspace with its own set(), so a 193-workspace session fanned 193 writes out to every non-React store subscriber and re-spread three whole workspace-keyed maps per workspace. Fold the whole session into one patch, release the string-keyed terminal scroll-intent entries on pane close, and drop the per-workspace/per-tab reconnect debug logs. * fix(test): make the hydration fixture bucket switch exhaustive oxlint --type-aware flags the default arm; naming the editor case clears it. --- ...cile-hydrated-workspace-tab-models.test.ts | 5 +- ...reconcile-hydrated-workspace-tab-models.ts | 11 +- .../app-shell/use-app-startup-hydration.ts | 2 +- .../src/lib/pane-manager/pane-split-close.ts | 7 + .../terminal-scroll-intent-dom-tracking.ts | 3 +- ...rminal-scroll-intent-key-retention.test.ts | 159 ++++++++++++ .../terminal-scroll-intent-key-store.ts | 63 +++++ .../pane-manager/terminal-scroll-intent.ts | 35 ++- .../store-session-terminal-reconnect.test.ts | 7 +- ...ted-workspace-reconciliation-batch.test.ts | 148 +++++++++++ ...drated-workspace-reconciliation-fixture.ts | 242 ++++++++++++++++++ .../slices/tabs/tabs-reconciliation-batch.ts | 55 ++++ .../store/slices/tabs/tabs-reconciliation.ts | 69 +++-- .../store/slices/tabs/tabs-session-actions.ts | 42 ++- .../store/slices/tabs/tabs-slice-contract.ts | 2 + .../terminals/workspace-terminal-reconnect.ts | 19 +- 16 files changed, 805 insertions(+), 64 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-retention.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-store.ts create mode 100644 src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts create mode 100644 src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-fixture.ts create mode 100644 src/renderer/src/store/slices/tabs/tabs-reconciliation-batch.ts diff --git a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts index f4940db6ab5..cf33aed8e3e 100644 --- a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts +++ b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts @@ -2,13 +2,14 @@ import { describe, expect, it, vi } from 'vitest' import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models' describe('reconcileHydratedWorkspaceTabModels', () => { - it('reconciles every workspace the session hydrated, in session order', () => { + it('reconciles every workspace the session hydrated, in session order, in one call', () => { const reconcile = vi.fn() const reconciled = reconcileHydratedWorkspaceTabModels( { tabsByWorktree: { 'wt-a': [], 'wt-b': [], 'wt-c': [] } }, reconcile ) - expect(reconcile.mock.calls.map((call) => call[0])).toEqual(['wt-a', 'wt-b', 'wt-c']) + expect(reconcile).toHaveBeenCalledTimes(1) + expect(reconcile.mock.calls[0]?.[0]).toEqual(['wt-a', 'wt-b', 'wt-c']) expect(reconciled).toEqual(['wt-a', 'wt-b', 'wt-c']) }) diff --git a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts index 84704b3eb2d..3da45244660 100644 --- a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts +++ b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts @@ -3,12 +3,13 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st /** Reconcile every workspace loaded during boot so stale unified-tab subsets converge. */ export function reconcileHydratedWorkspaceTabModels( session: Pick, - reconcileWorktreeTabModel: (worktreeId: string) => unknown + // Why batched: one store write for the whole session instead of one per + // workspace, each fanning out to every non-React store subscriber. + reconcileWorktreeTabModels: (worktreeIds: readonly string[]) => void ): string[] { - const reconciled: string[] = [] - for (const worktreeId of Object.keys(session.tabsByWorktree)) { - reconcileWorktreeTabModel(worktreeId) - reconciled.push(worktreeId) + const reconciled = Object.keys(session.tabsByWorktree) + if (reconciled.length > 0) { + reconcileWorktreeTabModels(reconciled) } return reconciled } diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 447fbfb3780..b50ac86705e 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -198,7 +198,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions) reconcileHydratedWorkspaceTabModels( sessionRead.session, - useAppStore.getState().reconcileWorktreeTabModel + useAppStore.getState().reconcileWorktreeTabModels ) }) await timeRendererStartupStep('prepare-terminal-startup-restoration', () => diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index 80e35a18171..df725156655 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -21,6 +21,7 @@ import { disposeWebgl } from './pane-webgl-renderer' import { clearPendingSplitScrollRestore, scheduleSplitScrollRestore } from './pane-split-scroll' import { reattachWebglIfNeeded } from './pane-webgl-reattach' import { toPublicPane } from './pane-public-view' +import { releaseTerminalScrollIntentKey } from './terminal-scroll-intent-key-store' type MovedPaneSplitState = { pane: ManagedPaneInternal @@ -179,6 +180,12 @@ function teardownManagedPane( const closedLeafId = pane.leafId args.releasePaneIdentity(args.paneId) removePaneContainer(args, pane) + if (reason === 'close') { + // Leaf ids are minted UUIDs and never reused, so a closed leaf's scroll + // intent is unreachable. Detach/retire hand the leaf to a new host, which + // must still be able to restore it. + releaseTerminalScrollIntentKey(closedLeafId) + } const nextActivePaneId = activateReplacementPane(args) applyPaneOpacity(args.panes.values(), nextActivePaneId, args.styleOptions) for (const p of args.panes.values()) { diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts index 9545615705c..81436039d88 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts @@ -8,7 +8,8 @@ import { syncTerminalScrollIntentFromViewport } from './terminal-scroll-intent' import { syncTerminalScrollIntentSoon } from './terminal-scroll-intent-settle' -import type { TerminalScrollIntentKey, TerminalScrollIntentTarget } from './terminal-scroll-intent' +import type { TerminalScrollIntentTarget } from './terminal-scroll-intent' +import type { TerminalScrollIntentKey } from './terminal-scroll-intent-key-store' import { isTerminalScrollIntentRebuildInFlight, onTerminalScrollIntentBufferRebuildComplete diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-retention.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-retention.test.ts new file mode 100644 index 00000000000..e7da03090a1 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-retention.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal } from './pane-manager-types' +import type { TerminalLeafId } from '../../../../shared/stable-pane-id' + +const disposePane = vi.hoisted(() => + vi.fn((pane: ManagedPaneInternal, panes: Map) => { + panes.delete(pane.id) + }) +) + +vi.mock('./pane-tree-ops', () => ({ + captureScrollState: vi.fn(), + findPaneChildren: vi.fn(() => []), + promoteSibling: vi.fn(), + removeDividers: vi.fn(), + safeFit: vi.fn(), + wrapInSplit: vi.fn() +})) +vi.mock('./pane-lifecycle', () => ({ disposePane, openTerminal: vi.fn() })) +vi.mock('./pane-webgl-renderer', () => ({ disposeWebgl: vi.fn() })) +vi.mock('./pane-split-scroll', () => ({ + clearPendingSplitScrollRestore: vi.fn(), + scheduleSplitScrollRestore: vi.fn() +})) +vi.mock('./pane-drag-reorder', () => ({ updateMultiPaneState: vi.fn() })) +vi.mock('./pane-divider', () => ({ applyDividerStyles: vi.fn(), applyPaneOpacity: vi.fn() })) + +import { + closeManagedPane, + detachManagedPaneForExternalMove, + retireManagedPanePreservingPty +} from './pane-split-close' +import { + bindTerminalScrollIntentKey, + markTerminalPinnedViewport, + type TerminalScrollIntentTarget +} from './terminal-scroll-intent' +import { + readTerminalScrollIntentKeyRetention, + releaseTerminalScrollIntentKey +} from './terminal-scroll-intent-key-store' + +function leafIdAt(index: number): TerminalLeafId { + return `11111111-1111-4111-8111-${String(index).padStart(12, '0')}` as TerminalLeafId +} + +/** A pinned (non-bottom) viewport so a keyed intent is actually retained. */ +function createPinnedTerminal(): TerminalScrollIntentTarget { + return { + buffer: { active: { type: 'normal', viewportY: 3, baseY: 40 } } as never, + scrollToBottom: vi.fn(), + scrollToLine: vi.fn() + } +} + +function createPane(id: number, leafId: TerminalLeafId): ManagedPaneInternal { + const container = { + classList: { contains: (className: string) => className === 'pane' }, + dataset: { paneId: String(id), leafId }, + parentElement: null, + remove: vi.fn() + } + return { + id, + leafId, + stablePaneId: leafId, + terminal: { focus: vi.fn() } as never, + container: container as unknown as HTMLElement, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'auto', + gpuRenderingEnabled: false, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: null, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + fitAddon: {} as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +function openPane(id: number, leafId: TerminalLeafId): ManagedPaneInternal { + const pane = createPane(id, leafId) + const terminal = createPinnedTerminal() + bindTerminalScrollIntentKey(terminal, leafId) + markTerminalPinnedViewport(terminal) + return pane +} + +function closeArgs(pane: ManagedPaneInternal, panes: Map) { + return { + paneId: pane.id, + activePaneId: null, + panes, + root: {} as HTMLElement, + styleOptions: {}, + managerOptions: { linkOpenHint: () => '' }, + getDragCallbacks: () => ({}) as never, + releasePaneIdentity: vi.fn(), + setActivePaneId: vi.fn() + } +} + +describe('terminal scroll intent key retention', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns both keyed maps to baseline after 200 pane open/close cycles', () => { + const baseline = readTerminalScrollIntentKeyRetention() + + for (let index = 0; index < 200; index += 1) { + const leafId = leafIdAt(index) + const pane = openPane(index + 1, leafId) + const panes = new Map([[pane.id, pane]]) + // A pinned pane must actually retain its keyed intent while it is open. + expect(readTerminalScrollIntentKeyRetention()).toEqual({ + intents: baseline.intents + 1, + bindings: baseline.bindings + 1 + }) + // Keep a second pane so close is never the last-pane no-op path. + const survivor = createPane(10_000 + index, leafIdAt(10_000 + index)) + panes.set(survivor.id, survivor) + closeManagedPane(closeArgs(pane, panes)) + } + + expect(readTerminalScrollIntentKeyRetention()).toEqual(baseline) + }) + + it('keeps the keyed intent when the leaf is handed to a new host', () => { + const baseline = readTerminalScrollIntentKeyRetention() + + for (const teardown of [detachManagedPaneForExternalMove, retireManagedPanePreservingPty]) { + const leafId = leafIdAt(9000 + Number(teardown === retireManagedPanePreservingPty)) + const pane = openPane(9000, leafId) + const survivor = createPane(9001, leafIdAt(9001)) + const panes = new Map([ + [pane.id, pane], + [survivor.id, survivor] + ]) + + expect(teardown(closeArgs(pane, panes))).toBe(true) + expect(readTerminalScrollIntentKeyRetention().intents).toBe(baseline.intents + 1) + + releaseTerminalScrollIntentKey(leafId) + } + + expect(readTerminalScrollIntentKeyRetention()).toEqual(baseline) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-store.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-store.ts new file mode 100644 index 00000000000..23d2d5d8af1 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-store.ts @@ -0,0 +1,63 @@ +import type { TerminalScrollBufferType } from './terminal-scroll-buffer-snapshot' + +export type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport' +export type TerminalScrollIntentKey = string + +export type TerminalScrollIntent = { + kind: TerminalScrollIntentKind + bufferType: TerminalScrollBufferType + viewportY: number + baseY: number + revision: number +} + +// Keyed by stable leaf id, so a pin outlives the xterm instance that recorded +// it (workspace switch, keyed remount). Unlike the WeakMap-keyed siblings in +// terminal-scroll-intent.ts these hold a strong string key, so a leaf that is +// gone for good must be released explicitly — see releaseTerminalScrollIntentKey. +const terminalScrollIntentByKey = new Map() +const terminalScrollIntentBindingByKey = new Map() + +export function readKeyedTerminalScrollIntent( + key: TerminalScrollIntentKey +): TerminalScrollIntent | undefined { + return terminalScrollIntentByKey.get(key) +} + +export function writeKeyedTerminalScrollIntent( + key: TerminalScrollIntentKey, + intent: TerminalScrollIntent +): void { + terminalScrollIntentByKey.set(key, intent) +} + +export function readKeyedTerminalScrollIntentBinding( + key: TerminalScrollIntentKey +): number | undefined { + return terminalScrollIntentBindingByKey.get(key) +} + +export function writeKeyedTerminalScrollIntentBinding( + key: TerminalScrollIntentKey, + binding: number +): void { + terminalScrollIntentBindingByKey.set(key, binding) +} + +/** + * Drops the keyed intent for a leaf that is gone for good. Only safe on a real + * close: plain disposal (workspace switch, keyed remount, manager destroy) + * relies on these entries to restore the pin when the leaf mounts again. + */ +export function releaseTerminalScrollIntentKey(key: TerminalScrollIntentKey): void { + terminalScrollIntentByKey.delete(key) + terminalScrollIntentBindingByKey.delete(key) +} + +/** Retention probe for the leak tests. */ +export function readTerminalScrollIntentKeyRetention(): { intents: number; bindings: number } { + return { + intents: terminalScrollIntentByKey.size, + bindings: terminalScrollIntentBindingByKey.size + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts index bd0b63611a6..9e1e32fcb81 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts @@ -3,6 +3,17 @@ import { notifyTerminalFollowOutputWaiters } from './terminal-follow-output-waiters' import { isTerminalScrollIntentRebuildInFlight } from './terminal-scroll-intent-rebuild' +import { + readKeyedTerminalScrollIntent, + readKeyedTerminalScrollIntentBinding, + writeKeyedTerminalScrollIntent, + writeKeyedTerminalScrollIntentBinding +} from './terminal-scroll-intent-key-store' +import type { + TerminalScrollIntent, + TerminalScrollIntentKey, + TerminalScrollIntentKind +} from './terminal-scroll-intent-key-store' import { clampTerminalViewportY, isTerminalViewportAtBottom, @@ -11,24 +22,12 @@ import { type TerminalScrollBufferType } from './terminal-scroll-buffer-snapshot' -type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport' - export type TerminalScrollIntentTarget = { buffer?: Parameters[0]['buffer'] scrollToBottom?: () => void scrollToLine?: (line: number) => void } -export type TerminalScrollIntentKey = string - -type TerminalScrollIntent = { - kind: TerminalScrollIntentKind - bufferType: TerminalScrollBufferType - viewportY: number - baseY: number - revision: number -} - export type TerminalStructuralScrollIntentSnapshot = { kind: TerminalScrollIntentKind bufferType: TerminalScrollBufferType @@ -53,8 +52,6 @@ const terminalScrollIntentKeyByTerminal = new WeakMap< TerminalScrollIntentKey >() const terminalScrollIntentKeyBindingByTerminal = new WeakMap() -const terminalScrollIntentByKey = new Map() -const terminalScrollIntentBindingByKey = new Map() let nextTerminalScrollIntentRevision = 1 let nextTerminalScrollIntentKeyBinding = 1 @@ -92,7 +89,7 @@ function writeIntentSnapshot( terminalScrollIntentByTerminal.set(terminal, intent) const key = terminalScrollIntentKeyByTerminal.get(terminal) if (key) { - terminalScrollIntentByKey.set(key, intent) + writeKeyedTerminalScrollIntent(key, intent) } if (kind === 'followOutput') { notifyTerminalFollowOutputWaiters(terminal) @@ -106,7 +103,7 @@ function readStoredIntent(terminal: TerminalScrollIntentTarget): TerminalScrollI return terminalIntent } const key = terminalScrollIntentKeyByTerminal.get(terminal) - return key ? terminalScrollIntentByKey.get(key) : undefined + return key ? readKeyedTerminalScrollIntent(key) : undefined } export function bindTerminalScrollIntentKey( @@ -120,8 +117,8 @@ export function bindTerminalScrollIntentKey( const binding = nextTerminalScrollIntentKeyBinding nextTerminalScrollIntentKeyBinding += 1 terminalScrollIntentKeyBindingByTerminal.set(terminal, binding) - terminalScrollIntentBindingByKey.set(key, binding) - const existing = terminalScrollIntentByKey.get(key) + writeKeyedTerminalScrollIntentBinding(key, binding) + const existing = readKeyedTerminalScrollIntent(key) if (existing) { terminalScrollIntentByTerminal.set(terminal, existing) } @@ -137,7 +134,7 @@ export function isTerminalScrollIntentKeyBindingCurrent( } return ( terminalScrollIntentKeyBindingByTerminal.get(terminal) === - terminalScrollIntentBindingByKey.get(key) + readKeyedTerminalScrollIntentBinding(key) ) } diff --git a/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts b/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts index 81c6350680d..c0f05124eda 100644 --- a/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts +++ b/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts @@ -599,9 +599,12 @@ describe('reconnectPersistedTerminals', () => { } const sshConnectionStates = new Map([[targetId, currentAuthorityState]]) const originalGet = sshConnectionStates.get.bind(sshConnectionStates) + // Rotate from the first read after the entry authority check, so the + // pre-publication check sees the new generation whatever number of + // intermediate reads the reconnect pass happens to make. sshConnectionStates.get = ((key: string) => { authorityReads += 1 - return authorityReads >= 4 ? rotatedAuthorityState : originalGet(key) + return authorityReads >= 2 ? rotatedAuthorityState : originalGet(key) }) as typeof sshConnectionStates.get store.setState({ repos: [ @@ -636,7 +639,7 @@ describe('reconnectPersistedTerminals', () => { }) const after = store.getState() - expect(authorityReads).toBeGreaterThanOrEqual(4) + expect(authorityReads).toBeGreaterThanOrEqual(2) expect(after.tabsByWorktree).toBe(before.tabsByWorktree) expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId) expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds) diff --git a/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts b/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts new file mode 100644 index 00000000000..e6a7bfe7002 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { createTabsSliceMockApi } from '../tabs-slice-test-harness' +import { createTestStore } from '../store-test-helpers' +import { + buildHydratedWorkspaceFixture, + HYDRATED_TAB_COUNT, + HYDRATED_WORKSPACE_COUNT, + RECONCILIATION_WRITABLE_KEYS +} from './hydrated-workspace-reconciliation-fixture' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +createTabsSliceMockApi() + +// Approximates the renderer's live non-React store-subscriber population. +const SUBSCRIBER_COUNT = 1200 + +type Store = ReturnType + +function hydrateFixtureStore(): { store: Store; workspaceIds: string[] } { + const fixture = buildHydratedWorkspaceFixture() + expect(fixture.workspaceIds).toHaveLength(HYDRATED_WORKSPACE_COUNT) + expect(fixture.tabCount).toBe(HYDRATED_TAB_COUNT) + const store = createTestStore() + store.setState(fixture.state) + return { store, workspaceIds: fixture.workspaceIds } +} + +function countNotifications(store: Store, run: () => void): number { + let notifications = 0 + const unsubscribes = Array.from({ length: SUBSCRIBER_COUNT }, () => + store.subscribe(() => { + notifications += 1 + }) + ) + try { + run() + } finally { + for (const unsubscribe of unsubscribes) { + unsubscribe() + } + } + return notifications +} + +/** Group ids for restored legacy terminals are minted, so pin them to compare states. */ +function withDeterministicUuids(run: () => T): T { + let counter = 0 + const spy = vi.spyOn(globalThis.crypto, 'randomUUID').mockImplementation(() => { + counter += 1 + return `00000000-0000-4000-8000-${String(counter).padStart(12, '0')}` + }) + try { + return run() + } finally { + spy.mockRestore() + } +} + +function reconciliationSnapshot(store: Store): Record { + const state = store.getState() as unknown as Record + return Object.fromEntries(RECONCILIATION_WRITABLE_KEYS.map((key) => [key, state[key]])) +} + +describe('whole-session workspace tab-model reconciliation', () => { + it('collapses a 193-workspace hydration to one store write', () => { + const perWorkspace = hydrateFixtureStore() + const perWorkspaceNotifications = countNotifications(perWorkspace.store, () => { + for (const worktreeId of perWorkspace.workspaceIds) { + perWorkspace.store.getState().reconcileWorktreeTabModel(worktreeId) + } + }) + + const batched = hydrateFixtureStore() + const batchedNotifications = countNotifications(batched.store, () => { + batched.store.getState().reconcileWorktreeTabModels(batched.workspaceIds) + }) + + // The fixture must actually be write-heavy, or the collapse proves nothing. + expect(perWorkspaceNotifications / SUBSCRIBER_COUNT).toBeGreaterThan(100) + expect(batchedNotifications).toBe(SUBSCRIBER_COUNT) + }) + + it('leaves the store byte-identical to the per-workspace path', () => { + const perWorkspace = hydrateFixtureStore() + withDeterministicUuids(() => { + for (const worktreeId of perWorkspace.workspaceIds) { + perWorkspace.store.getState().reconcileWorktreeTabModel(worktreeId) + } + }) + const expected = reconciliationSnapshot(perWorkspace.store) + + const batched = hydrateFixtureStore() + withDeterministicUuids(() => { + batched.store.getState().reconcileWorktreeTabModels(batched.workspaceIds) + }) + const actual = reconciliationSnapshot(batched.store) + + expect(actual).toEqual(expected) + // Object key order is observable through Object.keys/entries iteration in + // selectors and session serialization, so equal values are not enough. + for (const key of RECONCILIATION_WRITABLE_KEYS) { + const actualValue = actual[key] + const expectedValue = expected[key] + if (actualValue && typeof actualValue === 'object' && !Array.isArray(actualValue)) { + expect([key, Object.keys(actualValue)]).toEqual([key, Object.keys(expectedValue as object)]) + } + } + }) + + it('re-reconciling the batched result is a no-op, as it is for the per-workspace path', () => { + const { store, workspaceIds } = hydrateFixtureStore() + store.getState().reconcileWorktreeTabModels(workspaceIds) + const settled = reconciliationSnapshot(store) + + const notifications = countNotifications(store, () => { + store.getState().reconcileWorktreeTabModels(workspaceIds) + }) + + expect(notifications).toBe(0) + expect(reconciliationSnapshot(store)).toEqual(settled) + }) + + it('indexes openFiles once instead of rescanning it per workspace', () => { + const { store, workspaceIds } = hydrateFixtureStore() + const openFiles = store.getState().openFiles + let scans = 0 + store.setState({ + openFiles: new Proxy(openFiles, { + get(target, property, receiver) { + if (property === 'filter') { + scans += 1 + } + return Reflect.get(target, property, receiver) + } + }) + }) + + store.getState().reconcileWorktreeTabModels(workspaceIds) + + expect(scans).toBe(0) + }) +}) diff --git a/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-fixture.ts b/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-fixture.ts new file mode 100644 index 00000000000..30ba7be1633 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-fixture.ts @@ -0,0 +1,242 @@ +import type { AppState } from '../../types' +import type { Tab, TabGroup } from '../../../../../shared/tab-types' +import type { TerminalTab } from '../../../../../shared/terminal-tab-types' +import type { OpenFile } from '../editor' + +/** + * Session shape measured on a real heavy profile: 193 workspaces / 382 tabs, + * spread over every reconciliation outcome (no-op, dropped tab, restored legacy + * runtime terminal, orphan sweep, editor tab) so a whole-session fold exercises + * both the workspace-scoped maps and the store-global ones workspaces share. + */ +export const HYDRATED_WORKSPACE_COUNT = 193 +export const HYDRATED_TAB_COUNT = 382 + +const BUCKETS = ['stable', 'stale', 'legacy', 'orphan', 'editor'] as const +type Bucket = (typeof BUCKETS)[number] + +export type HydratedWorkspaceFixture = { + workspaceIds: string[] + tabCount: number + state: Partial +} + +type FixtureDraft = { + unifiedTabsByWorktree: Record + groupsByWorktree: Record + activeGroupIdByWorktree: Record + tabsByWorktree: Record + activeTabIdByWorktree: Record + tabBarOrderByWorktree: Record + ptyIdsByTabId: Record + pendingReconnectPtyIdByTabId: Record + unreadTerminalTabs: Record + cacheTimerByKey: Record + openFiles: OpenFile[] +} + +function unifiedTab(id: string, worktreeId: string, groupId: string, over: Partial): Tab { + return { + id, + entityId: id, + groupId, + worktreeId, + contentType: 'terminal', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...over + } +} + +function runtimeTab(id: string, worktreeId: string, over: Partial): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...over + } +} + +function setGroup(draft: FixtureDraft, worktreeId: string, groupId: string, tabs: Tab[]): void { + draft.unifiedTabsByWorktree[worktreeId] = tabs + draft.groupsByWorktree[worktreeId] = [ + { + id: groupId, + worktreeId, + activeTabId: tabs[0]?.id ?? null, + tabOrder: tabs.map((tab) => tab.id) + } + ] +} + +/** Live terminal rows that reconcile to a no-op patch. */ +function buildStableWorkspace(draft: FixtureDraft, worktreeId: string, groupId: string): number { + const live = `${worktreeId}#live` + setGroup(draft, worktreeId, groupId, [unifiedTab(live, worktreeId, groupId, {})]) + draft.tabsByWorktree[worktreeId] = [runtimeTab(live, worktreeId, {})] + return 1 +} + +/** A unified row with no runtime backing: dropped, and its store-global unread flag with it. */ +function buildStaleWorkspace(draft: FixtureDraft, worktreeId: string, groupId: string): number { + const live = `${worktreeId}#live` + const stale = `${worktreeId}#stale` + setGroup(draft, worktreeId, groupId, [ + unifiedTab(live, worktreeId, groupId, {}), + unifiedTab(stale, worktreeId, groupId, { sortOrder: 1 }) + ]) + draft.tabsByWorktree[worktreeId] = [runtimeTab(live, worktreeId, {})] + draft.unreadTerminalTabs[stale] = true + return 2 +} + +/** Live only in a reconnect map: restored into the unified model, minting a group and layout. */ +function buildLegacyWorkspace(draft: FixtureDraft, worktreeId: string): number { + const legacy = `${worktreeId}#legacy` + draft.tabsByWorktree[worktreeId] = [runtimeTab(legacy, worktreeId, { ptyId: null })] + draft.pendingReconnectPtyIdByTabId[legacy] = `session-${legacy}` + draft.ptyIdsByTabId[legacy] = [] + draft.unifiedTabsByWorktree[worktreeId] = [] + draft.groupsByWorktree[worktreeId] = [] + draft.activeTabIdByWorktree[worktreeId] = legacy + return 1 +} + +/** No PTY and no unified row: swept, which writes the store-global per-tab maps. */ +function buildOrphanWorkspace(draft: FixtureDraft, worktreeId: string): number { + const orphan = `${worktreeId}#orphan` + draft.tabsByWorktree[worktreeId] = [runtimeTab(orphan, worktreeId, { ptyId: null })] + draft.tabBarOrderByWorktree[worktreeId] = [orphan] + draft.activeTabIdByWorktree[worktreeId] = orphan + draft.cacheTimerByKey[`${orphan}:git`] = 1 + draft.unifiedTabsByWorktree[worktreeId] = [] + draft.groupsByWorktree[worktreeId] = [] + return 1 +} + +/** One editor tab backed by an open file, one that is not — the openFiles rescan path. */ +function buildEditorWorkspace(draft: FixtureDraft, worktreeId: string, groupId: string): number { + const fileId = `${worktreeId}#file` + const ghost = `${worktreeId}#ghost-file` + setGroup(draft, worktreeId, groupId, [ + unifiedTab(fileId, worktreeId, groupId, { contentType: 'editor' }), + unifiedTab(ghost, worktreeId, groupId, { contentType: 'editor', sortOrder: 1 }) + ]) + draft.tabsByWorktree[worktreeId] = [] + draft.openFiles.push({ + id: fileId, + filePath: `/tmp/${worktreeId}/${fileId}`, + relativePath: fileId, + worktreeId, + language: 'typescript', + isDirty: false, + mode: 'edit' + }) + return 2 +} + +function buildWorkspace( + draft: FixtureDraft, + bucket: Bucket, + worktreeId: string, + groupId: string +): number { + switch (bucket) { + case 'stable': + return buildStableWorkspace(draft, worktreeId, groupId) + case 'stale': + return buildStaleWorkspace(draft, worktreeId, groupId) + case 'legacy': + return buildLegacyWorkspace(draft, worktreeId) + case 'orphan': + return buildOrphanWorkspace(draft, worktreeId) + case 'editor': + return buildEditorWorkspace(draft, worktreeId, groupId) + } +} + +/** Tops the fixture up to the measured tab count with extra live rows. */ +function padToTabCount(draft: FixtureDraft, workspaceIds: string[], missing: number): number { + let added = 0 + for (let slot = 0; added < missing; slot += 1) { + const worktreeId = workspaceIds[slot % workspaceIds.length] + const group = draft.groupsByWorktree[worktreeId]?.[0] + if (!group || draft.tabsByWorktree[worktreeId] == null) { + continue + } + const id = `${worktreeId}#extra-${slot}` + draft.unifiedTabsByWorktree[worktreeId].push( + unifiedTab(id, worktreeId, group.id, { sortOrder: 10 + slot }) + ) + draft.tabsByWorktree[worktreeId].push(runtimeTab(id, worktreeId, { sortOrder: 10 + slot })) + group.tabOrder.push(id) + added += 1 + } + return added +} + +export function buildHydratedWorkspaceFixture( + workspaceCount = HYDRATED_WORKSPACE_COUNT, + tabCountTarget = HYDRATED_TAB_COUNT +): HydratedWorkspaceFixture { + const draft: FixtureDraft = { + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + tabsByWorktree: {}, + activeTabIdByWorktree: {}, + tabBarOrderByWorktree: {}, + ptyIdsByTabId: {}, + pendingReconnectPtyIdByTabId: {}, + unreadTerminalTabs: {}, + cacheTimerByKey: {}, + openFiles: [] + } + const workspaceIds: string[] = [] + let tabCount = 0 + + for (let index = 0; index < workspaceCount; index += 1) { + const worktreeId = `repo1::/tmp/w${index}` + const groupId = `g-${index}` + workspaceIds.push(worktreeId) + draft.activeGroupIdByWorktree[worktreeId] = groupId + tabCount += buildWorkspace(draft, BUCKETS[index % BUCKETS.length], worktreeId, groupId) + } + tabCount += padToTabCount(draft, workspaceIds, Math.max(0, tabCountTarget - tabCount)) + + return { workspaceIds, tabCount, state: { ...draft } } +} + +/** Every top-level key the two reconciliation patch producers can write. */ +export const RECONCILIATION_WRITABLE_KEYS = [ + 'unifiedTabsByWorktree', + 'groupsByWorktree', + 'activeGroupIdByWorktree', + 'layoutByWorktree', + 'unreadTerminalTabs', + 'tabsByWorktree', + 'ptyIdsByTabId', + 'runtimePaneTitlesByTabId', + 'expandedPaneByTabId', + 'canExpandPaneByTabId', + 'terminalLayoutsByTabId', + 'pendingStartupByTabId', + 'pendingInitialCwdByTabId', + 'pendingSetupSplitByTabId', + 'pendingIssueCommandSplitByTabId', + 'automaticAgentResumeClaimsByTabId', + 'nativeChatLaunchPromptByTabId', + 'nativeChatLaunchDraftByTabId', + 'tabBarOrderByWorktree', + 'cacheTimerByKey', + 'activeTabIdByWorktree', + 'activeTabId' +] as const satisfies readonly (keyof AppState)[] diff --git a/src/renderer/src/store/slices/tabs/tabs-reconciliation-batch.ts b/src/renderer/src/store/slices/tabs/tabs-reconciliation-batch.ts new file mode 100644 index 00000000000..1eb11064203 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-reconciliation-batch.ts @@ -0,0 +1,55 @@ +import type { AppState } from '../../types' + +/** + * Scratch shared by one multi-workspace reconciliation fold. + * + * Reconciling N workspaces one `set()` at a time re-spreads the same + * workspace-keyed maps N times and rescans `openFiles` N times. A batch lets + * the fold clone each map once and then write into its own private draft, and + * index `openFiles` once — `openFiles` is never written by reconciliation, so + * the index stays valid for the whole fold. + */ +export type WorktreeTabModelReconciliationBatch = { + /** Top-level `AppState` keys this fold already cloned and therefore owns. */ + readonly ownedStateKeys: Set + readonly liveEditorIdsByWorktree: ReadonlyMap> +} + +export const EMPTY_LIVE_EDITOR_IDS: ReadonlySet = new Set() + +export function createWorktreeTabModelReconciliationBatch( + state: Pick +): WorktreeTabModelReconciliationBatch { + const liveEditorIdsByWorktree = new Map>() + for (const file of state.openFiles) { + let ids = liveEditorIdsByWorktree.get(file.worktreeId) + if (!ids) { + ids = new Set() + liveEditorIdsByWorktree.set(file.worktreeId, ids) + } + ids.add(file.id) + } + return { ownedStateKeys: new Set(), liveEditorIdsByWorktree } +} + +/** + * Sets one workspace entry, mutating the batch's own draft once it owns the + * map. Insertion order matches the spread it replaces: an existing key keeps + * its slot, a new key is appended. + */ +export function writeBatchedWorkspaceRecordEntry( + current: Record, + stateKey: string, + worktreeId: string, + // `undefined` is accepted because the spread this replaces also stored it. + value: T | undefined, + batch: WorktreeTabModelReconciliationBatch | undefined +): Record { + if (batch?.ownedStateKeys.has(stateKey)) { + ;(current as Record)[worktreeId] = value + return current + } + const next = { ...current, [worktreeId]: value } as Record + batch?.ownedStateKeys.add(stateKey) + return next +} diff --git a/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts b/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts index 1be6199b55c..72d7be7195e 100644 --- a/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts +++ b/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts @@ -7,6 +7,11 @@ import { getOrphanTerminalIds, terminalTabHasReconnectablePty } from '../terminal-orphan-helpers' +import { + EMPTY_LIVE_EDITOR_IDS, + writeBatchedWorkspaceRecordEntry, + type WorktreeTabModelReconciliationBatch +} from './tabs-reconciliation-batch' export type WorktreeTabModelReconciliation = { patch: Partial @@ -14,9 +19,15 @@ export type WorktreeTabModelReconciliation = { activeRenderableTabId: string | null } +/** + * Pure projection of one workspace's reconciliation patch. Passing `batch` + * lets a multi-workspace fold reuse its own map drafts and `openFiles` index; + * the projected values are identical either way. + */ export function projectWorktreeTabModelReconciliation( state: AppState, - worktreeId: string + worktreeId: string, + batch?: WorktreeTabModelReconciliationBatch ): WorktreeTabModelReconciliation { const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] const groups = state.groupsByWorktree[worktreeId] ?? [] @@ -93,9 +104,13 @@ export function projectWorktreeTabModelReconciliation( const liveTerminalIds = new Set( runtimeTerminalTabs.filter((tab) => !orphanTerminalIds.has(tab.id)).map((tab) => tab.id) ) - const liveEditorIds = new Set( - state.openFiles.filter((file) => file.worktreeId === worktreeId).map((file) => file.id) - ) + // Why batched: the unbatched scan is O(openFiles) per workspace, so a + // whole-session reconcile is O(workspaces x openFiles). + const liveEditorIds: ReadonlySet = batch + ? (batch.liveEditorIdsByWorktree.get(worktreeId) ?? EMPTY_LIVE_EDITOR_IDS) + : new Set( + state.openFiles.filter((file) => file.worktreeId === worktreeId).map((file) => file.id) + ) const liveBrowserIds = new Set( (state.browserTabsByWorktree[worktreeId] ?? []).map((browserTab) => browserTab.id) ) @@ -177,7 +192,10 @@ export function projectWorktreeTabModelReconciliation( ) let nextUnreadTerminalTabs = state.unreadTerminalTabs if (droppedTerminalEntityIds.length > 0) { - const copy = { ...state.unreadTerminalTabs } + // A batch that already owns this map published it in an earlier patch, so + // draining further entries in place needs no second patch entry. + const owned = batch?.ownedStateKeys.has('unreadTerminalTabs') === true + const copy = owned ? state.unreadTerminalTabs : { ...state.unreadTerminalTabs } let changed = false for (const entityId of droppedTerminalEntityIds) { if (copy[entityId]) { @@ -187,25 +205,44 @@ export function projectWorktreeTabModelReconciliation( } if (changed) { nextUnreadTerminalTabs = copy + batch?.ownedStateKeys.add('unreadTerminalTabs') } } patch = { - unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: validTabs }, - groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: nextGroups }, - activeGroupIdByWorktree: { - ...state.activeGroupIdByWorktree, - [worktreeId]: nextActiveGroupId - }, + unifiedTabsByWorktree: writeBatchedWorkspaceRecordEntry( + state.unifiedTabsByWorktree, + 'unifiedTabsByWorktree', + worktreeId, + validTabs, + batch + ), + groupsByWorktree: writeBatchedWorkspaceRecordEntry( + state.groupsByWorktree, + 'groupsByWorktree', + worktreeId, + nextGroups, + batch + ), + activeGroupIdByWorktree: writeBatchedWorkspaceRecordEntry( + state.activeGroupIdByWorktree, + 'activeGroupIdByWorktree', + worktreeId, + nextActiveGroupId, + batch + ), ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs ? { unreadTerminalTabs: nextUnreadTerminalTabs } : {}), ...(nextLayout && layoutChanged ? { - layoutByWorktree: { - ...state.layoutByWorktree, - // Why: restored runtime terminals need a concrete leaf before activation. - [worktreeId]: nextLayout - } + // Why: restored runtime terminals need a concrete leaf before activation. + layoutByWorktree: writeBatchedWorkspaceRecordEntry( + state.layoutByWorktree, + 'layoutByWorktree', + worktreeId, + nextLayout, + batch + ) } : {}), ...(orphanTerminalIds.size > 0 diff --git a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts index 1c2d22c0ce7..e33f1f533cd 100644 --- a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts @@ -8,6 +8,8 @@ import { } from '../degraded-repo-worktree-validity' import { buildHydratedTabState } from '../tabs-hydration' import { projectWorktreeTabModelReconciliation } from './tabs-reconciliation' +import { createWorktreeTabModelReconciliationBatch } from './tabs-reconciliation-batch' +import type { AppState } from '../../types' function replaceWorkspaceRecordKeys( current: Record, @@ -20,11 +22,49 @@ function replaceWorkspaceRecordKeys( } } +/** + * Folds every workspace's reconciliation into one patch. Equivalent to + * applying each patch with its own `set()`: each projection reads the state + * left by its predecessors (they share `unreadTerminalTabs` and the orphan + * cleanup maps), only the store write and subscriber fanout are deferred. + */ +function projectWorktreeTabModelReconciliations( + state: AppState, + worktreeIds: readonly string[] +): Partial { + const batch = createWorktreeTabModelReconciliationBatch(state) + // Private working copy so batch-owned maps can be written in place. + const working = { ...state } + const merged: Partial = {} + for (const worktreeId of worktreeIds) { + const { patch } = projectWorktreeTabModelReconciliation(working, worktreeId, batch) + if (Object.keys(patch).length === 0) { + continue + } + Object.assign(merged, patch) + Object.assign(working, patch) + } + return merged +} + export function createTabsSessionActions( set: TabsSliceSet, get: TabsSliceGet -): Pick { +): Pick< + TabsSlice, + 'reconcileWorktreeTabModel' | 'reconcileWorktreeTabModels' | 'hydrateTabsSession' +> { return { + reconcileWorktreeTabModels: (worktreeIds) => { + if (worktreeIds.length === 0) { + return + } + const patch = projectWorktreeTabModelReconciliations(get(), worktreeIds) + if (Object.keys(patch).length > 0) { + set(patch) + } + }, + reconcileWorktreeTabModel: (worktreeId) => { const reconciliation = projectWorktreeTabModelReconciliation(get(), worktreeId) if (Object.keys(reconciliation.patch).length > 0) { diff --git a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts index 456d5892255..81d27ff54d5 100644 --- a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts +++ b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts @@ -152,6 +152,8 @@ export type TabsSlice = { renderableTabCount: number activeRenderableTabId: string | null } + /** Reconciles many workspaces through one store write instead of one per workspace. */ + reconcileWorktreeTabModels: (worktreeIds: readonly string[]) => void hydrateTabsSession: ( session: WorkspaceSessionState, options?: WorkspaceSessionHydrationOptions diff --git a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts index 5b1e0d39f93..ea0c0545998 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts @@ -50,18 +50,6 @@ export function createWorkspaceTerminalReconnectActions( const repoById = buildByIdIndex(get().repos) for (const worktreeId of ids) { const tabs = tabsByWorktree[worktreeId] ?? [] - const worktree = worktreeById.get(worktreeId) - const repo = worktree ? (repoById.get(worktree.repoId) ?? null) : null - // Why: only allow deferred reattach when the SSH connection is active; reattaching to a not-yet-connected relay (deferred/passphrase targets) would fail. - const sshTargetId = options?.directSshAuthority.targetId ?? repo?.connectionId ?? null - const sshState = sshTargetId ? get().sshConnectionStates.get(sshTargetId) : null - const sshConnected = sshTargetId != null && sshState?.status === 'connected' - const supportsDeferredReattach = options - ? sshConnected - : !repo?.connectionId || sshConnected - console.debug( - `[reconnect-terminals] worktree=${worktreeId} connectionId=${repo?.connectionId} sshStatus=${sshState?.status} supportsDeferredReattach=${supportsDeferredReattach}` - ) const targetTabIds = pendingReconnectTabByWorktree[worktreeId] ?? [] const tabsToReconnect: TerminalTab[] = targetTabIds.length > 0 @@ -84,11 +72,8 @@ export function createWorkspaceTerminalReconnectActions( ? undefined : pendingPtyId const hasLeafMappings = Object.keys(leafPtyMap).length > 0 - // Why: publish live PTY hints before mount; pty-connection reattaches later. - console.debug( - `[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}` - ) - // Why: populate ptyIdsByTabId so the sessions status segment maps daemon IDs to tabs; otherwise all sessions look like orphans until the pane mounts. + // Why: publish live PTY hints before mount (pty-connection reattaches later) so the + // sessions status segment maps daemon IDs to tabs; otherwise all sessions look like orphans until the pane mounts. // A row whose tab.ptyId went to the canonical row has no tab-level id left, but its own leaf PTYs still need advertising. const allPtyIds = hasLeafMappings ? (Object.values(leafPtyMap).filter(Boolean) as string[]) From 53f105827bc1949c3c36838052ecd227b962d7f4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:08:01 -0700 Subject: [PATCH 15/25] perf(windows): stop asking the process table for memory, and share one projection per snapshot (#18151) Two costs on the Windows process-table hot path, plus the EDR doc that described neither of them accurately. 1. The snapshot set `ProcessDataFlag.Memory` and surfaced `memoryBytes`, which nothing read. The addon serves that flag with a second `OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)` and a `GetProcessMemoryInfo` per process (process.cc:47-63), so the flag was one wasted handle per process per snapshot. 2. The shared TTL cache gave every pane the same native rows array, but each pane still ran `native.map(toProcessRow)` over the whole table, rebuilt a `childrenByPpid` Map from scratch, and did two linear scans. The `.map()` also handed `getProcessTableIndex` a new array each call, defeating the POSIX memo by construction. Both now cache per snapshot identity, and the POSIX resolver drops its duplicate descendant walk. `getProcessTableIndex` / `buildProcessTableIndex` are generic over the row shape so the Windows rows reuse the existing pass instead of a parallel one. No behavior change: same rows in, same rows out, same descendant ordering and same has-children answers. --- docs/reference/windows-edr-posture.md | 64 ++++--- docs/reference/windows-process-enumeration.md | 15 +- .../providers/agent-foreground-process.ts | 43 ++--- ...foreground-process-inspection-cost.test.ts | 156 ++++++++++++++++++ .../windows-foreground-process-rows.ts | 71 ++++---- .../windows/windows-process-table-cim-scan.ts | 2 - .../windows/windows-process-table.test.ts | 23 +-- src/main/windows/windows-process-table.ts | 34 ++-- src/shared/process-table-snapshot.ts | 65 ++++++-- 9 files changed, 346 insertions(+), 127 deletions(-) create mode 100644 src/main/providers/windows-foreground-process-inspection-cost.test.ts diff --git a/docs/reference/windows-edr-posture.md b/docs/reference/windows-edr-posture.md index d44aaf33e38..06eb2d5ff9b 100644 --- a/docs/reference/windows-edr-posture.md +++ b/docs/reference/windows-edr-posture.md @@ -72,12 +72,20 @@ behavioural engine can be expected to score it low. ### Every process gets a handle, on a timer -`src/main/windows/windows-process-table.ts` takes a Toolhelp32 snapshot under one -of two flag sets. Identity (`None | CreationTime`) answers pid/ppid/name from the -snapshot alone and opens nothing; the detailed set adds `CommandLine`, which -costs one `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` per process. `Memory` -is retired — it took a second handle carrying `PROCESS_VM_READ` and never read -through it. +`src/main/windows/windows-process-table.ts` takes a Toolhelp32 snapshot under +**one** flag set, `CommandLine | CreationTime`, shared by every caller. pid, ppid +and name come out of the snapshot itself and open nothing. `CommandLine` is what +opens a handle: the addon calls `GetProcessCommandLine` per process, which opens +`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and walks the PEB with three +`ReadProcessMemory` calls (`src/process_commandline.cc:32,41-47` in the vendored +`@vscode/windows-process-tree` 0.8.0 source that `config/patches/` patches). + +`Memory` is retired as of this change, and that is a real reduction: it made +`GetProcessMemoryUsage` open a **second** `PROCESS_QUERY_INFORMATION | +PROCESS_VM_READ` handle per process for a `GetProcessMemoryInfo` call whose +result no caller read (`src/process.cc:47-63`). Dropping it halves the handles +opened per snapshot. It does not remove the remote memory read, because the +command line still performs one. It exists because seven independent readers used to fork `powershell.exe` for a `Get-CimInstance Win32_Process` scan. That cost, measured: a PowerShell @@ -90,23 +98,41 @@ panes multiplied it (#15036). The native snapshot answers the same question in See [`windows-process-enumeration.md`](./windows-process-enumeration.md). -Asking for fewer fields is cheaper, and since the split the module does: one -cache per flag set, so teardown identity and the session owner probe open no -handle at all (6.3 ms p50) while only the callers that read a command line pay -for one (12.3 ms p50, at 492 processes). Each cache still single-flights within -itself, and one gate serializes the native reads because the vendored wrapper -coalesces the flags of two overlapping calls. +Asking for fewer fields is cheaper, and the module now asks for the smallest set +that still answers every caller. There is **no** per-flag-set cache split: one +TTL-cached snapshot serves everyone, deliberately, because a split would restore +the per-pane fan-out the cache exists to remove — a 32-wide teardown has to +collapse into one scan. So the cheap identity-only read is not something any +caller can select; every read pays for `CommandLine`. An earlier revision of this +file described a two-cache design with 6.3 ms / 12.3 ms p50 figures at 492 +processes. That design is not in the tree and those numbers describe no code +path here; the figures that do apply are the module's own, in +[`windows-process-enumeration.md`](./windows-process-enumeration.md). **How an EDR reads it:** a cross-process handle plus a remote memory read against every process on the box, repeating on a cadence, is the read half of the telemetry that credential dumping and process injection produce. MDE surfaced it -as "suspicious memory activity". The memory read is gone: the command line now -comes from the kernel, through `NtQueryInformationProcess`'s -`ProcessCommandLineInformation` class, which needs only -`PROCESS_QUERY_LIMITED_INFORMATION`. `ReadProcessMemory` is absent from the -compiled addon, asserted against the binary's import table because the published -prebuild loads fine and emits byte-identical strings. What is left to declare to -administrators is the per-process handle itself. +as "suspicious memory activity". + +**That signal is still present.** An earlier revision of this file claimed the +command line "now comes from the kernel" through `NtQueryInformationProcess`'s +`ProcessCommandLineInformation` class, needing only +`PROCESS_QUERY_LIMITED_INFORMATION`, and that `ReadProcessMemory` was absent from +the compiled addon. None of that is true of the code we ship. +`process_commandline.cc` calls `NtQueryInformationProcess` with +`ProcessBasicInformation` only — to locate the PEB — and then issues three +`ReadProcessMemory` calls against a `PROCESS_VM_READ` handle to read the PEB, the +`RTL_USER_PROCESS_PARAMETERS`, and the command-line buffer. Nothing asserts an +import table, and no such assertion would pass. + +What this change did remove is the `Memory` flag's second handle and its +`GetProcessMemoryInfo` call, so the per-process handle count per snapshot halves. +What remains to declare to administrators is unchanged in kind: one +`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` handle and a PEB read against every +process on the box, at the shared snapshot's cadence. Moving to +`ProcessCommandLineInformation` (Windows 8.1+, `PROCESS_QUERY_LIMITED_INFORMATION` +only) would genuinely retire the remote read, but it is an addon patch nobody has +written; treat it as unclaimed work, not as shipped. ### Encoded, policy-bypassing PowerShell diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md index ef1faf5237c..87ac2a97fb1 100644 --- a/docs/reference/windows-process-enumeration.md +++ b/docs/reference/windows-process-enumeration.md @@ -40,6 +40,15 @@ Measured on Windows 11 with 1050 processes (p50 / p95): | + memory + command line | 30.6 ms | 33.7 ms | | `Get-CimInstance` via PowerShell | 706 ms | 723 ms | +Those are the module's published figures. The flag set this module actually +requests is `CommandLine | CreationTime` — **not** `Memory`, which cost a second +`OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)` plus +`GetProcessMemoryInfo` per process (`src/process.cc:47-63`) for a value nothing +read. Dropping it halves the handles a snapshot opens. The remaining set sits +between the two rows above and has not been measured separately; on a real +Windows host, `Get-Counter '\Process(Orca)\Handle Count'` sampled across a +snapshot cadence is the check. + Those CIM numbers are from a 1050-process host. The scan scales with process count: on a 1486-process Windows SSH host it measured **1.36 s** and produced **4.8 MiB** of JSON, against the fallback's 3 s and 8 MiB limits. Both limits @@ -220,11 +229,13 @@ ownership, and CPU accounting in the memory collector — still reads it through its own query. Those callers are not migrated. Committed private bytes have no equivalent either, and the one memory value the -snapshot does carry is unusable for the sizes Orca now sees: `process.cc` stores +snapshot _can_ carry is unusable for the sizes Orca now sees: `process.cc` stores `pmc.WorkingSetSize` into a `DWORD`, so anything above 4 GB wraps. That is the second reason `windows-process-resource-collector.ts` still runs its own `Get-CimInstance` sweep — it needs `PageFileUsage` (commit) and the CPU-time -counters in the same pass. Migrating it to the native table would cost both. +counters in the same pass. Migrating it to the native table would cost both, and +it is why this module no longer sets the `Memory` flag at all: the field had no +reader, and asking for it opened a handle per process on every snapshot. Start time is a proxy for identity, not identity. The durable answer for the process trees Orca itself spawns is an inherited handle: a job object names the diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index d244e0100dc..2000d35261c 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -1,7 +1,9 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' import { + collectDescendantsFromIndex, getFreshProcessTableSnapshot, + getProcessTableIndex, getProcessTableSnapshot, type ProcessTableRow } from '../../shared/process-table-snapshot' @@ -43,29 +45,6 @@ type ShellForegroundConfirmationOptions = { | Promise | null> } -function collectDescendants( - rows: Row[], - rootPid: number -): (Row & { depth: number })[] { - const childrenByParent = new Map() - for (const row of rows) { - const children = childrenByParent.get(row.ppid) ?? [] - children.push(row) - childrenByParent.set(row.ppid, children) - } - - const descendants: (Row & { depth: number })[] = [] - const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 })) - while (stack.length > 0) { - const { row, depth } = stack.pop()! - descendants.push({ ...row, depth }) - for (const child of childrenByParent.get(row.pid) ?? []) { - stack.push({ row: child, depth: depth + 1 }) - } - } - return descendants -} - function commandExecutable(command: string): string { const trimmed = command.trim().replace(/^[-]/, '') if (trimmed.startsWith('"') || trimmed.startsWith("'")) { @@ -97,12 +76,12 @@ export async function confirmShellForegroundProcess( } } try { - const rows = await getFreshProcessTableSnapshot() - if (!rows.some((row) => row.pid === shellPid)) { + const index = getProcessTableIndex(await getFreshProcessTableSnapshot()) + const root = index.byPid.get(shellPid) + if (!root) { return false } - const root = rows.find((row) => row.pid === shellPid)! - const tree = [{ ...root, depth: 0 }, ...collectDescendants(rows, shellPid)] + const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, shellPid)] const spawnedShellBasename = executableBasename(spawnedShellProcess) const foregroundShell = tree .filter( @@ -172,7 +151,7 @@ export async function resolveAgentForegroundProcessWithAvailability( const rows = options.fresh ? await getFreshProcessTableSnapshot() : await getProcessTableSnapshot() - if (options.fresh && !rows.some((row) => row.pid === shellPid)) { + if (options.fresh && !getProcessTableIndex(rows).byPid.has(shellPid)) { return { available: false, processName: fallbackProcess } } return { @@ -186,11 +165,13 @@ export async function resolveAgentForegroundProcessWithAvailability( } export function resolveAgentForegroundProcessFromPs( - rows: ProcessTableRow[], + rows: readonly ProcessTableRow[], shellPid: number ): string | null { - const shellRow = rows.find((row) => row.pid === shellPid) - const candidates = collectDescendants(rows, shellPid) + // Memoized per snapshot identity, so the caller's own index build is reused. + const index = getProcessTableIndex(rows) + const shellRow = index.byPid.get(shellPid) + const candidates = collectDescendantsFromIndex(index, shellPid) // Why: `+` in `ps stat` marks the process holding the terminal foreground. // The root shell can hold it after Ctrl-Z, so use the whole PTY tree as the // foreground gate; otherwise a stopped agent child still masquerades as live. diff --git a/src/main/providers/windows-foreground-process-inspection-cost.test.ts b/src/main/providers/windows-foreground-process-inspection-cost.test.ts new file mode 100644 index 00000000000..f08d1b690bb --- /dev/null +++ b/src/main/providers/windows-foreground-process-inspection-cost.test.ts @@ -0,0 +1,156 @@ +// Regression guard on the per-inspection cost of Windows agent foreground +// inspection — the Windows analogue of the POSIX index memo (#6288). +// +// The shared TTL cache already collapses N panes into one Toolhelp32 snapshot +// (windows-agent-foreground-process-scan-volume.test.ts). What it never +// collapsed is the work each pane does ON that snapshot: a full +// `native.map(toProcessRow)` projection, a `childrenByPpid` Map rebuilt from +// scratch, and two linear scans. This file counts that work at a realistic +// table size and pane count, and pins the flag set the snapshot asks for. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' +import { + queryWindowsPaneProcessInventory, + resetWindowsProcessRowsSnapshotForTests +} from './windows-foreground-process-rows' + +// 1050 processes is the host measured in windows-process-enumeration.md; 11 +// panes is the fan-out the shared snapshot exists to serve. +const TABLE_SIZE = 1050 +const PANE_COUNT = 11 + +const SELF_ROW = { pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' } + +const shellPid = (pane: number): number => 10_000 + pane * 10 +const agentPid = (pane: number): number => shellPid(pane) + 1 +/** A row every pane can look up, so distinct results == distinct projections. */ +const PROBE_PID = 900_000 + TABLE_SIZE - 1 + +/** One shell + one agent child per pane, padded out to a real table size. */ +function buildNativeTable(): { pid: number; ppid: number; name: string; commandLine: string }[] { + const rows = [SELF_ROW] + for (let pane = 0; pane < PANE_COUNT; pane += 1) { + rows.push({ pid: shellPid(pane), ppid: 4, name: 'cmd.exe', commandLine: 'cmd.exe' }) + rows.push({ + pid: agentPid(pane), + ppid: shellPid(pane), + name: 'node.exe', + commandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js' + }) + } + for (let filler = rows.length; filler < TABLE_SIZE; filler += 1) { + rows.push({ pid: 900_000 + filler, ppid: 4, name: 'svchost.exe', commandLine: 'svchost.exe' }) + } + return rows +} + +const NATIVE_TABLE = buildNativeTable() + +/** + * Count `Map.prototype.set` calls — the primitive both the old per-call + * `childrenByPpid` rebuild and the shared index build are made of. Patched for + * one awaited region and restored in `finally`, so nothing else observes it. + */ +async function countMapInsertions(run: () => Promise): Promise { + const original = Map.prototype.set + let insertions = 0 + Map.prototype.set = function patched(this: Map, key: unknown, value: unknown) { + insertions += 1 + return original.call(this, key, value) + } as typeof Map.prototype.set + try { + await run() + } finally { + Map.prototype.set = original + } + return insertions +} + +describe('windows foreground inspection cost per pane', () => { + const getAllProcesses = vi.fn() + let platform: PropertyDescriptor | undefined + let flagsSeen: number[] = [] + + beforeEach(() => { + flagsSeen = [] + getAllProcesses.mockReset() + getAllProcesses.mockImplementation((cb: (rows: unknown) => void, flags: number) => { + flagsSeen.push(flags) + cb(NATIVE_TABLE) + }) + platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + getAllProcesses + })) + resetWindowsProcessRowsSnapshotForTests() + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(0) + }) + + afterEach(() => { + vi.useRealTimers() + __setWindowsProcessTreeLoaderForTests() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + }) + + async function sweepPanes(): Promise<(number | undefined)[]> { + const resolved: (number | undefined)[] = [] + for (let pane = 0; pane < PANE_COUNT; pane += 1) { + const inventory = await queryWindowsPaneProcessInventory(shellPid(pane), { + anchorPid: agentPid(pane) + }) + expect(inventory?.candidates).toHaveLength(1) + resolved.push(inventory?.candidates[0]?.pid) + } + return resolved + } + + it('never sets the Memory flag on the snapshot', async () => { + await queryWindowsPaneProcessInventory(shellPid(0)) + expect(flagsSeen).toHaveLength(1) + // Memory is bit 0, and it costs the addon a second OpenProcess per process + // carrying PROCESS_VM_READ (process.cc `GetProcessMemoryUsage`). + expect(flagsSeen[0]! & 1).toBe(0) + // CommandLine (2) | CreationTime (4). + expect(flagsSeen[0]).toBe(6) + }) + + it('projects the shared snapshot once for the whole pane fan-out', async () => { + const probeRows: unknown[] = [] + for (let pane = 0; pane < PANE_COUNT; pane += 1) { + const inventory = await queryWindowsPaneProcessInventory(shellPid(pane), { + anchorPid: PROBE_PID + }) + probeRows.push(inventory?.anchorRow) + } + expect(probeRows.filter(Boolean)).toHaveLength(PANE_COUNT) + // One projection produced every pane's row object. Pre-fix each pane ran + // its own `native.map(toProcessRow)` over all 1050 rows, so this set held + // PANE_COUNT distinct objects and the sweep allocated PANE_COUNT * 1050. + expect(new Set(probeRows).size).toBe(1) + }) + + it('indexes the shared snapshot once for the whole pane fan-out', async () => { + // Prime the TTL cache and the index so the snapshot read is not in the count. + await queryWindowsPaneProcessInventory(shellPid(0), { anchorPid: agentPid(0) }) + + const insertions = await countMapInsertions(async () => { + await sweepPanes() + }) + + // Pre-fix every pane rebuilt a whole-table `childrenByPpid`, so this was + // >= PANE_COUNT * (rows with a distinct ppid). One shared index makes the + // whole sweep cost no table-sized Map build at all. + expect(insertions).toBeLessThan(TABLE_SIZE) + }) + + it('resolves the same foreground child for every pane as an unshared scan would', async () => { + const resolved = await sweepPanes() + expect(resolved).toEqual(Array.from({ length: PANE_COUNT }, (_, pane) => agentPid(pane))) + }) +}) diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts index f5a74bd0fe2..5be4734dddc 100644 --- a/src/main/providers/windows-foreground-process-rows.ts +++ b/src/main/providers/windows-foreground-process-rows.ts @@ -1,3 +1,7 @@ +import { + collectDescendantsFromIndex, + getProcessTableIndex +} from '../../shared/process-table-snapshot' import { readWindowsProcessTable, readWindowsProcessTableFresh, @@ -25,15 +29,40 @@ function toProcessRow(row: NativeWindowsProcessRow): WindowsProcessRow { } } +/** + * One projection per snapshot identity, mirroring `getProcessTableIndex`. + * + * The TTL cache already gives every pane the same native rows array; without + * this each of them still rebuilt ~1050 row objects, which also handed + * `getProcessTableIndex` a new array each time and defeated its memo by + * construction. Keyed weakly, so a projection dies with its snapshot. Rows are + * shared, never mutated: descendants are copied with their depth, and + * `anchorRow` is read-only to every caller. + */ +const projectedRows = new WeakMap() + +function projectProcessRows(native: readonly NativeWindowsProcessRow[]): WindowsProcessRow[] { + const cached = projectedRows.get(native) + if (cached) { + return cached + } + const rows = native.map(toProcessRow) + projectedRows.set(native, rows) + return rows +} + /** * Rows from a scan that starts after this call. * * PID-identity checks in teardown must not reuse a cached row — it can predate * the very recycle it is meant to detect. Rejects when the table is unreadable, * so "unavailable" stays distinguishable from "nothing is running". + * + * `readonly` because the projection is shared with every other reader of the + * same snapshot. */ -export async function queryWindowsProcessRowsFresh(): Promise { - return (await readWindowsProcessTableFresh()).map(toProcessRow) +export async function queryWindowsProcessRowsFresh(): Promise { + return projectProcessRows(await readWindowsProcessTableFresh()) } export async function queryWindowsProcessDescendants( @@ -63,21 +92,22 @@ export async function queryWindowsPaneProcessInventory( options.fresh === true ? await readWindowsProcessTableFresh() : await readWindowsProcessTable() - rows = native.map(toProcessRow) + rows = projectProcessRows(native) } catch { return null } + // One index per snapshot, shared by every pane inspecting inside the TTL + // window: `byPid` answers both lookups that used to be linear scans, and + // `childrenByPpid` replaces a per-call Map rebuild over the whole table. + const index = getProcessTableIndex(rows) // Why: a snapshot that omitted the PTY root may be stale or permission- // filtered; only an observed root can authoritatively have no descendants. - if (!rows.some((row) => row.pid === rootPid)) { + if (!index.byPid.has(rootPid)) { return null } return { - candidates: collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth), - anchorRow: - options.anchorPid !== undefined - ? (rows.find((row) => row.pid === options.anchorPid) ?? null) - : null + candidates: collectDescendantsFromIndex(index, rootPid).sort((a, b) => b.depth - a.depth), + anchorRow: options.anchorPid !== undefined ? (index.byPid.get(options.anchorPid) ?? null) : null } } @@ -85,26 +115,3 @@ export async function queryWindowsPaneProcessInventory( export function resetWindowsProcessRowsSnapshotForTests(): void { resetWindowsProcessTableForTests() } - -function collectDescendants( - rows: Row[], - rootPid: number -): (Row & { depth: number })[] { - const childrenByParent = new Map() - for (const row of rows) { - const children = childrenByParent.get(row.ppid) ?? [] - children.push(row) - childrenByParent.set(row.ppid, children) - } - - const descendants: (Row & { depth: number })[] = [] - const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 })) - while (stack.length > 0) { - const { row, depth } = stack.pop()! - descendants.push({ ...row, depth }) - for (const child of childrenByParent.get(row.pid) ?? []) { - stack.push({ row: child, depth: depth + 1 }) - } - } - return descendants -} diff --git a/src/main/windows/windows-process-table-cim-scan.ts b/src/main/windows/windows-process-table-cim-scan.ts index 898f0c36f33..213f157f63b 100644 --- a/src/main/windows/windows-process-table-cim-scan.ts +++ b/src/main/windows/windows-process-table-cim-scan.ts @@ -75,8 +75,6 @@ export function parseWindowsCimProcessRows(stdout: string): WindowsProcessRow[] return [] } const name = fieldAsString(row.Name) - // memoryBytes stays undefined: Win32_Process reports WorkingSetSize, but no - // caller reads it off this table and asking widens an already costly scan. return [{ pid, ppid, name, command: fieldAsString(row.CommandLine) || name }] }) } diff --git a/src/main/windows/windows-process-table.test.ts b/src/main/windows/windows-process-table.test.ts index 609de009820..360dae4ec14 100644 --- a/src/main/windows/windows-process-table.test.ts +++ b/src/main/windows/windows-process-table.test.ts @@ -52,21 +52,24 @@ describe('windows process table', () => { it('maps native rows, defaulting an unreadable command line to empty', async () => { const rows = await readWindowsProcessTableFresh() expect(rows).toEqual([ - { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '', memoryBytes: undefined }, + { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '' }, { pid: 100, ppid: 4, name: 'orca.exe', command: '"C:/a b/orca.exe" --x', - memoryBytes: 4096, creationTimeMs: 1_700_000_000_000 } ]) }) - it('requests memory and command line together', async () => { + it('requests the command line and creation time, never memory', async () => { await readWindowsProcessTableFresh() - expect(getAllProcesses.mock.calls[0]?.[1]).toBe(7) + // CommandLine (2) | CreationTime (4). The Memory bit (1) stays clear: the + // addon opens a second PROCESS_VM_READ handle per process to serve it and + // nothing reads a working set off this table. + expect(getAllProcesses.mock.calls[0]?.[1]).toBe(6) + expect((getAllProcesses.mock.calls[0]?.[1] as number) & 1).toBe(0) }) it('only advertises PID-safe ownership when the native creation-time field exists', () => { @@ -400,20 +403,19 @@ describe('resolving the native reader', () => { }) const rows = await readWindowsProcessTableFresh() expect(rows).toEqual([ - { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '', memoryBytes: undefined }, + { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '' }, { pid: 100, ppid: 4, name: 'orca.exe', command: '"C:/a b/orca.exe" --x', - memoryBytes: 4096, creationTimeMs: 1_700_000_000_000 } ]) expect(isWindowsProcessTableAvailable()).toBe(true) }) - it('asks the addon for memory and command line, as the package path does', async () => { + it('asks the addon for the command line but not memory, as the package path does', async () => { const addon = addonReturning(NATIVE) __setWindowsProcessTreeRequireForTests((specifier: string) => { if (specifier === ADDON_SPECIFIER) { @@ -422,9 +424,10 @@ describe('resolving the native reader', () => { throw new Error('MODULE_NOT_FOUND') }) await readWindowsProcessTableFresh() - // Memory | CommandLine. A bare snapshot would silently drop the command - // line every agent-recognition caller matches on first. - expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 3) + // CommandLine only: a bare snapshot would silently drop the command line + // every agent-recognition caller matches on first, and the relay addon + // exposes no CreationTime bit to add. + expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 2) }) it('reaches the CIM scan when neither the package nor the addon is present', async () => { diff --git a/src/main/windows/windows-process-table.ts b/src/main/windows/windows-process-table.ts index 9308c64a9f0..42d48f4fc79 100644 --- a/src/main/windows/windows-process-table.ts +++ b/src/main/windows/windows-process-table.ts @@ -23,6 +23,10 @@ import { readWindowsProcessRowsWithCim } from './windows-process-table-cim-scan' * pid+ppid+name 15.9 / 17.5 ms * +memory +commandLine 30.6 / 33.7 ms * PowerShell CIM 706 / 723 ms + * + * Those are the module's published figures for both extra fields together; the + * only flag set this module asks for is `CommandLine` (+ `CreationTime`, free), + * which sits between the two rows and has not been separately measured. */ export type WindowsProcessRow = { @@ -31,8 +35,6 @@ export type WindowsProcessRow = { name: string /** Full command line. Empty when the process denied a query handle. */ command: string - /** Working set in bytes, or undefined when not requested/queryable. */ - memoryBytes?: number /** Process creation time in Unix milliseconds, when the native snapshot provides it. */ creationTimeMs?: number } @@ -41,7 +43,6 @@ type NativeProcessInfo = { pid: number ppid: number name: string - memory?: number commandLine?: string creationTimeMs?: number } @@ -49,7 +50,6 @@ type NativeProcessInfo = { type WindowsProcessTreeModule = { ProcessDataFlag: { None: number - Memory: number CommandLine: number CreationTime?: number } @@ -82,7 +82,10 @@ type WindowsProcessTreeAddon = { ) => void } -/** Mirrors the package's enum; the addon takes the raw bit field. */ +/** + * Mirrors the package's enum; the addon takes the raw bit field. `Memory` (1) + * is listed for completeness and is deliberately never set — see `flags` below. + */ const PROCESS_DATA_FLAG = { None: 0, Memory: 1, CommandLine: 2 } as const /** Staged beside the relay bundle by build-relay; see RELAY_ARTIFACTS. */ @@ -190,16 +193,16 @@ function readNativeRows(): Promise { } const readId = ++readSequence const readerEpoch = nativeReaderEpoch - // Why always both flags: each adds an OpenProcess per process (Memory a - // GetProcessMemoryInfo, CommandLine a PEB read), so asking for less would be - // cheaper -- 15.9ms p50 versus 30.6ms at 1050 processes. But every read shares - // one snapshot so a 32-wide teardown collapses into a single scan, and that - // snapshot has to satisfy every caller. Splitting the cache per field set - // would restore exactly the fan-out it exists to prevent. - const flags = - native.ProcessDataFlag.Memory | - native.ProcessDataFlag.CommandLine | - (native.ProcessDataFlag.CreationTime ?? 0) + // Why CommandLine but not Memory: each flag costs one OpenProcess per process + // inside the addon (process.cc), and every caller of this table matches on + // `command`, while nothing reads a working set off it -- the Resource Manager + // runs its own CIM sweep because it needs commit and CPU time in one pass, and + // `process.cc` truncates the working set into a DWORD anyway. Dropping Memory + // halves the per-snapshot handle count; the remaining flags stay in ONE flag + // set because every read shares one snapshot, so a 32-wide teardown collapses + // into a single scan. Splitting the cache per field set would restore exactly + // the fan-out it exists to prevent. + const flags = native.ProcessDataFlag.CommandLine | (native.ProcessDataFlag.CreationTime ?? 0) return new Promise((resolve, reject) => { // Hoisted so a synchronous throw from getAllProcesses can clear it. An // orphaned timer would otherwise fire later and wedge a reader that had @@ -241,7 +244,6 @@ function readNativeRows(): Promise { ppid: row.ppid, name: row.name, command: row.commandLine ?? '', - memoryBytes: row.memory, ...(typeof row.creationTimeMs === 'number' ? { creationTimeMs: row.creationTimeMs } : {}) diff --git a/src/shared/process-table-snapshot.ts b/src/shared/process-table-snapshot.ts index 2d65fc3f324..54764767740 100644 --- a/src/shared/process-table-snapshot.ts +++ b/src/shared/process-table-snapshot.ts @@ -124,28 +124,36 @@ export type ProcessTableIndexStats = { indexLookups: number } -export type ProcessTableIndex = { - rows: readonly ProcessTableRow[] - byPid: ReadonlyMap - childrenByPpid: ReadonlyMap +/** The parent/child fields every process-table row shape shares. */ +export type ProcessIdentityRow = { pid: number; ppid: number } + +export type ProcessTableIndexOf = { + rows: readonly Row[] + byPid: ReadonlyMap + childrenByPpid: ReadonlyMap stats?: ProcessTableIndexStats } +export type ProcessTableIndex = ProcessTableIndexOf + /** * Build the correlation indexes in one linear pass over a capture. Only the * indexes a resolver actually reads are materialized: group indexes would cost * two more maps plus a per-row array allocation on every capture, and foreground * membership is derived from each row's own `pgid` against the root's `tpgid`. + * + * Generic over the row shape so the Windows snapshot (`pid`/`ppid`/`name`/ + * `command`) shares this pass rather than carrying a parallel one. */ -export function buildProcessTableIndex( - rows: readonly ProcessTableRow[], +export function buildProcessTableIndex( + rows: readonly Row[], stats?: ProcessTableIndexStats -): ProcessTableIndex { +): ProcessTableIndexOf { if (stats) { stats.indexBuilds += 1 } - const byPid = new Map() - const childrenByPpid = new Map() + const byPid = new Map() + const childrenByPpid = new Map() for (const row of rows) { if (stats) { stats.rowVisits += 1 @@ -161,6 +169,29 @@ export function buildProcessTableIndex( return { rows, byPid, childrenByPpid, stats } } +/** + * Depth-first descendants of `rootPid`, deepest-last, off a prebuilt index. + * + * Each row is copied with its depth, so callers may not mutate the index's rows + * through the result. Ordering matches a per-call `childrenByPpid` walk exactly: + * children keep capture order and the stack pops last-pushed first. + */ +export function collectDescendantsFromIndex( + index: ProcessTableIndexOf, + rootPid: number +): (Row & { depth: number })[] { + const descendants: (Row & { depth: number })[] = [] + const stack = (index.childrenByPpid.get(rootPid) ?? []).map((row) => ({ row, depth: 1 })) + while (stack.length > 0) { + const { row, depth } = stack.pop()! + descendants.push({ ...row, depth }) + for (const child of index.childrenByPpid.get(row.pid) ?? []) { + stack.push({ row: child, depth: depth + 1 }) + } + } + return descendants +} + /** * Rank a descendant row as a foreground candidate: a `+` (foreground process * group) row always outranks a background one, then the deepest wins. @@ -169,9 +200,9 @@ export function scoreForegroundCandidateRow(row: ProcessTableRow & { depth: numb return (row.stat.includes('+') ? 10_000 : 0) + row.depth } -export function lookupProcessTableIndex( - index: ProcessTableIndex, - lookup: (index: ProcessTableIndex) => T, +export function lookupProcessTableIndex( + index: ProcessTableIndexOf, + lookup: (index: ProcessTableIndexOf) => T, stats = index.stats ): T { if (stats) { @@ -180,7 +211,9 @@ export function lookupProcessTableIndex( return lookup(index) } -const processTableIndexes = new WeakMap() +// Keyed by array identity, which also pins the row shape the entry was built +// for, so the one cast below cannot hand a caller another row type's index. +const processTableIndexes = new WeakMap() /** * Memoize one index per snapshot identity, so the panes that share a TTL-cached @@ -195,8 +228,10 @@ const processTableIndexes = new WeakMap( + rows: readonly Row[] +): ProcessTableIndexOf { + const cached = processTableIndexes.get(rows) as ProcessTableIndexOf | undefined if (cached) { return cached } From d22d5c80a69ed7341074c388128dadabd43f5b26 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:08:27 -0700 Subject: [PATCH 16/25] fix(terminal): stop a hidden pane's cursor blink deterministically (#18152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A retained hidden pane keeps a live WebglRenderer, and the only thing that stops its 600 ms cursor-blink timer is a real DOM blur event. Today that arrives incidentally from display:none/visibility:hidden; under a hide mode that keeps focus (opacity:0 without inert) it never fires and the pane blinks — redrawing its whole cursor row per toggle — until the 5-minute idle timeout. Park terminal.options.cursorBlink on suspend and restore the parked value on resume, so the property holds regardless of which CSS hid the pane. Settings writes land on the parked value while hidden, so a mid-hide settings change cannot re-arm the timer behind the surface, and a user who disabled blink never gets it back. --- .../terminal-pane/terminal-appearance.ts | 5 +- .../pane-cursor-blink-suspension.test.ts | 300 ++++++++++++++++++ .../pane-cursor-blink-suspension.ts | 57 ++++ .../pane-manager-pane-creation.ts | 5 + .../pane-manager/pane-rendering-control.ts | 13 + .../pane-webgl-context-recovery.test.ts | 1 + .../pane-webgl-refresh-lifecycle.test.ts | 1 + .../terminal-webgl-hidden-retention.test.ts | 3 +- 8 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index bb61a50fbbc..a5aa36568ca 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -21,6 +21,7 @@ import { resolveTerminalCursorInactiveStyle } from '@/lib/pane-manager/pane-terminal-options' import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' +import { setTerminalCursorBlinkOption } from '@/lib/pane-manager/pane-cursor-blink-suspension' import type { PtyTransport } from './pty-transport' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' import { HEX_COLOR_RE } from '../../../../shared/color-validation' @@ -180,7 +181,9 @@ export function applyTerminalAppearance( const cursorStyle = settings.terminalCursorStyle ?? 'block' pane.terminal.options.cursorStyle = cursorStyle pane.terminal.options.cursorInactiveStyle = resolveTerminalCursorInactiveStyle(cursorStyle) - pane.terminal.options.cursorBlink = settings.terminalCursorBlink + // Why not a direct write: a suspended (hidden) pane parks the value instead, so a + // settings change mid-hide cannot re-arm its blink timer behind the hidden surface. + setTerminalCursorBlinkOption(pane.terminal, settings.terminalCursorBlink) const paneSize = paneFontSizes.get(pane.id) const metricOptions = { fontSize: paneSize ?? settings.terminalFontSize, diff --git a/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts new file mode 100644 index 00000000000..66dc3fc872b --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts @@ -0,0 +1,300 @@ +// @vitest-environment happy-dom + +import { Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal } from './pane-manager-types' +import { resumePaneRendering, suspendPaneRendering } from './pane-rendering-control' +import { resetHiddenWebglRetentionForTest } from './terminal-webgl-hidden-retention' +import { isTerminalCursorBlinkSuspended } from './pane-cursor-blink-suspension' + +/** + * Invariant (`terminal-render.hidden-pane-idle-cost`): a hidden pane must not drive + * the cursor-blink redraw loop, and revealing it must put back exactly the blink + * state it had — never a frozen, missing, or unexpectedly blinking cursor. + * + * Oracle: the cursor xterm actually renders. The DOM renderer stamps + * `xterm-cursor` / `xterm-cursor-blink` on the cursor cell (DomRendererRowFactory), + * so these assertions read rendered output, not the option value — an assertion on + * `options.cursorBlink` alone would pass with the bug present. + * + * The hidden-side cost is proved separately by the redraw-count test at the bottom: + * the DOM renderer already gates its blink class on per-terminal focus, whereas the + * WebGL renderer's blink timer arms on `document.hasFocus()`, which is what leaks. + */ + +const COLS = 80 +const ROWS = 24 + +type TestPane = ManagedPaneInternal & { host: HTMLElement } + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())) +} + +async function flushRender(): Promise { + await nextFrame() + await nextFrame() + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +function createTestPane(options: { cursorBlink?: boolean; webglAddon?: boolean } = {}): TestPane { + const host = document.createElement('div') + document.body.appendChild(host) + const terminal = new Terminal({ + cols: COLS, + rows: ROWS, + cursorBlink: options.cursorBlink ?? true, + allowProposedApi: true + }) + terminal.open(host) + terminal.focus() + const pane = { + id: 1, + terminal, + host, + container: host, + xtermContainer: host, + // Off so resume's reattachWebglIfNeeded leaves the DOM renderer in place; + // the rendered-cursor oracle needs a renderer that paints in happy-dom. + gpuRenderingEnabled: false, + terminalGpuAcceleration: 'off', + webglAddon: options.webglAddon ? ({ dispose: vi.fn() } as never) : null, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + webglAttachFailedSinceRecovery: false, + hasComplexScriptOutput: false, + pendingWebglRefreshRafId: null, + pendingObservedFitRafId: null + } as unknown as TestPane + return pane +} + +/** The cursor cell as the renderer painted it, or null when no cursor is rendered. */ +function renderedCursor(pane: TestPane): HTMLElement | null { + return pane.host.querySelector('.xterm-cursor') +} + +function rendersBlinkingCursor(pane: TestPane): boolean { + return renderedCursor(pane)?.classList.contains('xterm-cursor-blink') === true +} + +function renderedText(pane: TestPane): string { + return pane.host.querySelector('.xterm-rows')?.textContent ?? '' +} + +/** Reveal = the manager's resume pass, then the terminal regains real DOM focus. */ +async function reveal(panes: TestPane[], owner?: object): Promise { + resumePaneRendering(panes, owner) + for (const pane of panes) { + pane.terminal.focus() + } +} + +describe('hidden-pane cursor blink suspension', () => { + beforeEach(() => { + resetHiddenWebglRetentionForTest() + // happy-dom has no canvas text metrics; xterm measures glyphs on open(). + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(() => { + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it('renders a blinking cursor again one frame after reveal', async () => { + const pane = createTestPane() + await write(pane.terminal, 'ready$ ') + await flushRender() + expect(rendersBlinkingCursor(pane)).toBe(true) + + suspendPaneRendering([pane]) + expect(isTerminalCursorBlinkSuspended(pane.terminal)).toBe(true) + + await reveal([pane]) + await nextFrame() + expect(renderedCursor(pane), 'reveal must not leave the pane without a cursor').not.toBeNull() + expect(rendersBlinkingCursor(pane)).toBe(true) + expect(isTerminalCursorBlinkSuspended(pane.terminal)).toBe(false) + }) + + it('keeps a user who disabled cursor blink un-blinking across hide and reveal', async () => { + const pane = createTestPane({ cursorBlink: false }) + await write(pane.terminal, 'ready$ ') + await flushRender() + expect(rendersBlinkingCursor(pane)).toBe(false) + + suspendPaneRendering([pane]) + await reveal([pane]) + await flushRender() + + expect(renderedCursor(pane), 'a steady cursor must still be rendered').not.toBeNull() + expect(rendersBlinkingCursor(pane), 'blink must stay off for this user').toBe(false) + }) + + it('does not drop output written while hidden, and echoes typing after reveal', async () => { + const pane = createTestPane() + await write(pane.terminal, 'before-hide\r\n') + await flushRender() + + suspendPaneRendering([pane]) + await write(pane.terminal, 'while-hidden\r\n') + + await reveal([pane]) + await flushRender() + expect(renderedText(pane)).toContain('before-hide') + expect(renderedText(pane)).toContain('while-hidden') + + // Real key input through xterm's textarea must still route to the PTY. + const typed: string[] = [] + pane.terminal.onData((data) => typed.push(data)) + const keydown = new KeyboardEvent('keydown', { + key: 'x', + code: 'KeyX', + bubbles: true, + cancelable: true + }) + Object.defineProperty(keydown, 'keyCode', { value: 88 }) + pane.terminal.textarea?.dispatchEvent(keydown) + expect(typed, 'keystrokes must still reach the PTY after a hide/reveal').toEqual(['x']) + + await write(pane.terminal, 'x') + await flushRender() + expect(renderedText(pane)).toContain('x') + expect(rendersBlinkingCursor(pane)).toBe(true) + }) + + it('restores blink on the retained-hidden-WebGL path, which skips dispose', async () => { + const owner = {} + const pane = createTestPane({ webglAddon: true }) + const addon = pane.webglAddon + await write(pane.terminal, 'ready$ ') + await flushRender() + + suspendPaneRendering([pane], { owner, livePanes: () => [pane] }) + // The retention branch returns before disposeWebgl — this is the path where + // nothing else could have stopped the blink timer. + expect(addon?.dispose).not.toHaveBeenCalled() + expect(isTerminalCursorBlinkSuspended(pane.terminal)).toBe(true) + + await reveal([pane], owner) + await nextFrame() + expect(rendersBlinkingCursor(pane)).toBe(true) + }) + + it('restores every pane of a split, including one revealed a second time', async () => { + const owner = {} + const left = createTestPane() + const right = createTestPane() + const panes = [left, right] + await write(left.terminal, 'left$ ') + await write(right.terminal, 'right$ ') + await flushRender() + + for (let cycle = 0; cycle < 2; cycle++) { + suspendPaneRendering(panes, { owner, livePanes: () => panes }) + resumePaneRendering(panes, owner) + // One at a time: the DOM renderer only paints a blinking cursor in the pane + // that currently holds real focus, so focus each split half in turn. + for (const [name, pane] of [ + ['left', left], + ['right', right] + ] as const) { + pane.terminal.focus() + await nextFrame() + expect(renderedCursor(pane), `${name} cursor, cycle ${cycle}`).not.toBeNull() + expect(rendersBlinkingCursor(pane), `${name} blink, cycle ${cycle}`).toBe(true) + } + } + expect(renderedText(left)).toContain('left$') + expect(renderedText(right)).toContain('right$') + }) + + it('does not re-arm a hidden pane when the blink setting changes mid-hide', async () => { + const pane = createTestPane({ cursorBlink: false }) + suspendPaneRendering([pane]) + + // What applyTerminalAppearance does when the user flips the setting. + const { setTerminalCursorBlinkOption } = await import('./pane-cursor-blink-suspension') + setTerminalCursorBlinkOption(pane.terminal, true) + expect( + pane.terminal.options.cursorBlink, + 'a hidden pane must not start blinking behind the surface' + ).toBe(false) + + await reveal([pane]) + await nextFrame() + expect(rendersBlinkingCursor(pane), 'the new setting applies on reveal').toBe(true) + }) + + it('resume is a no-op for a pane that was never suspended', async () => { + const pane = createTestPane() + await flushRender() + await reveal([pane]) + await nextFrame() + expect(rendersBlinkingCursor(pane)).toBe(true) + }) +}) + +/** + * Deterministic cost gate for the hidden side. + * + * Model, taken from `@xterm/addon-webgl@0.20` sources: `CursorBlinkStateManager` + * runs a 600 ms interval whenever `ICoreBrowserService.isFocused` is true — a + * DOCUMENT-level fact — and every toggle calls `WebglRenderer._requestRedrawCursor()`, + * which redraws one full row (`_updateModel` loops `x < cols` per row). + * `WebglRenderer._updateCursorBlink()` decides whether the manager exists from + * `decPrivateModes.cursorBlink ?? terminal.options.cursorBlink`, which is read from + * the real Terminal below. + */ +const BLINK_INTERVAL_MS = 600 + +function blinkCellsRedrawnPerWindow(terminal: Terminal, windowMs: number): number { + const decPrivateBlink = ( + terminal as unknown as { + _core: { coreService: { decPrivateModes: { cursorBlink?: boolean } } } + } + )._core.coreService.decPrivateModes.cursorBlink + const blinking = decPrivateBlink ?? terminal.options.cursorBlink === true + if (!blinking) { + return 0 + } + return Math.floor(windowMs / BLINK_INTERVAL_MS) * terminal.cols +} + +describe('hidden-pane cursor blink redraw cost', () => { + beforeEach(() => { + resetHiddenWebglRetentionForTest() + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(() => { + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it('drops retained hidden panes to zero blink redraws and restores them on reveal', async () => { + const owner = {} + // The retention cap: MAX_RETAINED_HIDDEN_WEBGL_CONTEXTS. + const panes = Array.from({ length: 6 }, () => createTestPane({ webglAddon: true })) + const windowMs = 30_000 + const cells = () => + panes.reduce((sum, pane) => sum + blinkCellsRedrawnPerWindow(pane.terminal, windowMs), 0) + + expect(cells()).toBe(6 * 50 * COLS) + + suspendPaneRendering(panes, { owner, livePanes: () => panes }) + expect(cells(), 'hidden panes must cost nothing while the window is visible').toBe(0) + + await reveal(panes, owner) + expect(cells()).toBe(6 * 50 * COLS) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts new file mode 100644 index 00000000000..13f90af696c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts @@ -0,0 +1,57 @@ +import type { Terminal } from '@xterm/xterm' + +/** + * Park `cursorBlink` while a pane is hidden. + * + * Why: a retained hidden pane (`terminal-webgl-hidden-retention.ts`) keeps a live + * WebglRenderer, and the only thing that stops its 600 ms blink timer is + * `WebglRenderer.handleBlur()` — reached only from a real DOM blur event. Today + * that arrives incidentally, because `display:none`/`visibility:hidden` move focus; + * under `opacity:0` without `inert` (TerminalOverlaySlot's startup probe) it never + * fires, `Terminal.blur()` is a no-op on a textarea that is not the active element, + * and the pane blinks — redrawing its whole cursor row through + * `WebglRenderer._updateModel` — until the 5-minute idle timeout. + * + * `cursorBlink` is the public option that tears the timer down deterministically + * (`RenderService.handleOptionsChanged` -> `WebglRenderer._updateCursorBlink`), so + * "a hidden pane does not blink" stops depending on which CSS hid it. + * + * Resume restores the parked value rather than the settings value, so a pane that + * was not blinking before the hide never comes back blinking. + */ +const parkedCursorBlink = new WeakMap() + +export function suspendTerminalCursorBlink(terminal: Terminal): void { + if (parkedCursorBlink.has(terminal)) { + return + } + parkedCursorBlink.set(terminal, terminal.options.cursorBlink === true) + terminal.options.cursorBlink = false +} + +/** Restores the pre-suspend blink state. No-op on a terminal that was never suspended. */ +export function resumeTerminalCursorBlink(terminal: Terminal): void { + if (!parkedCursorBlink.has(terminal)) { + return + } + const restored = parkedCursorBlink.get(terminal) === true + parkedCursorBlink.delete(terminal) + terminal.options.cursorBlink = restored +} + +/** + * Settings-driven blink writes land on the parked value while a pane is suspended; + * writing the option directly would re-arm the blink timer behind a hidden surface + * until the next reveal. + */ +export function setTerminalCursorBlinkOption(terminal: Terminal, enabled: boolean): void { + if (parkedCursorBlink.has(terminal)) { + parkedCursorBlink.set(terminal, enabled) + return + } + terminal.options.cursorBlink = enabled +} + +export function isTerminalCursorBlinkSuspended(terminal: Terminal): boolean { + return parkedCursorBlink.has(terminal) +} diff --git a/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts b/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts index 11efdff06c0..483f49a1f55 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts @@ -2,6 +2,7 @@ import type { ManagedPane, ManagedPaneInternal, PaneManagerOptions } from './pan import type { PaneManagerHost } from './pane-manager-host' import { applyPaneOpacity } from './pane-divider' import { createPaneDOM, openTerminal } from './pane-lifecycle' +import { suspendTerminalCursorBlink } from './pane-cursor-blink-suspension' import { shouldFollowMouseFocus } from './focus-follows-mouse' import { toPublicPane } from './pane-public-view' @@ -53,6 +54,10 @@ export function createManagedPaneInternal( } ) pane.webglAttachmentDeferred = host.isRenderingSuspended() + if (host.isRenderingSuspended()) { + // A pane that mounts behind a hidden surface never sees suspendPaneRendering(). + suspendTerminalCursorBlink(pane.terminal) + } host.panes.set(id, pane) host.identities.register(id, leafId) return pane diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts index 6709a7a1f98..219f17f4be6 100644 --- a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts +++ b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts @@ -1,4 +1,8 @@ import type { ManagedPaneInternal } from './pane-manager-types' +import { + resumeTerminalCursorBlink, + suspendTerminalCursorBlink +} from './pane-cursor-blink-suspension' import { safeFit } from './pane-tree-ops' import { attachWebgl, @@ -66,6 +70,12 @@ export function suspendPaneRendering( for (const pane of suspended) { pane.webglAttachmentDeferred = true pane.terminal.blur() + // Why here, above the retention return: the retention branch keeps a live + // WebglRenderer, whose blink timer only stops on a real DOM blur event. blur() + // above is a no-op unless that pane's textarea held focus, so under a hide mode + // that keeps focus the pane would blink — redrawing its cursor row — until the + // 5-minute idle timeout. Parking the option makes it unconditional. + suspendTerminalCursorBlink(pane.terminal) } // Keep recent hidden worktrees on live WebGL so switch-back never presents // DOM-fallback frames; evicted/over-cap owners fall back to dispose. @@ -86,6 +96,9 @@ export function resumePaneRendering( } for (const pane of panes) { clearTerminalWebglAttachBackoff(pane) + // Before the attach below so a freshly constructed WebglRenderer already samples + // the restored option and blinks on its first frame. + resumeTerminalCursorBlink(pane.terminal) const rebuildDeferred = pane.webglRebuildDeferred === true pane.webglAttachmentDeferred = false // Reveal can retry before the next resume, so both paths share the bounded loss policy. diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts index 2897c1627de..fef2c9f5d1e 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts @@ -14,6 +14,7 @@ function createPane(options: { loadAddon?: () => void } = {}): ManagedPaneIntern leafId, stablePaneId: leafId, terminal: { + options: { cursorBlink: true }, cols: 80, rows: 24, refresh: vi.fn(), diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts index d6e30bb57f8..09f2ef98c84 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts @@ -17,6 +17,7 @@ function createPane( leafId, stablePaneId: leafId, terminal: { + options: { cursorBlink: true }, element: null, cols: 80, rows: 24, diff --git a/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts b/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts index e630e88d149..7d6bfdf26c2 100644 --- a/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts @@ -10,7 +10,8 @@ import { function createPane(withAddon = true): ManagedPaneInternal { return { - terminal: { blur: vi.fn() }, + // options mirrors the real Terminal: suspend parks cursorBlink here. + terminal: { blur: vi.fn(), options: { cursorBlink: true } }, webglAddon: withAddon ? ({ dispose: vi.fn() } as unknown as ManagedPaneInternal['webglAddon']) : null, From b00ec2073133fb4e965e93640988966eccad1d01 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:10:02 -0700 Subject: [PATCH 17/25] perf(startup): stop an unreachable SSH host from gating local terminal restore (#18164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(startup): stop an unreachable SSH host from gating local terminal restore An asleep or unreachable SSH target held the terminal-restoration gate for the full 15s reconnect timeout, so no terminal restored — local ones included. Startup now awaits only the target that owns the active workspace's tabs and lets the rest connect in the background, folded into the existing deferred path that reattaches their PTYs on tab focus. Also splits the renderer's git-environment fence out of the first-window PTY services barrier: worktree hydration needs shell-PATH generation and the managed WSL CLI registration, not a daemon PTY spawn or a hook-server bind. Terminal restoration still fences on the first-window services via app:prepareTerminalStartupRestoration. Measured with tests/tools/benchmarks/startup-time-bench.mjs (382 restored tabs, 28k-file profile, medians of 3): unreachable SSH host: 17.27s -> 1.34s to renderer-startup-hydration-done all-local: 1.98s -> 1.33s * fix(startup): restore the startup-ordering oracle and keep a connected background SSH target undeferred app-startup-routing.test.ts pinned the old step names, so the two ordering cases went vacuous-then-red when the barrier split. Repoint them at the steps that now carry the same fences: 'git-environment-barrier-await' (shell PATH + managed WSL, the fence host Git needs) before hydration worktrees, and 'prepare-terminal-startup-restoration' (which awaits firstWindowStartupServicesReady in main) before terminal reconnect. Both still fail against main's hydration source. Also: the timed-out-eager rewrite of the deferred list re-added background targets that had already connected, undoing removeDeferredSshReconnectTarget and sending fresh panes on a reachable host down the cold-restore path. --- .../startup/desktop-startup-ordering.test.ts | 42 ++++ .../startup/main-process-ipc-bootstrap.ts | 7 + .../startup/main-process-runtime-launch.ts | 3 + src/preload/api/app-api.ts | 3 + src/preload/api/app-bridge.ts | 2 + .../startup-actions-selector.test.ts | 1 + .../src/app-shell/startup-actions-selector.ts | 4 + .../app-shell/use-app-startup-hydration.ts | 30 ++- src/renderer/src/app-startup-routing.test.ts | 25 ++- .../active-workspace-ssh-targets.test.ts | 112 +++++++++ .../startup/active-workspace-ssh-targets.ts | 49 ++++ .../src/startup/ssh-startup-reconnect.ts | 18 +- .../startup-ssh-connection-restore.test.ts | 208 +++++++++++++++++ .../startup/startup-ssh-connection-restore.ts | 66 +++++- .../src/web/preload-api/web-app-api.ts | 1 + .../startup-bench-state-fixture.mjs | 193 ++++++++++++++++ tests/tools/benchmarks/startup-time-bench.mjs | 212 ++++-------------- 17 files changed, 783 insertions(+), 193 deletions(-) create mode 100644 src/renderer/src/startup/active-workspace-ssh-targets.test.ts create mode 100644 src/renderer/src/startup/active-workspace-ssh-targets.ts create mode 100644 src/renderer/src/startup/startup-ssh-connection-restore.test.ts create mode 100644 tests/tools/benchmarks/startup-bench-state-fixture.mjs diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index e432ed383d2..fc381f15714 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -183,6 +183,48 @@ describe('startup ordering', () => { ) }) + it('keeps the git-environment barrier off the PTY startup services', () => { + const barrierSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ipc-bootstrap.ts'), + 'utf8' + ) + const launchSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const gitBarrierStart = barrierSource.indexOf( + "ipcMain.handle('app:awaitGitEnvironmentStartupBarrier'" + ) + const gitBarrierEnd = barrierSource.indexOf( + "'app:prepareTerminalStartupRestoration'", + gitBarrierStart + ) + expect(gitBarrierStart).toBeGreaterThanOrEqual(0) + expect(gitBarrierEnd).toBeGreaterThan(gitBarrierStart) + const gitBarrier = barrierSource.slice(gitBarrierStart, gitBarrierEnd) + // The git environment fence is shell PATH + WSL registration; a daemon PTY provider or a + // hook-server bind here puts terminal startup back in front of worktree hydration. + expect(gitBarrier).toContain('state.shellPathReady') + expect(gitBarrier).toContain('state.managedWslCliStartupBarrierReady') + expect(gitBarrier).not.toContain('firstWindowStartupServicesReady') + // The published promise must be the same one the terminal startup services wait on. + expect(launchSource).toContain('state.shellPathReady = shellPathReady') + expect(launchSource.indexOf('state.shellPathReady = shellPathReady')).toBeLessThan( + launchSource.indexOf('await launchDesktopMode(') + ) + // Terminal restoration itself must still fence on the first-window services. + const restorationStart = barrierSource.indexOf( + "ipcMain.handle('app:prepareTerminalStartupRestoration'" + ) + const restorationEnd = barrierSource.indexOf( + "'app:recoverLegacyWorkerTerminalsForRendererStartup'", + restorationStart + ) + expect(barrierSource.slice(restorationStart, restorationEnd)).toContain( + 'state.firstWindowStartupServicesReady' + ) + }) + it('reconciles retained Codex homes after authoritative daemon inventory', () => { const source = readFileSync( join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts index 89be84d2119..918fd844364 100644 --- a/src/main/startup/main-process-ipc-bootstrap.ts +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -11,6 +11,13 @@ export function registerMainProcessIpcHandlers(): void { state.managedWslCliStartupBarrierReady ]) }) + // Why separate from the first-window barrier: host Git needs the shell-PATH + // generation and the managed WSL CLI registration, not a daemon PTY provider + // or a hook-server bind. Bundling them made worktree hydration wait on a + // terminal service it never calls. + ipcMain.handle('app:awaitGitEnvironmentStartupBarrier', async () => { + await Promise.all([state.shellPathReady, state.managedWslCliStartupBarrierReady]) + }) ipcMain.handle('app:prepareTerminalStartupRestoration', async () => { await Promise.all([ state.firstWindowStartupServicesReady, diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 7e5f278fdc5..9df28ecea8c 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -289,6 +289,9 @@ export async function initializeMainProcessRuntimeLaunch( state.serveOptions = serveOptions const runtimeRpc = installRuntimeRpc(runtime, serveOptions) const shellPathReady = shellPathHydration.whenReady() + // Why published: the renderer's git-environment barrier must fence on the same + // generation the terminal startup services wait for, not a later re-read. + state.shellPathReady = shellPathReady let desktopWindow: BrowserWindow | null = null if (process.platform === 'win32' && app.isPackaged && !serveOptions) { const desktopStartup = startWindowsDesktopBeforeShellPathReady({ diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index 88cb86dc32b..b2d6eed966c 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -38,6 +38,9 @@ export type AppApi = { /** Resolves when the daemon PTY provider and hook receiver have either * started or failed open for the first BrowserWindow. */ awaitFirstWindowStartupServices: () => Promise + /** Resolves when host Git can run: shell-PATH generation is published and the + * managed WSL CLI registration has reconciled. Does not wait on PTY services. */ + awaitGitEnvironmentStartupBarrier: () => Promise /** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */ prepareTerminalStartupRestoration: () => Promise /** Reconciles legacy worker authority around persisted terminal reconnect. */ diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts index 22d46cc40d2..2a0d50e9de4 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -43,6 +43,8 @@ export const appApi = { awaitBeforeUnloadCheckpoint: () => awaitBeforeUnloadCheckpoint(), awaitFirstWindowStartupServices: (): Promise => ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), + awaitGitEnvironmentStartupBarrier: (): Promise => + ipcRenderer.invoke('app:awaitGitEnvironmentStartupBarrier'), prepareTerminalStartupRestoration: (): Promise => ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => diff --git a/src/renderer/src/app-shell/startup-actions-selector.test.ts b/src/renderer/src/app-shell/startup-actions-selector.test.ts index 1d559eb0dcb..45a1b9cf5df 100644 --- a/src/renderer/src/app-shell/startup-actions-selector.test.ts +++ b/src/renderer/src/app-shell/startup-actions-selector.test.ts @@ -30,6 +30,7 @@ function makeActions(): StartupActions { reconnectPersistedTerminals: vi.fn(), setTerminalStartupRestorationReady: vi.fn(), setDeferredSshReconnectTargets: vi.fn(), + removeDeferredSshReconnectTarget: vi.fn(), setSshConnectionState: vi.fn(), hydratePersistedUI: vi.fn(), setHydrationSucceeded: vi.fn(), diff --git a/src/renderer/src/app-shell/startup-actions-selector.ts b/src/renderer/src/app-shell/startup-actions-selector.ts index c7dca311a03..ac18349909a 100644 --- a/src/renderer/src/app-shell/startup-actions-selector.ts +++ b/src/renderer/src/app-shell/startup-actions-selector.ts @@ -22,6 +22,7 @@ export type StartupActions = Pick< | 'reconnectPersistedTerminals' | 'setTerminalStartupRestorationReady' | 'setDeferredSshReconnectTargets' + | 'removeDeferredSshReconnectTarget' | 'setSshConnectionState' | 'hydratePersistedUI' | 'setHydrationSucceeded' @@ -59,6 +60,8 @@ export function selectStartupActions(state: StartupActions): StartupActions { cachedStartupActions.setTerminalStartupRestorationReady === state.setTerminalStartupRestorationReady && cachedStartupActions.setDeferredSshReconnectTargets === state.setDeferredSshReconnectTargets && + cachedStartupActions.removeDeferredSshReconnectTarget === + state.removeDeferredSshReconnectTarget && cachedStartupActions.setSshConnectionState === state.setSshConnectionState && cachedStartupActions.hydratePersistedUI === state.hydratePersistedUI && cachedStartupActions.setHydrationSucceeded === state.setHydrationSucceeded && @@ -91,6 +94,7 @@ export function selectStartupActions(state: StartupActions): StartupActions { reconnectPersistedTerminals: state.reconnectPersistedTerminals, setTerminalStartupRestorationReady: state.setTerminalStartupRestorationReady, setDeferredSshReconnectTargets: state.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: state.removeDeferredSshReconnectTarget, setSshConnectionState: state.setSshConnectionState, hydratePersistedUI: state.hydratePersistedUI, setHydrationSucceeded: state.setHydrationSucceeded, diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index b50ac86705e..91db232081c 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -19,6 +19,7 @@ import { } from '../startup/startup-diagnostics' import { recoverFromDegradedStartup } from '../startup/startup-degraded-recovery' import { restoreSshConnectionsForStartup } from '../startup/startup-ssh-connection-restore' +import { collectActiveWorkspaceSshTargetIds } from '../startup/active-workspace-ssh-targets' import { publishTerminalViewAttributesAtAppStart } from '../components/terminal-pane/terminal-appearance' import { getSystemPrefersDark } from '../lib/terminal-theme' import { @@ -155,9 +156,12 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta // Why: disconnected SSH repos hydrate from local metadata; only runtime-owned repos use placeholders. parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime' ) - // Why: worktree refresh can spawn host Git; wait for main's shell-PATH generation fence first. - await timeRendererStartupStep('first-window-services-await', () => - window.api.app.awaitFirstWindowStartupServices() + // Why this barrier and not the first-window one: worktree refresh can spawn host Git, + // which needs the shell-PATH generation and the managed WSL CLI registration. It never + // needs the daemon PTY provider or the hook-server bind, and `prepare-terminal-startup-restoration` + // below still fences those before any terminal is restored. + await timeRendererStartupStep('git-environment-barrier-await', () => + window.api.app.awaitGitEnvironmentStartupBarrier() ) await timeRendererStartupStep('fetch-hydration-worktrees', () => mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY, (repo) => @@ -213,9 +217,14 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta actions.pruneLastVisitedTimestamps() actions.seedActiveWorktreeLastVisitedIfMissing() }) - await timeRendererStartupStep('fetch-browser-session-profiles', () => + // Why started here but not awaited: on a remote runtime this is an RPC with a 15s + // timeout, and nothing between here and terminal restoration reads the profile list — + // awaiting it put that timeout on the terminal-restoration gate. Starting it at the + // original point keeps the profiles landing no later than they did before; the action + // swallows its own failures, so the `.catch` only marks the timing wrapper handled. + void timeRendererStartupStep('fetch-browser-session-profiles', () => actions.fetchBrowserSessionProfiles() - ) + ).catch(() => {}) const onboardingState = await onboardingPromise if (!cancelled) { onOnboardingLoadedRef.current(onboardingState) @@ -228,9 +237,17 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta ) if (connectionIds.length > 0) { try { + // Why scoped: an unreachable host used to hold every restored terminal — local ones + // included — for the full reconnect timeout. Only the targets whose panes mount as + // soon as the gate opens are worth waiting for; the rest reattach on tab focus. + const blockingConnectionIds = collectActiveWorkspaceSshTargetIds( + useAppStore.getState() + ) await restoreSshConnectionsForStartup({ connectionIds, + blockingConnectionIds, setDeferredSshReconnectTargets: actions.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: actions.removeDeferredSshReconnectTarget, publishSshConnectionState: actions.setSshConnectionState }) } catch (err) { @@ -240,7 +257,8 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta logRendererStartupDiagnostic('ssh-reconnect-skipped', { connectionIds: 0 }) } - // first-window-services-await already fenced worktree hydration; terminal recovery reuses that ready state. + // Why no explicit barrier here: prepare-terminal-startup-restoration above already awaited + // the first-window services, and main re-awaits them inside this handler anyway. await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () => window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 24a9cc75120..d1aad35829a 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -68,8 +68,11 @@ describe('renderer startup runtime routing', () => { const hydrationWorktreesIndex = source.indexOf( "timeRendererStartupStep('fetch-hydration-worktrees'" ) - const servicesIndex = source.indexOf( - "timeRendererStartupStep('first-window-services-await'", + // Why this barrier: worktree hydration can spawn host Git, so it must sit behind the + // shell-PATH + managed-WSL fence. On packaged Windows the window opens before + // shellPathReady resolves, so this really is the fence, not a formality. + const gitEnvironmentBarrierIndex = source.indexOf( + "timeRendererStartupStep('git-environment-barrier-await'", sessionIndex ) const fullWorktreesIndex = source.indexOf('await actions.fetchAllWorktrees()') @@ -89,8 +92,11 @@ describe('renderer startup runtime routing', () => { expect(localReposIndex).toBeLessThan(localGroupsIndex) expect(localGroupsIndex).toBeLessThan(localFoldersIndex) expect(localReposIndex).toBeLessThan(sessionIndex) - expect(sessionIndex).toBeLessThan(servicesIndex) - expect(servicesIndex).toBeLessThan(hydrationWorktreesIndex) + expect(sessionIndex).toBeLessThan(gitEnvironmentBarrierIndex) + expect(gitEnvironmentBarrierIndex).toBeLessThan(hydrationWorktreesIndex) + expect(source.slice(gitEnvironmentBarrierIndex, hydrationWorktreesIndex)).toContain( + 'window.api.app.awaitGitEnvironmentStartupBarrier()' + ) const hydrationWorktreeBlock = source.slice( hydrationWorktreesIndex, source.indexOf('await keybindingsPromise') @@ -180,7 +186,13 @@ describe('renderer startup runtime routing', () => { it('waits for first-window startup services before terminal reconnect', () => { const source = readSource(STARTUP_HYDRATION_PATH) - const servicesIndex = source.indexOf("timeRendererStartupStep('first-window-services-await'") + // Why this step: `app:prepareTerminalStartupRestoration` awaits + // firstWindowStartupServicesReady + managedWslCliStartupBarrierReady in main before it + // does anything else, so it is the renderer-side position of that fence. + // `desktop-startup-ordering.test.ts` pins the main-side await itself. + const servicesIndex = source.indexOf( + "timeRendererStartupStep('prepare-terminal-startup-restoration'" + ) const preReconnectRecoveryIndex = source.indexOf( "timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect'" ) @@ -193,6 +205,9 @@ describe('renderer startup runtime routing', () => { ) expect(servicesIndex).toBeGreaterThanOrEqual(0) + expect(source.slice(servicesIndex)).toContain( + 'window.api.app.prepareTerminalStartupRestoration()' + ) expect(preReconnectRecoveryIndex).toBeGreaterThan(servicesIndex) expect(capabilityRefreshIndex).toBeGreaterThan(preReconnectRecoveryIndex) expect(reconnectIndex).toBeGreaterThan(capabilityRefreshIndex) diff --git a/src/renderer/src/startup/active-workspace-ssh-targets.test.ts b/src/renderer/src/startup/active-workspace-ssh-targets.test.ts new file mode 100644 index 00000000000..f36a186b0ac --- /dev/null +++ b/src/renderer/src/startup/active-workspace-ssh-targets.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import { collectActiveWorkspaceSshTargetIds } from './active-workspace-ssh-targets' + +function tab(id: string, ptyId: string | null = null): TerminalTab { + return { + id, + ptyId, + worktreeId: 'repo-a::/w/a', + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } as TerminalTab +} + +const emptyInput = { + activeWorktreeId: null as string | null, + tabsByWorktree: {} as Record, + pendingReconnectPtyIdByTabId: {} as Record, + terminalLayoutsByTabId: {} as Record }>, + repos: [] as { id: string; connectionId?: string | null }[] +} + +describe('collectActiveWorkspaceSshTargetIds', () => { + it('returns nothing when no workspace is active', () => { + expect(collectActiveWorkspaceSshTargetIds(emptyInput)).toEqual([]) + }) + + it('returns nothing for a purely local active workspace', () => { + expect( + collectActiveWorkspaceSshTargetIds({ + ...emptyInput, + activeWorktreeId: 'repo-a::/w/a', + tabsByWorktree: { 'repo-a::/w/a': [tab('t1', 'local-pty-1')] }, + repos: [{ id: 'repo-a', connectionId: null }] + }) + ).toEqual([]) + }) + + it('names the target from the active workspace repo connection', () => { + expect( + collectActiveWorkspaceSshTargetIds({ + ...emptyInput, + activeWorktreeId: 'repo-remote::/srv/w', + repos: [{ id: 'repo-remote', connectionId: 'ssh-1' }] + }) + ).toEqual(['ssh-1']) + }) + + it('names the target from a restored PTY id when the repo catalog has no connection', () => { + // SSH worktrees are absent from worktreesByRepo at cold start; the PTY id is the durable name. + expect( + collectActiveWorkspaceSshTargetIds({ + ...emptyInput, + activeWorktreeId: 'repo-a::/w/a', + tabsByWorktree: { 'repo-a::/w/a': [tab('t1')] }, + pendingReconnectPtyIdByTabId: { t1: toAppSshPtyId('ssh-2', 'pty-9') }, + repos: [{ id: 'repo-a' }] + }) + ).toEqual(['ssh-2']) + }) + + it('names split-leaf targets on the active workspace', () => { + expect( + collectActiveWorkspaceSshTargetIds({ + ...emptyInput, + activeWorktreeId: 'repo-a::/w/a', + tabsByWorktree: { 'repo-a::/w/a': [tab('t1')] }, + terminalLayoutsByTabId: { + t1: { + ptyIdsByLeafId: { + leaf1: toAppSshPtyId('ssh-3', 'pty-1'), + leaf2: null, + leaf3: 'local-pty-2' + } + } + }, + repos: [{ id: 'repo-a' }] + }) + ).toEqual(['ssh-3']) + }) + + it('ignores targets that only own an inactive workspace', () => { + expect( + collectActiveWorkspaceSshTargetIds({ + ...emptyInput, + activeWorktreeId: 'repo-a::/w/a', + tabsByWorktree: { + 'repo-a::/w/a': [tab('t1', 'local-pty-1')], + 'repo-b::/w/b': [tab('t2', toAppSshPtyId('ssh-other', 'pty-1'))] + }, + repos: [{ id: 'repo-a' }, { id: 'repo-b', connectionId: 'ssh-other' }] + }) + ).toEqual([]) + }) + + it('deduplicates a target named by both the repo and its PTY ids', () => { + expect( + collectActiveWorkspaceSshTargetIds({ + ...emptyInput, + activeWorktreeId: 'repo-remote::/srv/w', + tabsByWorktree: { + 'repo-remote::/srv/w': [tab('t1', toAppSshPtyId('ssh-1', 'pty-1'))] + }, + repos: [{ id: 'repo-remote', connectionId: 'ssh-1' }] + }) + ).toEqual(['ssh-1']) + }) +}) diff --git a/src/renderer/src/startup/active-workspace-ssh-targets.ts b/src/renderer/src/startup/active-workspace-ssh-targets.ts new file mode 100644 index 00000000000..c7514abf4a1 --- /dev/null +++ b/src/renderer/src/startup/active-workspace-ssh-targets.ts @@ -0,0 +1,49 @@ +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' + +type ActiveWorkspaceSshTargetInput = { + activeWorktreeId: string | null + tabsByWorktree: Readonly> + /** Restored tab-level PTY ids, keyed by tab id. */ + pendingReconnectPtyIdByTabId: Readonly> + terminalLayoutsByTabId: Readonly< + Record> } | undefined> + > + repos: readonly { id: string; connectionId?: string | null }[] +} + +/** + * SSH targets that own terminals the user sees the moment the startup gate opens: the ones + * whose reconnect must still be awaited. Everything else can connect in the background and + * reattach on tab focus. + * + * Derived from the restored PTY ids rather than the repo catalog alone, because SSH worktrees + * are absent from `worktreesByRepo` at cold start — the PTY id is the durable name of the + * target the pane will reattach. + */ +export function collectActiveWorkspaceSshTargetIds(input: ActiveWorkspaceSshTargetInput): string[] { + const { activeWorktreeId } = input + if (!activeWorktreeId) { + return [] + } + const targetIds = new Set() + const repoId = getRepoIdFromWorktreeId(activeWorktreeId) + const connectionId = input.repos.find((repo) => repo.id === repoId)?.connectionId + if (connectionId) { + targetIds.add(connectionId) + } + for (const tab of input.tabsByWorktree[activeWorktreeId] ?? []) { + const ptyIds = [ + tab.ptyId, + input.pendingReconnectPtyIdByTabId[tab.id], + ...Object.values(input.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}) + ] + for (const ptyId of ptyIds) { + const parsed = ptyId ? parseAppSshPtyId(ptyId) : null + if (parsed) { + targetIds.add(parsed.connectionId) + } + } + } + return [...targetIds] +} diff --git a/src/renderer/src/startup/ssh-startup-reconnect.ts b/src/renderer/src/startup/ssh-startup-reconnect.ts index d312239a878..e31bd963d6f 100644 --- a/src/renderer/src/startup/ssh-startup-reconnect.ts +++ b/src/renderer/src/startup/ssh-startup-reconnect.ts @@ -6,7 +6,9 @@ export type SshStartupReconnectResult = { export async function reconnectSshTargetForRendererStartup(args: { targetId: string - timeoutMs: number + /** Omitted for a connect nobody is waiting on — no timer, so it cannot report + * a timeout the caller has no use for. */ + timeoutMs?: number connect: (targetId: string) => Promise publishState: (targetId: string, state: SshConnectionState) => void onFailure: (targetId: string, error: unknown) => void @@ -14,10 +16,16 @@ export async function reconnectSshTargetForRendererStartup(args: { const { targetId, timeoutMs, connect, publishState, onFailure } = args let timeoutId: ReturnType | null = null try { - const timeout = new Promise((_resolve, reject) => { - timeoutId = setTimeout(() => reject(new Error('SSH reconnect timeout')), timeoutMs) - }) - const state = await Promise.race([connect(targetId), timeout]) + const connected = connect(targetId) + const state = + timeoutMs === undefined + ? await connected + : await Promise.race([ + connected, + new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => reject(new Error('SSH reconnect timeout')), timeoutMs) + }) + ]) // Why: the state-change IPC can trail connect's resolution. Publish the // authoritative result before restored terminals inspect renderer state. if (state) { diff --git a/src/renderer/src/startup/startup-ssh-connection-restore.test.ts b/src/renderer/src/startup/startup-ssh-connection-restore.test.ts new file mode 100644 index 00000000000..3464816d0d8 --- /dev/null +++ b/src/renderer/src/startup/startup-ssh-connection-restore.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { SshConnectionState, SshProviderEpoch, SshTarget } from '../../../shared/ssh-types' +import { restoreSshConnectionsForStartup } from './startup-ssh-connection-restore' + +function connectedState(targetId: string): SshConnectionState { + return { + targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'epoch' as SshProviderEpoch, + connectionGeneration: 1, + remotePlatform: 'linux' + } +} + +function target(id: string, lastRequiredPassphrase = false): SshTarget { + return { + id, + label: id, + host: `${id}.example`, + port: 22, + username: 'orca', + lastRequiredPassphrase + } +} + +type Harness = { + connect: Mock<(targetId: string) => Promise> + getState: Mock<(targetId: string) => Promise> + setDeferredSshReconnectTargets: Mock<(targetIds: string[]) => void> + removeDeferredSshReconnectTarget: Mock<(targetId: string) => void> + publishSshConnectionState: Mock<(targetId: string, state: SshConnectionState) => void> +} + +let harness: Harness + +function installWindowApi(targets: SshTarget[]): void { + harness = { + connect: vi.fn(), + getState: vi.fn().mockResolvedValue(null), + setDeferredSshReconnectTargets: vi.fn(), + removeDeferredSshReconnectTarget: vi.fn(), + publishSshConnectionState: vi.fn() + } + vi.stubGlobal('window', { + api: { + app: { startupDiagnostic: undefined }, + ssh: { + listTargets: vi.fn().mockResolvedValue(targets), + connect: (args: { targetId: string }) => harness.connect(args.targetId), + getState: (args: { targetId: string }) => harness.getState(args.targetId) + } + } + }) +} + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('restoreSshConnectionsForStartup', () => { + it('does not wait on a target that owns no immediately-mounted pane', async () => { + vi.useFakeTimers() + installWindowApi([target('ssh-active'), target('ssh-asleep')]) + // The asleep host never answers — the old code awaited it for the full timeout. + harness.connect.mockImplementation((targetId: string) => + targetId === 'ssh-active' + ? Promise.resolve(connectedState(targetId)) + : new Promise(() => {}) + ) + + let settled = false + const restore = restoreSshConnectionsForStartup({ + connectionIds: ['ssh-active', 'ssh-asleep'], + blockingConnectionIds: ['ssh-active'], + setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget, + publishSshConnectionState: harness.publishSshConnectionState + }).then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(0) + await restore + expect(settled).toBe(true) + // Both were dialled; only the active one gated restoration. + expect(harness.connect).toHaveBeenCalledWith('ssh-active') + expect(harness.connect).toHaveBeenCalledWith('ssh-asleep') + expect(harness.publishSshConnectionState).toHaveBeenCalledWith( + 'ssh-active', + connectedState('ssh-active') + ) + // The unreachable host is deferred, so its panes reattach on tab focus. + expect(harness.setDeferredSshReconnectTargets).toHaveBeenCalledWith(['ssh-asleep']) + }) + + it('awaits the target that owns the active workspace', async () => { + vi.useFakeTimers() + installWindowApi([target('ssh-active')]) + harness.connect.mockReturnValue(new Promise(() => {})) + + let settled = false + const restore = restoreSshConnectionsForStartup({ + connectionIds: ['ssh-active'], + blockingConnectionIds: ['ssh-active'], + setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget, + publishSshConnectionState: harness.publishSshConnectionState + }).then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(14_000) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1_000) + await restore + expect(settled).toBe(true) + expect(harness.setDeferredSshReconnectTargets).toHaveBeenLastCalledWith(['ssh-active']) + }) + + it('clears the deferred flag once a background target connects', async () => { + installWindowApi([target('ssh-bg')]) + harness.connect.mockResolvedValue(connectedState('ssh-bg')) + + await restoreSshConnectionsForStartup({ + connectionIds: ['ssh-bg'], + blockingConnectionIds: [], + setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget, + publishSshConnectionState: harness.publishSshConnectionState + }) + await vi.waitFor(() => + expect(harness.removeDeferredSshReconnectTarget).toHaveBeenCalledWith('ssh-bg') + ) + expect(harness.publishSshConnectionState).toHaveBeenCalledWith( + 'ssh-bg', + connectedState('ssh-bg') + ) + }) + + it('does not push a connected background target back into the deferred list', async () => { + installWindowApi([target('ssh-active'), target('ssh-bg')]) + // The active host never answers and times out; the background host connects first. + harness.connect.mockImplementation((targetId: string) => + targetId === 'ssh-bg' + ? Promise.resolve(connectedState(targetId)) + : new Promise(() => {}) + ) + + await restoreSshConnectionsForStartup({ + connectionIds: ['ssh-active', 'ssh-bg'], + blockingConnectionIds: ['ssh-active'], + setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget, + publishSshConnectionState: harness.publishSshConnectionState + }) + + expect(harness.removeDeferredSshReconnectTarget).toHaveBeenCalledWith('ssh-bg') + // The timed-out rewrite must not resurrect the reachable background target: a deferred + // connected target sends fresh panes down the cold-restore path instead of the normal one. + expect(harness.setDeferredSshReconnectTargets).toHaveBeenLastCalledWith(['ssh-active']) + }, 30_000) + + it('keeps passphrase targets deferred and never dials them', async () => { + installWindowApi([target('ssh-key', true), target('ssh-bg')]) + harness.connect.mockResolvedValue(connectedState('ssh-bg')) + + await restoreSshConnectionsForStartup({ + connectionIds: ['ssh-key', 'ssh-bg'], + blockingConnectionIds: [], + setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget, + publishSshConnectionState: harness.publishSshConnectionState + }) + + expect(harness.connect).not.toHaveBeenCalledWith('ssh-key') + expect(harness.setDeferredSshReconnectTargets).toHaveBeenCalledWith(['ssh-key', 'ssh-bg']) + }) + + it('awaits every target when no blocking set is supplied', async () => { + vi.useFakeTimers() + installWindowApi([target('ssh-a'), target('ssh-b')]) + harness.connect.mockReturnValue(new Promise(() => {})) + + let settled = false + const restore = restoreSshConnectionsForStartup({ + connectionIds: ['ssh-a', 'ssh-b'], + setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget, + publishSshConnectionState: harness.publishSshConnectionState + }).then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(14_000) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1_000) + await restore + expect(harness.setDeferredSshReconnectTargets).toHaveBeenLastCalledWith(['ssh-a', 'ssh-b']) + }) +}) diff --git a/src/renderer/src/startup/startup-ssh-connection-restore.ts b/src/renderer/src/startup/startup-ssh-connection-restore.ts index b3ad1a42d61..35efaed2bfa 100644 --- a/src/renderer/src/startup/startup-ssh-connection-restore.ts +++ b/src/renderer/src/startup/startup-ssh-connection-restore.ts @@ -8,13 +8,27 @@ const SSH_RECONNECT_TIMEOUT_MS = 15_000 * Re-establishes the SSH targets that were live at shutdown before terminal reconnect, so * SSH-backed tabs route through pty.attach. Passphrase-protected and timed-out targets are * handed back as deferred so their PTYs reattach on tab focus instead of stacking dialogs. + * + * Only `blockingConnectionIds` are awaited. Every other target connects in the background and + * is registered as deferred up front, so an unreachable host cannot hold local terminal + * restoration for the reconnect timeout. Background connects keep running in main; the pane's + * deferred flow joins the same in-flight `ssh.connect` on tab focus. */ export async function restoreSshConnectionsForStartup(args: { connectionIds: string[] + /** Targets whose panes mount as soon as the startup gate opens. Omitted = await all. */ + blockingConnectionIds?: readonly string[] setDeferredSshReconnectTargets: (targetIds: string[]) => void + removeDeferredSshReconnectTarget: (targetId: string) => void publishSshConnectionState: (targetId: string, state: SshConnectionState) => void }): Promise { - const { connectionIds, setDeferredSshReconnectTargets, publishSshConnectionState } = args + const { + connectionIds, + blockingConnectionIds, + setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget, + publishSshConnectionState + } = args const allTargets = await timeRendererStartupStep('ssh-list-targets', () => window.api.ssh.listTargets() ) @@ -24,11 +38,42 @@ export async function restoreSshConnectionsForStartup(args: { needsPassphrase: targetMap.get(targetId)?.lastRequiredPassphrase ?? false })) - const eagerTargets = targets.filter((t) => !t.needsPassphrase) - const deferredTargets = targets.filter((t) => t.needsPassphrase) + const passphraseTargetIds = targets.filter((t) => t.needsPassphrase).map((t) => t.targetId) + const blocking = blockingConnectionIds ? new Set(blockingConnectionIds) : null + const eagerTargets = targets.filter( + (t) => !t.needsPassphrase && (blocking === null || blocking.has(t.targetId)) + ) + const backgroundTargets = targets.filter( + (t) => !t.needsPassphrase && blocking !== null && !blocking.has(t.targetId) + ) - if (deferredTargets.length > 0) { - setDeferredSshReconnectTargets(deferredTargets.map((t) => t.targetId)) + const deferredTargetIds = [...passphraseTargetIds, ...backgroundTargets.map((t) => t.targetId)] + if (deferredTargetIds.length > 0) { + setDeferredSshReconnectTargets(deferredTargetIds) + } + + // Why tracked: the timed-out branch below rewrites the whole deferred list, and a + // background target that already connected must not be pushed back into it. + const connectedBackgroundTargetIds = new Set() + // Why fired before the awaited group: a background target that lands before terminal + // reconnect reads as an ordinary connected target, exactly as it does today. + for (const { targetId } of backgroundTargets) { + void reconnectSshTargetForRendererStartup({ + targetId, + connect: (id) => window.api.ssh.connect({ targetId: id }), + publishState: (id, state) => { + publishSshConnectionState(id, state) + if (state.status === 'connected') { + // Why: a still-deferred connected target sends fresh panes down the deferred + // spawn path instead of the normal one. Clear it as soon as it is reachable. + connectedBackgroundTargetIds.add(id) + removeDeferredSshReconnectTarget(id) + } + }, + onFailure: (id, error) => { + console.warn(`SSH background auto-reconnect failed for ${id}:`, error) + } + }) } // Why: treat timed-out eager targets as deferred so their PTYs reattach on tab focus (ssh.connect keeps running in main and likely finishes by then). @@ -52,10 +97,17 @@ export async function restoreSshConnectionsForStartup(args: { } }) ), - { eagerTargets: eagerTargets.length, deferredTargets: deferredTargets.length } + { + eagerTargets: eagerTargets.length, + deferredTargets: passphraseTargetIds.length, + backgroundTargets: backgroundTargets.length + } ) if (timedOutTargets.length > 0) { - setDeferredSshReconnectTargets([...deferredTargets.map((t) => t.targetId), ...timedOutTargets]) + setDeferredSshReconnectTargets([ + ...deferredTargetIds.filter((id) => !connectedBackgroundTargetIds.has(id)), + ...timedOutTargets + ]) } // Why: older/wrapped providers may return no state from connect; poll main once as a compatibility fallback before terminal restoration. diff --git a/src/renderer/src/web/preload-api/web-app-api.ts b/src/renderer/src/web/preload-api/web-app-api.ts index f182b8a7790..28fd9f99176 100644 --- a/src/renderer/src/web/preload-api/web-app-api.ts +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -33,6 +33,7 @@ export function createWebAppApi(): Partial { // Staging already wrote through to browser storage, so there is nothing left to join. awaitBeforeUnloadCheckpoint: () => Promise.resolve(), awaitFirstWindowStartupServices: () => Promise.resolve(), + awaitGitEnvironmentStartupBarrier: () => Promise.resolve(), prepareTerminalStartupRestoration: () => Promise.resolve(), recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), startupDiagnostic: () => Promise.resolve(), diff --git a/tests/tools/benchmarks/startup-bench-state-fixture.mjs b/tests/tools/benchmarks/startup-bench-state-fixture.mjs new file mode 100644 index 00000000000..87f353bcfce --- /dev/null +++ b/tests/tools/benchmarks/startup-bench-state-fixture.mjs @@ -0,0 +1,193 @@ +/** + * Persisted-state fixtures for the startup benchmark: the git repos, GitHub + * remotes, restored terminal tabs, and unreachable SSH targets that `orca-data.json` + * must contain for a run to exercise the corresponding startup path. + */ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +function initFixtureGitRepo(repoDir) { + mkdirSync(repoDir, { recursive: true }) + if (!existsSync(join(repoDir, '.git'))) { + const init = spawnSync('git', ['init', repoDir], { stdio: 'ignore' }) + if (init.status !== 0) { + throw new Error(`Failed to create git repo fixture at ${repoDir}`) + } + } + return realpathSync(repoDir) +} + +/** + * Seed repos whose hydration reaches the `gh` login probe: a GitHub `origin` + * remote and no github.user/user.username config (the bench also points + * GIT_CONFIG_GLOBAL away from the developer's real config at launch). + */ +function buildGithubRepoFixtures(fixtureDir, githubRepos) { + const repos = [] + for (let i = 0; i < githubRepos; i++) { + const repoPath = initFixtureGitRepo(join(fixtureDir, `bench-gh-repo-${i}`)) + const remote = spawnSync( + 'git', + [ + '-C', + repoPath, + 'remote', + 'add', + 'origin', + `https://github.com/orca-bench/bench-gh-repo-${i}.git` + ], + { stdio: 'ignore' } + ) + // Exit 3 (remote exists) is fine on fixture reuse; anything else is not. + if (remote.status !== 0 && remote.status !== 3) { + throw new Error(`Failed to add GitHub remote to ${repoPath}`) + } + repos.push({ + id: `bench-gh-repo-${i}`, + path: repoPath, + displayName: `Bench GH Repo ${i}`, + badgeColor: '#000000', + addedAt: 1, + externalWorktreeVisibility: 'show' + }) + } + return repos +} + +/** + * SSH targets on TEST-NET-3 (RFC 5737). The address is guaranteed unroutable, + * so the TCP handshake never completes and never gets a reset — the wire + * behaviour of a host that is asleep or behind a dropped VPN. + */ +function buildUnreachableSshTargets(count) { + const targets = [] + for (let i = 0; i < count; i++) { + targets.push({ + id: `bench-ssh-unreachable-${i}`, + label: `Unreachable Host ${i}`, + host: `203.0.113.${i + 1}`, + port: 22, + username: 'orca', + source: 'manual', + lastRequiredPassphrase: false + }) + } + return targets +} + +export function writePersistedStateFixture( + fixtureDir, + { stateProfile, sessionTabs, githubRepos, sshUnreachableTargets = 0 } +) { + const dataPath = join(fixtureDir, 'orca-data.json') + if (stateProfile === 'none' && githubRepos === 0 && sshUnreachableTargets === 0) { + try { + unlinkSync(dataPath) + } catch { + // no persisted state fixture + } + return 0 + } + if (!['none', 'restored-local-tabs'].includes(stateProfile)) { + throw new Error(`Unknown state profile: ${stateProfile}`) + } + + const githubRepoEntries = buildGithubRepoFixtures(fixtureDir, githubRepos) + const sshTargets = buildUnreachableSshTargets(sshUnreachableTargets) + if (stateProfile === 'none') { + const state = { + schemaVersion: 1, + ...(sshTargets.length > 0 ? { sshTargets } : {}), + repos: githubRepoEntries, + settings: { + telemetry: { + installId: 'startup-bench', + optedIn: false, + existedBeforeTelemetryRelease: true + } + } + } + const json = JSON.stringify(state, null, 2) + writeFileSync(dataPath, json, 'utf-8') + return Buffer.byteLength(json) + } + + const repoPath = initFixtureGitRepo(join(fixtureDir, 'bench-repo')) + const repoId = 'bench-repo' + const worktreeId = `${repoId}::${repoPath}` + const tabCount = Math.max(1, sessionTabs) + const tabs = [] + const terminalLayoutsByTabId = {} + const activeTabIdByWorktree = {} + for (let i = 0; i < tabCount; i++) { + const tabId = `bench-tab-${String(i).padStart(5, '0')}` + const ptyId = `bench-pty-${String(i).padStart(5, '0')}` + tabs.push({ + id: tabId, + ptyId, + worktreeId, + title: `Terminal ${i + 1}`, + customTitle: null, + color: null, + sortOrder: i, + createdAt: 1 + }) + terminalLayoutsByTabId[tabId] = { + root: null, + activeLeafId: null, + expandedLeafId: null + } + } + activeTabIdByWorktree[worktreeId] = tabs[0]?.id ?? null + const state = { + schemaVersion: 1, + repos: [ + { + id: repoId, + path: repoPath, + displayName: 'Bench Repo', + badgeColor: '#000000', + addedAt: 1, + externalWorktreeVisibility: 'show' + }, + ...githubRepoEntries + ], + settings: { + telemetry: { + installId: 'startup-bench', + optedIn: false, + existedBeforeTelemetryRelease: true + } + }, + ui: { + lastActiveRepoId: repoId, + lastActiveWorktreeId: worktreeId + }, + workspaceSession: { + activeRepoId: repoId, + activeWorktreeId: worktreeId, + activeTabId: tabs[0]?.id ?? null, + tabsByWorktree: { + [worktreeId]: tabs + }, + terminalLayoutsByTabId, + activeTabIdByWorktree, + activeWorktreeIdsOnShutdown: [worktreeId], + defaultTerminalTabsAppliedByWorktreeId: { + [worktreeId]: true + }, + // Why on the session and not just the target list: startup reconnect only + // dials targets that were connected at shutdown. + ...(sshTargets.length > 0 + ? { activeConnectionIdsAtShutdown: sshTargets.map((target) => target.id) } + : {}) + } + } + if (sshTargets.length > 0) { + state.sshTargets = sshTargets + } + const json = JSON.stringify(state, null, 2) + writeFileSync(dataPath, json, 'utf-8') + return Buffer.byteLength(json) +} diff --git a/tests/tools/benchmarks/startup-time-bench.mjs b/tests/tools/benchmarks/startup-time-bench.mjs index 29363432c7a..e39f2d1d133 100644 --- a/tests/tools/benchmarks/startup-time-bench.mjs +++ b/tests/tools/benchmarks/startup-time-bench.mjs @@ -12,6 +12,7 @@ * node tests/tools/benchmarks/startup-time-bench.mjs --label baseline * [--iterations 5] [--files 28000] [--fixture-dir ] * [--state-profile none|restored-local-tabs] [--session-tabs 200] + * [--ssh-unreachable-targets 1] * [--github-repos 3] [--gh-hang-ms 30000] * [--wait-for-event renderer-startup-hydration-done] * [--exe ] [--timeout-ms 240000] @@ -27,20 +28,14 @@ * Results: tests/tools/benchmarks/results/startup-
diff --git a/src/renderer/src/components/landing-github-star-state.ts b/src/renderer/src/components/landing-github-star-state.ts new file mode 100644 index 00000000000..098646b00f5 --- /dev/null +++ b/src/renderer/src/components/landing-github-star-state.ts @@ -0,0 +1,39 @@ +import { useEffect, useState, type Dispatch, type SetStateAction } from 'react' + +export type LandingStarState = 'loading' | 'starred' | 'not-starred' | 'web-fallback' | 'hidden' + +/** + * Resolve the viewer's Orca star state once per Landing mount. + * + * Why it lives here and not in the star button: the button renders inside a + * footer that is conditionally mounted on whether `repos` currently carries a + * GitHub provider identity, and `repos` is rewritten wholesale on every + * repo-catalog push. Every flicker of that condition re-ran the button's mount + * effect and forked another `gh api user/starred/...` (#18234). Landing itself + * only mounts when the user navigates, so the check runs once per visit. + */ +export function useLandingOrcaStarState(): [ + LandingStarState, + Dispatch> +] { + const [state, setState] = useState('loading') + + useEffect(() => { + let cancelled = false + void window.api.gh.checkOrcaStarred().then((result) => { + if (cancelled) { + return + } + if (result === null) { + setState('web-fallback') + } else { + setState(result ? 'starred' : 'not-starred') + } + }) + return () => { + cancelled = true + } + }, []) + + return [state, setState] +} From efe330549028f8fa5984a5a30311c399b3a88cb9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:31:02 -0700 Subject: [PATCH 23/25] test(editor): isolate prefix bleed in the batched model sweep, drop a dead export (#18240) Readiness-review follow-ups to #18144. The parity test in closed-editor-tab-disposal.test.ts cannot see prefix bleed: buildScenario closes tab-0..tab-99, so tab-10 is in the closed batch too and the per-tab oracle disposes its models via tab-10's own prefix. Batched and oracle agree and the assertion passes even with a bleeding predicate. Verified by mutation: replacing the boundary probe with a naive startsWith leaves all five of that file's tests green. Adds a test through the batched disposeClosedEditorTabs entry point with a still-OPEN tab-10 alongside a closed tab-1, which does fail under that mutation. Also records the `boundary + 1` advance in hasPaneScopeOwner as load-bearing for `:::` runs, with a test that fails under a `+ 2` "tidy-up", and removes disposeUnattachedMonacoModelsByPathPrefix, which #18144 left with zero production callers and a comment claiming it was kept for callers that do not exist. Finally, documents why title-derived rows carry `startedAt: 0`, which is the sole reason their `now` stamps cannot move a dashboard bucket. --- .../closed-editor-tab-cache-sweep.test.ts | 45 ++++++++++++++++++- .../editor/closed-editor-tab-cache-sweep.ts | 3 ++ .../editor/closed-editor-tab-disposal.test.ts | 24 ++++++++++ .../editor/diff-monaco-model-disposal.test.ts | 6 +-- .../editor/diff-monaco-model-disposal.ts | 7 --- .../worktree-title-derived-agent-rows.ts | 6 +++ 6 files changed, 80 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts b/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts index 726795a31dd..9d3b7ff042d 100644 --- a/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts +++ b/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts @@ -1,9 +1,52 @@ import { describe, expect, it } from 'vitest' import type { PdfViewPosition } from '@/lib/scroll-cache' -import { sweepClosedPdfViewPositions } from './closed-editor-tab-cache-sweep' +import { + deletePaneScopedCacheEntries, + sweepClosedPdfViewPositions +} from './closed-editor-tab-cache-sweep' const position = (pageNumber: number): PdfViewPosition => ({ pageNumber, top: 0, left: 0 }) +describe('deletePaneScopedCacheEntries', () => { + it('does not delete an owner whose id merely extends a closed owner id', () => { + const cache = new Map([ + ['tab-1::pane-1', 1], + ['tab-10::pane-1', 2], + ['tab-1x::pane-1', 3] + ]) + deletePaneScopedCacheEntries(cache, ['tab-1']) + expect([...cache.keys()]).toEqual(['tab-10::pane-1', 'tab-1x::pane-1']) + }) + + it('matches an owner that ends at the second `::` of a `:::` run', () => { + // Locks the `boundary + 1` advance in hasPaneScopeOwner: `a:` ends at index 2, which only the + // second `::` of the run exposes. A `+ 2` advance would skip it and leak the entry. + const cache = new Map([ + ['a:::b', 1], + ['a::b', 2], + ['ab:::c', 3] + ]) + deletePaneScopedCacheEntries(cache, ['a:']) + expect([...cache.keys()]).toEqual(['a::b', 'ab:::c']) + }) + + it('sweeps every owner in the batch in one pass', () => { + const cache = new Map([ + ['tab-1::pane-1', 1], + ['tab-2::pane-1', 2], + ['tab-3::pane-1', 3] + ]) + deletePaneScopedCacheEntries(cache, ['tab-1', 'tab-3']) + expect([...cache.keys()]).toEqual(['tab-2::pane-1']) + }) + + it('is a no-op for an empty owner batch', () => { + const cache = new Map([['tab-1::pane-1', 1]]) + deletePaneScopedCacheEntries(cache, []) + expect(cache.size).toBe(1) + }) +}) + describe('sweepClosedPdfViewPositions', () => { it('deletes the unscoped :pdf entry', () => { const cache = new Map([['/a.pdf:pdf', position(4)]]) diff --git a/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.ts b/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.ts index b9769fb9dc8..a553625c838 100644 --- a/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.ts +++ b/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.ts @@ -26,6 +26,9 @@ function hasPaneScopeOwner(key: string, owners: ReadonlySet): boolean { for ( let boundary = key.indexOf('::'); boundary !== -1; + // `+ 1`, not `+ 2`: in a `:::` run the second `::` starts one char after the first, and it can + // be the only boundary an owner ends at (owner `a:` against key `a:::b`). Skipping to `+ 2` + // steps over it and silently leaks that entry. boundary = key.indexOf('::', boundary + 1) ) { if (owners.has(key.slice(0, boundary))) { diff --git a/src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts b/src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts index 0223a4394b0..f23876fcf35 100644 --- a/src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts +++ b/src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts @@ -226,6 +226,30 @@ describe('disposeClosedEditorTabs', () => { expect(scrollTopCache.size).toBe(0) }) + // Why this is not covered by the parity test above: `buildScenario` closes tab-0..tab-99, so + // tab-10 is in the closed batch too. Prefix bleed from tab-1 would dispose tab-10's models, but + // the per-tab oracle disposes them as well via tab-10's own prefix, so the two agree and the + // assertion still passes. Isolating it needs a still-OPEN tab whose id extends a closed one. + it('does not dispose a still-open tab whose id extends a closed tab id', () => { + const closed = getDiffViewerMonacoModelPaths({ modelKey: 'tab-1', generationSuffix: '' }) + const stillOpen = getDiffViewerMonacoModelPaths({ modelKey: 'tab-10', generationSuffix: '' }) + const models = [ + createModel(closed.originalModelPath), + createModel(closed.modifiedModelPath), + createModel(stillOpen.originalModelPath), + createModel(stillOpen.modifiedModelPath) + ] + + // Batched entry point on purpose: the owned prefixes become a Set probed at the URI's own `:` + // boundaries, which is a different predicate from the pre-batch per-prefix `startsWith`. + disposeClosedEditorTabs(createRegistry(models), [diffTab('tab-1'), diffTab('tab-2')]) + + expect(models.filter((m) => m.disposed).map((m) => m.path)).toEqual([ + closed.originalModelPath, + closed.modifiedModelPath + ]) + }) + it('is a no-op when nothing closed', () => { const registry = createRegistry([createModel('diff:original:tab-1:tab-1')]) disposeClosedEditorTabs(registry, []) diff --git a/src/renderer/src/components/editor/diff-monaco-model-disposal.test.ts b/src/renderer/src/components/editor/diff-monaco-model-disposal.test.ts index dcefc61195d..75ccc46eff2 100644 --- a/src/renderer/src/components/editor/diff-monaco-model-disposal.test.ts +++ b/src/renderer/src/components/editor/diff-monaco-model-disposal.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { disposeUnattachedDiffViewerMonacoModels, disposeUnattachedMonacoModelPaths, - disposeUnattachedMonacoModelsByPathPrefix, + disposeUnattachedMonacoModelsByPathPrefixes, getDiffViewerMonacoModelPathPrefixes, getDiffViewerMonacoModelPaths } from './diff-monaco-model-disposal' @@ -139,7 +139,7 @@ describe('diff Monaco model disposal', () => { const monacoRegistry = createRegistry(models) const { originalModelPathPrefix } = getDiffViewerMonacoModelPathPrefixes('tab-1') - disposeUnattachedMonacoModelsByPathPrefix(monacoRegistry, originalModelPathPrefix) + disposeUnattachedMonacoModelsByPathPrefixes(monacoRegistry, [originalModelPathPrefix]) expect(baseDispose).toHaveBeenCalledOnce() expect(generatedDispose).toHaveBeenCalledOnce() @@ -177,7 +177,7 @@ describe('diff Monaco model disposal', () => { const monacoRegistry = createRegistry(models) const { originalModelPathPrefix } = getDiffViewerMonacoModelPathPrefixes('foo') - disposeUnattachedMonacoModelsByPathPrefix(monacoRegistry, originalModelPathPrefix) + disposeUnattachedMonacoModelsByPathPrefixes(monacoRegistry, [originalModelPathPrefix]) expect(ownedDispose).toHaveBeenCalledOnce() expect(siblingDispose).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/editor/diff-monaco-model-disposal.ts b/src/renderer/src/components/editor/diff-monaco-model-disposal.ts index f833e53ec5b..2a1b5ceed5d 100644 --- a/src/renderer/src/components/editor/diff-monaco-model-disposal.ts +++ b/src/renderer/src/components/editor/diff-monaco-model-disposal.ts @@ -79,13 +79,6 @@ export function disposeUnattachedMonacoModelPaths( } } -export function disposeUnattachedMonacoModelsByPathPrefix( - monacoRegistry: MonacoModelRegistry, - modelPathPrefix: string -): void { - disposeUnattachedMonacoModelsByPathPrefixes(monacoRegistry, [modelPathPrefix]) -} - /** * Sweeps every owned prefix in a single scan of the global model registry. * diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 25d87c50801..08c0d27b9e7 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -215,6 +215,12 @@ function buildTitleDerivedAgentRow(args: { agentType, rowSource: 'live', state: rowState, + // Load-bearing zero, not a placeholder: `dashboardRowBucketProjection` reads `startedAt === 0` + // as "title-derived" and short-circuits `unseen`. That is the ONLY reason the `args.now` stamps + // on `entry` above (updatedAt / stateStartedAt / observation) cannot move this row's bucket. + // Dashboard bucket caches key their invalidation on the freshness boundary in + // `isExplicitAgentStatusFresh` alone; give this a real timestamp and every one of them starts + // serving stale counts, with no test failing at the point of the change. startedAt: 0 } } From fda9cae5e27a0d8533a1ee76ee70436f59856a16 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:32:06 -0700 Subject: [PATCH 24/25] perf(renderer): build useRef seeds once instead of every render (#18159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(renderer): stop re-running useRef initializers and ref-mirror effects every render React evaluates the argument you pass to `useRef` on every render and discards every result after the first. 30 renderer sites did real work in there — walking every browser page/tab across all worktrees, building activation-order maps, and minting `crypto.randomUUID()` per render on browser pages and the AI vault. Also moves 12 verbatim ref-mirror Effects to render-phase assignment, and routes the `tab.rename` shortcut straight to the focused tab instead of through a store field every mounted tab subscribed to. * perf(renderer): drop Fix 2 (render-phase ref mirrors) to satisfy no-ref-current-in-render * perf(renderer): convert the four lazy-useRef sites that landed on main --- .../src/app-shell/app-command-handlers.ts | 3 +- .../activity/use-agent-pane-threads.ts | 6 +- .../use-browser-page-webview-lifecycle.ts | 3 +- .../use-browser-page-zoom-feedback.ts | 4 +- .../use-remote-browser-page-input.ts | 3 +- ...-doc-preview-guest-tools.lazy-ref.test.tsx | 76 ++++++++++ .../use-doc-preview-guest-tools.ts | 3 +- .../dashboard/useAgentBucketCounts.ts | 3 +- .../dashboard/useLiveDashboardSnapshot.ts | 3 +- .../combined-diff-section-load-registry.ts | 9 +- .../use-combined-diff-view-restore.ts | 19 +-- .../use-emulator-pane-session.ts | 6 +- .../use-feature-wall-session-depth.ts | 5 +- .../use-feature-wall-tour-telemetry.ts | 3 +- .../FloatingTerminalPanel.shortcuts.test.tsx | 17 ++- .../floating-terminal-panel-test-fixtures.ts | 2 - .../floating-terminal-panel-test-harness.ts | 5 +- .../use-floating-terminal-panel-shortcuts.ts | 3 +- .../use-native-chat-live-session.ts | 3 +- .../use-native-chat-retained-session.ts | 3 +- .../right-sidebar/ai-vault-session-refresh.ts | 6 +- .../source-control/sync/git-history-panel.tsx | 3 +- .../useCreatePullRequestDialogFields.ts | 5 +- .../right-sidebar/useFileExplorerTree.ts | 3 +- .../settings/CommitMessageAiPane.tsx | 3 +- .../repository-source-control-ai-global-ux.ts | 35 +++-- .../use-settings-interaction-controller.ts | 3 +- .../use-workspace-kanban-column-resize.ts | 4 +- .../viewport/use-scroll-to-top.ts | 3 +- .../SortableTab.rename-shortcut.test.tsx | 42 ++++-- .../src/components/tab-bar/SortableTab.tsx | 29 ++-- .../SortableTab.update-depth-probe.test.tsx | 62 +++++++-- .../tab-bar/terminal-tab-rename-request.ts | 19 +++ .../use-terminal-tab-cold-parking.ts | 5 +- .../TerminalQuickCommandDialog.tsx | 5 +- ...erminal-window-lifecycle.lazy-ref.test.tsx | 86 ++++++++++++ .../use-terminal-window-lifecycle.ts | 11 +- .../composer-state/async-composer-state.ts | 31 +++-- .../composer-state/composer-drop-listener.ts | 3 +- src/renderer/src/hooks/use-audio-capture.ts | 3 +- src/renderer/src/lazy-use-ref-ratchet.test.ts | 130 ++++++++++++++++++ .../slices/tabs-label-and-pin-state.test.ts | 8 -- .../store/slices/tabs/create-tabs-slice.ts | 1 - .../store/slices/tabs/tabs-label-actions.ts | 5 - .../store/slices/tabs/tabs-slice-contract.ts | 3 - 45 files changed, 544 insertions(+), 143 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.lazy-ref.test.tsx create mode 100644 src/renderer/src/components/tab-bar/terminal-tab-rename-request.ts create mode 100644 src/renderer/src/components/use-terminal-window-lifecycle.lazy-ref.test.tsx create mode 100644 src/renderer/src/lazy-use-ref-ratchet.test.ts diff --git a/src/renderer/src/app-shell/app-command-handlers.ts b/src/renderer/src/app-shell/app-command-handlers.ts index 99915597e67..bb60f898130 100644 --- a/src/renderer/src/app-shell/app-command-handlers.ts +++ b/src/renderer/src/app-shell/app-command-handlers.ts @@ -5,6 +5,7 @@ import { requestScrollToCurrentWorkspaceRevealAndRename } from '@/lib/scroll-to- import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' import { shouldShowWorktreeHistoryControls } from '../lib/titlebar-worktree-history-controls' import { TOGGLE_WORKSPACE_BOARD_EVENT } from '../components/sidebar/useWorkspaceBoardPanel' +import { requestTerminalTabRename } from '../components/tab-bar/terminal-tab-rename-request' import { deleteHoveredWorkspaceImmediately, resolveHoveredWorkspaceDeleteTarget @@ -180,7 +181,7 @@ export function createAppCommandHandlers( ) { return false } - return claim('tab.rename', () => store.setRenamingTabId(store.activeTabId!)) + return claim('tab.rename', () => requestTerminalTabRename(store.activeTabId!)) } ], [ diff --git a/src/renderer/src/components/activity/use-agent-pane-threads.ts b/src/renderer/src/components/activity/use-agent-pane-threads.ts index 01eb0475a02..1934488439e 100644 --- a/src/renderer/src/components/activity/use-agent-pane-threads.ts +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -121,8 +121,10 @@ export function useAgentPaneThreads(args: { // identities across rebuilds, so a status write to one agent leaves every other row's // memo bail-out and cached search text intact. Rebuilds are deterministic, so a repeated // (StrictMode/deferred) memo invocation returns identical objects from the cache. - const eventBuildCacheRef = useRef(createActivityEventBuildCache()) - const threadReuseCacheRef = useRef(createAgentPaneThreadReuseCache()) + const eventBuildCacheRef = useRef>(undefined!) + eventBuildCacheRef.current ??= createActivityEventBuildCache() + const threadReuseCacheRef = useRef>(undefined!) + threadReuseCacheRef.current ??= createAgentPaneThreadReuseCache() const { events: allEvents, liveAgentByPaneKey } = useMemo( () => diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts index a8de0e1e254..72859a415b5 100644 --- a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts @@ -132,7 +132,8 @@ export function useBrowserPageWebviewLifecycle({ const addBrowserHistoryEntryRef = useRef(addBrowserHistoryEntry) const createBrowserTab = useAppStore((s) => s.createBrowserTab) const isPaintableRef = useRef(isPaintable) - const annotationViewportBridgeTokenRef = useRef(createBrowserUuid().replaceAll('-', '')) + const annotationViewportBridgeTokenRef = useRef(undefined!) + annotationViewportBridgeTokenRef.current ??= createBrowserUuid().replaceAll('-', '') const isActiveRef = useRef(isActive) const pendingAnnotationPayloadRef = useRef(pendingAnnotationPayload) const browserAnnotations = useAppStore( diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-zoom-feedback.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-zoom-feedback.ts index 368d66d5e8d..09474d21418 100644 --- a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-zoom-feedback.ts +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-zoom-feedback.ts @@ -28,9 +28,9 @@ export function useBrowserPageZoomFeedback(browserTabId: string): { // tab's zoom through the shared setting. Why the module-level lookup: the guest webview outlives // this component (worktree switch, Settings visit), so re-seeding on remount would let a later // Settings change retroactively hijack a tab the user already zoomed. - const paneZoomLevelRef = useRef( + const paneZoomLevelRef = useRef(undefined!) + paneZoomLevelRef.current ??= getExplicitBrowserPageZoomLevel(browserTabId) ?? normalizedBrowserDefaultZoomLevel - ) const [browserZoomPercent, setBrowserZoomPercent] = useState(browserDefaultZoomPercent) const [browserZoomFeedbackVisible, setBrowserZoomFeedbackVisible] = useState(false) const browserZoomFeedbackTimerRef = useRef>(undefined) diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts index 64002a1208a..c58a5c56a1a 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts @@ -28,7 +28,8 @@ export function useRemoteBrowserPageInputQueue(): { remoteWheelFrameRef: React.MutableRefObject remoteWheelInFlightRef: React.MutableRefObject } { - const remoteInputQueueRef = useRef>(Promise.resolve()) + const remoteInputQueueRef = useRef>(undefined!) + remoteInputQueueRef.current ??= Promise.resolve() const pendingRemoteWheelRef = useRef(null) const remoteWheelFrameRef = useRef(null) const remoteWheelInFlightRef = useRef(false) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.lazy-ref.test.tsx b/src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.lazy-ref.test.tsx new file mode 100644 index 00000000000..d68cd200c21 --- /dev/null +++ b/src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.lazy-ref.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment happy-dom + +/** + * The annotation viewport bridge token used to sit in a `useRef(...)` argument, so every render of + * a doc preview minted a fresh `crypto.randomUUID()` and threw it away — only the mount-time token + * was ever read. Pin the mint count to the mount count. + */ +import { useState } from 'react' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const browserUuidCalls = vi.hoisted(() => ({ count: 0 })) + +vi.mock('@/lib/browser-uuid', () => ({ + createBrowserUuid: () => { + browserUuidCalls.count += 1 + return `00000000-0000-4000-8000-${String(browserUuidCalls.count).padStart(12, '0')}` + } +})) + +vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutLabel: () => 'Cmd+G' })) +vi.mock('@/components/browser-pane/annotate/guest-annotation-viewport-bridge', () => ({ + syncGuestAnnotationViewportBridge: vi.fn() +})) +vi.mock('@/components/browser-pane/annotate/use-browser-page-annotation-send', () => ({ + useBrowserPageAnnotationSend: () => ({ + browserAnnotations: [], + setBrowserAnnotationTrayOpen: vi.fn() + }) +})) +vi.mock('@/components/browser-pane/annotate/use-browser-page-grab-annotations', () => ({ + useBrowserPageGrabAnnotations: () => ({}) +})) +vi.mock('@/components/browser-pane/annotate/use-browser-page-markup-capture', () => ({ + useBrowserPageMarkupCapture: () => ({}) +})) +vi.mock('@/components/browser-pane/annotate/useGrabMode', () => ({ + useGrabMode: () => ({ active: false }) +})) + +const { useDocPreviewGuestTools } = await import('./use-doc-preview-guest-tools') + +let bumpRender: (() => void) | null = null + +function Host(): null { + const [, setTick] = useState(0) + bumpRender = () => setTick((tick) => tick + 1) + useDocPreviewGuestTools({ + previewId: 'preview-1', + worktreeId: 'wt-1', + grantId: 'grant-1', + webviewRef: { current: null }, + containerRef: { current: null }, + toolsReady: true + } as unknown as Parameters[0]) + return null +} + +afterEach(() => { + cleanup() + browserUuidCalls.count = 0 + bumpRender = null +}) + +describe('useDocPreviewGuestTools annotation bridge token', () => { + it('mints the bridge token once per mount, not once per render', () => { + render() + expect(browserUuidCalls.count).toBe(1) + + for (let i = 0; i < 20; i += 1) { + act(() => bumpRender?.()) + } + + expect(browserUuidCalls.count).toBe(1) + }) +}) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.ts b/src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.ts index 9eec9b8affe..83706aa3fcd 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.ts +++ b/src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.ts @@ -41,7 +41,8 @@ export function useDocPreviewGuestTools({ // Why still empty before the first grant: the page is only a tool target once a document is on // screen, and useGrabMode needs a stable identity every render rather than one to guess with. const toolTargetId = grantId === null ? '' : previewId - const annotationViewportBridgeTokenRef = useRef(createBrowserUuid().replaceAll('-', '')) + const annotationViewportBridgeTokenRef = useRef(undefined!) + annotationViewportBridgeTokenRef.current ??= createBrowserUuid().replaceAll('-', '') const [browserOverlayViewport, setBrowserOverlayViewport] = useState({ scrollX: 0, scrollY: 0, diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts index 60df4995e56..0e8c2881244 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts @@ -51,7 +51,8 @@ export function useAgentBucketCounts(): AgentBucketCounts { // Why a per-hook cache: unrelated status/title writes change one worktree's inputs; // the cache keeps every other worktree's counts without rerunning its row pipeline. - const cacheRef = useRef(createDashboardBucketCountsCache()) + const cacheRef = useRef>(undefined!) + cacheRef.current ??= createDashboardBucketCountsCache() return useMemo(() => { return buildDashboardBucketCounts( { diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts index 227cf4e3f8b..52270167ef9 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts @@ -11,7 +11,8 @@ import { createWorktreeAgentRowsCache } from './worktree-agent-rows-cache' * is no relay, so we derive it here from the same builder the bridge uses. */ export function useLiveDashboardSnapshot(): DashboardSnapshot { - const rowsCacheRef = useRef(createWorktreeAgentRowsCache()) + const rowsCacheRef = useRef>(undefined!) + rowsCacheRef.current ??= createWorktreeAgentRowsCache() const repos = useAppStore((s) => s.repos) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-registry.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-registry.ts index c74d03d7692..370dbd6ebf0 100644 --- a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-registry.ts +++ b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-registry.ts @@ -47,11 +47,10 @@ export function useCombinedDiffSectionLoadRegistry( const loadSectionRef = useRef<(index: number) => Promise>(async () => {}) const retrySectionRef = useRef<(index: number) => void>(() => {}) const requestSectionReloadRef = useRef<(index: number) => void>(() => {}) - const loadSchedulerRef = useRef( - createCombinedDiffLoadScheduler({ - loadSection: (index) => loadSectionRef.current(index) - }) - ) + const loadSchedulerRef = useRef>(undefined!) + loadSchedulerRef.current ??= createCombinedDiffLoadScheduler({ + loadSection: (index) => loadSectionRef.current(index) + }) sectionsRef.current = sections useEffect(() => { diff --git a/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.ts b/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.ts index 9f657e3abd0..7fcc833df91 100644 --- a/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.ts +++ b/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.ts @@ -1,4 +1,4 @@ -import { useCallback, useLayoutEffect, useRef } from 'react' +import { useCallback, useLayoutEffect, useRef, useState } from 'react' import type React from 'react' import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' import type { GitStatusEntry } from '../../../../../../shared/git-status-types' @@ -65,13 +65,16 @@ export function useCombinedDiffViewRestore({ sectionLoadTokensRef } = registry - const scrollOffsetRef = useRef(combinedDiffScrollTopCache.get(viewStateKey) ?? 0) - const scrollAnchorRef = useRef( - combinedDiffScrollAnchorCache.get(viewStateKey) ?? null - ) - const latestDomScrollAnchorRef = useRef( - combinedDiffScrollAnchorCache.get(viewStateKey) ?? null - ) + // Why useState and not `useRef(expr)`: the latter re-reads all three caches on every render and + // throws the result away, and an anchor seeds legitimately to null so a nullish guard would keep + // re-reading. useState's initializer runs once without writing a ref during render. + const [restoreSeed] = useState<{ offset: number; anchor: VirtualizedScrollAnchor }>(() => ({ + offset: combinedDiffScrollTopCache.get(viewStateKey) ?? 0, + anchor: combinedDiffScrollAnchorCache.get(viewStateKey) ?? null + })) + const scrollOffsetRef = useRef(restoreSeed.offset) + const scrollAnchorRef = useRef(restoreSeed.anchor) + const latestDomScrollAnchorRef = useRef(restoreSeed.anchor) // Why: tab/worktree switches unmount this viewer; cache by pane key so remount restores sections+scroll before repaint. const initializedEntryStateRef = useRef<{ diff --git a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts index 2ac04a9ca99..c696374526e 100644 --- a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts +++ b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts @@ -39,11 +39,13 @@ export function useEmulatorPaneSession({ const configuredDefaultUdid = useAppStore( (state) => state.settings?.mobileEmulatorDefaultDeviceUdid ?? null ) - const prelaunchedSessionRef = useRef( + // Why the lazy initializer: the consume deletes the handoff entry, and a `useRef(expr)` argument + // re-runs every render — so a prelaunch registered after mount was consumed and then discarded. + const [prelaunchedSession] = useState(() => consumePrelaunchedSimulatorSession(worktreeId) ) const prelaunchedState = buildPrelaunchedEmulatorSessionState( - prelaunchedSessionRef.current, + prelaunchedSession, configuredDefaultUdid ) const [selectedUdid, setSelectedUdid] = useState(prelaunchedState.selectedUdid) diff --git a/src/renderer/src/components/feature-wall/use-feature-wall-session-depth.ts b/src/renderer/src/components/feature-wall/use-feature-wall-session-depth.ts index c4d57043410..9ac9fcaa48f 100644 --- a/src/renderer/src/components/feature-wall/use-feature-wall-session-depth.ts +++ b/src/renderer/src/components/feature-wall/use-feature-wall-session-depth.ts @@ -47,13 +47,14 @@ export function useFeatureWallSessionDepth( visitedWorkbenchSteps: Set visitedReviewSteps: Set lastGroupId: FeatureWallWorkflowId | null - }>({ + }>(undefined!) + sessionDepthRef.current ??= { visitedWorkflows: new Set(), visitedAgentSteps: new Set(), visitedWorkbenchSteps: new Set(), visitedReviewSteps: new Set(), lastGroupId: null - }) + } const getTourDepthSummary = useCallback((): FeatureWallTourDepthSummary => { const session = sessionDepthRef.current diff --git a/src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.ts b/src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.ts index 7f98c90520a..23134e2a4a6 100644 --- a/src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.ts +++ b/src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.ts @@ -77,7 +77,8 @@ export function useFeatureWallTourTelemetry(args: { getDepthSummary: () => FeatureWallTourDepthSummary }): { markExitAction: (exitAction: FeatureWallExitAction) => void } { const { isOpen, source, getDepthSummary } = args - const telemetryRef = useRef(createFeatureWallTourTelemetryState()) + const telemetryRef = useRef(undefined!) + telemetryRef.current ??= createFeatureWallTourTelemetryState() const sourceRef = useRef(source) const getDepthSummaryRef = useRef(getDepthSummary) // Why: close telemetry may emit from stable callbacks; keep the payload diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx index 3593643b50c..3e4de4227e7 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx @@ -11,6 +11,10 @@ import { type FloatingPanelStoreState } from './floating-terminal-panel-test-fixtures' import { mocks, setupFloatingTerminalPanelTest } from './floating-terminal-panel-test-harness' +import { + RENAME_TERMINAL_TAB_EVENT, + type RenameTerminalTabDetail +} from '@/components/tab-bar/terminal-tab-rename-request' import { attachRef, bindFocusedFloatingPanelKeydown, @@ -159,6 +163,15 @@ vi.mock('@/components/ShortcutKeyCombo', async () => { return (await import('./floating-terminal-panel-component-stubs')).createShortcutKeyComboModule() }) +/** Tab ids the panel asked to rename, in dispatch order. */ +function dispatchedRenameTabIds(): string[] { + return vi + .mocked(window.dispatchEvent) + .mock.calls.map(([event]) => event as CustomEvent) + .filter((event) => event.type === RENAME_TERMINAL_TAB_EVENT) + .map((event) => event.detail.tabId) +} + describe('FloatingTerminalPanel close behavior', () => { beforeEach(setupFloatingTerminalPanelTest) @@ -434,7 +447,7 @@ describe('FloatingTerminalPanel close behavior', () => { expect(preventDefault).toHaveBeenCalledWith() expect(stopPropagation).toHaveBeenCalledWith() expect(stopImmediatePropagation).toHaveBeenCalledWith() - expect(mocks.setRenamingTabId).toHaveBeenCalledWith('tab-1') + expect(dispatchedRenameTabIds()).toEqual(['tab-1']) expect(mocks.setTabCustomTitle).not.toHaveBeenCalled() }) @@ -607,7 +620,7 @@ describe('FloatingTerminalPanel close behavior', () => { }) ) - expect(mocks.setRenamingTabId).not.toHaveBeenCalled() + expect(dispatchedRenameTabIds()).toEqual([]) }) it('leaves focused floating xterm tab index shortcuts to terminal-first terminals', async () => { diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts index ac6fc0c1cf4..b51cb9a6d78 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts @@ -15,7 +15,6 @@ export type FloatingPanelStoreState = { activeGroupIdByWorktree: Record activeTabIdByWorktree: Record expandedPaneByTabId: Record - renamingTabId: string | null createTab: ( worktreeId: string, groupId?: string, @@ -42,7 +41,6 @@ export type FloatingPanelStoreState = { activateTab: (tabId: string) => void setActiveTab: (tabId: string) => void setTabCustomTitle: (tabId: string, title: string | null) => void - setRenamingTabId: (tabId: string | null) => void setTabColor: (tabId: string, color: string | null) => void setTabPaneExpanded: (tabId: string, expanded: boolean) => void makePreviewFilePermanent: (fileId: string, tabId?: string) => void diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts index 392a7bd224c..183508fa9aa 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts @@ -59,7 +59,6 @@ export type FloatingTerminalPanelMocks = { pinFile: Mock setFloatingFocus: Mock<(state: { panelFocused: boolean; terminalFocused: boolean }) => void> setActiveTab: Mock - setRenamingTabId: Mock setTabColor: Mock setTabCustomTitle: Mock setTabPaneExpanded: Mock @@ -106,7 +105,6 @@ export const mocks: FloatingTerminalPanelMocks = { pinFile: vi.fn(), setFloatingFocus: vi.fn(), setActiveTab: vi.fn(), - setRenamingTabId: vi.fn(), setTabColor: vi.fn(), setTabCustomTitle: vi.fn(), setTabPaneExpanded: vi.fn(), @@ -133,7 +131,6 @@ function resetStore(tabs: TerminalTab[] = []): void { activeGroupIdByWorktree: {}, activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null }, expandedPaneByTabId: {}, - renamingTabId: null, activateTab: mocks.activateTab, closeBrowserTab: mocks.closeBrowserTab, closeFile: mocks.closeFile, @@ -147,7 +144,6 @@ function resetStore(tabs: TerminalTab[] = []): void { pinFile: mocks.pinFile, setActiveTab: mocks.setActiveTab, setTabCustomTitle: mocks.setTabCustomTitle, - setRenamingTabId: mocks.setRenamingTabId, setTabColor: mocks.setTabColor, setTabPaneExpanded: mocks.setTabPaneExpanded, browserDefaultUrl: 'about:blank', @@ -196,6 +192,7 @@ export async function setupFloatingTerminalPanelTest(): Promise { } vi.stubGlobal('window', { addEventListener: vi.fn(), + dispatchEvent: vi.fn(), api: { app: { getFloatingMarkdownDirectory: mocks.getFloatingMarkdownDirectory, diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts index b597810c0fd..ee1979451cf 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts @@ -7,6 +7,7 @@ import { } from '@/lib/floating-workspace-shortcut-policy' import { isFloatingWorkspaceTerminalInputTarget } from '@/lib/floating-workspace-terminal-actions' import { getShortcutPlatform } from '@/lib/shortcut-platform' +import { requestTerminalTabRename } from '@/components/tab-bar/terminal-tab-rename-request' import { useAppStore } from '@/store' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { KeybindingContext, KeybindingMatchOptions } from '../../../../shared/keybindings' @@ -168,7 +169,7 @@ export function useFloatingTerminalPanelShortcuts({ return 'unmatched' } consume() - useAppStore.getState().setRenamingTabId(activeTab.id) + requestTerminalTabRename(activeTab.id) return 'handled' } consume() diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 59c4b80e389..9335965bdb8 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -117,7 +117,8 @@ export function useNativeChatLiveSession( // Appended messages accumulate separately from the snapshot so pagination doesn't lose in-flight appends; merged by id and capped to the read window (#6). const [appended, setAppended] = useState([]) // Id-dedup merger backing `appended`; caches the id→index map so each live frame costs O(incoming), not O(existing) (#18). - const appendMergerRef = useRef(createNativeChatMerger(NATIVE_CHAT_SOURCE_PRIORITY)) + const appendMergerRef = useRef>(undefined!) + appendMergerRef.current ??= createNativeChatMerger(NATIVE_CHAT_SOURCE_PRIORITY) const [hookState, hookStateStartedAt, hookHasWorkingSubagents] = useNativeChatHookStatus(paneKey) diff --git a/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts b/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts index 6d176f2ac55..eda95b074cc 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts @@ -22,7 +22,8 @@ export function useNativeChatRetainedSession( args.transcriptPath ?? null ]) const activeIdentityRef = useRef(identity) - const retentionRef = useRef(createNativeChatTranscriptRetention()) + const retentionRef = useRef>(undefined!) + retentionRef.current ??= createNativeChatTranscriptRetention() const sessionMatchesIdentity = activeIdentityRef.current === identity const readPhase = sessionMatchesIdentity ? session.readPhase : 'loading' diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index b1f72178b24..d8655a560d3 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -61,7 +61,8 @@ export function useAiVaultSessionRefresh( const sessions = scanResult?.sessions ?? EMPTY_AI_VAULT_SESSIONS const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const requestTokenRef = useRef(crypto.randomUUID()) + const requestTokenRef = useRef(undefined!) + requestTokenRef.current ??= crypto.randomUUID() const refreshIdRef = useRef(0) const refreshInFlightRef = useRef(false) const pendingRefreshRef = useRef(false) @@ -69,7 +70,8 @@ export function useAiVaultSessionRefresh( const pendingBackgroundRef = useRef(true) const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null) const mountedRef = useRef(true) - const publicationGateRef = useRef(new AiVaultSessionPublicationGate()) + const publicationGateRef = useRef(undefined!) + publicationGateRef.current ??= new AiVaultSessionPublicationGate() const scanScopeKey = `${aiVaultSessionResultCacheKey(executionHostScope, scopePaths)}\n${sessionLimit}` const scopePathsRef = useRef(scopePaths) scopePathsRef.current = scopePaths diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/git-history-panel.tsx b/src/renderer/src/components/right-sidebar/source-control/sync/git-history-panel.tsx index 35751a428d1..71de9872649 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/git-history-panel.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/sync/git-history-panel.tsx @@ -92,7 +92,8 @@ export function GitHistoryPanel({ const loadedCommitsRef = useRef<{ result: GitHistoryResult | undefined ids: Set - }>({ result, ids: new Set() }) + }>(undefined!) + loadedCommitsRef.current ??= { result, ids: new Set() } // A new history result can reorder or replace commits, so drop any expansion // and cached file lists rather than risk showing stale files under a row. diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index 18e1c4c999d..fbb0508ee24 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -58,9 +58,8 @@ export function useCreatePullRequestDialogFields({ const generationRequestIdRef = useRef(0) const generationSeedRef = useRef(null) const restoredExternalGenerationSeedRef = useRef(null) - const fieldRevisionsRef = useRef( - createInitialPullRequestFieldRevisions() - ) + const fieldRevisionsRef = useRef(undefined!) + fieldRevisionsRef.current ??= createInitialPullRequestFieldRevisions() const [base, setBase] = useState('') const [title, setTitle] = useState('') const [body, setBody] = useState('') diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index d6f695c2ac3..4c8311f8dfa 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -45,7 +45,8 @@ export function useFileExplorerTree( const [rootError, setRootError] = useState(null) const dirCacheRef = useRef(dirCache) dirCacheRef.current = dirCache - const dirLoadTrackerRef = useRef(createFileExplorerDirLoadTracker()) + const dirLoadTrackerRef = useRef>(undefined!) + dirLoadTrackerRef.current ??= createFileExplorerDirLoadTracker() // Why: a ref, not state — the expansion effect must read the mark set by a refresh that landed // after the effect's render, and a state write would only be visible one render too late. const staleDirsRef = useRef(new Set()) diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx index d14ff189c72..096ccd34d51 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -104,7 +104,8 @@ export function CommitMessageAiPane({ const searchQuery = settingsSearchQuery ?? storeSearchQuery const config = readSettings(settings) const ownership = getSettingOwnershipSummary('sourceControlAiDefaults') - const settingsWriteQueueRef = useRef>(Promise.resolve()) + const settingsWriteQueueRef = useRef>(undefined!) + settingsWriteQueueRef.current ??= Promise.resolve() const localWriteConfig = (patch: SourceControlAiSettingsPatch): Promise => { const next = settingsWriteQueueRef.current diff --git a/src/renderer/src/components/settings/repository-source-control-ai-global-ux.ts b/src/renderer/src/components/settings/repository-source-control-ai-global-ux.ts index a27db245e6d..3a13f6186ca 100644 --- a/src/renderer/src/components/settings/repository-source-control-ai-global-ux.ts +++ b/src/renderer/src/components/settings/repository-source-control-ai-global-ux.ts @@ -83,25 +83,24 @@ export function useRepositorySourceControlAiGlobalUx({ const lastSyncedRepoIdRef = useRef(repoId) const pendingWritesRef = useRef(0) - const queueRef = useRef( - createRepoAiPersistQueue({ - getRepoId: () => repoIdRef.current, - getPersisted: () => persistedRef.current, - setPersisted: (value) => { - persistedRef.current = value - if (mountedRef.current) { - setBaselineRepoAiRef.current(value) - } - }, - updateRepo: (id, updates) => updateRepoRef.current(id, updates), - isMounted: () => mountedRef.current, - onError: (message) => { - if (mountedRef.current) { - setSaveError(message) - } + const queueRef = useRef>(undefined!) + queueRef.current ??= createRepoAiPersistQueue({ + getRepoId: () => repoIdRef.current, + getPersisted: () => persistedRef.current, + setPersisted: (value) => { + persistedRef.current = value + if (mountedRef.current) { + setBaselineRepoAiRef.current(value) } - }) - ) + }, + updateRepo: (id, updates) => updateRepoRef.current(id, updates), + isMounted: () => mountedRef.current, + onError: (message) => { + if (mountedRef.current) { + setSaveError(message) + } + } + }) useEffect(() => { const repoChanged = lastSyncedRepoIdRef.current !== repoId diff --git a/src/renderer/src/components/settings/use-settings-interaction-controller.ts b/src/renderer/src/components/settings/use-settings-interaction-controller.ts index bfd24c7e5d8..6d949004749 100644 --- a/src/renderer/src/components/settings/use-settings-interaction-controller.ts +++ b/src/renderer/src/components/settings/use-settings-interaction-controller.ts @@ -39,7 +39,8 @@ export function useSettingsInteractionController(model: SettingsStoreModel) { const pendingScrollTargetWatchRef = useRef(null) const repoHooksRequestSeqRef = useRef(0) const shortcutsEscapeConfirmUntilRef = useRef(0) - const sourceControlAiWriteQueueRef = useRef>(Promise.resolve()) + const sourceControlAiWriteQueueRef = useRef>(undefined!) + sourceControlAiWriteQueueRef.current ??= Promise.resolve() const hasUnsavedSourceControlAiPromptChanges = hasUnsavedCommitPromptChanges || hasUnsavedBranchPromptChanges diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-column-resize.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-column-resize.ts index a48e940a3db..17547d5cd56 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-column-resize.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-column-resize.ts @@ -20,7 +20,8 @@ export function useWorkspaceKanbanColumnResize( clampWorkspaceBoardColumnWidth(committedWidth) ) const [isResizingColumn, setIsResizingColumn] = useState(false) - const committedWidthRef = useRef(clampWorkspaceBoardColumnWidth(committedWidth)) + const nextCommittedWidth = clampWorkspaceBoardColumnWidth(committedWidth) + const committedWidthRef = useRef(nextCommittedWidth) const commitWidthRef = useRef(onCommitWidth) const resizingRef = useRef(false) const startXRef = useRef(0) @@ -29,7 +30,6 @@ export function useWorkspaceKanbanColumnResize( const frameRef = useRef(null) commitWidthRef.current = onCommitWidth - const nextCommittedWidth = clampWorkspaceBoardColumnWidth(committedWidth) if (committedWidthRef.current !== nextCommittedWidth) { committedWidthRef.current = nextCommittedWidth if (!resizingRef.current) { diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.ts index d732aa71cec..8c88d812746 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.ts @@ -42,7 +42,8 @@ export function useWorktreeListScrollToTop({ showScrollToTop: boolean scrollToTop: () => void } { - const detectorRef = useRef(createHardScrollUpDetectorState()) + const detectorRef = useRef(undefined!) + detectorRef.current ??= createHardScrollUpDetectorState() const [showScrollToTop, setShowScrollToTop] = useState(false) const showScrollToTopRef = useRef(false) const idleTimerRef = useRef(null) diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index fd275935f77..2e04247dd9b 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -1,4 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { requestTerminalTabRename } from './terminal-tab-rename-request' + +const windowListeners = new Map void>>() const reactHookRuntime = vi.hoisted(() => ({ states: [] as unknown[], @@ -11,10 +14,8 @@ const storeState = vi.hoisted( clearTabLaunchAgent: ReturnType ptyIdsByTabId: Record retainedAgentsByPaneKey: Record - renamingTabId: string | null keybindings: Record repos: unknown[] - setRenamingTabId: ReturnType terminalLayoutsByTabId: Record worktreesByRepo: Record unreadTerminalTabs: Record @@ -23,12 +24,8 @@ const storeState = vi.hoisted( clearTabLaunchAgent: vi.fn(), ptyIdsByTabId: {} as Record, retainedAgentsByPaneKey: {}, - renamingTabId: null as string | null, keybindings: {}, repos: [], - setRenamingTabId: vi.fn((tabId: string | null) => { - storeState.renamingTabId = tabId - }), terminalLayoutsByTabId: {}, worktreesByRepo: {}, unreadTerminalTabs: {} as Record @@ -340,13 +337,24 @@ describe('SortableTab rename shortcut signal', () => { beforeEach(() => { reactHookRuntime.states = [] reactHookRuntime.index = 0 - storeState.renamingTabId = 'terminal-tab-1' storeState.unreadTerminalTabs = {} storeState.clearTabLaunchAgent.mockClear() - storeState.setRenamingTabId.mockClear() + windowListeners.clear() vi.stubGlobal('window', { - addEventListener: vi.fn(), - removeEventListener: vi.fn() + addEventListener: vi.fn((type: string, listener: (event: Event) => void) => { + const listeners = windowListeners.get(type) ?? new Set<(event: Event) => void>() + listeners.add(listener) + windowListeners.set(type, listeners) + }), + removeEventListener: vi.fn((type: string, listener: (event: Event) => void) => { + windowListeners.get(type)?.delete(listener) + }), + dispatchEvent: vi.fn((event: Event) => { + for (const listener of windowListeners.get(event.type) ?? []) { + listener(event) + } + return true + }) }) vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0) @@ -355,22 +363,30 @@ describe('SortableTab rename shortcut signal', () => { vi.stubGlobal('cancelAnimationFrame', vi.fn()) }) - it('opens the inline rename input and consumes the matching store signal', async () => { + it('opens the inline rename input for a request that targets this tab', async () => { await renderSortableTab() + requestTerminalTabRename('terminal-tab-1') const rerender = expandNode(await renderSortableTab()) const inputs = findElementsByType(rerender, 'input') - expect(storeState.setRenamingTabId).toHaveBeenCalledWith(null) - expect(storeState.renamingTabId).toBeNull() expect(inputs).toHaveLength(1) expect(inputs[0].props.value).toBe('Runtime terminal title') expect(inputs[0].props['data-tab-rename-input']).toBe('true') }) + it('ignores a rename request aimed at a different tab', async () => { + await renderSortableTab() + requestTerminalTabRename('terminal-tab-2') + const rerender = expandNode(await renderSortableTab()) + + expect(findElementsByType(rerender, 'input')).toHaveLength(0) + }) + it('ignores IME composition Enter before committing the custom tab title', async () => { const onSetCustomTitle = vi.fn() await renderSortableTab({ onSetCustomTitle }) + requestTerminalTabRename('terminal-tab-1') let rerender = expandNode(await renderSortableTab({ onSetCustomTitle })) let input = findElementsByType(rerender, 'input')[0] ;(input.props.onChange as (event: { target: { value: string } }) => void)({ diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 70935abb29a..cb5966d1e23 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -17,6 +17,10 @@ import { type DropIndicator } from './drop-indicator' import { preventMiddleButtonDefault } from './middle-button-default-guard' +import { + RENAME_TERMINAL_TAB_EVENT, + type RenameTerminalTabDetail +} from './terminal-tab-rename-request' import { SortableTabContextMenu } from './SortableTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' @@ -97,8 +101,6 @@ export default function SortableTab({ terminalLayout: s.terminalLayoutsByTabId?.[tab.id] }) ) - const renamingTabId = useAppStore((s) => s.renamingTabId) - const setRenamingTabId = useAppStore((s) => s.setRenamingTabId) // Why: shellOverride is stamped at create time, so changing the default shell later won't repaint existing tabs. const shellForIcon = tab.shellOverride @@ -166,14 +168,25 @@ export default function SortableTab({ }) }, []) - // Why: the tab.rename shortcut routes through store renamingTabId; open the editor and clear it so it fires once. + // Why the ref: keeps the listener subscribed to tab.id alone, so OSC title churn can't + // resubscribe it mid-edit. Written from an Effect, not in render -- a render React discards + // must not leave a stale handler behind for the next commit to fire. + const handleRenameOpenRef = useRef(handleRenameOpen) useEffect(() => { - if (renamingTabId !== tab.id) { - return + handleRenameOpenRef.current = handleRenameOpen + }, [handleRenameOpen]) + + useEffect(() => { + const onRenameRequest = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (detail?.tabId !== tab.id) { + return + } + handleRenameOpenRef.current() } - handleRenameOpen() - setRenamingTabId(null) - }, [renamingTabId, tab.id, handleRenameOpen, setRenamingTabId]) + window.addEventListener(RENAME_TERMINAL_TAB_EVENT, onRenameRequest) + return () => window.removeEventListener(RENAME_TERMINAL_TAB_EVENT, onRenameRequest) + }, [tab.id]) useEffect(() => { const closeMenu = (): void => setMenuOpen(false) diff --git a/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx index acbd7ce38bc..39cacfb7825 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx @@ -18,6 +18,7 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import { useAppStore } from '../../store' import SortableTab from './SortableTab' +import { requestTerminalTabRename } from './terminal-tab-rename-request' type ProbeState = { unreadTerminalTabs: Record @@ -27,9 +28,7 @@ type ProbeState = { runtimePaneTitlesByTabId: Record> ptyIdsByTabId: Record terminalLayoutsByTabId: Record - renamingTabId: string | null keybindings: Record - setRenamingTabId: (tabId: string | null) => void } type StoreApiWithHook = { @@ -44,7 +43,7 @@ async function createProbeStore(): Promise { const globals = globalThis as Record if (!globals[globalKey]) { const { create } = await import('zustand') - globals[globalKey] = create((set) => ({ + globals[globalKey] = create(() => ({ unreadTerminalTabs: {}, unreadAgentCompletionPanes: {}, agentStatusByPaneKey: {}, @@ -52,9 +51,7 @@ async function createProbeStore(): Promise { runtimePaneTitlesByTabId: {}, ptyIdsByTabId: {}, terminalLayoutsByTabId: {}, - renamingTabId: null, - keybindings: {}, - setRenamingTabId: (tabId) => set({ renamingTabId: tabId }) + keybindings: {} })) } return globals[globalKey] as StoreApiWithHook @@ -101,6 +98,15 @@ vi.mock('@/components/ui/input', () => ({ Input: (props: Record) => })) +// Counts SortableTab's own renders: it is rendered unconditionally inside the tab body, so one +// stub render == one SortableTab render, which the Harness counter above cannot see. +vi.mock('./TerminalTabLeadingIcon', () => ({ + TerminalTabLeadingIcon: () => { + tabRenderCount += 1 + return + } +})) + vi.mock('./shell-icons', () => ({ ShellIcon: () => })) vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => })) vi.mock('../sidebar/WorktreeCardHelpers', () => ({ FilledBellIcon: () => })) @@ -127,6 +133,7 @@ const dragData: TabDragItemData = { const probeStore = useAppStore as unknown as StoreApiWithHook let renderCount = 0 +let tabRenderCount = 0 function Harness({ tab }: { tab: TerminalTab }): ReactElement { renderCount += 1 @@ -170,25 +177,52 @@ function ChurningHarness({ titles }: { titles: string[] }): ReactElement { afterEach(() => { cleanup() - probeStore.setState({ renamingTabId: null, unreadTerminalTabs: {}, agentStatusEpoch: 0 }) + probeStore.setState({ unreadTerminalTabs: {}, agentStatusEpoch: 0 }) renderCount = 0 + tabRenderCount = 0 }) describe('SortableTab update-depth probe', () => { - it('settles when the rename shortcut arms renamingTabId', () => { - probeStore.setState({ renamingTabId: 'terminal-tab-1' }) + it('settles when the rename shortcut targets this tab', () => { const { container } = render() + act(() => requestTerminalTabRename('terminal-tab-1')) expect(container.querySelector('[data-tab-rename-input]')).not.toBeNull() - expect(probeStore.getState().renamingTabId).toBeNull() expect(renderCount).toBeLessThan(20) }) it('settles under title churn while the rename editor is open', () => { - probeStore.setState({ renamingTabId: 'terminal-tab-1' }) render() + act(() => requestTerminalTabRename('terminal-tab-1')) expect(renderCount).toBeLessThan(40) }) + // Regression: the shortcut used to arm a store field every tab subscribed to, so one rename + // re-rendered every mounted tab twice — once to notice the id, once when the tab cleared it. + it('re-renders only the targeted tab, once, per rename request', () => { + render( + <> + + + + + ) + const mountRenders = tabRenderCount + act(() => requestTerminalTabRename('terminal-tab-1')) + expect(tabRenderCount - mountRenders).toBe(1) + }) + + it('does not re-render any tab for a rename request no mounted tab owns', () => { + render( + <> + + + + ) + const mountRenders = tabRenderCount + act(() => requestTerminalTabRename('terminal-tab-9')) + expect(tabRenderCount).toBe(mountRenders) + }) + it('settles under a store write storm', () => { render() const before = renderCount @@ -201,12 +235,12 @@ describe('SortableTab update-depth probe', () => { }) it('settles in StrictMode double-invoked effects', () => { - probeStore.setState({ renamingTabId: 'terminal-tab-1' }) - render( + const { container } = render( ) - expect(probeStore.getState().renamingTabId).toBeNull() + act(() => requestTerminalTabRename('terminal-tab-1')) + expect(container.querySelector('[data-tab-rename-input]')).not.toBeNull() }) }) diff --git a/src/renderer/src/components/tab-bar/terminal-tab-rename-request.ts b/src/renderer/src/components/tab-bar/terminal-tab-rename-request.ts new file mode 100644 index 00000000000..4559ad0ea95 --- /dev/null +++ b/src/renderer/src/components/tab-bar/terminal-tab-rename-request.ts @@ -0,0 +1,19 @@ +/** + * Targeted rename dispatch for the `tab.rename` shortcut. + * + * Why an event and not store state: a store field is read by every mounted tab, so arming it + * re-rendered the whole strip, and the consuming tab then had to clear it — a second pass over + * every tab. The event reaches only the tab that owns the id. Mirrors the terminal-pane + * TOGGLE_TERMINAL_PANE_EXPAND_EVENT / FOCUS_TERMINAL_PANE_EVENT dispatch. + */ +export const RENAME_TERMINAL_TAB_EVENT = 'orca-rename-terminal-tab' + +export type RenameTerminalTabDetail = { + tabId: string +} + +export function requestTerminalTabRename(tabId: string): void { + window.dispatchEvent( + new CustomEvent(RENAME_TERMINAL_TAB_EVENT, { detail: { tabId } }) + ) +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index e28637097f7..dfc6ffc9614 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -120,7 +120,10 @@ export function useTerminalTabColdParking(args: { ) const terminalTabHiddenSinceRef = useRef(new Map()) // Why: view switches hide every tab at once, so the park clock cannot rank them. - const terminalTabActivationOrderRef = useRef(createTerminalTabActivationOrder()) + const terminalTabActivationOrderRef = useRef>( + undefined! + ) + terminalTabActivationOrderRef.current ??= createTerminalTabActivationOrder() // Why (shared measure-clock contract with Terminal.tsx): tab hiddenSince // survives a background-measure window so per-tab park deadlines stay in // sync with the worktree retention/TTL clock, and a post-measure cool-down diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx index c0d5a26c2e1..9593c23d276 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx @@ -76,7 +76,10 @@ export function TerminalQuickCommandDialog({ const [draft, setDraft] = useState(command) const wasOpenRef = useRef(open) const syncedCommandRef = useRef(command) - const draftMemoryRef = useRef(createTerminalQuickCommandDialogDraftMemory(command, fallbackAgent)) + const draftMemoryRef = useRef>( + undefined! + ) + draftMemoryRef.current ??= createTerminalQuickCommandDialogDraftMemory(command, fallbackAgent) const initialScope = getTerminalQuickCommandScope(command) const lastRepoScopeIdRef = useRef( initialScope.type === 'repo' ? initialScope.repoId : null diff --git a/src/renderer/src/components/use-terminal-window-lifecycle.lazy-ref.test.tsx b/src/renderer/src/components/use-terminal-window-lifecycle.lazy-ref.test.tsx new file mode 100644 index 00000000000..ce99e46db27 --- /dev/null +++ b/src/renderer/src/components/use-terminal-window-lifecycle.lazy-ref.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom + +/** + * `collectBrowserWebviewIds` walks every browser page and tab across every worktree. It used to sit + * in a `useRef(...)` argument, so `Terminal` paid for the whole walk on every render and threw the + * result away. Pin the invocation count to the mount count, not the render count. + */ +import { useState } from 'react' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const collectBrowserWebviewIdsCalls = vi.hoisted(() => ({ count: 0 })) + +vi.mock('../store', () => { + const state = { + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + openFiles: [] + } + const useAppStore = Object.assign(() => undefined, { + getState: () => state, + subscribe: () => () => {} + }) + return { useAppStore } +}) + +vi.mock('../store/slices/browser-webview-cleanup', () => ({ + collectBrowserWebviewIds: (...args: unknown[]) => { + collectBrowserWebviewIdsCalls.count += 1 + void args + return new Set() + }, + destroyRemovedBrowserWebview: vi.fn() +})) + +vi.mock('@/lib/updater-beforeunload', () => ({ + isIntentionalAppRestartInProgress: () => false +})) +vi.mock('@/lib/shutdown-checkpoint-guard', () => ({ + preventUnloadAndScheduleShutdownCheckpointReset: vi.fn() +})) +vi.mock('./window-close-request-coordinator', () => ({ + setWindowCloseRequestHandler: vi.fn() +})) + +const { useTerminalWindowLifecycle } = await import('./use-terminal-window-lifecycle') + +const controller = { + activeBrowserTabId: null, + activeTabType: 'terminal', + activeWorktreeBrowserTabIdsKey: '', + proceedToNativeWindowClose: () => {}, + queueEditorCloseRequests: () => {}, + renderedActiveWorktreeId: null, + setActiveBrowserTab: () => {}, + setActiveTabType: () => {}, + windowCloseAfterDirtyRef: { current: false } +} as unknown as Parameters[0] + +let bumpRender: (() => void) | null = null + +function Host(): null { + const [, setTick] = useState(0) + bumpRender = () => setTick((tick) => tick + 1) + useTerminalWindowLifecycle(controller) + return null +} + +afterEach(() => { + cleanup() + collectBrowserWebviewIdsCalls.count = 0 + bumpRender = null +}) + +describe('useTerminalWindowLifecycle browser-webview id seed', () => { + it('collects the id set once per mount, not once per render', () => { + render() + expect(collectBrowserWebviewIdsCalls.count).toBe(1) + + for (let i = 0; i < 20; i += 1) { + act(() => bumpRender?.()) + } + + expect(collectBrowserWebviewIdsCalls.count).toBe(1) + }) +}) diff --git a/src/renderer/src/components/use-terminal-window-lifecycle.ts b/src/renderer/src/components/use-terminal-window-lifecycle.ts index a252422de9f..23cdb930535 100644 --- a/src/renderer/src/components/use-terminal-window-lifecycle.ts +++ b/src/renderer/src/components/use-terminal-window-lifecycle.ts @@ -58,11 +58,12 @@ export function useTerminalWindowLifecycle(controller: TerminalActivationControl // oxlint-disable-next-line react-hooks/exhaustive-deps -- controller refs preserve their original stable identities. }, [proceedToNativeWindowClose, queueEditorCloseRequests]) - const prevBrowserWebviewIdsRef = useRef>( - collectBrowserWebviewIds( - useAppStore.getState().browserTabsByWorktree, - useAppStore.getState().browserPagesByWorkspace - ) + // Why lazy: a `useRef(expr)` argument re-runs on every render and is thrown away, and this + // walks every browser page and tab across all worktrees on a component that renders constantly. + const prevBrowserWebviewIdsRef = useRef>(undefined!) + prevBrowserWebviewIdsRef.current ??= collectBrowserWebviewIds( + useAppStore.getState().browserTabsByWorktree, + useAppStore.getState().browserPagesByWorkspace ) useEffect(() => { let prevBrowserTabs = useAppStore.getState().browserTabsByWorktree diff --git a/src/renderer/src/hooks/composer-state/async-composer-state.ts b/src/renderer/src/hooks/composer-state/async-composer-state.ts index 2980576e3f7..f84baa51469 100644 --- a/src/renderer/src/hooks/composer-state/async-composer-state.ts +++ b/src/renderer/src/hooks/composer-state/async-composer-state.ts @@ -128,14 +128,13 @@ export function useComposerAsyncState(input: ComposerAsyncStateInput) { const [linkDirectLoading, setLinkDirectLoading] = useState(false) - const lastAutoNameRef = useRef( - getInitialAutoManagedWorkspaceName({ - draftName: persistDraft ? newWorkspaceDraft?.name : null, - draftLinkedWorkItem: persistDraft ? draftLinkedWorkItemSeed : null, - initialName, - initialLinkedWorkItem: initialLinkedWorkItemSeed - }) - ) + const lastAutoNameRef = useRef(undefined!) + lastAutoNameRef.current ??= getInitialAutoManagedWorkspaceName({ + draftName: persistDraft ? newWorkspaceDraft?.name : null, + draftLinkedWorkItem: persistDraft ? draftLinkedWorkItemSeed : null, + initialName, + initialLinkedWorkItem: initialLinkedWorkItemSeed + }) const nameRef = useRef(name) @@ -153,12 +152,18 @@ export function useComposerAsyncState(input: ComposerAsyncStateInput) { }, [name, note]) // Why: PR checkout refs resolve async, so submit can still see the linked PR as a checkout source if Create fires before the resolver settles. + // Why useState and not `useRef(expr)`: the seed legitimately resolves to null, so a nullish + // guard would keep re-running it; useState's initializer runs once without writing in render. + const [initialSmartGitHubPrStartPointSelection] = + useState(() => + getInitialGitHubPrStartPointSelection({ + item: initialGitHubWorkItem, + linkedWorkItem: initialLinkedWorkItemSeed, + repoId: selectedRepo?.id ?? initialRepoId + }) + ) const smartGitHubPrStartPointSelectionRef = useRef( - getInitialGitHubPrStartPointSelection({ - item: initialGitHubWorkItem, - linkedWorkItem: initialLinkedWorkItemSeed, - repoId: selectedRepo?.id ?? initialRepoId - }) + initialSmartGitHubPrStartPointSelection ) useEffect(() => { diff --git a/src/renderer/src/hooks/composer-state/composer-drop-listener.ts b/src/renderer/src/hooks/composer-state/composer-drop-listener.ts index 145fe1a3c35..5cca0d4b073 100644 --- a/src/renderer/src/hooks/composer-state/composer-drop-listener.ts +++ b/src/renderer/src/hooks/composer-state/composer-drop-listener.ts @@ -10,7 +10,8 @@ export function useComposerDropListener( useEffect(() => { applyDropRef.current = applyDrop }, [applyDrop]) - const instanceIdRef = useRef(Symbol('composer')) + const instanceIdRef = useRef(undefined!) + instanceIdRef.current ??= Symbol('composer') useEffect(() => { const instanceId = instanceIdRef.current diff --git a/src/renderer/src/hooks/use-audio-capture.ts b/src/renderer/src/hooks/use-audio-capture.ts index 702640ae1c9..31e851a58ee 100644 --- a/src/renderer/src/hooks/use-audio-capture.ts +++ b/src/renderer/src/hooks/use-audio-capture.ts @@ -55,7 +55,8 @@ export function useAudioCapture(publishMeter?: DictationMeterPublisher) { const capturedChunkCountRef = useRef(0) const sessionIdRef = useRef('desktop') const trackLostCleanupRef = useRef<(() => void) | null>(null) - const meterAnalyzerRef = useRef(createDictationMeterAnalyzerState()) + const meterAnalyzerRef = useRef>(undefined!) + meterAnalyzerRef.current ??= createDictationMeterAnalyzerState() const publishedMeterRef = useRef(DEFAULT_DICTATION_METER) const lastMeterPublishedAtRef = useRef(Number.NEGATIVE_INFINITY) diff --git a/src/renderer/src/lazy-use-ref-ratchet.test.ts b/src/renderer/src/lazy-use-ref-ratchet.test.ts new file mode 100644 index 00000000000..c0b23821bcb --- /dev/null +++ b/src/renderer/src/lazy-use-ref-ratchet.test.ts @@ -0,0 +1,130 @@ +import { readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * `useRef(create())` evaluates its argument on EVERY render and discards every result after the + * first, so any real work there is pure waste. The lazy form keeps the same value with none of the + * churn: + * + * const ref = useRef(undefined!) + * ref.current ??= create() + * + * Use a `useState` lazy initializer instead when the seed can legitimately be null/undefined, or + * an explicit `seededRef` guard when both are true. + */ +const RENDERER_ROOT = import.meta.dirname + +function collectSourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist') { + continue + } + collectSourceFiles(full, out) + continue + } + if (!/\.tsx?$/.test(entry.name) || /\.(test|spec)\.tsx?$/.test(entry.name)) { + continue + } + out.push(full) + } + return out +} + +/** Reads the balanced argument text of the `useRef(...)` starting at `from`. */ +function readUseRefArgument(source: string, from: number): { arg: string; end: number } | null { + let i = from + while (source[i] === ' ') { + i++ + } + if (source[i] === '<') { + let depth = 0 + while (i < source.length) { + if (source[i] === '<') { + depth++ + } else if (source[i] === '>') { + depth-- + if (depth === 0) { + i++ + break + } + } + i++ + } + } + while (source[i] === ' ') { + i++ + } + if (source[i] !== '(') { + return null + } + const argStart = i + 1 + let depth = 0 + while (i < source.length) { + if (source[i] === '(') { + depth++ + } else if (source[i] === ')') { + depth-- + if (depth === 0) { + return { arg: source.slice(argStart, i).trim(), end: i + 1 } + } + } + i++ + } + return null +} + +// Allowed because none of these is work that a render repeats for nothing: +// - an empty collection literal, which the repo keeps in the direct form +// - `undefined!`, the lazy-seed marker +// - a function literal, which is the ref's payload rather than its initialization +// - a primitive coercion of an already-computed value +const ALLOWED_ARGUMENT = new RegExp( + [ + '^new (Map|Set|WeakMap|WeakSet)(<[\\s\\S]*>)?\\(\\)$', + '^undefined!$', + '^(async )?\\([\\s\\S]*?\\)\\s*(:[^=]*)?=>[\\s\\S]*$', + '^(Boolean|Number|String)\\([\\s\\S]*\\)$', + '^[A-Za-z_$][\\w$.?\\[\\]\'"]*$' + ].join('|') +) + +function findNonLazyUseRefs(file: string): string[] { + const source = readFileSync(file, 'utf8') + const findings: string[] = [] + let index = 0 + while ((index = source.indexOf('useRef', index)) !== -1) { + const start = index + index += 'useRef'.length + if (/[\w$.]/.test(source[start - 1] ?? '')) { + continue + } + const parsed = readUseRefArgument(source, index) + if (!parsed) { + continue + } + index = parsed.end + const arg = parsed.arg + if (arg === '' || ALLOWED_ARGUMENT.test(arg)) { + continue + } + // Only a call or constructor invocation actually burns work per render. + if (!/\(/.test(arg) && !/\bnew\b/.test(arg)) { + continue + } + const line = source.slice(0, start).split('\n').length + findings.push( + `${path.relative(RENDERER_ROOT, file)}:${line} useRef(${arg.replace(/\s+/g, ' ').slice(0, 90)})` + ) + } + return findings +} + +describe('renderer useRef initializers', () => { + it('never does work in the useRef argument', () => { + const findings = collectSourceFiles(RENDERER_ROOT).flatMap(findNonLazyUseRefs) + expect(findings).toEqual([]) + }) +}) diff --git a/src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts b/src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts index ef78f34f954..ea2a9bbb385 100644 --- a/src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts +++ b/src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts @@ -26,14 +26,6 @@ describe('TabsSlice', () => { store = createTestStore() }) - it('setRenamingTabId sets and clears the tab rename signal', () => { - expect(store.getState().renamingTabId).toBeNull() - store.getState().setRenamingTabId('terminal-tab-1') - expect(store.getState().renamingTabId).toBe('terminal-tab-1') - store.getState().setRenamingTabId(null) - expect(store.getState().renamingTabId).toBeNull() - }) - // ─── setTabLabel / setTabCustomLabel / setUnifiedTabColor ───────── describe('tab property setters', () => { diff --git a/src/renderer/src/store/slices/tabs/create-tabs-slice.ts b/src/renderer/src/store/slices/tabs/create-tabs-slice.ts index 799cd257339..adbea2f1726 100644 --- a/src/renderer/src/store/slices/tabs/create-tabs-slice.ts +++ b/src/renderer/src/store/slices/tabs/create-tabs-slice.ts @@ -14,7 +14,6 @@ import { createTabsSessionActions } from './tabs-session-actions' export const createTabsSlice: StateCreator = (set, get) => ({ unifiedTabsByWorktree: {}, - renamingTabId: null, groupsByWorktree: {}, activeGroupIdByWorktree: {}, layoutByWorktree: {}, diff --git a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts index 012e1e8c0c0..8acb925bfae 100644 --- a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts @@ -18,7 +18,6 @@ export function createTabsLabelActions( | 'setTabLabel' | 'setTabViewMode' | 'toggleTabViewMode' - | 'setRenamingTabId' | 'setTabCustomLabel' | 'setUnifiedTabColor' | 'pinTab' @@ -101,10 +100,6 @@ export function createTabsLabelActions( } }, - setRenamingTabId: (tabId) => { - set({ renamingTabId: tabId }) - }, - setTabCustomLabel: (tabId, label, opts) => { const exists = get().getTab(tabId) !== null set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}) diff --git a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts index 81d27ff54d5..2835d5e46d6 100644 --- a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts +++ b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts @@ -13,8 +13,6 @@ export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' export type TabsSlice = { unifiedTabsByWorktree: Record - // Why: id of the tab whose inline title editor should open; shortcut (tab.rename) sets it, the tab clears it on consume. - renamingTabId: string | null groupsByWorktree: Record activeGroupIdByWorktree: Record layoutByWorktree: Record @@ -101,7 +99,6 @@ export type TabsSlice = { opts?: { recordInteraction?: boolean } ) => void setUnifiedTabColor: (tabId: string, color: string | null) => void - setRenamingTabId: (tabId: string | null) => void pinTab: (tabId: string) => void unpinTab: (tabId: string) => void closeOtherTabs: (tabId: string) => string[] From 974f3164a0380505611b19668612341e0bd3f742 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:36:00 -0700 Subject: [PATCH 25/25] Make terminal error overlays opaque (#18231) * Make terminal error overlays opaque * fix(terminal): keep opaque error toast text readable * fix(terminal): keep toast fallback opaque on older browsers --------- Co-authored-by: Merge Sim --- .../terminal-pane/TerminalErrorToast.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx index dcb838007b9..8b627a9651f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx @@ -179,6 +179,11 @@ export function TerminalErrorToast({ const showIssueLink = !ssh && !paneOwnerUnverified && !showDaemonRestart && !isExplainedTerminalError(error) const displayError = humanizeTerminalError(error) + const tint = paneOwnerUnverified + ? null + : ssh + ? 'color-mix(in srgb, var(--color-amber-500) 20%, var(--popover))' + : 'color-mix(in srgb, var(--destructive) 20%, var(--popover))' const [retrying, setRetrying] = useState(false) const [retryFailed, setRetryFailed] = useState(false) const [environmentFooter, setEnvironmentFooter] = useState<{ @@ -231,17 +236,14 @@ export function TerminalErrorToast({ zIndex: 50, padding: '10px 14px', borderRadius: 6, - background: paneOwnerUnverified - ? 'var(--popover)' - : ssh - ? 'rgba(234, 179, 8, 0.12)' - : 'rgba(220, 38, 38, 0.15)', + background: 'var(--popover)', + backgroundImage: tint ? `linear-gradient(${tint}, ${tint})` : undefined, border: paneOwnerUnverified ? '1px solid var(--color-amber-500)' : ssh ? '1px solid rgba(234, 179, 8, 0.35)' : '1px solid rgba(220, 38, 38, 0.4)', - color: paneOwnerUnverified ? 'var(--popover-foreground)' : ssh ? '#fde68a' : '#fca5a5', + color: 'var(--popover-foreground)', fontSize: 12, fontFamily: 'monospace', whiteSpace: 'pre-wrap', @@ -268,7 +270,7 @@ export function TerminalErrorToast({ )}{' '} {translate( 'auto.components.terminal.pane.TerminalErrorToast.a7e2fd2699', @@ -326,7 +328,7 @@ export function TerminalErrorToast({ style={{ background: 'none', border: 'none', - color: paneOwnerUnverified ? 'var(--popover-foreground)' : ssh ? '#fde68a' : '#fca5a5', + color: 'inherit', cursor: 'pointer', fontSize: 14, padding: '0 0 0 8px',