From 5cd435530c5a435e566fbddbbc6c3f6535e9acac Mon Sep 17 00:00:00 2001 From: Jonas Kunert Date: Tue, 21 Jul 2026 19:22:34 +0200 Subject: [PATCH 1/2] Recover orphaned conversation files on startup (#10) Scan the conversations directory for .json files missing from the workspace-state index (e.g. after a crash or window reload) and re-add them via _updateConversationIndex so they show up in history again instead of being silently lost. Co-Authored-By: Claude Fable 5 --- src/extension.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/extension.ts b/src/extension.ts index 8fa37fb..fce0b0c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1873,6 +1873,9 @@ class ClaudeChatProvider { } catch { await vscode.workspace.fs.createDirectory(vscode.Uri.file(this._conversationsPath)); } + + // Recover conversation files whose index entry was lost (e.g. crash/reload) + await this._recoverOrphanedConversations(); } catch (error: any) { console.error('Failed to initialize conversations directory:', error.message); } @@ -3268,6 +3271,39 @@ class ClaudeChatProvider { return this._conversationIndex.length > 0 ? this._conversationIndex[0] : undefined; } + // Scan the conversations directory for .json files missing from the index + // (e.g. after a crash/reload) and re-add them so they don't get lost. + private async _recoverOrphanedConversations(): Promise { + if (!this._conversationsPath) { return; } + + try { + const entries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(this._conversationsPath)); + const indexedFilenames = new Set(this._conversationIndex.map(entry => entry.filename)); + let recovered = 0; + + for (const [name, type] of entries) { + if ((type & vscode.FileType.File) === 0 || !name.endsWith('.json')) { continue; } + if (indexedFilenames.has(name)) { continue; } + + try { + const filePath = path.join(this._conversationsPath, name); + const content = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)); + const conversationData = JSON.parse(new TextDecoder().decode(content)); + this._updateConversationIndex(name, conversationData); + recovered++; + } catch { + // Skip files that can't be parsed + } + } + + if (recovered > 0) { + console.log(`Recovered ${recovered} orphaned conversation(s)`); + } + } catch { + // Conversations directory may not exist + } + } + private async _loadConversationHistory(filename: string): Promise { if (!this._conversationsPath) { return; } From cfcb13562c6dae7d3fe8cc8471e86a8bae4743ab Mon Sep 17 00:00:00 2001 From: Jonas Kunert Date: Tue, 21 Jul 2026 19:30:02 +0200 Subject: [PATCH 2/2] Fix ordering, schema validation and index persistence in recovery (#10) - Re-sort the index by startTime after recovering orphaned files instead of unshifting them, so an old recovered conversation is no longer mistaken for the latest one (which would resume --resume into a stale sessionId). - Validate parsed conversation data (messages array, sessionId, numeric totalCost) before indexing it, so a foreign/old-schema JSON file can no longer produce a broken index entry. - Build recovered entries in-memory and persist the index once after the scan instead of once per recovered file, avoiding unordered stacked workspaceState writes. Extracted _buildConversationIndexEntry so the normal save path and recovery share the same entry shape. - Load the conversation index before _initializeConversations() runs, since recovery inside it depends on the index already being loaded. Co-Authored-By: Claude Fable 5 --- src/extension.ts | 44 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index fce0b0c..3bc53a1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -207,11 +207,13 @@ class ClaudeChatProvider { // Initialize backup repository and conversations this._initializeBackupRepo(); - this._initializeConversations(); - // Load conversation index from workspace state + // Load conversation index from workspace state (before recovery runs inside + // _initializeConversations, which relies on this being populated already) this._conversationIndex = this._context.workspaceState.get('claude.conversationIndex', []); + this._initializeConversations(); + // Load saved model preference this._selectedModel = this._context.workspaceState.get('claude.selectedModel', 'default'); @@ -3234,14 +3236,15 @@ class ClaudeChatProvider { this._sendOpenCreditsBalance(); } - private _updateConversationIndex(filename: string, conversationData: ConversationData): void { + // Build an index entry from parsed conversation data; shared by the normal save + // path and the orphan-recovery path. + private _buildConversationIndexEntry(filename: string, conversationData: ConversationData) { // Extract first and last user messages const userMessages = conversationData.messages.filter((m: any) => m.messageType === 'userInput'); const firstUserMessage = userMessages.length > 0 ? userMessages[0].data : 'No user message'; const lastUserMessage = userMessages.length > 0 ? userMessages[userMessages.length - 1].data : firstUserMessage; - // Create or update index entry - const indexEntry = { + return { filename: filename, sessionId: conversationData.sessionId, startTime: conversationData.startTime || '', @@ -3251,6 +3254,10 @@ class ClaudeChatProvider { firstUserMessage: firstUserMessage.substring(0, 100), // Truncate for storage lastUserMessage: lastUserMessage.substring(0, 100) }; + } + + private _updateConversationIndex(filename: string, conversationData: ConversationData): void { + const indexEntry = this._buildConversationIndexEntry(filename, conversationData); // Remove any existing entry for this session (in case of updates) this._conversationIndex = this._conversationIndex.filter(entry => entry.filename !== conversationData.filename); @@ -3289,7 +3296,18 @@ class ClaudeChatProvider { const filePath = path.join(this._conversationsPath, name); const content = await vscode.workspace.fs.readFile(vscode.Uri.file(filePath)); const conversationData = JSON.parse(new TextDecoder().decode(content)); - this._updateConversationIndex(name, conversationData); + + // Skip files that don't look like a valid conversation (e.g. foreign/old schema) + if (!Array.isArray(conversationData?.messages) || typeof conversationData.sessionId !== 'string' || !conversationData.sessionId) { + continue; + } + if (typeof conversationData.totalCost !== 'number') { + conversationData.totalCost = 0; + } + + // Add to the in-memory index only — do not call _updateConversationIndex here, + // which would unshift it as "latest" and persist to workspace state per file. + this._conversationIndex.push(this._buildConversationIndexEntry(name, conversationData)); recovered++; } catch { // Skip files that can't be parsed @@ -3297,6 +3315,20 @@ class ClaudeChatProvider { } if (recovered > 0) { + // Re-sort by startTime (descending) so recovered — possibly old — + // conversations don't get treated as "latest" just because they were + // appended last. Missing/empty startTime sorts to the end. + this._conversationIndex.sort((a, b) => { + const aTime = a.startTime || ''; + const bTime = b.startTime || ''; + return aTime < bTime ? 1 : aTime > bTime ? -1 : 0; + }); + + if (this._conversationIndex.length > 50) { + this._conversationIndex = this._conversationIndex.slice(0, 50); + } + + await this._context.workspaceState.update('claude.conversationIndex', this._conversationIndex); console.log(`Recovered ${recovered} orphaned conversation(s)`); } } catch {