From b85e267352bf026f2de7f93f8cf895473c984626 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:08:55 -0700 Subject: [PATCH] perf(vault): stop rebuilding session maps on every worktree switch (#11303) Switching worktrees rebuilt two ~500-entry maps in the Agent Session History panel because their memo deps included the active repo/worktree, which the maps never read; the worktree map also rebuilt ~530 path candidates (and re-normalized every root) per session, ~255k isPathInsideOrEqual calls per switch. - Drop activeRepo/activeWorktree from the sessionProjectById memo via buildAiVaultSessionProjectById, and activeWorktreeId from useAiVaultSessionWorktreeMap; 'current' is now stamped per row at read time (withAiVaultCurrentWorktreeStatus), so switches reuse both maps. - Hoist candidate building out of the per-session loop and precompute a normalized-root matcher per candidate, so map rebuilds on data changes are O(sessions + roots) normalizations instead of O(sessions x roots). NFC folding from #10841 is untouched; non-ASCII (NFD/CJK) matching is covered by new tests. Warm switch with the panel on All/500 drops 425ms -> 125ms on the full-scale rig (cold 491ms -> 193ms); panel-closed switches are unchanged. --- .../components/right-sidebar/AiVaultPanel.tsx | 63 +++--- .../ai-vault-session-projects.test.ts | 53 ++++- .../ai-vault-session-projects.ts | 111 +++++++---- .../ai-vault-session-worktree-map.test.tsx | 187 ++++++++++++++++++ .../ai-vault-session-worktree.ts | 153 ++++++++------ 5 files changed, 439 insertions(+), 128 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index c7a0479c39b..048ba89252e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -21,13 +21,19 @@ import { normalizeAiVaultScopeForContext } from './ai-vault-scope-state' import { countAiVaultViewAdjustments } from './ai-vault-view-defaults' -import { buildAiVaultProjectContext } from './ai-vault-session-projects' +import { + buildAiVaultProjectContext, + buildAiVaultSessionProjectById +} from './ai-vault-session-projects' import { resolveAiVaultSessionResumeActions, resolveAiVaultSessionResumeState } from './ai-vault-session-resume' import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions' -import { useAiVaultSessionWorktreeMap } from './ai-vault-session-worktree' +import { + useAiVaultSessionWorktreeMap, + withAiVaultCurrentWorktreeStatus +} from './ai-vault-session-worktree' import { openAiVaultSessionLogInOrca } from './ai-vault-session-log-open' import { useAiVaultOriginalPaneActions } from './ai-vault-original-pane-actions' import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types' @@ -136,27 +142,36 @@ export default function AiVaultPanel(): React.JSX.Element { scopePaths, executionHostScope ) + // Deliberately blind to the active repo/worktree: rebuilding these ~500-entry + // maps on every worktree switch is what made switching visibly slow (#10841 era). const sessionProjectById = useMemo( () => - buildAiVaultProjectContext({ + buildAiVaultSessionProjectById({ repos, worktrees: allWorktrees, projectHostSetupProjection, - activeRepo, - activeWorktree, sessions - }).sessionProjectById, - [activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos, sessions] + }), + [allWorktrees, projectHostSetupProjection, repos, sessions] ) const sessionWorktreeById = useAiVaultSessionWorktreeMap({ sessions, repos, - worktrees: allWorktrees, - activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null + worktrees: allWorktrees }) + const effectiveActiveWorktreeId = activeWorktreeId ?? activeWorktree?.id ?? null + // `current` is stamped per row at read time so the map above stays cached. + const getSessionWorktreeInfo = useCallback( + (session: AiVaultSession) => + withAiVaultCurrentWorktreeStatus( + sessionWorktreeById.get(session.id) ?? null, + effectiveActiveWorktreeId + ), + [effectiveActiveWorktreeId, sessionWorktreeById] + ) const launchActions = useAiVaultSessionLaunchActions({ activeWorktree: activeWorktree ?? null, - activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, + activeWorktreeId: effectiveActiveWorktreeId, targetState: resumeTargetState, agentCmdOverrides }) @@ -242,20 +257,13 @@ export default function AiVaultPanel(): React.JSX.Element { resolveAiVaultSessionResumeState({ sessionFilePath: session.filePath, sessionExecutionHostId: session.executionHostId, - worktreeInfo: sessionWorktreeById.get(session.id) ?? null, - activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, + worktreeInfo: getSessionWorktreeInfo(session), + activeWorktreeId: effectiveActiveWorktreeId, worktrees: allWorktrees, repos, targetState: resumeTargetState }), - [ - activeWorktree?.id, - activeWorktreeId, - allWorktrees, - repos, - resumeTargetState, - sessionWorktreeById - ] + [allWorktrees, effectiveActiveWorktreeId, getSessionWorktreeInfo, repos, resumeTargetState] ) const getSessionResumeActions = useCallback( @@ -263,20 +271,13 @@ export default function AiVaultPanel(): React.JSX.Element { resolveAiVaultSessionResumeActions({ sessionFilePath: session.filePath, sessionExecutionHostId: session.executionHostId, - worktreeInfo: sessionWorktreeById.get(session.id) ?? null, - activeWorktreeId: activeWorktreeId ?? activeWorktree?.id ?? null, + worktreeInfo: getSessionWorktreeInfo(session), + activeWorktreeId: effectiveActiveWorktreeId, worktrees: allWorktrees, repos, targetState: resumeTargetState }), - [ - activeWorktree?.id, - activeWorktreeId, - allWorktrees, - repos, - resumeTargetState, - sessionWorktreeById - ] + [allWorktrees, effectiveActiveWorktreeId, getSessionWorktreeInfo, repos, resumeTargetState] ) const handleScopeChange = useCallback((nextScope: AiVaultScope) => { @@ -355,7 +356,7 @@ export default function AiVaultPanel(): React.JSX.Element { getSessionResumeActions={getSessionResumeActions} getOriginalPaneTarget={getOriginalPaneTarget} getSessionLiveState={getSessionLiveState} - getWorktreeInfo={(session) => sessionWorktreeById.get(session.id) ?? null} + getWorktreeInfo={getSessionWorktreeInfo} onToggleGroup={toggleGroup} onJumpToOriginalPane={jumpToOriginalPane} onJumpToWorktree={jumpToWorktree} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts index d622af80803..e9d693d7dc0 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest' import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { Project, ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' -import { buildAiVaultProjectContext, toAiVaultProjectKey } from './ai-vault-session-projects' +import { + buildAiVaultProjectContext, + buildAiVaultSessionProjectById, + toAiVaultProjectKey +} from './ai-vault-session-projects' const baseSession: AiVaultSession = { id: 'claude:1', @@ -446,6 +450,53 @@ describe('buildAiVaultProjectContext', () => { }) }) +describe('buildAiVaultSessionProjectById', () => { + it('produces the exact context map without reading the active repo/worktree', () => { + const repoA = makeRepo({ id: 'repo-a', displayName: 'Alpha', path: '/Users/ada/alpha' }) + const repoB = makeRepo({ id: 'repo-b', displayName: 'Beta', path: '/Users/ada/beta' }) + const worktreeA = makeWorktree({ id: 'wt-a', repoId: repoA.id, path: '/Users/ada/alpha' }) + const worktreeB = makeWorktree({ id: 'wt-b', repoId: repoB.id, path: '/Users/ada/beta' }) + const shared = { + repos: [repoA, repoB], + worktrees: [worktreeA, worktreeB], + projectHostSetupProjection: makeProjection({}), + sessions: [ + makeSession({ id: 'claude:a', cwd: '/Users/ada/alpha/src' }), + makeSession({ id: 'claude:b', cwd: '/Users/ada/beta' }), + makeSession({ id: 'claude:none', cwd: '/Users/ada/elsewhere' }) + ] + } + + const standalone = buildAiVaultSessionProjectById(shared) + + // The active repo/worktree must never leak into attribution. + for (const [activeRepo, activeWorktree] of [ + [repoA, worktreeA], + [repoB, worktreeB], + [null, null] + ] as const) { + expect(standalone).toEqual( + buildAiVaultProjectContext({ ...shared, activeRepo, activeWorktree }).sessionProjectById + ) + } + }) + + it('attributes non-ASCII cwds across unicode normalization forms', () => { + // NFC worktree path vs NFD session cwd — both spell /Users/ada/café. + const repo = makeRepo({ id: 'repo-1', displayName: 'Café', path: '/Users/ada/caf\u00e9' }) + const worktree = makeWorktree({ id: 'wt-1', repoId: repo.id, path: '/Users/ada/caf\u00e9' }) + + const map = buildAiVaultSessionProjectById({ + repos: [repo], + worktrees: [worktree], + projectHostSetupProjection: makeProjection({}), + sessions: [makeSession({ id: 'claude:nfd', cwd: '/Users/ada/cafe\u0301/src' })] + }) + + expect(map.get('claude:nfd')).toMatchObject({ kind: 'repo', key: 'repo:repo-1' }) + }) +}) + function makeSession(overrides: Partial): AiVaultSession { return { ...baseSession, ...overrides } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts index f9f13dc9c7c..be78edf4af2 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -8,7 +8,7 @@ import type { ProjectHostSetupProjection } from '../../../../shared/project-host import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' import { - isPathInsideOrEqual, + createNormalizedPathInsideOrEqualMatcher, normalizeRuntimePathForComparison, normalizeRuntimePathSeparators } from '../../../../shared/cross-platform-path' @@ -31,6 +31,8 @@ type SessionProjectCandidate = { hostKey: ExecutionHostId projectId: string | null repoId: string | null + // Precomputed so a 500-session pass doesn't re-normalize every root per session. + ownsNormalizedCwd: (normalizedCwd: string) => boolean } type ProjectResolverArgs = { @@ -50,6 +52,31 @@ export function buildAiVaultProjectContext({ activeWorktree, sessions }: ProjectResolverArgs): AiVaultProjectContext { + const setupByRepoId = buildSetupByRepoId(projectHostSetupProjection.setups) + + return { + activeProjectKey: resolveActiveProjectKey(activeRepo, activeWorktree, setupByRepoId), + activeRepoId: activeRepo?.id ?? activeWorktree?.repoId ?? null, + projectLabelByKey: buildProjectLabelByKey(repos, projectHostSetupProjection), + sessionProjectById: buildAiVaultSessionProjectById({ + repos, + worktrees, + projectHostSetupProjection, + sessions + }) + } +} + +/** + * Session→project attribution never reads the active repo/worktree — exposed + * separately so memoizing callers don't rebuild the map on worktree switches. + */ +export function buildAiVaultSessionProjectById({ + repos, + worktrees, + projectHostSetupProjection, + sessions +}: Omit): Map { const repoById = new Map(repos.map((repo) => [repo.id, repo])) const setupByRepoId = buildSetupByRepoId(projectHostSetupProjection.setups) const projectLabelByKey = buildProjectLabelByKey(repos, projectHostSetupProjection) @@ -60,20 +87,13 @@ export function buildAiVaultProjectContext({ setupByRepoId ) const sessionProjectById = new Map() - for (const session of sessions) { sessionProjectById.set( session.id, resolveSessionProject(session, candidates, projectLabelByKey) ) } - - return { - activeProjectKey: resolveActiveProjectKey(activeRepo, activeWorktree, setupByRepoId), - activeRepoId: activeRepo?.id ?? activeWorktree?.repoId ?? null, - projectLabelByKey, - sessionProjectById - } + return sessionProjectById } export function toAiVaultProjectKey( @@ -145,17 +165,19 @@ function buildProjectCandidates( } const repo = repoById.get(worktree.repoId) const setup = setupByRepoId.get(worktree.repoId) - candidates.push({ - source: 'worktree', - normalizedPath: normalizeRuntimePathForComparison(worktree.path), - hostKey: resolveCandidateHostId( - worktree.hostId, - setup?.hostId, - repo ? getRepoExecutionHostId(repo) : null - ), - projectId: worktree.projectId ?? setup?.projectId ?? null, - repoId: worktree.repoId - }) + candidates.push( + makeProjectCandidate({ + source: 'worktree', + path: worktree.path, + hostKey: resolveCandidateHostId( + worktree.hostId, + setup?.hostId, + repo ? getRepoExecutionHostId(repo) : null + ), + projectId: worktree.projectId ?? setup?.projectId ?? null, + repoId: worktree.repoId + }) + ) } for (const setup of projection.setups) { @@ -167,13 +189,15 @@ function buildProjectCandidates( if (!hasCandidatePath(setup.path)) { continue } - candidates.push({ - source: 'setup', - normalizedPath: normalizeRuntimePathForComparison(setup.path), - hostKey: resolveCandidateHostId(setup.hostId, getRepoExecutionHostId(setup)), - projectId: setup.projectId, - repoId: setup.repoId || null - }) + candidates.push( + makeProjectCandidate({ + source: 'setup', + path: setup.path, + hostKey: resolveCandidateHostId(setup.hostId, getRepoExecutionHostId(setup)), + projectId: setup.projectId, + repoId: setup.repoId || null + }) + ) } for (const repo of repoById.values()) { @@ -183,18 +207,32 @@ function buildProjectCandidates( if (!hasCandidatePath(repo.path)) { continue } - candidates.push({ - source: 'setup', - normalizedPath: normalizeRuntimePathForComparison(repo.path), - hostKey: getRepoExecutionHostId(repo), - projectId: null, - repoId: repo.id - }) + candidates.push( + makeProjectCandidate({ + source: 'setup', + path: repo.path, + hostKey: getRepoExecutionHostId(repo), + projectId: null, + repoId: repo.id + }) + ) } return candidates } +function makeProjectCandidate( + fields: Omit & { path: string } +): SessionProjectCandidate { + const { path, ...rest } = fields + const normalizedPath = normalizeRuntimePathForComparison(path) + return { + ...rest, + normalizedPath, + ownsNormalizedCwd: createNormalizedPathInsideOrEqualMatcher(normalizedPath) + } +} + function resolveCandidateHostId( ...values: readonly (string | null | undefined)[] ): ExecutionHostId { @@ -221,9 +259,8 @@ function resolveSessionProject( return { kind: 'unknown', key: 'unknown', label: '' } } - const matches = candidates.filter((candidate) => - isPathInsideOrEqual(candidate.normalizedPath, cwd) - ) + const normalizedCwd = normalizeRuntimePathForComparison(cwd) + const matches = candidates.filter((candidate) => candidate.ownsNormalizedCwd(normalizedCwd)) const sessionHostId = normalizeExecutionHostId(session.executionHostId) const hostMatches = sessionHostId ? matches.filter((candidate) => candidate.hostKey === sessionHostId) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx new file mode 100644 index 00000000000..aae0ad8d32a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx @@ -0,0 +1,187 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { Repo, Worktree } from '../../../../shared/types' +import { + resolveAiVaultSessionWorktreeDisplay, + useAiVaultSessionWorktreeMap, + withAiVaultCurrentWorktreeStatus +} from './ai-vault-session-worktree' + +function makeSession(overrides: Partial): AiVaultSession { + return { + id: 'codex:session-1', + executionHostId: 'local', + agent: 'codex', + sessionId: 'session-1', + title: 'Find the pane', + cwd: '/repo/orca/src', + branch: null, + model: null, + filePath: '/home/ada/.codex/session-1.jsonl', + codexHome: null, + createdAt: null, + updatedAt: '2026-06-24T10:00:00.000Z', + modifiedAt: '2026-06-24T10:00:00.000Z', + messageCount: 2, + totalTokens: 42, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: "codex resume 'session-1'", + subagent: null, + ...overrides + } +} + +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 + } +} + +function makeRepo(overrides: Partial = {}): Repo { + return { + id: 'repo-1', + path: '/repo/orca', + displayName: 'orca', + badgeColor: '#737373', + addedAt: 1, + connectionId: null, + executionHostId: 'local', + ...overrides + } +} + +const worktreeA = makeWorktree({ id: 'repo-1::/repo/alpha', path: '/repo/alpha' }) +const worktreeB = makeWorktree({ id: 'repo-1::/repo/beta', path: '/repo/beta' }) +const sessionInA = makeSession({ id: 'codex:in-a', cwd: '/repo/alpha/src' }) +const sessionInB = makeSession({ id: 'codex:in-b', cwd: '/repo/beta' }) +const sessionUnmatched = makeSession({ id: 'codex:lost', cwd: '/elsewhere/deep' }) +const repos = [makeRepo()] +const worktrees = [worktreeA, worktreeB] +const sessions = [sessionInA, sessionInB, sessionUnmatched] + +describe('useAiVaultSessionWorktreeMap', () => { + it('keeps the map identity across worktree switches while the current flag moves', () => { + // Mirrors the panel wiring: cached map + per-row current stamping. + const { result, rerender } = renderHook( + ({ activeWorktreeId }: { activeWorktreeId: string | null }) => { + const map = useAiVaultSessionWorktreeMap({ sessions, repos, worktrees }) + return { + map, + infoFor: (sessionId: string) => + withAiVaultCurrentWorktreeStatus(map.get(sessionId) ?? null, activeWorktreeId) + } + }, + { initialProps: { activeWorktreeId: worktreeA.id as string | null } } + ) + + const firstMap = result.current.map + expect(result.current.infoFor(sessionInA.id)?.status).toBe('current') + expect(result.current.infoFor(sessionInB.id)?.status).toBe('active') + + rerender({ activeWorktreeId: worktreeB.id }) + + // The switch must not rebuild the 500-session map — only the stamp moves. + expect(result.current.map).toBe(firstMap) + expect(result.current.infoFor(sessionInA.id)?.status).toBe('active') + expect(result.current.infoFor(sessionInB.id)?.status).toBe('current') + }) + + it('render-time stamping matches per-session resolution for every status case', () => { + const sshWorktree = makeWorktree({ + id: 'repo-ssh::/srv/orca', + repoId: 'repo-ssh', + displayName: 'ssh', + path: '/srv/orca', + hostId: 'ssh:target-1' + }) + const archivedWorktree = makeWorktree({ + id: 'repo-1::/repo/attic', + path: '/repo/attic', + isArchived: true + }) + const allWorktrees = [...worktrees, sshWorktree, archivedWorktree] + const allRepos = [...repos, makeRepo({ id: 'repo-ssh', path: '/srv/orca' })] + const allSessions = [ + sessionInA, // active worktree + sessionInB, // non-active worktree + sessionUnmatched, // no worktree match + makeSession({ id: 'codex:ssh', cwd: '/srv/orca/src', executionHostId: 'ssh:target-1' }), + makeSession({ id: 'codex:attic', cwd: '/repo/attic' }), + makeSession({ id: 'codex:no-cwd', cwd: null, branch: 'feature/x' }) + ] + + const { result } = renderHook(() => + useAiVaultSessionWorktreeMap({ + sessions: allSessions, + repos: allRepos, + worktrees: allWorktrees + }) + ) + + for (const activeWorktreeId of [worktreeA.id, sshWorktree.id, archivedWorktree.id, null]) { + for (const session of allSessions) { + expect( + withAiVaultCurrentWorktreeStatus(result.current.get(session.id) ?? null, activeWorktreeId) + ).toEqual( + resolveAiVaultSessionWorktreeDisplay({ + session, + repos: allRepos, + worktrees: allWorktrees, + activeWorktreeId + }) + ) + } + } + }) + + it('matches non-ASCII cwds regardless of unicode normalization form', () => { + // macOS file pickers yield NFD while agents record NFC cwds (#10832) — + // explicit escapes so tooling can't silently re-normalize the fixture. + const nfcCafe = makeWorktree({ id: 'repo-1::/repo/caf\u00e9', path: '/repo/caf\u00e9' }) + const cjk = makeWorktree({ id: 'repo-1::/repo/作業区', path: '/repo/作業区' }) + const nfdSession = makeSession({ id: 'codex:nfd', cwd: '/repo/cafe\u0301/src' }) + const cjkSession = makeSession({ id: 'codex:cjk', cwd: '/repo/作業区/src' }) + + const { result } = renderHook(() => + useAiVaultSessionWorktreeMap({ + sessions: [nfdSession, cjkSession], + repos, + worktrees: [nfcCafe, cjk] + }) + ) + + expect(result.current.get(nfdSession.id)).toMatchObject({ + status: 'active', + worktreeId: nfcCafe.id + }) + expect( + withAiVaultCurrentWorktreeStatus(result.current.get(nfdSession.id) ?? null, nfcCafe.id) + ?.status + ).toBe('current') + expect(result.current.get(cjkSession.id)).toMatchObject({ + status: 'active', + worktreeId: cjk.id + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts index 670e2deecc1..d03aedb809e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.ts @@ -8,7 +8,7 @@ import { type ExecutionHostId } from '../../../../shared/execution-host' import { - isPathInsideOrEqual, + createNormalizedPathInsideOrEqualMatcher, isRuntimePathAbsolute, normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' @@ -41,6 +41,9 @@ type WorktreeCandidate = { hostId: ExecutionHostId status: Exclude source: 'current-path' | 'prior-path' + // Precomputed so a 500-session scan doesn't re-normalize ~500 roots per session. + ownsNormalizedCwd: (normalizedCwd: string) => boolean + normalizedPathLength: number } export function resolveAiVaultSessionWorktreeInfo({ @@ -54,17 +57,42 @@ export function resolveAiVaultSessionWorktreeInfo({ worktrees: readonly Worktree[] activeWorktreeId: string | null }): AiVaultSessionWorktreeInfo | null { + return withAiVaultCurrentWorktreeStatus( + resolveWorktreeInfoFromCandidates(session, buildWorktreeCandidates(worktrees, repos)), + activeWorktreeId + ) +} + +/** + * Stamps `status: 'current'` at read time so the session→worktree map itself + * never depends on the active worktree — switching worktrees must not rebuild it. + */ +export function withAiVaultCurrentWorktreeStatus( + worktreeInfo: AiVaultSessionWorktreeInfo | null, + activeWorktreeId: string | null +): AiVaultSessionWorktreeInfo | null { + if (!worktreeInfo?.worktreeId || worktreeInfo.worktreeId !== activeWorktreeId) { + return worktreeInfo + } + return worktreeInfo.status === 'current' ? worktreeInfo : { ...worktreeInfo, status: 'current' } +} + +function resolveWorktreeInfoFromCandidates( + session: AiVaultSession, + candidates: readonly WorktreeCandidate[] +): AiVaultSessionWorktreeInfo | null { if (!session.cwd) { return null } const sessionHostId = normalizeExecutionHostId(session.executionHostId) - const candidates = buildWorktreeCandidates(worktrees, repos) - .filter((candidate) => isSessionInWorktreePath(candidate.path, session.cwd!)) + const normalizedCwd = normalizeRuntimePathForComparison(session.cwd) + const matched = candidates + .filter((candidate) => candidate.ownsNormalizedCwd(normalizedCwd)) .filter((candidate) => !sessionHostId || candidate.hostId === sessionHostId) .sort(compareWorktreeCandidates) - const best = candidates[0] + const best = matched[0] if (!best) { return { status: 'unavailable', @@ -73,15 +101,8 @@ export function resolveAiVaultSessionWorktreeInfo({ } } - const status = - best.worktree.id === activeWorktreeId - ? 'current' - : best.worktree.isArchived - ? 'archived' - : best.status - return { - status, + status: best.status, label: best.worktree.displayName || compactPathLabel(best.path), path: best.path, worktreeId: best.worktree.id @@ -109,22 +130,35 @@ export function resolveAiVaultSessionWorktreeDisplay(args: { worktrees: readonly Worktree[] activeWorktreeId: string | null }): AiVaultSessionWorktreeInfo | null { - const resolved = resolveAiVaultSessionWorktreeInfo(args) + return withAiVaultCurrentWorktreeStatus( + resolveWorktreeDisplayFromCandidates( + args.session, + buildWorktreeCandidates(args.worktrees, args.repos ?? []) + ), + args.activeWorktreeId + ) +} + +function resolveWorktreeDisplayFromCandidates( + session: AiVaultSession, + candidates: readonly WorktreeCandidate[] +): AiVaultSessionWorktreeInfo | null { + const resolved = resolveWorktreeInfoFromCandidates(session, candidates) if (resolved) { return resolved } - const cwd = args.session.cwd?.trim() + const cwd = session.cwd?.trim() if (cwd) { return unavailableWorktreeInfo(cwd) } - const titlePath = extractWorktreePathFromSessionTitle(args.session.title) + const titlePath = extractWorktreePathFromSessionTitle(session.title) if (titlePath) { return unavailableWorktreeInfo(titlePath) } - const branch = args.session.branch?.trim() + const branch = session.branch?.trim() if (branch) { return { status: 'unavailable', @@ -136,32 +170,31 @@ export function resolveAiVaultSessionWorktreeDisplay(args: { return null } +/** + * Deliberately unaware of the active worktree: stamping `current` here would + * rebuild the whole map (candidates × sessions) on every worktree switch. + * Callers derive it per row via `withAiVaultCurrentWorktreeStatus`. + */ export function useAiVaultSessionWorktreeMap({ sessions, repos = [], - worktrees, - activeWorktreeId + worktrees }: { sessions: readonly AiVaultSession[] repos?: readonly Pick[] worktrees: readonly Worktree[] - activeWorktreeId: string | null }): ReadonlyMap { - return useMemo( - () => - new Map( - sessions.flatMap((session) => { - const worktreeInfo = resolveAiVaultSessionWorktreeDisplay({ - session, - repos, - worktrees, - activeWorktreeId - }) - return worktreeInfo ? [[session.id, worktreeInfo] as const] : [] - }) - ), - [activeWorktreeId, repos, sessions, worktrees] - ) + return useMemo(() => { + // Hoisted out of the per-session loop: candidates and their normalized + // roots are session-independent. + const candidates = buildWorktreeCandidates(worktrees, repos) + return new Map( + sessions.flatMap((session) => { + const worktreeInfo = resolveWorktreeDisplayFromCandidates(session, candidates) + return worktreeInfo ? [[session.id, worktreeInfo] as const] : [] + }) + ) + }, [repos, sessions, worktrees]) } function buildWorktreeCandidates( @@ -176,48 +209,50 @@ function buildWorktreeCandidates( normalizeExecutionHostId(worktree.hostId) ?? (repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID) if (hasUsablePath(worktree.path)) { - candidates.push({ - worktree, - path: worktree.path, - hostId, - status: worktree.isArchived ? 'archived' : 'active', - source: 'current-path' - }) + candidates.push(makeWorktreeCandidate(worktree, worktree.path, hostId, 'current-path')) } for (const priorWorktreeId of worktree.priorWorktreeIds ?? []) { const parsed = splitWorktreeIdForFilesystem(priorWorktreeId) if (!parsed || parsed.repoId !== worktree.repoId || !hasUsablePath(parsed.worktreePath)) { continue } - candidates.push({ - worktree, - path: parsed.worktreePath, - hostId, - status: worktree.isArchived ? 'archived' : 'active', - source: 'prior-path' - }) + candidates.push(makeWorktreeCandidate(worktree, parsed.worktreePath, hostId, 'prior-path')) } } return candidates } +function makeWorktreeCandidate( + worktree: Worktree, + path: string, + hostId: ExecutionHostId, + source: WorktreeCandidate['source'] +): WorktreeCandidate { + const ownsCwd = createNormalizedPathInsideOrEqualMatcher(path) + // A WSL UNC root also owns sessions recorded under its Linux-native cwd. + const wslPath = parseWslUncPath(path) + const ownsCwdViaWslAlias = wslPath + ? createNormalizedPathInsideOrEqualMatcher(wslPath.linuxPath) + : null + return { + worktree, + path, + hostId, + status: worktree.isArchived ? 'archived' : 'active', + source, + ownsNormalizedCwd: (normalizedCwd) => + ownsCwd(normalizedCwd) || (ownsCwdViaWslAlias?.(normalizedCwd) ?? false), + normalizedPathLength: normalizeRuntimePathForComparison(path).length + } +} + function hasUsablePath(pathValue: string): boolean { const trimmed = pathValue.trim() return Boolean(trimmed && isRuntimePathAbsolute(trimmed)) } -function isSessionInWorktreePath(worktreePath: string, sessionCwd: string): boolean { - if (isPathInsideOrEqual(worktreePath, sessionCwd)) { - return true - } - const wslPath = parseWslUncPath(worktreePath) - return wslPath ? isPathInsideOrEqual(wslPath.linuxPath, sessionCwd) : false -} - function compareWorktreeCandidates(left: WorktreeCandidate, right: WorktreeCandidate): number { - const lengthDifference = - normalizeRuntimePathForComparison(right.path).length - - normalizeRuntimePathForComparison(left.path).length + const lengthDifference = right.normalizedPathLength - left.normalizedPathLength if (lengthDifference !== 0) { return lengthDifference }