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.
This commit is contained in:
Brennan Benson
2026-07-28 23:14:54 -07:00
parent 09a39c7b90
commit b85e267352
5 changed files with 439 additions and 128 deletions
@@ -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}
@@ -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>): AiVaultSession {
return { ...baseSession, ...overrides }
}
@@ -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<ProjectResolverArgs, 'activeRepo' | 'activeWorktree'>): Map<string, AiVaultSessionProject> {
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<string, AiVaultSessionProject>()
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<SessionProjectCandidate, 'normalizedPath' | 'ownsNormalizedCwd'> & { 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)
@@ -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>): 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> = {}): 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> = {}): 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
})
})
})
@@ -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<AiVaultSessionWorktreeStatus, 'current'>
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<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
worktrees: readonly Worktree[]
activeWorktreeId: string | null
}): ReadonlyMap<string, AiVaultSessionWorktreeInfo> {
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
}