From 4ded642a3a78a6623774db853d8a82d5daa7283e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:47:02 -0700 Subject: [PATCH] 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. --- .../remote-session-scanner-sources.ts | 26 +- .../ai-vault/remote-session-scanner-types.ts | 4 + .../ai-vault/remote-session-scanner.test.ts | 73 ++++++ src/main/ai-vault/remote-session-scanner.ts | 22 +- .../ai-vault/runtime-session-scanner.test.ts | 2 + src/main/ai-vault/runtime-session-scanner.ts | 4 + .../ai-vault/session-scanner-accumulator.ts | 4 + .../ai-vault/session-scanner-agent-parser.ts | 3 +- .../session-scanner-gemini-parsers.ts | 156 +++++++++++ .../ai-vault/session-scanner-parse-cache.ts | 17 +- .../session-scanner-primary-parsers.ts | 171 +++--------- .../session-scanner-recoverable-empty.test.ts | 245 ++++++++++++++++++ .../session-scanner-subagent-transcripts.ts | 65 +++++ src/main/ai-vault/session-scanner-types.ts | 3 + src/main/ipc/ai-vault.test.ts | 2 + .../AiVaultSessionActionMenuItems.tsx | 20 +- .../right-sidebar/AiVaultSessionDetails.tsx | 200 +++++++------- .../right-sidebar/AiVaultSessionRow.tsx | 2 +- .../AiVaultSessionUnsavedNotice.tsx | 91 +++++++ .../AiVaultSessionVirtualList.tsx | 15 +- .../SessionRowTrailingActions.tsx | 2 +- .../ai-vault-original-pane.test.ts | 2 + .../ai-vault-session-display.test.ts | 2 + .../ai-vault-session-filters.test.ts | 56 ++++ .../right-sidebar/ai-vault-session-filters.ts | 16 +- .../ai-vault-session-projects.test.ts | 2 + .../ai-vault-session-resume.test.ts | 43 +++ .../right-sidebar/ai-vault-session-resume.ts | 20 +- .../ai-vault-session-row-display.tsx | 17 +- .../ai-vault-session-worktree.test.ts | 2 + src/renderer/src/i18n/locales/en.json | 7 + src/renderer/src/i18n/locales/es.json | 11 +- src/renderer/src/i18n/locales/ja.json | 11 +- src/renderer/src/i18n/locales/ko.json | 11 +- src/renderer/src/i18n/locales/zh.json | 11 +- src/shared/ai-vault-types.test.ts | 88 +++++++ src/shared/ai-vault-types.ts | 47 +++- 37 files changed, 1201 insertions(+), 272 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-gemini-parsers.ts create mode 100644 src/main/ai-vault/session-scanner-recoverable-empty.test.ts create mode 100644 src/main/ai-vault/session-scanner-subagent-transcripts.ts create mode 100644 src/renderer/src/components/right-sidebar/AiVaultSessionUnsavedNotice.tsx create mode 100644 src/shared/ai-vault-types.test.ts 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: {