Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 73 additions & 5 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -1873,6 +1875,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);
}
Expand Down Expand Up @@ -3231,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 || '',
Expand All @@ -3248,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);
Expand All @@ -3268,6 +3278,64 @@ 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<void> {
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));

// 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
}
}

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 {
// Conversations directory may not exist
}
}

private async _loadConversationHistory(filename: string): Promise<void> {
if (!this._conversationsPath) { return; }

Expand Down