mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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"""'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -625,6 +625,8 @@ function openMainWindow(): BrowserWindow {
|
||||
crashReports ?? undefined,
|
||||
keybindings,
|
||||
{
|
||||
getAdditionalAiVaultCodexHomePaths: () =>
|
||||
codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [],
|
||||
onBeforeRelaunch: () => {
|
||||
isQuitting = true
|
||||
store?.flush()
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -257,6 +257,7 @@ function normalizePersistedRightSidebarTab(
|
||||
if (
|
||||
tab === 'explorer' ||
|
||||
tab === 'search' ||
|
||||
tab === 'vault' ||
|
||||
tab === 'source-control' ||
|
||||
tab === 'checks' ||
|
||||
tab === 'ports'
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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}`
|
||||
|
||||
Reference in New Issue
Block a user