From 7f14f64db7908eb7ae9d0368b311335e64251639 Mon Sep 17 00:00:00 2001 From: Jonas Kunert Date: Wed, 22 Jul 2026 18:06:28 +0200 Subject: [PATCH] fix: key conversation history by project path, not workspace identity Conversation files (storageUri/conversations) and the index (workspaceState) were both scoped to the VS Code workspace identity, so opening the same folder directly vs. through a .code-workspace file produced two separate histories (#123). - new project key: sha256 over the normalized (win32: lowercased) path of the primary workspace folder, 16 hex chars; 'no-workspace' when no folder is open, - conversation files now live in globalStorageUri/conversations//, the index in globalState['claude.conversationIndex::'], - one-time idempotent migration copies (never moves) legacy files and merges the legacy index (dedupe by filename, cap 50); the migrated flag is only set once every copy succeeded, so a failed copy is retried on the next start; legacy data stays in place as rollback, - the legacy index fallback in the constructor is gated on the migration flag so an intentionally cleared history stays empty instead of resurrecting legacy entries, - multi-root (#22): new setting claudeCodeChat.workspace.root picks the workspace folder used as Claude's cwd and backup root; empty default keeps today's behavior (first folder). Claude runs and checkpoints follow the chosen root via _getPrimaryWorkspaceFolder(). Out of scope (follow-ups): storing history inside the project (#124), moving the backup git repo and permissions.json off the workspace identity. Co-Authored-By: Claude Fable 5 --- package.json | 5 ++ src/extension.ts | 169 ++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 158 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 6bcdf89..7f6300e 100644 --- a/package.json +++ b/package.json @@ -208,6 +208,11 @@ "type": "boolean", "default": false, "description": "Enable the local router to convert OpenAI format to Anthropic format. Required for providers that use OpenAI-compatible APIs." + }, + "claudeCodeChat.workspace.root": { + "type": "string", + "default": "", + "description": "Multi-root workspaces: path of the workspace folder Claude should use as its working directory. Empty = first workspace folder." } } } diff --git a/src/extension.ts b/src/extension.ts index 8fa37fb..b7f2bd7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,6 +3,7 @@ import * as cp from 'child_process'; import * as util from 'util'; import * as path from 'path'; import * as os from 'os'; +import * as crypto from 'crypto'; import getHtml from './ui'; import { startRouter, stopRouter, setModelConfig, setBaseUrl } from './router'; import { fetchAndResolveModels } from './model-updater'; @@ -172,6 +173,10 @@ class ClaudeChatProvider { private _backupRepoPath: string | undefined; private _commits: Array<{ id: string, sha: string, message: string, timestamp: string }> = []; private _conversationsPath: string | undefined; + // Stable per-project key derived from the primary workspace folder's path, + // used to scope conversation history so it stays consistent whether the folder + // is opened directly or as part of a .code-workspace file. + private _projectKey: string | undefined; // Pending permission requests from stdio control_request messages private _pendingPermissionRequests: Map { try { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workspaceFolder = this._getPrimaryWorkspaceFolder(); if (!workspaceFolder) { return; } const storagePath = this._context.storageUri?.fsPath; @@ -1751,7 +1771,7 @@ class ClaudeChatProvider { private async _createBackupCommit(userMessage: string): Promise { try { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workspaceFolder = this._getPrimaryWorkspaceFolder(); if (!workspaceFolder || !this._backupRepoPath) { return; } const workspacePath = workspaceFolder.uri.fsPath; @@ -1821,7 +1841,7 @@ class ClaudeChatProvider { return; } - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const workspaceFolder = this._getPrimaryWorkspaceFolder(); if (!workspaceFolder || !this._backupRepoPath) { vscode.window.showErrorMessage('No workspace folder or backup repository available.'); return; @@ -1857,15 +1877,46 @@ class ClaudeChatProvider { } } - private async _initializeConversations(): Promise { - try { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { return; } + // Resolve the workspace folder Claude should treat as its working directory + // (cwd, backup repo work-tree). Defaults to the first workspace folder; can be + // overridden for multi-root workspaces via claudeCodeChat.workspace.root. + private _getPrimaryWorkspaceFolder(): vscode.WorkspaceFolder | undefined { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) { return undefined; } + + const configuredRoot = vscode.workspace.getConfiguration('claudeCodeChat').get('workspace.root', '').trim(); + if (configuredRoot) { + const normalizedRoot = path.normalize(configuredRoot); + const match = folders.find(folder => { + const normalizedFolder = path.normalize(folder.uri.fsPath); + return process.platform === 'win32' + ? normalizedFolder.toLowerCase() === normalizedRoot.toLowerCase() + : normalizedFolder === normalizedRoot; + }); + if (match) { return match; } + } - const storagePath = this._context.storageUri?.fsPath; - if (!storagePath) { return; } + return folders[0]; + } - this._conversationsPath = path.join(storagePath, 'conversations'); + // Derive a stable per-project key from the primary workspace folder's path, so + // the same folder keeps a single conversation history regardless of whether it + // was opened directly or as part of a .code-workspace file — those use + // different VS Code workspace identities and used to split the history (#123). + // Falls back to a fixed key when no workspace folder is open. + private _getProjectKey(): string { + const workspaceFolder = this._getPrimaryWorkspaceFolder(); + if (!workspaceFolder) { return 'no-workspace'; } + + const normalizedPath = path.normalize(workspaceFolder.uri.fsPath); + const key = process.platform === 'win32' ? normalizedPath.toLowerCase() : normalizedPath; + return crypto.createHash('sha256').update(key).digest('hex').slice(0, 16); + } + + private async _initializeConversations(): Promise { + try { + const projectKey = this._projectKey ?? this._getProjectKey(); + this._conversationsPath = path.join(this._context.globalStorageUri.fsPath, 'conversations', projectKey); // Create conversations directory if it doesn't exist try { @@ -1873,11 +1924,97 @@ class ClaudeChatProvider { } catch { await vscode.workspace.fs.createDirectory(vscode.Uri.file(this._conversationsPath)); } + + // One-time copy of legacy workspace-scoped history into the new + // project-scoped location (never moves/deletes the originals) + await this._migrateLegacyHistory(); } catch (error: any) { console.error('Failed to initialize conversations directory:', error.message); } } + // Legacy behavior (#123): conversation files lived under the workspace-scoped + // storageUri and the index lived in workspaceState, both keyed by the VS Code + // workspace identity (folder vs. .code-workspace hash differ for the same + // folder). Copies (never moves/deletes) any such files into the new + // project-scoped globalStorage location and merges the legacy index into the + // new one. Guarded so it only ever runs once per project. + private async _migrateLegacyHistory(): Promise { + if (!this._projectKey || !this._conversationsPath) { return; } + + const migratedFlagKey = `claude.historyMigrated::${this._projectKey}`; + if (this._context.globalState.get(migratedFlagKey, false)) { return; } + + try { + const legacyConversationsPath = this._context.storageUri + ? path.join(this._context.storageUri.fsPath, 'conversations') + : undefined; + + let mergedIndex = this._conversationIndex; + let copyErrors = 0; + + if (legacyConversationsPath) { + let legacyEntries: Array<[string, vscode.FileType]> = []; + try { + legacyEntries = await vscode.workspace.fs.readDirectory(vscode.Uri.file(legacyConversationsPath)); + } catch { + legacyEntries = []; + } + + for (const [name, type] of legacyEntries) { + if ((type & vscode.FileType.File) === 0 || !name.endsWith('.json')) { continue; } + + const destPath = path.join(this._conversationsPath, name); + try { + await vscode.workspace.fs.stat(vscode.Uri.file(destPath)); + continue; // Already present at the destination, don't overwrite + } catch { + // Doesn't exist yet, copy it + } + + try { + const content = await vscode.workspace.fs.readFile(vscode.Uri.file(path.join(legacyConversationsPath, name))); + await vscode.workspace.fs.writeFile(vscode.Uri.file(destPath), content); + } catch (error: any) { + console.error('Failed to migrate conversation file:', name, error.message); + copyErrors++; + } + } + + const legacyIndex = this._context.workspaceState.get('claude.conversationIndex', []); + + const byFilename = new Map(); + for (const entry of [...mergedIndex, ...legacyIndex]) { + if (!byFilename.has(entry.filename)) { + byFilename.set(entry.filename, entry); + } + } + + mergedIndex = Array.from(byFilename.values()).sort((a, b) => { + const aTime = a.startTime || ''; + const bTime = b.startTime || ''; + return aTime < bTime ? 1 : aTime > bTime ? -1 : 0; + }); + + if (mergedIndex.length > 50) { + mergedIndex = mergedIndex.slice(0, 50); + } + } + + this._conversationIndex = mergedIndex; + await this._context.globalState.update(`claude.conversationIndex::${this._projectKey}`, this._conversationIndex); + + // Only mark as migrated if every file copy succeeded — migration is + // idempotent (only copies missing destinations), so leaving the flag + // unset makes the next activation retry the failed file(s). The index + // merge above is safe to keep either way; originals stay in storageUri. + if (copyErrors === 0) { + await this._context.globalState.update(migratedFlagKey, true); + } + } catch (error: any) { + console.error('Failed to migrate legacy conversation history:', error.message); + } + } /** * Check if a tool is pre-approved in local permissions @@ -3260,8 +3397,8 @@ class ClaudeChatProvider { this._conversationIndex = this._conversationIndex.slice(0, 50); } - // Save to workspace state - this._context.workspaceState.update('claude.conversationIndex', this._conversationIndex); + // Save to global state, scoped to this project + this._context.globalState.update(`claude.conversationIndex::${this._projectKey}`, this._conversationIndex); } private _getLatestConversation(): any | undefined {