diff --git a/src/shared/ai-vault-session-depth.test.ts b/src/shared/ai-vault-session-depth.test.ts index 86ceace2215..c262d521449 100644 --- a/src/shared/ai-vault-session-depth.test.ts +++ b/src/shared/ai-vault-session-depth.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { AiVaultListResult, AiVaultSession } from './ai-vault-types' +import { isPathInsideOrEqual } from './cross-platform-path' import { aiVaultScanLimit, aiVaultSessionDepthCovers, @@ -83,3 +84,43 @@ describe('Agent Session History depth', () => { expect(truncateAiVaultListResult(loaded, 'unlimited')).toBe(loaded) }) }) + +describe('Agent Session History scope truncation', () => { + it('normalizes scope roots and candidate cwd once per truncation pass', () => { + const loaded = result( + Array.from({ length: 1000 }, (_, i) => session(`id-${i}`, `/other/${i}`, i)) + ) + const scopes = Array.from({ length: 100 }, (_, i) => `/repo/${i}`) + const normalize = vi.spyOn(String.prototype, 'normalize') + let selected: AiVaultListResult + try { + selected = truncateAiVaultListResult(loaded, 10, scopes) + expect(normalize.mock.calls.length).toBeLessThanOrEqual(1100) + } finally { + normalize.mockRestore() + } + expect(selected.sessions).toEqual(loaded.sessions.slice(0, 10)) + }) + + it('selects scoped sessions identically to the pre-hoist path predicate', () => { + const cases = [ + ['C:\\Users\\Ada\\repo', 'c:/users/ada/repo/app'], + ['C:/Users/Ada/repo/', 'C:\\Users\\Ada\\repo'], + ['C:\\Users\\Ada\\repo', 'C:\\Users\\Ada\\repo-other'], + ['//wsl.localhost/Ubuntu/home/Ada/Repo', '//wsl$/Ubuntu/home/Ada/Repo/App'], + ['//wsl.localhost/Ubuntu/home/ada/repo', '//wsl.localhost/Debian/home/ada/repo'], + ['/Users/ada/repo', '/Users/ada/repo//app/'], + ['/Users/ada/repo', '/Users/ada/repository'], + ['/', '/anywhere'] + ] + for (const [scope, cwd] of cases) { + const loaded = result([session('scoped', cwd!, 0)]) + const selected = truncateAiVaultListResult(loaded, 0, [scope!]) + expect({ scope, cwd, kept: selected.sessions.length === 1 }).toEqual({ + scope, + cwd, + kept: isPathInsideOrEqual(scope!, cwd!) + }) + } + }) +}) diff --git a/src/shared/ai-vault-session-depth.ts b/src/shared/ai-vault-session-depth.ts index 6e12574c545..8499d4c0dc6 100644 --- a/src/shared/ai-vault-session-depth.ts +++ b/src/shared/ai-vault-session-depth.ts @@ -1,4 +1,7 @@ -import { isPathInsideOrEqual } from './cross-platform-path' +import { + createNormalizedPathInsideOrEqualMatcher, + normalizeRuntimePathForComparison +} from './cross-platform-path' import type { AiVaultListArgs, AiVaultListResult } from './ai-vault-types' export const DEFAULT_AI_VAULT_SCAN_LIMIT = 1000 @@ -40,10 +43,12 @@ export function truncateAiVaultListResult( } const selectedIds = new Set(result.sessions.slice(0, depth).map((session) => session.id)) if (scopePaths.length > 0) { + const scopeMatchers = scopePaths.map(createNormalizedPathInsideOrEqualMatcher) let scopedCount = 0 for (const session of result.sessions) { const cwd = session.cwd - if (cwd && scopePaths.some((scopePath) => isPathInsideOrEqual(scopePath, cwd))) { + const normalizedCwd = cwd ? normalizeRuntimePathForComparison(cwd) : null + if (normalizedCwd !== null && scopeMatchers.some((matches) => matches(normalizedCwd))) { selectedIds.add(session.id) if (++scopedCount >= depth) { break diff --git a/src/shared/ai-vault-session-filters.test.ts b/src/shared/ai-vault-session-filters.test.ts index 746c18010ec..4efd5b86036 100644 --- a/src/shared/ai-vault-session-filters.test.ts +++ b/src/shared/ai-vault-session-filters.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { AiVaultSession } from './ai-vault-types' import { agentLabel, @@ -9,6 +9,8 @@ import { parseVaultQuery } from './ai-vault-session-filters' import { sessionPreviewSearchText } from './ai-vault-session-display' +import { isPathInsideOrEqual } from './cross-platform-path' +import { parseWslUncPath } from './wsl-paths' const baseSession: AiVaultSession = { id: 'claude:1', @@ -198,3 +200,162 @@ describe('/shared ai-vault-session-filters (lifted core)', () => { expect(sessionPreviewSearchText(baseSession)).toContain('scope tabs') }) }) + +describe('/shared ai-vault-session-filters (hoisted matchers and sort keys)', () => { + it('prepares workspace path matching once for a large session filter pass', () => { + const sessions = Array.from({ length: 1000 }, (_, i) => ({ + ...baseSession, + id: String(i), + cwd: `/other/${i}` + })) + const activeWorktreePaths = Array.from({ length: 100 }, (_, i) => `/repo/${i}`) + const normalize = vi.spyOn(String.prototype, 'normalize') + try { + expect( + filterAiVaultSessions(sessions, { + query: '', + agents: ['claude'], + scope: 'workspace', + sort: 'updated', + activeWorktreePaths, + hideEmptySessions: false + }) + ).toEqual([]) + expect(normalize.mock.calls.length).toBeLessThanOrEqual(1100) + } finally { + normalize.mockRestore() + } + }) + + it('does not read transcript previews for empty or field-only queries', () => { + let reads = 0 + const sessions = Array.from({ length: 1000 }, (_, i) => ({ + ...baseSession, + id: String(i), + get previewMessages() { + reads++ + return baseSession.previewMessages + } + })) + for (const query of ['', 'repo:repo', 'path:app']) { + expect( + filterAiVaultSessions(sessions, { + query, + agents: ['claude'], + scope: 'all', + sort: 'updated', + activeWorktreePaths: [], + hideEmptySessions: false + }) + ).toHaveLength(1000) + } + expect(reads).toBe(0) + expect( + filterAiVaultSessions(sessions, { + query: 'scope', + agents: ['claude'], + scope: 'all', + sort: 'updated', + activeWorktreePaths: [], + hideEmptySessions: false + }) + ).toHaveLength(1000) + expect(reads).toBeGreaterThan(0) + }) + + it('parses each sort timestamp once with original ordering, including invalid dates', () => { + const sessions = Array.from({ length: 2000 }, (_, i) => ({ + ...baseSession, + id: String(i), + updatedAt: + i % 131 === 0 + ? 'invalid' + : new Date(1700000000000 + ((i * 173) % 1999) * 1000).toISOString() + })) + const parse = vi.spyOn(Date, 'parse') + let actual: AiVaultSession[] + let expected: AiVaultSession[] + try { + expected = [...sessions].sort( + (a, b) => Date.parse(b.updatedAt ?? b.modifiedAt) - Date.parse(a.updatedAt ?? a.modifiedAt) + ) + expect(parse.mock.calls.length).toBeGreaterThan(10_000) + parse.mockClear() + actual = filterAiVaultSessions(sessions, { + query: '', + agents: ['claude'], + scope: 'all', + sort: 'updated', + activeWorktreePaths: [], + hideEmptySessions: false + }) + expect(parse).toHaveBeenCalledTimes(2000) + } finally { + parse.mockRestore() + } + actual.forEach((session, i) => expect(session).toBe(expected[i])) + }) + + it('matches workspace scope identically to the pre-hoist path predicate', () => { + // The hoisted matcher must agree with isPathInsideOrEqual on every spelling the + // supported hosts produce: Windows case/separator folding, trailing separators, + // both WSL UNC aliases, /mnt drive mounts and near-miss sibling directories. + const cases: [workspace: string, cwd: string][] = [ + ['C:\\Users\\Ada\\repo', 'c:/users/ada/repo/app'], + ['C:/Users/Ada/repo/', 'C:\\Users\\Ada\\repo'], + ['C:\\Users\\Ada\\repo', 'C:\\Users\\Ada\\repo-other'], + ['C:\\', 'C:\\anything'], + ['//wsl.localhost/Ubuntu/home/ada/repo', '//wsl$/Ubuntu/home/ada/repo/app'], + ['//wsl.localhost/Ubuntu/home/Ada/Repo', '//wsl$/Ubuntu/home/Ada/Repo/App'], + ['//wsl.localhost/Ubuntu/home/Ada/Repo', '//wsl$/Ubuntu/home/ada/repo/App'], + ['//wsl$/Ubuntu/home/ada/repo', '//WSL.LOCALHOST/ubuntu/home/ada/repo'], + ['//wsl.localhost/Ubuntu/home/ada/repo', '//wsl.localhost/Debian/home/ada/repo'], + ['//wsl.localhost/Ubuntu/mnt/c/work', '/mnt/c/work/app'], + ['/Users/ada/repo', '/Users/ada/repo//app/'], + ['/Users/ada/repo', '/Users/Ada/repo/app'], + ['/Users/ada/repo', '/Users/ada/repository'], + ['/', '/anywhere'] + ] + for (const [workspace, cwd] of cases) { + const expected = + isPathInsideOrEqual(workspace, cwd) || + (parseWslUncPath(workspace) + ? isPathInsideOrEqual(parseWslUncPath(workspace)!.linuxPath, cwd) + : false) + const actual = filterAiVaultSessions([{ ...baseSession, cwd }], { + query: '', + agents: ['claude'], + scope: 'workspace', + sort: 'updated', + activeWorktreePaths: [workspace], + hideEmptySessions: false + }) + expect({ workspace, cwd, matched: actual.length === 1 }).toEqual({ + workspace, + cwd, + matched: expected + }) + } + }) + + it('orders by creation time identically to the per-comparison parse', () => { + const sessions = Array.from({ length: 500 }, (_, i) => ({ + ...baseSession, + id: String(i), + createdAt: + i % 37 === 0 ? 'invalid' : new Date(1700000000000 + ((i * 91) % 499) * 1000).toISOString() + })) + const expected = [...sessions].sort( + (a, b) => Date.parse(b.createdAt ?? b.modifiedAt) - Date.parse(a.createdAt ?? a.modifiedAt) + ) + const actual = filterAiVaultSessions(sessions, { + query: '', + agents: ['claude'], + scope: 'all', + sort: 'created', + activeWorktreePaths: [], + hideEmptySessions: false + }) + actual.forEach((session, i) => expect(session).toBe(expected[i])) + }) +}) diff --git a/src/shared/ai-vault-session-filters.ts b/src/shared/ai-vault-session-filters.ts index 46fafbc8efd..7a0708151ed 100644 --- a/src/shared/ai-vault-session-filters.ts +++ b/src/shared/ai-vault-session-filters.ts @@ -3,7 +3,7 @@ // Metro only watches mobile/ + repo-root src/shared, never src/renderer. // INVARIANT: /shared is a leaf — this module must NOT import from src/renderer. import { - isPathInsideOrEqual, + createNormalizedPathInsideOrEqualMatcher, normalizeRuntimePathForComparison, normalizeRuntimePathSeparators } from './cross-platform-path' @@ -79,45 +79,50 @@ export function filterAiVaultSessions( const agentSet = new Set(filters.agents) const parsedQuery = parseVaultQuery(filters.query) + const workspaceMatchers = + filters.scope === 'workspace' + ? filters.activeWorktreePaths.map(createAiVaultWorkspaceMatcher) + : [] - return sessions - .filter((session) => { - if (!agentSet.has(session.agent)) { + const filtered = sessions.filter((session) => { + if (!agentSet.has(session.agent)) { + return false + } + // Hide plain empty sessions, but keep sessions with resumable content + // (some parsers only learn turns from previews, e.g. Grok) and zero-turn + // sessions that still carry recoverable content (queued prompts / + // subagent transcripts) so a lost conversation is surfaced distinctly. + if ( + filters.hideEmptySessions && + !isAiVaultSessionResumableContent(session) && + !isAiVaultSessionRecoverableEmpty(session) + ) { + return false + } + if (filters.scope === 'workspace') { + const cwd = session.cwd + const normalizedCwd = cwd ? normalizeRuntimePathForComparison(cwd) : null + if (normalizedCwd === null || !workspaceMatchers.some((matches) => matches(normalizedCwd))) { return false } - // Hide plain empty sessions, but keep sessions with resumable content - // (some parsers only learn turns from previews, e.g. Grok) and zero-turn - // sessions that still carry recoverable content (queued prompts / - // subagent transcripts) so a lost conversation is surfaced distinctly. - if ( - filters.hideEmptySessions && - !isAiVaultSessionResumableContent(session) && - !isAiVaultSessionRecoverableEmpty(session) - ) { + } + if (filters.scope === 'project') { + if (!filters.activeProjectKey) { return false } - if (filters.scope === 'workspace') { - const cwd = session.cwd - if ( - !cwd || - !filters.activeWorktreePaths.some((pathValue) => - isAiVaultSessionInWorkspacePath(pathValue, cwd) - ) - ) { - return false - } + if (filters.sessionProjectById?.get(session.id)?.key !== filters.activeProjectKey) { + return false } - if (filters.scope === 'project') { - if (!filters.activeProjectKey) { - return false - } - if (filters.sessionProjectById?.get(session.id)?.key !== filters.activeProjectKey) { - return false - } - } - return matchesQuery(session, parsedQuery, filters) - }) - .sort((left, right) => compareSessions(left, right, filters.sort)) + } + return matchesQuery(session, parsedQuery, filters) + }) + if (filtered.length < 2) { + return filtered + } + return filtered + .map((session) => ({ session, time: sessionSortTime(session, filters.sort) })) + .sort((left, right) => right.time - left.time) + .map(({ session }) => session) } export function groupAiVaultSessions( @@ -206,48 +211,48 @@ function matchesQuery( parsed: ParsedQuery, filters: Pick ): boolean { - const searchable = [ - session.title, - session.sessionId, - session.agent, - session.branch, - session.model, - session.cwd, - session.filePath, - sessionPreviewSearchText(session) - ] - .filter(Boolean) - .join(' ') - .toLowerCase() - - if (parsed.terms.some((term) => !searchable.includes(term))) { - return false + if (parsed.terms.length > 0) { + const searchable = [ + session.title, + session.sessionId, + session.agent, + session.branch, + session.model, + session.cwd, + session.filePath, + sessionPreviewSearchText(session) + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + if (parsed.terms.some((term) => !searchable.includes(term))) { + return false + } } - - const sessionProject = filters.sessionProjectById?.get(session.id) - const repoLabel = ( - sessionProject?.kind === 'repo' - ? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label) - : folderLabel(session.cwd) - ).toLowerCase() - if (parsed.repoTerms.some((term) => !repoLabel.includes(term))) { - return false + if (parsed.repoTerms.length > 0) { + const sessionProject = filters.sessionProjectById?.get(session.id) + const repoLabel = ( + sessionProject?.kind === 'repo' + ? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label) + : folderLabel(session.cwd) + ).toLowerCase() + if (parsed.repoTerms.some((term) => !repoLabel.includes(term))) { + return false + } } - - const pathSearch = `${session.cwd ?? ''} ${session.filePath}`.toLowerCase() - if (parsed.pathTerms.some((term) => !pathSearch.includes(term))) { - return false + if (parsed.pathTerms.length > 0) { + const pathSearch = `${session.cwd ?? ''} ${session.filePath}`.toLowerCase() + if (parsed.pathTerms.some((term) => !pathSearch.includes(term))) { + return false + } } return true } -function compareSessions(left: AiVaultSession, right: AiVaultSession, sort: AiVaultSort): number { - const leftValue = sort === 'created' ? left.createdAt : left.updatedAt - const rightValue = sort === 'created' ? right.createdAt : right.updatedAt - const leftTime = Date.parse(leftValue ?? left.modifiedAt) - const rightTime = Date.parse(rightValue ?? right.modifiedAt) - return rightTime - leftTime +function sessionSortTime(session: AiVaultSession, sort: AiVaultSort): number { + const value = sort === 'created' ? session.createdAt : session.updatedAt + return Date.parse(value ?? session.modifiedAt) } function getGroupIdentity( @@ -276,19 +281,15 @@ function getGroupIdentity( return { key: folderGroupKey(session.cwd), label: folderLabel(session.cwd) } } -function isAiVaultSessionInWorkspacePath(workspacePath: string, sessionCwd: string): boolean { - if (isPathInsideOrEqual(workspacePath, sessionCwd)) { - return true - } - +function createAiVaultWorkspaceMatcher(workspacePath: string): (normalizedCwd: string) => boolean { + const matches = createNormalizedPathInsideOrEqualMatcher(workspacePath) const workspaceWslPath = parseWslUncPath(workspacePath) if (!workspaceWslPath) { - return false + return matches } - - // WSL agent transcripts record Linux cwd values even when Orca stores the - // active worktree as a Windows UNC path. - return isPathInsideOrEqual(workspaceWslPath.linuxPath, sessionCwd) + // WSL transcripts record Linux cwd even when the workspace uses a UNC path. + const matchesLinux = createNormalizedPathInsideOrEqualMatcher(workspaceWslPath.linuxPath) + return (cwd) => matches(cwd) || matchesLinux(cwd) } function tokenizeQuery(query: string): string[] {