diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts new file mode 100644 index 00000000000..b50497e8ca6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -0,0 +1,165 @@ +import { basename, extname } from 'path' +import { + aiVaultAgentLabel, + buildAiVaultResumeCommand, + type AiVaultAgent, + type AiVaultSession, + type AiVaultSessionPreviewMessage +} from '../../shared/ai-vault-types' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' +import { + extractPreviewContentText, + extractString, + normalizePreviewText, + timestampMs +} from './session-scanner-values' + +const SESSION_PREVIEW_MESSAGE_LIMIT = 5 + +export function createAccumulator(args: { + agent: AiVaultAgent + file: FileWithMtime + sessionId: string +}): SessionAccumulator { + return { + agent: args.agent, + sessionId: args.sessionId, + title: null, + fallbackTitle: null, + cwd: null, + branch: null, + model: null, + filePath: args.file.path, + createdAt: null, + updatedAt: null, + modifiedAt: args.file.modifiedAt, + messageCount: 0, + totalTokens: 0, + previewMessages: [], + latestTimestampMs: 0 + } +} + +export function finalizeSession( + accumulator: SessionAccumulator, + platform: NodeJS.Platform, + options: { codexHome?: string | null } = {} +): AiVaultSession | null { + const sessionId = accumulator.sessionId.trim() + if (!sessionId) { + return null + } + const title = + accumulator.title || + accumulator.fallbackTitle || + `${aiVaultAgentLabel(accumulator.agent)} ${sessionId.slice(0, 8)}` + + return { + id: `${accumulator.agent}:${sessionId}:${accumulator.filePath}`, + agent: accumulator.agent, + sessionId, + title, + cwd: accumulator.cwd, + branch: accumulator.branch, + model: accumulator.model, + filePath: accumulator.filePath, + codexHome: accumulator.agent === 'codex' ? (options.codexHome ?? null) : null, + createdAt: accumulator.createdAt, + updatedAt: accumulator.updatedAt, + modifiedAt: accumulator.modifiedAt, + messageCount: accumulator.messageCount, + totalTokens: accumulator.totalTokens, + previewMessages: accumulator.previewMessages, + resumeCommand: buildAiVaultResumeCommand({ + agent: accumulator.agent, + sessionId, + cwd: accumulator.cwd, + platform, + codexHome: options.codexHome + }) + } +} + +export function updateTimeline(accumulator: SessionAccumulator, timestamp: unknown): void { + const parsed = timestampMs(timestamp) + if (!Number.isFinite(parsed)) { + return + } + const iso = new Date(parsed).toISOString() + if (!accumulator.createdAt || parsed < Date.parse(accumulator.createdAt)) { + accumulator.createdAt = iso + } + if (!accumulator.updatedAt || parsed >= Date.parse(accumulator.updatedAt)) { + accumulator.updatedAt = iso + accumulator.latestTimestampMs = parsed + } +} + +export function addPreviewMessage( + accumulator: SessionAccumulator, + args: { + role: AiVaultSessionPreviewMessage['role'] + text: string | null + timestamp?: unknown + } +): void { + const text = normalizePreviewText(args.text ?? '') + if (!text) { + return + } + accumulator.previewMessages.push({ + role: args.role, + text, + timestamp: timestampIso(args.timestamp) + }) + if (accumulator.previewMessages.length > SESSION_PREVIEW_MESSAGE_LIMIT) { + accumulator.previewMessages.shift() + } +} + +export function addPreviewContent( + accumulator: SessionAccumulator, + role: AiVaultSessionPreviewMessage['role'], + content: unknown, + timestamp?: unknown +): void { + addPreviewMessage(accumulator, { + role, + text: extractPreviewContentText(content), + timestamp + }) +} + +export function timestampIso(value: unknown): string | null { + const parsed = timestampMs(value) + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null +} + +export function updateLatestLocation( + accumulator: SessionAccumulator, + record: Record +): void { + const timestamp = extractString(record.timestamp) + const parsed = timestamp ? Date.parse(timestamp) : accumulator.latestTimestampMs + if (!Number.isFinite(parsed) || parsed < accumulator.latestTimestampMs) { + return + } + const cwd = extractString(record.cwd) + const branch = extractString(record.gitBranch) + if (cwd) { + accumulator.cwd = cwd + } + if (branch) { + accumulator.branch = branch + } +} + +export function sessionSortTime(session: AiVaultSession): number { + return Date.parse(session.updatedAt ?? session.modifiedAt) +} + +export function sessionIdFromFileName(filePath: string): string { + const fileName = basename(filePath, extname(filePath)) + const match = fileName.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) + return match?.[0] ?? fileName +} diff --git a/src/main/ai-vault/session-scanner-codex-paths.ts b/src/main/ai-vault/session-scanner-codex-paths.ts new file mode 100644 index 00000000000..6c13dbd8247 --- /dev/null +++ b/src/main/ai-vault/session-scanner-codex-paths.ts @@ -0,0 +1,27 @@ +import { dirname, resolve } from 'path' + +export function codexHomeForSessionsDir( + sessionsDir: string, + defaultCodexHomeDir: string +): string | null { + const codexHome = dirname(sessionsDir) + return codexHome === defaultCodexHomeDir ? null : codexHome +} + +export function uniqueCodexSessionsDirs(paths: readonly string[]): string[] { + const seen = new Set() + const unique: string[] = [] + for (const path of paths) { + const trimmed = path.trim() + if (!trimmed) { + continue + } + const key = resolve(trimmed) + if (seen.has(key)) { + continue + } + seen.add(key) + unique.push(trimmed) + } + return unique +} diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts new file mode 100644 index 00000000000..5159626f2ac --- /dev/null +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -0,0 +1,95 @@ +import { readdir, stat } from 'fs/promises' +import { basename, delimiter, extname, join } from 'path' +import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types' +import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types' +import { errorMessage } from './session-scanner-values' + +export async function discoverFiles(args: { + rootDir: string + limit: number + agent: AiVaultAgent + issues: AiVaultScanIssue[] + extensions: string[] + filePredicate?: (path: string) => boolean +}): Promise { + const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, { + extensions: new Set(args.extensions), + filePredicate: args.filePredicate + }) + const files: FileWithMtime[] = [] + for (const path of paths) { + try { + const fileStat = await stat(path) + files.push({ + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString() + }) + } catch (err) { + args.issues.push({ agent: args.agent, path, message: errorMessage(err) }) + } + } + return { + agent: args.agent, + rootDir: args.rootDir, + files: files.sort((left, right) => right.mtimeMs - left.mtimeMs).slice(0, args.limit) + } +} + +export async function discoverOpenClawFiles(args: { + rootDirs: string[] + limit: number + issues: AiVaultScanIssue[] +}): Promise { + const discoveries = await Promise.all( + args.rootDirs.map((rootDir) => + discoverFiles({ + rootDir: basename(rootDir) === 'agents' ? rootDir : join(rootDir, 'agents'), + limit: args.limit, + agent: 'openclaw', + issues: args.issues, + extensions: ['.jsonl'], + filePredicate: (path) => path.split(/[\\/]/).includes('sessions') + }) + ) + ) + const files = discoveries + .flatMap((discovery) => discovery.files) + .sort((left, right) => right.mtimeMs - left.mtimeMs) + .slice(0, args.limit) + return { agent: 'openclaw', rootDir: args.rootDirs.join(delimiter), files } +} + +export async function walkSessionFiles( + dirPath: string, + agent: AiVaultAgent, + issues: AiVaultScanIssue[], + options: { + extensions: Set + filePredicate?: (path: string) => boolean + } +): Promise { + let entries + try { + entries = await readdir(dirPath, { withFileTypes: true }) + } catch { + return [] + } + + const files: string[] = [] + for (const entry of entries) { + const fullPath = join(dirPath, entry.name) + if (entry.isDirectory()) { + files.push(...(await walkSessionFiles(fullPath, agent, issues, options))) + continue + } + if ( + entry.isFile() && + options.extensions.has(extname(entry.name).toLowerCase()) && + (options.filePredicate?.(fullPath) ?? true) + ) { + files.push(fullPath) + } + } + return files +} diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts new file mode 100644 index 00000000000..a4eafced6cc --- /dev/null +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -0,0 +1,267 @@ +import { createReadStream } from 'fs' +import { readFile } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { createInterface } from 'readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' +import { + addPreviewContent, + addPreviewMessage, + createAccumulator, + finalizeSession, + sessionIdFromFileName, + updateTimeline +} from './session-scanner-accumulator' +import { + arrayValue, + asRecord, + extractContentText, + extractMessageText, + extractPreviewContentText, + extractString, + firstString, + normalizeTitleText, + parseJsonObject, + readJsonObjectIfExists, + tokenTotal +} from './session-scanner-values' + +export async function parseRovoSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const metadata = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + if (!metadata) { + return null + } + const accumulator = createAccumulator({ + agent: 'rovo', + file, + sessionId: basename(dirname(file.path)) + }) + accumulator.title = firstString(metadata, ['title', 'name', 'summary']) + accumulator.cwd = firstString(metadata, [ + 'workspace_path', + 'workspacePath', + 'workspace', + 'cwd', + 'working_directory', + 'workingDirectory', + 'project_path', + 'projectPath' + ]) + updateTimeline( + accumulator, + extractString(metadata.created_at) ?? extractString(metadata.createdAt) + ) + updateTimeline( + accumulator, + extractString(metadata.updated_at) ?? extractString(metadata.updatedAt) + ) + + const contextPath = join(dirname(file.path), 'session_context.json') + const context = await readJsonObjectIfExists(contextPath) + if (context) { + consumeRovoSessionContext(accumulator, context) + } + + return finalizeSession(accumulator, platform) +} + +export function consumeRovoSessionContext( + accumulator: SessionAccumulator, + context: Record +): void { + for (const message of arrayValue(context.messages)) { + const record = asRecord(message) + const role = extractString(record?.role) + if (role === 'user' || role === 'assistant') { + accumulator.messageCount++ + updateTimeline(accumulator, extractString(record?.timestamp)) + if (role === 'user') { + accumulator.title ??= extractContentText(record?.content) + } + addPreviewContent(accumulator, role, record?.content, record?.timestamp) + } + } + + for (const historyEntry of arrayValue(context.message_history)) { + consumeRovoHistoryEntry(accumulator, asRecord(historyEntry)) + } +} + +export function consumeRovoHistoryEntry( + accumulator: SessionAccumulator, + record: Record | null +): void { + if (!record) { + return + } + updateTimeline(accumulator, extractString(record.timestamp)) + const role = extractString(record.role) ?? rovoRoleFromKind(record.kind) + if (role !== 'user' && role !== 'assistant') { + return + } + const text = rovoPartsText(arrayValue(record.parts), role) + if (!text) { + return + } + accumulator.messageCount++ + if (role === 'user') { + accumulator.title ??= text + } + addPreviewMessage(accumulator, { + role, + text, + timestamp: record.timestamp + }) +} + +export function rovoRoleFromKind(value: unknown): 'user' | 'assistant' | null { + if (value === 'request') { + return 'user' + } + if (value === 'response') { + return 'assistant' + } + return null +} + +export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): string | null { + const texts: string[] = [] + for (const part of parts) { + const record = asRecord(part) + if (!record) { + continue + } + const kind = extractString(record.part_kind) + if (role === 'user' && kind !== 'user-prompt' && kind !== 'text') { + continue + } + if (role === 'assistant' && kind !== 'text') { + continue + } + const text = extractString(record.content) ?? extractString(record.text) + if (text) { + texts.push(text) + } + } + return normalizeTitleText(texts.join(' ')) +} + +export async function parseMessageGraphSessionFile( + agent: 'openclaw' | 'pi', + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const accumulator = createAccumulator({ + agent, + file, + sessionId: sessionIdFromFileName(file.path) + }) + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + + for await (const line of lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + updateTimeline(accumulator, extractString(record.timestamp)) + if (record.type === 'session') { + const sessionId = extractString(record.id) + if (sessionId) { + accumulator.sessionId = sessionId + } + accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd + continue + } + if (record.type === 'model_change') { + accumulator.model = extractString(record.modelId) ?? accumulator.model + continue + } + if (record.type !== 'message') { + continue + } + const message = asRecord(record.message) + const role = extractString(message?.role) + if (role === 'user' || role === 'assistant') { + accumulator.messageCount++ + if (role === 'user') { + accumulator.title ??= extractMessageText(message) + } else { + accumulator.model = extractString(message?.model) ?? accumulator.model + accumulator.totalTokens += tokenTotal(message?.usage) + } + addPreviewContent(accumulator, role, message?.content, record.timestamp) + } + } + + return finalizeSession(accumulator, platform) +} + +export async function parseDroidSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const accumulator = createAccumulator({ + agent: 'droid', + file, + sessionId: sessionIdFromFileName(file.path) + }) + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + + for await (const line of lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + updateTimeline(accumulator, record.timestamp) + if (record.type === 'session_start') { + accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId + accumulator.title = normalizeTitleText(extractString(record.title) ?? '') + accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd + continue + } + if (record.type === 'system') { + accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd + accumulator.model = extractString(record.model) ?? accumulator.model + } + const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId) + if (streamSessionId) { + accumulator.sessionId = streamSessionId + } + if (record.type === 'message') { + const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role) + if (role === 'user' || role === 'assistant') { + accumulator.messageCount++ + if (role === 'user') { + accumulator.title ??= + normalizeTitleText(extractString(record.text) ?? '') || + extractMessageText(asRecord(record.message)) + } + addPreviewMessage(accumulator, { + role, + text: + extractString(record.text) ?? + extractPreviewContentText(asRecord(record.message)?.content), + timestamp: record.timestamp + }) + } + } else if (record.type === 'completion') { + accumulator.messageCount++ + accumulator.totalTokens += tokenTotal(record.usage) + addPreviewMessage(accumulator, { + role: 'assistant', + text: extractString(record.finalText), + timestamp: record.timestamp + }) + } + } + return finalizeSession(accumulator, platform) +} diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts new file mode 100644 index 00000000000..08d229dd482 --- /dev/null +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -0,0 +1,300 @@ +import { createReadStream } from 'fs' +import { readFile } from 'fs/promises' +import { createInterface } from 'readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { CodexUsageSnapshot, FileWithMtime, SessionAccumulator } from './session-scanner-types' +import { + addPreviewContent, + createAccumulator, + finalizeSession, + sessionIdFromFileName, + updateLatestLocation, + updateTimeline +} from './session-scanner-accumulator' +import { + arrayValue, + asRecord, + claudeUsageTotal, + extractContentText, + extractGitBranch, + extractMessageText, + extractModel, + extractString, + normalizeCodexUsage, + normalizeTitleText, + parseJsonObject, + subtractCodexUsage, + tokenTotal +} from './session-scanner-values' + +export async function parseClaudeSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const accumulator = createAccumulator({ + agent: 'claude', + file, + sessionId: sessionIdFromFileName(file.path) + }) + let metaTitle: string | null = null + let generatedTitle: string | null = null + + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + + for await (const line of lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + + if (typeof record.sessionId === 'string' && record.sessionId.trim()) { + accumulator.sessionId = record.sessionId.trim() + } + updateTimeline(accumulator, extractString(record.timestamp)) + updateLatestLocation(accumulator, record) + + if (record.type === 'custom-title') { + accumulator.title = normalizeTitleText(extractString(record.customTitle) ?? '') + continue + } + + if (record.type === 'ai-title') { + generatedTitle ??= normalizeTitleText(extractString(record.aiTitle) ?? '') + continue + } + + if (record.type === 'agent-name' && !generatedTitle) { + metaTitle ??= normalizeTitleText(extractString(record.agentName) ?? '') + continue + } + + if (record.type === 'user') { + accumulator.messageCount++ + const title = extractMessageText(record.message) + addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp) + if (title && record.isMeta !== true && !accumulator.title) { + accumulator.title = title + } else if (title && !metaTitle) { + metaTitle = title + } + continue + } + + if (record.type === 'assistant') { + accumulator.messageCount++ + const message = asRecord(record.message) + addPreviewContent(accumulator, 'assistant', message?.content, record.timestamp) + const model = extractString(message?.model) + if (model) { + accumulator.model = model + } + accumulator.totalTokens += claudeUsageTotal(message?.usage) + } + } + + accumulator.fallbackTitle = generatedTitle ?? metaTitle + return finalizeSession(accumulator, platform) +} + +export async function parseCodexSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform, + codexHome: string | null = null +): Promise { + const accumulator = createAccumulator({ + agent: 'codex', + file, + sessionId: sessionIdFromFileName(file.path) + }) + let previousTotals: CodexUsageSnapshot | null = null + + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + + for await (const line of lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + + updateTimeline(accumulator, extractString(record.timestamp)) + + const payload = asRecord(record.payload) + if (record.type === 'session_meta' && payload) { + const sessionId = extractString(payload.id) + if (sessionId) { + accumulator.sessionId = sessionId + } + const cwd = extractString(payload.cwd) + if (cwd) { + accumulator.cwd = cwd + } + accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch + continue + } + + if (record.type === 'turn_context' && payload) { + const cwd = extractString(payload.cwd) + if (cwd) { + accumulator.cwd = cwd + } + const model = extractModel(payload) + if (model) { + accumulator.model = model + } + continue + } + + if (!payload) { + continue + } + + if (record.type === 'response_item' && payload.type === 'message') { + accumulator.messageCount++ + if (payload.role === 'user' && !accumulator.title) { + accumulator.title = extractContentText(payload.content) + } + addPreviewContent( + accumulator, + payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown', + payload.content, + record.timestamp + ) + continue + } + + if (record.type !== 'event_msg') { + continue + } + + if (payload.type === 'user_message') { + accumulator.messageCount++ + if (!accumulator.title) { + accumulator.title = extractContentText(payload.message) + } + addPreviewContent(accumulator, 'user', payload.message, record.timestamp) + continue + } + + if (payload.type === 'agent_message') { + accumulator.messageCount++ + addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp) + continue + } + + if (payload.type !== 'token_count') { + continue + } + + const info = asRecord(payload.info) + if (!info) { + continue + } + const totalUsage = normalizeCodexUsage(info.total_token_usage) + const lastUsage = normalizeCodexUsage(info.last_token_usage) + const delta = totalUsage ? subtractCodexUsage(totalUsage, previousTotals) : lastUsage + if (totalUsage) { + previousTotals = totalUsage + } + if (delta) { + accumulator.totalTokens += delta.totalTokens + } + const model = extractModel(payload) + if (model) { + accumulator.model = model + } + } + + return finalizeSession(accumulator, platform, { codexHome }) +} + +export async function parseGeminiSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + if (file.path.endsWith('.jsonl')) { + return parseGeminiJsonlSessionFile(file, platform) + } + + const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + if (!record) { + return null + } + const accumulator = createAccumulator({ + agent: 'gemini', + file, + sessionId: extractString(record.sessionId) ?? sessionIdFromFileName(file.path) + }) + updateTimeline(accumulator, extractString(record.startTime)) + updateTimeline(accumulator, extractString(record.lastUpdated)) + for (const message of arrayValue(record.messages)) { + consumeGeminiMessage(accumulator, asRecord(message)) + } + return finalizeSession(accumulator, platform) +} + +export async function parseGeminiJsonlSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform +): Promise { + const accumulator = createAccumulator({ + agent: 'gemini', + file, + sessionId: sessionIdFromFileName(file.path) + }) + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + + for await (const line of lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + const setRecord = asRecord(record.$set) + if (setRecord) { + updateTimeline(accumulator, extractString(setRecord.lastUpdated)) + continue + } + const sessionId = extractString(record.sessionId) + if (sessionId) { + accumulator.sessionId = sessionId + } + updateTimeline(accumulator, extractString(record.startTime)) + updateTimeline(accumulator, extractString(record.lastUpdated)) + consumeGeminiMessage(accumulator, record) + } + + return finalizeSession(accumulator, platform) +} + +export function consumeGeminiMessage( + accumulator: SessionAccumulator, + record: Record | null +): void { + if (!record) { + return + } + updateTimeline(accumulator, extractString(record.timestamp)) + if (record.type === 'user') { + accumulator.messageCount++ + accumulator.title ??= extractContentText(record.content) + addPreviewContent(accumulator, 'user', record.content, record.timestamp) + return + } + if (record.type === 'gemini') { + accumulator.messageCount++ + addPreviewContent(accumulator, 'assistant', record.content, record.timestamp) + const model = extractString(record.model) + if (model) { + accumulator.model = model + } + accumulator.totalTokens += tokenTotal(record.tokens) + } +} diff --git a/src/main/ai-vault/session-scanner-secondary-parsers.ts b/src/main/ai-vault/session-scanner-secondary-parsers.ts new file mode 100644 index 00000000000..e2632490e1f --- /dev/null +++ b/src/main/ai-vault/session-scanner-secondary-parsers.ts @@ -0,0 +1,238 @@ +import { createReadStream } from 'fs' +import { readFile, readdir } from 'fs/promises' +import { join } from 'path' +import { createInterface } from 'readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' +import { + addPreviewContent, + addPreviewMessage, + createAccumulator, + finalizeSession, + sessionIdFromFileName, + updateTimeline +} from './session-scanner-accumulator' +import { + arrayValue, + asRecord, + copilotModelMetricsTotal, + extractContentText, + extractMessageText, + extractPreviewContentText, + extractString, + extractTrustedFolder, + findOpenCodeStorageRoot, + normalizeTitleText, + numberValue, + parseJsonObject, + timeObjectValue, + tokenTotal +} from './session-scanner-values' + +export async function parseCopilotSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const accumulator = createAccumulator({ + agent: 'copilot', + file, + sessionId: sessionIdFromFileName(file.path) + }) + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + + for await (const line of lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + updateTimeline(accumulator, extractString(record.timestamp)) + const data = asRecord(record.data) + if (record.type === 'session.start' && data) { + const sessionId = extractString(data.sessionId) + if (sessionId) { + accumulator.sessionId = sessionId + } + updateTimeline(accumulator, extractString(data.startTime)) + continue + } + if (record.type === 'session.model_change' && data) { + accumulator.model = extractString(data.newModel) ?? accumulator.model + continue + } + if (record.type === 'session.info' && data) { + accumulator.cwd = extractTrustedFolder(data.message) ?? accumulator.cwd + continue + } + if (record.type === 'user.message' && data) { + accumulator.messageCount++ + accumulator.title ??= normalizeTitleText( + extractString(data.transformedContent) ?? extractString(data.content) ?? '' + ) + addPreviewMessage(accumulator, { + role: 'user', + text: extractString(data.transformedContent) ?? extractString(data.content), + timestamp: record.timestamp + }) + continue + } + if (record.type === 'assistant.message' && data) { + accumulator.messageCount++ + addPreviewMessage(accumulator, { + role: 'assistant', + text: extractString(data.content), + timestamp: record.timestamp + }) + continue + } + if (record.type === 'session.shutdown' && data) { + accumulator.model = extractString(data.currentModel) ?? accumulator.model + accumulator.totalTokens += numberValue(data.currentTokens) + accumulator.totalTokens += copilotModelMetricsTotal(data.modelMetrics) + } + } + + return finalizeSession(accumulator, platform) +} + +export async function parseCursorSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const accumulator = createAccumulator({ + agent: 'cursor', + file, + sessionId: sessionIdFromFileName(file.path) + }) + const lines = createInterface({ + input: createReadStream(file.path, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + + for await (const line of lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + updateTimeline(accumulator, extractString(record.timestamp)) + const role = extractString(record.role) + if (role === 'user' || role === 'assistant') { + accumulator.messageCount++ + if (role === 'user') { + accumulator.title ??= + extractMessageText(record.message) ?? extractContentText(record.content) + } + addPreviewContent( + accumulator, + role, + asRecord(record.message)?.content ?? record.content, + record.timestamp + ) + } + } + return finalizeSession(accumulator, platform) +} + +export async function parseOpenCodeSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + if (!record) { + return null + } + const sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path) + const accumulator = createAccumulator({ agent: 'opencode', file, sessionId }) + accumulator.title = normalizeTitleText(extractString(record.title) ?? '') + accumulator.cwd = extractString(record.directory) + updateTimeline(accumulator, timeObjectValue(record.time, 'created')) + updateTimeline(accumulator, timeObjectValue(record.time, 'updated')) + await consumeOpenCodeMessages(accumulator, findOpenCodeStorageRoot(file.path), sessionId) + return finalizeSession(accumulator, platform) +} + +export async function consumeOpenCodeMessages( + accumulator: SessionAccumulator, + storageRoot: string | null, + sessionId: string +): Promise { + if (!storageRoot) { + return + } + const messageDir = join(storageRoot, 'message', sessionId) + let entries + try { + entries = await readdir(messageDir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) { + continue + } + const message = asRecord( + JSON.parse(await readFile(join(messageDir, entry.name), 'utf-8')) as unknown + ) + if (!message) { + continue + } + const role = extractString(message.role) + if (role === 'user' || role === 'assistant') { + accumulator.messageCount++ + updateTimeline(accumulator, timeObjectValue(message.time, 'created')) + if (role === 'user') { + accumulator.title ??= extractString(asRecord(message.summary)?.title) + accumulator.title ??= extractString(asRecord(message.summary)?.body) + } + addPreviewMessage(accumulator, { + role, + text: + extractPreviewContentText(message.content) ?? + extractString(asRecord(message.summary)?.body) ?? + extractString(asRecord(message.summary)?.title), + timestamp: timeObjectValue(message.time, 'created') + }) + accumulator.model = + extractString(asRecord(message.model)?.modelID) || + extractString(message.modelID) || + accumulator.model + accumulator.totalTokens += tokenTotal(message.tokens) + } + } +} + +export async function parseHermesSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + if (!record) { + return null + } + const accumulator = createAccumulator({ + agent: 'hermes', + file, + sessionId: extractString(record.session_id) ?? sessionIdFromFileName(file.path) + }) + accumulator.model = extractString(record.model) + accumulator.cwd = extractString(record.cwd) + updateTimeline(accumulator, extractString(record.session_start)) + updateTimeline(accumulator, extractString(record.last_updated)) + for (const message of arrayValue(record.messages)) { + const messageRecord = asRecord(message) + const role = extractString(messageRecord?.role) + if (role === 'user' || role === 'assistant') { + accumulator.messageCount++ + if (role === 'user') { + accumulator.title ??= extractContentText(messageRecord?.content) + } + addPreviewContent(accumulator, role, messageRecord?.content) + } + } + if (accumulator.messageCount === 0) { + accumulator.messageCount = numberValue(record.message_count) + } + return finalizeSession(accumulator, platform) +} diff --git a/src/main/ai-vault/session-scanner-token-values.ts b/src/main/ai-vault/session-scanner-token-values.ts new file mode 100644 index 00000000000..83b288140ec --- /dev/null +++ b/src/main/ai-vault/session-scanner-token-values.ts @@ -0,0 +1,106 @@ +import type { CodexUsageSnapshot } from './session-scanner-types' +import { asRecord } from './session-scanner-values' + +export function tokenTotal(value: unknown): number { + const usage = asRecord(value) + if (!usage) { + return 0 + } + const explicitTotal = + numberValue(usage.total) || numberValue(usage.totalTokens) || numberValue(usage.total_tokens) + if (explicitTotal > 0) { + return explicitTotal + } + + const fields: unknown[] = [ + usage.input, + usage.inputTokens, + usage.input_tokens, + usage.output, + usage.outputTokens, + usage.output_tokens, + usage.cacheRead, + usage.cacheReadTokens, + usage.cache_read_input_tokens, + usage.cacheWrite, + usage.cacheWriteTokens, + usage.cache_creation_input_tokens, + usage.cached, + usage.cachedInputTokens, + usage.cached_input_tokens, + usage.reasoning, + usage.reasoningOutputTokens, + usage.reasoning_output_tokens + ] + return fields.reduce((total, current) => total + numberValue(current), 0) +} + +export function copilotModelMetricsTotal(value: unknown): number { + const metrics = asRecord(value) + if (!metrics) { + return 0 + } + let total = 0 + for (const metric of Object.values(metrics)) { + const record = asRecord(metric) + const usage = asRecord(record?.usage) + if (!usage) { + continue + } + total += tokenTotal(usage) + } + return total +} + +export function claudeUsageTotal(value: unknown): number { + const usage = asRecord(value) + if (!usage) { + return 0 + } + return ( + numberValue(usage.input_tokens) + + numberValue(usage.output_tokens) + + numberValue(usage.cache_read_input_tokens) + + numberValue(usage.cache_creation_input_tokens) + ) +} + +export function normalizeCodexUsage(value: unknown): CodexUsageSnapshot | null { + const usage = asRecord(value) + if (!usage) { + return null + } + const inputTokens = numberValue(usage.input_tokens) + const cachedInputTokens = numberValue(usage.cached_input_tokens ?? usage.cache_read_input_tokens) + const outputTokens = numberValue(usage.output_tokens) + const reasoningOutputTokens = numberValue(usage.reasoning_output_tokens) + const totalTokens = numberValue(usage.total_tokens) + + return { + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + totalTokens: totalTokens > 0 ? totalTokens : inputTokens + outputTokens + } +} + +export function subtractCodexUsage( + current: CodexUsageSnapshot, + previous: CodexUsageSnapshot | null +): CodexUsageSnapshot { + return { + inputTokens: Math.max(current.inputTokens - (previous?.inputTokens ?? 0), 0), + cachedInputTokens: Math.max(current.cachedInputTokens - (previous?.cachedInputTokens ?? 0), 0), + outputTokens: Math.max(current.outputTokens - (previous?.outputTokens ?? 0), 0), + reasoningOutputTokens: Math.max( + current.reasoningOutputTokens - (previous?.reasoningOutputTokens ?? 0), + 0 + ), + totalTokens: Math.max(current.totalTokens - (previous?.totalTokens ?? 0), 0) + } +} + +export function numberValue(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts new file mode 100644 index 00000000000..47beebcfbc4 --- /dev/null +++ b/src/main/ai-vault/session-scanner-types.ts @@ -0,0 +1,75 @@ +import type { AiVaultAgent } from '../../shared/ai-vault-types' +import type { + AiVaultScanIssue, + AiVaultSession, + AiVaultSessionPreviewMessage +} from '../../shared/ai-vault-types' + +export type AiVaultScanOptions = { + claudeProjectsDir?: string + codexSessionsDir?: string + additionalCodexSessionsDirs?: readonly string[] + geminiSessionsDir?: string + copilotSessionsDir?: string + cursorProjectsDir?: string + opencodeStorageDir?: string + hermesSessionsDir?: string + rovoSessionsDir?: string + openclawStateDir?: string + openclawLegacyStateDir?: string + piSessionsDir?: string + droidSessionsDir?: string + droidProjectsDir?: string + limit?: number + limitPerAgent?: number + platform?: NodeJS.Platform +} + +export type FileWithMtime = { + path: string + mtimeMs: number + modifiedAt: string +} + +export type SessionFileCandidate = { + agent: AiVaultAgent + file: FileWithMtime + codexHome: string | null +} + +export type SessionFileDiscovery = { + agent: AiVaultAgent + rootDir: string + files: FileWithMtime[] +} + +export type SessionParseResult = { + session: AiVaultSession | null + issue: AiVaultScanIssue | null +} + +export type SessionAccumulator = { + agent: AiVaultAgent + sessionId: string + title: string | null + fallbackTitle: string | null + cwd: string | null + branch: string | null + model: string | null + filePath: string + createdAt: string | null + updatedAt: string | null + modifiedAt: string + messageCount: number + totalTokens: number + previewMessages: AiVaultSessionPreviewMessage[] + latestTimestampMs: number +} + +export type CodexUsageSnapshot = { + inputTokens: number + cachedInputTokens: number + outputTokens: number + reasoningOutputTokens: number + totalTokens: number +} diff --git a/src/main/ai-vault/session-scanner-values.ts b/src/main/ai-vault/session-scanner-values.ts new file mode 100644 index 00000000000..bafd02b4844 --- /dev/null +++ b/src/main/ai-vault/session-scanner-values.ts @@ -0,0 +1,242 @@ +import { homedir } from 'os' +import { basename, dirname, join } from 'path' +import { readFile } from 'fs/promises' + +const SESSION_PREVIEW_TEXT_LIMIT = 220 + +export function timestampMs(value: unknown): number { + if (typeof value === 'string') { + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : Number.NaN + } + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return Number.NaN + } + return value > 1_000_000_000_000 ? value : value * 1000 +} + +export function parseJsonObject(line: string): Record | null { + if (!line.trim()) { + return null + } + try { + const parsed = JSON.parse(line) as unknown + return asRecord(parsed) + } catch { + return null + } +} + +export function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +export function extractString(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +export function extractModel(value: unknown): string | null { + const record = asRecord(value) + if (!record) { + return null + } + return ( + extractString(record.model) || + extractString(record.model_name) || + extractString(asRecord(record.metadata)?.model) || + extractString(asRecord(record.info)?.model) || + null + ) +} + +export function extractGitBranch(value: unknown): string | null { + const git = asRecord(value) + if (!git) { + return null + } + return extractString(git.branch) || extractString(git.current_branch) +} + +export function extractMessageText(value: unknown): string | null { + const message = asRecord(value) + if (!message) { + return null + } + return extractContentText(message.content) +} + +export function extractContentText(value: unknown): string | null { + if (typeof value === 'string') { + return normalizeTitleText(value) + } + if (!Array.isArray(value)) { + return null + } + const parts: string[] = [] + for (const item of value) { + if (typeof item === 'string') { + parts.push(item) + continue + } + const record = asRecord(item) + const text = extractString(record?.text) || extractString(record?.content) + if (text) { + parts.push(text) + } + } + return normalizeTitleText(parts.join(' ')) +} + +export function normalizeTitleText(value: string): string | null { + const withoutReminders = value + .replace(/[\s\S]*?<\/system-reminder>/gi, ' ') + .replace(/\s+/g, ' ') + .trim() + if (!withoutReminders) { + return null + } + if (/^# AGENTS\.md instructions for\b/i.test(withoutReminders)) { + return null + } + if (/^/i.test(withoutReminders)) { + return null + } + return withoutReminders.length > 96 ? `${withoutReminders.slice(0, 93)}...` : withoutReminders +} + +export function extractPreviewContentText(value: unknown): string | null { + if (typeof value === 'string') { + return normalizePreviewText(value) + } + if (!Array.isArray(value)) { + return null + } + const parts: string[] = [] + for (const item of value) { + if (typeof item === 'string') { + parts.push(item) + continue + } + const record = asRecord(item) + const text = extractString(record?.text) || extractString(record?.content) + if (text) { + parts.push(text) + } + } + return normalizePreviewText(parts.join(' ')) +} + +export function normalizePreviewText(value: string): string | null { + const normalized = value + .replace(/[\s\S]*?<\/system-reminder>/gi, ' ') + .replace(/\s+/g, ' ') + .trim() + if (!normalized) { + return null + } + if (/^# AGENTS\.md instructions for\b/i.test(normalized) || /^/i.test(normalized)) { + return null + } + return normalized.length > SESSION_PREVIEW_TEXT_LIMIT + ? `${normalized.slice(0, SESSION_PREVIEW_TEXT_LIMIT - 3)}...` + : normalized +} + +export async function readJsonObjectIfExists( + filePath: string +): Promise | null> { + try { + return asRecord(JSON.parse(await readFile(filePath, 'utf-8')) as unknown) + } catch { + return null + } +} + +export function arrayValue(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +export function firstString(record: Record, keys: string[]): string | null { + for (const key of keys) { + const value = extractString(record[key]) + if (value) { + return value + } + } + return null +} + +export function extractTrustedFolder(value: unknown): string | null { + const message = extractString(value) + if (!message) { + return null + } + return message.match(/^Folder (.+) has been added to trusted folders\.$/)?.[1] ?? null +} + +export function timeObjectValue(value: unknown, key: string): string | null { + const record = asRecord(value) + if (!record) { + return null + } + const rawValue = record[key] + if (typeof rawValue === 'string') { + return rawValue + } + const parsed = timestampMs(rawValue) + if (!Number.isFinite(parsed)) { + return null + } + return new Date(parsed).toISOString() +} + +export function findOpenCodeStorageRoot(filePath: string): string | null { + const sessionDir = dirname(filePath) + const sessionRoot = dirname(sessionDir) + if (basename(sessionRoot) !== 'session') { + return null + } + return dirname(sessionRoot) +} + +export function normalizePiSessionsDir(rawValue: string): string { + const trimmed = rawValue.trim() + if (!trimmed) { + return join(homedir(), '.pi', 'agent', 'sessions') + } + const normalized = trimmed.replace(/[\\/]+$/, '') + const leaf = basename(normalized) + if (leaf === 'sessions') { + return normalized + } + if (leaf === 'agent') { + return join(normalized, 'sessions') + } + if (leaf === '.pi') { + return join(normalized, 'agent', 'sessions') + } + return normalized +} + +export function clampPositiveInteger(value: number | undefined, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback +} + +export function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +export { + claudeUsageTotal, + copilotModelMetricsTotal, + normalizeCodexUsage, + numberValue, + subtractCodexUsage, + tokenTotal +} from './session-scanner-token-values' diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts new file mode 100644 index 00000000000..de39a421c10 --- /dev/null +++ b/src/main/ai-vault/session-scanner.test.ts @@ -0,0 +1,545 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { AI_VAULT_AGENTS, buildAiVaultResumeCommand } from '../../shared/ai-vault-types' +import { scanAiVaultSessions } from './session-scanner' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +function isolatedScanRoots(root: string) { + return { + claudeProjectsDir: join(root, 'claude-projects'), + codexSessionsDir: join(root, 'codex-sessions'), + geminiSessionsDir: join(root, 'gemini-sessions'), + copilotSessionsDir: join(root, 'copilot-sessions'), + cursorProjectsDir: join(root, 'cursor-projects'), + opencodeStorageDir: join(root, 'opencode-storage'), + hermesSessionsDir: join(root, 'hermes-sessions'), + rovoSessionsDir: join(root, 'rovo-sessions'), + openclawStateDir: join(root, 'openclaw-state'), + openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), + piSessionsDir: join(root, 'pi-sessions'), + droidSessionsDir: join(root, 'droid-sessions'), + droidProjectsDir: join(root, 'droid-projects') + } +} + +function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +describe('scanAiVaultSessions', () => { + it('indexes Claude and Codex transcripts with resume commands', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const claudeRoot = roots.claudeProjectsDir + const codexRoot = roots.codexSessionsDir + await mkdir(join(claudeRoot, 'project'), { recursive: true }) + await mkdir(join(codexRoot, '2026', '05', '01'), { recursive: true }) + + await writeFile( + join(claudeRoot, 'project', 'claude-session.jsonl'), + [ + JSON.stringify({ + type: 'user', + sessionId: 'claude-session', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: '/repo/app', + gitBranch: 'feature/vault', + isMeta: false, + message: { role: 'user', content: 'Implement the vault panel' } + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'claude-session', + timestamp: '2026-05-01T10:02:00.000Z', + cwd: '/repo/app', + gitBranch: 'feature/vault', + message: { + model: 'claude-sonnet-4-5', + usage: { + input_tokens: 100, + output_tokens: 40, + cache_read_input_tokens: 10, + cache_creation_input_tokens: 5 + } + } + }), + JSON.stringify({ + type: 'custom-title', + sessionId: 'claude-session', + timestamp: '2026-05-01T10:03:00.000Z', + customTitle: 'Vault polish pass' + }) + ].join('\n') + ) + + await writeFile( + join( + codexRoot, + '2026', + '05', + '01', + 'rollout-2026-05-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl' + ), + [ + JSON.stringify({ + timestamp: '2026-05-01T11:00:00.000Z', + type: 'session_meta', + payload: { + id: '019f0000-1111-7222-8333-444444444444', + cwd: '/repo/app/packages/web', + git: { branch: 'feature/codex-vault' } + } + }), + JSON.stringify({ + timestamp: '2026-05-01T11:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [ + { type: 'text', text: '# AGENTS.md instructions for /repo/app ' } + ] + } + }), + JSON.stringify({ + timestamp: '2026-05-01T11:00:02.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Fix the resume picker filters' }] + } + }), + JSON.stringify({ + timestamp: '2026-05-01T11:00:03.000Z', + type: 'turn_context', + payload: { cwd: '/repo/app/packages/web', model: 'gpt-5.3-codex' } + }), + JSON.stringify({ + timestamp: '2026-05-01T11:00:04.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 500, + cached_input_tokens: 100, + output_tokens: 125, + reasoning_output_tokens: 25, + total_tokens: 625 + } + } + } + }), + JSON.stringify({ + timestamp: '2026-05-01T11:00:05.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { + input_tokens: 500, + cached_input_tokens: 100, + output_tokens: 125, + reasoning_output_tokens: 25, + total_tokens: 625 + } + } + } + }) + ].join('\n') + ) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'darwin' + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(2) + expect(result.sessions.map((session) => session.title).sort()).toEqual([ + 'Fix the resume picker filters', + 'Vault polish pass' + ]) + + const claude = result.sessions.find((session) => session.agent === 'claude') + expect(claude).toMatchObject({ + sessionId: 'claude-session', + cwd: '/repo/app', + branch: 'feature/vault', + model: 'claude-sonnet-4-5', + messageCount: 2, + totalTokens: 155, + resumeCommand: "cd '/repo/app' && claude --resume 'claude-session'" + }) + + const codex = result.sessions.find((session) => session.agent === 'codex') + expect(codex).toMatchObject({ + sessionId: '019f0000-1111-7222-8333-444444444444', + cwd: '/repo/app/packages/web', + branch: 'feature/codex-vault', + model: 'gpt-5.3-codex', + messageCount: 2, + totalTokens: 625, + resumeCommand: `cd '/repo/app/packages/web' && CODEX_HOME='${root}' codex resume '019f0000-1111-7222-8333-444444444444'` + }) + }) + + it('indexes Codex sessions from Orca runtime homes with resumable commands', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-runtime-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const runtimeHome = join(root, 'codex-runtime-home', 'home') + const runtimeSessionsDir = join(runtimeHome, 'sessions') + await mkdir(join(runtimeSessionsDir, '2026', '06', '04'), { recursive: true }) + + await writeFile( + join( + runtimeSessionsDir, + '2026', + '06', + '04', + 'rollout-2026-06-04T23-58-22-019e9693-64fc-7370-9c18-7e625c595d0f.jsonl' + ), + jsonLines([ + { + timestamp: '2026-06-04T23:58:22.000Z', + type: 'session_meta', + payload: { + id: '019e9693-64fc-7370-9c18-7e625c595d0f', + cwd: '/Users/nwparker/orca/workspaces/orca/mem4' + } + }, + { + timestamp: '2026-06-04T23:58:23.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Resume this managed Codex session' }] + } + } + ]) + ) + + const result = await scanAiVaultSessions({ + ...roots, + additionalCodexSessionsDirs: [runtimeSessionsDir], + platform: 'darwin' + }) + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'codex', + sessionId: '019e9693-64fc-7370-9c18-7e625c595d0f', + cwd: '/Users/nwparker/orca/workspaces/orca/mem4', + codexHome: runtimeHome, + resumeCommand: `cd '/Users/nwparker/orca/workspaces/orca/mem4' && CODEX_HOME='${runtimeHome}' codex resume '019e9693-64fc-7370-9c18-7e625c595d0f'` + }) + }) + + it('indexes every supported agent transcript format with native resume commands', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-all-agents-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + + await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true }) + await writeFile( + join(roots.claudeProjectsDir, 'project', 'claude-session.jsonl'), + jsonLines([ + { + type: 'user', + sessionId: 'claude-session', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: '/tmp/claude', + message: { role: 'user', content: 'Claude title' } + } + ]) + ) + + await mkdir(join(roots.codexSessionsDir, '2026', '05', '01'), { recursive: true }) + await writeFile( + join(roots.codexSessionsDir, '2026', '05', '01', 'rollout-2026-codex-session.jsonl'), + jsonLines([ + { + timestamp: '2026-05-01T10:01:00.000Z', + type: 'session_meta', + payload: { id: 'codex-session', cwd: '/tmp/codex' } + }, + { + timestamp: '2026-05-01T10:01:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Codex title' }] + } + } + ]) + ) + + await mkdir(roots.geminiSessionsDir, { recursive: true }) + await writeFile( + join(roots.geminiSessionsDir, 'gemini-session.json'), + JSON.stringify({ + sessionId: 'gemini-session', + startTime: '2026-05-01T10:02:00.000Z', + lastUpdated: '2026-05-01T10:02:01.000Z', + messages: [ + { + type: 'user', + timestamp: '2026-05-01T10:02:00.000Z', + content: [{ text: 'Gemini title' }] + }, + { + type: 'gemini', + timestamp: '2026-05-01T10:02:01.000Z', + model: 'gemini-2.5-pro', + tokens: { input: 10, output: 5 } + } + ] + }) + ) + + await mkdir(roots.copilotSessionsDir, { recursive: true }) + await writeFile( + join(roots.copilotSessionsDir, 'copilot-session.jsonl'), + jsonLines([ + { + type: 'session.start', + data: { sessionId: 'copilot-session', startTime: '2026-05-01T10:03:00.000Z' }, + timestamp: '2026-05-01T10:03:00.000Z' + }, + { + type: 'session.info', + data: { + infoType: 'folder_trust', + message: 'Folder /tmp/copilot has been added to trusted folders.' + }, + timestamp: '2026-05-01T10:03:01.000Z' + }, + { + type: 'user.message', + data: { transformedContent: 'Copilot title' }, + timestamp: '2026-05-01T10:03:02.000Z' + } + ]) + ) + + await mkdir(join(roots.cursorProjectsDir, 'project', 'agent-transcripts'), { recursive: true }) + await writeFile( + join(roots.cursorProjectsDir, 'project', 'agent-transcripts', 'cursor-session.jsonl'), + jsonLines([ + { + role: 'user', + message: { content: [{ type: 'text', text: 'Cursor title' }] } + }, + { role: 'assistant', message: { content: [{ type: 'text', text: 'Done' }] } } + ]) + ) + + await mkdir(join(roots.opencodeStorageDir, 'session', 'project'), { recursive: true }) + await mkdir(join(roots.opencodeStorageDir, 'message', 'opencode-session'), { recursive: true }) + await writeFile( + join(roots.opencodeStorageDir, 'session', 'project', 'ses_opencode.json'), + JSON.stringify({ + id: 'opencode-session', + directory: '/tmp/opencode', + title: 'OpenCode title', + time: { created: 1_777_634_000_000, updated: 1_777_634_001_000 } + }) + ) + await writeFile( + join(roots.opencodeStorageDir, 'message', 'opencode-session', 'msg_1.json'), + JSON.stringify({ + role: 'user', + summary: { title: 'OpenCode title' }, + time: { created: 1_777_634_000_000 }, + tokens: { input: 7, output: 3 } + }) + ) + + await mkdir(roots.hermesSessionsDir, { recursive: true }) + await writeFile( + join(roots.hermesSessionsDir, 'session_hermes-session.json'), + JSON.stringify({ + session_id: 'hermes-session', + model: 'hermes-1', + cwd: '/tmp/hermes', + session_start: '2026-05-01T10:05:00.000Z', + last_updated: '2026-05-01T10:05:01.000Z', + messages: [{ role: 'user', content: 'Hermes title' }] + }) + ) + + await mkdir(join(roots.rovoSessionsDir, 'rovo-session'), { recursive: true }) + await writeFile( + join(roots.rovoSessionsDir, 'rovo-session', 'metadata.json'), + JSON.stringify({ title: 'Rovo title', workspace_path: '/tmp/rovo' }) + ) + await writeFile( + join(roots.rovoSessionsDir, 'rovo-session', 'session_context.json'), + JSON.stringify({ + message_history: [ + { + kind: 'request', + timestamp: '2026-05-01T10:06:00.000Z', + parts: [{ part_kind: 'user-prompt', content: 'Rovo title' }] + } + ] + }) + ) + + await mkdir(join(roots.openclawStateDir, 'agents', 'default', 'sessions'), { recursive: true }) + await writeFile( + join(roots.openclawStateDir, 'agents', 'default', 'sessions', 'openclaw-session.jsonl'), + jsonLines([ + { + type: 'session', + id: 'openclaw-session', + timestamp: '2026-05-01T10:07:00.000Z', + cwd: '/tmp/openclaw' + }, + { + type: 'message', + timestamp: '2026-05-01T10:07:01.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'OpenClaw title' }] } + } + ]) + ) + + await mkdir(roots.piSessionsDir, { recursive: true }) + await writeFile( + join(roots.piSessionsDir, 'pi-session.jsonl'), + jsonLines([ + { + type: 'session', + id: 'pi-session', + timestamp: '2026-05-01T10:08:00.000Z', + cwd: '/tmp/pi' + }, + { + type: 'message', + timestamp: '2026-05-01T10:08:01.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'Pi title' }] } + } + ]) + ) + + await mkdir(roots.droidSessionsDir, { recursive: true }) + await writeFile( + join(roots.droidSessionsDir, 'droid-session.jsonl'), + jsonLines([ + { + type: 'system', + session_id: 'droid-session', + timestamp: '2026-05-01T10:09:00.000Z', + model: 'droid-model', + cwd: '/tmp/droid' + }, + { + type: 'message', + session_id: 'droid-session', + timestamp: '2026-05-01T10:09:01.000Z', + role: 'user', + text: 'Droid title' + }, + { + type: 'completion', + session_id: 'droid-session', + timestamp: '2026-05-01T10:09:02.000Z', + usage: { input_tokens: 2, output_tokens: 3 } + } + ]) + ) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'darwin', + limit: 20 + }) + + expect(result.issues).toEqual([]) + expect(new Set(result.sessions.map((session) => session.agent))).toEqual( + new Set(AI_VAULT_AGENTS) + ) + + const commandByAgent = new Map( + result.sessions.map((session) => [session.agent, session.resumeCommand]) + ) + expect(commandByAgent.get('claude')).toBe( + "cd '/tmp/claude' && claude --resume 'claude-session'" + ) + expect(commandByAgent.get('codex')).toBe( + `cd '/tmp/codex' && CODEX_HOME='${root}' codex resume 'codex-session'` + ) + expect(commandByAgent.get('gemini')).toBe("gemini --resume 'gemini-session'") + expect(commandByAgent.get('copilot')).toBe( + "cd '/tmp/copilot' && copilot --resume='copilot-session'" + ) + expect(commandByAgent.get('cursor')).toBe("cursor-agent --resume 'cursor-session'") + expect(commandByAgent.get('opencode')).toBe( + "cd '/tmp/opencode' && opencode --session 'opencode-session'" + ) + expect(commandByAgent.get('hermes')).toBe( + "cd '/tmp/hermes' && hermes --resume 'hermes-session'" + ) + expect(commandByAgent.get('rovo')).toBe( + "cd '/tmp/rovo' && acli rovodev run --restore 'rovo-session'" + ) + expect(commandByAgent.get('openclaw')).toBe( + "cd '/tmp/openclaw' && openclaw --resume 'openclaw-session'" + ) + expect(commandByAgent.get('pi')).toBe("cd '/tmp/pi' && pi --session 'pi-session'") + expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'") + }) +}) + +describe('buildAiVaultResumeCommand', () => { + it('wraps Windows cwd changes in cmd so PowerShell and cmd launch the same resume command', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: 'C:\\Users\\Ada Lovelace\\repo', + platform: 'win32' + }) + ).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""') + }) + + it('carries non-default Codex homes in copied resume commands', () => { + expect( + buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: '/repo/app', + platform: 'darwin', + codexHome: '/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' + }) + ).toBe( + "cd '/repo/app' && CODEX_HOME='/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' codex resume 'session-1'" + ) + + expect( + buildAiVaultResumeCommand({ + agent: 'codex', + sessionId: 'session-1', + cwd: 'C:\\Users\\Ada Lovelace\\repo', + platform: 'win32', + codexHome: 'C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home' + }) + ).toBe( + 'cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && set ""CODEX_HOME=C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home"" && codex resume ""session-1"""' + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts new file mode 100644 index 00000000000..5e31ff966c2 --- /dev/null +++ b/src/main/ai-vault/session-scanner.ts @@ -0,0 +1,306 @@ +import { homedir } from 'os' +import { basename, join } from 'path' +import type { + AiVaultListResult, + AiVaultScanIssue, + AiVaultSession +} from '../../shared/ai-vault-types' +import { sessionSortTime } from './session-scanner-accumulator' +import { codexHomeForSessionsDir, uniqueCodexSessionsDirs } from './session-scanner-codex-paths' +import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery' +import { + parseDroidSessionFile, + parseMessageGraphSessionFile, + parseRovoSessionFile +} from './session-scanner-graph-parsers' +import { + parseClaudeSessionFile, + parseCodexSessionFile, + parseGeminiSessionFile +} from './session-scanner-primary-parsers' +import { + parseCopilotSessionFile, + parseCursorSessionFile, + parseHermesSessionFile, + parseOpenCodeSessionFile +} from './session-scanner-secondary-parsers' +import type { + AiVaultScanOptions, + SessionFileCandidate, + SessionFileDiscovery, + SessionParseResult +} from './session-scanner-types' +import { + clampPositiveInteger, + errorMessage, + normalizePiSessionsDir +} from './session-scanner-values' + +const DEFAULT_LIMIT = 1000 +const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000 +const SESSION_PARSE_CONCURRENCY = 8 +const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects') +const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex') +const CODEX_HOME_DIR = process.env.CODEX_HOME?.trim() || DEFAULT_CODEX_HOME_DIR +const CODEX_SESSIONS_DIR = join(CODEX_HOME_DIR, 'sessions') +const GEMINI_SESSIONS_DIR = join(homedir(), '.gemini', 'tmp') +const COPILOT_SESSIONS_DIR = join( + process.env.COPILOT_HOME?.trim() || join(homedir(), '.copilot'), + 'session-state' +) +const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') +const OPENCODE_STORAGE_DIR = join( + process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'), + 'storage' +) +const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') +const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') +const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw') +const OPENCLAW_LEGACY_STATE_DIR = join(homedir(), '.clawdbot') +const PI_SESSIONS_DIR = normalizePiSessionsDir( + process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions') +) +const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions') +const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects') + +export async function scanAiVaultSessions( + options: AiVaultScanOptions = {} +): Promise { + const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT) + const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT) + const platform = options.platform ?? process.platform + const issues: AiVaultScanIssue[] = [] + const codexSessionsDirs = uniqueCodexSessionsDirs([ + options.codexSessionsDir ?? CODEX_SESSIONS_DIR, + ...(options.additionalCodexSessionsDirs ?? []) + ]) + + const discoveries = await Promise.all([ + discoverFiles({ + rootDir: options.claudeProjectsDir ?? CLAUDE_PROJECTS_DIR, + limit: limitPerAgent, + agent: 'claude', + issues, + extensions: ['.jsonl'] + }), + ...codexSessionsDirs.map((rootDir) => + discoverFiles({ + rootDir, + limit: limitPerAgent, + agent: 'codex', + issues, + extensions: ['.jsonl'] + }) + ), + discoverFiles({ + rootDir: options.geminiSessionsDir ?? GEMINI_SESSIONS_DIR, + limit: limitPerAgent, + agent: 'gemini', + issues, + extensions: ['.json', '.jsonl'] + }), + discoverFiles({ + rootDir: options.copilotSessionsDir ?? COPILOT_SESSIONS_DIR, + limit: limitPerAgent, + agent: 'copilot', + issues, + extensions: ['.jsonl'] + }), + discoverFiles({ + rootDir: options.cursorProjectsDir ?? CURSOR_PROJECTS_DIR, + limit: limitPerAgent, + agent: 'cursor', + issues, + extensions: ['.jsonl'], + filePredicate: (path) => path.split(/[\\/]/).includes('agent-transcripts') + }), + discoverFiles({ + rootDir: join(options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, 'session'), + limit: limitPerAgent, + agent: 'opencode', + issues, + extensions: ['.json'] + }), + discoverFiles({ + rootDir: options.hermesSessionsDir ?? HERMES_SESSIONS_DIR, + limit: limitPerAgent, + agent: 'hermes', + issues, + extensions: ['.json'], + filePredicate: (path) => basename(path).startsWith('session_') + }), + discoverFiles({ + rootDir: options.rovoSessionsDir ?? ROVO_SESSIONS_DIR, + limit: limitPerAgent, + agent: 'rovo', + issues, + extensions: ['.json'], + filePredicate: (path) => basename(path) === 'metadata.json' + }), + discoverOpenClawFiles({ + rootDirs: [ + options.openclawStateDir ?? OPENCLAW_STATE_DIR, + options.openclawLegacyStateDir ?? OPENCLAW_LEGACY_STATE_DIR + ], + limit: limitPerAgent, + issues + }), + discoverFiles({ + rootDir: options.piSessionsDir ?? PI_SESSIONS_DIR, + limit: limitPerAgent, + agent: 'pi', + issues, + extensions: ['.jsonl'] + }), + discoverFiles({ + rootDir: options.droidSessionsDir ?? DROID_SESSIONS_DIR, + limit: limitPerAgent, + agent: 'droid', + issues, + extensions: ['.jsonl'] + }), + discoverFiles({ + rootDir: options.droidProjectsDir ?? DROID_PROJECTS_DIR, + limit: limitPerAgent, + agent: 'droid', + issues, + extensions: ['.jsonl'] + }) + ]) + + const candidates = discoveries + .flatMap((discovery) => + discovery.files.map( + (file): SessionFileCandidate => ({ + agent: discovery.agent, + file, + codexHome: + discovery.agent === 'codex' + ? codexHomeForSessionsDir(discovery.rootDir, DEFAULT_CODEX_HOME_DIR) + : null + }) + ) + ) + .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs) + + const parsedSessions = await parseSessionCandidates({ + candidates, + limit, + platform, + issues + }) + + const sessions = parsedSessions + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + .slice(0, limit) + + return { + sessions, + issues, + scannedAt: new Date().toISOString() + } +} + +async function parseSessionCandidates(args: { + candidates: SessionFileCandidate[] + limit: number + platform: NodeJS.Platform + issues: AiVaultScanIssue[] +}): Promise { + const sessions: AiVaultSession[] = [] + let index = 0 + + while (index < args.candidates.length) { + if (canStopParsingSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) { + break + } + + const remaining = args.candidates.length - index + const needed = Math.max(args.limit - sessions.length, 1) + const batchSize = Math.min(SESSION_PARSE_CONCURRENCY, needed, remaining) + const batch = args.candidates.slice(index, index + batchSize) + const results = await Promise.all( + batch.map((candidate) => parseSessionCandidate(candidate, args.platform)) + ) + + for (const result of results) { + if (result.issue) { + args.issues.push(result.issue) + } + if (result.session) { + sessions.push(result.session) + } + } + + index += batchSize + } + + return sessions +} + +async function parseSessionCandidate( + candidate: SessionFileCandidate, + platform: NodeJS.Platform +): Promise { + try { + const session = await parseAgentSessionFile(candidate, platform) + return { session, issue: null } + } catch (err) { + return { + session: null, + issue: { + agent: candidate.agent, + path: candidate.file.path, + message: errorMessage(err) + } + } + } +} + +async function parseAgentSessionFile( + candidate: SessionFileCandidate, + platform: NodeJS.Platform +): Promise { + switch (candidate.agent) { + case 'claude': + return parseClaudeSessionFile(candidate.file, platform) + case 'codex': + return parseCodexSessionFile(candidate.file, platform, candidate.codexHome) + case 'gemini': + return parseGeminiSessionFile(candidate.file, platform) + case 'copilot': + return parseCopilotSessionFile(candidate.file, platform) + case 'cursor': + return parseCursorSessionFile(candidate.file, platform) + case 'opencode': + return parseOpenCodeSessionFile(candidate.file, platform) + case 'hermes': + return parseHermesSessionFile(candidate.file, platform) + case 'rovo': + return parseRovoSessionFile(candidate.file, platform) + case 'openclaw': + return parseMessageGraphSessionFile('openclaw', candidate.file, platform) + case 'pi': + return parseMessageGraphSessionFile('pi', candidate.file, platform) + case 'droid': + return parseDroidSessionFile(candidate.file, platform) + } +} + +function canStopParsingSessions( + sessions: AiVaultSession[], + limit: number, + nextCandidateMtimeMs: number | undefined +): boolean { + if (sessions.length < limit || typeof nextCandidateMtimeMs !== 'number') { + return false + } + const visibleCutoff = sessions + .map(sessionSortTime) + .sort((left, right) => right - left) + .at(limit - 1) + + // Transcript mtime is already our discovery bound and fallback sort key; older + // files cannot displace the current visible set once the cutoff is newer. + return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff +} diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 013ae41d5c2..4c5723f952e 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -135,6 +135,10 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } + getHostRuntimeHomePath(): string { + return this.getRuntimeHomePath() + } + private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null { if (process.platform !== 'win32') { return null diff --git a/src/main/index.ts b/src/main/index.ts index d057e2cadf2..04ad7f472f5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -625,6 +625,8 @@ function openMainWindow(): BrowserWindow { crashReports ?? undefined, keybindings, { + getAdditionalAiVaultCodexHomePaths: () => + codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [], onBeforeRelaunch: () => { isQuitting = true store?.flush() diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts new file mode 100644 index 00000000000..0c4a9fdd35e --- /dev/null +++ b/src/main/ipc/ai-vault.ts @@ -0,0 +1,60 @@ +import { ipcMain } from 'electron' +import { join } from 'path' +import { scanAiVaultSessions } from '../ai-vault/session-scanner' +import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' + +const AI_VAULT_CACHE_TTL_MS = 15_000 + +type AiVaultHandlerOptions = { + getAdditionalCodexHomePaths?: () => readonly string[] +} + +type CachedAiVaultList = { + key: string + result: AiVaultListResult + expiresAt: number +} + +let cachedList: CachedAiVaultList | null = null +let inflightList: Promise | null = null +let inflightKey: string | null = null +let handlerOptions: AiVaultHandlerOptions = {} + +async function listAiVaultSessions(args?: AiVaultListArgs): Promise { + const key = String(args?.limit ?? 'default') + 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. + if (args?.force !== true && cachedList?.key === key && cachedList.expiresAt > now) { + return cachedList.result + } + if (inflightList && inflightKey === key) { + return inflightList + } + + inflightKey = key + const additionalCodexSessionsDirs = + handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? + [] + inflightList = scanAiVaultSessions({ limit: args?.limit, additionalCodexSessionsDirs }) + .then((result) => { + cachedList = { + key, + result, + expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS + } + return result + }) + .finally(() => { + inflightKey = null + inflightList = null + }) + return inflightList +} + +export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void { + handlerOptions = options + ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) => + listAiVaultSessions(args) + ) +} diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 183e4c9f23d..7c55815643f 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -27,6 +27,7 @@ const { registerFilesystemHandlersMock, registerRuntimeHandlersMock, registerRuntimeEnvironmentHandlersMock, + registerAiVaultHandlersMock, registerCodexAccountHandlersMock, registerAgentHookHandlersMock, registerAgentTrustHandlersMock, @@ -75,6 +76,7 @@ const { registerFilesystemHandlersMock: vi.fn(), registerRuntimeHandlersMock: vi.fn(), registerRuntimeEnvironmentHandlersMock: vi.fn(), + registerAiVaultHandlersMock: vi.fn(), registerCodexAccountHandlersMock: vi.fn(), registerAgentHookHandlersMock: vi.fn(), registerAgentTrustHandlersMock: vi.fn(), @@ -232,6 +234,10 @@ vi.mock('./runtime-environments', () => ({ registerRuntimeEnvironmentHandlers: registerRuntimeEnvironmentHandlersMock })) +vi.mock('./ai-vault', () => ({ + registerAiVaultHandlers: registerAiVaultHandlersMock +})) + vi.mock('./codex-accounts', () => ({ registerCodexAccountHandlers: registerCodexAccountHandlersMock })) @@ -310,6 +316,7 @@ describe('registerCoreHandlers', () => { registerFilesystemHandlersMock.mockReset() registerRuntimeHandlersMock.mockReset() registerRuntimeEnvironmentHandlersMock.mockReset() + registerAiVaultHandlersMock.mockReset() registerCodexAccountHandlersMock.mockReset() registerAgentHookHandlersMock.mockReset() registerAgentTrustHandlersMock.mockReset() @@ -346,6 +353,7 @@ describe('registerCoreHandlers', () => { const rateLimits = { marker: 'rateLimits' } const agentAwakeService = { marker: 'agentAwakeService' } const onBeforeRelaunch = vi.fn() + const getAdditionalAiVaultCodexHomePaths = vi.fn(() => ['/runtime/codex/home']) registerCoreHandlers( store as never, @@ -363,7 +371,7 @@ describe('registerCoreHandlers', () => { agentAwakeService as never, undefined, undefined, - { onBeforeRelaunch } + { getAdditionalAiVaultCodexHomePaths, onBeforeRelaunch } ) expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage) @@ -398,6 +406,9 @@ describe('registerCoreHandlers', () => { expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store) expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime) expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalled() + expect(registerAiVaultHandlersMock).toHaveBeenCalledWith({ + getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths + }) expect(registerCliHandlersMock).toHaveBeenCalled() expect(registerPreflightHandlersMock).toHaveBeenCalled() expect(registerShellHandlersMock).toHaveBeenCalled() diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index a553ec43ff1..9b607c40c31 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -23,6 +23,7 @@ import { registerMemoryHandlers } from './memory' import { registerRateLimitHandlers } from './rate-limits' import { registerRuntimeHandlers } from './runtime' import { registerRuntimeEnvironmentHandlers } from './runtime-environments' +import { registerAiVaultHandlers } from './ai-vault' import { registerNotificationHandlers } from './notifications' import { registerNotebookHandlers } from './notebook' import { registerOnboardingHandlers } from './onboarding' @@ -66,6 +67,7 @@ let registered = false type CoreHandlerLifecycleOptions = { onBeforeRelaunch?: () => void + getAdditionalAiVaultCodexHomePaths?: () => readonly string[] } export function registerCoreHandlers( @@ -155,6 +157,9 @@ export function registerCoreHandlers( registerFilesystemWatcherHandlers() registerRuntimeHandlers(runtime) registerRuntimeEnvironmentHandlers() + registerAiVaultHandlers({ + getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths + }) registerClipboardHandlers() registerUpdaterHandlers(store) registerSpeechHandlers(store) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 7fc32273253..dfc5dcd67d4 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -419,6 +419,7 @@ function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSide if ( tab === 'explorer' || tab === 'search' || + tab === 'vault' || tab === 'source-control' || tab === 'checks' || tab === 'ports' diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 2eac89eafb4..2cc10233b52 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -133,7 +133,9 @@ const UiUpdate = z lastActiveWorktreeId: NullableString.optional(), sidebarWidth: z.number().finite().optional(), rightSidebarOpen: z.boolean().optional(), - rightSidebarTab: z.enum(['explorer', 'search', 'source-control', 'checks', 'ports']).optional(), + rightSidebarTab: z + .enum(['explorer', 'search', 'vault', 'source-control', 'checks', 'ports']) + .optional(), rightSidebarWidth: z.number().finite().optional(), groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(), showWorkspaceLineage: z.boolean().optional(), diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 90e88478f9f..fe274d22ecb 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -329,6 +329,7 @@ import type { OpenCodeUsageSnapshot, OpenCodeUsageSummary } from '../shared/opencode-usage-types' +import type { AiVaultListArgs, AiVaultListResult } from '../shared/ai-vault-types' import type { TelemetryConsentState } from '../shared/telemetry-consent-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import type { AppStarSource } from '../shared/gh-star-source' @@ -677,6 +678,10 @@ export type OpenCodeUsageApi = { }) => Promise } +export type AiVaultApi = { + listSessions: (args?: AiVaultListArgs) => Promise +} + export type AppApi = { /** Returns the app identity currently exposed to native chrome and the titlebar. */ getIdentity: () => Promise @@ -1864,6 +1869,7 @@ export type PreloadApi = { claudeUsage: ClaudeUsageApi codexUsage: CodexUsageApi openCodeUsage: OpenCodeUsageApi + aiVault: AiVaultApi fs: { readDir: (args: { dirPath: string; connectionId?: string }) => Promise readFile: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index a5bf73c305a..d1325253ab9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -139,6 +139,7 @@ import type { AutomationUpdateInput } from '../shared/automations-types' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' +import type { AiVaultListArgs } from '../shared/ai-vault-types' import { ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, type EditorPrepareHotExitDetail @@ -3251,6 +3252,11 @@ const api = { ipcRenderer.invoke('openCodeUsage:getRecentSessions', args) }, + aiVault: { + listSessions: (args?: AiVaultListArgs): Promise => + ipcRenderer.invoke('aiVault:listSessions', args) + }, + runtime: { syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise => ipcRenderer.invoke('runtime:syncWindowGraph', graph), diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 26057d8bc02..710a2179fc5 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -52,6 +52,7 @@ import { handleSwitchTerminalTab } from '../hooks/ipc-tab-switch' import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout' +import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer' import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal' import { shouldRepairActiveTerminalTab } from './terminal/active-terminal-repair' import { addBackgroundMountedTerminalWorktree } from './terminal/background-terminal-worktree-mount' @@ -1972,6 +1973,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ /> + ) }) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx new file mode 100644 index 00000000000..fc40c509ed1 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -0,0 +1,415 @@ +import { useVirtualizer } from '@tanstack/react-virtual' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' +import { CLIENT_PLATFORM } from '@/lib/new-workspace' +import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session' +import { useAppStore } from '@/store' +import { useActiveWorktree, useRepoById } from '@/store/selectors' +import { + agentLabel, + filterAiVaultSessions, + groupAiVaultSessions, + type AiVaultSessionGroup +} from './ai-vault-session-filters' +import { + AI_VAULT_AGENTS, + buildAiVaultResumeCommand, + type AiVaultAgent, + type AiVaultGroup, + type AiVaultListResult, + type AiVaultScope, + type AiVaultSession, + type AiVaultSort +} from '../../../../shared/ai-vault-types' +import { EmptyState, SessionLoadingState, VaultGroupHeader } from './AiVaultPanelControls' +import { VaultSessionRow } from './AiVaultSessionRow' +import { findSessionRepo } from './ai-vault-session-repo-match' +import type { Repo } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { AiVaultPanelHeader } from './AiVaultPanelHeader' + +const SESSION_LIMIT = 500 +const VAULT_ROW_OVERSCAN = 8 + +type AiVaultListRow = + | { type: 'group'; group: AiVaultSessionGroup } + | { type: 'session'; groupKey: string; session: AiVaultSession } + +export default function AiVaultPanel(): React.JSX.Element { + const activeWorktree = useActiveWorktree() + const activeRepo = useRepoById(activeWorktree?.repoId ?? null) + const agentCmdOverrides = useAppStore((s) => s.settings?.agentCmdOverrides ?? {}) + const repos = useAppStore((s) => s.repos) + const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) + const [query, setQuery] = useState('') + const [scope, setScope] = useState('workspace') + const [sort, setSort] = useState('updated') + const [group, setGroup] = useState('folder') + 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 listScrollRef = useRef(null) + + const isRemoteWorktree = Boolean(activeRepo?.connectionId) + const activeWorktreePath = activeWorktree?.path ?? null + const hasAllAgentsSelected = agents.length === AI_VAULT_AGENTS.length + const viewAdjustmentCount = + (hasAllAgentsSelected ? 0 : 1) + + (sort === 'updated' ? 0 : 1) + + (group === 'folder' ? 0 : 1) + + (hideEmptySessions ? 0 : 1) + + useEffect(() => { + if (!activeWorktreePath && scope === 'workspace') { + setScope('all') + } + }, [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, { + query, + agents, + scope, + sort, + activeWorktreePath, + hideEmptySessions + }), + [activeWorktreePath, agents, hideEmptySessions, query, scope, sessions, sort] + ) + + const groups = useMemo( + () => groupAiVaultSessions(filteredSessions, group), + [filteredSessions, group] + ) + + const vaultRows = useMemo(() => { + const rows: AiVaultListRow[] = [] + for (const sessionGroup of groups) { + rows.push({ type: 'group', group: sessionGroup }) + if (!collapsedGroups.has(sessionGroup.key)) { + for (const session of sessionGroup.sessions) { + rows.push({ type: 'session', groupKey: sessionGroup.key, session }) + } + } + } + return rows + }, [collapsedGroups, groups]) + + const virtualizer = useVirtualizer({ + count: vaultRows.length, + getScrollElement: () => listScrollRef.current, + estimateSize: (index) => (vaultRows[index]?.type === 'group' ? 28 : 64), + overscan: VAULT_ROW_OVERSCAN, + getItemKey: (index) => { + const row = vaultRows[index] + if (!row) { + return `missing:${index}` + } + return row.type === 'group' ? `group:${row.group.key}` : `session:${row.session.id}` + } + }) + + const sessionRepoById = useMemo(() => { + const matches = new Map() + for (const session of sessions) { + const repo = findSessionRepo(session.cwd, repos, worktreesByRepo) + if (repo) { + matches.set(session.id, repo) + } + } + return matches + }, [repos, sessions, worktreesByRepo]) + + const buildResumeCommand = useCallback( + (session: AiVaultSession): string => + buildAiVaultResumeCommand({ + agent: session.agent, + sessionId: session.sessionId, + cwd: session.cwd, + platform: CLIENT_PLATFORM, + commandOverride: agentCmdOverrides[session.agent], + codexHome: session.codexHome + }), + [agentCmdOverrides] + ) + + const copyResumeCommand = useCallback( + async (session: AiVaultSession): Promise => { + await window.api.ui.writeClipboardText(buildResumeCommand(session)) + toast.success( + translate( + 'auto.components.right.sidebar.AiVaultPanel.resumeCommandCopied', + 'Resume command copied' + ) + ) + }, + [buildResumeCommand] + ) + + const copyText = useCallback(async (text: string, label: string): Promise => { + await window.api.ui.writeClipboardText(text) + toast.success( + translate('auto.components.right.sidebar.AiVaultPanel.valueCopied', '{{value0}} copied', { + value0: label + }) + ) + }, []) + + const handleResume = useCallback( + (session: AiVaultSession): void => { + if (!activeWorktree) { + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.openWorkspaceBeforeResuming', + 'Open a workspace before resuming a session.' + ) + ) + return + } + if (isRemoteWorktree) { + toast.error( + translate( + 'auto.components.right.sidebar.AiVaultPanel.localWorkspacesOnly', + 'Resume from history is only available in local workspaces.' + ) + ) + return + } + launchAiVaultSessionInNewTab({ + agent: session.agent, + worktreeId: activeWorktree.id, + command: buildResumeCommand(session) + }) + toast.success( + translate( + 'auto.components.right.sidebar.AiVaultPanel.agentSessionQueued', + '{{value0}} session queued', + { value0: agentLabel(session.agent) } + ) + ) + }, + [activeWorktree, buildResumeCommand, isRemoteWorktree] + ) + + const setAgentEnabled = useCallback((agent: AiVaultAgent, enabled: boolean) => { + setAgents((current) => { + if (enabled) { + return current.includes(agent) ? current : [...current, agent] + } + const next = current.filter((entry) => entry !== agent) + return next.length > 0 ? next : current + }) + }, []) + + const resetViewOptions = useCallback(() => { + setAgents([...AI_VAULT_AGENTS]) + setSort('updated') + setGroup('folder') + setHideEmptySessions(true) + }, []) + + const toggleGroup = useCallback((key: string) => { + setCollapsedGroups((current) => { + const next = new Set(current) + if (next.has(key)) { + next.delete(key) + } else { + next.add(key) + } + return next + }) + }, []) + + return ( +
+ void refresh({ force: true })} + /> + + {isRemoteWorktree ? ( +
+ {translate( + 'auto.components.right.sidebar.AiVaultPanel.remoteBrowseLocalHistory', + 'Remote workspaces can browse local history. Resume actions run from local workspaces.' + )} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + {scanResult && scanResult.issues.length > 0 ? ( +
+ {translate( + 'auto.components.right.sidebar.AiVaultPanel.transcriptsSkipped', + '{{count}} transcript skipped', + { count: scanResult.issues.length } + )} +
+ ) : null} + +
+ {loading && sessions.length === 0 ? : null} + + {!loading && sessions.length === 0 && !error ? ( + + ) : null} + + {sessions.length > 0 && filteredSessions.length === 0 ? ( + + ) : null} + + {vaultRows.length > 0 ? ( +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const row = vaultRows[virtualRow.index] + if (!row) { + return null + } + return ( +
+ {row.type === 'group' ? ( + toggleGroup(row.group.key)} + /> + ) : ( + handleResume(row.session)} + onCopyResume={() => void copyResumeCommand(row.session)} + onCopyId={() => + void copyText( + row.session.sessionId, + translate( + 'auto.components.right.sidebar.AiVaultPanel.sessionId', + 'Session ID' + ) + ) + } + onCopyPath={() => + void copyText( + row.session.filePath, + translate( + 'auto.components.right.sidebar.AiVaultPanel.logPath', + 'Log path' + ) + ) + } + onOpenLog={() => void window.api.shell.openFilePath(row.session.filePath)} + onRevealLog={() => void window.api.shell.openPath(row.session.filePath)} + onOpenCwd={ + row.session.cwd + ? () => { + if (row.session.cwd) { + void window.api.shell.openPath(row.session.cwd) + } + } + : undefined + } + /> + )} +
+ ) + })} +
+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx new file mode 100644 index 00000000000..6c640793171 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelControls.tsx @@ -0,0 +1,312 @@ +import type React from 'react' +import { + ArchiveRestore, + Calendar, + ChevronRight, + Clock3, + FolderOpen, + Globe2, + ListFilter, + LoaderCircle +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' +import { AgentIcon } from '@/lib/agent-catalog' +import { cn } from '@/lib/utils' +import { + AI_VAULT_AGENTS, + type AiVaultAgent, + type AiVaultGroup, + type AiVaultScope, + type AiVaultSort +} from '../../../../shared/ai-vault-types' +import { agentLabel, type AiVaultSessionGroup } from './ai-vault-session-filters' +import { translate } from '@/i18n/i18n' + +const VAULT_SCOPE_TOGGLE_ITEM_CLASS = + 'size-7 min-w-7 border border-transparent bg-transparent p-0 text-foreground shadow-none hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-[checked=true]:border-foreground/20 aria-[checked=true]:bg-foreground/10 aria-[checked=true]:text-foreground aria-[checked=true]:shadow-xs aria-[checked=true]:hover:bg-foreground/15 aria-[checked=true]:hover:text-foreground data-[state=on]:border-foreground/20 data-[state=on]:bg-foreground/10 data-[state=on]:text-foreground data-[state=on]:shadow-xs data-[state=on]:hover:bg-foreground/15 data-[state=on]:hover:text-foreground' + +export function VaultGroupHeader({ + group, + collapsed, + onToggle +}: { + group: AiVaultSessionGroup + collapsed: boolean + onToggle: () => void +}): React.JSX.Element { + return ( + + ) +} + +export function SessionLoadingState(): React.JSX.Element { + return ( +
+
+ + + {translate( + 'auto.components.right.sidebar.AiVaultPanelControls.scanningSessions', + 'Scanning sessions' + )} + +
+
+ {Array.from({ length: 6 }, (_, index) => ( +
+
+
+
+
+
+
+
+ ))} +
+
+ ) +} + +export function VaultScopeSwitch({ + scope, + workspaceAvailable, + onScopeChange +}: { + scope: AiVaultScope + workspaceAvailable: boolean + onScopeChange: (scope: AiVaultScope) => void +}): React.JSX.Element { + const workspaceLabel = translate( + 'auto.components.right.sidebar.AiVaultPanelControls.workspaceScope', + 'Workspace' + ) + const globalLabel = translate( + 'auto.components.right.sidebar.AiVaultPanelControls.globalScope', + 'Global' + ) + + return ( + { + if (value === 'workspace' || value === 'all') { + onScopeChange(value) + } + }} + variant="outline" + className="shrink-0 rounded-md border border-sidebar-border bg-sidebar-accent/35 shadow-xs" + aria-label={translate( + 'auto.components.right.sidebar.AiVaultPanelControls.scopeAriaLabel', + 'Session History scope: {{value0}}', + { + value0: + scope === 'workspace' + ? translate( + 'auto.components.right.sidebar.AiVaultPanelControls.currentWorkspaceLower', + 'current workspace' + ) + : translate( + 'auto.components.right.sidebar.AiVaultPanelControls.allSessionsLower', + 'all sessions' + ) + } + )} + > + + + + + + + + {globalLabel} + + + + + + + + + + {workspaceLabel} + + + + ) +} + +export function VaultViewMenu({ + agents, + sort, + group, + hideEmptySessions, + adjustmentCount, + onAgentEnabledChange, + onSortChange, + onGroupChange, + onHideEmptySessionsChange, + onReset +}: { + agents: readonly AiVaultAgent[] + sort: AiVaultSort + group: AiVaultGroup + hideEmptySessions: boolean + adjustmentCount: number + onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void + onSortChange: (sort: AiVaultSort) => void + onGroupChange: (group: AiVaultGroup) => void + onHideEmptySessionsChange: (hideEmptySessions: boolean) => void + onReset: () => void +}): React.JSX.Element { + return ( + + + + + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.agents', 'Agents')} + + {AI_VAULT_AGENTS.map((agent) => ( + onAgentEnabledChange(agent, checked === true)} + onSelect={(event) => event.preventDefault()} + > + + {agentLabel(agent)} + + ))} + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.sort', 'Sort')} + + onSortChange(value as AiVaultSort)} + > + + + {translate( + 'auto.components.right.sidebar.AiVaultPanelControls.lastUpdated', + 'Last updated' + )} + + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.created', 'Created')} + + + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.group', 'Group')} + + onGroupChange(value as AiVaultGroup)} + > + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.folder', 'Folder')} + + + + {translate('auto.components.right.sidebar.AiVaultPanelControls.agent', 'Agent')} + + + + onHideEmptySessionsChange(checked === true)} + onSelect={(event) => event.preventDefault()} + > + {translate( + 'auto.components.right.sidebar.AiVaultPanelControls.hideEmptySessions', + 'Hide empty sessions' + )} + + {adjustmentCount > 0 ? ( + <> + + + {translate( + 'auto.components.right.sidebar.AiVaultPanelControls.resetView', + 'Reset view' + )} + + + ) : null} + + + ) +} + +export function EmptyState({ title }: { title: string }): React.JSX.Element { + return ( +
+ +

{title}

+
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx new file mode 100644 index 00000000000..dd45be03bf1 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx @@ -0,0 +1,152 @@ +import { Grid2x2, LoaderCircle, RefreshCw, Search, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import { + type AiVaultAgent, + type AiVaultGroup, + type AiVaultScope, + type AiVaultSort +} from '../../../../shared/ai-vault-types' +import { VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls' + +type AiVaultPanelHeaderProps = { + query: string + loading: boolean + shownCount: number + sessionCount: number + hasScanResult: boolean + activeWorktreePath: string | null + scope: AiVaultScope + agents: readonly AiVaultAgent[] + sort: AiVaultSort + group: AiVaultGroup + hideEmptySessions: boolean + adjustmentCount: number + onQueryChange: (query: string) => void + onScopeChange: (scope: AiVaultScope) => void + onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void + onSortChange: (sort: AiVaultSort) => void + onGroupChange: (group: AiVaultGroup) => void + onHideEmptySessionsChange: (hideEmptySessions: boolean) => void + onReset: () => void + onRefresh: () => void +} + +export function AiVaultPanelHeader({ + query, + loading, + shownCount, + sessionCount, + hasScanResult, + activeWorktreePath, + scope, + agents, + sort, + group, + hideEmptySessions, + adjustmentCount, + onQueryChange, + onScopeChange, + onAgentEnabledChange, + onSortChange, + onGroupChange, + onHideEmptySessionsChange, + onReset, + onRefresh +}: AiVaultPanelHeaderProps): React.JSX.Element { + return ( +
+
+ +
+
+ {translate( + 'auto.components.right.sidebar.AiVaultPanel.sessionHistory', + 'Agent Session History' + )} +
+
+ {hasScanResult + ? translate( + 'auto.components.right.sidebar.AiVaultPanel.shownRecent', + '{{value0}} shown · {{value1}} recent', + { value0: shownCount, value1: sessionCount } + ) + : translate( + 'auto.components.right.sidebar.AiVaultPanel.resumePastSessions', + 'Resume past sessions' + )} +
+
+
+ + + +
+
+ +
+ + onQueryChange(event.target.value)} + placeholder={translate( + 'auto.components.right.sidebar.AiVaultPanel.searchSessions', + 'Search sessions' + )} + className="min-w-0 flex-1 bg-transparent py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/50" + spellCheck={false} + /> + {loading ? : null} + {query ? ( + + ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx new file mode 100644 index 00000000000..ed3b686a07a --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -0,0 +1,411 @@ +import type React from 'react' +import { Copy, FileJson, FolderOpen, MoreHorizontal, Play } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { HoverCardContent } from '@/components/ui/hover-card' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { AgentIcon } from '@/lib/agent-catalog' +import { cn } from '@/lib/utils' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { Repo } from '../../../../shared/types' +import { agentLabel } from './ai-vault-session-filters' +import { translate } from '@/i18n/i18n' + +export function SessionDetailsHoverCard({ + session, + repo, + resumeCommand +}: { + session: AiVaultSession + repo: Repo | null + resumeCommand: string +}): React.JSX.Element { + const updatedAt = session.updatedAt ?? session.modifiedAt + + return ( + +
+
+ + + +
+
+ {session.title} +
+
+ {agentLabel(session.agent)} + {repo ? ( + <> + + + + ) : null} +
+
+
+ +
+ + + + {session.branch ? ( + + ) : null} + {session.model ? ( + + ) : null} + 0 + ? translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.tokenSuffix', + ' · {{value0}} tok', + { value0: formatTokenCount(session.totalTokens) } + ) + : '' + } + )} + /> + +
+ +
+
+ {translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.latestLog', + 'Latest log' + )} +
+ {session.previewMessages.length > 0 ? ( +
+ {session.previewMessages.map((message, index) => ( +
+
+ {previewRoleLabel(message.role)} +
+
+ {message.text} +
+
+ ))} +
+ ) : ( +
+ {translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.noReadablePreview', + 'No readable message preview in this transcript.' + )} +
+ )} +
+ +
+
+ {translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.resumeCommand', + 'Resume command' + )} +
+
+ {resumeCommand} +
+
+
+
+ ) +} + +function DetailLine({ + label, + value, + mono = false +}: { + label: string + value: string + mono?: boolean +}): React.JSX.Element { + return ( +
+ {label} + + {value} + +
+ ) +} + +export function RepoBadge({ repo }: { repo: Repo }): React.JSX.Element { + return ( + + + + {repo.displayName} + + + ) +} + +export function MetaDot(): React.JSX.Element { + return · +} + +export function SessionActionsMenu({ + session, + onResume, + onCopyResume, + onCopyId, + onCopyPath, + onOpenLog, + onRevealLog, + onOpenCwd, + resumeDisabled +}: { + session: AiVaultSession + onResume: () => void + onCopyResume: () => void + onCopyId: () => void + onCopyPath: () => void + onOpenLog: () => void + onRevealLog: () => void + onOpenCwd?: () => void + resumeDisabled: boolean +}): React.JSX.Element { + return ( + + + + + + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.resumeInNewTab', + 'Resume in New Tab' + )} + + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.copyResumeCommand', + 'Copy Resume Command' + )} + + + + + {translate('auto.components.right.sidebar.AiVaultSessionDetails.openLog', 'Open Log')} + + + + {translate('auto.components.right.sidebar.AiVaultSessionDetails.revealLog', 'Reveal Log')} + + {onOpenCwd ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.openWorkingDirectory', + 'Open Working Directory' + )} + + ) : null} + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.copySessionId', + 'Copy Session ID' + )} + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.copyLogPath', + 'Copy Log Path' + )} + + + + ) +} + +export function SessionTime({ + value, + className +}: { + value: string + className?: string +}): React.JSX.Element { + const timestamp = Date.parse(value) + if (!Number.isFinite(timestamp)) { + return ( + + {translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.unknownTime', + 'Unknown time' + )} + + ) + } + + const date = new Date(timestamp) + return ( + + + + ) +} + +function formatDateTime(value: string | null): string { + if (!value) { + return translate('auto.components.right.sidebar.AiVaultSessionDetails.unknown', 'Unknown') + } + const timestamp = Date.parse(value) + if (!Number.isFinite(timestamp)) { + return translate('auto.components.right.sidebar.AiVaultSessionDetails.unknown', 'Unknown') + } + return new Date(timestamp).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) +} + +function previewRoleLabel(role: AiVaultSession['previewMessages'][number]['role']): string { + if (role === 'user') { + return translate('auto.components.right.sidebar.AiVaultSessionDetails.user', 'User') + } + if (role === 'assistant') { + return translate('auto.components.right.sidebar.AiVaultSessionDetails.assistant', 'Assistant') + } + if (role === 'tool') { + return translate('auto.components.right.sidebar.AiVaultSessionDetails.tool', 'Tool') + } + if (role === 'system') { + return translate('auto.components.right.sidebar.AiVaultSessionDetails.system', 'System') + } + return translate('auto.components.right.sidebar.AiVaultSessionDetails.log', 'Log') +} + +function formatTimeAgo(timestamp: number): string { + const diffMs = Date.now() - timestamp + if (diffMs < 60_000) { + return translate('auto.components.right.sidebar.AiVaultSessionDetails.justNow', 'Just now') + } + const minutes = Math.floor(diffMs / 60_000) + if (minutes < 60) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.minutesAgo', + '{{value0}}m ago', + { value0: minutes } + ) + } + const hours = Math.floor(minutes / 60) + if (hours < 24) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.hoursAgo', + '{{value0}}h ago', + { value0: hours } + ) + } + const days = Math.floor(hours / 24) + if (days < 30) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.daysAgo', + '{{value0}}d ago', + { value0: days } + ) + } + const months = Math.floor(days / 30) + if (months < 12) { + return translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.monthsAgo', + '{{value0}}mo ago', + { value0: months } + ) + } + return translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.yearsAgo', + '{{value0}}y ago', + { value0: Math.floor(months / 12) } + ) +} + +export function formatTokenCount(value: number): string { + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}m` + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}k` + } + return String(value) +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx new file mode 100644 index 00000000000..3fa92be5c49 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -0,0 +1,244 @@ +import type React from 'react' +import { Copy, FileJson, FolderOpen, Play } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { HoverCard, HoverCardTrigger } from '@/components/ui/hover-card' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { AgentIcon } from '@/lib/agent-catalog' +import { cn } from '@/lib/utils' +import { + AI_VAULT_SESSION_DRAG_END_EVENT, + AI_VAULT_SESSION_DRAG_START_EVENT, + writeAiVaultSessionDragData +} from '@/lib/ai-vault-session-drag' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { Repo } from '../../../../shared/types' +import { agentLabel, folderLabel } from './ai-vault-session-filters' +import { translate } from '@/i18n/i18n' +import { + formatTokenCount, + MetaDot, + RepoBadge, + SessionActionsMenu, + SessionDetailsHoverCard, + SessionTime +} from './AiVaultSessionDetails' + +export function VaultSessionRow({ + session, + repo, + resumeCommand, + resumeDisabled, + onResume, + onCopyResume, + onCopyId, + onCopyPath, + onOpenLog, + onRevealLog, + onOpenCwd +}: { + session: AiVaultSession + repo: Repo | null + resumeCommand: string + resumeDisabled: boolean + onResume: () => void + onCopyResume: () => void + onCopyId: () => void + onCopyPath: () => void + onOpenLog: () => void + onRevealLog: () => void + onOpenCwd?: () => void +}): React.JSX.Element { + const updatedAt = session.updatedAt ?? session.modifiedAt + + return ( + + + + +
{ + if (resumeDisabled) { + event.preventDefault() + return + } + writeAiVaultSessionDragData(event.dataTransfer, { + agent: session.agent, + sessionId: session.sessionId, + title: session.title, + command: resumeCommand + }) + window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_START_EVENT)) + }} + onDragEnd={() => { + window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_END_EVENT)) + }} + onDoubleClick={() => { + if (!resumeDisabled) { + onResume() + } + }} + > +
+
+ {session.title} +
+ +
+ {folderLabel(session.cwd)} +
+
+ +
+ + +
+
+
+
+ + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab', + 'Resume in New Tab' + )} + + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand', + 'Copy Resume Command' + )} + + + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')} + + + + {translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')} + + {onOpenCwd ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory', + 'Open Working Directory' + )} + + ) : null} + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.copySessionId', + 'Copy Session ID' + )} + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.copyLogPath', + 'Copy Log Path' + )} + + +
+ +
+ ) +} + +function SessionMetadata({ + session, + repo +}: { + session: AiVaultSession + repo: Repo | null +}): React.JSX.Element { + return ( +
+ + + + {repo ? ( + + ) : ( + + {folderLabel(session.cwd)} + + )} + {session.model ? ( + <> + + {session.model} + + ) : null} + {session.branch ? ( + <> + + {session.branch} + + ) : null} + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.messageCount', + '{{value0}} msgs', + { value0: session.messageCount } + )} + + {session.totalTokens > 0 ? ( + <> + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.tokenCount', + '{{value0}} tok', + { value0: formatTokenCount(session.totalTokens) } + )} + + + ) : null} +
+ ) +} 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 new file mode 100644 index 00000000000..117529b8abc --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { + filterAiVaultSessions, + folderLabel, + groupAiVaultSessions, + parseVaultQuery +} from './ai-vault-session-filters' + +const baseSession: AiVaultSession = { + id: 'claude:1', + agent: 'claude', + sessionId: 'session-1', + title: 'Implement vault filters', + cwd: '/Users/ada/repo/app', + branch: 'feature/vault', + model: 'claude-sonnet-4-5', + filePath: '/Users/ada/.claude/projects/session-1.jsonl', + codexHome: null, + createdAt: '2026-05-01T10:00:00.000Z', + updatedAt: '2026-05-01T10:10:00.000Z', + modifiedAt: '2026-05-01T10:10:00.000Z', + messageCount: 4, + totalTokens: 1200, + previewMessages: [], + resumeCommand: "cd '/Users/ada/repo/app' && claude --resume 'session-1'" +} + +describe('filterAiVaultSessions', () => { + it('filters by workspace, agent, plain terms, repo: and path: operators', () => { + const sessions: AiVaultSession[] = [ + baseSession, + { + ...baseSession, + id: 'codex:2', + agent: 'codex', + sessionId: 'session-2', + title: 'Repair terminal tabs', + cwd: '/Users/ada/other/packages/ui', + branch: 'fix/terminal', + filePath: '/Users/ada/.codex/sessions/session-2.jsonl' + } + ] + + expect( + filterAiVaultSessions(sessions, { + query: 'vault repo:repo path:app', + agents: ['claude'], + scope: 'workspace', + sort: 'updated', + activeWorktreePath: '/Users/ada/repo', + hideEmptySessions: true + }).map((session) => session.id) + ).toEqual(['claude:1']) + }) + + it('hides empty metadata-only sessions when requested', () => { + const emptySession: AiVaultSession = { + ...baseSession, + id: 'claude:empty', + sessionId: 'empty-session', + title: 'Claude empty-session', + messageCount: 0 + } + + expect( + filterAiVaultSessions([emptySession, baseSession], { + query: '', + agents: ['claude'], + scope: 'all', + sort: 'updated', + activeWorktreePath: null, + hideEmptySessions: true + }).map((session) => session.id) + ).toEqual(['claude:1']) + + const shownWhenAllowed = filterAiVaultSessions([emptySession, baseSession], { + query: '', + agents: ['claude'], + scope: 'all', + sort: 'updated', + activeWorktreePath: null, + hideEmptySessions: false + }).map((session) => session.id) + + expect(new Set(shownWhenAllowed)).toEqual(new Set(['claude:1', 'claude:empty'])) + }) + + it('matches Windows workspace paths case-insensitively', () => { + expect( + filterAiVaultSessions( + [ + { + ...baseSession, + cwd: 'C:\\Users\\Ada\\Repo\\App' + } + ], + { + query: '', + agents: ['claude'], + scope: 'workspace', + sort: 'updated', + activeWorktreePath: 'c:\\users\\ada\\repo', + hideEmptySessions: true + } + ) + ).toHaveLength(1) + }) +}) + +describe('groupAiVaultSessions', () => { + it('groups by folder or agent without changing session order', () => { + const sessions: AiVaultSession[] = [ + baseSession, + { ...baseSession, id: 'codex:2', agent: 'codex', cwd: '/Users/ada/repo/app' } + ] + + expect(groupAiVaultSessions(sessions, 'folder')).toEqual([ + { key: '/users/ada/repo/app', label: 'repo/app', sessions } + ]) + expect(groupAiVaultSessions(sessions, 'agent').map((group) => group.label)).toEqual([ + 'Claude', + 'Codex' + ]) + }) +}) + +describe('parseVaultQuery', () => { + it('keeps quoted terms together', () => { + expect(parseVaultQuery('"resume picker" repo:orca path:src')).toEqual({ + terms: ['resume picker'], + repoTerms: ['orca'], + pathTerms: ['src'] + }) + }) +}) + +describe('folderLabel', () => { + it('uses the last two path segments for compact labels', () => { + expect(folderLabel('C:\\Users\\Ada\\repo\\app')).toBe('repo/app') + }) +}) 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 new file mode 100644 index 00000000000..a42bdc0fd6e --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts @@ -0,0 +1,178 @@ +import { + isPathInsideOrEqual, + normalizeRuntimePathSeparators +} from '../../../../shared/cross-platform-path' +import type { + AiVaultAgent, + AiVaultGroup, + AiVaultScope, + AiVaultSession, + AiVaultSort +} from '../../../../shared/ai-vault-types' +import { aiVaultAgentLabel } from '../../../../shared/ai-vault-types' + +export type AiVaultSessionFilterState = { + query: string + agents: readonly AiVaultAgent[] + scope: AiVaultScope + sort: AiVaultSort + activeWorktreePath: string | null + hideEmptySessions: boolean +} + +export type AiVaultSessionGroup = { + key: string + label: string + sessions: AiVaultSession[] +} + +type ParsedQuery = { + terms: string[] + repoTerms: string[] + pathTerms: string[] +} + +export function filterAiVaultSessions( + sessions: readonly AiVaultSession[], + filters: AiVaultSessionFilterState +): AiVaultSession[] { + const agentSet = new Set(filters.agents) + const parsedQuery = parseVaultQuery(filters.query) + + return sessions + .filter((session) => { + if (!agentSet.has(session.agent)) { + return false + } + if (filters.hideEmptySessions && session.messageCount === 0) { + return false + } + if ( + filters.scope === 'workspace' && + filters.activeWorktreePath && + (!session.cwd || !isPathInsideOrEqual(filters.activeWorktreePath, session.cwd)) + ) { + return false + } + return matchesQuery(session, parsedQuery) + }) + .sort((left, right) => compareSessions(left, right, filters.sort)) +} + +export function groupAiVaultSessions( + sessions: readonly AiVaultSession[], + group: AiVaultGroup +): AiVaultSessionGroup[] { + const groups = new Map() + + for (const session of sessions) { + const key = group === 'agent' ? session.agent : getFolderGroupKey(session.cwd) + const label = group === 'agent' ? agentLabel(session.agent) : folderLabel(session.cwd) + const existing = groups.get(key) + if (existing) { + existing.sessions.push(session) + } else { + groups.set(key, { key, label, sessions: [session] }) + } + } + + return [...groups.values()] +} + +export function folderLabel(pathValue: string | null): string { + if (!pathValue) { + return 'Unknown location' + } + const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) + if (parts.length >= 2) { + return parts.slice(-2).join('/') + } + return parts[0] ?? pathValue +} + +export function agentLabel(agent: AiVaultAgent): string { + return aiVaultAgentLabel(agent) +} + +export function parseVaultQuery(query: string): ParsedQuery { + const terms: string[] = [] + const repoTerms: string[] = [] + const pathTerms: string[] = [] + + for (const rawToken of tokenizeQuery(query)) { + const token = rawToken.toLowerCase() + if (token.startsWith('repo:')) { + const value = token.slice('repo:'.length) + if (value) { + repoTerms.push(value) + } + continue + } + if (token.startsWith('path:')) { + const value = token.slice('path:'.length) + if (value) { + pathTerms.push(value) + } + continue + } + terms.push(token) + } + + return { terms, repoTerms, pathTerms } +} + +function matchesQuery(session: AiVaultSession, parsed: ParsedQuery): boolean { + const searchable = [ + session.title, + session.sessionId, + session.agent, + session.branch, + session.model, + session.cwd, + session.filePath + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + + if (parsed.terms.some((term) => !searchable.includes(term))) { + return false + } + + const repoLabel = 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 + } + + 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 getFolderGroupKey(pathValue: string | null): string { + return pathValue ? normalizeRuntimePathSeparators(pathValue).toLowerCase() : 'unknown' +} + +function tokenizeQuery(query: string): string[] { + const tokens: string[] = [] + const pattern = /"([^"]+)"|'([^']+)'|(\S+)/g + let match: RegExpExecArray | null + while ((match = pattern.exec(query)) !== null) { + const token = match[1] ?? match[2] ?? match[3] + if (token?.trim()) { + tokens.push(token.trim()) + } + } + return tokens +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-repo-match.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-repo-match.ts new file mode 100644 index 00000000000..79628e23154 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-repo-match.ts @@ -0,0 +1,33 @@ +import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path' +import type { Repo } from '../../../../shared/types' + +export function findSessionRepo( + cwd: string | null, + repos: readonly Repo[], + worktreesByRepo: Record +): Repo | null { + if (!cwd) { + return null + } + let bestRepo: Repo | null = null + let bestPathLength = -1 + + const consider = (repo: Repo, path: string): void => { + if (!path || !isPathInsideOrEqual(path, cwd)) { + return + } + if (path.length > bestPathLength) { + bestRepo = repo + bestPathLength = path.length + } + } + + for (const repo of repos) { + consider(repo, repo.path) + for (const worktree of worktreesByRepo[repo.id] ?? []) { + consider(repo, worktree.path) + } + } + + return bestRepo +} diff --git a/src/renderer/src/components/right-sidebar/index.tsx b/src/renderer/src/components/right-sidebar/index.tsx index ec6c9342c22..a1ba2bf1d78 100644 --- a/src/renderer/src/components/right-sidebar/index.tsx +++ b/src/renderer/src/components/right-sidebar/index.tsx @@ -1,5 +1,6 @@ +/* eslint-disable max-lines -- Why: the right sidebar owns activity-bar visibility, routing, and resize behavior as one interaction surface; splitting the tab table away would make hidden-tab fallbacks harder to audit. */ import React, { useEffect, useMemo, useState } from 'react' -import { Plug, Files, Search, GitBranch, ListChecks, PanelRight } from 'lucide-react' +import { Plug, Files, Search, GitBranch, ListChecks, PanelRight, Grid2x2 } from 'lucide-react' import { useAppStore } from '@/store' import { useRepoById } from '@/store/selectors' import { cn } from '@/lib/utils' @@ -82,6 +83,12 @@ function RightSidebarInner(): React.JSX.Element { title: translate('auto.components.right.sidebar.index.06219e4cb1', 'Search'), shortcut: searchShortcut === 'Unassigned' ? '' : searchShortcut }, + { + id: 'vault', + icon: Grid2x2, + title: translate('auto.components.right.sidebar.index.aiVaultSessionHistory', 'Agents'), + shortcut: '' + }, { id: 'source-control', icon: GitBranch, diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx index 84efd9cad9c..a3d457ca3bc 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/right-sidebar-panel-content.tsx @@ -6,6 +6,7 @@ const SearchPanel = lazy(() => import('./Search')) const SourceControl = lazy(() => import('./SourceControl')) const ChecksPanel = lazy(() => import('./ChecksPanel')) const PortsPanel = lazy(() => import('./PortsPanel')) +const AiVaultPanel = lazy(() => import('./AiVaultPanel')) type RightSidebarPanelContentProps = { effectiveTab: RightSidebarTab @@ -29,6 +30,7 @@ export function RightSidebarPanelContent({ {effectiveTab === 'ports' && ( )} + {effectiveTab === 'vault' && }
) diff --git a/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx new file mode 100644 index 00000000000..0427a2916c8 --- /dev/null +++ b/src/renderer/src/components/tab-group/AiVaultSessionDropLayer.tsx @@ -0,0 +1,309 @@ +import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react' +import { toast } from 'sonner' +import { getConnectionId } from '@/lib/connection-context' +import { + AI_VAULT_SESSION_DRAG_END_EVENT, + AI_VAULT_SESSION_DRAG_START_EVENT, + clearAiVaultSessionDragData, + hasAiVaultSessionDragData, + readAiVaultSessionDragData +} from '@/lib/ai-vault-session-drag' +import { launchAiVaultSessionInNewTab } from '@/lib/launch-ai-vault-session' +import { resolveDropZone, type TabDropZone } from './useTabDragSplit' +import { translate } from '@/i18n/i18n' + +type PaneDropTarget = { + groupId: string + zone: TabDropZone + overlayStyle: CSSProperties +} + +function getZoneOverlayStyle(rect: DOMRect, layerRect: DOMRect, zone: TabDropZone): CSSProperties { + const left = rect.left - layerRect.left + const top = rect.top - layerRect.top + const width = rect.width + const height = rect.height + + switch (zone) { + case 'up': + return { left, top, width, height: height / 2 } + case 'down': + return { left, top: top + height / 2, width, height: height / 2 } + case 'left': + return { left, top, width: width / 2, height } + case 'right': + return { left: left + width / 2, top, width: width / 2, height } + case 'center': + return { left, top, width, height } + } +} + +function containsPoint(rect: DOMRect, x: number, y: number): boolean { + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom +} + +function resolvePaneDropTarget( + worktreeId: string, + layerRect: DOMRect, + point: { x: number; y: number } +): PaneDropTarget | null { + const elements = Array.from( + document.querySelectorAll('[data-tab-group-body-id][data-worktree-id]') + ) + for (const element of elements) { + if (element.dataset.worktreeId !== worktreeId) { + continue + } + const groupId = element.dataset.tabGroupBodyId + const rect = element.getBoundingClientRect() + if (!groupId || rect.width <= 0 || rect.height <= 0 || !containsPoint(rect, point.x, point.y)) { + continue + } + const zone = resolveDropZone(rect, point) + return { + groupId, + zone, + overlayStyle: getZoneOverlayStyle(rect, layerRect, zone) + } + } + return null +} + +export default function AiVaultSessionDropLayer({ + worktreeId, + enabled +}: { + worktreeId: string + enabled: boolean +}): React.JSX.Element { + const [isDragActive, setIsDragActive] = useState(false) + const [target, setTarget] = useState(null) + const layerRef = useRef(null) + + const clearDragState = useCallback(() => { + setIsDragActive(false) + setTarget(null) + clearAiVaultSessionDragData() + }, []) + + const updateTarget = useCallback( + ( + dataTransfer: DataTransfer, + point: { + x: number + y: number + } + ): PaneDropTarget | null => { + if (!hasAiVaultSessionDragData(dataTransfer)) { + setTarget(null) + return null + } + const layerElement = layerRef.current + if (!layerElement) { + setTarget(null) + return null + } + const layerRect = layerElement.getBoundingClientRect() + const nextTarget = resolvePaneDropTarget(worktreeId, layerRect, { + x: point.x, + y: point.y + }) + setTarget((current) => { + if ( + current?.groupId === nextTarget?.groupId && + current?.zone === nextTarget?.zone && + current?.overlayStyle.left === nextTarget?.overlayStyle.left && + current?.overlayStyle.top === nextTarget?.overlayStyle.top && + current?.overlayStyle.width === nextTarget?.overlayStyle.width && + current?.overlayStyle.height === nextTarget?.overlayStyle.height + ) { + return current + } + return nextTarget + }) + return nextTarget + }, + [worktreeId] + ) + + const handleSessionDrop = useCallback( + ( + dataTransfer: DataTransfer, + point: { + x: number + y: number + } + ): boolean => { + if (!hasAiVaultSessionDragData(dataTransfer)) { + return false + } + + const layerRect = layerRef.current?.getBoundingClientRect() + const wasInsideLayer = layerRect ? containsPoint(layerRect, point.x, point.y) : false + const dropTarget = updateTarget(dataTransfer, point) ?? target + const payload = readAiVaultSessionDragData(dataTransfer) + clearDragState() + if (!dropTarget) { + if (wasInsideLayer) { + toast.error( + translate( + 'auto.components.tab.group.AiVaultSessionDropLayer.dropOntoTerminalPane', + 'Drop onto a terminal pane to resume this session.' + ) + ) + } + return wasInsideLayer + } + if (!payload) { + toast.error( + translate( + 'auto.components.tab.group.AiVaultSessionDropLayer.couldNotReadPayload', + 'Could not read the session drag payload.' + ) + ) + return true + } + + const connectionId = getConnectionId(worktreeId) + if (connectionId) { + toast.error( + translate( + 'auto.components.tab.group.AiVaultSessionDropLayer.localWorkspacesOnly', + 'Resume from history is only available in local workspaces.' + ) + ) + return true + } + if (connectionId === undefined) { + toast.error( + translate( + 'auto.components.tab.group.AiVaultSessionDropLayer.openLocalWorkspace', + 'Open a local workspace before resuming a session.' + ) + ) + return true + } + + launchAiVaultSessionInNewTab({ + agent: payload.agent, + worktreeId, + command: payload.command, + targetGroupId: dropTarget.groupId, + splitDirection: dropTarget.zone === 'center' ? undefined : dropTarget.zone + }) + toast.success( + translate( + 'auto.components.tab.group.AiVaultSessionDropLayer.sessionQueued', + 'Session queued' + ) + ) + return true + }, + [clearDragState, target, updateTarget, worktreeId] + ) + + useEffect(() => { + if (!enabled) { + clearDragState() + return + } + + const markDragActive = (): void => { + setIsDragActive(true) + } + + const markIfVaultDrag = (event: DragEvent): void => { + if (event.dataTransfer && hasAiVaultSessionDragData(event.dataTransfer)) { + markDragActive() + } + } + + const handleWindowDrop = (event: DragEvent): void => { + if (!event.dataTransfer || !hasAiVaultSessionDragData(event.dataTransfer)) { + return + } + // Electron sometimes accepts dragover on the overlay but skips React's + // delegated drop handler; capture keeps the visible target and action in sync. + if (handleSessionDrop(event.dataTransfer, { x: event.clientX, y: event.clientY })) { + event.preventDefault() + event.stopPropagation() + } + } + + window.addEventListener('dragenter', markIfVaultDrag, true) + window.addEventListener('dragover', markIfVaultDrag, true) + window.addEventListener('drop', handleWindowDrop, true) + window.addEventListener('drop', clearDragState) + window.addEventListener('dragend', clearDragState, true) + window.addEventListener(AI_VAULT_SESSION_DRAG_START_EVENT, markDragActive) + window.addEventListener(AI_VAULT_SESSION_DRAG_END_EVENT, clearDragState) + return () => { + window.removeEventListener('dragenter', markIfVaultDrag, true) + window.removeEventListener('dragover', markIfVaultDrag, true) + window.removeEventListener('drop', handleWindowDrop, true) + window.removeEventListener('drop', clearDragState) + window.removeEventListener('dragend', clearDragState, true) + window.removeEventListener(AI_VAULT_SESSION_DRAG_START_EVENT, markDragActive) + window.removeEventListener(AI_VAULT_SESSION_DRAG_END_EVENT, clearDragState) + } + }, [clearDragState, enabled, handleSessionDrop]) + + const handleDragOver = useCallback( + (event: React.DragEvent) => { + if (!hasAiVaultSessionDragData(event.dataTransfer)) { + return + } + event.preventDefault() + event.stopPropagation() + setIsDragActive(true) + const nextTarget = updateTarget(event.dataTransfer, { + x: event.clientX, + y: event.clientY + }) + event.dataTransfer.dropEffect = nextTarget ? 'copy' : 'none' + }, + [updateTarget] + ) + + const handleDrop = useCallback( + (event: React.DragEvent) => { + if (!hasAiVaultSessionDragData(event.dataTransfer)) { + return + } + event.preventDefault() + event.stopPropagation() + handleSessionDrop(event.dataTransfer, { + x: event.clientX, + y: event.clientY + }) + }, + [handleSessionDrop] + ) + + const handleDragLeave = useCallback((event: React.DragEvent) => { + const relatedTarget = event.relatedTarget + if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) { + return + } + setTarget(null) + }, []) + + return ( +