mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user