From 7cde0f0b76f4bb07a98d386439ff2eed77bb3bda Mon Sep 17 00:00:00 2001 From: gatsby74 <166927047+gatsby74@users.noreply.github.com> Date: Thu, 25 Jun 2026 07:03:35 +0200 Subject: [PATCH] fix(ai-vault): surface a workspace's own sessions past the recency cap (#6273) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Orca Co-authored-by: brennanb2025 --- .../session-scanner-scope-discovery.ts | 253 ++++++++++++++++++ .../ai-vault/session-scanner-scope.test.ts | 169 ++++++++++++ src/main/ai-vault/session-scanner-types.ts | 3 + src/main/ai-vault/session-scanner.ts | 72 ++++- src/main/ipc/ai-vault.ts | 7 +- .../components/right-sidebar/AiVaultPanel.tsx | 96 +++---- .../right-sidebar/ai-vault-scope-paths.ts | 128 +++++++++ .../ai-vault-session-filters.test.ts | 175 +++++++++++- .../right-sidebar/ai-vault-session-filters.ts | 61 ----- .../right-sidebar/ai-vault-session-refresh.ts | 87 ++++++ src/shared/ai-vault-types.ts | 3 + 11 files changed, 924 insertions(+), 130 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-scope-discovery.ts create mode 100644 src/main/ai-vault/session-scanner-scope.test.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts diff --git a/src/main/ai-vault/session-scanner-scope-discovery.ts b/src/main/ai-vault/session-scanner-scope-discovery.ts new file mode 100644 index 000000000..7fae046cf --- /dev/null +++ b/src/main/ai-vault/session-scanner-scope-discovery.ts @@ -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//` 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 + issues: AiVaultScanIssue[] +}): Promise { + if (args.scopePaths.length === 0 || args.limit <= 0) { + return [] + } + const scopeProjectPrefixes = claudeProjectScopePrefixes(args.scopePaths) + const collected = new Map() + 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 { + const prefixes = new Set() + 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) { + 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 +): Promise { + 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 { + 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 { + 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 { + 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 + limit: number + excludedFilePaths: ReadonlySet +}): Promise { + 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, + 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(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 + } +} diff --git a/src/main/ai-vault/session-scanner-scope.test.ts b/src/main/ai-vault/session-scanner-scope.test.ts new file mode 100644 index 000000000..ec9856389 --- /dev/null +++ b/src/main/ai-vault/session-scanner-scope.test.ts @@ -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) { + 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 { + 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') + }) +}) diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index fc7cf389b..b6c6ae964 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -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 } diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 484dbbc63..181bf1466 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -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() + 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 + platform: NodeJS.Platform + issues: AiVaultScanIssue[] +}): Promise { + 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 diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 5e0c839ee..f00157944 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -22,7 +22,11 @@ let inflightKey: string | null = null let handlerOptions: AiVaultHandlerOptions = {} async function listAiVaultSessions(args?: AiVaultListArgs): Promise { - 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 scanAiVaultSessions({ limit: args?.limit, + scopePaths: args?.scopePaths, additionalCodexSessionsDirs, wslHomeDirs: await getAiVaultWslHomeDirs() }))() diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 39ac366ed..77036fb5e 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -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('project') const [hideEmptySessions, setHideEmptySessions] = useState(true) const [agents, setAgents] = useState([...AI_VAULT_AGENTS]) - const [sessions, setSessions] = useState([]) - const [scanResult, setScanResult] = useState(null) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()) - const refreshIdRef = useRef(0) - const refreshInFlightRef = useRef(false) - const mountedRef = useRef(true) const userChangedScopeRef = useRef(false) const preferredScopeRef = useRef(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 => { - 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, { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts new file mode 100644 index 000000000..6aad12c03 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-scope-paths.ts @@ -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 | null, + liveWorktrees: readonly Pick[] = [] +): 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[] = [], + 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 { + const setupsByRepoId = new Map() + 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 | { 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, + liveWorktrees: readonly Pick[] +): 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 + ) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts index 5acfd125b..3190221f3 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts @@ -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( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts index f04225477..21e9d61de 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts @@ -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 | null, - liveWorktrees: readonly Pick[] = [] -): 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, - liveWorktrees: readonly Pick[] -): 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 diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts new file mode 100644 index 000000000..8d9a7fdfd --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -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 + scanResult: AiVaultListResult | null + sessions: AiVaultSession[] +} { + const [sessions, setSessions] = useState([]) + const [scanResult, setScanResult] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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(scopePaths) + scopePathsRef.current = scopePaths + + const refresh = useCallback(async (args: { force?: boolean } = {}): Promise => { + // 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 } +} diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 8df6d611d..d05674627 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -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 = {