Detect and surface recoverable zero-turn sessions in AI Vault (#7889)
Identify Claude sessions with no saved conversation turns but possessing recoverable signals such as queued prompts or subagent transcripts. This displays them in the sidebar with a "Not saved" badge instead of filtering them out as empty, and provides detailed notices to recover them via logs while disabling standard resume actions. Additionally, extract Gemini session parsing logic into a separate module and support counting sibling subagent transcripts across local and remote SSH session scans.
This commit is contained in:
parent
8dbcb2be3e
commit
4ded642a3a
|
|
@ -5,10 +5,8 @@ import { parseCodexSessionContent } from './session-scanner-codex-parser'
|
|||
import { parseDevinSessionContent } from './session-scanner-devin-parser'
|
||||
import { parseDroidSessionContent } from './session-scanner-droid-parser'
|
||||
import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers'
|
||||
import {
|
||||
parseClaudeSessionContent,
|
||||
parseGeminiSessionContent
|
||||
} from './session-scanner-primary-parsers'
|
||||
import { parseClaudeSessionContent } from './session-scanner-primary-parsers'
|
||||
import { parseGeminiSessionContent } from './session-scanner-gemini-parsers'
|
||||
import {
|
||||
parseCopilotSessionContent,
|
||||
parseCursorSessionContent,
|
||||
|
|
@ -36,13 +34,19 @@ export function remoteSessionSources(
|
|||
): RemoteSessionSource[] {
|
||||
return [
|
||||
...remoteCodexSources(remoteHome, hostPlatform),
|
||||
jsonlSource(
|
||||
'claude',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.claude', 'projects'],
|
||||
parseClaudeSessionContent
|
||||
),
|
||||
{
|
||||
...jsonlSource(
|
||||
'claude',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.claude', 'projects'],
|
||||
parseClaudeSessionContent
|
||||
),
|
||||
// The remote host owns the transcript disk, so the local readdir in the
|
||||
// Claude parser is skipped; the walked listing supplies the sibling
|
||||
// subagent counts instead.
|
||||
collectSubagentSiblingCounts: true
|
||||
},
|
||||
source(
|
||||
'gemini',
|
||||
remoteHome,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ export type RemoteSessionSource = {
|
|||
rootDir: string
|
||||
extensions: readonly string[]
|
||||
filePredicate?: (path: string) => boolean
|
||||
// Claude layout: count `<session>/subagents/*.jsonl` siblings from the walked
|
||||
// listing and drop them from candidates instead of indexing them as sessions.
|
||||
collectSubagentSiblingCounts?: boolean
|
||||
parse: (
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
|
|
@ -31,4 +34,5 @@ export type RemoteSessionSource = {
|
|||
export type RemoteSessionCandidate = {
|
||||
source: RemoteSessionSource
|
||||
file: FileWithMtime
|
||||
subagentTranscriptCount?: number
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,6 +201,79 @@ describe('scanRemoteAiVaultSessions', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('counts remote sibling subagent transcripts for zero-turn Claude sessions', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/lost-session.jsonl',
|
||||
jsonLines([{ type: 'mode', mode: 'default', sessionId: 'lost-session' }]),
|
||||
50
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/lost-session/subagents/agent-a.jsonl',
|
||||
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask A' } }]),
|
||||
51
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/lost-session/subagents/agent-b.jsonl',
|
||||
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask B' } }]),
|
||||
52
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
// Subagent transcripts must not surface as standalone sessions; they only
|
||||
// contribute recoverable signal to their zero-turn parent.
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
agent: 'claude',
|
||||
sessionId: 'lost-session',
|
||||
messageCount: 0,
|
||||
subagentTranscriptCount: 2,
|
||||
filePath: '/home/ada/.claude/projects/repo/lost-session.jsonl'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores remote subagent siblings for Claude sessions with real turns', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/live-session.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
sessionId: 'live-session',
|
||||
type: 'user',
|
||||
message: { content: [{ type: 'text', text: 'Do the thing' }] }
|
||||
}
|
||||
]),
|
||||
60
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/live-session/subagents/agent-a.jsonl',
|
||||
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask' } }]),
|
||||
61
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
sessionId: 'live-session',
|
||||
messageCount: 1,
|
||||
subagentTranscriptCount: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('builds resume commands with the remote host platform', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { FileStat, IFilesystemProvider } from '../providers/types'
|
|||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { sessionSortTime } from './session-scanner-accumulator'
|
||||
import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
import { remoteSessionSources } from './remote-session-scanner-sources'
|
||||
|
|
@ -77,11 +78,15 @@ async function discoverRemoteSourceCandidates(args: {
|
|||
context: RemoteScannerContext
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<RemoteSessionCandidate[]> {
|
||||
const paths = await walkRemoteSessionFiles(
|
||||
const walked = await walkRemoteSessionFiles(
|
||||
args.source,
|
||||
args.context.provider,
|
||||
args.context.hostPlatform
|
||||
)
|
||||
const partition = args.source.collectSubagentSiblingCounts
|
||||
? partitionSubagentTranscriptPaths(walked)
|
||||
: null
|
||||
const paths = partition ? partition.sessionFilePaths : walked
|
||||
const files = await mapRemoteScanConcurrently(paths, (path) =>
|
||||
statRemoteFile(
|
||||
args.context.provider,
|
||||
|
|
@ -93,7 +98,11 @@ async function discoverRemoteSourceCandidates(args: {
|
|||
)
|
||||
return files
|
||||
.filter((file): file is FileWithMtime => Boolean(file))
|
||||
.map((file) => ({ source: args.source, file }))
|
||||
.map((file) => ({
|
||||
source: args.source,
|
||||
file,
|
||||
subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0
|
||||
}))
|
||||
}
|
||||
|
||||
async function walkRemoteSessionFiles(
|
||||
|
|
@ -199,7 +208,14 @@ async function parseRemoteSessionCandidate(
|
|||
if (read.isBinary) {
|
||||
return null
|
||||
}
|
||||
return candidate.source.parse(candidate.file, read.content, context)
|
||||
const session = await candidate.source.parse(candidate.file, read.content, context)
|
||||
// Mirror the local zero-turn-only rule: sibling subagent transcripts are
|
||||
// recoverable signal only when the parent conversation persisted no turns.
|
||||
const subagentTranscriptCount = candidate.subagentTranscriptCount ?? 0
|
||||
if (session && session.messageCount === 0 && subagentTranscriptCount > 0) {
|
||||
return { ...session, subagentTranscriptCount }
|
||||
}
|
||||
return session
|
||||
} catch (err) {
|
||||
issues.push({
|
||||
executionHostId: context.executionHostId,
|
||||
|
|
|
|||
|
|
@ -139,6 +139,8 @@ function session(
|
|||
messageCount: 1,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: `codex resume ${sessionId}`
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ const aiVaultListResultSchema = z.object({
|
|||
messageCount: z.number(),
|
||||
totalTokens: z.number(),
|
||||
previewMessages: z.array(aiVaultSessionPreviewMessageSchema),
|
||||
// Default keeps remote hosts running an older build (no recoverable-signal
|
||||
// fields) parseable; they simply report no recoverable-empty sessions.
|
||||
queuedMessageCount: z.number().default(0),
|
||||
subagentTranscriptCount: z.number().default(0),
|
||||
resumeCommand: z.string()
|
||||
})
|
||||
),
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ export function createAccumulator(args: {
|
|||
messageCount: 0,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
latestTimestampMs: 0
|
||||
}
|
||||
}
|
||||
|
|
@ -110,6 +112,8 @@ export function finalizeSession(
|
|||
messageCount: accumulator.messageCount,
|
||||
totalTokens: accumulator.totalTokens,
|
||||
previewMessages: accumulator.previewMessages,
|
||||
queuedMessageCount: accumulator.queuedMessageCount,
|
||||
subagentTranscriptCount: accumulator.subagentTranscriptCount,
|
||||
resumeCommand: buildAiVaultResumeCommand({
|
||||
agent: accumulator.agent,
|
||||
sessionId,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-sc
|
|||
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
|
||||
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
|
||||
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
|
||||
import { parseClaudeSessionFile, parseGeminiSessionFile } from './session-scanner-primary-parsers'
|
||||
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
|
||||
import { parseGeminiSessionFile } from './session-scanner-gemini-parsers'
|
||||
import { parseCodexSessionFile } from './session-scanner-codex-parser'
|
||||
import {
|
||||
parseCopilotSessionFile,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
import { createReadStream } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
FileWithMtime,
|
||||
ResumableParseFinalizeOptions,
|
||||
ResumableSessionParseState,
|
||||
SessionAccumulator
|
||||
} from './session-scanner-types'
|
||||
import {
|
||||
accumulatorFoldResumeState,
|
||||
addPreviewContent,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
sessionIdFromFileName,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import {
|
||||
arrayValue,
|
||||
asRecord,
|
||||
extractContentText,
|
||||
extractString,
|
||||
parseJsonObject,
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
export async function parseGeminiSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
if (file.path.endsWith('.jsonl')) {
|
||||
return parseGeminiJsonlSessionFile(file, platform)
|
||||
}
|
||||
|
||||
return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform)
|
||||
}
|
||||
|
||||
export async function parseGeminiSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ResumableParseFinalizeOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
if (file.path.endsWith('.jsonl')) {
|
||||
return parseGeminiJsonlSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
return parseGeminiJsonSessionContent(file, content, platform, options)
|
||||
}
|
||||
|
||||
function parseGeminiJsonSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: ResumableParseFinalizeOptions = {}
|
||||
): AiVaultSession | null {
|
||||
const record = asRecord(JSON.parse(content) 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, options)
|
||||
}
|
||||
|
||||
export async function parseGeminiJsonlSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseGeminiJsonlSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
function consumeGeminiJsonlRecordLine(accumulator: SessionAccumulator, line: string): void {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
const setRecord = asRecord(record.$set)
|
||||
if (setRecord) {
|
||||
updateTimeline(accumulator, extractString(setRecord.lastUpdated))
|
||||
return
|
||||
}
|
||||
const sessionId = extractString(record.sessionId)
|
||||
if (sessionId) {
|
||||
accumulator.sessionId = sessionId
|
||||
}
|
||||
updateTimeline(accumulator, extractString(record.startTime))
|
||||
updateTimeline(accumulator, extractString(record.lastUpdated))
|
||||
consumeGeminiMessage(accumulator, record)
|
||||
}
|
||||
|
||||
// Resumable only for the JSONL log format; Gemini's legacy single-JSON
|
||||
// session documents are rewritten in place and must be re-read whole.
|
||||
export function createGeminiJsonlSessionResumeState(
|
||||
file: FileWithMtime
|
||||
): ResumableSessionParseState {
|
||||
return accumulatorFoldResumeState(
|
||||
createAccumulator({ agent: 'gemini', file, sessionId: sessionIdFromFileName(file.path) }),
|
||||
consumeGeminiJsonlRecordLine
|
||||
)
|
||||
}
|
||||
|
||||
async function parseGeminiJsonlSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ResumableParseFinalizeOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const state = createGeminiJsonlSessionResumeState(args.file)
|
||||
for await (const line of args.lines) {
|
||||
state.consumeLine(line)
|
||||
}
|
||||
return state.finalize(args.platform, args.options)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,14 +5,13 @@ import { parseAgentSessionFile } from './session-scanner-agent-parser'
|
|||
import { createCodexSessionResumeState } from './session-scanner-codex-parser'
|
||||
import { createDroidSessionResumeState } from './session-scanner-droid-parser'
|
||||
import { createMessageGraphSessionResumeState } from './session-scanner-graph-parsers'
|
||||
import {
|
||||
createClaudeSessionResumeState,
|
||||
createGeminiJsonlSessionResumeState
|
||||
} from './session-scanner-primary-parsers'
|
||||
import { createClaudeSessionResumeState } from './session-scanner-primary-parsers'
|
||||
import { createGeminiJsonlSessionResumeState } from './session-scanner-gemini-parsers'
|
||||
import {
|
||||
createCopilotSessionResumeState,
|
||||
createCursorSessionResumeState
|
||||
} from './session-scanner-secondary-parsers'
|
||||
import { countSubagentTranscripts } from './session-scanner-subagent-transcripts'
|
||||
import type { ResumableSessionParseState, SessionFileCandidate } from './session-scanner-types'
|
||||
|
||||
// Sized past the default recency cap (1000) plus the in-scope cap (2000) so a
|
||||
|
|
@ -132,6 +131,16 @@ export async function parseAgentSessionFileCached(
|
|||
if (stats) {
|
||||
stats.reused++
|
||||
}
|
||||
// A zero-turn transcript usually never changes again, but its sibling
|
||||
// subagents/ dir can gain files after the parent's last write (a
|
||||
// still-running subagent finishing). The mtime+size key can't see that,
|
||||
// so refresh the cheap directory count on reuse.
|
||||
if (entry.session && candidate.agent === 'claude' && entry.session.messageCount === 0) {
|
||||
const subagentTranscriptCount = await countSubagentTranscripts(file.path)
|
||||
if (subagentTranscriptCount !== entry.session.subagentTranscriptCount) {
|
||||
entry.session = { ...entry.session, subagentTranscriptCount }
|
||||
}
|
||||
}
|
||||
storeEntry(file.path, entry)
|
||||
return entry.session
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
import { createReadStream } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import type {
|
||||
FileWithMtime,
|
||||
ResumableSessionParseState,
|
||||
SessionAccumulator
|
||||
} from './session-scanner-types'
|
||||
import {
|
||||
accumulatorFoldResumeState,
|
||||
addPreviewContent,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
|
|
@ -17,16 +15,14 @@ import {
|
|||
updateLatestLocation,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import { countSubagentTranscripts } from './session-scanner-subagent-transcripts'
|
||||
import {
|
||||
arrayValue,
|
||||
asRecord,
|
||||
claudeUsageTotal,
|
||||
extractContentText,
|
||||
extractMessageText,
|
||||
extractString,
|
||||
normalizeTitleText,
|
||||
parseJsonObject,
|
||||
tokenTotal
|
||||
parseJsonObject
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
|
|
@ -102,6 +98,22 @@ export function consumeClaudeSessionLine(state: ClaudeSessionParseState, line: s
|
|||
return
|
||||
}
|
||||
|
||||
if (record.type === 'queue-operation') {
|
||||
// Enqueued prompts hold real content (e.g. queued subagent messages) that
|
||||
// survives even when the conversation was never persisted — recoverable
|
||||
// signal for an otherwise-empty session, but not a conversation turn.
|
||||
// Count net of remove/dequeue: consumed or user-removed prompts are written
|
||||
// as later queue-operation records and are no longer queued. Accepted gap:
|
||||
// a dequeue/remove of an uncounted empty-content enqueue can undercount,
|
||||
// which only hides the recoverable badge — it never fabricates one.
|
||||
if (record.operation === 'enqueue' && (extractString(record.content)?.trim().length ?? 0) > 0) {
|
||||
accumulator.queuedMessageCount++
|
||||
} else if (record.operation === 'remove' || record.operation === 'dequeue') {
|
||||
accumulator.queuedMessageCount = Math.max(0, accumulator.queuedMessageCount - 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (record.type === 'user') {
|
||||
accumulator.messageCount++
|
||||
const title = extractMessageText(record.message)
|
||||
|
|
@ -129,11 +141,11 @@ export function consumeClaudeSessionLine(state: ClaudeSessionParseState, line: s
|
|||
}
|
||||
}
|
||||
|
||||
export function finalizeClaudeSessionParseState(
|
||||
export async function finalizeClaudeSessionParseState(
|
||||
state: ClaudeSessionParseState,
|
||||
platform: NodeJS.Platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): AiVaultSession | null {
|
||||
): Promise<AiVaultSession | null> {
|
||||
// Finalize a snapshot: the live state (and its preview array) may keep
|
||||
// accumulating appended lines after this session object is handed out.
|
||||
const snapshot = cloneClaudeSessionParseState(state)
|
||||
|
|
@ -141,6 +153,17 @@ export function finalizeClaudeSessionParseState(
|
|||
// session name (ai-title) should outrank the raw first prompt when present.
|
||||
snapshot.accumulator.fallbackTitle =
|
||||
snapshot.generatedTitle ?? snapshot.firstUserTitle ?? snapshot.metaTitle
|
||||
// Only a zero-turn transcript needs its sibling subagent transcripts counted;
|
||||
// for normal sessions the extra directory read is skipped. The sibling dir
|
||||
// lives on the host that owns the transcript, so content fetched from a
|
||||
// remote (SSH) host must not readdir this machine's disk.
|
||||
const ownsTranscriptDisk =
|
||||
!options.executionHostId || options.executionHostId === LOCAL_EXECUTION_HOST_ID
|
||||
if (snapshot.accumulator.messageCount === 0 && ownsTranscriptDisk) {
|
||||
snapshot.accumulator.subagentTranscriptCount = await countSubagentTranscripts(
|
||||
snapshot.accumulator.filePath
|
||||
)
|
||||
}
|
||||
return finalizeSession(snapshot.accumulator, platform, options)
|
||||
}
|
||||
|
||||
|
|
@ -198,133 +221,3 @@ async function parseClaudeSessionLines(args: {
|
|||
}
|
||||
return finalizeClaudeSessionParseState(state, args.platform, args.options)
|
||||
}
|
||||
|
||||
export async function parseGeminiSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
if (file.path.endsWith('.jsonl')) {
|
||||
return parseGeminiJsonlSessionFile(file, platform)
|
||||
}
|
||||
|
||||
return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform)
|
||||
}
|
||||
|
||||
export async function parseGeminiSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
if (file.path.endsWith('.jsonl')) {
|
||||
return parseGeminiJsonlSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
return parseGeminiJsonSessionContent(file, content, platform, options)
|
||||
}
|
||||
|
||||
function parseGeminiJsonSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: ParserSessionOptions = {}
|
||||
): AiVaultSession | null {
|
||||
const record = asRecord(JSON.parse(content) 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, options)
|
||||
}
|
||||
|
||||
export async function parseGeminiJsonlSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseGeminiJsonlSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
function consumeGeminiJsonlRecordLine(accumulator: SessionAccumulator, line: string): void {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
const setRecord = asRecord(record.$set)
|
||||
if (setRecord) {
|
||||
updateTimeline(accumulator, extractString(setRecord.lastUpdated))
|
||||
return
|
||||
}
|
||||
const sessionId = extractString(record.sessionId)
|
||||
if (sessionId) {
|
||||
accumulator.sessionId = sessionId
|
||||
}
|
||||
updateTimeline(accumulator, extractString(record.startTime))
|
||||
updateTimeline(accumulator, extractString(record.lastUpdated))
|
||||
consumeGeminiMessage(accumulator, record)
|
||||
}
|
||||
|
||||
// Resumable only for the JSONL log format; Gemini's legacy single-JSON
|
||||
// session documents are rewritten in place and must be re-read whole.
|
||||
export function createGeminiJsonlSessionResumeState(
|
||||
file: FileWithMtime
|
||||
): ResumableSessionParseState {
|
||||
return accumulatorFoldResumeState(
|
||||
createAccumulator({ agent: 'gemini', file, sessionId: sessionIdFromFileName(file.path) }),
|
||||
consumeGeminiJsonlRecordLine
|
||||
)
|
||||
}
|
||||
|
||||
async function parseGeminiJsonlSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const state = createGeminiJsonlSessionResumeState(args.file)
|
||||
for await (const line of args.lines) {
|
||||
state.consumeLine(line)
|
||||
}
|
||||
return state.finalize(args.platform, args.options)
|
||||
}
|
||||
|
||||
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,245 @@
|
|||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { isAiVaultSessionRecoverableEmpty } from '../../shared/ai-vault-types'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { parseClaudeSessionContent } from './session-scanner-primary-parsers'
|
||||
import { countSubagentTranscripts } from './session-scanner-subagent-transcripts'
|
||||
import { isolatedScanRoots, writeJsonlFile } from './session-scanner-test-fixtures'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
// Shape of the real ~/.claude/.../9176163a-*.jsonl artifact: CLI metadata plus
|
||||
// four queued subagent messages, and zero user/assistant conversation turns.
|
||||
function metadataOnlyTranscript(sessionId: string): unknown[] {
|
||||
return [
|
||||
{
|
||||
type: 'last-prompt',
|
||||
lastPrompt: 'Run the review',
|
||||
leafUuid: 'leaf-1',
|
||||
sessionId
|
||||
},
|
||||
{ type: 'ai-title', aiTitle: 'Push failure recovery review', sessionId },
|
||||
{ type: 'mode', mode: 'default', sessionId },
|
||||
{ type: 'permission-mode', permissionMode: 'acceptEdits', sessionId },
|
||||
...['contracts', 'environment', 'correctness', 'perf'].map((lens, index) => ({
|
||||
type: 'queue-operation',
|
||||
operation: 'enqueue',
|
||||
timestamp: `2026-07-08T16:3${index}:00.000Z`,
|
||||
sessionId,
|
||||
content: `<agent-message from="rev-${lens}">Review complete</agent-message>`
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
describe('recoverable-but-empty Claude sessions', () => {
|
||||
it('surfaces a zero-turn session with queued messages and subagent transcripts', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-recoverable-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const sessionId = '9176163a-2f89-431f-b202-32f04d61f124'
|
||||
|
||||
await writeJsonlFile(
|
||||
join(roots.claudeProjectsDir, 'project', `${sessionId}.jsonl`),
|
||||
metadataOnlyTranscript(sessionId)
|
||||
)
|
||||
// Sibling subagent transcripts survive even though the parent conversation
|
||||
// was never persisted; a .meta.json sidecar is not a transcript.
|
||||
const subagentsDir = join(roots.claudeProjectsDir, 'project', sessionId, 'subagents')
|
||||
await writeJsonlFile(join(subagentsDir, 'agent-arev-contracts-1.jsonl'), [
|
||||
{
|
||||
type: 'user',
|
||||
sessionId,
|
||||
message: { role: 'user', content: 'Contracts review' }
|
||||
}
|
||||
])
|
||||
await writeJsonlFile(join(subagentsDir, 'agent-arev-perf-2.jsonl'), [
|
||||
{
|
||||
type: 'user',
|
||||
sessionId,
|
||||
message: { role: 'user', content: 'Perf review' }
|
||||
}
|
||||
])
|
||||
await writeFile(join(subagentsDir, 'agent-arev-contracts-1.meta.json'), '{}')
|
||||
|
||||
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
|
||||
const session = result.sessions.find((entry) => entry.sessionId === sessionId)
|
||||
|
||||
expect(session).toBeDefined()
|
||||
expect(session?.messageCount).toBe(0)
|
||||
expect(session?.queuedMessageCount).toBe(4)
|
||||
expect(session?.subagentTranscriptCount).toBe(2)
|
||||
expect(isAiVaultSessionRecoverableEmpty(session!)).toBe(true)
|
||||
})
|
||||
|
||||
it('reports no recoverable signal for a plain metadata-only session', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-plain-empty-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const sessionId = 'plain-empty-session'
|
||||
|
||||
await writeJsonlFile(join(roots.claudeProjectsDir, 'project', `${sessionId}.jsonl`), [
|
||||
{ type: 'mode', mode: 'default', sessionId },
|
||||
{ type: 'permission-mode', permissionMode: 'default', sessionId }
|
||||
])
|
||||
|
||||
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
|
||||
const session = result.sessions.find((entry) => entry.sessionId === sessionId)
|
||||
|
||||
expect(session).toBeDefined()
|
||||
expect(session?.messageCount).toBe(0)
|
||||
expect(session?.queuedMessageCount).toBe(0)
|
||||
expect(session?.subagentTranscriptCount).toBe(0)
|
||||
expect(isAiVaultSessionRecoverableEmpty(session!)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not count subagent transcripts for a session that has real turns', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-nonempty-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const sessionId = 'conversation-session'
|
||||
|
||||
await writeJsonlFile(join(roots.claudeProjectsDir, 'project', `${sessionId}.jsonl`), [
|
||||
{
|
||||
type: 'user',
|
||||
sessionId,
|
||||
message: { role: 'user', content: 'Do the thing' }
|
||||
}
|
||||
])
|
||||
await writeJsonlFile(
|
||||
join(roots.claudeProjectsDir, 'project', sessionId, 'subagents', 'agent-x.jsonl'),
|
||||
[
|
||||
{
|
||||
type: 'user',
|
||||
sessionId,
|
||||
message: { role: 'user', content: 'Subtask' }
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
|
||||
const session = result.sessions.find((entry) => entry.sessionId === sessionId)
|
||||
|
||||
expect(session?.messageCount).toBe(1)
|
||||
// Skipped for sessions with content: the directory read only runs at zero turns.
|
||||
expect(session?.subagentTranscriptCount).toBe(0)
|
||||
})
|
||||
|
||||
it('counts queued messages net of remove and dequeue operations', async () => {
|
||||
const sessionId = 'net-queue-session'
|
||||
const content = [
|
||||
{ type: 'mode', mode: 'default', sessionId },
|
||||
...[1, 2, 3].map((n) => ({
|
||||
type: 'queue-operation',
|
||||
operation: 'enqueue',
|
||||
sessionId,
|
||||
content: `queued prompt ${n}`
|
||||
})),
|
||||
// One prompt consumed, one removed by the user: only one is still queued.
|
||||
{ type: 'queue-operation', operation: 'dequeue', sessionId },
|
||||
{
|
||||
type: 'queue-operation',
|
||||
operation: 'remove',
|
||||
sessionId,
|
||||
content: null
|
||||
}
|
||||
]
|
||||
.map((line) => JSON.stringify(line))
|
||||
.join('\n')
|
||||
const file = {
|
||||
path: `/tmp/${sessionId}.jsonl`,
|
||||
mtimeMs: 0,
|
||||
modifiedAt: '2026-07-08T16:30:00.000Z'
|
||||
}
|
||||
|
||||
const session = await parseClaudeSessionContent(file, content, 'darwin', {
|
||||
executionHostId: 'ssh:host'
|
||||
})
|
||||
expect(session?.queuedMessageCount).toBe(1)
|
||||
})
|
||||
|
||||
it('picks up subagent transcripts written after the parent transcript last changed', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-late-sub-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const sessionId = 'late-subagent-session'
|
||||
|
||||
await writeJsonlFile(join(roots.claudeProjectsDir, 'project', `${sessionId}.jsonl`), [
|
||||
{ type: 'mode', mode: 'default', sessionId }
|
||||
])
|
||||
const first = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
|
||||
expect(
|
||||
first.sessions.find((entry) => entry.sessionId === sessionId)?.subagentTranscriptCount
|
||||
).toBe(0)
|
||||
|
||||
// The parent file never changes again, but a still-running subagent lands
|
||||
// its transcript afterwards; the cached parse must still surface it.
|
||||
await writeJsonlFile(
|
||||
join(roots.claudeProjectsDir, 'project', sessionId, 'subagents', 'agent-late.jsonl'),
|
||||
[
|
||||
{
|
||||
type: 'user',
|
||||
sessionId,
|
||||
message: { role: 'user', content: 'Late subtask' }
|
||||
}
|
||||
]
|
||||
)
|
||||
const second = await scanAiVaultSessions({ ...roots, platform: 'darwin' })
|
||||
expect(
|
||||
second.sessions.find((entry) => entry.sessionId === sessionId)?.subagentTranscriptCount
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('never counts local subagent transcripts for a remote-host transcript', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-remote-'))
|
||||
tempRoots.push(root)
|
||||
const sessionId = 'remote-session'
|
||||
const transcriptPath = join(root, `${sessionId}.jsonl`)
|
||||
// A local sibling dir exists, but the transcript content came from an SSH
|
||||
// host — its real subagents live on that host, not on this disk.
|
||||
await writeJsonlFile(join(root, sessionId, 'subagents', 'agent-x.jsonl'), [{ type: 'user' }])
|
||||
const content = metadataOnlyTranscript(sessionId)
|
||||
.map((line) => JSON.stringify(line))
|
||||
.join('\n')
|
||||
const file = {
|
||||
path: transcriptPath,
|
||||
mtimeMs: 0,
|
||||
modifiedAt: '2026-07-08T16:30:00.000Z'
|
||||
}
|
||||
|
||||
const remote = await parseClaudeSessionContent(file, content, 'linux', {
|
||||
executionHostId: 'ssh:host'
|
||||
})
|
||||
expect(remote?.subagentTranscriptCount).toBe(0)
|
||||
expect(remote?.queuedMessageCount).toBe(4)
|
||||
|
||||
const local = await parseClaudeSessionContent(file, content, 'darwin')
|
||||
expect(local?.subagentTranscriptCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('countSubagentTranscripts', () => {
|
||||
it('returns 0 when the sibling subagents directory is absent', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-nosub-'))
|
||||
tempRoots.push(root)
|
||||
expect(await countSubagentTranscripts(join(root, 'session.jsonl'))).toBe(0)
|
||||
})
|
||||
|
||||
it('counts only .jsonl transcripts, excluding meta sidecars', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-sub-count-'))
|
||||
tempRoots.push(root)
|
||||
const transcriptPath = join(root, 'session.jsonl')
|
||||
const subagentsDir = join(root, 'session', 'subagents')
|
||||
await writeJsonlFile(join(subagentsDir, 'agent-a.jsonl'), [{ type: 'user' }])
|
||||
await writeJsonlFile(join(subagentsDir, 'agent-b.jsonl'), [{ type: 'user' }])
|
||||
await writeFile(join(subagentsDir, 'agent-a.meta.json'), '{}')
|
||||
|
||||
expect(await countSubagentTranscripts(transcriptPath)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { readdir } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, join } from 'node:path'
|
||||
|
||||
// Claude writes subagent transcripts to a sibling directory named after the
|
||||
// parent transcript file (…/<enc>/<uuid>.jsonl → …/<enc>/<uuid>/subagents/).
|
||||
// These survive intact even when the parent conversation persisted zero turns,
|
||||
// so they are the recoverable signal that keeps such a session from being hidden.
|
||||
export function subagentTranscriptsDirFor(transcriptFilePath: string): string {
|
||||
const stem = basename(transcriptFilePath, extname(transcriptFilePath))
|
||||
return join(dirname(transcriptFilePath), stem, 'subagents')
|
||||
}
|
||||
|
||||
/**
|
||||
* Count sibling subagent transcript files for a session's transcript. Returns 0
|
||||
* when the directory is absent (the common case), so callers can treat any
|
||||
* positive count as recoverable content. Meta sidecars (`*.meta.json`) are not
|
||||
* transcripts and are excluded.
|
||||
*/
|
||||
export async function countSubagentTranscripts(transcriptFilePath: string): Promise<number> {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await readdir(subagentTranscriptsDirFor(transcriptFilePath))
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
return entries.filter((name) => name.endsWith('.jsonl')).length
|
||||
}
|
||||
|
||||
// Direct child of a subagents dir: `<parent>/<uuid>/subagents/<file>.jsonl`.
|
||||
// Greedy prefix means nested subagent trees attribute to their nearest parent,
|
||||
// matching the local direct-children-only readdir semantics.
|
||||
const SUBAGENT_DIRECT_CHILD_PATTERN = /^(.*)[\\/]subagents[\\/][^\\/]+\.jsonl$/i
|
||||
const SUBAGENT_SUBTREE_PATTERN = /[\\/]subagents[\\/]/i
|
||||
|
||||
/**
|
||||
* Partition a recursively walked transcript listing into session candidates and
|
||||
* per-parent sibling subagent transcript counts. Remote (SSH) scans cannot
|
||||
* readdir the transcript's sibling directory, but their walk already enumerates
|
||||
* subagent paths — counting from the listing costs no extra round-trips.
|
||||
* Subagent transcripts share the parent sessionId and are not independently
|
||||
* resumable, so they are excluded from candidates (mirrors the local discovery
|
||||
* pruning in session-scanner-source-discovery.ts).
|
||||
*/
|
||||
export function partitionSubagentTranscriptPaths(paths: readonly string[]): {
|
||||
sessionFilePaths: string[]
|
||||
subagentTranscriptCounts: Map<string, number>
|
||||
} {
|
||||
const sessionFilePaths: string[] = []
|
||||
const subagentTranscriptCounts = new Map<string, number>()
|
||||
for (const path of paths) {
|
||||
if (!SUBAGENT_SUBTREE_PATTERN.test(path)) {
|
||||
sessionFilePaths.push(path)
|
||||
continue
|
||||
}
|
||||
const directChild = SUBAGENT_DIRECT_CHILD_PATTERN.exec(path)
|
||||
if (directChild) {
|
||||
const parentTranscriptPath = `${directChild[1]}.jsonl`
|
||||
subagentTranscriptCounts.set(
|
||||
parentTranscriptPath,
|
||||
(subagentTranscriptCounts.get(parentTranscriptPath) ?? 0) + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
return { sessionFilePaths, subagentTranscriptCounts }
|
||||
}
|
||||
|
|
@ -100,6 +100,9 @@ export type SessionAccumulator = {
|
|||
messageCount: number
|
||||
totalTokens: number
|
||||
previewMessages: AiVaultSessionPreviewMessage[]
|
||||
// Recoverable signal for a zero-turn transcript (see AiVaultSession).
|
||||
queuedMessageCount: number
|
||||
subagentTranscriptCount: number
|
||||
latestTimestampMs: number
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -237,6 +237,8 @@ function session(
|
|||
messageCount: 1,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: `codex resume ${sessionId}`
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ export function SessionActionMenuItems({
|
|||
onJumpToOriginalPane?: () => void
|
||||
showJumpToWorktree: boolean
|
||||
onJumpToWorktree?: () => void
|
||||
onCopyResume: () => void
|
||||
// Absent for zero-turn sessions: copying a resume command that lands in an
|
||||
// empty conversation would contradict the "not saved" state.
|
||||
onCopyResume?: () => void
|
||||
onCopyId: () => void
|
||||
onCopyPath: () => void
|
||||
onOpenLog?: () => void
|
||||
|
|
@ -60,13 +62,15 @@ export function SessionActionMenuItems({
|
|||
<Play className="size-3.5" />
|
||||
{resumeLabel}
|
||||
</Item>
|
||||
<Item onSelect={onCopyResume}>
|
||||
<Copy className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand',
|
||||
'Copy Resume Command'
|
||||
)}
|
||||
</Item>
|
||||
{onCopyResume ? (
|
||||
<Item onSelect={onCopyResume}>
|
||||
<Copy className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand',
|
||||
'Copy Resume Command'
|
||||
)}
|
||||
</Item>
|
||||
) : null}
|
||||
{hasLocalPathActions ? (
|
||||
<>
|
||||
<Separator />
|
||||
|
|
|
|||
|
|
@ -3,9 +3,14 @@ import { FileJson, FolderGit2, MessageSquare, Play } from 'lucide-react'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import {
|
||||
isAiVaultSessionResumableContent,
|
||||
type AiVaultScope,
|
||||
type AiVaultSession
|
||||
} from '../../../../shared/ai-vault-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { sessionDetailConversationTurns } from './ai-vault-session-display'
|
||||
import { SessionUnsavedConversationNotice } from './AiVaultSessionUnsavedNotice'
|
||||
import {
|
||||
aiVaultWorktreeCompactPath,
|
||||
aiVaultWorktreeStatusLabel,
|
||||
|
|
@ -36,8 +41,13 @@ export function SessionInlineDetails({
|
|||
onResumeInNewTab: () => void
|
||||
onOpenLog?: () => void
|
||||
}): React.JSX.Element {
|
||||
const showResumeInWorktree = Boolean(resumeActions.worktree.worktreeId)
|
||||
const showResumeInNewTab = !showResumeInWorktree || Boolean(resumeActions.newTab.worktreeId)
|
||||
// A zero-turn transcript would resume into an empty conversation, so the plain
|
||||
// resume affordances are withheld and a distinct "not saved" state is shown.
|
||||
const hasResumableContent = isAiVaultSessionResumableContent(session)
|
||||
const showResumeInWorktree = hasResumableContent && Boolean(resumeActions.worktree.worktreeId)
|
||||
const showResumeInNewTab =
|
||||
hasResumableContent &&
|
||||
(!resumeActions.worktree.worktreeId || Boolean(resumeActions.newTab.worktreeId))
|
||||
const detailTurns = sessionDetailConversationTurns(session, 3)
|
||||
const worktreeDisplay = worktreeInfo
|
||||
|
||||
|
|
@ -54,34 +64,42 @@ export function SessionInlineDetails({
|
|||
}}
|
||||
>
|
||||
<div className="space-y-3 p-3">
|
||||
<SessionReceiptSection
|
||||
icon={<MessageSquare className="size-3" />}
|
||||
label={translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionDetails.latestTurns',
|
||||
'Latest turns'
|
||||
)}
|
||||
>
|
||||
{detailTurns.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{detailTurns.map((turn, index) => (
|
||||
<ConversationTurnCard
|
||||
key={`${turn.timestamp ?? 'turn'}-${index}`}
|
||||
role={turn.role}
|
||||
text={turn.text}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<SessionDetailEmptyState
|
||||
message={translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionDetails.noPreviewAvailable',
|
||||
'No conversation preview available'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SessionReceiptSection>
|
||||
{hasResumableContent ? (
|
||||
<SessionReceiptSection
|
||||
icon={<MessageSquare className="size-3" />}
|
||||
label={translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionDetails.latestTurns',
|
||||
'Latest turns'
|
||||
)}
|
||||
>
|
||||
{detailTurns.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{detailTurns.map((turn, index) => (
|
||||
<ConversationTurnCard
|
||||
key={`${turn.timestamp ?? 'turn'}-${index}`}
|
||||
role={turn.role}
|
||||
text={turn.text}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<SessionDetailEmptyState
|
||||
message={translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionDetails.noPreviewAvailable',
|
||||
'No conversation preview available'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</SessionReceiptSection>
|
||||
) : (
|
||||
// An unsaved session has no turns to show; the notice replaces the
|
||||
// preview section instead of stacking a second empty state under it.
|
||||
<SessionUnsavedConversationNotice session={session} logAvailable={Boolean(onOpenLog)} />
|
||||
)}
|
||||
|
||||
{shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, { vaultScope }) ? (
|
||||
{shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, {
|
||||
vaultScope
|
||||
}) ? (
|
||||
<SessionReceiptSection
|
||||
icon={<FolderGit2 className="size-3" />}
|
||||
label={translate(
|
||||
|
|
@ -94,64 +112,66 @@ export function SessionInlineDetails({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 border-t border-sidebar-border/80 bg-sidebar-accent/15 px-3 py-2">
|
||||
{showResumeInWorktree ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={resumeActions.worktree.disabled}
|
||||
draggable={false}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onResumeInWorktree()
|
||||
}}
|
||||
className="h-7 shrink-0 px-2.5 text-[11px]"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionDetails.resumeInWorktree',
|
||||
'Resume in Worktree'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{showResumeInNewTab ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={showResumeInWorktree ? 'secondary' : 'default'}
|
||||
size="xs"
|
||||
disabled={resumeActions.newTab.disabled}
|
||||
draggable={false}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onResumeInNewTab()
|
||||
}}
|
||||
className="h-7 shrink-0 px-2.5 text-[11px]"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
|
||||
'Resume in New Tab'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{onOpenLog ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
draggable={false}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onOpenLog()
|
||||
}}
|
||||
className="h-7 shrink-0 px-2.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<FileJson className="size-3.5" />
|
||||
{translate('auto.components.right.sidebar.AiVaultSessionDetails.viewLog', 'View Log')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{showResumeInWorktree || showResumeInNewTab || onOpenLog ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5 border-t border-sidebar-border/80 bg-sidebar-accent/15 px-3 py-2">
|
||||
{showResumeInWorktree ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="xs"
|
||||
disabled={resumeActions.worktree.disabled}
|
||||
draggable={false}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onResumeInWorktree()
|
||||
}}
|
||||
className="h-7 shrink-0 px-2.5 text-[11px]"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionDetails.resumeInWorktree',
|
||||
'Resume in Worktree'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{showResumeInNewTab ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={showResumeInWorktree ? 'secondary' : 'default'}
|
||||
size="xs"
|
||||
disabled={resumeActions.newTab.disabled}
|
||||
draggable={false}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onResumeInNewTab()
|
||||
}}
|
||||
className="h-7 shrink-0 px-2.5 text-[11px]"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
|
||||
'Resume in New Tab'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{onOpenLog ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
draggable={false}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onOpenLog()
|
||||
}}
|
||||
className="h-7 shrink-0 px-2.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<FileJson className="size-3.5" />
|
||||
{translate('auto.components.right.sidebar.AiVaultSessionDetails.viewLog', 'View Log')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -219,7 +239,9 @@ function WorktreeMetadataLines({
|
|||
return (
|
||||
<div className="grid min-w-0 gap-1 text-[11px] leading-4">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
|
||||
{shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, { vaultScope }) ? (
|
||||
{shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, {
|
||||
vaultScope
|
||||
}) ? (
|
||||
<>
|
||||
<span className="shrink-0 text-[10px] font-medium uppercase tracking-[0.04em] text-muted-foreground">
|
||||
{aiVaultWorktreeStatusLabel(worktreeInfo.status)}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export function VaultSessionRow({
|
|||
resumeActions: AiVaultSessionResumeActions
|
||||
onResumeInWorktree: () => void
|
||||
onResumeInNewTab: () => void
|
||||
onCopyResume: () => void
|
||||
onCopyResume?: () => void
|
||||
onCopyId: () => void
|
||||
onCopyPath: () => void
|
||||
onOpenLog?: () => void
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import type React from 'react'
|
||||
import { Archive } from 'lucide-react'
|
||||
import {
|
||||
aiVaultSessionRecoverableSignalCount,
|
||||
isAiVaultSessionRecoverableEmpty,
|
||||
type AiVaultSession
|
||||
} from '../../../../shared/ai-vault-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
// Distinct state for a zero-turn transcript: the conversation was not persisted,
|
||||
// but queued prompts and/or subagent transcripts may still be recoverable.
|
||||
export function SessionUnsavedConversationNotice({
|
||||
session,
|
||||
logAvailable
|
||||
}: {
|
||||
session: AiVaultSession
|
||||
// Whether an open-log affordance exists nearby; remote (SSH) sessions have
|
||||
// none, so the "open the log" hint would point at nothing.
|
||||
logAvailable: boolean
|
||||
}): React.JSX.Element {
|
||||
const recoverable = isAiVaultSessionRecoverableEmpty(session)
|
||||
|
||||
return (
|
||||
<section className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
<Archive className="size-3 text-muted-foreground/80" />
|
||||
<span>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionDetails.conversationNotSaved',
|
||||
'Conversation not saved'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed border-border/70 bg-foreground/[0.04] px-2.5 py-2 text-[11px] leading-4 text-muted-foreground">
|
||||
{recoverable
|
||||
? translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionDetails.recoverableEmptyDetail',
|
||||
'This session has no saved conversation, but {{value0}} recoverable item(s) survive.',
|
||||
{ value0: aiVaultSessionRecoverableSignalCount(session) }
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionDetails.emptyConversationDetail',
|
||||
'This session has no saved conversation and cannot be resumed.'
|
||||
)}
|
||||
{recoverable && logAvailable
|
||||
? ` ${translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionDetails.recoverableEmptyOpenLogHint',
|
||||
'Open the log to recover them.'
|
||||
)}`
|
||||
: null}
|
||||
{recoverable ? (
|
||||
<SessionRecoverableSignalLines
|
||||
queuedMessageCount={session.queuedMessageCount}
|
||||
subagentTranscriptCount={session.subagentTranscriptCount}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRecoverableSignalLines({
|
||||
queuedMessageCount,
|
||||
subagentTranscriptCount
|
||||
}: {
|
||||
queuedMessageCount: number
|
||||
subagentTranscriptCount: number
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<ul className="mt-1.5 space-y-0.5 text-[11px] leading-4 text-foreground/80">
|
||||
{queuedMessageCount > 0 ? (
|
||||
<li>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionDetails.queuedMessages',
|
||||
'{{value0}} queued message(s)',
|
||||
{ value0: queuedMessageCount }
|
||||
)}
|
||||
</li>
|
||||
) : null}
|
||||
{subagentTranscriptCount > 0 ? (
|
||||
<li>
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionDetails.subagentTranscripts',
|
||||
'{{value0}} subagent transcript(s)',
|
||||
{ value0: subagentTranscriptCount }
|
||||
)}
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import type { AiVaultSessionGroup } from './ai-vault-session-filters'
|
|||
import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane'
|
||||
import {
|
||||
aiVaultSessionResumeLabel,
|
||||
aiVaultSessionRowResumeGating,
|
||||
type AiVaultSessionResumeActions,
|
||||
type AiVaultSessionResumeState
|
||||
} from './ai-vault-session-resume'
|
||||
|
|
@ -282,6 +283,12 @@ function AiVaultVirtualRow({
|
|||
: null
|
||||
const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null
|
||||
const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null
|
||||
// Gate resume on real content: a zero-turn transcript would resume into an
|
||||
// empty conversation, so it is never offered as normally resumable.
|
||||
const resumeGating =
|
||||
row.type === 'session'
|
||||
? aiVaultSessionRowResumeGating(row.session, resumeState)
|
||||
: { resumeDisabled: true, canCopyResumeCommand: false }
|
||||
const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : ''
|
||||
const canOpenLocalSessionPaths =
|
||||
row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId)
|
||||
|
|
@ -309,7 +316,7 @@ function AiVaultVirtualRow({
|
|||
worktreeInfo={worktreeInfo}
|
||||
vaultScope={vaultScope}
|
||||
detailsExpanded={expandedSessionIds.has(row.session.id)}
|
||||
resumeDisabled={resumeState?.blocked ?? true}
|
||||
resumeDisabled={resumeGating.resumeDisabled}
|
||||
resumeLabel={resumeLabel}
|
||||
resumeActions={
|
||||
resumeActions ?? {
|
||||
|
|
@ -338,7 +345,11 @@ function AiVaultVirtualRow({
|
|||
onResume(row.session, resumeActions.newTab.worktreeId)
|
||||
}
|
||||
}}
|
||||
onCopyResume={() => onCopyResume(row.session, resumeState?.worktreeId)}
|
||||
onCopyResume={
|
||||
resumeGating.canCopyResumeCommand
|
||||
? () => onCopyResume(row.session, resumeState?.worktreeId)
|
||||
: undefined
|
||||
}
|
||||
onCopyId={() => onCopyId(row.session)}
|
||||
onCopyPath={() => onCopyPath(row.session)}
|
||||
onOpenLog={canOpenLocalSessionPaths ? () => onOpenLog(row.session) : undefined}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export function SessionRowTrailingActions({
|
|||
showJumpToWorktree: boolean
|
||||
onJumpToWorktree?: () => void
|
||||
onResume: () => void
|
||||
onCopyResume: () => void
|
||||
onCopyResume?: () => void
|
||||
onCopyId: () => void
|
||||
onCopyPath: () => void
|
||||
onOpenLog?: () => void
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ const baseSession: AiVaultSession = {
|
|||
messageCount: 2,
|
||||
totalTokens: 42,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: "codex resume 'session-1'"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ const baseSession: AiVaultSession = {
|
|||
{ role: 'assistant', text: 'I updated the fixture ordering', timestamp: null },
|
||||
{ role: 'system', text: 'hidden runtime bookkeeping', timestamp: null }
|
||||
],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: "cd '/Users/ada/repo/app' && codex resume 'session-1'"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ const baseSession: AiVaultSession = {
|
|||
messageCount: 4,
|
||||
totalTokens: 1200,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: "cd '/Users/ada/repo/app' && claude --resume 'session-1'"
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +137,60 @@ describe('filterAiVaultSessions', () => {
|
|||
expect(new Set(shownWhenAllowed)).toEqual(new Set(['claude:1', 'claude:empty']))
|
||||
})
|
||||
|
||||
it('keeps zero-turn sessions that carry recoverable content when hiding empties', () => {
|
||||
const recoverableEmpty: AiVaultSession = {
|
||||
...baseSession,
|
||||
id: 'claude:recoverable',
|
||||
sessionId: 'recoverable-session',
|
||||
title: 'Claude recoverable-session',
|
||||
messageCount: 0,
|
||||
queuedMessageCount: 4,
|
||||
subagentTranscriptCount: 2
|
||||
}
|
||||
const plainEmpty: AiVaultSession = {
|
||||
...baseSession,
|
||||
id: 'claude:plain-empty',
|
||||
sessionId: 'plain-empty',
|
||||
title: 'Claude plain-empty',
|
||||
messageCount: 0
|
||||
}
|
||||
|
||||
const shown = filterAiVaultSessions([recoverableEmpty, plainEmpty, baseSession], {
|
||||
query: '',
|
||||
agents: ['claude'],
|
||||
scope: 'all',
|
||||
sort: 'updated',
|
||||
activeWorktreePaths: [],
|
||||
hideEmptySessions: true
|
||||
}).map((session) => session.id)
|
||||
|
||||
expect(new Set(shown)).toEqual(new Set(['claude:1', 'claude:recoverable']))
|
||||
})
|
||||
|
||||
it('keeps zero-count sessions whose previews prove real turns when hiding empties', () => {
|
||||
// Grok-style: the turn count only comes from metadata that may be absent,
|
||||
// but the preview messages prove the conversation exists and is resumable.
|
||||
const previewOnly: AiVaultSession = {
|
||||
...baseSession,
|
||||
id: 'claude:preview-only',
|
||||
sessionId: 'preview-only',
|
||||
title: 'Claude preview-only',
|
||||
messageCount: 0,
|
||||
previewMessages: [{ role: 'user', text: 'ship the fix', timestamp: null }]
|
||||
}
|
||||
|
||||
const shown = filterAiVaultSessions([previewOnly], {
|
||||
query: '',
|
||||
agents: ['claude'],
|
||||
scope: 'all',
|
||||
sort: 'updated',
|
||||
activeWorktreePaths: [],
|
||||
hideEmptySessions: true
|
||||
}).map((session) => session.id)
|
||||
|
||||
expect(shown).toEqual(['claude:preview-only'])
|
||||
})
|
||||
|
||||
it('matches visible preview message text', () => {
|
||||
expect(
|
||||
filterAiVaultSessions(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ import type {
|
|||
AiVaultSession,
|
||||
AiVaultSort
|
||||
} from '../../../../shared/ai-vault-types'
|
||||
import { aiVaultAgentLabel } from '../../../../shared/ai-vault-types'
|
||||
import {
|
||||
aiVaultAgentLabel,
|
||||
isAiVaultSessionRecoverableEmpty,
|
||||
isAiVaultSessionResumableContent
|
||||
} from '../../../../shared/ai-vault-types'
|
||||
import { sessionPreviewSearchText } from './ai-vault-session-display'
|
||||
import type { AiVaultSessionProject } from './ai-vault-session-projects'
|
||||
|
||||
|
|
@ -64,7 +68,15 @@ export function filterAiVaultSessions(
|
|||
if (!agentSet.has(session.agent)) {
|
||||
return false
|
||||
}
|
||||
if (filters.hideEmptySessions && session.messageCount === 0) {
|
||||
// Hide plain empty sessions, but keep sessions with resumable content
|
||||
// (some parsers only learn turns from previews, e.g. Grok) and zero-turn
|
||||
// sessions that still carry recoverable content (queued prompts /
|
||||
// subagent transcripts) so a lost conversation is surfaced distinctly.
|
||||
if (
|
||||
filters.hideEmptySessions &&
|
||||
!isAiVaultSessionResumableContent(session) &&
|
||||
!isAiVaultSessionRecoverableEmpty(session)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (filters.scope === 'workspace') {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ const baseSession: AiVaultSession = {
|
|||
messageCount: 4,
|
||||
totalTokens: 1200,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: "cd '/Users/ada/orca' && claude --resume 'session-1'"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
|||
import { resolveAiVaultSessionLaunchTarget } from './ai-vault-session-launch-actions'
|
||||
import {
|
||||
aiVaultSessionResumeLabel,
|
||||
aiVaultSessionRowResumeGating,
|
||||
type AiVaultSessionResumeTargetState,
|
||||
resolveAiVaultSessionResumeActions,
|
||||
resolveAiVaultSessionResumeState
|
||||
|
|
@ -566,3 +567,45 @@ describe('aiVaultSessionResumeLabel', () => {
|
|||
expect(aiVaultSessionResumeLabel({ usesSessionWorktree: false })).toBe('Resume in New Tab')
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiVaultSessionRowResumeGating', () => {
|
||||
const zeroTurnSession = { messageCount: 0, previewMessages: [] }
|
||||
const sessionWithTurns = { messageCount: 3, previewMessages: [] }
|
||||
const unblocked = { blocked: false }
|
||||
|
||||
it('withholds every resume affordance for a zero-turn session', () => {
|
||||
expect(aiVaultSessionRowResumeGating(zeroTurnSession, unblocked)).toEqual({
|
||||
resumeDisabled: true,
|
||||
canCopyResumeCommand: false
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps copy-resume available when only the workspace target is blocked', () => {
|
||||
expect(aiVaultSessionRowResumeGating(sessionWithTurns, { blocked: true })).toEqual({
|
||||
resumeDisabled: true,
|
||||
canCopyResumeCommand: true
|
||||
})
|
||||
expect(aiVaultSessionRowResumeGating(sessionWithTurns, null)).toEqual({
|
||||
resumeDisabled: true,
|
||||
canCopyResumeCommand: true
|
||||
})
|
||||
})
|
||||
|
||||
it('treats user/assistant previews as resumable content when the turn count is unknown', () => {
|
||||
const previewOnlySession = {
|
||||
messageCount: 0,
|
||||
previewMessages: [{ role: 'user' as const, text: 'hello', timestamp: null }]
|
||||
}
|
||||
expect(aiVaultSessionRowResumeGating(previewOnlySession, unblocked)).toEqual({
|
||||
resumeDisabled: false,
|
||||
canCopyResumeCommand: true
|
||||
})
|
||||
})
|
||||
|
||||
it('enables resume for an unblocked session with turns', () => {
|
||||
expect(aiVaultSessionRowResumeGating(sessionWithTurns, unblocked)).toEqual({
|
||||
resumeDisabled: false,
|
||||
canCopyResumeCommand: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import {
|
|||
getAiVaultResumeWorkspaceExecutionHostId,
|
||||
getAiVaultResumeWorkspaceTargetStatus
|
||||
} from '@/lib/ai-vault-resume-target'
|
||||
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import {
|
||||
isAiVaultSessionResumableContent,
|
||||
type AiVaultSession
|
||||
} from '../../../../shared/ai-vault-types'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
|
|
@ -200,6 +203,21 @@ function resolveAiVaultResumeTargetState(args: {
|
|||
}
|
||||
}
|
||||
|
||||
// Resume needs actual conversation content: a zero-turn transcript would resume
|
||||
// into an empty session. Workspace-target blocking only disables in-app resume;
|
||||
// copying the command stays available for blocked-but-real sessions, so the copy
|
||||
// affordance is gated on content alone.
|
||||
export function aiVaultSessionRowResumeGating(
|
||||
session: Pick<AiVaultSession, 'messageCount' | 'previewMessages'>,
|
||||
state: Pick<AiVaultSessionResumeState, 'blocked'> | null
|
||||
): { resumeDisabled: boolean; canCopyResumeCommand: boolean } {
|
||||
const hasResumableContent = isAiVaultSessionResumableContent(session)
|
||||
return {
|
||||
resumeDisabled: (state?.blocked ?? true) || !hasResumableContent,
|
||||
canCopyResumeCommand: hasResumableContent
|
||||
}
|
||||
}
|
||||
|
||||
export function aiVaultSessionResumeLabel(
|
||||
state: Pick<AiVaultSessionResumeState, 'usesSessionWorktree'>
|
||||
): string {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ import { AgentIcon } from '@/lib/agent-catalog'
|
|||
import { useRepoById } from '@/store/selectors'
|
||||
import { resolveRepoBadgeColor } from '../../../../shared/repo-badge-color'
|
||||
import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id'
|
||||
import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import {
|
||||
isAiVaultSessionRecoverableEmpty,
|
||||
type AiVaultScope,
|
||||
type AiVaultSession
|
||||
} from '../../../../shared/ai-vault-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { SessionTime } from './AiVaultSessionDetails'
|
||||
import { agentLabel } from './ai-vault-session-filters'
|
||||
|
|
@ -45,6 +49,17 @@ export function SessionMetadata({
|
|||
{ value0: session.messageCount }
|
||||
)}
|
||||
</span>
|
||||
{isAiVaultSessionRecoverableEmpty(session) ? (
|
||||
<>
|
||||
<span className="shrink-0 text-muted-foreground/55">·</span>
|
||||
<span className="shrink-0 rounded-sm border border-dashed border-border/70 px-1 py-0 text-[10px] font-medium leading-4 text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.recoverableBadge',
|
||||
'Not saved'
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="shrink-0 text-muted-foreground/55">·</span>
|
||||
<SessionTime value={updatedAt} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ const baseSession: AiVaultSession = {
|
|||
messageCount: 2,
|
||||
totalTokens: 42,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: "codex resume 'session-1'"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9757,6 +9757,12 @@
|
|||
"originalAsk": "Original ask",
|
||||
"latestTurns": "Latest turns",
|
||||
"noPreviewAvailable": "No conversation preview available",
|
||||
"conversationNotSaved": "Conversation not saved",
|
||||
"recoverableEmptyDetail": "This session has no saved conversation, but {{value0}} recoverable item(s) survive.",
|
||||
"recoverableEmptyOpenLogHint": "Open the log to recover them.",
|
||||
"emptyConversationDetail": "This session has no saved conversation and cannot be resumed.",
|
||||
"queuedMessages": "{{value0}} queued message(s)",
|
||||
"subagentTranscripts": "{{value0}} subagent transcript(s)",
|
||||
"messageCount": "{{value0}} msgs",
|
||||
"updated": "Updated",
|
||||
"created": "Created",
|
||||
|
|
@ -9807,6 +9813,7 @@
|
|||
},
|
||||
"AiVaultSessionRow": {
|
||||
"noPreviewAvailable": "No conversation preview available",
|
||||
"recoverableBadge": "Not saved",
|
||||
"dragToResume": "Drag to resume in a new tab",
|
||||
"resumeAgentSession": "Resume {{value0}} session",
|
||||
"resumeInNewTab": "Resume in New Tab",
|
||||
|
|
|
|||
|
|
@ -9803,7 +9803,13 @@
|
|||
"sessionRole": "Sesión",
|
||||
"jumpToOriginalPane": "Saltar al panel original",
|
||||
"worktree": "Worktree",
|
||||
"jumpToWorktree": "Saltar al worktree"
|
||||
"jumpToWorktree": "Saltar al worktree",
|
||||
"conversationNotSaved": "Conversación no guardada",
|
||||
"recoverableEmptyDetail": "Esta sesión no tiene conversación guardada, pero quedan {{value0}} elemento(s) recuperable(s).",
|
||||
"recoverableEmptyOpenLogHint": "Abre el registro para recuperarlos.",
|
||||
"emptyConversationDetail": "Esta sesión no tiene conversación guardada y no se puede reanudar.",
|
||||
"queuedMessages": "{{value0}} mensaje(s) en cola",
|
||||
"subagentTranscripts": "{{value0}} transcripción(es) de subagente"
|
||||
},
|
||||
"AiVaultSessionRow": {
|
||||
"resumeAgentSession": "Reanudar sesión de {{value0}}",
|
||||
|
|
@ -9829,7 +9835,8 @@
|
|||
"systemRole": "Sistema",
|
||||
"sessionRole": "Sesión",
|
||||
"jumpToOriginalPane": "Saltar al panel original",
|
||||
"jumpToWorktree": "Saltar al worktree"
|
||||
"jumpToWorktree": "Saltar al worktree",
|
||||
"recoverableBadge": "No guardada"
|
||||
},
|
||||
"FileExplorerNameFilter": {
|
||||
"26fb73c6e3": "Buscar archivos",
|
||||
|
|
|
|||
|
|
@ -9803,7 +9803,13 @@
|
|||
"sessionRole": "セッション",
|
||||
"jumpToOriginalPane": "元のペインにジャンプ",
|
||||
"worktree": "ワークツリー",
|
||||
"jumpToWorktree": "ワークツリーにジャンプ"
|
||||
"jumpToWorktree": "ワークツリーにジャンプ",
|
||||
"conversationNotSaved": "会話が保存されていません",
|
||||
"recoverableEmptyDetail": "このセッションには保存された会話がありませんが、{{value0}} 件の復元可能な項目が残っています。",
|
||||
"recoverableEmptyOpenLogHint": "ログを開いて復元できます。",
|
||||
"emptyConversationDetail": "このセッションには保存された会話がなく、再開できません。",
|
||||
"queuedMessages": "キュー内のメッセージ {{value0}} 件",
|
||||
"subagentTranscripts": "サブエージェントの履歴 {{value0}} 件"
|
||||
},
|
||||
"AiVaultSessionRow": {
|
||||
"resumeAgentSession": "{{value0}} セッションを再開",
|
||||
|
|
@ -9829,7 +9835,8 @@
|
|||
"systemRole": "システム",
|
||||
"sessionRole": "セッション",
|
||||
"jumpToOriginalPane": "元のペインにジャンプ",
|
||||
"jumpToWorktree": "ワークツリーにジャンプ"
|
||||
"jumpToWorktree": "ワークツリーにジャンプ",
|
||||
"recoverableBadge": "未保存"
|
||||
},
|
||||
"FileExplorerNameFilter": {
|
||||
"26fb73c6e3": "ファイルを検索",
|
||||
|
|
|
|||
|
|
@ -9803,7 +9803,13 @@
|
|||
"sessionRole": "세션",
|
||||
"jumpToOriginalPane": "원래 창으로 이동",
|
||||
"worktree": "작업 트리",
|
||||
"jumpToWorktree": "작업 트리로 이동"
|
||||
"jumpToWorktree": "작업 트리로 이동",
|
||||
"conversationNotSaved": "대화가 저장되지 않음",
|
||||
"recoverableEmptyDetail": "이 세션에는 저장된 대화가 없지만 복구 가능한 항목이 {{value0}}개 남아 있습니다.",
|
||||
"recoverableEmptyOpenLogHint": "로그를 열어 복구하세요.",
|
||||
"emptyConversationDetail": "이 세션에는 저장된 대화가 없어 재개할 수 없습니다.",
|
||||
"queuedMessages": "대기 중인 메시지 {{value0}}개",
|
||||
"subagentTranscripts": "서브에이전트 대화 기록 {{value0}}개"
|
||||
},
|
||||
"AiVaultSessionRow": {
|
||||
"resumeAgentSession": "{{value0}} 세션 재개",
|
||||
|
|
@ -9829,7 +9835,8 @@
|
|||
"systemRole": "시스템",
|
||||
"sessionRole": "세션",
|
||||
"jumpToOriginalPane": "원래 창으로 이동",
|
||||
"jumpToWorktree": "작업 트리로 이동"
|
||||
"jumpToWorktree": "작업 트리로 이동",
|
||||
"recoverableBadge": "저장 안 됨"
|
||||
},
|
||||
"FileExplorerNameFilter": {
|
||||
"26fb73c6e3": "파일 찾기",
|
||||
|
|
|
|||
|
|
@ -9803,7 +9803,13 @@
|
|||
"sessionRole": "会话",
|
||||
"jumpToOriginalPane": "跳转到原始窗格",
|
||||
"worktree": "工作树",
|
||||
"jumpToWorktree": "跳转到工作树"
|
||||
"jumpToWorktree": "跳转到工作树",
|
||||
"conversationNotSaved": "对话未保存",
|
||||
"recoverableEmptyDetail": "此会话没有已保存的对话,但仍有 {{value0}} 个可恢复项。",
|
||||
"recoverableEmptyOpenLogHint": "打开日志以恢复它们。",
|
||||
"emptyConversationDetail": "此会话没有已保存的对话,无法恢复。",
|
||||
"queuedMessages": "{{value0}} 条排队消息",
|
||||
"subagentTranscripts": "{{value0}} 个子代理记录"
|
||||
},
|
||||
"AiVaultSessionRow": {
|
||||
"resumeAgentSession": "恢复 {{value0}} 会话",
|
||||
|
|
@ -9829,7 +9835,8 @@
|
|||
"systemRole": "系统",
|
||||
"sessionRole": "会话",
|
||||
"jumpToOriginalPane": "跳转到原始窗格",
|
||||
"jumpToWorktree": "跳转到工作树"
|
||||
"jumpToWorktree": "跳转到工作树",
|
||||
"recoverableBadge": "未保存"
|
||||
},
|
||||
"FileExplorerNameFilter": {
|
||||
"26fb73c6e3": "查找文件",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
aiVaultSessionRecoverableSignalCount,
|
||||
isAiVaultSessionRecoverableEmpty,
|
||||
isAiVaultSessionResumableContent,
|
||||
type AiVaultSessionPreviewMessage
|
||||
} from './ai-vault-types'
|
||||
|
||||
type SignalFields = {
|
||||
messageCount: number
|
||||
previewMessages: AiVaultSessionPreviewMessage[]
|
||||
queuedMessageCount: number
|
||||
subagentTranscriptCount: number
|
||||
}
|
||||
|
||||
function signal(overrides: Partial<SignalFields> = {}): SignalFields {
|
||||
return {
|
||||
messageCount: 0,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function preview(role: AiVaultSessionPreviewMessage['role']): AiVaultSessionPreviewMessage {
|
||||
return { role, text: 'preview text', timestamp: null }
|
||||
}
|
||||
|
||||
describe('isAiVaultSessionResumableContent', () => {
|
||||
it('is true when the transcript holds conversation turns', () => {
|
||||
expect(isAiVaultSessionResumableContent(signal({ messageCount: 2 }))).toBe(true)
|
||||
expect(isAiVaultSessionResumableContent(signal())).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts conversation previews when the turn count is missing', () => {
|
||||
// Some parsers (Grok, OpenCode fallback schemas) derive messageCount from
|
||||
// metadata that can be absent while real turns exist in previews.
|
||||
expect(isAiVaultSessionResumableContent(signal({ previewMessages: [preview('user')] }))).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
isAiVaultSessionResumableContent(signal({ previewMessages: [preview('assistant')] }))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores non-conversation previews', () => {
|
||||
expect(isAiVaultSessionResumableContent(signal({ previewMessages: [preview('system')] }))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAiVaultSessionRecoverableEmpty', () => {
|
||||
it('is true for a zero-turn session with queued or subagent signal', () => {
|
||||
expect(isAiVaultSessionRecoverableEmpty(signal({ queuedMessageCount: 3 }))).toBe(true)
|
||||
expect(isAiVaultSessionRecoverableEmpty(signal({ subagentTranscriptCount: 1 }))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a plain empty session or one with resumable content', () => {
|
||||
expect(isAiVaultSessionRecoverableEmpty(signal())).toBe(false)
|
||||
expect(
|
||||
isAiVaultSessionRecoverableEmpty(signal({ messageCount: 2, queuedMessageCount: 4 }))
|
||||
).toBe(false)
|
||||
expect(
|
||||
isAiVaultSessionRecoverableEmpty(
|
||||
signal({ previewMessages: [preview('user')], queuedMessageCount: 4 })
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('aiVaultSessionRecoverableSignalCount', () => {
|
||||
it('sums queued messages and subagent transcripts, clamping negatives', () => {
|
||||
expect(
|
||||
aiVaultSessionRecoverableSignalCount({
|
||||
queuedMessageCount: 4,
|
||||
subagentTranscriptCount: 2
|
||||
})
|
||||
).toBe(6)
|
||||
expect(
|
||||
aiVaultSessionRecoverableSignalCount({
|
||||
queuedMessageCount: -1,
|
||||
subagentTranscriptCount: 3
|
||||
})
|
||||
).toBe(3)
|
||||
})
|
||||
})
|
||||
|
|
@ -72,9 +72,48 @@ export type AiVaultSession = {
|
|||
messageCount: number
|
||||
totalTokens: number
|
||||
previewMessages: AiVaultSessionPreviewMessage[]
|
||||
// Recoverable signal for sessions whose conversation transcript persisted zero
|
||||
// user/assistant turns: queued (never-flushed) prompts and sibling subagent
|
||||
// transcripts survive even when the main conversation was lost.
|
||||
queuedMessageCount: number
|
||||
subagentTranscriptCount: number
|
||||
resumeCommand: string
|
||||
}
|
||||
|
||||
// A session is only offered for normal resume when its transcript actually holds
|
||||
// conversation turns; resuming a zero-turn transcript lands in an empty session.
|
||||
// Conversation previews count as evidence too: some parsers (e.g. Grok, OpenCode
|
||||
// fallback schemas) only learn the turn count from metadata that may be absent.
|
||||
export function isAiVaultSessionResumableContent(
|
||||
session: Pick<AiVaultSession, 'messageCount' | 'previewMessages'>
|
||||
): boolean {
|
||||
return (
|
||||
session.messageCount > 0 ||
|
||||
session.previewMessages.some(
|
||||
(message) => message.role === 'user' || message.role === 'assistant'
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function aiVaultSessionRecoverableSignalCount(
|
||||
session: Pick<AiVaultSession, 'queuedMessageCount' | 'subagentTranscriptCount'>
|
||||
): number {
|
||||
return Math.max(0, session.queuedMessageCount) + Math.max(0, session.subagentTranscriptCount)
|
||||
}
|
||||
|
||||
// Zero-turn transcript that still carries recoverable content (queued prompts
|
||||
// and/or subagent transcripts). Surfaced distinctly instead of hidden as empty.
|
||||
export function isAiVaultSessionRecoverableEmpty(
|
||||
session: Pick<
|
||||
AiVaultSession,
|
||||
'messageCount' | 'previewMessages' | 'queuedMessageCount' | 'subagentTranscriptCount'
|
||||
>
|
||||
): boolean {
|
||||
return (
|
||||
!isAiVaultSessionResumableContent(session) && aiVaultSessionRecoverableSignalCount(session) > 0
|
||||
)
|
||||
}
|
||||
|
||||
export type AiVaultScanIssue = {
|
||||
executionHostId?: ExecutionHostId
|
||||
agent: AiVaultAgent
|
||||
|
|
@ -120,7 +159,13 @@ export function buildAiVaultResumeCommand(args: {
|
|||
: quoteShellArg(resumeTarget, platform)
|
||||
const resumeCommand = buildAgentResumeInvocation(agent, baseCommand, sessionArg)
|
||||
|
||||
return buildAiVaultResumeShellCommand({ resumeCommand, cwd, platform, codexHome, shell })
|
||||
return buildAiVaultResumeShellCommand({
|
||||
resumeCommand,
|
||||
cwd,
|
||||
platform,
|
||||
codexHome,
|
||||
shell
|
||||
})
|
||||
}
|
||||
|
||||
export function buildAiVaultResumeShellCommand(args: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue