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:
parent
c328ac8a9c
commit
7cde0f0b76
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}))()
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue