Add AI Vault session history

## Summary
- add AI Vault session scanning and resume command construction
- add the Agents sidebar panel with filtering, grouping, copy/open actions, and local resume launch
- support dragging saved sessions onto terminal split panes

## Validation
- pnpm run lint
- pnpm run typecheck
- pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/register-core-handlers.test.ts src/main/ai-vault/session-scanner.test.ts src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts src/renderer/src/lib/ai-vault-session-drag.test.ts src/renderer/src/lib/launch-ai-vault-session.test.ts
This commit is contained in:
Neil
2026-06-11 12:50:57 -07:00
committed by Jinwoo-H
parent 388d0a90b4
commit 470d46ecea
47 changed files with 5672 additions and 11 deletions
@@ -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<string, unknown>
): 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
}
@@ -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<string>()
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
}
@@ -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<SessionFileDiscovery> {
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<SessionFileDiscovery> {
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<string>
filePredicate?: (path: string) => boolean
}
): Promise<string[]> {
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
}
@@ -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<AiVaultSession | null> {
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<string, unknown>
): 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<string, unknown> | 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<AiVaultSession | null> {
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<AiVaultSession | null> {
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)
}
@@ -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<AiVaultSession | null> {
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<AiVaultSession | null> {
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<AiVaultSession | null> {
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<AiVaultSession | null> {
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<string, unknown> | 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)
}
}
@@ -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<AiVaultSession | null> {
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<AiVaultSession | null> {
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<AiVaultSession | null> {
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<void> {
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<AiVaultSession | null> {
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)
}
@@ -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<number>((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
}
@@ -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
}
+242
View File
@@ -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<string, unknown> | 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<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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(/<system-reminder>[\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 (/^<INSTRUCTIONS>/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(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!normalized) {
return null
}
if (/^# AGENTS\.md instructions for\b/i.test(normalized) || /^<INSTRUCTIONS>/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<Record<string, unknown> | 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<string, unknown>, 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'
+545
View File
@@ -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 <INSTRUCTIONS>' }
]
}
}),
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"""'
)
})
})
+306
View File
@@ -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<AiVaultListResult> {
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<SessionFileDiscovery>([
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<AiVaultSession[]> {
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<SessionParseResult> {
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<AiVaultSession | null> {
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
}
@@ -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
+2
View File
@@ -625,6 +625,8 @@ function openMainWindow(): BrowserWindow {
crashReports ?? undefined,
keybindings,
{
getAdditionalAiVaultCodexHomePaths: () =>
codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [],
onBeforeRelaunch: () => {
isQuitting = true
store?.flush()
+60
View File
@@ -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<AiVaultListResult> | null = null
let inflightKey: string | null = null
let handlerOptions: AiVaultHandlerOptions = {}
async function listAiVaultSessions(args?: AiVaultListArgs): Promise<AiVaultListResult> {
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)
)
}
+12 -1
View File
@@ -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()
+5
View File
@@ -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)
+1
View File
@@ -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'
+3 -1
View File
@@ -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(),
+6
View File
@@ -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<OpenCodeUsageSessionRow[]>
}
export type AiVaultApi = {
listSessions: (args?: AiVaultListArgs) => Promise<AiVaultListResult>
}
export type AppApi = {
/** Returns the app identity currently exposed to native chrome and the titlebar. */
getIdentity: () => Promise<AppIdentity>
@@ -1864,6 +1869,7 @@ export type PreloadApi = {
claudeUsage: ClaudeUsageApi
codexUsage: CodexUsageApi
openCodeUsage: OpenCodeUsageApi
aiVault: AiVaultApi
fs: {
readDir: (args: { dirPath: string; connectionId?: string }) => Promise<DirEntry[]>
readFile: (args: {
+6
View File
@@ -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<unknown> =>
ipcRenderer.invoke('aiVault:listSessions', args)
},
runtime: {
syncWindowGraph: (graph: RuntimeSyncWindowGraph): Promise<RuntimeSyncWindowGraphResult> =>
ipcRenderer.invoke('runtime:syncWindowGraph', graph),
+2
View File
@@ -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({
/>
<BrowserPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
<EmulatorPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isVisible} />
<AiVaultSessionDropLayer worktreeId={worktreeId} enabled={isVisible} />
</div>
)
})
@@ -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<AiVaultScope>('workspace')
const [sort, setSort] = useState<AiVaultSort>('updated')
const [group, setGroup] = useState<AiVaultGroup>('folder')
const [hideEmptySessions, setHideEmptySessions] = useState(true)
const [agents, setAgents] = useState<AiVaultAgent[]>([...AI_VAULT_AGENTS])
const [sessions, setSessions] = useState<AiVaultSession[]>([])
const [scanResult, setScanResult] = useState<AiVaultListResult | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set())
const refreshIdRef = useRef(0)
const refreshInFlightRef = useRef(false)
const mountedRef = useRef(true)
const listScrollRef = useRef<HTMLDivElement>(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<void> => {
if (refreshInFlightRef.current) {
return
}
refreshInFlightRef.current = true
const refreshId = refreshIdRef.current + 1
refreshIdRef.current = refreshId
setLoading(true)
setError(null)
try {
const result = await window.api.aiVault.listSessions({
limit: SESSION_LIMIT,
force: args.force
})
if (!mountedRef.current || refreshIdRef.current !== refreshId) {
return
}
setScanResult(result)
setSessions(result.sessions)
} catch (err) {
if (mountedRef.current && refreshIdRef.current === refreshId) {
setError(err instanceof Error ? err.message : String(err))
}
} finally {
refreshInFlightRef.current = false
if (mountedRef.current && refreshIdRef.current === refreshId) {
setLoading(false)
}
}
}, [])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
refreshIdRef.current += 1
refreshInFlightRef.current = false
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const filteredSessions = useMemo(
() =>
filterAiVaultSessions(sessions, {
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<string, Repo>()
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<void> => {
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<void> => {
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 (
<div className="flex h-full min-h-0 flex-col bg-sidebar">
<AiVaultPanelHeader
query={query}
loading={loading}
shownCount={filteredSessions.length}
sessionCount={sessions.length}
hasScanResult={Boolean(scanResult)}
activeWorktreePath={activeWorktreePath}
scope={scope}
agents={agents}
sort={sort}
group={group}
hideEmptySessions={hideEmptySessions}
adjustmentCount={viewAdjustmentCount}
onQueryChange={setQuery}
onScopeChange={setScope}
onAgentEnabledChange={setAgentEnabled}
onSortChange={setSort}
onGroupChange={setGroup}
onHideEmptySessionsChange={setHideEmptySessions}
onReset={resetViewOptions}
onRefresh={() => void refresh({ force: true })}
/>
{isRemoteWorktree ? (
<div className="border-b border-sidebar-border px-3 py-2 text-[11px] leading-4 text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultPanel.remoteBrowseLocalHistory',
'Remote workspaces can browse local history. Resume actions run from local workspaces.'
)}
</div>
) : null}
{error ? (
<div className="border-b border-sidebar-border px-3 py-2 text-xs text-destructive">
{error}
</div>
) : null}
{scanResult && scanResult.issues.length > 0 ? (
<div className="border-b border-sidebar-border px-3 py-1.5 text-[11px] text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultPanel.transcriptsSkipped',
'{{count}} transcript skipped',
{ count: scanResult.issues.length }
)}
</div>
) : null}
<div ref={listScrollRef} className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek">
{loading && sessions.length === 0 ? <SessionLoadingState /> : null}
{!loading && sessions.length === 0 && !error ? (
<EmptyState
title={translate(
'auto.components.right.sidebar.AiVaultPanel.noAgentSessionsFound',
'No agent sessions found'
)}
/>
) : null}
{sessions.length > 0 && filteredSessions.length === 0 ? (
<EmptyState
title={translate(
'auto.components.right.sidebar.AiVaultPanel.noSessionsMatchFilters',
'No sessions match the current filters'
)}
/>
) : null}
{vaultRows.length > 0 ? (
<div className="relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = vaultRows[virtualRow.index]
if (!row) {
return null
}
return (
<div
key={virtualRow.key}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
className="absolute left-0 top-0 w-full"
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
{row.type === 'group' ? (
<VaultGroupHeader
group={row.group}
collapsed={collapsedGroups.has(row.group.key)}
onToggle={() => toggleGroup(row.group.key)}
/>
) : (
<VaultSessionRow
session={row.session}
repo={sessionRepoById.get(row.session.id) ?? null}
resumeCommand={buildResumeCommand(row.session)}
resumeDisabled={!activeWorktree || isRemoteWorktree}
onResume={() => 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
}
/>
)}
</div>
)
})}
</div>
) : null}
</div>
</div>
)
}
@@ -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 (
<button
type="button"
className="flex h-7 w-full items-center gap-1.5 border-y border-sidebar-border bg-sidebar-accent/25 px-2.5 text-left text-[11px] font-semibold text-sidebar-foreground hover:bg-sidebar-accent/45"
onClick={onToggle}
>
<ChevronRight className={cn('size-3 transition-transform', !collapsed && 'rotate-90')} />
<span className="min-w-0 flex-1 truncate">{group.label}</span>
<span className="rounded bg-sidebar-accent px-1.5 py-0.5 text-[10px] leading-none text-sidebar-accent-foreground">
{group.sessions.length}
</span>
</button>
)
}
export function SessionLoadingState(): React.JSX.Element {
return (
<div className="px-3 py-3" aria-busy="true">
<div className="mb-3 flex items-center gap-2 text-[11px] text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
<span>
{translate(
'auto.components.right.sidebar.AiVaultPanelControls.scanningSessions',
'Scanning sessions'
)}
</span>
</div>
<div className="space-y-3">
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="flex items-start gap-2">
<div className="mt-1 size-4 rounded-full bg-sidebar-accent" />
<div className="min-w-0 flex-1 space-y-1.5">
<div className="h-3 w-4/5 rounded-sm bg-sidebar-accent" />
<div className="h-2.5 w-3/5 rounded-sm bg-sidebar-accent/75" />
<div className="h-2.5 w-2/5 rounded-sm bg-sidebar-accent/60" />
</div>
</div>
))}
</div>
</div>
)
}
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 (
<ToggleGroup
type="single"
value={scope}
onValueChange={(value) => {
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'
)
}
)}
>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="all"
aria-label={globalLabel}
className={VAULT_SCOPE_TOGGLE_ITEM_CLASS}
>
<Globe2 className="size-3.5" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{globalLabel}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="workspace"
disabled={!workspaceAvailable}
aria-label={workspaceLabel}
className={VAULT_SCOPE_TOGGLE_ITEM_CLASS}
>
<FolderOpen className="size-3.5" />
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{workspaceLabel}
</TooltipContent>
</Tooltip>
</ToggleGroup>
)
}
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="relative size-7 text-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
aria-label={translate(
'auto.components.right.sidebar.AiVaultPanelControls.viewOptionsAriaLabel',
'Session History view options'
)}
>
<ListFilter className="size-3.5" />
<span className="sr-only">
{translate(
'auto.components.right.sidebar.AiVaultPanelControls.viewOptions',
'View options'
)}
</span>
{adjustmentCount > 0 ? (
<span
aria-hidden
className="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] font-medium leading-none text-primary-foreground"
>
{adjustmentCount}
</span>
) : null}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6} className="w-56">
<DropdownMenuLabel>
{translate('auto.components.right.sidebar.AiVaultPanelControls.agents', 'Agents')}
</DropdownMenuLabel>
{AI_VAULT_AGENTS.map((agent) => (
<DropdownMenuCheckboxItem
key={agent}
checked={agents.includes(agent)}
disabled={agents.length === 1 && agents.includes(agent)}
onCheckedChange={(checked) => onAgentEnabledChange(agent, checked === true)}
onSelect={(event) => event.preventDefault()}
>
<AgentIcon agent={agent} size={14} />
{agentLabel(agent)}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>
{translate('auto.components.right.sidebar.AiVaultPanelControls.sort', 'Sort')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={sort}
onValueChange={(value) => onSortChange(value as AiVaultSort)}
>
<DropdownMenuRadioItem value="updated">
<Clock3 className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultPanelControls.lastUpdated',
'Last updated'
)}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="created">
<Calendar className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultPanelControls.created', 'Created')}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>
{translate('auto.components.right.sidebar.AiVaultPanelControls.group', 'Group')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={group}
onValueChange={(value) => onGroupChange(value as AiVaultGroup)}
>
<DropdownMenuRadioItem value="folder">
<FolderOpen className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultPanelControls.folder', 'Folder')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="agent">
<ArchiveRestore className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultPanelControls.agent', 'Agent')}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={hideEmptySessions}
onCheckedChange={(checked) => onHideEmptySessionsChange(checked === true)}
onSelect={(event) => event.preventDefault()}
>
{translate(
'auto.components.right.sidebar.AiVaultPanelControls.hideEmptySessions',
'Hide empty sessions'
)}
</DropdownMenuCheckboxItem>
{adjustmentCount > 0 ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onReset}>
{translate(
'auto.components.right.sidebar.AiVaultPanelControls.resetView',
'Reset view'
)}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
)
}
export function EmptyState({ title }: { title: string }): React.JSX.Element {
return (
<div className="flex h-full flex-col items-center justify-center px-4 text-center text-muted-foreground">
<ArchiveRestore className="mb-3 size-7 opacity-50" />
<p className="text-sm font-medium">{title}</p>
</div>
)
}
@@ -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 (
<div className="shrink-0 border-b border-sidebar-border px-2.5 py-2">
<div className="flex items-center gap-1.5">
<Grid2x2 className="size-4 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-foreground">
{translate(
'auto.components.right.sidebar.AiVaultPanel.sessionHistory',
'Agent Session History'
)}
</div>
<div className="truncate text-[11px] text-muted-foreground">
{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'
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<VaultScopeSwitch
scope={scope}
workspaceAvailable={Boolean(activeWorktreePath)}
onScopeChange={onScopeChange}
/>
<VaultViewMenu
agents={agents}
sort={sort}
group={group}
hideEmptySessions={hideEmptySessions}
adjustmentCount={adjustmentCount}
onAgentEnabledChange={onAgentEnabledChange}
onSortChange={onSortChange}
onGroupChange={onGroupChange}
onHideEmptySessionsChange={onHideEmptySessionsChange}
onReset={onReset}
/>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.right.sidebar.AiVaultPanel.refreshSessionHistory',
'Refresh Session History'
)}
onClick={onRefresh}
disabled={loading}
aria-busy={loading}
className="size-7"
>
{loading ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
</Button>
</div>
</div>
<div className="mt-2 flex h-8 items-center gap-1.5 rounded-md border border-sidebar-border bg-input/50 px-2 focus-within:border-sidebar-ring focus-within:ring-[2px] focus-within:ring-sidebar-ring/30">
<Search className="size-3.5 shrink-0 text-muted-foreground" />
<input
value={query}
onChange={(event) => 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 ? <LoaderCircle className="size-3 animate-spin text-muted-foreground" /> : null}
{query ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-5 rounded-sm text-muted-foreground hover:text-foreground"
onClick={() => onQueryChange('')}
aria-label={translate(
'auto.components.right.sidebar.AiVaultPanel.clearSearch',
'Clear search'
)}
>
<X className="size-3" />
</Button>
) : null}
</div>
</div>
)
}
@@ -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 (
<HoverCardContent
side="left"
align="start"
sideOffset={8}
className="scrollbar-sleek max-h-[min(28rem,calc(100vh-2rem))] w-80 overflow-y-auto p-3"
>
<div className="min-w-0">
<div className="flex items-start gap-2">
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={session.agent} size={16} />
</span>
<div className="min-w-0 flex-1">
<div className="line-clamp-2 text-[13px] font-medium leading-5 text-popover-foreground">
{session.title}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5">
<span className="text-[11px] text-muted-foreground">{agentLabel(session.agent)}</span>
{repo ? (
<>
<MetaDot />
<RepoBadge repo={repo} />
</>
) : null}
</div>
</div>
</div>
<div className="mt-3 space-y-1.5 text-[11px] leading-4">
<DetailLine
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.updated',
'Updated'
)}
value={formatDateTime(updatedAt)}
/>
<DetailLine
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.created',
'Created'
)}
value={formatDateTime(session.createdAt)}
/>
<DetailLine
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.workingDir',
'Working dir'
)}
value={
session.cwd ??
translate(
'auto.components.right.sidebar.AiVaultSessionDetails.unknownLocation',
'Unknown location'
)
}
mono
/>
{session.branch ? (
<DetailLine
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.branch',
'Branch'
)}
value={session.branch}
/>
) : null}
{session.model ? (
<DetailLine
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.model',
'Model'
)}
value={session.model}
/>
) : null}
<DetailLine
label={translate('auto.components.right.sidebar.AiVaultSessionDetails.usage', 'Usage')}
value={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.usageValue',
'{{value0}} msgs{{value1}}',
{
value0: session.messageCount,
value1:
session.totalTokens > 0
? translate(
'auto.components.right.sidebar.AiVaultSessionDetails.tokenSuffix',
' · {{value0}} tok',
{ value0: formatTokenCount(session.totalTokens) }
)
: ''
}
)}
/>
<DetailLine
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.session',
'Session'
)}
value={session.sessionId}
mono
/>
</div>
<div className="mt-3 border-t border-border/60 pt-2">
<div className="mb-1.5 text-[11px] font-semibold text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.latestLog',
'Latest log'
)}
</div>
{session.previewMessages.length > 0 ? (
<div className="space-y-2">
{session.previewMessages.map((message, index) => (
<div key={`${message.role}:${index}`} className="min-w-0">
<div className="mb-0.5 text-[10px] font-semibold uppercase text-muted-foreground">
{previewRoleLabel(message.role)}
</div>
<div className="line-clamp-3 whitespace-pre-wrap text-[11px] leading-4 text-popover-foreground/90">
{message.text}
</div>
</div>
))}
</div>
) : (
<div className="text-[11px] leading-4 text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.noReadablePreview',
'No readable message preview in this transcript.'
)}
</div>
)}
</div>
<div className="mt-3 border-t border-border/60 pt-2">
<div className="mb-1 text-[11px] font-semibold text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.resumeCommand',
'Resume command'
)}
</div>
<div className="line-clamp-3 break-all font-mono text-[10.5px] leading-4 text-popover-foreground/85">
{resumeCommand}
</div>
</div>
</div>
</HoverCardContent>
)
}
function DetailLine({
label,
value,
mono = false
}: {
label: string
value: string
mono?: boolean
}): React.JSX.Element {
return (
<div className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-2">
<span className="text-muted-foreground">{label}</span>
<span className={cn('min-w-0 truncate text-popover-foreground/90', mono && 'font-mono')}>
{value}
</span>
</div>
)
}
export function RepoBadge({ repo }: { repo: Repo }): React.JSX.Element {
return (
<span className="flex h-[16px] max-w-[7rem] shrink-0 items-center gap-1.5 rounded-[4px] border border-border bg-accent px-1.5 leading-none dark:border-border/60 dark:bg-accent/50">
<span
className="size-1.5 shrink-0 rounded-full"
style={{ backgroundColor: repo.badgeColor }}
/>
<span className="min-w-0 truncate text-[10px] font-semibold lowercase text-foreground">
{repo.displayName}
</span>
</span>
)
}
export function MetaDot(): React.JSX.Element {
return <span className="shrink-0 text-muted-foreground/45">·</span>
}
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.sessionActions',
'{{value0}} session actions',
{ value0: agentLabel(session.agent) }
)}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={resumeDisabled} onSelect={onResume}>
<Play className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.resumeInNewTab',
'Resume in New Tab'
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCopyResume}>
<Copy className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.copyResumeCommand',
'Copy Resume Command'
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onOpenLog}>
<FileJson className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionDetails.openLog', 'Open Log')}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onRevealLog}>
<FolderOpen className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionDetails.revealLog', 'Reveal Log')}
</DropdownMenuItem>
{onOpenCwd ? (
<DropdownMenuItem onSelect={onOpenCwd}>
<FolderOpen className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.openWorkingDirectory',
'Open Working Directory'
)}
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onCopyId}>
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.copySessionId',
'Copy Session ID'
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCopyPath}>
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.copyLogPath',
'Copy Log Path'
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function SessionTime({
value,
className
}: {
value: string
className?: string
}): React.JSX.Element {
const timestamp = Date.parse(value)
if (!Number.isFinite(timestamp)) {
return (
<span className={cn('shrink-0 text-[11px] text-muted-foreground', className)}>
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.unknownTime',
'Unknown time'
)}
</span>
)
}
const date = new Date(timestamp)
return (
<span className={cn('shrink-0 text-[11px] text-muted-foreground', className)}>
<time dateTime={date.toISOString()}>{formatTimeAgo(timestamp)}</time>
</span>
)
}
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)
}
@@ -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 (
<HoverCard openDelay={350} closeDelay={80}>
<ContextMenu>
<HoverCardTrigger asChild>
<ContextMenuTrigger asChild>
<div
draggable={!resumeDisabled}
className={cn(
'group relative flex min-h-[64px] w-full items-start border-b border-sidebar-border px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/55',
!resumeDisabled && 'cursor-grab active:cursor-grabbing'
)}
onDragStart={(event) => {
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()
}
}}
>
<div className="min-w-0 flex-1 pr-16">
<div className="truncate text-[13px] font-medium leading-5 text-foreground">
{session.title}
</div>
<SessionMetadata session={session} repo={repo} />
<div className="mt-1 truncate font-mono text-[11px] leading-4 text-muted-foreground/75">
{folderLabel(session.cwd)}
</div>
</div>
<SessionTime
value={updatedAt}
className="absolute right-3 top-2 transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
/>
<div className="pointer-events-none absolute right-2 top-1.5 flex items-center gap-1 rounded-md bg-sidebar/95 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeAgentSession',
'Resume {{value0}} session',
{ value0: agentLabel(session.agent) }
)}
disabled={resumeDisabled}
onClick={(event) => {
event.stopPropagation()
onResume()
}}
>
<Play className="size-3.5" />
</Button>
<SessionActionsMenu
session={session}
onResume={onResume}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
onCopyPath={onCopyPath}
onOpenLog={onOpenLog}
onRevealLog={onRevealLog}
onOpenCwd={onOpenCwd}
resumeDisabled={resumeDisabled}
/>
</div>
</div>
</ContextMenuTrigger>
</HoverCardTrigger>
<ContextMenuContent>
<ContextMenuItem disabled={resumeDisabled} onSelect={onResume}>
<Play className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
'Resume in New Tab'
)}
</ContextMenuItem>
<ContextMenuItem onSelect={onCopyResume}>
<Copy className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand',
'Copy Resume Command'
)}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={onOpenLog}>
<FileJson className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionRow.openLog', 'Open Log')}
</ContextMenuItem>
<ContextMenuItem onSelect={onRevealLog}>
<FolderOpen className="size-3.5" />
{translate('auto.components.right.sidebar.AiVaultSessionRow.revealLog', 'Reveal Log')}
</ContextMenuItem>
{onOpenCwd ? (
<ContextMenuItem onSelect={onOpenCwd}>
<FolderOpen className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.openWorkingDirectory',
'Open Working Directory'
)}
</ContextMenuItem>
) : null}
<ContextMenuSeparator />
<ContextMenuItem onSelect={onCopyId}>
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.copySessionId',
'Copy Session ID'
)}
</ContextMenuItem>
<ContextMenuItem onSelect={onCopyPath}>
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.copyLogPath',
'Copy Log Path'
)}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<SessionDetailsHoverCard session={session} repo={repo} resumeCommand={resumeCommand} />
</HoverCard>
)
}
function SessionMetadata({
session,
repo
}: {
session: AiVaultSession
repo: Repo | null
}): React.JSX.Element {
return (
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11px] leading-4 text-muted-foreground">
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
<AgentIcon agent={session.agent} size={14} />
</span>
{repo ? (
<RepoBadge repo={repo} />
) : (
<span className="max-w-[7rem] truncate font-mono text-[10.5px] text-muted-foreground/85">
{folderLabel(session.cwd)}
</span>
)}
{session.model ? (
<>
<MetaDot />
<span className="max-w-[92px] truncate">{session.model}</span>
</>
) : null}
{session.branch ? (
<>
<MetaDot />
<span className="min-w-0 truncate text-muted-foreground/85">{session.branch}</span>
</>
) : null}
<MetaDot />
<span className="shrink-0">
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.messageCount',
'{{value0}} msgs',
{ value0: session.messageCount }
)}
</span>
{session.totalTokens > 0 ? (
<>
<MetaDot />
<span className="shrink-0">
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.tokenCount',
'{{value0}} tok',
{ value0: formatTokenCount(session.totalTokens) }
)}
</span>
</>
) : null}
</div>
)
}
@@ -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')
})
})
@@ -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<string, AiVaultSessionGroup>()
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
}
@@ -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<string, readonly { path: string }[] | undefined>
): 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
}
@@ -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,
@@ -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' && (
<PortsPanel isVisible={rightSidebarOpen && effectiveTab === 'ports'} />
)}
{effectiveTab === 'vault' && <AiVaultPanel />}
</Suspense>
</div>
)
@@ -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<HTMLElement>('[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<PaneDropTarget | null>(null)
const layerRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
const relatedTarget = event.relatedTarget
if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) {
return
}
setTarget(null)
}, [])
return (
<div
ref={layerRef}
aria-hidden="true"
data-ai-vault-session-drop-layer="true"
data-worktree-id={worktreeId}
className={`absolute inset-0 z-[10000] ${
isDragActive ? 'pointer-events-auto' : 'pointer-events-none'
}`}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragLeave={handleDragLeave}
>
{isDragActive && target ? (
<div className="tab-drop-overlay absolute" style={target.overlayStyle} />
) : null}
</div>
)
}
@@ -366,8 +366,9 @@ export default function TabGroupPanel({
<div
ref={setBodyDropRef}
className="relative flex-1 min-h-0 overflow-hidden"
data-tab-group-body-id={groupId}
data-worktree-id={worktreeId}
className="relative flex-1 min-h-0 overflow-hidden"
style={bodyAnchorStyle}
>
{/* Why: this empty anchor lets the agent-sessions tour read as a
@@ -145,7 +145,7 @@ function getDragCenter(
}
}
function resolveDropZone(
export function resolveDropZone(
rect: { left: number; top: number; width: number; height: number },
point: { x: number; y: number }
): TabDropZone {
+100 -1
View File
@@ -2254,6 +2254,13 @@
"1bce81dba6": "simulator",
"1ff1c77616": "browser",
"586d2ac445": "terminal"
},
"AiVaultSessionDropLayer": {
"dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.",
"couldNotReadPayload": "Could not read the session drag payload.",
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"openLocalWorkspace": "Open a local workspace before resuming a session.",
"sessionQueued": "Session queued"
}
},
"bar": {
@@ -8110,7 +8117,8 @@
"9f83375839": "checks",
"6306b48afd": "source-control",
"ef182dcb12": "search",
"fc3095d2ed": "explorer"
"fc3095d2ed": "explorer",
"aiVaultSessionHistory": "Agents"
},
"right": {
"panel": {
@@ -8244,6 +8252,97 @@
}
}
}
},
"AiVaultPanel": {
"resumeCommandCopied": "Resume command copied",
"valueCopied": "{{value0}} copied",
"openWorkspaceBeforeResuming": "Open a workspace before resuming a session.",
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"agentSessionQueued": "{{value0}} session queued",
"sessionHistory": "Agent Session History",
"shownRecent": "{{value0}} shown · {{value1}} recent",
"resumePastSessions": "Resume past sessions",
"refreshSessionHistory": "Refresh Session History",
"searchSessions": "Search sessions",
"clearSearch": "Clear search",
"remoteBrowseLocalHistory": "Remote workspaces can browse local history. Resume actions run from local workspaces.",
"transcriptsSkipped": "{{count}} transcript skipped",
"noAgentSessionsFound": "No agent sessions found",
"noSessionsMatchFilters": "No sessions match the current filters",
"sessionId": "Session ID",
"logPath": "Log path"
},
"AiVaultPanelControls": {
"scanningSessions": "Scanning sessions",
"scopeAriaLabel": "Session History scope: {{value0}}",
"currentWorkspaceLower": "current workspace",
"allSessionsLower": "all sessions",
"thisScope": "This",
"allScope": "All",
"scope": "Scope",
"currentWorkspace": "Current workspace",
"allSessions": "All sessions",
"viewOptionsAriaLabel": "Session History view options",
"viewOptions": "View options",
"agents": "Agents",
"sort": "Sort",
"lastUpdated": "Last updated",
"created": "Created",
"group": "Group",
"folder": "Folder",
"agent": "Agent",
"resetView": "Reset view",
"hideEmptySessions": "Hide empty sessions",
"workspaceScope": "Workspace",
"globalScope": "Global"
},
"AiVaultSessionDetails": {
"updated": "Updated",
"created": "Created",
"workingDir": "Working dir",
"unknownLocation": "Unknown location",
"branch": "Branch",
"model": "Model",
"usage": "Usage",
"usageValue": "{{value0}} msgs{{value1}}",
"tokenSuffix": " · {{value0}} tok",
"session": "Session",
"latestLog": "Latest log",
"noReadablePreview": "No readable message preview in this transcript.",
"resumeCommand": "Resume command",
"sessionActions": "{{value0}} session actions",
"resumeInNewTab": "Resume in New Tab",
"copyResumeCommand": "Copy Resume Command",
"openLog": "Open Log",
"revealLog": "Reveal Log",
"openWorkingDirectory": "Open Working Directory",
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"unknownTime": "Unknown time",
"unknown": "Unknown",
"user": "User",
"assistant": "Assistant",
"tool": "Tool",
"system": "System",
"log": "Log",
"justNow": "Just now",
"minutesAgo": "{{value0}}m ago",
"hoursAgo": "{{value0}}h ago",
"daysAgo": "{{value0}}d ago",
"monthsAgo": "{{value0}}mo ago",
"yearsAgo": "{{value0}}y ago"
},
"AiVaultSessionRow": {
"resumeAgentSession": "Resume {{value0}} session",
"resumeInNewTab": "Resume in New Tab",
"copyResumeCommand": "Copy Resume Command",
"openLog": "Open Log",
"revealLog": "Reveal Log",
"openWorkingDirectory": "Open Working Directory",
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"messageCount": "{{value0}} msgs",
"tokenCount": "{{value0}} tok"
}
}
},
+100 -1
View File
@@ -2240,6 +2240,13 @@
"1bce81dba6": "simulador",
"1ff1c77616": "navegador",
"586d2ac445": "terminal"
},
"AiVaultSessionDropLayer": {
"dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.",
"couldNotReadPayload": "Could not read the session drag payload.",
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"openLocalWorkspace": "Open a local workspace before resuming a session.",
"sessionQueued": "Session queued"
}
},
"bar": {
@@ -7983,7 +7990,8 @@
"9f83375839": "cheques",
"6306b48afd": "control de fuente",
"ef182dcb12": "buscar",
"fc3095d2ed": "explorador"
"fc3095d2ed": "explorador",
"aiVaultSessionHistory": "Agents"
},
"right": {
"panel": {
@@ -8117,6 +8125,97 @@
}
}
}
},
"AiVaultPanel": {
"resumeCommandCopied": "Resume command copied",
"valueCopied": "{{value0}} copied",
"openWorkspaceBeforeResuming": "Open a workspace before resuming a session.",
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"agentSessionQueued": "{{value0}} session queued",
"sessionHistory": "Agent Session History",
"shownRecent": "{{value0}} shown · {{value1}} recent",
"resumePastSessions": "Resume past sessions",
"refreshSessionHistory": "Refresh Session History",
"searchSessions": "Search sessions",
"clearSearch": "Clear search",
"remoteBrowseLocalHistory": "Remote workspaces can browse local history. Resume actions run from local workspaces.",
"transcriptsSkipped": "{{count}} transcript skipped",
"noAgentSessionsFound": "No agent sessions found",
"noSessionsMatchFilters": "No sessions match the current filters",
"sessionId": "Session ID",
"logPath": "Log path"
},
"AiVaultPanelControls": {
"scanningSessions": "Scanning sessions",
"scopeAriaLabel": "Session History scope: {{value0}}",
"currentWorkspaceLower": "current workspace",
"allSessionsLower": "all sessions",
"thisScope": "This",
"allScope": "All",
"scope": "Scope",
"currentWorkspace": "Current workspace",
"allSessions": "All sessions",
"viewOptionsAriaLabel": "Session History view options",
"viewOptions": "View options",
"agents": "Agents",
"sort": "Sort",
"lastUpdated": "Last updated",
"created": "Created",
"group": "Group",
"folder": "Folder",
"agent": "Agent",
"resetView": "Reset view",
"hideEmptySessions": "Hide empty sessions",
"workspaceScope": "Workspace",
"globalScope": "Global"
},
"AiVaultSessionDetails": {
"updated": "Updated",
"created": "Created",
"workingDir": "Working dir",
"unknownLocation": "Unknown location",
"branch": "Branch",
"model": "Model",
"usage": "Usage",
"usageValue": "{{value0}} msgs{{value1}}",
"tokenSuffix": " · {{value0}} tok",
"session": "Session",
"latestLog": "Latest log",
"noReadablePreview": "No readable message preview in this transcript.",
"resumeCommand": "Resume command",
"sessionActions": "{{value0}} session actions",
"resumeInNewTab": "Resume in New Tab",
"copyResumeCommand": "Copy Resume Command",
"openLog": "Open Log",
"revealLog": "Reveal Log",
"openWorkingDirectory": "Open Working Directory",
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"unknownTime": "Unknown time",
"unknown": "Unknown",
"user": "User",
"assistant": "Assistant",
"tool": "Tool",
"system": "System",
"log": "Log",
"justNow": "Just now",
"minutesAgo": "{{value0}}m ago",
"hoursAgo": "{{value0}}h ago",
"daysAgo": "{{value0}}d ago",
"monthsAgo": "{{value0}}mo ago",
"yearsAgo": "{{value0}}y ago"
},
"AiVaultSessionRow": {
"resumeAgentSession": "Resume {{value0}} session",
"resumeInNewTab": "Resume in New Tab",
"copyResumeCommand": "Copy Resume Command",
"openLog": "Open Log",
"revealLog": "Reveal Log",
"openWorkingDirectory": "Open Working Directory",
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"messageCount": "{{value0}} msgs",
"tokenCount": "{{value0}} tok"
}
}
},
+100 -1
View File
@@ -2254,6 +2254,13 @@
"1bce81dba6": "シミュレータ",
"1ff1c77616": "ブラウザ",
"586d2ac445": "terminal"
},
"AiVaultSessionDropLayer": {
"dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.",
"couldNotReadPayload": "Could not read the session drag payload.",
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"openLocalWorkspace": "Open a local workspace before resuming a session.",
"sessionQueued": "Session queued"
}
},
"bar": {
@@ -8091,7 +8098,8 @@
"9f83375839": "チェック",
"6306b48afd": "ソース管理",
"ef182dcb12": "検索",
"fc3095d2ed": "エクスプローラ"
"fc3095d2ed": "エクスプローラ",
"aiVaultSessionHistory": "Agents"
},
"right": {
"panel": {
@@ -8225,6 +8233,97 @@
}
}
}
},
"AiVaultPanel": {
"resumeCommandCopied": "Resume command copied",
"valueCopied": "{{value0}} copied",
"openWorkspaceBeforeResuming": "Open a workspace before resuming a session.",
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"agentSessionQueued": "{{value0}} session queued",
"sessionHistory": "Agent Session History",
"shownRecent": "{{value0}} shown · {{value1}} recent",
"resumePastSessions": "Resume past sessions",
"refreshSessionHistory": "Refresh Session History",
"searchSessions": "Search sessions",
"clearSearch": "Clear search",
"remoteBrowseLocalHistory": "Remote workspaces can browse local history. Resume actions run from local workspaces.",
"transcriptsSkipped": "{{count}} transcript skipped",
"noAgentSessionsFound": "No agent sessions found",
"noSessionsMatchFilters": "No sessions match the current filters",
"sessionId": "Session ID",
"logPath": "Log path"
},
"AiVaultPanelControls": {
"scanningSessions": "Scanning sessions",
"scopeAriaLabel": "Session History scope: {{value0}}",
"currentWorkspaceLower": "current workspace",
"allSessionsLower": "all sessions",
"thisScope": "This",
"allScope": "All",
"scope": "Scope",
"currentWorkspace": "Current workspace",
"allSessions": "All sessions",
"viewOptionsAriaLabel": "Session History view options",
"viewOptions": "View options",
"agents": "Agents",
"sort": "Sort",
"lastUpdated": "Last updated",
"created": "Created",
"group": "Group",
"folder": "Folder",
"agent": "Agent",
"resetView": "Reset view",
"hideEmptySessions": "Hide empty sessions",
"workspaceScope": "Workspace",
"globalScope": "Global"
},
"AiVaultSessionDetails": {
"updated": "Updated",
"created": "Created",
"workingDir": "Working dir",
"unknownLocation": "Unknown location",
"branch": "Branch",
"model": "Model",
"usage": "Usage",
"usageValue": "{{value0}} msgs{{value1}}",
"tokenSuffix": " · {{value0}} tok",
"session": "Session",
"latestLog": "Latest log",
"noReadablePreview": "No readable message preview in this transcript.",
"resumeCommand": "Resume command",
"sessionActions": "{{value0}} session actions",
"resumeInNewTab": "Resume in New Tab",
"copyResumeCommand": "Copy Resume Command",
"openLog": "Open Log",
"revealLog": "Reveal Log",
"openWorkingDirectory": "Open Working Directory",
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"unknownTime": "Unknown time",
"unknown": "Unknown",
"user": "User",
"assistant": "Assistant",
"tool": "Tool",
"system": "System",
"log": "Log",
"justNow": "Just now",
"minutesAgo": "{{value0}}m ago",
"hoursAgo": "{{value0}}h ago",
"daysAgo": "{{value0}}d ago",
"monthsAgo": "{{value0}}mo ago",
"yearsAgo": "{{value0}}y ago"
},
"AiVaultSessionRow": {
"resumeAgentSession": "Resume {{value0}} session",
"resumeInNewTab": "Resume in New Tab",
"copyResumeCommand": "Copy Resume Command",
"openLog": "Open Log",
"revealLog": "Reveal Log",
"openWorkingDirectory": "Open Working Directory",
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"messageCount": "{{value0}} msgs",
"tokenCount": "{{value0}} tok"
}
}
},
+100 -1
View File
@@ -2254,6 +2254,13 @@
"1bce81dba6": "시뮬레이터",
"1ff1c77616": "브라우저",
"586d2ac445": "terminal"
},
"AiVaultSessionDropLayer": {
"dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.",
"couldNotReadPayload": "Could not read the session drag payload.",
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"openLocalWorkspace": "Open a local workspace before resuming a session.",
"sessionQueued": "Session queued"
}
},
"bar": {
@@ -8091,7 +8098,8 @@
"9f83375839": "검사",
"6306b48afd": "소스 제어",
"ef182dcb12": "검색",
"fc3095d2ed": "탐침"
"fc3095d2ed": "탐침",
"aiVaultSessionHistory": "Agents"
},
"right": {
"panel": {
@@ -8225,6 +8233,97 @@
}
}
}
},
"AiVaultPanel": {
"resumeCommandCopied": "Resume command copied",
"valueCopied": "{{value0}} copied",
"openWorkspaceBeforeResuming": "Open a workspace before resuming a session.",
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"agentSessionQueued": "{{value0}} session queued",
"sessionHistory": "Agent Session History",
"shownRecent": "{{value0}} shown · {{value1}} recent",
"resumePastSessions": "Resume past sessions",
"refreshSessionHistory": "Refresh Session History",
"searchSessions": "Search sessions",
"clearSearch": "Clear search",
"remoteBrowseLocalHistory": "Remote workspaces can browse local history. Resume actions run from local workspaces.",
"transcriptsSkipped": "{{count}} transcript skipped",
"noAgentSessionsFound": "No agent sessions found",
"noSessionsMatchFilters": "No sessions match the current filters",
"sessionId": "Session ID",
"logPath": "Log path"
},
"AiVaultPanelControls": {
"scanningSessions": "Scanning sessions",
"scopeAriaLabel": "Session History scope: {{value0}}",
"currentWorkspaceLower": "current workspace",
"allSessionsLower": "all sessions",
"thisScope": "This",
"allScope": "All",
"scope": "Scope",
"currentWorkspace": "Current workspace",
"allSessions": "All sessions",
"viewOptionsAriaLabel": "Session History view options",
"viewOptions": "View options",
"agents": "Agents",
"sort": "Sort",
"lastUpdated": "Last updated",
"created": "Created",
"group": "Group",
"folder": "Folder",
"agent": "Agent",
"resetView": "Reset view",
"hideEmptySessions": "Hide empty sessions",
"workspaceScope": "Workspace",
"globalScope": "Global"
},
"AiVaultSessionDetails": {
"updated": "Updated",
"created": "Created",
"workingDir": "Working dir",
"unknownLocation": "Unknown location",
"branch": "Branch",
"model": "Model",
"usage": "Usage",
"usageValue": "{{value0}} msgs{{value1}}",
"tokenSuffix": " · {{value0}} tok",
"session": "Session",
"latestLog": "Latest log",
"noReadablePreview": "No readable message preview in this transcript.",
"resumeCommand": "Resume command",
"sessionActions": "{{value0}} session actions",
"resumeInNewTab": "Resume in New Tab",
"copyResumeCommand": "Copy Resume Command",
"openLog": "Open Log",
"revealLog": "Reveal Log",
"openWorkingDirectory": "Open Working Directory",
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"unknownTime": "Unknown time",
"unknown": "Unknown",
"user": "User",
"assistant": "Assistant",
"tool": "Tool",
"system": "System",
"log": "Log",
"justNow": "Just now",
"minutesAgo": "{{value0}}m ago",
"hoursAgo": "{{value0}}h ago",
"daysAgo": "{{value0}}d ago",
"monthsAgo": "{{value0}}mo ago",
"yearsAgo": "{{value0}}y ago"
},
"AiVaultSessionRow": {
"resumeAgentSession": "Resume {{value0}} session",
"resumeInNewTab": "Resume in New Tab",
"copyResumeCommand": "Copy Resume Command",
"openLog": "Open Log",
"revealLog": "Reveal Log",
"openWorkingDirectory": "Open Working Directory",
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"messageCount": "{{value0}} msgs",
"tokenCount": "{{value0}} tok"
}
}
},
+100 -1
View File
@@ -2254,6 +2254,13 @@
"1bce81dba6": "模拟器",
"1ff1c77616": "浏览器",
"586d2ac445": "terminal"
},
"AiVaultSessionDropLayer": {
"dropOntoTerminalPane": "Drop onto a terminal pane to resume this session.",
"couldNotReadPayload": "Could not read the session drag payload.",
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"openLocalWorkspace": "Open a local workspace before resuming a session.",
"sessionQueued": "Session queued"
}
},
"bar": {
@@ -8091,7 +8098,8 @@
"9f83375839": "检查",
"6306b48afd": "源代码控制",
"ef182dcb12": "搜索",
"fc3095d2ed": "探险家"
"fc3095d2ed": "探险家",
"aiVaultSessionHistory": "Agents"
},
"right": {
"panel": {
@@ -8225,6 +8233,97 @@
}
}
}
},
"AiVaultPanel": {
"resumeCommandCopied": "Resume command copied",
"valueCopied": "{{value0}} copied",
"openWorkspaceBeforeResuming": "Open a workspace before resuming a session.",
"localWorkspacesOnly": "Resume from history is only available in local workspaces.",
"agentSessionQueued": "{{value0}} session queued",
"sessionHistory": "Agent Session History",
"shownRecent": "{{value0}} shown · {{value1}} recent",
"resumePastSessions": "Resume past sessions",
"refreshSessionHistory": "Refresh Session History",
"searchSessions": "Search sessions",
"clearSearch": "Clear search",
"remoteBrowseLocalHistory": "Remote workspaces can browse local history. Resume actions run from local workspaces.",
"transcriptsSkipped": "{{count}} transcript skipped",
"noAgentSessionsFound": "No agent sessions found",
"noSessionsMatchFilters": "No sessions match the current filters",
"sessionId": "Session ID",
"logPath": "Log path"
},
"AiVaultPanelControls": {
"scanningSessions": "Scanning sessions",
"scopeAriaLabel": "Session History scope: {{value0}}",
"currentWorkspaceLower": "current workspace",
"allSessionsLower": "all sessions",
"thisScope": "This",
"allScope": "All",
"scope": "Scope",
"currentWorkspace": "Current workspace",
"allSessions": "All sessions",
"viewOptionsAriaLabel": "Session History view options",
"viewOptions": "View options",
"agents": "Agents",
"sort": "Sort",
"lastUpdated": "Last updated",
"created": "Created",
"group": "Group",
"folder": "Folder",
"agent": "Agent",
"resetView": "Reset view",
"hideEmptySessions": "Hide empty sessions",
"workspaceScope": "Workspace",
"globalScope": "Global"
},
"AiVaultSessionDetails": {
"updated": "Updated",
"created": "Created",
"workingDir": "Working dir",
"unknownLocation": "Unknown location",
"branch": "Branch",
"model": "Model",
"usage": "Usage",
"usageValue": "{{value0}} msgs{{value1}}",
"tokenSuffix": " · {{value0}} tok",
"session": "Session",
"latestLog": "Latest log",
"noReadablePreview": "No readable message preview in this transcript.",
"resumeCommand": "Resume command",
"sessionActions": "{{value0}} session actions",
"resumeInNewTab": "Resume in New Tab",
"copyResumeCommand": "Copy Resume Command",
"openLog": "Open Log",
"revealLog": "Reveal Log",
"openWorkingDirectory": "Open Working Directory",
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"unknownTime": "Unknown time",
"unknown": "Unknown",
"user": "User",
"assistant": "Assistant",
"tool": "Tool",
"system": "System",
"log": "Log",
"justNow": "Just now",
"minutesAgo": "{{value0}}m ago",
"hoursAgo": "{{value0}}h ago",
"daysAgo": "{{value0}}d ago",
"monthsAgo": "{{value0}}mo ago",
"yearsAgo": "{{value0}}y ago"
},
"AiVaultSessionRow": {
"resumeAgentSession": "Resume {{value0}} session",
"resumeInNewTab": "Resume in New Tab",
"copyResumeCommand": "Copy Resume Command",
"openLog": "Open Log",
"revealLog": "Reveal Log",
"openWorkingDirectory": "Open Working Directory",
"copySessionId": "Copy Session ID",
"copyLogPath": "Copy Log Path",
"messageCount": "{{value0}} msgs",
"tokenCount": "{{value0}} tok"
}
}
},
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
AI_VAULT_SESSION_DRAG_TYPE,
clearAiVaultSessionDragData,
hasAiVaultSessionDragData,
readAiVaultSessionDragData,
writeAiVaultSessionDragData,
type AiVaultSessionDragPayload
} from './ai-vault-session-drag'
class FakeDataTransfer {
effectAllowed = 'all'
types: string[] = []
private readonly data = new Map<string, string>()
setData(type: string, value: string): void {
if (!this.types.includes(type)) {
this.types.push(type)
}
this.data.set(type, value)
}
getData(type: string): string {
return this.data.get(type) ?? ''
}
}
class TypeOnlyDataTransfer extends FakeDataTransfer {
override getData(_type: string): string {
return ''
}
}
function createTransfer(): DataTransfer {
return new FakeDataTransfer() as unknown as DataTransfer
}
describe('Session History session drag data', () => {
afterEach(() => {
clearAiVaultSessionDragData()
})
it('writes and reads the private session history payload', () => {
const transfer = createTransfer()
const payload: AiVaultSessionDragPayload = {
agent: 'claude',
sessionId: 'session-1',
title: 'Fix terminal split',
command: "cd '/repo' && claude --resume session-1"
}
writeAiVaultSessionDragData(transfer, payload)
expect(transfer.effectAllowed).toBe('copy')
expect(hasAiVaultSessionDragData(transfer)).toBe(true)
expect(readAiVaultSessionDragData(transfer)).toEqual(payload)
})
it('rejects malformed payloads', () => {
const transfer = createTransfer()
transfer.setData(
AI_VAULT_SESSION_DRAG_TYPE,
JSON.stringify({ kind: 'ai-vault-session', version: 1, agent: 'bad', command: 'claude' })
)
expect(readAiVaultSessionDragData(transfer)).toBeNull()
})
it('falls back to the active renderer drag payload when Chromium hides custom data', () => {
const source = createTransfer()
const payload: AiVaultSessionDragPayload = {
agent: 'codex',
sessionId: 'session-2',
title: 'Resume a hidden payload',
command: "cd '/repo' && codex resume session-2"
}
writeAiVaultSessionDragData(source, payload)
const dropTransfer = new TypeOnlyDataTransfer() as unknown as DataTransfer
dropTransfer.setData(AI_VAULT_SESSION_DRAG_TYPE, '')
expect(hasAiVaultSessionDragData(dropTransfer)).toBe(true)
expect(readAiVaultSessionDragData(dropTransfer)).toEqual(payload)
clearAiVaultSessionDragData()
expect(readAiVaultSessionDragData(dropTransfer)).toBeNull()
})
})
@@ -0,0 +1,84 @@
import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../../shared/ai-vault-types'
export const AI_VAULT_SESSION_DRAG_TYPE = 'application/x-orca-ai-vault-session'
export const AI_VAULT_SESSION_DRAG_START_EVENT = 'orca-ai-vault-session-drag-start'
export const AI_VAULT_SESSION_DRAG_END_EVENT = 'orca-ai-vault-session-drag-end'
export type AiVaultSessionDragPayload = {
agent: AiVaultAgent
sessionId: string
title: string
command: string
}
let activeAiVaultSessionDragPayload: AiVaultSessionDragPayload | null = null
type SerializedAiVaultSessionDragPayload = AiVaultSessionDragPayload & {
kind: 'ai-vault-session'
version: 1
}
function isAiVaultAgent(value: unknown): value is AiVaultAgent {
return typeof value === 'string' && (AI_VAULT_AGENTS as readonly string[]).includes(value)
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0
}
function isSerializedPayload(value: unknown): value is SerializedAiVaultSessionDragPayload {
if (!value || typeof value !== 'object') {
return false
}
const payload = value as Partial<SerializedAiVaultSessionDragPayload>
return (
payload.kind === 'ai-vault-session' &&
payload.version === 1 &&
isAiVaultAgent(payload.agent) &&
isNonEmptyString(payload.sessionId) &&
isNonEmptyString(payload.title) &&
isNonEmptyString(payload.command)
)
}
export function writeAiVaultSessionDragData(
dataTransfer: DataTransfer,
payload: AiVaultSessionDragPayload
): void {
activeAiVaultSessionDragPayload = { ...payload }
dataTransfer.effectAllowed = 'copy'
// Why: avoid text/plain so terminal/native drop targets cannot paste the
// resume command instead of letting Orca's pane drop layer handle it.
dataTransfer.setData(
AI_VAULT_SESSION_DRAG_TYPE,
JSON.stringify({ kind: 'ai-vault-session', version: 1, ...payload })
)
}
export function hasAiVaultSessionDragData(dataTransfer: DataTransfer): boolean {
return Array.from(dataTransfer.types).includes(AI_VAULT_SESSION_DRAG_TYPE)
}
export function clearAiVaultSessionDragData(): void {
activeAiVaultSessionDragPayload = null
}
export function readAiVaultSessionDragData(
dataTransfer: DataTransfer
): AiVaultSessionDragPayload | null {
const raw = dataTransfer.getData(AI_VAULT_SESSION_DRAG_TYPE)
if (!raw) {
return hasAiVaultSessionDragData(dataTransfer) ? activeAiVaultSessionDragPayload : null
}
try {
const parsed: unknown = JSON.parse(raw)
if (!isSerializedPayload(parsed)) {
return null
}
const { agent, sessionId, title, command } = parsed
return { agent, sessionId, title, command }
} catch {
return null
}
}
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockCreateTab = vi.fn()
const mockCreateEmptySplitGroup = vi.fn()
const mockQueueTabStartupCommand = vi.fn()
const mockSetActiveTabType = vi.fn()
const mockSetTabBarOrder = vi.fn()
const mockState = {
createTab: mockCreateTab,
createEmptySplitGroup: mockCreateEmptySplitGroup,
queueTabStartupCommand: mockQueueTabStartupCommand,
setActiveTabType: mockSetActiveTabType,
setTabBarOrder: mockSetTabBarOrder,
tabsByWorktree: {} as Record<string, { id: string }[]>,
openFiles: [] as { id: string; worktreeId: string }[],
browserTabsByWorktree: {} as Record<string, { id: string }[]>,
tabBarOrderByWorktree: {} as Record<string, string[]>
}
vi.mock('@/store', () => ({
useAppStore: {
getState: () => mockState
}
}))
vi.mock('@/components/tab-bar/reconcile-order', () => ({
reconcileTabOrder: (
_current: string[] | undefined,
terminalIds: string[],
editorIds: string[],
browserIds: string[]
) => [...terminalIds, ...editorIds, ...browserIds]
}))
vi.mock('@/lib/telemetry', () => ({
tuiAgentToAgentKind: (agent: string) => agent
}))
import { launchAiVaultSessionInNewTab } from './launch-ai-vault-session'
describe('launchAiVaultSessionInNewTab', () => {
beforeEach(() => {
vi.clearAllMocks()
mockState.tabsByWorktree = {}
mockState.openFiles = []
mockState.browserTabsByWorktree = {}
mockState.tabBarOrderByWorktree = {}
mockCreateTab.mockImplementation((worktreeId: string) => {
const tab = { id: `tab-${(mockState.tabsByWorktree[worktreeId] ?? []).length + 1}` }
mockState.tabsByWorktree[worktreeId] = [...(mockState.tabsByWorktree[worktreeId] ?? []), tab]
return tab
})
mockCreateEmptySplitGroup.mockReturnValue('group-new')
})
it('creates a terminal in the requested tab group and queues the resume command', () => {
const result = launchAiVaultSessionInNewTab({
agent: 'claude',
worktreeId: 'wt-1',
targetGroupId: 'group-1',
command: 'claude --resume session-1'
})
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', 'group-1')
expect(mockQueueTabStartupCommand).toHaveBeenCalledWith('tab-1', {
command: 'claude --resume session-1',
telemetry: {
agent_kind: 'claude',
launch_source: 'sidebar',
request_kind: 'resume'
}
})
expect(mockSetActiveTabType).toHaveBeenCalledWith('terminal')
expect(mockSetTabBarOrder).toHaveBeenCalledWith('wt-1', ['tab-1'])
expect(result).toEqual({ tabId: 'tab-1', groupId: 'group-1' })
})
it('creates a split group before launching when a split direction is provided', () => {
launchAiVaultSessionInNewTab({
agent: 'codex',
worktreeId: 'wt-1',
targetGroupId: 'group-1',
splitDirection: 'right',
command: 'codex resume session-2'
})
expect(mockCreateEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right')
expect(mockCreateTab).toHaveBeenCalledWith('wt-1', 'group-new')
})
})
@@ -0,0 +1,48 @@
import { useAppStore } from '@/store'
import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order'
import { tuiAgentToAgentKind } from '@/lib/telemetry'
import type { AiVaultAgent } from '../../../shared/ai-vault-types'
import type { TabSplitDirection } from '@/store/slices/tabs'
export function launchAiVaultSessionInNewTab(args: {
agent: AiVaultAgent
worktreeId: string
command: string
targetGroupId?: string
splitDirection?: TabSplitDirection
}): { tabId: string; groupId?: string } {
const store = useAppStore.getState()
let targetGroupId = args.targetGroupId
if (args.splitDirection && targetGroupId) {
targetGroupId =
store.createEmptySplitGroup(args.worktreeId, targetGroupId, args.splitDirection) ??
targetGroupId
}
const tab = store.createTab(args.worktreeId, targetGroupId)
store.queueTabStartupCommand(tab.id, {
command: args.command,
telemetry: {
agent_kind: tuiAgentToAgentKind(args.agent),
launch_source: 'sidebar',
request_kind: 'resume'
}
})
store.setActiveTabType('terminal')
const fresh = useAppStore.getState()
const termIds = (fresh.tabsByWorktree[args.worktreeId] ?? []).map((t) => t.id)
const editorIds = fresh.openFiles.filter((f) => f.worktreeId === args.worktreeId).map((f) => f.id)
const browserIds = (fresh.browserTabsByWorktree?.[args.worktreeId] ?? []).map((t) => t.id)
const base = reconcileTabOrder(
fresh.tabBarOrderByWorktree[args.worktreeId],
termIds,
editorIds,
browserIds
)
const order = base.filter((id) => id !== tab.id)
order.push(tab.id)
fresh.setTabBarOrder(args.worktreeId, order)
return { tabId: tab.id, groupId: targetGroupId }
}
+1
View File
@@ -257,6 +257,7 @@ function normalizePersistedRightSidebarTab(
if (
tab === 'explorer' ||
tab === 'search' ||
tab === 'vault' ||
tab === 'source-control' ||
tab === 'checks' ||
tab === 'ports'
+8
View File
@@ -571,6 +571,14 @@ function createWebPreloadApi(): Partial<PreloadApi> {
memory: {
getSnapshot: () => Promise.resolve(createEmptyMemorySnapshot())
},
aiVault: {
listSessions: () =>
Promise.resolve({
sessions: [],
issues: [],
scannedAt: new Date().toISOString()
})
},
preflight: createPreflightApi(),
notifications: createNotificationsApi(),
rateLimits: createRateLimitsApi(),
+169
View File
@@ -0,0 +1,169 @@
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import type { TuiAgent } from './types'
export const AI_VAULT_AGENTS = [
'claude',
'codex',
'hermes',
'pi',
'cursor',
'gemini',
'rovo',
'copilot',
'opencode',
'openclaw',
'droid'
] as const satisfies readonly TuiAgent[]
export type AiVaultAgent = (typeof AI_VAULT_AGENTS)[number]
export type AiVaultScope = 'workspace' | 'all'
export type AiVaultSort = 'updated' | 'created'
export type AiVaultGroup = 'folder' | 'agent'
export const AI_VAULT_AGENT_LABELS = {
claude: 'Claude',
codex: 'Codex',
hermes: 'Hermes',
pi: 'Pi',
cursor: 'Cursor',
gemini: 'Gemini',
rovo: 'Rovo Dev',
copilot: 'GitHub Copilot',
opencode: 'OpenCode',
openclaw: 'OpenClaw',
droid: 'Droid'
} as const satisfies Record<AiVaultAgent, string>
export type AiVaultSessionPreviewMessage = {
role: 'user' | 'assistant' | 'system' | 'tool' | 'unknown'
text: string
timestamp: string | null
}
export type AiVaultSession = {
id: string
agent: AiVaultAgent
sessionId: string
title: string
cwd: string | null
branch: string | null
model: string | null
filePath: string
codexHome: string | null
createdAt: string | null
updatedAt: string | null
modifiedAt: string
messageCount: number
totalTokens: number
previewMessages: AiVaultSessionPreviewMessage[]
resumeCommand: string
}
export type AiVaultScanIssue = {
agent: AiVaultAgent
path: string
message: string
}
export type AiVaultListArgs = {
limit?: number
force?: boolean
}
export type AiVaultListResult = {
sessions: AiVaultSession[]
issues: AiVaultScanIssue[]
scannedAt: string
}
export function buildAiVaultResumeCommand(args: {
agent: AiVaultAgent
sessionId: string
cwd: string | null
platform: NodeJS.Platform
commandOverride?: string | null
codexHome?: string | null
}): string {
const { agent, sessionId, cwd, platform, commandOverride, codexHome } = args
const baseCommand = commandOverride?.trim() || defaultAiVaultResumeCommandBase(agent)
const sessionArg = quoteShellArg(sessionId, platform)
const resumeCommand = buildAgentResumeInvocation(agent, baseCommand, sessionArg, {
codexHome: codexHome?.trim() || null,
platform
})
if (!cwd) {
return resumeCommand
}
if (platform === 'win32') {
const inner = `cd /d ${quoteWindowsCmdArg(cwd)} && ${resumeCommand}`
return `cmd /d /s /c ${quoteWindowsCmdArg(inner)}`
}
return `cd ${quoteShellArg(cwd, platform)} && ${resumeCommand}`
}
export function aiVaultAgentLabel(agent: AiVaultAgent): string {
return AI_VAULT_AGENT_LABELS[agent]
}
function defaultAiVaultResumeCommandBase(agent: AiVaultAgent): string {
if (agent === 'cursor') {
return 'cursor-agent'
}
if (agent === 'hermes') {
return 'hermes'
}
if (agent === 'rovo') {
return 'acli'
}
return TUI_AGENT_CONFIG[agent].detectCmd
}
function buildAgentResumeInvocation(
agent: AiVaultAgent,
baseCommand: string,
sessionArg: string,
options: { codexHome: string | null; platform: NodeJS.Platform }
): string {
switch (agent) {
case 'codex':
return `${codexHomeEnvPrefix(options.codexHome, options.platform)}${baseCommand} resume ${sessionArg}`
case 'rovo':
return `${baseCommand} rovodev run --restore ${sessionArg}`
case 'opencode':
case 'pi':
return `${baseCommand} --session ${sessionArg}`
case 'copilot':
return `${baseCommand} --resume=${sessionArg}`
case 'claude':
case 'cursor':
case 'gemini':
case 'hermes':
case 'openclaw':
case 'droid':
return `${baseCommand} --resume ${sessionArg}`
}
}
function codexHomeEnvPrefix(codexHome: string | null, platform: NodeJS.Platform): string {
if (!codexHome) {
return ''
}
if (platform === 'win32') {
return `set ${quoteWindowsCmdArg(`CODEX_HOME=${codexHome}`)} && `
}
return `CODEX_HOME=${quoteShellArg(codexHome, platform)} `
}
function quoteShellArg(value: string, platform: NodeJS.Platform): string {
if (platform === 'win32') {
return quoteWindowsCmdArg(value)
}
return `'${value.replace(/'/g, `'\\''`)}'`
}
function quoteWindowsCmdArg(value: string): string {
return `"${value.replace(/"/g, '""')}"`
}
+7 -1
View File
@@ -2757,7 +2757,13 @@ export type TaskResumeState = {
jiraQuery?: string
}
export type RightSidebarTab = 'explorer' | 'search' | 'source-control' | 'checks' | 'ports'
export type RightSidebarTab =
| 'explorer'
| 'search'
| 'vault'
| 'source-control'
| 'checks'
| 'ports'
export type ProjectOrderBy = 'manual' | 'recent'
export type WorkspaceHostScope = 'all' | 'local' | `ssh:${string}` | `runtime:${string}`