diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index e8d43ae1f..c2b3bd344 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -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, diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index d5a4b2f1a..c65ef99ad 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -21,6 +21,9 @@ export type RemoteSessionSource = { rootDir: string extensions: readonly string[] filePredicate?: (path: string) => boolean + // Claude layout: count `/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 } diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index 16173f7b7..3d7755bce 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -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( diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 1b7d66cb7..9e09cf29e 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -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 { - 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, diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index ffb2a1c39..0044929c3 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -139,6 +139,8 @@ function session( messageCount: 1, totalTokens: 0, previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, resumeCommand: `codex resume ${sessionId}` } } diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index 7a7008922..2b75c7584 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -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() }) ), diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index 8e44e30c6..5d7d1df93 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -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, diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index 5a81ff260..22c6cd46a 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -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, diff --git a/src/main/ai-vault/session-scanner-gemini-parsers.ts b/src/main/ai-vault/session-scanner-gemini-parsers.ts new file mode 100644 index 000000000..6f0b9fdc7 --- /dev/null +++ b/src/main/ai-vault/session-scanner-gemini-parsers.ts @@ -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 { + 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 { + 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 { + 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 | Iterable + platform: NodeJS.Platform + options?: ResumableParseFinalizeOptions +}): Promise { + 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 | null +): void { + if (!record) { + return + } + updateTimeline(accumulator, extractString(record.timestamp)) + if (record.type === 'user') { + accumulator.messageCount++ + accumulator.title ??= extractContentText(record.content) + addPreviewContent(accumulator, 'user', record.content, record.timestamp) + return + } + if (record.type === 'gemini') { + accumulator.messageCount++ + addPreviewContent(accumulator, 'assistant', record.content, record.timestamp) + const model = extractString(record.model) + if (model) { + accumulator.model = model + } + accumulator.totalTokens += tokenTotal(record.tokens) + } +} diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index 20650f105..70d0d311d 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -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 } diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index e07329a30..c72d0da82 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -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 { // 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 { - 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 { - 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 { - 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 | Iterable - platform: NodeJS.Platform - options?: ParserSessionOptions -}): Promise { - 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 | null -): void { - if (!record) { - return - } - updateTimeline(accumulator, extractString(record.timestamp)) - if (record.type === 'user') { - accumulator.messageCount++ - accumulator.title ??= extractContentText(record.content) - addPreviewContent(accumulator, 'user', record.content, record.timestamp) - return - } - if (record.type === 'gemini') { - accumulator.messageCount++ - addPreviewContent(accumulator, 'assistant', record.content, record.timestamp) - const model = extractString(record.model) - if (model) { - accumulator.model = model - } - accumulator.totalTokens += tokenTotal(record.tokens) - } -} diff --git a/src/main/ai-vault/session-scanner-recoverable-empty.test.ts b/src/main/ai-vault/session-scanner-recoverable-empty.test.ts new file mode 100644 index 000000000..21409fbc6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-recoverable-empty.test.ts @@ -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: `Review complete` + })) + ] +} + +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) + }) +}) diff --git a/src/main/ai-vault/session-scanner-subagent-transcripts.ts b/src/main/ai-vault/session-scanner-subagent-transcripts.ts new file mode 100644 index 000000000..303b9b968 --- /dev/null +++ b/src/main/ai-vault/session-scanner-subagent-transcripts.ts @@ -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 (…//.jsonl → …///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 { + 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: `//subagents/.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 +} { + const sessionFilePaths: string[] = [] + const subagentTranscriptCounts = new Map() + 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 } +} diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 5364e29e2..803554316 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -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 } diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 0fae2259f..ab1c7d779 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -237,6 +237,8 @@ function session( messageCount: 1, totalTokens: 0, previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, resumeCommand: `codex resume ${sessionId}` } } diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx index e4e16f4dc..87d29aaa6 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionActionMenuItems.tsx @@ -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({ {resumeLabel} - - - {translate( - 'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand', - 'Copy Resume Command' - )} - + {onCopyResume ? ( + + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.copyResumeCommand', + 'Copy Resume Command' + )} + + ) : null} {hasLocalPathActions ? ( <> diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx index 85f5b2a57..f5d6b4eb3 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionDetails.tsx @@ -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({ }} >
- } - label={translate( - 'auto.components.right.sidebar.AiVaultSessionDetails.latestTurns', - 'Latest turns' - )} - > - {detailTurns.length > 0 ? ( -
- {detailTurns.map((turn, index) => ( - - ))} -
- ) : ( - - )} -
+ {hasResumableContent ? ( + } + label={translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.latestTurns', + 'Latest turns' + )} + > + {detailTurns.length > 0 ? ( +
+ {detailTurns.map((turn, index) => ( + + ))} +
+ ) : ( + + )} +
+ ) : ( + // An unsaved session has no turns to show; the notice replaces the + // preview section instead of stacking a second empty state under it. + + )} - {shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, { vaultScope }) ? ( + {shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, { + vaultScope + }) ? ( } label={translate( @@ -94,64 +112,66 @@ export function SessionInlineDetails({ ) : null}
-
- {showResumeInWorktree ? ( - - ) : null} - {showResumeInNewTab ? ( - - ) : null} - {onOpenLog ? ( - - ) : null} -
+ {showResumeInWorktree || showResumeInNewTab || onOpenLog ? ( +
+ {showResumeInWorktree ? ( + + ) : null} + {showResumeInNewTab ? ( + + ) : null} + {onOpenLog ? ( + + ) : null} +
+ ) : null} ) } @@ -219,7 +239,9 @@ function WorktreeMetadataLines({ return (
- {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, { vaultScope }) ? ( + {shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, { + vaultScope + }) ? ( <> {aiVaultWorktreeStatusLabel(worktreeInfo.status)} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx index b0d880163..bf9f147e7 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionRow.tsx @@ -64,7 +64,7 @@ export function VaultSessionRow({ resumeActions: AiVaultSessionResumeActions onResumeInWorktree: () => void onResumeInNewTab: () => void - onCopyResume: () => void + onCopyResume?: () => void onCopyId: () => void onCopyPath: () => void onOpenLog?: () => void diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionUnsavedNotice.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionUnsavedNotice.tsx new file mode 100644 index 000000000..56a3e951c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionUnsavedNotice.tsx @@ -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 ( +
+
+ + + {translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.conversationNotSaved', + 'Conversation not saved' + )} + +
+
+ {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 ? ( + + ) : null} +
+
+ ) +} + +function SessionRecoverableSignalLines({ + queuedMessageCount, + subagentTranscriptCount +}: { + queuedMessageCount: number + subagentTranscriptCount: number +}): React.JSX.Element { + return ( +
    + {queuedMessageCount > 0 ? ( +
  • + {translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.queuedMessages', + '{{value0}} queued message(s)', + { value0: queuedMessageCount } + )} +
  • + ) : null} + {subagentTranscriptCount > 0 ? ( +
  • + {translate( + 'auto.components.right.sidebar.AiVaultSessionDetails.subagentTranscripts', + '{{value0}} subagent transcript(s)', + { value0: subagentTranscriptCount } + )} +
  • + ) : null} +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx index 545b40955..505d793d2 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.tsx @@ -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} diff --git a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx index 10167da34..dbb5cfaf7 100644 --- a/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx +++ b/src/renderer/src/components/right-sidebar/SessionRowTrailingActions.tsx @@ -64,7 +64,7 @@ export function SessionRowTrailingActions({ showJumpToWorktree: boolean onJumpToWorktree?: () => void onResume: () => void - onCopyResume: () => void + onCopyResume?: () => void onCopyId: () => void onCopyPath: () => void onOpenLog?: () => void diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts index c47f64b97..e5fd9c270 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.test.ts @@ -25,6 +25,8 @@ const baseSession: AiVaultSession = { messageCount: 2, totalTokens: 42, previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, resumeCommand: "codex resume 'session-1'" } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts index 3da906781..bd2c3a080 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-display.test.ts @@ -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'" } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts index acbe0b051..3f1012d61 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.test.ts @@ -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( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts index 21e9d61de..61dd7119f 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts @@ -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') { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts index d0e4c1a1c..b73e2afd8 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -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'" } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts index 381b1d49c..978033825 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts @@ -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 + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts index eb82d910c..a166adf01 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -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, + state: Pick | null +): { resumeDisabled: boolean; canCopyResumeCommand: boolean } { + const hasResumableContent = isAiVaultSessionResumableContent(session) + return { + resumeDisabled: (state?.blocked ?? true) || !hasResumableContent, + canCopyResumeCommand: hasResumableContent + } +} + export function aiVaultSessionResumeLabel( state: Pick ): string { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx index 1d3972a6f..c356cb134 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-row-display.tsx @@ -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 } )}
+ {isAiVaultSessionRecoverableEmpty(session) ? ( + <> + · + + {translate( + 'auto.components.right.sidebar.AiVaultSessionRow.recoverableBadge', + 'Not saved' + )} + + + ) : null} ·
diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts index 56ba2f246..0b3e2c8d7 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts @@ -31,6 +31,8 @@ const baseSession: AiVaultSession = { messageCount: 2, totalTokens: 42, previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, resumeCommand: "codex resume 'session-1'" } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0ab7e8d57..af0a3f6d9 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d167b12eb..46b775190 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 1a5f93b5d..a774406d4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": "ファイルを検索", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 2a869c6dd..d3fd20216 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": "파일 찾기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 033ee3e01..8b83cb8f9 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": "查找文件", diff --git a/src/shared/ai-vault-types.test.ts b/src/shared/ai-vault-types.test.ts new file mode 100644 index 000000000..e832a9958 --- /dev/null +++ b/src/shared/ai-vault-types.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index df6abfb83..0696f770a 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -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 +): boolean { + return ( + session.messageCount > 0 || + session.previewMessages.some( + (message) => message.role === 'user' || message.role === 'assistant' + ) + ) +} + +export function aiVaultSessionRecoverableSignalCount( + session: Pick +): 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: {