diff --git a/src/main/ai-vault/cached-session-list.test.ts b/src/main/ai-vault/cached-session-list.test.ts index fda6a19143f6..8f3246fb22cf 100644 --- a/src/main/ai-vault/cached-session-list.test.ts +++ b/src/main/ai-vault/cached-session-list.test.ts @@ -1,5 +1,8 @@ +import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AiVaultListResult } from '../../shared/ai-vault-types' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' const { filterPathsToRunningWslDistrosAsync, @@ -125,3 +128,68 @@ describe('invalidateAiVaultSessionListCache generation guard', () => { expect(listRunningWslHomeDirsAsync).not.toHaveBeenCalled() }) }) + +describe('listAiVaultSessions symlinked scope', () => { + let root: string + beforeEach(async () => { + resetAiVaultSessionListCacheForTests() + scanAiVaultSessionsInService.mockReset() + root = await realpath(await mkdtemp(join(tmpdir(), 'orca-cached-scope-'))) + }) + afterEach(async () => { + resetAiVaultSessionListCacheForTests() + await rm(root, { recursive: true, force: true }) + }) + + it('scans the real path too and keeps real-path sessions in a symlinked workspace', async () => { + const realRepo = join(root, 'data', 'api') + const linkedRepo = join(root, 'work-api') + await mkdir(realRepo, { recursive: true }) + await symlink(realRepo, linkedRepo, 'junction') + const recorded = { + id: 'codex:s1', + executionHostId: 'local', + agent: 'codex', + sessionId: 's1', + title: 's1', + cwd: realRepo, + branch: null, + model: null, + filePath: join(root, 's1.jsonl'), + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-10-20T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 's1', + subagent: null + } satisfies AiVaultSession + const outOfScope = (id: string): AiVaultSession => ({ + ...recorded, + id: `codex:${id}`, + sessionId: id, + cwd: join(root, 'elsewhere'), + modifiedAt: '2026-10-21T00:00:00.000Z' + }) + scanAiVaultSessionsInService.mockResolvedValueOnce({ + ...scanResult('scan-A'), + sessions: [outOfScope('newer-1'), outOfScope('newer-2'), recorded] + }) + + const listed = await listAiVaultSessions({ limit: 1, scopePaths: [linkedRepo] }) + + expect(scanAiVaultSessionsInService.mock.calls[0]?.[0]).toMatchObject({ + scopePaths: [linkedRepo, realRepo] + }) + expect(listed.sessions.find((entry) => entry.id === 'codex:s1')?.cwdAliases).toEqual([ + linkedRepo + ]) + // A cache hit at depth 1 keeps one out-of-scope row; the aliased row must count as in scope. + const cached = await listAiVaultSessions({ limit: 1, scopePaths: [linkedRepo] }) + expect(cached.sessions.map((entry) => entry.id)).toEqual(['codex:newer-1', 'codex:s1']) + }) +}) diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 7b7db0131f2f..e3af8239cdc2 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -17,6 +17,10 @@ import type { AiVaultScanOptions } from './session-scanner-types' import { prepareOpenCodeWslReaders } from './opencode-wsl-runtime-preparation' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { AiVaultScanCoordinator } from './ai-vault-scan-coordinator' +import { + attachAiVaultScopeCwdAliases, + resolveAiVaultScopeRealPaths +} from './session-scope-real-paths' import { startLocalAiVaultScanProgress, type LocalAiVaultScanProgressHandle @@ -143,18 +147,19 @@ export async function listAiVaultSessions( if (!scanProgress) { throw new Error('Local scan progress was not created.') } + const scope = await resolveAiVaultScopeRealPaths(args?.scopePaths ?? []) const scanned = await scanAiVaultSessionsInBackground( { includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions, limit: args?.limit, unlimited: args?.unlimited, - scopePaths: args?.scopePaths, + scopePaths: args?.scopePaths ? scope.scanScopePaths : undefined, ...(await localAiVaultScanRoots()) }, scanSignal, scanProgress ) - const result = { ...scanned, scanId } + const result = { ...attachAiVaultScopeCwdAliases(scanned, scope), scanId } // A delete (or other invalidation) landed while this scan was running: // its result predates the delete, so caching it would resurrect the // deleted session for the TTL. Return it to this caller but don't cache. diff --git a/src/main/ai-vault/session-list-result-validation.test.ts b/src/main/ai-vault/session-list-result-validation.test.ts index 0e63b9de1689..a971cccd0a31 100644 --- a/src/main/ai-vault/session-list-result-validation.test.ts +++ b/src/main/ai-vault/session-list-result-validation.test.ts @@ -2,6 +2,22 @@ import { describe, expect, it } from 'vitest' import { parseAiVaultListResult } from './session-list-result-validation' describe('parseAiVaultListResult', () => { + it('keeps host cwd aliases, and drops only malformed ones', () => { + const parsed = parseAiVaultListResult({ + sessions: [ + { ...validSession('aliased'), cwdAliases: ['/home/me/work/api'] }, + { ...validSession('malformed'), cwdAliases: [42] } + ], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.sessions.map((entry) => [entry.sessionId, entry.cwdAliases])).toEqual([ + ['aliased', ['/home/me/work/api']], + ['malformed', undefined] + ]) + }) + it('keeps valid sessions when another wire entry is malformed', () => { const parsed = parseAiVaultListResult({ sessions: [validSession(), { id: 42 }], diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index fcae149b68a7..fcf139739d11 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { AI_VAULT_AGENTS, + AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultAgent, type AiVaultListResult, type AiVaultScanIssue, @@ -51,6 +52,12 @@ const aiVaultSessionSchema = z.object({ sessionId: z.string(), title: z.string(), cwd: z.string().nullable(), + // Why catch: a malformed alias list must cost only the aliases, never the whole row. + cwdAliases: z + .array(z.string().min(1).max(4096)) + .max(AI_VAULT_SCOPE_PATHS_MAX_COUNT) + .optional() + .catch(undefined), branch: z.string().nullable(), model: z.string().nullable(), filePath: z.string(), diff --git a/src/main/ai-vault/session-scope-real-paths.test.ts b/src/main/ai-vault/session-scope-real-paths.test.ts new file mode 100644 index 000000000000..ea91967b1e83 --- /dev/null +++ b/src/main/ai-vault/session-scope-real-paths.test.ts @@ -0,0 +1,121 @@ +import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import { + attachAiVaultScopeCwdAliases, + resolveAiVaultScopeRealPaths +} from './session-scope-real-paths' + +let root: string +let realRepo: string +let linkedRepo: string + +// Junctions need no Windows symlink privilege; elsewhere the type is ignored. +const linkDir = (target: string, path: string): Promise => symlink(target, path, 'junction') + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'orca-scope-real-paths-'))) + realRepo = join(root, 'data', 'api') + linkedRepo = join(root, 'work-api') + await mkdir(join(realRepo, 'pkg'), { recursive: true }) + await linkDir(realRepo, linkedRepo) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +function session(id: string, cwd: string | null, filePath = `/sessions/${id}.jsonl`) { + return { + id, + executionHostId: 'local', + agent: 'codex', + sessionId: id, + title: id, + cwd, + branch: null, + model: null, + filePath, + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-10-20T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: id, + subagent: null + } satisfies AiVaultSession +} + +function result(sessions: AiVaultSession[]): AiVaultListResult { + return { sessions, issues: [], scannedAt: '2026-10-20T00:00:00.000Z' } +} + +describe('resolveAiVaultScopeRealPaths', () => { + it('adds the real path of a symlinked scope so the scan admits sessions recorded there', async () => { + const scope = await resolveAiVaultScopeRealPaths([linkedRepo, realRepo, join(root, 'gone')]) + + expect(scope.scanScopePaths).toEqual([linkedRepo, realRepo, join(root, 'gone')]) + expect(scope.links).toEqual([{ scopePath: linkedRepo, realPath: realRepo }]) + }) + + it('skips paths this host does not have and paths that are not links', async () => { + const scope = await resolveAiVaultScopeRealPaths([realRepo, join(root, 'other-host-path')]) + + expect(scope.links).toEqual([]) + expect(scope.scanScopePaths).toEqual([realRepo, join(root, 'other-host-path')]) + }) +}) + +describe('attachAiVaultScopeCwdAliases', () => { + it('aliases cwds under the real path to the registered spelling and keeps cwd for resume', async () => { + const scope = await resolveAiVaultScopeRealPaths([linkedRepo]) + const listed = attachAiVaultScopeCwdAliases( + result([session('real', realRepo), session('nested', join(realRepo, 'pkg'))]), + scope + ) + + expect(listed.sessions.map((entry) => [entry.cwd, entry.cwdAliases])).toEqual([ + [realRepo, [linkedRepo]], + [join(realRepo, 'pkg'), [join(linkedRepo, 'pkg')]] + ]) + }) + + it('never resolves a recorded cwd, so a cwd spelled through another link stays unmatched', async () => { + const otherLink = join(root, 'other-link') + await linkDir(realRepo, otherLink) + const rows = [session('other-link', join(otherLink, 'pkg'))] + + const listed = attachAiVaultScopeCwdAliases( + result(rows), + await resolveAiVaultScopeRealPaths([linkedRepo]) + ) + + expect(listed.sessions).toEqual(rows) + }) + + it('leaves unrelated, cwd-less and WSL rows untouched', async () => { + const scope = await resolveAiVaultScopeRealPaths([linkedRepo]) + const rows = [ + session('unrelated', join(root, 'elsewhere')), + session('no-cwd', null), + session('wsl', '/data/api', '\\\\wsl.localhost\\Ubuntu\\home\\me\\.codex\\s.jsonl') + ] + const listed = attachAiVaultScopeCwdAliases(result(rows), scope) + + expect(listed.sessions).toEqual(rows) + }) + + it('returns the scan result unchanged when no scope path is a link', async () => { + const scanned = result([session('real', realRepo)]) + + expect( + attachAiVaultScopeCwdAliases(scanned, await resolveAiVaultScopeRealPaths([realRepo])) + ).toBe(scanned) + }) +}) diff --git a/src/main/ai-vault/session-scope-real-paths.ts b/src/main/ai-vault/session-scope-real-paths.ts new file mode 100644 index 000000000000..5e335e0c8e62 --- /dev/null +++ b/src/main/ai-vault/session-scope-real-paths.ts @@ -0,0 +1,100 @@ +import { realpath } from 'node:fs/promises' +import { isAbsolute, join } from 'node:path' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import { + normalizeRuntimePathForComparison, + relativePathInsideRoot +} from '../../shared/cross-platform-path' +import { sessionFileExecutionNamespace } from '../../shared/ai-vault-scope-cwd-match' +import { parseWslUncPath } from '../../shared/wsl-paths' + +/** A scope path registered through a symlink, and where it really points on this host. */ +type AiVaultScopeLink = { scopePath: string; realPath: string } + +export type AiVaultScopeRealPaths = { + /** The registered paths plus their real paths, so the scan admits sessions recorded under either. */ + scanScopePaths: readonly string[] + links: readonly AiVaultScopeLink[] +} + +/** + * Why here: this runs on the host that owns the files (the desktop for local + * scans, orcad for SSH and server hosts), so a symlink resolves against the + * disk the agents actually wrote their cwd on. + */ +export async function resolveAiVaultScopeRealPaths( + scopePaths: readonly string[] +): Promise { + const links: AiVaultScopeLink[] = [] + for (const scopePath of scopePaths) { + // Why skip WSL UNC paths: Windows cannot resolve a Linux symlink through the share. + if (!isAbsolute(scopePath) || parseWslUncPath(scopePath)) { + continue + } + // A path missing on this host belongs to another host's workspace; it stays unmatched. + const realPath = await realpath(scopePath).catch(() => null) + if (realPath && !sameComparisonPath(realPath, scopePath)) { + links.push({ scopePath, realPath }) + } + } + const scanScopePaths = [...scopePaths] + for (const { realPath } of links) { + if (!scanScopePaths.some((path) => sameComparisonPath(path, realPath))) { + scanScopePaths.push(realPath) + } + } + return { scanScopePaths, links } +} + +/** + * Gives each session recorded under a linked scope's real path the registered + * spelling as a matching-only alias. Resume keeps reading the recorded `cwd`. + * Why no realpath per cwd: a listing must not touch arbitrary cwds, which can sit + * on hung network mounts or behind macOS privacy prompts. + */ +export function attachAiVaultScopeCwdAliases( + result: AiVaultListResult, + scope: AiVaultScopeRealPaths +): AiVaultListResult { + if (scope.links.length === 0) { + return result + } + const aliasesByCwd = new Map() + let changed = false + const sessions: AiVaultSession[] = [] + for (const session of result.sessions) { + const cwd = session.cwd + // Why native only: WSL rows carry Linux cwds this Windows host cannot resolve. + if (!cwd || !isAbsolute(cwd) || sessionFileExecutionNamespace(session.filePath) !== 'native') { + sessions.push(session) + continue + } + let aliases = aliasesByCwd.get(cwd) + if (!aliases) { + aliases = scopeAliasesFor(cwd, scope.links) + aliasesByCwd.set(cwd, aliases) + } + if (aliases.length === 0) { + sessions.push(session) + continue + } + changed = true + sessions.push({ ...session, cwdAliases: aliases }) + } + return changed ? { ...result, sessions } : result +} + +function scopeAliasesFor(cwd: string, links: readonly AiVaultScopeLink[]): string[] { + const aliases: string[] = [] + for (const { scopePath, realPath } of links) { + const suffix = relativePathInsideRoot(realPath, cwd) + if (suffix !== null) { + aliases.push(suffix ? join(scopePath, suffix) : scopePath) + } + } + return aliases +} + +function sameComparisonPath(left: string, right: string): boolean { + return normalizeRuntimePathForComparison(left) === normalizeRuntimePathForComparison(right) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts new file mode 100644 index 000000000000..a0790f2eaa8f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts @@ -0,0 +1,57 @@ +import { sessionFileExecutionNamespace } from '../../../../shared/ai-vault-scope-cwd-match' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' + +type ProjectCandidate = { + normalizedPath: string + ownsNormalizedCwd: (normalizedCwd: string, filePath?: string) => boolean +} + +/** Sessions that share namespace, cwd and host aliases share one attribution. */ +export function aiVaultSessionProjectCwdKey( + session: Pick +): string { + return JSON.stringify([ + sessionFileExecutionNamespace(session.filePath), + session.cwd, + ...(session.cwdAliases ?? []) + ]) +} + +/** + * Candidates owning the cwd or one of its host aliases, best first. Aliases are + * the host's spellings of cwd under a symlinked workspace. + */ +export function matchAiVaultProjectCandidates( + session: Pick, + cwd: string, + candidates: readonly T[], + compare: (left: T, right: T) => number +): T[] { + const normalizedCwds = [cwd, ...(session.cwdAliases ?? [])].map(normalizeRuntimePathForComparison) + const remainderByCandidate = new Map() + for (const candidate of candidates) { + for (const normalizedCwd of normalizedCwds) { + if (!candidate.ownsNormalizedCwd(normalizedCwd, session.filePath)) { + continue + } + const remainder = normalizedCwd.length - candidate.normalizedPath.length + const previous = remainderByCandidate.get(candidate) + remainderByCandidate.set( + candidate, + previous === undefined ? remainder : Math.min(previous, remainder) + ) + } + } + const matches = [...remainderByCandidate.keys()] + if (!session.cwdAliases?.length) { + return matches.sort(compare) + } + // Why the remainder, not the root's length: roots spelled through different links + // have unrelated lengths; the closest root leaves the shortest tail. + return matches.sort( + (left, right) => + (remainderByCandidate.get(left) ?? 0) - (remainderByCandidate.get(right) ?? 0) || + compare(left, right) + ) +} 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 719c8076aeda..f5a5bc0a4a4e 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 @@ -528,6 +528,53 @@ describe('buildAiVaultSessionProjectById', () => { expect(map.get('claude:nfd')).toMatchObject({ kind: 'repo', key: 'repo:repo-1' }) }) + it('attributes a real-path session to the symlinked workspace its host alias names', () => { + const parent = makeRepo({ id: 'parent', displayName: 'Work', path: '/Users/ada/work' }) + const child = makeRepo({ id: 'child', displayName: 'Api', path: '/Users/ada/work/api' }) + const map = buildAiVaultSessionProjectById({ + repos: [parent, child], + worktrees: [ + makeWorktree({ id: 'wt-parent', repoId: parent.id, path: parent.path }), + makeWorktree({ id: 'wt-child', repoId: child.id, path: child.path }) + ], + projectHostSetupProjection: makeProjection({}), + sessions: [ + makeSession({ + id: 'claude:linked', + cwd: '/data/src/api/pkg', + cwdAliases: ['/Users/ada/work/api/pkg'] + }) + ] + }) + + expect(map.get('claude:linked')).toMatchObject({ kind: 'repo', key: 'repo:child' }) + }) + + it('keeps a nested project over a longer-spelled parent link that reaches it through an alias', () => { + const parent = makeRepo({ + id: 'parent', + displayName: 'Parent', + path: '/a-very-long-parent-link' + }) + const child = makeRepo({ id: 'child', displayName: 'Child', path: '/real/child' }) + const map = buildAiVaultSessionProjectById({ + repos: [parent, child], + worktrees: [ + makeWorktree({ id: 'wt-parent', repoId: parent.id, path: parent.path }), + makeWorktree({ id: 'wt-child', repoId: child.id, path: child.path }) + ], + projectHostSetupProjection: makeProjection({}), + sessions: [ + makeSession({ + id: 'claude:nested', + cwd: '/real/child/pkg', + cwdAliases: ['/a-very-long-parent-link/child/pkg'] + }) + ] + }) + + expect(map.get('claude:nested')).toMatchObject({ kind: 'repo', key: 'repo:child' }) + }) }) function makeSession(overrides: Partial): AiVaultSession { 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 580cff3a46bb..678d8091fc23 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 @@ -1,7 +1,4 @@ -import { - createNormalizedAiVaultScopeCwdMatcher, - sessionFileExecutionNamespace -} from '../../../../shared/ai-vault-scope-cwd-match' +import { createNormalizedAiVaultScopeCwdMatcher } from '../../../../shared/ai-vault-scope-cwd-match' import { resolveActiveProjectKey } from './ai-vault-project-key' import { toAiVaultProjectKey } from '../../../../shared/ai-vault-project-key' import { @@ -16,6 +13,10 @@ import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import { + aiVaultSessionProjectCwdKey, + matchAiVaultProjectCandidates +} from './ai-vault-session-project-candidates' import { folderGroupKey, folderLabel, @@ -105,7 +106,7 @@ export function buildAiVaultSessionProjectById({ projectsByCwd = new Map() projectsByHostAndCwd.set(host, projectsByCwd) } - const cwdKey = JSON.stringify([sessionFileExecutionNamespace(session.filePath), session.cwd]) + const cwdKey = aiVaultSessionProjectCwdKey(session) let project = projectsByCwd.get(cwdKey) if (!project) { project = resolveSessionProject(session, candidates, projectLabelByKey) @@ -267,10 +268,7 @@ function resolveSessionProject( return { kind: 'unknown', key: 'unknown', label: '' } } - const normalizedCwd = normalizeRuntimePathForComparison(cwd) - const matches = candidates.filter((candidate) => - candidate.ownsNormalizedCwd(normalizedCwd, session.filePath) - ) + const matches = matchAiVaultProjectCandidates(session, cwd, candidates, compareCandidates) const sessionHostId = normalizeExecutionHostId(session.executionHostId) const hostMatches = sessionHostId ? matches.filter((candidate) => candidate.hostKey === sessionHostId) @@ -286,7 +284,7 @@ function resolveSessionProject( return folderProject(cwd) } - const bestCandidate = hostMatches.sort(compareCandidates)[0] + const bestCandidate = hostMatches[0] if (!bestCandidate) { return folderProject(cwd) } diff --git a/src/shared/ai-vault-scope-cwd-match.ts b/src/shared/ai-vault-scope-cwd-match.ts index 1548d4386648..39987a6ade20 100644 --- a/src/shared/ai-vault-scope-cwd-match.ts +++ b/src/shared/ai-vault-scope-cwd-match.ts @@ -3,6 +3,7 @@ import { normalizeRuntimePathForComparison } from './cross-platform-path' import { parseWslUncPath, toWindowsWslPath } from './wsl-paths' +import type { AiVaultSession } from './ai-vault-types' /** Native scans can contain several WSL distros under the same execution host. */ export function sessionFileExecutionNamespace(filePath: string): string { @@ -19,6 +20,17 @@ export function createAiVaultScopeCwdMatcher( cwd ? matches(normalizeRuntimePathForComparison(cwd), filePath) : false } +/** The recorded cwd, or a host-resolved alias of it, is inside the scope. */ +export function aiVaultSessionInScope( + inScope: (cwd: string | null | undefined, filePath?: string) => boolean, + session: Pick +): boolean { + return ( + inScope(session.cwd, session.filePath) || + (session.cwdAliases?.some((alias) => inScope(alias, session.filePath)) ?? false) + ) +} + /** Pre-normalized variant for project attribution across many candidate roots. */ export function createNormalizedAiVaultScopeCwdMatcher( scopePaths: readonly string[] diff --git a/src/shared/ai-vault-session-depth.test.ts b/src/shared/ai-vault-session-depth.test.ts index f8aa4b0c5486..62471e4dfa39 100644 --- a/src/shared/ai-vault-session-depth.test.ts +++ b/src/shared/ai-vault-session-depth.test.ts @@ -142,3 +142,14 @@ describe('Agent Session History scope truncation', () => { } }) }) + +describe('truncateAiVaultListResult symlinked scope', () => { + it('keeps a session whose host alias is in scope when depth drops out-of-scope rows', () => { + const linked = { ...session('linked', '/data/src/api', 1), cwdAliases: ['/home/me/work/api'] } + const other = session('other', '/elsewhere', 2) + + const truncated = truncateAiVaultListResult(result([other, linked]), 0, ['/home/me/work/api']) + + expect(truncated.sessions.map((entry) => entry.id)).toEqual(['linked']) + }) +}) diff --git a/src/shared/ai-vault-session-depth.ts b/src/shared/ai-vault-session-depth.ts index 423c01816396..d27817b06fd1 100644 --- a/src/shared/ai-vault-session-depth.ts +++ b/src/shared/ai-vault-session-depth.ts @@ -1,4 +1,4 @@ -import { createAiVaultScopeCwdMatcher } from './ai-vault-scope-cwd-match' +import { aiVaultSessionInScope, createAiVaultScopeCwdMatcher } from './ai-vault-scope-cwd-match' import type { AiVaultListArgs, AiVaultListResult } from './ai-vault-types' export const DEFAULT_AI_VAULT_SCAN_LIMIT = 1000 @@ -54,7 +54,8 @@ export function truncateAiVaultListResult( const inScope = scopePaths.length > 0 ? createAiVaultScopeCwdMatcher(scopePaths) : null let outOfScopeCount = 0 const sessions = result.sessions.filter( - (session) => inScope?.(session.cwd, session.filePath) === true || outOfScopeCount++ < depth + (session) => + (inScope !== null && aiVaultSessionInScope(inScope, session)) || outOfScopeCount++ < depth ) return { ...result, diff --git a/src/shared/ai-vault-session-filters.test.ts b/src/shared/ai-vault-session-filters.test.ts index 4efd5b860367..451f89af88ad 100644 --- a/src/shared/ai-vault-session-filters.test.ts +++ b/src/shared/ai-vault-session-filters.test.ts @@ -359,3 +359,24 @@ describe('/shared ai-vault-session-filters (hoisted matchers and sort keys)', () actual.forEach((session, i) => expect(session).toBe(expected[i])) }) }) + +describe('filterAiVaultSessions symlinked workspace', () => { + it('keeps a session recorded under the real path when the host aliased it to the workspace', () => { + const recordedUnderRealPath: AiVaultSession = { + ...baseSession, + cwd: '/data/src/repo', + cwdAliases: ['/Users/ada/repo'] + } + + const filtered = filterAiVaultSessions([recordedUnderRealPath], { + query: '', + agents: ['claude'], + scope: 'workspace', + sort: 'updated', + activeWorktreePaths: ['/Users/ada/repo'], + hideEmptySessions: false + }) + + expect(filtered.map((entry) => entry.cwd)).toEqual(['/data/src/repo']) + }) +}) diff --git a/src/shared/ai-vault-session-filters.ts b/src/shared/ai-vault-session-filters.ts index cc70af15f6db..d828789ca754 100644 --- a/src/shared/ai-vault-session-filters.ts +++ b/src/shared/ai-vault-session-filters.ts @@ -8,7 +8,7 @@ import { } from './cross-platform-path' import { isClipboardTextByteLengthOverLimit } from './clipboard-text' import { splitAiVaultSearchQuery } from './ai-vault-search-query-operators' -import { createAiVaultScopeCwdMatcher } from './ai-vault-scope-cwd-match' +import { aiVaultSessionInScope, createAiVaultScopeCwdMatcher } from './ai-vault-scope-cwd-match' import type { AiVaultAgent, AiVaultGroup, @@ -99,7 +99,7 @@ export function filterAiVaultSessions( return false } if (filters.scope === 'workspace') { - if (!inWorkspace(session.cwd, session.filePath)) { + if (!aiVaultSessionInScope(inWorkspace, session)) { return false } } diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 400829983602..e4606b5c3ec7 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -118,6 +118,8 @@ export type AiVaultSession = { sessionId: string title: string cwd: string | null + /** The host's spellings of `cwd` under a symlinked scope path. Scope matching only; resume reads `cwd`. */ + cwdAliases?: string[] branch: string | null model: string | null filePath: string