fix(ai-vault): surface a workspace's own sessions past the recency cap (#6273)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
This commit is contained in:
gatsby74 2026-06-25 07:03:35 +02:00 committed by GitHub
parent c328ac8a9c
commit 7cde0f0b76
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 924 additions and 130 deletions

View File

@ -0,0 +1,253 @@
import { createReadStream } from 'fs'
import { readdir, stat } from 'fs/promises'
import { createInterface } from 'readline'
import { extname, join } from 'path'
import {
isPathInsideOrEqual,
normalizeRuntimePathForComparison
} from '../../shared/cross-platform-path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { parseWslUncPath } from '../../shared/wsl-paths'
import type { FileWithMtime } from './session-scanner-types'
import { errorMessage, extractString, parseJsonObject } from './session-scanner-values'
// Reading a few lines of one transcript per project dir is enough to learn that
// dir's cwd; cap both so a giant or cwd-less transcript can't stall the scan.
const REPRESENTATIVE_CWD_LINE_LIMIT = 200
const REPRESENTATIVE_FILE_LIMIT = 3
const CLAUDE_EXTENSIONS = new Set(['.jsonl'])
/**
* Fully include the transcripts of Claude project directories whose cwd falls
* inside the active workspace/project paths.
*
* Why: Claude organizes `~/.claude/projects/<cwd-encoded>/` one directory per
* cwd. The global scan is recency-capped, so a project the user hasn't touched
* recently can drop off the list entirely even though `claude --resume` still
* finds it. For scoped panel views we resolve each project dir's cwd cheaply and
* bypass the cap for the ones that belong to the active scope.
*/
export async function discoverInScopeClaudeFiles(args: {
rootDirs: readonly string[]
scopePaths: readonly string[]
limit: number
excludedFilePaths: ReadonlySet<string>
issues: AiVaultScanIssue[]
}): Promise<FileWithMtime[]> {
if (args.scopePaths.length === 0 || args.limit <= 0) {
return []
}
const scopeProjectPrefixes = claudeProjectScopePrefixes(args.scopePaths)
const collected = new Map<string, FileWithMtime>()
for (const rootDir of args.rootDirs) {
for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes)) {
const cwd = await readProjectDirCwd(projectDir)
if (!cwd || !args.scopePaths.some((scopePath) => isCwdInsideScopePath(scopePath, cwd))) {
continue
}
await collectClaudeFiles({
projectDir,
issues: args.issues,
collected,
limit: args.limit,
excludedFilePaths: args.excludedFilePaths
})
}
}
return [...collected.values()].sort((left, right) => right.mtimeMs - left.mtimeMs)
}
function claudeProjectScopePrefixes(scopePaths: readonly string[]): Set<string> {
const prefixes = new Set<string>()
for (const scopePath of scopePaths) {
for (const candidate of scopePathCandidates(scopePath)) {
prefixes.add(encodeClaudeProjectPath(candidate))
}
}
return prefixes
}
function scopePathCandidates(scopePath: string): string[] {
const wslScopePath = parseWslUncPath(scopePath)
return wslScopePath ? [scopePath, wslScopePath.linuxPath] : [scopePath]
}
function encodeClaudeProjectPath(pathValue: string): string {
return normalizeRuntimePathForComparison(pathValue).replace(/[^a-zA-Z0-9]/g, '-')
}
function isClaudeProjectDirInScope(projectDirName: string, scopePrefixes: ReadonlySet<string>) {
for (const prefix of scopePrefixes) {
if (projectDirName === prefix || projectDirName.startsWith(`${prefix}-`)) {
return true
}
}
return false
}
function isCwdInsideScopePath(scopePath: string, cwd: string): boolean {
if (isPathInsideOrEqual(scopePath, cwd)) {
return true
}
const wslScopePath = parseWslUncPath(scopePath)
if (!wslScopePath) {
return false
}
// WSL transcripts record Linux cwd values even when the renderer sends the
// active worktree as a Windows UNC path.
return isPathInsideOrEqual(wslScopePath.linuxPath, cwd)
}
async function listProjectDirs(
rootDir: string,
scopeProjectPrefixes: ReadonlySet<string>
): Promise<string[]> {
let entries
try {
entries = await readdir(rootDir, { withFileTypes: true })
} catch {
return []
}
return entries
.filter(
(entry) => entry.isDirectory() && isClaudeProjectDirInScope(entry.name, scopeProjectPrefixes)
)
.map((entry) => join(rootDir, entry.name))
}
async function readProjectDirCwd(projectDir: string): Promise<string | null> {
const files = await newestClaudeFilesInDir(projectDir)
for (const file of files.slice(0, REPRESENTATIVE_FILE_LIMIT)) {
const cwd = await readFirstCwd(file)
if (cwd) {
return cwd
}
}
return null
}
async function newestClaudeFilesInDir(projectDir: string): Promise<string[]> {
let entries
try {
entries = await readdir(projectDir, { withFileTypes: true })
} catch {
return []
}
const newest: { path: string; mtimeMs: number }[] = []
for (const entry of entries) {
if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
continue
}
const path = join(projectDir, entry.name)
try {
addBoundedPath(newest, REPRESENTATIVE_FILE_LIMIT, {
path,
mtimeMs: (await stat(path)).mtimeMs
})
} catch {
// Best effort: unreadable candidates are ignored here and reported during
// full collection if the project directory proves in-scope.
}
}
return newest.sort((left, right) => right.mtimeMs - left.mtimeMs).map((value) => value.path)
}
async function readFirstCwd(filePath: string): Promise<string | null> {
const input = createReadStream(filePath, { encoding: 'utf-8' })
const lines = createInterface({ input, crlfDelay: Infinity })
let read = 0
try {
for await (const line of lines) {
if (read++ >= REPRESENTATIVE_CWD_LINE_LIMIT) {
break
}
const cwd = extractString(parseJsonObject(line)?.cwd)
if (cwd) {
return cwd
}
}
} catch {
return null
} finally {
// readline.close() leaves the underlying stream open; destroy it so the early
// break/catch paths don't leak a file descriptor (this runs per project dir).
lines.close()
input.destroy()
}
return null
}
async function collectClaudeFiles(args: {
projectDir: string
issues: AiVaultScanIssue[]
collected: Map<string, FileWithMtime>
limit: number
excludedFilePaths: ReadonlySet<string>
}): Promise<void> {
let entries
try {
entries = await readdir(args.projectDir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
if (!entry.isFile() || !CLAUDE_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
continue
}
const path = join(args.projectDir, entry.name)
if (args.collected.has(path) || args.excludedFilePaths.has(path)) {
continue
}
try {
const fileStat = await stat(path)
addBoundedFile(args.collected, args.limit, {
path,
mtimeMs: fileStat.mtimeMs,
modifiedAt: fileStat.mtime.toISOString()
})
} catch (err) {
args.issues.push({ agent: 'claude', path, message: errorMessage(err) })
}
}
}
function addBoundedFile(
collected: Map<string, FileWithMtime>,
limit: number,
file: FileWithMtime
): void {
if (collected.size < limit) {
collected.set(file.path, file)
return
}
let oldest: FileWithMtime | null = null
for (const candidate of collected.values()) {
if (!oldest || candidate.mtimeMs < oldest.mtimeMs) {
oldest = candidate
}
}
if (oldest && file.mtimeMs > oldest.mtimeMs) {
collected.delete(oldest.path)
collected.set(file.path, file)
}
}
function addBoundedPath<T extends { mtimeMs: number }>(items: T[], limit: number, item: T): void {
if (items.length < limit) {
items.push(item)
return
}
let oldestIndex = 0
for (let index = 1; index < items.length; index++) {
if (items[index].mtimeMs < items[oldestIndex].mtimeMs) {
oldestIndex = index
}
}
if (item.mtimeMs > items[oldestIndex].mtimeMs) {
items[oldestIndex] = item
}
}

View File

@ -0,0 +1,169 @@
import { mkdtemp, mkdir, rm, utimes, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it } from 'vitest'
import { scanAiVaultSessions } from './session-scanner'
import type { AiVaultScanOptions } from './session-scanner-types'
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
// Point every non-Claude source at a nonexistent dir so the scan only sees the
// Claude fixtures created per test.
function scopedScanOptions(claudeProjectsDir: string, extra: Partial<AiVaultScanOptions>) {
return {
claudeProjectsDir,
codexSessionsDir: '/nonexistent/codex',
geminiSessionsDir: '/nonexistent/gemini',
copilotSessionsDir: '/nonexistent/copilot',
cursorProjectsDir: '/nonexistent/cursor',
opencodeStorageDir: '/nonexistent/opencode',
opencodeDbPaths: [],
grokSessionsDir: '/nonexistent/grok',
devinTranscriptsDir: '/nonexistent/devin',
hermesSessionsDir: '/nonexistent/hermes',
rovoSessionsDir: '/nonexistent/rovo',
openclawStateDir: '/nonexistent/openclaw',
openclawLegacyStateDir: '/nonexistent/openclaw-legacy',
piSessionsDir: '/nonexistent/pi',
droidSessionsDir: '/nonexistent/droid',
droidProjectsDir: '/nonexistent/droid-projects',
kimiSessionsDir: '/nonexistent/kimi',
...extra
} satisfies AiVaultScanOptions
}
async function writeClaudeSession(args: {
claudeRoot: string
dirName: string
sessionId: string
cwd: string
iso: string
leadingCwdlessLine?: boolean
}): Promise<void> {
const dir = join(args.claudeRoot, args.dirName)
await mkdir(dir, { recursive: true })
const records: unknown[] = []
if (args.leadingCwdlessLine) {
records.push({ type: 'last-prompt', sessionId: args.sessionId })
}
records.push({
type: 'user',
sessionId: args.sessionId,
timestamp: args.iso,
cwd: args.cwd,
message: { role: 'user', content: `work in ${args.cwd}` }
})
const filePath = join(dir, `${args.sessionId}.jsonl`)
await writeFile(filePath, records.map((record) => JSON.stringify(record)).join('\n'))
const time = new Date(args.iso)
await utimes(filePath, time, time)
}
describe('scanAiVaultSessions scope inclusion', () => {
it('surfaces in-scope sessions older than the global recency cap', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
tempRoots.push(root)
const claudeRoot = join(root, 'claude-projects')
// One old in-scope session that the recency cap would otherwise drop.
await writeClaudeSession({
claudeRoot,
dirName: '-repo-app',
sessionId: 'old-in-scope',
cwd: '/repo/app',
iso: '2026-01-01T00:00:00.000Z',
leadingCwdlessLine: true
})
// A session in a sub-cwd directory of the same workspace path.
await writeClaudeSession({
claudeRoot,
dirName: '-repo-app-packages-ui',
sessionId: 'old-in-scope-subdir',
cwd: '/repo/app/packages/ui',
iso: '2026-01-02T00:00:00.000Z'
})
// Recent out-of-scope sessions that fill the cap.
for (let index = 0; index < 4; index++) {
await writeClaudeSession({
claudeRoot,
dirName: `-other-${index}`,
sessionId: `recent-${index}`,
cwd: `/other/${index}`,
iso: `2026-06-2${index}T00:00:00.000Z`
})
}
const withoutScope = await scanAiVaultSessions(scopedScanOptions(claudeRoot, { limit: 2 }))
const withScope = await scanAiVaultSessions(
scopedScanOptions(claudeRoot, { limit: 2, scopePaths: ['/repo/app'] })
)
const ids = (result: { sessions: { sessionId: string }[] }) =>
result.sessions.map((session) => session.sessionId)
// The cap hides the old in-scope sessions when no scope is provided.
expect(ids(withoutScope)).not.toContain('old-in-scope')
expect(ids(withoutScope)).not.toContain('old-in-scope-subdir')
// Scope paths force them back in, including the sub-cwd directory.
expect(ids(withScope)).toContain('old-in-scope')
expect(ids(withScope)).toContain('old-in-scope-subdir')
})
it('does not duplicate sessions already in the capped result', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
tempRoots.push(root)
const claudeRoot = join(root, 'claude-projects')
await writeClaudeSession({
claudeRoot,
dirName: '-repo-app',
sessionId: 'recent-in-scope',
cwd: '/repo/app',
iso: '2026-06-24T00:00:00.000Z'
})
const result = await scanAiVaultSessions(
scopedScanOptions(claudeRoot, { limit: 50, scopePaths: ['/repo/app'] })
)
const matches = result.sessions.filter((session) => session.sessionId === 'recent-in-scope')
expect(matches).toHaveLength(1)
})
it('matches WSL UNC scope paths against Linux Claude cwd values', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-scope-'))
tempRoots.push(root)
const claudeRoot = join(root, 'claude-projects')
await writeClaudeSession({
claudeRoot,
dirName: '-home-ada-repo',
sessionId: 'old-wsl-in-scope',
cwd: '/home/ada/repo',
iso: '2026-01-01T00:00:00.000Z'
})
for (let index = 0; index < 4; index++) {
await writeClaudeSession({
claudeRoot,
dirName: `-other-wsl-${index}`,
sessionId: `recent-wsl-${index}`,
cwd: `/other/wsl/${index}`,
iso: `2026-06-2${index}T00:00:00.000Z`
})
}
const result = await scanAiVaultSessions(
scopedScanOptions(claudeRoot, {
limit: 2,
scopePaths: ['\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo']
})
)
expect(result.sessions.map((session) => session.sessionId)).toContain('old-wsl-in-scope')
})
})

View File

@ -29,6 +29,9 @@ export type AiVaultScanOptions = {
kimiSessionsDir?: string
limit?: number
limitPerAgent?: number
// Active workspace/project paths whose sessions must be included regardless of
// the recency cap (see discoverInScopeClaudeFiles).
scopePaths?: readonly string[]
platform?: NodeJS.Platform
}

View File

@ -6,6 +6,7 @@ import type {
import { sessionSortTime } from './session-scanner-accumulator'
import { parseAgentSessionFile } from './session-scanner-agent-parser'
import { codexHomeForSessionsDir } from './session-scanner-codex-paths'
import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery'
import {
DEFAULT_CODEX_HOME_DIR,
discoverAiVaultSessionSources
@ -13,6 +14,7 @@ import {
import type {
AiVaultScanOptions,
SessionFileCandidate,
SessionFileDiscovery,
SessionParseResult
} from './session-scanner-types'
import { clampPositiveInteger, errorMessage } from './session-scanner-values'
@ -20,6 +22,9 @@ import { clampPositiveInteger, errorMessage } from './session-scanner-values'
const DEFAULT_LIMIT = 1000
const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000
const SESSION_PARSE_CONCURRENCY = 8
// Upper bound on extra in-scope transcripts discovered and parsed past the
// recency cap; guards against a pathological scoped history directory.
const SCOPE_PARSE_LIMIT = 2000
/**
* Scan all supported AI agent session stores and return a unified, sorted,
@ -61,17 +66,80 @@ export async function scanAiVaultSessions(
issues
})
const sessions = parsedSessions
const cappedSessions = parsedSessions
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit)
const scopeSessions = await scanInScopeSessions({
discoveries,
scopePaths: options.scopePaths ?? [],
alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)),
platform,
issues
})
return {
sessions,
sessions: mergeSessions(cappedSessions, scopeSessions),
issues,
scannedAt: new Date().toISOString()
}
}
// In-scope sessions are guaranteed regardless of the recency cap, so the global
// (already capped) result and the scope result are unioned and de-duplicated by
// session id, then re-sorted DESC.
function mergeSessions(
cappedSessions: AiVaultSession[],
scopeSessions: AiVaultSession[]
): AiVaultSession[] {
if (scopeSessions.length === 0) {
return cappedSessions
}
const byId = new Map<string, AiVaultSession>()
for (const session of cappedSessions) {
byId.set(session.id, session)
}
for (const session of scopeSessions) {
byId.set(session.id, session)
}
return [...byId.values()].sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
}
async function scanInScopeSessions(args: {
discoveries: SessionFileDiscovery[]
scopePaths: readonly string[]
alreadyParsedFilePaths: ReadonlySet<string>
platform: NodeJS.Platform
issues: AiVaultScanIssue[]
}): Promise<AiVaultSession[]> {
if (args.scopePaths.length === 0) {
return []
}
const claudeRootDirs = args.discoveries
.filter((discovery) => discovery.agent === 'claude')
.map((discovery) => discovery.rootDir)
const files = await discoverInScopeClaudeFiles({
rootDirs: claudeRootDirs,
scopePaths: args.scopePaths,
limit: SCOPE_PARSE_LIMIT,
excludedFilePaths: args.alreadyParsedFilePaths,
issues: args.issues
})
const candidates = files.map(
(file): SessionFileCandidate => ({ agent: 'claude', file, codexHome: null })
)
if (candidates.length === 0) {
return []
}
// Parse every in-scope candidate (limit === candidate count never early-stops).
return parseSessionCandidates({
candidates,
limit: candidates.length,
platform: args.platform,
issues: args.issues
})
}
async function parseSessionCandidates(args: {
candidates: SessionFileCandidate[]
limit: number

View File

@ -22,7 +22,11 @@ let inflightKey: string | null = null
let handlerOptions: AiVaultHandlerOptions = {}
async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
const key = String(args?.limit ?? 'default')
// Scope paths change the result set, so they must be part of the cache key.
const key = JSON.stringify({
limit: args?.limit ?? 'default',
scopePaths: args?.scopePaths ?? []
})
const now = Date.now()
// Why: opening this panel repeatedly should not re-parse hundreds of JSONL
// transcripts; explicit refreshes bypass the cache but not an active scan.
@ -40,6 +44,7 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListR
inflightList = (async () =>
scanAiVaultSessions({
limit: args?.limit,
scopePaths: args?.scopePaths,
additionalCodexSessionsDirs,
wslHomeDirs: await getAiVaultWslHomeDirs()
}))()

View File

@ -14,12 +14,11 @@ import {
useRepoById,
useRepos
} from '@/store/selectors'
import { agentLabel, filterAiVaultSessions, groupAiVaultSessions } from './ai-vault-session-filters'
import {
agentLabel,
deriveAiVaultWorkspaceScopePaths,
filterAiVaultSessions,
groupAiVaultSessions
} from './ai-vault-session-filters'
deriveAiVaultScopeSessionPaths,
deriveAiVaultWorkspaceScopePaths
} from './ai-vault-scope-paths'
import {
DEFAULT_AI_VAULT_SCOPE,
getRestorableAiVaultScope,
@ -30,7 +29,6 @@ import {
AI_VAULT_AGENTS,
type AiVaultAgent,
type AiVaultGroup,
type AiVaultListResult,
type AiVaultScope,
type AiVaultSession,
type AiVaultSort
@ -39,8 +37,7 @@ import { getLocalExecutionHostLabel } from '../../../../shared/execution-host'
import { translate } from '@/i18n/i18n'
import { AiVaultPanelHeader } from './AiVaultPanelHeader'
import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList'
const SESSION_LIMIT = 500
import { useAiVaultSessionRefresh } from './ai-vault-session-refresh'
export default function AiVaultPanel(): React.JSX.Element {
const activeWorktree = useActiveWorktree()
@ -57,14 +54,7 @@ export default function AiVaultPanel(): React.JSX.Element {
const [group, setGroup] = useState<AiVaultGroup>('project')
const [hideEmptySessions, setHideEmptySessions] = useState(true)
const [agents, setAgents] = useState<AiVaultAgent[]>([...AI_VAULT_AGENTS])
const [sessions, setSessions] = useState<AiVaultSession[]>([])
const [scanResult, setScanResult] = useState<AiVaultListResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set())
const refreshIdRef = useRef(0)
const refreshInFlightRef = useRef(false)
const mountedRef = useRef(true)
const userChangedScopeRef = useRef(false)
const preferredScopeRef = useRef<AiVaultScope>(DEFAULT_AI_VAULT_SCOPE)
@ -75,7 +65,31 @@ export default function AiVaultPanel(): React.JSX.Element {
() => deriveAiVaultWorkspaceScopePaths(activeWorktree ?? null, allWorktrees),
[activeWorktree, allWorktrees]
)
const projectContext = useMemo(
const projectScopeContext = useMemo(
() =>
buildAiVaultProjectContext({
repos,
worktrees: allWorktrees,
projectHostSetupProjection,
activeRepo,
activeWorktree,
sessions: []
}),
[activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos]
)
const activeProjectKey = projectScopeContext.activeProjectKey
const projectLabelByKey = projectScopeContext.projectLabelByKey
// Sent to the scanner so scoped views surface sessions older than the global cap.
const scopePaths = useMemo(
() =>
deriveAiVaultScopeSessionPaths(activeWorktree ?? null, allWorktrees, {
activeProjectKey,
projectHostSetupProjection
}),
[activeProjectKey, activeWorktree, allWorktrees, projectHostSetupProjection]
)
const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh(scopePaths)
const sessionProjectById = useMemo(
() =>
buildAiVaultProjectContext({
repos,
@ -84,12 +98,9 @@ export default function AiVaultPanel(): React.JSX.Element {
activeRepo,
activeWorktree,
sessions
}),
}).sessionProjectById,
[activeRepo, activeWorktree, allWorktrees, projectHostSetupProjection, repos, sessions]
)
const activeProjectKey = projectContext.activeProjectKey
const projectLabelByKey = projectContext.projectLabelByKey
const sessionProjectById = projectContext.sessionProjectById
const hasAllAgentsSelected = agents.length === AI_VAULT_AGENTS.length
const viewAdjustmentCount =
(hasAllAgentsSelected ? 0 : 1) +
@ -122,51 +133,6 @@ export default function AiVaultPanel(): React.JSX.Element {
}
}, [activeProjectKey, activeWorktreePath, scope])
const refresh = useCallback(async (args: { force?: boolean } = {}): Promise<void> => {
if (refreshInFlightRef.current) {
return
}
refreshInFlightRef.current = true
const refreshId = refreshIdRef.current + 1
refreshIdRef.current = refreshId
setLoading(true)
setError(null)
try {
const result = await window.api.aiVault.listSessions({
limit: SESSION_LIMIT,
force: args.force
})
if (!mountedRef.current || refreshIdRef.current !== refreshId) {
return
}
setScanResult(result)
setSessions(result.sessions)
} catch (err) {
if (mountedRef.current && refreshIdRef.current === refreshId) {
setError(err instanceof Error ? err.message : String(err))
}
} finally {
refreshInFlightRef.current = false
if (mountedRef.current && refreshIdRef.current === refreshId) {
setLoading(false)
}
}
}, [])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
refreshIdRef.current += 1
refreshInFlightRef.current = false
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const filteredSessions = useMemo(
() =>
filterAiVaultSessions(sessions, {

View File

@ -0,0 +1,128 @@
import {
isRuntimePathAbsolute,
normalizeRuntimePathForComparison
} from '../../../../shared/cross-platform-path'
import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection'
import type { Worktree } from '../../../../shared/types'
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
export function deriveAiVaultWorkspaceScopePaths(
activeWorktree: Pick<Worktree, 'id' | 'path' | 'priorWorktreeIds' | 'repoId'> | null,
liveWorktrees: readonly Pick<Worktree, 'id' | 'path' | 'repoId'>[] = []
): string[] {
if (!activeWorktree) {
return []
}
const paths: string[] = []
addAiVaultWorkspaceScopePath(paths, activeWorktree.path)
for (const priorWorktreeId of activeWorktree.priorWorktreeIds ?? []) {
const parsed = splitWorktreeIdForFilesystem(priorWorktreeId)
if (!parsed || parsed.repoId !== activeWorktree.repoId) {
continue
}
if (isAiVaultWorkspaceScopePathClaimed(parsed.worktreePath, activeWorktree, liveWorktrees)) {
continue
}
addAiVaultWorkspaceScopePath(paths, parsed.worktreePath)
}
return paths
}
/**
* Paths sent to the scanner so a scoped panel view surfaces its own sessions
* even when they are older than the global recency cap. Covers the active
* workspace plus the active project's other worktrees (same repo), so both the
* Workspace and Project scopes stay complete.
*/
export function deriveAiVaultScopeSessionPaths(
activeWorktree: Pick<
Worktree,
'id' | 'path' | 'priorWorktreeIds' | 'projectId' | 'repoId'
> | null,
liveWorktrees: readonly Pick<Worktree, 'id' | 'path' | 'projectId' | 'repoId'>[] = [],
options: {
activeProjectKey?: string | null
projectHostSetupProjection?: ProjectHostSetupProjection
} = {}
): string[] {
const paths = deriveAiVaultWorkspaceScopePaths(activeWorktree, liveWorktrees)
if (!activeWorktree) {
return paths
}
const setupsByRepoId = buildProjectSetupsByRepoId(options.projectHostSetupProjection)
for (const worktree of liveWorktrees) {
if (
worktree.repoId === activeWorktree.repoId ||
worktreeProjectKey(worktree) === options.activeProjectKey ||
(setupsByRepoId.get(worktree.repoId) ?? []).some(
(setup) => worktreeProjectKey(setup, setup) === options.activeProjectKey
)
) {
addAiVaultWorkspaceScopePath(paths, worktree.path)
}
}
for (const setup of options.projectHostSetupProjection?.setups ?? []) {
if (worktreeProjectKey(setup, setup) === options.activeProjectKey) {
addAiVaultWorkspaceScopePath(paths, setup.path)
}
}
return paths
}
function buildProjectSetupsByRepoId(
projection?: ProjectHostSetupProjection
): Map<string, ProjectHostSetupProjection['setups']> {
const setupsByRepoId = new Map<string, ProjectHostSetupProjection['setups']>()
for (const setup of projection?.setups ?? []) {
const setups = setupsByRepoId.get(setup.repoId) ?? []
setups.push(setup)
setupsByRepoId.set(setup.repoId, setups)
}
return setupsByRepoId
}
function worktreeProjectKey(
entry: Pick<Worktree, 'projectId' | 'repoId'> | { projectId?: string | null; repoId?: string },
setup?: { projectId?: string | null; repoId?: string }
): string | null {
const projectId = entry.projectId ?? setup?.projectId ?? null
if (projectId) {
return projectId.startsWith('repo:') ? projectId : `project:${projectId}`
}
return entry.repoId ? `repo:${entry.repoId}` : null
}
function addAiVaultWorkspaceScopePath(paths: string[], pathValue: string): void {
const trimmedPath = pathValue.trim()
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
return
}
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
if (
paths.some((existingPath) => normalizeRuntimePathForComparison(existingPath) === comparisonPath)
) {
return
}
paths.push(trimmedPath)
}
function isAiVaultWorkspaceScopePathClaimed(
pathValue: string,
activeWorktree: Pick<Worktree, 'id'>,
liveWorktrees: readonly Pick<Worktree, 'id' | 'path'>[]
): boolean {
const trimmedPath = pathValue.trim()
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
return false
}
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
// AI Vault sessions are keyed by cwd only, so any live worktree now owning this path wins.
return liveWorktrees.some(
(worktree) =>
worktree.id !== activeWorktree.id &&
normalizeRuntimePathForComparison(worktree.path) === comparisonPath
)
}

View File

@ -2,13 +2,16 @@ import { describe, expect, it } from 'vitest'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import {
AI_VAULT_SESSION_FILTER_QUERY_MAX_BYTES,
deriveAiVaultWorkspaceScopePaths,
filterAiVaultSessions,
folderLabel,
groupAiVaultSessions,
isAiVaultSessionFilterQueryTooLarge,
parseVaultQuery
} from './ai-vault-session-filters'
import {
deriveAiVaultScopeSessionPaths,
deriveAiVaultWorkspaceScopePaths
} from './ai-vault-scope-paths'
const baseSession: AiVaultSession = {
id: 'claude:1',
@ -415,6 +418,176 @@ describe('deriveAiVaultWorkspaceScopePaths', () => {
})
})
describe('deriveAiVaultScopeSessionPaths', () => {
it('adds same-repo sibling worktrees on top of the workspace paths', () => {
expect(
deriveAiVaultScopeSessionPaths(
{
id: 'repo1::/Users/ada/workspaces/orca/fix-agent-history',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/fix-agent-history',
priorWorktreeIds: []
},
[
{
id: 'repo1::/Users/ada/workspaces/orca/fix-agent-history',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/fix-agent-history'
},
{
id: 'repo1::/Users/ada/workspaces/orca/sibling',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/sibling'
},
{
id: 'repo2::/Users/ada/workspaces/other/elsewhere',
repoId: 'repo2',
path: '/Users/ada/workspaces/other/elsewhere'
}
]
)
).toEqual([
'/Users/ada/workspaces/orca/fix-agent-history',
'/Users/ada/workspaces/orca/sibling'
])
})
it('returns no paths without an active worktree', () => {
expect(deriveAiVaultScopeSessionPaths(null, [])).toEqual([])
})
it('adds active project setup paths across repos', () => {
expect(
deriveAiVaultScopeSessionPaths(
{
id: 'repo1::/Users/ada/workspaces/orca/app',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/app',
priorWorktreeIds: []
},
[
{
id: 'repo1::/Users/ada/workspaces/orca/app',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/app'
},
{
id: 'repo2::/Users/ada/workspaces/orca/docs',
repoId: 'repo2',
path: '/Users/ada/workspaces/orca/docs'
}
],
{
activeProjectKey: 'project:orca',
projectHostSetupProjection: {
projects: [
{
id: 'orca',
displayName: 'Orca',
badgeColor: '#2563eb',
sourceRepoIds: ['repo1', 'repo2'],
createdAt: 1,
updatedAt: 1
}
],
setups: [
{
id: 'setup-1',
projectId: 'orca',
hostId: 'local',
repoId: 'repo1',
displayName: 'App',
path: '/Users/ada/workspaces/orca/app',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 1
},
{
id: 'setup-2',
projectId: 'orca',
hostId: 'local',
repoId: 'repo2',
displayName: 'Docs',
path: '/Users/ada/workspaces/orca/docs',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 1
}
]
}
}
)
).toEqual(['/Users/ada/workspaces/orca/app', '/Users/ada/workspaces/orca/docs'])
})
it('keeps live worktree paths when another setup shares the repo id', () => {
expect(
deriveAiVaultScopeSessionPaths(
{
id: 'repo1::/Users/ada/workspaces/orca/app',
repoId: 'repo1',
path: '/Users/ada/workspaces/orca/app',
priorWorktreeIds: []
},
[
{
id: 'repo2::/Users/ada/workspaces/orca/docs-worktree',
repoId: 'repo2',
path: '/Users/ada/workspaces/orca/docs-worktree'
}
],
{
activeProjectKey: 'project:orca',
projectHostSetupProjection: {
projects: [
{
id: 'orca',
displayName: 'Orca',
badgeColor: '#2563eb',
sourceRepoIds: ['repo1', 'repo2'],
createdAt: 1,
updatedAt: 1
}
],
setups: [
{
id: 'setup-1',
projectId: 'orca',
hostId: 'local',
repoId: 'repo2',
displayName: 'Docs',
path: '/Users/ada/workspaces/orca/docs',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 1
},
{
id: 'setup-2',
projectId: 'other',
hostId: 'local',
repoId: 'repo2',
displayName: 'Other',
path: '/Users/ada/workspaces/other',
setupState: 'ready',
setupMethod: 'imported-existing-folder',
createdAt: 1,
updatedAt: 1
}
]
}
}
)
).toEqual([
'/Users/ada/workspaces/orca/app',
'/Users/ada/workspaces/orca/docs-worktree',
'/Users/ada/workspaces/orca/docs'
])
})
})
describe('isAiVaultSessionFilterQueryTooLarge', () => {
it('counts UTF-8 bytes rather than UTF-16 code units', () => {
expect(

View File

@ -1,7 +1,5 @@
import {
isPathInsideOrEqual,
isRuntimePathAbsolute,
normalizeRuntimePathForComparison,
normalizeRuntimePathSeparators
} from '../../../../shared/cross-platform-path'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
@ -14,8 +12,6 @@ import type {
AiVaultSort
} from '../../../../shared/ai-vault-types'
import { aiVaultAgentLabel } from '../../../../shared/ai-vault-types'
import type { Worktree } from '../../../../shared/types'
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
import { sessionPreviewSearchText } from './ai-vault-session-display'
import type { AiVaultSessionProject } from './ai-vault-session-projects'
@ -95,31 +91,6 @@ export function filterAiVaultSessions(
.sort((left, right) => compareSessions(left, right, filters.sort))
}
export function deriveAiVaultWorkspaceScopePaths(
activeWorktree: Pick<Worktree, 'id' | 'path' | 'priorWorktreeIds' | 'repoId'> | null,
liveWorktrees: readonly Pick<Worktree, 'id' | 'path' | 'repoId'>[] = []
): string[] {
if (!activeWorktree) {
return []
}
const paths: string[] = []
addAiVaultWorkspaceScopePath(paths, activeWorktree.path)
for (const priorWorktreeId of activeWorktree.priorWorktreeIds ?? []) {
const parsed = splitWorktreeIdForFilesystem(priorWorktreeId)
if (!parsed || parsed.repoId !== activeWorktree.repoId) {
continue
}
if (isAiVaultWorkspaceScopePathClaimed(parsed.worktreePath, activeWorktree, liveWorktrees)) {
continue
}
addAiVaultWorkspaceScopePath(paths, parsed.worktreePath)
}
return paths
}
export function groupAiVaultSessions(
sessions: readonly AiVaultSession[],
group: AiVaultGroup,
@ -264,20 +235,6 @@ function getFolderGroupKey(pathValue: string | null): string {
return pathValue ? normalizeRuntimePathSeparators(pathValue).toLowerCase() : 'unknown'
}
function addAiVaultWorkspaceScopePath(paths: string[], pathValue: string): void {
const trimmedPath = pathValue.trim()
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
return
}
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
if (
paths.some((existingPath) => normalizeRuntimePathForComparison(existingPath) === comparisonPath)
) {
return
}
paths.push(trimmedPath)
}
function isAiVaultSessionInWorkspacePath(workspacePath: string, sessionCwd: string): boolean {
if (isPathInsideOrEqual(workspacePath, sessionCwd)) {
return true
@ -293,24 +250,6 @@ function isAiVaultSessionInWorkspacePath(workspacePath: string, sessionCwd: stri
return isPathInsideOrEqual(workspaceWslPath.linuxPath, sessionCwd)
}
function isAiVaultWorkspaceScopePathClaimed(
pathValue: string,
activeWorktree: Pick<Worktree, 'id'>,
liveWorktrees: readonly Pick<Worktree, 'id' | 'path'>[]
): boolean {
const trimmedPath = pathValue.trim()
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
return false
}
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
// AI Vault sessions are keyed by cwd only, so any live worktree now owning this path wins.
return liveWorktrees.some(
(worktree) =>
worktree.id !== activeWorktree.id &&
normalizeRuntimePathForComparison(worktree.path) === comparisonPath
)
}
function tokenizeQuery(query: string): string[] {
const tokens: string[] = []
const pattern = /"([^"]+)"|'([^']+)'|(\S+)/g

View File

@ -0,0 +1,87 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types'
const SESSION_LIMIT = 500
export function useAiVaultSessionRefresh(scopePaths: readonly string[]): {
error: string | null
loading: boolean
refresh: (args?: { force?: boolean }) => Promise<void>
scanResult: AiVaultListResult | null
sessions: AiVaultSession[]
} {
const [sessions, setSessions] = useState<AiVaultSession[]>([])
const [scanResult, setScanResult] = useState<AiVaultListResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const refreshIdRef = useRef(0)
const refreshInFlightRef = useRef(false)
const pendingRefreshRef = useRef(false)
const pendingForceRef = useRef(false)
const mountedRef = useRef(true)
const scopePathsKey = useMemo(() => scopePaths.join('\n'), [scopePaths])
const scopePathsRef = useRef<readonly string[]>(scopePaths)
scopePathsRef.current = scopePaths
const refresh = useCallback(async (args: { force?: boolean } = {}): Promise<void> => {
// A scope change during an in-flight scan must not be dropped; queue one more
// scan so the current scoped view is refreshed after the older scan settles.
if (refreshInFlightRef.current) {
pendingRefreshRef.current = true
pendingForceRef.current ||= args.force === true
return
}
refreshInFlightRef.current = true
const refreshId = refreshIdRef.current + 1
refreshIdRef.current = refreshId
setLoading(true)
setError(null)
try {
const result = await window.api.aiVault.listSessions({
limit: SESSION_LIMIT,
scopePaths: scopePathsRef.current,
force: args.force
})
if (!mountedRef.current || refreshIdRef.current !== refreshId) {
return
}
setScanResult(result)
setSessions(result.sessions)
} catch (err) {
if (mountedRef.current && refreshIdRef.current === refreshId) {
setError(err instanceof Error ? err.message : String(err))
}
} finally {
refreshInFlightRef.current = false
if (mountedRef.current && refreshIdRef.current === refreshId) {
setLoading(false)
}
if (pendingRefreshRef.current && mountedRef.current) {
pendingRefreshRef.current = false
const force = pendingForceRef.current
pendingForceRef.current = false
void refresh({ force })
}
}
// Deps are intentionally empty: refresh reads changing values through refs
// and recurses on itself, so its identity must stay stable.
}, [])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
refreshIdRef.current += 1
refreshInFlightRef.current = false
}
}, [])
// Re-scan on mount and whenever the active scope changes, since the scanner
// tailors its in-scope results to scopePaths.
useEffect(() => {
void refresh()
}, [refresh, scopePathsKey])
return { error, loading, refresh, scanResult, sessions }
}

View File

@ -74,6 +74,9 @@ export type AiVaultScanIssue = {
export type AiVaultListArgs = {
limit?: number
force?: boolean
// Active workspace/project paths. The global result is recency-capped, so these
// guarantee a scoped view still surfaces its own (possibly older) sessions.
scopePaths?: readonly string[]
}
export type AiVaultListResult = {