diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts new file mode 100644 index 000000000..bf11a0af2 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest' +import type { Worktree } from '../../../../shared/types' +import { + deriveAiVaultScopeSessionPaths, + deriveAiVaultWorkspaceScopePaths +} from './ai-vault-scope-paths' + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'repo-1::/repo/orca', + repoId: 'repo-1', + displayName: 'orca', + path: '/repo/orca', + head: 'abc123', + branch: 'main', + isBare: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + isMainWorktree: false, + ...overrides + } +} + +describe('deriveAiVaultWorkspaceScopePaths', () => { + it('returns the active workspace path', () => { + const active = makeWorktree() + expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual(['/repo/orca']) + }) + + it('returns nothing without an active workspace', () => { + expect(deriveAiVaultWorkspaceScopePaths(null, [makeWorktree()])).toEqual([]) + }) + + it('includes prior paths so renamed workspaces keep their transcripts', () => { + const active = makeWorktree({ + id: 'repo-1::/repo/orca-renamed', + path: '/repo/orca-renamed', + priorWorktreeIds: ['repo-1::/repo/orca'] + }) + + expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual([ + '/repo/orca-renamed', + '/repo/orca' + ]) + }) + + it('drops a prior path another live workspace now owns', () => { + // Sessions are keyed by cwd alone, so claiming a path a live workspace + // occupies would show that workspace's transcripts under this one. + const claimant = makeWorktree({ id: 'repo-1::/repo/orca', path: '/repo/orca' }) + const active = makeWorktree({ + id: 'repo-1::/repo/orca-renamed', + path: '/repo/orca-renamed', + priorWorktreeIds: ['repo-1::/repo/orca'] + }) + + expect(deriveAiVaultWorkspaceScopePaths(active, [claimant, active])).toEqual([ + '/repo/orca-renamed' + ]) + }) + + it('keeps a prior path the active workspace itself still owns', () => { + const active = makeWorktree({ priorWorktreeIds: ['repo-1::/repo/orca'] }) + expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual(['/repo/orca']) + }) + + it('drops a claimed prior path regardless of where the claimant sits in the list', () => { + // Ordering must not decide the claim: a lookup keyed by path has to + // exclude the active workspace up front, or listing it before a claimant + // that shares the path would mask the claim. + const active = makeWorktree({ + id: 'repo-1::/repo/orca-renamed', + path: '/repo/orca-renamed', + priorWorktreeIds: ['repo-1::/repo/orca'] + }) + const claimant = makeWorktree({ id: 'repo-1::/repo/orca', path: '/repo/orca' }) + // The active workspace also listed at the prior path — the only shape where + // a first-writer-wins map would name the active workspace the owner and so + // report the path unclaimed. + const activeAtPriorPath = makeWorktree({ id: active.id, path: '/repo/orca' }) + + for (const liveWorktrees of [ + [active, claimant], + [claimant, active], + [activeAtPriorPath, claimant], + [claimant, activeAtPriorPath] + ]) { + expect(deriveAiVaultWorkspaceScopePaths(active, liveWorktrees)).toEqual([ + '/repo/orca-renamed' + ]) + } + }) + + it('derives workspace scope paths at scale', () => { + // Separate from the session-scope guard: a quadratic dedupe reintroduced + // only in the workspace pass would not surface there. + const prefix = '/Users/dev/orca/workspaces/orca-monorepo/feature-' + const worktrees = Array.from({ length: 1200 }, (_, i) => + makeWorktree({ id: `repo-1::${prefix}${i}`, path: `${prefix}${i}` }) + ) + const active = makeWorktree({ + id: `repo-1::${prefix}0`, + path: `${prefix}0`, + // Priors drive the claim check, which is the other per-call scan here. + priorWorktreeIds: Array.from({ length: 25 }, (_, i) => `repo-1::${prefix}prior-${i}`) + }) + + const startedAt = performance.now() + const paths = deriveAiVaultWorkspaceScopePaths(active, worktrees) + const elapsedMs = performance.now() - startedAt + + expect(paths).toHaveLength(1 + 25) + expect(elapsedMs).toBeLessThan(100) + }) + + it('ignores prior ids belonging to another repo', () => { + const active = makeWorktree({ priorWorktreeIds: ['repo-2::/repo/other'] }) + expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual(['/repo/orca']) + }) + + it('skips relative and blank paths', () => { + const active = makeWorktree({ path: 'relative/path' }) + expect(deriveAiVaultWorkspaceScopePaths(active, [active])).toEqual([]) + expect(deriveAiVaultWorkspaceScopePaths(makeWorktree({ path: ' ' }), [])).toEqual([]) + }) +}) + +describe('deriveAiVaultScopeSessionPaths', () => { + it('covers the active workspace plus the rest of its repo', () => { + const active = makeWorktree() + const sibling = makeWorktree({ id: 'repo-1::/repo/feature', path: '/repo/feature' }) + const otherRepo = makeWorktree({ + id: 'repo-2::/repo/other', + repoId: 'repo-2', + path: '/repo/other' + }) + + expect(deriveAiVaultScopeSessionPaths(active, [active, sibling, otherRepo])).toEqual([ + '/repo/orca', + '/repo/feature' + ]) + }) + + it('deduplicates paths that differ only by separators or trailing slash', () => { + // The dedupe compares normalized keys; the first spelling is what ships. + const active = makeWorktree() + const trailing = makeWorktree({ id: 'repo-1::a', path: '/repo/orca/' }) + const doubled = makeWorktree({ id: 'repo-1::b', path: '/repo//orca' }) + + expect(deriveAiVaultScopeSessionPaths(active, [active, trailing, doubled])).toEqual([ + '/repo/orca' + ]) + }) + + it('treats NFD and NFC spellings of one path as the same scope entry', () => { + // macOS yields NFD on disk while agents record NFC cwds (#10832). + const nfc = '/repo/프로젝트' + const active = makeWorktree({ id: `repo-1::${nfc}`, path: nfc }) + const nfd = makeWorktree({ id: 'repo-1::nfd', path: nfc.normalize('NFD') }) + + expect(deriveAiVaultScopeSessionPaths(active, [active, nfd])).toEqual([nfc]) + }) + + it('derives scope paths at scale without re-normalizing the accumulator', () => { + // Regression guard: deduping by rescanning the accumulated paths made this + // O(n^2) in normalize('NFC') and cost ~190ms on a 1124-workspace profile — + // on the workspace-switch path, since these paths follow the active + // worktree. Times the real fan-out so it fails on a return to that shape. + // Path length matters as much as count: normalize() cost scales with it, + // so short synthetic paths would understate the old shape. ~50 chars + // matches the real profile this was measured on. + const prefix = '/Users/dev/orca/workspaces/orca-monorepo/feature-' + const worktrees = Array.from({ length: 1200 }, (_, i) => + makeWorktree({ id: `repo-1::${prefix}${i}`, path: `${prefix}${i}` }) + ) + + const startedAt = performance.now() + const paths = deriveAiVaultScopeSessionPaths(worktrees[0], worktrees) + const elapsedMs = performance.now() - startedAt + + expect(paths).toHaveLength(worktrees.length) + // ~190ms before, ~1ms after; loose enough for a slow CI box. + expect(elapsedMs).toBeLessThan(100) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts index 6aad12c03..7bc85b130 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts @@ -14,21 +14,33 @@ export function deriveAiVaultWorkspaceScopePaths( return [] } - const paths: string[] = [] - addAiVaultWorkspaceScopePath(paths, activeWorktree.path) + return collectWorkspaceScopePaths(activeWorktree, liveWorktrees).paths +} - for (const priorWorktreeId of activeWorktree.priorWorktreeIds ?? []) { +function collectWorkspaceScopePaths( + activeWorktree: Pick, + liveWorktrees: readonly Pick[] +): ScopePathAccumulator { + const accumulator = createScopePathAccumulator() + addAiVaultWorkspaceScopePath(accumulator, activeWorktree.path) + + const priorWorktreeIds = activeWorktree.priorWorktreeIds ?? [] + // Built once instead of rescanning every live worktree per prior id. + const claimedComparisonPaths = + priorWorktreeIds.length > 0 ? buildClaimedComparisonPaths(liveWorktrees, activeWorktree) : null + + for (const priorWorktreeId of priorWorktreeIds) { const parsed = splitWorktreeIdForFilesystem(priorWorktreeId) if (!parsed || parsed.repoId !== activeWorktree.repoId) { continue } - if (isAiVaultWorkspaceScopePathClaimed(parsed.worktreePath, activeWorktree, liveWorktrees)) { + if (isAiVaultWorkspaceScopePathClaimed(parsed.worktreePath, claimedComparisonPaths)) { continue } - addAiVaultWorkspaceScopePath(paths, parsed.worktreePath) + addAiVaultWorkspaceScopePath(accumulator, parsed.worktreePath) } - return paths + return accumulator } /** @@ -48,10 +60,12 @@ export function deriveAiVaultScopeSessionPaths( projectHostSetupProjection?: ProjectHostSetupProjection } = {} ): string[] { - const paths = deriveAiVaultWorkspaceScopePaths(activeWorktree, liveWorktrees) if (!activeWorktree) { - return paths + return [] } + // Carries the workspace pass's dedupe keys forward, so the project pass does + // not restart deduplication against a plain array. + const accumulator = collectWorkspaceScopePaths(activeWorktree, liveWorktrees) const setupsByRepoId = buildProjectSetupsByRepoId(options.projectHostSetupProjection) for (const worktree of liveWorktrees) { if ( @@ -61,15 +75,15 @@ export function deriveAiVaultScopeSessionPaths( (setup) => worktreeProjectKey(setup, setup) === options.activeProjectKey ) ) { - addAiVaultWorkspaceScopePath(paths, worktree.path) + addAiVaultWorkspaceScopePath(accumulator, worktree.path) } } for (const setup of options.projectHostSetupProjection?.setups ?? []) { if (worktreeProjectKey(setup, setup) === options.activeProjectKey) { - addAiVaultWorkspaceScopePath(paths, setup.path) + addAiVaultWorkspaceScopePath(accumulator, setup.path) } } - return paths + return accumulator.paths } function buildProjectSetupsByRepoId( @@ -95,34 +109,71 @@ function worktreeProjectKey( return entry.repoId ? `repo:${entry.repoId}` : null } -function addAiVaultWorkspaceScopePath(paths: string[], pathValue: string): void { +/** + * Paths plus their comparison keys. + * + * Why the key set: deduping by rescanning the accumulated paths re-normalized + * every accepted path on every insert, which is O(n^2) `normalize('NFC')` calls + * and cost ~190ms on a 1124-workspace profile — on the workspace-switch path, + * since these paths are derived from the active worktree. + */ +type ScopePathAccumulator = { + paths: string[] + comparisonKeys: Set +} + +function createScopePathAccumulator(): ScopePathAccumulator { + return { paths: [], comparisonKeys: new Set() } +} + +function addAiVaultWorkspaceScopePath(accumulator: ScopePathAccumulator, pathValue: string): void { const trimmedPath = pathValue.trim() if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) { return } const comparisonPath = normalizeRuntimePathForComparison(trimmedPath) - if ( - paths.some((existingPath) => normalizeRuntimePathForComparison(existingPath) === comparisonPath) - ) { + if (accumulator.comparisonKeys.has(comparisonPath)) { return } - paths.push(trimmedPath) + accumulator.comparisonKeys.add(comparisonPath) + accumulator.paths.push(trimmedPath) +} + +/** + * Comparison paths owned by a worktree *other than* the active one. + * + * Why exclude the active worktree while building rather than when reading: a + * path→id map would otherwise have to pick one owner among duplicates, and + * picking the active worktree would mask a real claimant sitting later in the + * list. Excluding it up front means any surviving entry is a claim by + * definition, which matches the previous `some()` regardless of ordering. + */ +function buildClaimedComparisonPaths( + liveWorktrees: readonly Pick[], + activeWorktree: Pick +): Set { + const claimedPaths = new Set() + for (const worktree of liveWorktrees) { + if (worktree.id === activeWorktree.id) { + continue + } + const trimmedPath = worktree.path.trim() + if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) { + continue + } + claimedPaths.add(normalizeRuntimePathForComparison(trimmedPath)) + } + return claimedPaths } function isAiVaultWorkspaceScopePathClaimed( pathValue: string, - activeWorktree: Pick, - liveWorktrees: readonly Pick[] + claimedComparisonPaths: Set | null ): boolean { const trimmedPath = pathValue.trim() - if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) { + if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath) || !claimedComparisonPaths) { return false } - const comparisonPath = normalizeRuntimePathForComparison(trimmedPath) // AI Vault sessions are keyed by cwd only, so any live worktree now owning this path wins. - return liveWorktrees.some( - (worktree) => - worktree.id !== activeWorktree.id && - normalizeRuntimePathForComparison(worktree.path) === comparisonPath - ) + return claimedComparisonPaths.has(normalizeRuntimePathForComparison(trimmedPath)) }