From dcce266015dc4596a8ab010257a8191593df2371 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 17 Jul 2026 01:49:00 +0800 Subject: [PATCH 01/11] fix(webview): isolate parallel provider view state Add view-local state loading and syncing for parallel ClineProvider instances, with safe initialization and configuration listener handling for test/shim environments. Key changes: - Add viewId property for unique provider instance identification - Implement viewLocalState buffer to isolate mode, profile, and apiConfig per view - Load view-local state after provider dependencies are initialized - Merge viewLocalState in getState() with local apiConfiguration taking precedence - Clear local overrides when saveViewState receives undefined or null values - Sync mode/profile changes through saveViewState(), loadViewState(), and profile activation - Preserve restored task mode in both global state and viewLocalState - Guard global configuration listener setup when VS Code workspace events are unavailable - Keep sticky-mode task restore compatible with view-local state isolation Tests: - Add 22 parallel mode cases covering viewId uniqueness, state isolation, save/load behavior, merge precedence, local override clearing, config listener handling, mode switching, profile activation, and multi-instance isolation - Verify sticky-mode restore compatibility --- src/core/webview/ClineProvider.ts | 330 +++-- .../ClineProvider.parallelMode.spec.ts | 1149 +++++++++++++++++ 2 files changed, 1385 insertions(+), 94 deletions(-) create mode 100644 src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6e5545a1f2..7224e2f311 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -211,6 +211,19 @@ export class ClineProvider */ private clineMessagesSeq = 0 + /** + * Unique identifier for this provider instance's view. + * Based on renderContext and a monotonically increasing counter to ensure uniqueness across multiple instances. + */ + public readonly viewId: string + + /** + * Local state buffer for this specific view instance. + * Used to isolate mode, apiConfiguration, and other fields from the shared ContextProxy singleton + * when running in parallel (multi-tab) mode. + */ + private viewLocalState: Partial = {} + public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "jul-2026-v3.68.0-friendli-ollama-anthropic-apimodelid" // v3.68.0 Friendli GLM-5.2 support, native Ollama thinking/reasoning, Anthropic custom apiModelId fix @@ -225,14 +238,15 @@ export class ClineProvider mdmService?: MdmService, ) { super() + // Initialize viewId based on renderContext and instance counter for uniqueness + this.viewId = `${renderContext}-${ClineProvider.activeInstances.size}` + ClineProvider.activeInstances.add(this) this.currentWorkspacePath = getWorkspacePath() this.pendingEditOperations = new PendingEditOperationStore( ClineProvider.PENDING_OPERATION_TIMEOUT_MS, (message) => this.log(message), ) - ClineProvider.activeInstances.add(this) - this.mdmService = mdmService this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) @@ -263,6 +277,9 @@ export class ClineProvider await this.postStateToWebviewWithoutClineMessages() }) + // Load initial state from global state into viewLocalState buffer after dependencies used by getState are ready. + void this.loadViewState() + // Initialize MCP Hub through the singleton manager McpServerManager.getInstance(this.context, this) .then((hub) => { @@ -281,6 +298,9 @@ export class ClineProvider this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager) + // Set up global state listener for cross-view synchronization + this.setupGlobalStateListener() + // Forward task events to the provider. // We do something fairly similar for the IPC-based API. this.taskCreationCallback = (instance: Task) => { @@ -398,6 +418,102 @@ export class ClineProvider } } + /** + * Loads initial state from global state into the view-local state buffer. + * This allows each provider instance to have its own isolated state for fields like mode, + * apiConfiguration, etc., while still sharing the same ContextProxy singleton. + */ + private async loadViewState(): Promise { + try { + const stateValues = this.contextProxy.getValues() + const providerSettings = this.contextProxy.getProviderSettings() + this.viewLocalState = { + mode: stateValues.mode, + currentApiConfigName: stateValues.currentApiConfigName, + apiConfiguration: providerSettings, + customModePrompts: stateValues.customModePrompts, + modeApiConfigs: stateValues.modeApiConfigs, + } + this.log(`[loadViewState] Loaded state for viewId ${this.viewId}`) + } catch (error) { + this.log( + `[loadViewState] Error loading state for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + /** + * Save a single view-local state value and sync to global state. + * This allows each Provider instance to have its own mode/apiConfig for parallel mode support. + */ + private async saveViewState(key: keyof ExtensionState, value: any): Promise { + // Update local cache first. Undefined/null clears should not leave a local override behind. + if (value === undefined || value === null) { + delete this.viewLocalState[key] + } else { + this.viewLocalState[key] = value + } + + // Also write to ContextProxy so other Provider instances can see it when they sync + await this.contextProxy.setValue(key as any, value) + + this.log(`[saveViewState] Saved ${String(key)} for viewId ${this.viewId}`) + } + + /** + * Sync all view-local state to global state. + * Called when user explicitly saves settings or switches tabs. + */ + private async syncViewStateToGlobal(): Promise { + try { + const keysToSync: Array = ["mode", "currentApiConfigName", "apiConfiguration"] + + for (const key of keysToSync) { + if (this.viewLocalState[key] !== undefined) { + await this.contextProxy.setValue(key as any, this.viewLocalState[key]) + } + } + + this.log(`[syncViewStateToGlobal] Synced all state for viewId ${this.viewId}`) + } catch (error) { + this.log( + `[syncViewStateToGlobal] Failed to sync state for viewId ${this.viewId}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + /** + * Sets up listeners for global state changes that may affect viewLocalState. + * When other views or the extension host modify mode or API configuration, + * this listener ensures the current view can receive notifications and update + * its local cache accordingly. + */ + private setupGlobalStateListener(): void { + // Listen for VSCode configuration changes that may affect mode or apiConfig + if (!vscode.workspace.onDidChangeConfiguration) { + return + } + + const configDisposable = vscode.workspace.onDidChangeConfiguration(async (e) => { + if ( + e.affectsConfiguration(`${Package.name}.mode`) || + e.affectsConfiguration(`${Package.name}.currentApiConfigName`) + ) { + this.log( + `[setupGlobalStateListener] Configuration changed for mode or currentApiConfigName, reloading state for viewId ${this.viewId}`, + ) + + // Reload viewLocalState from global state + await this.loadViewState() + + // Notify webview of the change + await this.postStateToWebview() + } + }) + + this.disposables.push(configDisposable) + } + /** * Override EventEmitter's on method to match TaskProviderLike interface */ @@ -1039,6 +1155,7 @@ export class ClineProvider } await this.updateGlobalState("mode", historyItem.mode) + this.viewLocalState.mode = historyItem.mode // Load the saved API config for the restored mode if it exists. // Skip mode-based profile activation if historyItem.apiConfigName exists, @@ -1480,6 +1597,9 @@ export class ClineProvider } } + // Save to view-local state first for parallel mode support. + await this.saveViewState("mode", newMode) + await this.updateGlobalState("mode", newMode) this.emit(RooCodeEventName.ModeChanged, newMode) @@ -1710,6 +1830,10 @@ export class ClineProvider this.contextProxy.setProviderSettings(providerSettings), ]) + // Save to view-local state for parallel mode support. + await this.saveViewState("currentApiConfigName", name) + this.viewLocalState.apiConfiguration = providerSettings + const { mode } = await this.getState() if (id && persistModeConfig) { @@ -2546,12 +2670,18 @@ export class ClineProvider > > { const stateValues = this.contextProxy.getValues() + + // NEW: Merge viewLocalState on top of global state + // This allows each Provider instance to have its own mode/apiConfig for parallel mode support + const mergedStateValues = { ...stateValues, ...this.viewLocalState } + const customModes = await this.customModesManager.getCustomModes() // Determine apiProvider with the same logic as before, while filtering retired providers. + // Use mergedStateValues to prioritize viewLocalState for parallel mode support const apiProvider: ProviderName = - stateValues.apiProvider && !isRetiredProvider(stateValues.apiProvider) - ? stateValues.apiProvider + mergedStateValues.apiProvider && !isRetiredProvider(mergedStateValues.apiProvider) + ? (mergedStateValues.apiProvider as ProviderName) : "anthropic" // Build the apiConfiguration object combining state values and secrets. @@ -2612,116 +2742,128 @@ export class ClineProvider const taskSyncEnabled: boolean = false // Return the same structure as before. + // Return the same structure as before. + // Fields that should prioritize viewLocalState (mode, currentApiConfigName, + // apiConfiguration, customModePrompts, modeApiConfigs) are already merged via + // mergedStateValues above, so they will use local cache values when available. return { - apiConfiguration: providerSettings, - lastShownAnnouncementId: stateValues.lastShownAnnouncementId, - customInstructions: stateValues.customInstructions, - apiModelId: stateValues.apiModelId, - alwaysAllowReadOnly: stateValues.alwaysAllowReadOnly ?? false, - alwaysAllowReadOnlyOutsideWorkspace: stateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, - alwaysAllowWrite: stateValues.alwaysAllowWrite ?? false, - alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false, - alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, - alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, - alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false, - alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false, - alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false, - alwaysAllowFollowupQuestions: stateValues.alwaysAllowFollowupQuestions ?? false, - followupAutoApproveTimeoutMs: stateValues.followupAutoApproveTimeoutMs ?? 60000, - diagnosticsEnabled: stateValues.diagnosticsEnabled ?? true, - allowedMaxRequests: stateValues.allowedMaxRequests, - allowedMaxCost: stateValues.allowedMaxCost, - autoCondenseContext: stateValues.autoCondenseContext ?? true, - autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100, + apiConfiguration: { + ...providerSettings, + // Prioritize viewLocalState.apiConfiguration for parallel mode support + ...mergedStateValues.apiConfiguration, + }, + lastShownAnnouncementId: mergedStateValues.lastShownAnnouncementId, + customInstructions: mergedStateValues.customInstructions, + apiModelId: mergedStateValues.apiModelId, + alwaysAllowReadOnly: mergedStateValues.alwaysAllowReadOnly ?? false, + alwaysAllowReadOnlyOutsideWorkspace: mergedStateValues.alwaysAllowReadOnlyOutsideWorkspace ?? false, + alwaysAllowWrite: mergedStateValues.alwaysAllowWrite ?? false, + alwaysAllowWriteOutsideWorkspace: mergedStateValues.alwaysAllowWriteOutsideWorkspace ?? false, + alwaysAllowWriteProtected: mergedStateValues.alwaysAllowWriteProtected ?? false, + alwaysAllowExecute: mergedStateValues.alwaysAllowExecute ?? false, + alwaysAllowMcp: mergedStateValues.alwaysAllowMcp ?? false, + alwaysAllowModeSwitch: mergedStateValues.alwaysAllowModeSwitch ?? false, + alwaysAllowSubtasks: mergedStateValues.alwaysAllowSubtasks ?? false, + alwaysAllowFollowupQuestions: mergedStateValues.alwaysAllowFollowupQuestions ?? false, + followupAutoApproveTimeoutMs: mergedStateValues.followupAutoApproveTimeoutMs ?? 60000, + diagnosticsEnabled: mergedStateValues.diagnosticsEnabled ?? true, + allowedMaxRequests: mergedStateValues.allowedMaxRequests, + allowedMaxCost: mergedStateValues.allowedMaxCost, + autoCondenseContext: mergedStateValues.autoCondenseContext ?? true, + autoCondenseContextPercent: mergedStateValues.autoCondenseContextPercent ?? 100, taskHistory: this.taskHistoryStore.getAll(), - allowedCommands: stateValues.allowedCommands, - deniedCommands: stateValues.deniedCommands, - soundEnabled: stateValues.soundEnabled ?? false, - ttsEnabled: stateValues.ttsEnabled ?? false, - ttsSpeed: stateValues.ttsSpeed ?? 1.0, - enableCheckpoints: stateValues.enableCheckpoints ?? true, - checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, - soundVolume: stateValues.soundVolume, - writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, - diffFuzzyThreshold: stateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, + allowedCommands: mergedStateValues.allowedCommands, + deniedCommands: mergedStateValues.deniedCommands, + soundEnabled: mergedStateValues.soundEnabled ?? false, + ttsEnabled: mergedStateValues.ttsEnabled ?? false, + ttsSpeed: mergedStateValues.ttsSpeed ?? 1.0, + enableCheckpoints: mergedStateValues.enableCheckpoints ?? true, + checkpointTimeout: mergedStateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, + soundVolume: mergedStateValues.soundVolume, + writeDelayMs: mergedStateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, + diffFuzzyThreshold: mergedStateValues.diffFuzzyThreshold ?? DEFAULT_DIFF_FUZZY_THRESHOLD, terminalShellIntegrationTimeout: - stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, - terminalShellIntegrationDisabled: stateValues.terminalShellIntegrationDisabled ?? true, - terminalCommandDelay: stateValues.terminalCommandDelay ?? 0, - terminalPowershellCounter: stateValues.terminalPowershellCounter ?? false, - terminalZshClearEolMark: stateValues.terminalZshClearEolMark ?? true, - terminalZshOhMy: stateValues.terminalZshOhMy ?? false, - terminalZshP10k: stateValues.terminalZshP10k ?? false, - terminalZdotdir: stateValues.terminalZdotdir ?? false, - terminalProfile: stateValues.terminalProfile, - mode: stateValues.mode ?? defaultModeSlug, - language: stateValues.language ?? formatLanguage(vscode.env.language), - mcpEnabled: stateValues.mcpEnabled ?? true, + mergedStateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, + terminalShellIntegrationDisabled: mergedStateValues.terminalShellIntegrationDisabled ?? true, + terminalCommandDelay: mergedStateValues.terminalCommandDelay ?? 0, + terminalPowershellCounter: mergedStateValues.terminalPowershellCounter ?? false, + terminalZshClearEolMark: mergedStateValues.terminalZshClearEolMark ?? true, + terminalZshOhMy: mergedStateValues.terminalZshOhMy ?? false, + terminalZshP10k: mergedStateValues.terminalZshP10k ?? false, + terminalZdotdir: mergedStateValues.terminalZdotdir ?? false, + terminalProfile: mergedStateValues.terminalProfile, + // mode: Prioritize viewLocalState for parallel mode support + mode: (mergedStateValues.mode as Mode) ?? defaultModeSlug, + language: mergedStateValues.language ?? formatLanguage(vscode.env.language), + mcpEnabled: mergedStateValues.mcpEnabled ?? true, mcpServers: this.mcpHub?.getAllServers() ?? [], - currentApiConfigName: stateValues.currentApiConfigName ?? "default", - listApiConfigMeta: stateValues.listApiConfigMeta ?? [], - pinnedApiConfigs: stateValues.pinnedApiConfigs ?? {}, - modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record), - customModePrompts: stateValues.customModePrompts ?? {}, - customSupportPrompts: stateValues.customSupportPrompts ?? {}, - enhancementApiConfigId: stateValues.enhancementApiConfigId, - experiments: stateValues.experiments ?? experimentDefault, - autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false, + // currentApiConfigName: Prioritize viewLocalState for parallel mode support + currentApiConfigName: mergedStateValues.currentApiConfigName ?? "default", + listApiConfigMeta: mergedStateValues.listApiConfigMeta ?? [], + pinnedApiConfigs: mergedStateValues.pinnedApiConfigs ?? {}, + // modeApiConfigs: Prioritize viewLocalState for parallel mode support + modeApiConfigs: (mergedStateValues.modeApiConfigs as Record) ?? ({} as Record), + // customModePrompts: Prioritize viewLocalState for parallel mode support + customModePrompts: mergedStateValues.customModePrompts ?? {}, + customSupportPrompts: mergedStateValues.customSupportPrompts ?? {}, + enhancementApiConfigId: mergedStateValues.enhancementApiConfigId, + experiments: mergedStateValues.experiments ?? experimentDefault, + autoApprovalEnabled: mergedStateValues.autoApprovalEnabled ?? false, customModes, - maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, - maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, - disabledTools: stateValues.disabledTools, - telemetrySetting: stateValues.telemetrySetting || "unset", - showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, - enableSubfolderRules: stateValues.enableSubfolderRules ?? false, - maxImageFileSize: stateValues.maxImageFileSize ?? 5, - maxTotalImageSize: stateValues.maxTotalImageSize ?? 20, - historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, - reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, - chatFontSize: stateValues.chatFontSize, - enterBehavior: stateValues.enterBehavior ?? "send", + maxOpenTabsContext: mergedStateValues.maxOpenTabsContext ?? 20, + maxWorkspaceFiles: mergedStateValues.maxWorkspaceFiles ?? 200, + disabledTools: mergedStateValues.disabledTools, + telemetrySetting: mergedStateValues.telemetrySetting || "unset", + showRooIgnoredFiles: mergedStateValues.showRooIgnoredFiles ?? false, + enableSubfolderRules: mergedStateValues.enableSubfolderRules ?? false, + maxImageFileSize: mergedStateValues.maxImageFileSize ?? 5, + maxTotalImageSize: mergedStateValues.maxTotalImageSize ?? 20, + historyPreviewCollapsed: mergedStateValues.historyPreviewCollapsed ?? false, + reasoningBlockCollapsed: mergedStateValues.reasoningBlockCollapsed ?? true, + chatFontSize: mergedStateValues.chatFontSize, + enterBehavior: mergedStateValues.enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated, sharingEnabled, publicSharingEnabled, organizationAllowList, organizationSettingsVersion, - customCondensingPrompt: stateValues.customCondensingPrompt, - codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, + customCondensingPrompt: mergedStateValues.customCondensingPrompt, + codebaseIndexModels: mergedStateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, codebaseIndexConfig: { - codebaseIndexEnabled: stateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, + codebaseIndexEnabled: mergedStateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, codebaseIndexQdrantUrl: - stateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", + mergedStateValues.codebaseIndexConfig?.codebaseIndexQdrantUrl ?? "http://localhost:6333", codebaseIndexEmbedderProvider: - stateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? "openai", - codebaseIndexEmbedderBaseUrl: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", - codebaseIndexEmbedderModelId: stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", + mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderProvider ?? "openai", + codebaseIndexEmbedderBaseUrl: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderBaseUrl ?? "", + codebaseIndexEmbedderModelId: mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelId ?? "", codebaseIndexEmbedderModelDimension: - stateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension, + mergedStateValues.codebaseIndexConfig?.codebaseIndexEmbedderModelDimension, codebaseIndexOpenAiCompatibleBaseUrl: - stateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl, - codebaseIndexSearchMaxResults: stateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults, - codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, - codebaseIndexBedrockRegion: stateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion, - codebaseIndexBedrockProfile: stateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile, + mergedStateValues.codebaseIndexConfig?.codebaseIndexOpenAiCompatibleBaseUrl, + codebaseIndexSearchMaxResults: mergedStateValues.codebaseIndexConfig?.codebaseIndexSearchMaxResults, + codebaseIndexSearchMinScore: mergedStateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, + codebaseIndexBedrockRegion: mergedStateValues.codebaseIndexConfig?.codebaseIndexBedrockRegion, + codebaseIndexBedrockProfile: mergedStateValues.codebaseIndexConfig?.codebaseIndexBedrockProfile, codebaseIndexOpenRouterSpecificProvider: - stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, + mergedStateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, }, - profileThresholds: stateValues.profileThresholds ?? {}, + profileThresholds: mergedStateValues.profileThresholds ?? {}, lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false), - includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, - maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, - includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true, - includeCurrentTime: stateValues.includeCurrentTime ?? true, - includeCurrentCost: stateValues.includeCurrentCost ?? true, - maxGitStatusFiles: stateValues.maxGitStatusFiles ?? 0, + includeDiagnosticMessages: mergedStateValues.includeDiagnosticMessages ?? true, + maxDiagnosticMessages: mergedStateValues.maxDiagnosticMessages ?? 50, + includeTaskHistoryInEnhance: mergedStateValues.includeTaskHistoryInEnhance ?? true, + includeCurrentTime: mergedStateValues.includeCurrentTime ?? true, + includeCurrentCost: mergedStateValues.includeCurrentCost ?? true, + maxGitStatusFiles: mergedStateValues.maxGitStatusFiles ?? 0, taskSyncEnabled, - imageGenerationProvider: stateValues.imageGenerationProvider, - openRouterImageApiKey: stateValues.openRouterImageApiKey, - openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel, - autoCloseZooOpenedFiles: stateValues.autoCloseZooOpenedFiles, - autoCloseZooOpenedFilesAfterUserEdited: stateValues.autoCloseZooOpenedFilesAfterUserEdited, - autoCloseZooOpenedNewFiles: stateValues.autoCloseZooOpenedNewFiles, + imageGenerationProvider: mergedStateValues.imageGenerationProvider, + openRouterImageApiKey: mergedStateValues.openRouterImageApiKey, + openRouterImageGenerationSelectedModel: mergedStateValues.openRouterImageGenerationSelectedModel, + autoCloseZooOpenedFiles: mergedStateValues.autoCloseZooOpenedFiles, + autoCloseZooOpenedFilesAfterUserEdited: mergedStateValues.autoCloseZooOpenedFilesAfterUserEdited, + autoCloseZooOpenedNewFiles: mergedStateValues.autoCloseZooOpenedNewFiles, } } diff --git a/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts new file mode 100644 index 0000000000..6bbb2d0c51 --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts @@ -0,0 +1,1149 @@ +// pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.parallelMode.spec.ts + +import * as vscode from "vscode" + +import { type ExtensionMessage, type ExtensionState, RooCodeEventName } from "@roo-code/types" + +import { defaultModeSlug } from "../../../shared/modes" +import { ContextProxy } from "../../config/ContextProxy" +import { ClineProvider } from "../ClineProvider" +import { TelemetryService } from "@roo-code/telemetry" + +// Mock p-wait-for +vi.mock("p-wait-for", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +// Mock fs/promises +vi.mock("fs/promises", async (importOriginal) => { + const actual = await importOriginal() + const mocked = { + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), + } + + return { + ...actual, + ...mocked, + default: { + ...actual, + ...mocked, + }, + } +}) + +// Mock axios +vi.mock("axios", () => ({ + default: { + get: vi.fn().mockResolvedValue({ data: { data: [] } }), + post: vi.fn(), + }, + get: vi.fn().mockResolvedValue({ data: { data: [] } }), + post: vi.fn(), +})) + +// Mock safeWriteJson +vi.mock("../../../utils/safeWriteJson", () => ({ + safeWriteJson: vi.fn().mockResolvedValue(undefined), +})) + +// Mock path utils +vi.mock("../../../utils/path", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getWorkspacePath: vi.fn().mockReturnValue(""), + } +}) + +// Mock storage utils +vi.mock("../../../utils/storage", () => ({ + getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"), + getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"), + getGlobalStoragePath: vi.fn().mockResolvedValue("/test/storage/path"), +})) + +// Mock MCP types +vi.mock("@modelcontextprotocol/sdk/types.js", () => ({ + CallToolResultSchema: {}, + ListResourcesResultSchema: {}, + ListResourceTemplatesResultSchema: {}, + ListToolsResultSchema: {}, + ReadResourceResultSchema: {}, + ErrorCode: { + InvalidRequest: "InvalidRequest", + MethodNotFound: "MethodNotFound", + InternalError: "InternalError", + }, + McpError: class McpError extends Error { + code: string + constructor(code: string, message: string) { + super(message) + this.name = "McpError" + this.code = code + } + }, +})) + +// Mock delay +vi.mock("delay", () => { + const delayFn = (_ms: number) => Promise.resolve() + delayFn.createDelay = () => delayFn + delayFn.reject = () => Promise.reject(new Error("Delay rejected")) + delayFn.range = () => Promise.resolve() + return { default: delayFn } +}) + +// Mock MCP client +vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({ + __esModule: true, + Client: vi.fn().mockImplementation(function () { + return { + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + listTools: vi.fn().mockResolvedValue({ tools: [] }), + callTool: vi.fn().mockResolvedValue({ content: [] }), + } + }), +})) + +vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({ + __esModule: true, + StdioClientTransport: vi.fn().mockImplementation(function () { + return { + connect: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + } + }), +})) + +// Mock vscode +vi.mock("vscode", () => ({ + ExtensionContext: vi.fn(), + OutputChannel: vi.fn(), + WebviewView: vi.fn(), + EventEmitter: vi.fn().mockImplementation(function () { + return { + event: vi.fn(), + fire: vi.fn(), + dispose: vi.fn(), + } + }), + Uri: { + joinPath: vi.fn(), + file: vi.fn(), + }, + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + Range: class Range { + constructor( + readonly startLine: number, + readonly startCharacter: number, + readonly endLine: number, + readonly endCharacter: number, + ) {} + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue([]), + update: vi.fn(), + }), + getWorkspaceFolder: vi.fn(), + createFileSystemWatcher: vi.fn().mockReturnValue({ + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + dispose: vi.fn(), + }), + onDidChangeConfiguration: vi.fn().mockImplementation(() => { + return { + dispose: vi.fn(), + } + }), + onDidSaveTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidCloseTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + }, + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + activeTextEditor: undefined, + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + createTextEditorDecorationType: vi.fn().mockReturnValue({}), + tabGroups: { + onDidChangeTabs: vi.fn().mockReturnValue({ dispose: vi.fn() }), + }, + }, + env: { + uriScheme: "vscode", + language: "en", + appName: "Visual Studio Code", + }, + ExtensionMode: { + Production: 1, + Development: 2, + Test: 3, + }, + version: "1.85.0", +})) + +// Mock TTS utils +vi.mock("../../../utils/tts", () => ({ + setTtsEnabled: vi.fn(), + setTtsSpeed: vi.fn(), +})) + +// Mock API +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn().mockReturnValue({ + getModel: vi.fn().mockReturnValue({ + id: "claude-3-sonnet", + }), + }), +})) + +// Mock system prompt +vi.mock("../../prompts/system", () => ({ + SYSTEM_PROMPT: vi.fn().mockResolvedValue("mocked system prompt"), + codeMode: "code", +})) + +// Mock WorkspaceTracker - simple mock that works (same pattern as sticky-mode.spec.ts) +vi.mock("../../../integrations/workspace/WorkspaceTracker", () => ({ + default: vi.fn().mockImplementation(function () { + return { + initializeFilePaths: vi.fn(), + dispose: vi.fn(), + } + }), +})) +// Mock ContextProxy for viewLocalState tests +vi.mock("../../config/ContextProxy", () => { + const defaultState = { + mode: "code", + currentApiConfigName: "default", + apiConfiguration: {}, + customModePrompts: {}, + modeApiConfigs: {}, + listApiConfigMeta: [], + pinnedApiConfigs: {}, + } + + class MockContextProxy { + public globalStorageUri: { fsPath: string } + public extensionUri: { fsPath: string } + public extensionMode = 1 + + constructor(public context: any) { + this.globalStorageUri = context?.globalStorageUri ?? { fsPath: "/test/storage/path" } + this.extensionUri = context?.extensionUri ?? { fsPath: "/test/path" } + } + + getValues = vi.fn().mockImplementation(() => ({ + ...defaultState, + mode: this.context?.globalState?.get("mode") ?? defaultState.mode, + currentApiConfigName: + this.context?.globalState?.get("currentApiConfigName") ?? defaultState.currentApiConfigName, + apiConfiguration: this.context?.globalState?.get("apiConfiguration") ?? defaultState.apiConfiguration, + customModePrompts: this.context?.globalState?.get("customModePrompts") ?? defaultState.customModePrompts, + modeApiConfigs: this.context?.globalState?.get("modeApiConfigs") ?? defaultState.modeApiConfigs, + listApiConfigMeta: this.context?.globalState?.get("listApiConfigMeta") ?? defaultState.listApiConfigMeta, + pinnedApiConfigs: this.context?.globalState?.get("pinnedApiConfigs") ?? defaultState.pinnedApiConfigs, + })) + getValue = vi.fn().mockImplementation((key: string) => this.context?.globalState?.get(key)) + getProviderSettings = vi.fn().mockReturnValue({ apiProvider: "anthropic" }) + setValue = vi.fn().mockImplementation((key: string, value: any) => { + return this.context?.globalState?.update?.(key, value) ?? Promise.resolve() + }) + setValues = vi.fn().mockImplementation((values: Record) => { + return Promise.all(Object.entries(values).map(([key, value]) => this.setValue(key, value))).then( + () => undefined, + ) + }) + setProviderSettings = vi.fn().mockImplementation((settings: Record) => this.setValues(settings)) + } + return { ContextProxy: MockContextProxy } +}) + +// Mock Task +vi.mock("../../task/Task", () => ({ + Task: vi.fn().mockImplementation(function (options: any) { + return { + api: undefined, + abortTask: vi.fn(), + handleWebviewAskResponse: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + overwriteClineMessages: vi.fn(), + overwriteApiConversationHistory: vi.fn(), + getTaskNumber: vi.fn().mockReturnValue(0), + setTaskNumber: vi.fn(), + setParentTask: vi.fn(), + setRootTask: vi.fn(), + taskId: options?.historyItem?.id || "test-task-id", + emit: vi.fn(), + } + }), +})) + +// Mock extract-text +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockImplementation(async (_filePath: string) => { + const content = "const x = 1;\nconst y = 2;\nconst z = 3;" + const lines = content.split("\n") + return lines.map((line, index) => `${index + 1} | ${line}`).join("\n") + }), +})) + +// Mock model cache +vi.mock("../../../api/providers/fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({}), + flushModels: vi.fn(), + getModelsFromCache: vi.fn().mockReturnValue(undefined), +})) + +// Mock cloud service +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn().mockReturnValue(true), + get instance() { + return { + isAuthenticated: vi.fn().mockReturnValue(false), + getAllowList: vi.fn().mockResolvedValue([]), + getUserInfo: vi.fn().mockReturnValue(null), + getOrganizationSettings: vi.fn().mockReturnValue(null), + off: vi.fn(), + } + }, + }, + getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), +})) + +// Mock modes +vi.mock("../../../shared/modes", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + modes: [ + { + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit"], + }, + { + slug: "architect", + name: "Architect Mode", + roleDefinition: "You are an architect", + groups: ["read", "edit"], + }, + { + slug: "debugger", + name: "Debugger Mode", + roleDefinition: "You are a debugger", + groups: ["read", "edit"], + }, + { + slug: "ask", + name: "Ask Mode", + roleDefinition: "You are a helpful assistant", + groups: ["read"], + }, + ], + getModeBySlug: vi.fn().mockImplementation((slug: string) => { + return actual.modes?.find((m) => m.slug === slug) ?? null + }), + defaultModeSlug: "code", + } +}) + +// Mock custom instructions +vi.mock("../../prompts/sections/custom-instructions", () => ({ + addCustomInstructions: vi.fn().mockResolvedValue("Combined instructions"), +})) + +// Mock zoo-code-auth +vi.mock("../../../services/zoo-code-auth", () => ({ + getZooCodeBaseUrl: vi.fn(() => "https://www.zoocode.dev"), + getCachedZooCodeToken: vi.fn(), + handleAuthCallback: vi.fn(), + setZooCodeUserInfo: vi.fn(), + disconnectZooCode: vi.fn(), +})) + +// Mock diff strategy +vi.mock("../diff/strategies/multi-search-replace", () => ({ + MultiSearchReplaceDiffStrategy: vi.fn().mockImplementation(function () { + return { + getToolDescription: () => "test", + getName: () => "test-strategy", + applyDiff: vi.fn(), + } + }), +})) + +// Mock Terminal +vi.mock("../../../integrations/terminal/Terminal", () => ({ + Terminal: { + defaultShellIntegrationTimeout: 10000, + setShellIntegrationTimeout: vi.fn(), + setShellIntegrationDisabled: vi.fn(), + setCommandDelay: vi.fn(), + setTerminalZshClearEolMark: vi.fn(), + setTerminalZshOhMy: vi.fn(), + setTerminalZshP10k: vi.fn(), + setPowershellCounter: vi.fn(), + setTerminalZdotdir: vi.fn(), + setTerminalProfile: vi.fn(), + }, +})) + +// Mock McpHub and McpServerManager +vi.mock("../../services/mcp/McpHub", () => ({ + McpHub: vi.fn().mockImplementation(function () { + return { + registerClient: vi.fn(), + unregisterClient: vi.fn(), + getAllServers: vi.fn().mockReturnValue([]), + } + }), +})) + +vi.mock("../../services/mcp/McpServerManager", () => ({ + McpServerManager: { + getInstance: vi.fn().mockResolvedValue({ + registerClient: vi.fn(), + unregisterClient: vi.fn(), + getAllServers: vi.fn().mockReturnValue([]), + }), + unregisterProvider: vi.fn(), + }, +})) + +// Mock SkillsManager +vi.mock("../../services/skills/SkillsManager", () => ({ + SkillsManager: vi.fn().mockImplementation(function () { + return { + initialize: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + } + }), +})) + +// Mock MarketplaceManager +vi.mock("../../services/marketplace", () => ({ + MarketplaceManager: vi.fn().mockImplementation(function () { + return { + cleanup: vi.fn(), + } + }), +})) + +// Mock ProviderSettingsManager +vi.mock("../../config/ProviderSettingsManager", () => ({ + ProviderSettingsManager: vi.fn().mockImplementation(function () { + return { + saveConfig: vi.fn().mockResolvedValue("test-id"), + listConfig: vi.fn().mockResolvedValue([]), + getProfile: vi.fn().mockResolvedValue({}), + activateProfile: vi.fn().mockImplementation(async (args: { name?: string; id?: string }) => ({ + name: args.name ?? "default", + id: args.id ?? "test-id", + apiProvider: "anthropic", + })), + setModeConfig: vi.fn().mockResolvedValue(undefined), + getModeConfigId: vi.fn().mockResolvedValue(undefined), + } + }), +})) + +// Mock CustomModesManager +vi.mock("../../config/CustomModesManager", () => ({ + CustomModesManager: vi.fn().mockImplementation(function () { + return { + updateCustomMode: vi.fn().mockResolvedValue(undefined), + getCustomModes: vi.fn().mockResolvedValue([]), + dispose: vi.fn(), + } + }), +})) + +// Mock task persistence +vi.mock("../../task-persistence/taskMessages", () => ({ + readTaskMessages: vi.fn().mockResolvedValue([]), +})) + +vi.mock("../../task-persistence", () => ({ + readApiMessages: vi.fn().mockResolvedValue([]), + saveApiMessages: vi.fn().mockResolvedValue(undefined), + saveTaskMessages: vi.fn().mockResolvedValue(undefined), + TaskHistoryStore: vi.fn().mockImplementation(function () { + return { + initialize: vi.fn().mockResolvedValue(undefined), + getAll: vi.fn().mockReturnValue([]), + get: vi.fn().mockReturnValue(null), + set: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + migrateFromGlobalState: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + } + }), + assertValidTransition: vi.fn(), +})) + +// Mock RateLimitClock +vi.mock("../../task/RateLimitClock", () => ({ + createRateLimitClock: vi.fn().mockReturnValue({ + isRateLimited: vi.fn().mockReturnValue(false), + resetTimer: vi.fn(), + }), +})) + +beforeAll(() => { + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "warn").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) +}) + +afterAll(() => { + vi.restoreAllMocks() +}) + +/** + * ClineProvider - Parallel Mode Support Tests + * + * These tests verify that the view-local state isolation feature works correctly, + * allowing multiple ClineProvider instances (e.g., in parallel tabs) to maintain + * independent mode, API configuration, and other view-specific settings. + */ +describe("ClineProvider - Parallel Mode Support", () => { + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + + beforeEach(() => { + vi.clearAllMocks() + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + const globalState: Record = { + mode: "code", + currentApiConfigName: "default", + apiConfiguration: {}, + customModePrompts: {}, + modeApiConfigs: {}, + listApiConfigMeta: [], + pinnedApiConfigs: {}, + } + + const secrets: Record = {} + + mockContext = { + extensionPath: "/test/path", + extensionUri: { fsPath: "/test/path" } as vscode.Uri, + globalState: { + get: vi.fn().mockImplementation((key: string) => { + return globalState[key] + }), + update: vi.fn().mockImplementation((key: string, value: any) => { + globalState[key] = value + return Promise.resolve() + }), + keys: vi.fn().mockImplementation(() => { + return Object.keys(globalState) + }), + } as any, + secrets: { + get: vi.fn().mockImplementation((key: string) => { + return secrets[key] + }), + store: vi.fn().mockImplementation((key: string, value: string) => { + secrets[key] = value + return Promise.resolve() + }), + delete: vi.fn().mockImplementation((key: string) => { + delete secrets[key] + return Promise.resolve() + }), + } as any, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + } as any, + subscriptions: [], + extension: { + packageJSON: { version: "1.0.0" }, + }, + globalStorageUri: { + fsPath: "/test/storage/path", + } as vscode.Uri, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { + appendLine: vi.fn(), + clear: vi.fn(), + dispose: vi.fn(), + } as unknown as vscode.OutputChannel + }) + + describe("viewId uniqueness", () => { + it("should assign unique viewId to each instance", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + // Each instance should have a unique viewId + expect(provider1.viewId).toBeDefined() + expect(provider2.viewId).toBeDefined() + expect(provider1.viewId).not.toBe(provider2.viewId) + + await provider1.dispose() + await provider2.dispose() + }) + + it("should have viewId in correct format: {renderContext}-{instanceCount}", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + expect(provider.viewId).toMatch(/^sidebar-\d+$/) + + await provider.dispose() + }) + + it("should increment instance count for each new instance", async () => { + const provider1 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + // First editor instance should be "editor-0" (or next available) + // Second editor instance should have a different number + const num1 = parseInt(provider1.viewId.split("-")[1]!) + const num2 = parseInt(provider2.viewId.split("-")[1]!) + + expect(num2).toBeGreaterThan(num1) + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("local state isolation", () => { + it("should isolate mode state between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + // Access viewLocalState via private property for testing + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + // Both should start with the same default mode from global state + expect(state1.mode).toBe("code") + expect(state2.mode).toBe("code") + + await provider1.dispose() + await provider2.dispose() + }) + + it("should allow different modes in separate instances after saveViewState", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + // Access private method for testing + const saveViewState1 = (provider1 as any).saveViewState.bind(provider1) + const saveViewState2 = (provider2 as any).saveViewState.bind(provider2) + + // Save different modes to each provider + await saveViewState1("mode", "architect") + await saveViewState2("mode", "debugger") + + // Verify isolation - each provider should have its own mode + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.mode).toBe("architect") + expect(state2.mode).toBe("debugger") + + await provider1.dispose() + await provider2.dispose() + }) + + it("should isolate currentApiConfigName between instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + const saveViewState1 = (provider1 as any).saveViewState.bind(provider1) + const saveViewState2 = (provider2 as any).saveViewState.bind(provider2) + + await saveViewState1("currentApiConfigName", "profile-a") + await saveViewState2("currentApiConfigName", "profile-b") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + + expect(state1.currentApiConfigName).toBe("profile-a") + expect(state2.currentApiConfigName).toBe("profile-b") + + await provider1.dispose() + await provider2.dispose() + }) + }) + + describe("saveViewState", () => { + it("should update viewLocalState when saveViewState is called", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + const contextProxySpy = vi.spyOn(provider.contextProxy, "setValue") + + await (provider as any).saveViewState("mode", "architect") + + // Verify viewLocalState was updated + expect((provider as any).viewLocalState.mode).toBe("architect") + + // Verify contextProxy.setValue was called + expect(contextProxySpy).toHaveBeenCalledWith("mode", "architect") + + await provider.dispose() + }) + + it("should update viewLocalState for currentApiConfigName", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).saveViewState("currentApiConfigName", "my-profile") + + expect((provider as any).viewLocalState.currentApiConfigName).toBe("my-profile") + + await provider.dispose() + }) + + it("should update viewLocalState for apiConfiguration", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + const testApiConfig = { + apiProvider: "openrouter" as const, + openRouterModelId: "claude-3.5-sonnet", + } + + await (provider as any).saveViewState("apiConfiguration", testApiConfig) + + expect((provider as any).viewLocalState.apiConfiguration).toEqual(testApiConfig) + + await provider.dispose() + }) + + it("should clear local override when saveViewState receives undefined", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).saveViewState("mode", "architect") + expect((provider as any).viewLocalState.mode).toBe("architect") + + await (provider as any).saveViewState("mode", undefined) + + expect(Object.prototype.hasOwnProperty.call((provider as any).viewLocalState, "mode")).toBe(false) + + await provider.dispose() + }) + }) + + describe("loadViewState", () => { + it("should load state from global state into viewLocalState", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Wait for the initial loadViewState to complete (it's called in constructor) + await vi.waitFor( + () => { + expect((provider as any).viewLocalState.mode).toBe("code") + return true // Success + }, + { timeout: 2000 }, + ) + + expect((provider as any).viewLocalState.currentApiConfigName).toBe("default") + + await provider.dispose() + }) + + it("should update viewLocalState when loadViewState is called manually", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Wait for initial load to complete + await vi.waitFor( + () => { + expect((provider as any).viewLocalState.mode).toBe("code") + return true + }, + { timeout: 2000 }, + ) + + // Update global state to simulate a change from another source + const originalGet = mockContext.globalState.get.bind(mockContext.globalState) + mockContext.globalState.get = vi.fn().mockImplementation((key: string) => { + if (key === "mode") return "architect" + if (key === "currentApiConfigName") return "new-profile" + if (key === "apiConfiguration") return {} + if (key === "customModePrompts") return {} + if (key === "modeApiConfigs") return {} + return originalGet(key) + }) + + // Manually call loadViewState to simulate a state reload + await (provider as any).loadViewState() + + const state = await provider.getState() + expect(state.mode).toBe("architect") + expect(state.currentApiConfigName).toBe("new-profile") + + await provider.dispose() + }) + }) + + describe("GlobalState listener", () => { + it("should call loadViewState when configuration changes for mode", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Wait for setupGlobalStateListener to register the disposable + await new Promise((resolve) => setImmediate(resolve)) + + // Get the onDidChangeConfiguration mock from vscode + const onDidChangeConfigurationMock = (vscode.workspace.onDidChangeConfiguration as any).mock + + if (onDidChangeConfigurationMock) { + // Simulate a configuration change event for mode + const configChangeEvent = { + affectsConfiguration: vi.fn().mockImplementation((key: string) => { + return key === "roo-cline.mode" || key === "roo-cline.currentApiConfigName" + }), + } + + // Find and trigger the listener + const disposables = (provider as any).disposables + for (const disposable of disposables) { + if (typeof disposable === "object" && typeof disposable.dispose === "function") { + // The listener should be registered, but we need to find the event emitter + break + } + } + + // For this test, we verify that the listener is registered by checking disposables + expect(disposables.length).toBeGreaterThan(0) + } + + await provider.dispose() + }) + + it("should call postStateToWebview after loadViewState on config change", async () => { + const mockPostMessage = vi.fn() + + const mockWebviewView: any = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + onDidDispose: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + } + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const configChangeHandler = (vscode.workspace.onDidChangeConfiguration as any).mock.calls.at(-1)?.[0] + + await (provider as any).resolveWebviewView(mockWebviewView) + mockPostMessage.mockClear() + + await configChangeHandler({ + affectsConfiguration: vi.fn().mockReturnValue(true), + }) + + // Wait for postStateToWebview to be called after the configuration change. + await vi.waitFor( + () => { + expect(mockPostMessage).toHaveBeenCalled() + return true + }, + { timeout: 2000 }, + ) + + await provider.dispose() + }) + }) + + describe("handleModeSwitch integration", () => { + it("should update viewLocalState.mode when handleModeSwitch is called", async () => { + const mockPostMessage = vi.fn() + + const mockWebviewView: any = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + onDidDispose: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + } + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).resolveWebviewView(mockWebviewView) + + // Spy on saveViewState + const saveViewStateSpy = vi.spyOn(provider as any, "saveViewState") + + // Call handleModeSwitch + await provider.handleModeSwitch("architect" as any) + + // Verify viewLocalState was updated + expect((provider as any).viewLocalState.mode).toBe("architect") + + // Verify saveViewState was called with the new mode + expect(saveViewStateSpy).toHaveBeenCalledWith("mode", "architect") + + await provider.dispose() + }) + + it("should emit ModeChanged event after handleModeSwitch", async () => { + const mockPostMessage = vi.fn() + + const mockWebviewView: any = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + onDidDispose: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + } + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).resolveWebviewView(mockWebviewView) + + // Listen for ModeChanged event + const modeChangedSpy = vi.fn() + provider.on(RooCodeEventName.ModeChanged, modeChangedSpy) + + // Call handleModeSwitch + await provider.handleModeSwitch("architect" as any) + + // Verify event was emitted + expect(modeChangedSpy).toHaveBeenCalledWith("architect") + + await provider.dispose() + }) + }) + + describe("activateProviderProfile integration", () => { + it("should update viewLocalState.currentApiConfigName when activateProviderProfile is called", async () => { + const mockPostMessage = vi.fn() + + const mockWebviewView: any = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + onDidDispose: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + } + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).resolveWebviewView(mockWebviewView) + + // Spy on saveViewState + const saveViewStateSpy = vi.spyOn(provider as any, "saveViewState") + + // Call activateProviderProfile + await provider.activateProviderProfile({ name: "my-profile" }) + + // Verify viewLocalState was updated + expect((provider as any).viewLocalState.currentApiConfigName).toBe("my-profile") + + // Verify saveViewState was called with the new profile name + expect(saveViewStateSpy).toHaveBeenCalledWith("currentApiConfigName", "my-profile") + + await provider.dispose() + }) + }) + + describe("getState merging", () => { + it("should merge viewLocalState on top of global state", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Initially, getState should return values from contextProxy (global state) + let state = await provider.getState() + expect(state.mode).toBe("code") + + // After saveViewState, viewLocalState should take precedence + await (provider as any).saveViewState("mode", "architect") + + state = await provider.getState() + expect(state.mode).toBe("architect") + + await provider.dispose() + }) + + it("should preserve global state values not overridden by viewLocalState", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).saveViewState("mode", "architect") + + const state = await provider.getState() + + // mode should come from viewLocalState + expect(state.mode).toBe("architect") + + // Other values should still come from global state / contextProxy + expect(state.language).toBeDefined() + expect(state.customModes).toBeDefined() + + await provider.dispose() + }) + + it("should let viewLocalState apiConfiguration override provider settings", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).saveViewState("apiConfiguration", { + apiProvider: "openrouter", + openRouterApiKey: "local-key", + }) + + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe("openrouter") + expect(state.apiConfiguration.openRouterApiKey).toBe("local-key") + + await provider.dispose() + }) + }) + + describe("multi-instance isolation", () => { + it("should maintain independent state across three instances", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + const provider3 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + + // Each should have a unique viewId + expect(provider1.viewId).not.toBe(provider2.viewId) + expect(provider2.viewId).not.toBe(provider3.viewId) + expect(provider1.viewId).not.toBe(provider3.viewId) + + // Set different modes for each + await (provider1 as any).saveViewState("mode", "code") + await (provider2 as any).saveViewState("mode", "architect") + await (provider3 as any).saveViewState("mode", "debugger") + + const state1 = await provider1.getState() + const state2 = await provider2.getState() + const state3 = await provider3.getState() + + expect(state1.mode).toBe("code") + expect(state2.mode).toBe("architect") + expect(state3.mode).toBe("debugger") + + await provider1.dispose() + await provider2.dispose() + await provider3.dispose() + }) + + it("should handle mode switch in one instance without affecting others", async () => { + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + // Set initial modes + await (provider1 as any).saveViewState("mode", "code") + await (provider2 as any).saveViewState("mode", "code") + + let state1 = await provider1.getState() + let state2 = await provider2.getState() + expect(state1.mode).toBe("code") + expect(state2.mode).toBe("code") + + // Switch mode in provider1 only + await (provider1 as any).saveViewState("mode", "architect") + + state1 = await provider1.getState() + state2 = await provider2.getState() + + expect(state1.mode).toBe("architect") + expect(state2.mode).toBe("code") // Should remain unchanged + + await provider1.dispose() + await provider2.dispose() + }) + }) +}) From 5319dfdc58cbda5b666563fbd6ee69162e086bf9 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 17 Jul 2026 02:10:39 +0800 Subject: [PATCH 02/11] test(webview): cover parallel mode state branches --- .../ClineProvider.parallelMode.spec.ts | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts index 6bbb2d0c51..bf1bbcb979 100644 --- a/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts @@ -771,6 +771,21 @@ describe("ClineProvider - Parallel Mode Support", () => { await provider.dispose() }) + + it("should clear local override when saveViewState receives null", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).saveViewState("currentApiConfigName", "my-profile") + expect((provider as any).viewLocalState.currentApiConfigName).toBe("my-profile") + + await (provider as any).saveViewState("currentApiConfigName", null) + + expect(Object.prototype.hasOwnProperty.call((provider as any).viewLocalState, "currentApiConfigName")).toBe( + false, + ) + + await provider.dispose() + }) }) describe("loadViewState", () => { @@ -823,6 +838,56 @@ describe("ClineProvider - Parallel Mode Support", () => { await provider.dispose() }) + + it("should log and keep existing viewLocalState when loadViewState fails", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const logSpy = vi.spyOn(provider as any, "log") + + ;(provider as any).viewLocalState = { mode: "architect" } + vi.spyOn(provider.contextProxy, "getValues").mockImplementation(() => { + throw new Error("load failed") + }) + + await (provider as any).loadViewState() + + expect((provider as any).viewLocalState.mode).toBe("architect") + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Error loading state")) + + await provider.dispose() + }) + }) + + describe("syncViewStateToGlobal", () => { + it("should sync defined view-local values to ContextProxy", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const contextProxySpy = vi.spyOn(provider.contextProxy, "setValue") + + ;(provider as any).viewLocalState = { + mode: "architect", + currentApiConfigName: undefined, + apiConfiguration: { apiProvider: "openrouter" }, + } + + await (provider as any).syncViewStateToGlobal() + + expect(contextProxySpy).toHaveBeenCalledWith("mode", "architect") + expect(contextProxySpy).toHaveBeenCalledWith("apiConfiguration", { apiProvider: "openrouter" }) + expect(contextProxySpy).not.toHaveBeenCalledWith("currentApiConfigName", expect.anything()) + + await provider.dispose() + }) + + it("should log sync errors without throwing", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const logSpy = vi.spyOn(provider as any, "log") + vi.spyOn(provider.contextProxy, "setValue").mockRejectedValueOnce(new Error("sync failed")) + ;(provider as any).viewLocalState = { mode: "architect" } + + await expect((provider as any).syncViewStateToGlobal()).resolves.toBeUndefined() + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Failed to sync state")) + + await provider.dispose() + }) }) describe("GlobalState listener", () => { @@ -859,6 +924,37 @@ describe("ClineProvider - Parallel Mode Support", () => { await provider.dispose() }) + it("should return without registering a listener when configuration events are unavailable", async () => { + const originalOnDidChangeConfiguration = vscode.workspace.onDidChangeConfiguration + ;(vscode.workspace as any).onDidChangeConfiguration = undefined + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const disposableCount = (provider as any).disposables.length + + ;(provider as any).setupGlobalStateListener() + + expect((provider as any).disposables).toHaveLength(disposableCount) + + await provider.dispose() + ;(vscode.workspace as any).onDidChangeConfiguration = originalOnDidChangeConfiguration + }) + + it("should ignore unrelated configuration changes", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const loadViewStateSpy = vi.spyOn(provider as any, "loadViewState") + const postStateToWebviewSpy = vi.spyOn(provider as any, "postStateToWebview") + const configChangeHandler = (vscode.workspace.onDidChangeConfiguration as any).mock.calls.at(-1)?.[0] + + await configChangeHandler({ + affectsConfiguration: vi.fn().mockReturnValue(false), + }) + + expect(loadViewStateSpy).not.toHaveBeenCalled() + expect(postStateToWebviewSpy).not.toHaveBeenCalled() + + await provider.dispose() + }) + it("should call postStateToWebview after loadViewState on config change", async () => { const mockPostMessage = vi.fn() @@ -944,6 +1040,73 @@ describe("ClineProvider - Parallel Mode Support", () => { await provider.dispose() }) + it("should post state and skip mode config lookup when API config locking is enabled", async () => { + const mockPostMessage = vi.fn() + const mockWebviewView: any = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), + } + mockContext.workspaceState.get = vi.fn().mockImplementation((key: string, fallback?: unknown) => { + return key === "lockApiConfigAcrossModes" ? true : fallback + }) + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const getModeConfigIdSpy = vi.spyOn((provider as any).providerSettingsManager, "getModeConfigId") + + await (provider as any).resolveWebviewView(mockWebviewView) + mockPostMessage.mockClear() + + await provider.handleModeSwitch("architect" as any) + + expect(getModeConfigIdSpy).not.toHaveBeenCalled() + expect(mockPostMessage).toHaveBeenCalled() + + await provider.dispose() + }) + + it("should activate configured mode profile when switching modes", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const providerSettingsManager = (provider as any).providerSettingsManager + providerSettingsManager.getModeConfigId.mockResolvedValueOnce("profile-id") + providerSettingsManager.listConfig.mockResolvedValueOnce([ + { id: "profile-id", name: "mode-profile", apiProvider: "openrouter" }, + ]) + providerSettingsManager.getProfile.mockResolvedValueOnce({ apiProvider: "openrouter" }) + const activateProviderProfileSpy = vi.spyOn(provider, "activateProviderProfile") + + await provider.handleModeSwitch("architect" as any) + + expect(activateProviderProfileSpy).toHaveBeenCalledWith({ name: "mode-profile" }) + + await provider.dispose() + }) + + it("should leave current configuration unchanged for empty mode profiles", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const providerSettingsManager = (provider as any).providerSettingsManager + providerSettingsManager.getModeConfigId.mockResolvedValueOnce("empty-profile-id") + providerSettingsManager.listConfig.mockResolvedValueOnce([ + { id: "empty-profile-id", name: "empty-profile" }, + ]) + providerSettingsManager.getProfile.mockResolvedValueOnce({}) + const activateProviderProfileSpy = vi.spyOn(provider, "activateProviderProfile") + + await provider.handleModeSwitch("architect" as any) + + expect(activateProviderProfileSpy).not.toHaveBeenCalled() + + await provider.dispose() + }) + it("should emit ModeChanged event after handleModeSwitch", async () => { const mockPostMessage = vi.fn() @@ -1023,6 +1186,27 @@ describe("ClineProvider - Parallel Mode Support", () => { await provider.dispose() }) + + it("should skip mode and task persistence when activation options disable them", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const providerSettingsManager = (provider as any).providerSettingsManager + const setModeConfigSpy = vi.spyOn(providerSettingsManager, "setModeConfig") + const persistStickyProviderProfileSpy = vi.spyOn( + provider as any, + "persistStickyProviderProfileToCurrentTask", + ) + + await provider.activateProviderProfile( + { name: "my-profile" }, + { persistModeConfig: false, persistTaskHistory: false }, + ) + + expect(setModeConfigSpy).not.toHaveBeenCalled() + expect(persistStickyProviderProfileSpy).not.toHaveBeenCalled() + expect((provider as any).viewLocalState.apiConfiguration).toEqual({ apiProvider: "anthropic" }) + + await provider.dispose() + }) }) describe("getState merging", () => { From 3519c67773abd627a36db1100cba0951367432d7 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 17 Jul 2026 02:46:34 +0800 Subject: [PATCH 03/11] fix(webview): isolate view-local state for parallel mode instances --- src/core/webview/ClineProvider.ts | 99 +++++++++-- .../ClineProvider.parallelMode.spec.ts | 168 +++++++++++++----- .../webview/__tests__/ClineProvider.spec.ts | 8 +- 3 files changed, 211 insertions(+), 64 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7224e2f311..abc6940722 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -161,6 +161,7 @@ export class ClineProvider public static readonly sideBarId = `${Package.name}.SidebarProvider` public static readonly tabPanelId = `${Package.name}.TabPanelProvider` private static activeInstances: Set = new Set() + private static nextViewId = 0 private disposables: vscode.Disposable[] = [] private webviewDisposables: vscode.Disposable[] = [] private view?: vscode.WebviewView | vscode.WebviewPanel @@ -238,8 +239,9 @@ export class ClineProvider mdmService?: MdmService, ) { super() - // Initialize viewId based on renderContext and instance counter for uniqueness - this.viewId = `${renderContext}-${ClineProvider.activeInstances.size}` + // Initialize viewId based on renderContext and monotonically increasing instance identifier for uniqueness. + // activeInstances is used for visibility/iteration checks, so we keep tracking instances separately. + this.viewId = `${renderContext}-${ClineProvider.nextViewId++}` ClineProvider.activeInstances.add(this) this.currentWorkspacePath = getWorkspacePath() this.pendingEditOperations = new PendingEditOperationStore( @@ -418,18 +420,33 @@ export class ClineProvider } } + /** + * Derive a view-specific ContextProxy key for persisting view-local state. + * Uses the current viewId so each parallel tab restores its own values on recreation. + */ + private viewStateKeyFor(key: "mode" | "currentApiConfigName" | "apiConfiguration"): string { + return `__view_state_${this.viewId}_${key}` + } + /** * Loads initial state from global state into the view-local state buffer. - * This allows each provider instance to have its own isolated state for fields like mode, - * apiConfiguration, etc., while still sharing the same ContextProxy singleton. + * For mode, currentApiConfigName, and apiConfiguration, reads from the view-specific key first; + * falls back to the shared key for backward compatibility with existing persisted state. */ private async loadViewState(): Promise { try { const stateValues = this.contextProxy.getValues() const providerSettings = this.contextProxy.getProviderSettings() + + // Try view-specific keys first, then fall back to shared keys for backward compatibility. + const getViewSpecificValue = (sharedKey: "mode" | "currentApiConfigName") => { + const viewKey = this.viewStateKeyFor(sharedKey) + return (this.contextProxy.getValue(viewKey as any) as any) ?? stateValues[sharedKey] + } + this.viewLocalState = { - mode: stateValues.mode, - currentApiConfigName: stateValues.currentApiConfigName, + mode: getViewSpecificValue("mode"), + currentApiConfigName: getViewSpecificValue("currentApiConfigName"), apiConfiguration: providerSettings, customModePrompts: stateValues.customModePrompts, modeApiConfigs: stateValues.modeApiConfigs, @@ -443,7 +460,7 @@ export class ClineProvider } /** - * Save a single view-local state value and sync to global state. + * Save a single view-local state value and sync to global state using a view-specific key. * This allows each Provider instance to have its own mode/apiConfig for parallel mode support. */ private async saveViewState(key: keyof ExtensionState, value: any): Promise { @@ -454,8 +471,12 @@ export class ClineProvider this.viewLocalState[key] = value } - // Also write to ContextProxy so other Provider instances can see it when they sync - await this.contextProxy.setValue(key as any, value) + // Persist to view-specific ContextProxy key for mode/currentApiConfigName/apiConfiguration, + // so recreated views restore their own values instead of the last writer's shared state. + if (key === "mode" || key === "currentApiConfigName" || key === "apiConfiguration") { + const viewKey = this.viewStateKeyFor(key as "mode" | "currentApiConfigName" | "apiConfiguration") + await this.contextProxy.setValue(viewKey as any, value) + } this.log(`[saveViewState] Saved ${String(key)} for viewId ${this.viewId}`) } @@ -1642,7 +1663,9 @@ export class ClineProvider } } else { // If no saved config for this mode, save current config as default. - const currentApiConfigNameAfter = this.getGlobalState("currentApiConfigName") + // Use view-local state (via getState()) so another tab's global write doesn't supply + // this view's configuration when running in parallel mode. + const { currentApiConfigName: currentApiConfigNameAfter } = await this.getState() if (currentApiConfigNameAfter) { const config = listApiConfig.find((c) => c.name === currentApiConfigNameAfter) @@ -1741,6 +1764,10 @@ export class ClineProvider this.contextProxy.setProviderSettings(providerSettings), ]) + // Update view-local state for parallel mode support. + this._updateViewLocalStateFromMutation({ currentApiConfigName: name } as Partial) + this.viewLocalState.apiConfiguration = providerSettings + // Change the provider for the current task. // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) @@ -1783,6 +1810,9 @@ export class ClineProvider listApiConfigMeta: entries, }) + // Update view-local state for parallel mode support. + this._updateViewLocalStateFromMutation({ currentApiConfigName: profileToActivate }) + await this.postStateToWebview() } @@ -1830,8 +1860,8 @@ export class ClineProvider this.contextProxy.setProviderSettings(providerSettings), ]) - // Save to view-local state for parallel mode support. - await this.saveViewState("currentApiConfigName", name) + // Update view-local state for parallel mode support. + this._updateViewLocalStateFromMutation({ currentApiConfigName: name } as Partial) this.viewLocalState.apiConfiguration = providerSettings const { mode } = await this.getState() @@ -2966,6 +2996,7 @@ export class ClineProvider public async setValue(key: K, value: RooCodeSettings[K]) { await this.contextProxy.setValue(key, value) + this._updateViewLocalStateFromMutation({ [key]: value }) } public getValue(key: K) { @@ -2978,6 +3009,48 @@ export class ClineProvider public async setValues(values: RooCodeSettings) { await this.contextProxy.setValues(values) + this._updateViewLocalStateFromMutation(values) + } + + /** + * Update or invalidate viewLocalState when ContextProxy is mutated via setValues, setValue, + * profile upsert/activation/deletion, or resetState. This ensures the local cache stays in + * sync with global state changes that would otherwise be invisible behind mergedStateValues. + */ + private _updateViewLocalStateFromMutation(values: Partial): void { + if ("mode" in values) { + const val = values.mode + if (val === undefined || val === null) { + delete this.viewLocalState.mode + } else { + this.viewLocalState.mode = val as any + } + } + + if ("currentApiConfigName" in values) { + const val = values.currentApiConfigName + if (val === undefined || val === null) { + delete this.viewLocalState.currentApiConfigName + } else { + this.viewLocalState.currentApiConfigName = val as any + } + } + + if ("apiConfiguration" in values) { + const val = (values as any).apiConfiguration + if (val === undefined || val === null) { + delete this.viewLocalState.apiConfiguration + } else { + this.viewLocalState.apiConfiguration = val + } + } + } + + /** + * Clear view-local state cache so that getState() falls back to ContextProxy defaults. + */ + private _clearViewLocalState(): void { + this.viewLocalState = {} } // dev @@ -3006,6 +3079,8 @@ export class ClineProvider } await this.contextProxy.resetAllState() + this._clearViewLocalState() + await this.providerSettingsManager.resetAllConfigs() await this.customModesManager.resetCustomModes() await this.removeClineFromStack() diff --git a/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts index bf1bbcb979..3158fd2b2e 100644 --- a/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts @@ -121,6 +121,29 @@ vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => ({ }), })) +const { onDidChangeConfigurationMock } = vi.hoisted(() => { + const onDidChangeConfigurationMock = vi.fn((handler: (e: any) => any) => { + const disposable = { + dispose: vi.fn(), + } + const checkedKeys: string[] = [] + void handler({ + affectsConfiguration: (key: string) => { + checkedKeys.push(key) + return false + }, + }) + + if (checkedKeys.includes("workbench.colorTheme")) { + onDidChangeConfigurationMock.mock.calls.pop() + } + + return disposable + }) + + return { onDidChangeConfigurationMock } +}) + // Mock vscode vi.mock("vscode", () => ({ ExtensionContext: vi.fn(), @@ -163,11 +186,7 @@ vi.mock("vscode", () => ({ onDidDelete: vi.fn(), dispose: vi.fn(), }), - onDidChangeConfiguration: vi.fn().mockImplementation(() => { - return { - dispose: vi.fn(), - } - }), + onDidChangeConfiguration: onDidChangeConfigurationMock, onDidSaveTextDocument: vi.fn(() => ({ dispose: vi.fn() })), onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })), onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), @@ -728,8 +747,9 @@ describe("ClineProvider - Parallel Mode Support", () => { // Verify viewLocalState was updated expect((provider as any).viewLocalState.mode).toBe("architect") - // Verify contextProxy.setValue was called - expect(contextProxySpy).toHaveBeenCalledWith("mode", "architect") + // saveViewState now uses view-specific key for mode/currentApiConfigName/apiConfiguration + const expectedViewKey = `__view_state_sidebar-${provider.viewId.split("-")[1]}_mode` + expect(contextProxySpy).toHaveBeenCalledWith(expectedViewKey, "architect") await provider.dispose() }) @@ -891,36 +911,58 @@ describe("ClineProvider - Parallel Mode Support", () => { }) describe("GlobalState listener", () => { - it("should call loadViewState when configuration changes for mode", async () => { + it("should invoke loadViewState and postStateToWebview when configuration changes for mode", async () => { + const mockPostMessage = vi.fn() + + const mockWebviewView: any = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + onDidDispose: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + } + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + const loadViewStateSpy = vi.spyOn(provider as any, "loadViewState") - // Wait for setupGlobalStateListener to register the disposable - await new Promise((resolve) => setImmediate(resolve)) - - // Get the onDidChangeConfiguration mock from vscode - const onDidChangeConfigurationMock = (vscode.workspace.onDidChangeConfiguration as any).mock - - if (onDidChangeConfigurationMock) { - // Simulate a configuration change event for mode - const configChangeEvent = { - affectsConfiguration: vi.fn().mockImplementation((key: string) => { - return key === "roo-cline.mode" || key === "roo-cline.currentApiConfigName" - }), - } - - // Find and trigger the listener - const disposables = (provider as any).disposables - for (const disposable of disposables) { - if (typeof disposable === "object" && typeof disposable.dispose === "function") { - // The listener should be registered, but we need to find the event emitter - break - } - } - - // For this test, we verify that the listener is registered by checking disposables - expect(disposables.length).toBeGreaterThan(0) + await (provider as any).resolveWebviewView(mockWebviewView) + mockPostMessage.mockClear() + + // Capture the registered config change handler + const configChangeHandler = (vscode.workspace.onDidChangeConfiguration as any).mock.calls.at(-1)?.[0] + + // Simulate a configuration change event for mode + const configChangeEvent = { + affectsConfiguration: vi.fn().mockImplementation((key: string) => { + return key === "zoo-code.mode" || key === "zoo-code.currentApiConfigName" + }), } + // Invoke the handler and wait for async operations to complete + await configChangeHandler(configChangeEvent) + + // Verify loadViewState was called + expect(loadViewStateSpy).toHaveBeenCalled() + + // Wait for postStateToWebview to be called after the configuration change. + await vi.waitFor( + () => { + expect(mockPostMessage).toHaveBeenCalled() + return true + }, + { timeout: 2000 }, + ) + await provider.dispose() }) @@ -1172,18 +1214,12 @@ describe("ClineProvider - Parallel Mode Support", () => { await (provider as any).resolveWebviewView(mockWebviewView) - // Spy on saveViewState - const saveViewStateSpy = vi.spyOn(provider as any, "saveViewState") - // Call activateProviderProfile await provider.activateProviderProfile({ name: "my-profile" }) - // Verify viewLocalState was updated + // Verify viewLocalState was updated (activateProviderProfile now uses _updateViewLocalStateFromMutation) expect((provider as any).viewLocalState.currentApiConfigName).toBe("my-profile") - // Verify saveViewState was called with the new profile name - expect(saveViewStateSpy).toHaveBeenCalledWith("currentApiConfigName", "my-profile") - await provider.dispose() }) @@ -1300,6 +1336,45 @@ describe("ClineProvider - Parallel Mode Support", () => { }) it("should handle mode switch in one instance without affecting others", async () => { + const mockPostMessage1 = vi.fn() + const mockPostMessage2 = vi.fn() + + const mockWebviewView1: any = { + webview: { + postMessage: mockPostMessage1, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + onDidDispose: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + } + + const mockWebviewView2: any = { + webview: { + postMessage: mockPostMessage2, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + onDidDispose: vi.fn().mockImplementation(() => { + return { dispose: vi.fn() } + }), + } + const provider1 = new ClineProvider( mockContext, mockOutputChannel, @@ -1308,17 +1383,20 @@ describe("ClineProvider - Parallel Mode Support", () => { ) const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) - // Set initial modes - await (provider1 as any).saveViewState("mode", "code") - await (provider2 as any).saveViewState("mode", "code") + await (provider1 as any).resolveWebviewView(mockWebviewView1) + await (provider2 as any).resolveWebviewView(mockWebviewView2) + + // Set initial modes via public handleModeSwitch + await provider1.handleModeSwitch("code" as any) + await provider2.handleModeSwitch("code" as any) let state1 = await provider1.getState() let state2 = await provider2.getState() expect(state1.mode).toBe("code") expect(state2.mode).toBe("code") - // Switch mode in provider1 only - await (provider1 as any).saveViewState("mode", "architect") + // Switch mode in provider1 only using public handleModeSwitch + await provider1.handleModeSwitch("architect" as any) state1 = await provider1.getState() state2 = await provider2.getState() diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b1e5df93f9..09989746b4 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1739,13 +1739,7 @@ describe("ClineProvider", () => { setModeConfig: vi.fn(), } as any - // Mock the ContextProxy's getValue method to return the current config name - const contextProxy = (provider as any).contextProxy - const getValueSpy = vi.spyOn(contextProxy, "getValue") - getValueSpy.mockImplementation((key: any) => { - if (key === "currentApiConfigName") return "current-config" - return undefined - }) + provider.setValue("currentApiConfigName", "current-config") // Switch to architect mode await provider.handleModeSwitch("architect") From a75d707d9beeba87c3d675832a5d6a0ab8919c02 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 17 Jul 2026 03:21:03 +0800 Subject: [PATCH 04/11] fix(api): sync configuration with provider local state --- .../__tests__/api-set-configuration.spec.ts | 103 ++++++++++++++++++ src/extension/api.ts | 2 +- 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 src/extension/__tests__/api-set-configuration.spec.ts diff --git a/src/extension/__tests__/api-set-configuration.spec.ts b/src/extension/__tests__/api-set-configuration.spec.ts new file mode 100644 index 0000000000..5995b57ee9 --- /dev/null +++ b/src/extension/__tests__/api-set-configuration.spec.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as vscode from "vscode" + +import { API } from "../api" +import { type RooCodeSettings } from "@roo-code/types" +import { ClineProvider } from "../../core/webview/ClineProvider" + +vi.mock("vscode") +vi.mock("../../core/webview/ClineProvider") + +describe("API - setConfiguration", () => { + let api: API + let mockOutputChannel: vscode.OutputChannel + let mockProvider: ClineProvider + let contextValues: RooCodeSettings + let viewLocalState: { apiConfiguration?: RooCodeSettings } + let mockContextProxySetValues: ReturnType Promise>> + let mockProviderSetValues: ReturnType Promise>> + let mockSaveConfig: ReturnType Promise>> + let mockPostStateToWebview: ReturnType Promise>> + + beforeEach(() => { + mockOutputChannel = { + appendLine: vi.fn(), + } as unknown as vscode.OutputChannel + + contextValues = { + currentApiConfigName: "default", + apiProvider: "deepseek", + deepSeekBaseUrl: "http://localhost:3000/deepseek", + apiModelId: "deepseek-v4-pro", + } + + viewLocalState = { + apiConfiguration: { + apiProvider: "openrouter", + openRouterBaseUrl: "http://localhost:3000/openrouter", + openRouterModelId: "openrouter/old-model", + }, + } + + mockContextProxySetValues = vi.fn().mockImplementation(async (values: RooCodeSettings) => { + contextValues = { + ...contextValues, + ...values, + } + }) + + mockProviderSetValues = vi + .fn<(values: RooCodeSettings) => Promise>() + .mockImplementation(async (values: RooCodeSettings) => { + await mockContextProxySetValues(values) + viewLocalState.apiConfiguration = values + }) + + mockSaveConfig = vi + .fn<(name: string, values: RooCodeSettings) => Promise>() + .mockResolvedValue("test-id") + mockPostStateToWebview = vi.fn<() => Promise>().mockResolvedValue(undefined) + + mockProvider = { + context: {} as vscode.ExtensionContext, + contextProxy: { + setValues: mockContextProxySetValues, + }, + providerSettingsManager: { + saveConfig: mockSaveConfig, + }, + setValues: mockProviderSetValues, + postStateToWebview: mockPostStateToWebview, + getState: vi.fn().mockImplementation(async () => ({ + apiConfiguration: { + ...contextValues, + ...viewLocalState.apiConfiguration, + }, + })), + on: vi.fn(), + getCurrentTaskStack: vi.fn().mockReturnValue([]), + viewLaunched: true, + } as unknown as ClineProvider + + api = new API(mockOutputChannel, mockProvider) + }) + + it("syncs sidebar provider view-local API configuration so getState reflects the new provider", async () => { + const newConfiguration: RooCodeSettings = { + currentApiConfigName: "deepseek-v4-pro", + apiProvider: "deepseek", + deepSeekBaseUrl: "http://localhost:3000/deepseek", + apiModelId: "deepseek-v4-pro", + } + + await api.setConfiguration(newConfiguration) + + const state = await mockProvider.getState() + + expect(state.apiConfiguration.apiProvider).toBe("deepseek") + expect(state.apiConfiguration.deepSeekBaseUrl).toBe("http://localhost:3000/deepseek") + expect(mockProviderSetValues).toHaveBeenCalledWith(newConfiguration) + expect(mockSaveConfig).toHaveBeenCalledWith("deepseek-v4-pro", newConfiguration) + expect(mockPostStateToWebview).toHaveBeenCalled() + }) +}) diff --git a/src/extension/api.ts b/src/extension/api.ts index c140ec8ec0..7df8054bd6 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -499,7 +499,7 @@ export class API extends EventEmitter implements RooCodeAPI { } public async setConfiguration(values: RooCodeSettings) { - await this.sidebarProvider.contextProxy.setValues(values) + await this.sidebarProvider.setValues(values) await this.sidebarProvider.providerSettingsManager.saveConfig(values.currentApiConfigName || "default", values) await this.sidebarProvider.postStateToWebview() } From 1922745ffd327fab1d9da4e0c1912bed980f6053 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 17 Jul 2026 03:40:37 +0800 Subject: [PATCH 05/11] fix(webview): sync flat provider settings to local state --- src/core/webview/ClineProvider.ts | 14 ++++++++++ .../ClineProvider.parallelMode.spec.ts | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index abc6940722..dd79342a84 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -50,6 +50,7 @@ import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, getModelId, isRetiredProvider, + PROVIDER_SETTINGS_KEYS, } from "@roo-code/types" import { RateLimitClock, createRateLimitClock } from "../task/RateLimitClock" import { aggregateTaskCostsRecursive, type AggregatedCosts } from "./aggregateTaskCosts" @@ -3043,6 +3044,19 @@ export class ClineProvider } else { this.viewLocalState.apiConfiguration = val } + } else if (PROVIDER_SETTINGS_KEYS.some((key) => key in values)) { + const providerSettingsUpdate = PROVIDER_SETTINGS_KEYS.reduce((acc, key) => { + if (key in values) { + return { ...acc, [key]: values[key as keyof RooCodeSettings] } + } + + return acc + }, {} as ProviderSettings) + + this.viewLocalState.apiConfiguration = { + ...(this.viewLocalState.apiConfiguration ?? {}), + ...providerSettingsUpdate, + } } } diff --git a/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts index 3158fd2b2e..1b3c180e06 100644 --- a/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts @@ -1294,6 +1294,33 @@ describe("ClineProvider - Parallel Mode Support", () => { await provider.dispose() }) + + it("should update viewLocalState apiConfiguration when setValues receives flat provider settings", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).saveViewState("apiConfiguration", { + apiProvider: "openrouter", + openRouterModelId: "openrouter/old-model", + }) + + await provider.setValues({ + apiProvider: "bedrock", + awsUseApiKey: true, + awsApiKey: "mock-key", + awsRegion: "us-east-1", + apiModelId: "anthropic.claude-opus-4-8-20261215-v1:0", + awsBedrockEndpoint: "http://127.0.0.1:4567", + awsBedrockEndpointEnabled: true, + }) + + const state = await provider.getState() + + expect(state.apiConfiguration.apiProvider).toBe("bedrock") + expect(state.apiConfiguration.awsBedrockEndpoint).toBe("http://127.0.0.1:4567") + expect((provider as any).viewLocalState.apiConfiguration.apiProvider).toBe("bedrock") + + await provider.dispose() + }) }) describe("multi-instance isolation", () => { From e77ca7e8c8e0042eb3a04c708ccf6b5150d12554 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 17 Jul 2026 03:58:01 +0800 Subject: [PATCH 06/11] fix(webview): persist view state by stable id --- packages/types/src/vscode-extension-host.ts | 1 + src/core/webview/ClineProvider.ts | 32 +++++++++++++--- .../ClineProvider.parallelMode.spec.ts | 38 +++++++++++++++++-- src/core/webview/webviewMessageHandler.ts | 2 + webview-ui/src/App.tsx | 2 +- webview-ui/src/__tests__/App.spec.tsx | 1 + .../src/context/ExtensionStateContext.tsx | 2 +- webview-ui/src/utils/vscode.ts | 33 +++++++++++++++- 8 files changed, 99 insertions(+), 12 deletions(-) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 75a72accde..b4a35485b0 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -621,6 +621,7 @@ export interface WebviewMessage { | "openRuleFile" | "openRulesDirectory" text?: string + viewStateId?: string taskId?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index dd79342a84..629cb1ca63 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -219,6 +219,12 @@ export class ClineProvider */ public readonly viewId: string + /** + * Stable identifier for persisted per-view state keys. + * Defaults to viewId until the webview reports its VS Code-persisted id. + */ + private viewStateId: string + /** * Local state buffer for this specific view instance. * Used to isolate mode, apiConfiguration, and other fields from the shared ContextProxy singleton @@ -243,6 +249,7 @@ export class ClineProvider // Initialize viewId based on renderContext and monotonically increasing instance identifier for uniqueness. // activeInstances is used for visibility/iteration checks, so we keep tracking instances separately. this.viewId = `${renderContext}-${ClineProvider.nextViewId++}` + this.viewStateId = this.viewId ClineProvider.activeInstances.add(this) this.currentWorkspacePath = getWorkspacePath() this.pendingEditOperations = new PendingEditOperationStore( @@ -423,10 +430,22 @@ export class ClineProvider /** * Derive a view-specific ContextProxy key for persisting view-local state. - * Uses the current viewId so each parallel tab restores its own values on recreation. + * Uses a stable per-view id so each restored tab reads and writes its own values + * independent of provider construction order. */ private viewStateKeyFor(key: "mode" | "currentApiConfigName" | "apiConfiguration"): string { - return `__view_state_${this.viewId}_${key}` + return `__view_state_${this.viewStateId}_${key}` + } + + public async setViewStateId(viewStateId: string | undefined): Promise { + const normalizedViewStateId = viewStateId?.trim() + + if (!normalizedViewStateId || normalizedViewStateId === this.viewStateId) { + return + } + + this.viewStateId = normalizedViewStateId.replace(/[^A-Za-z0-9_-]/g, "_") + await this.loadViewState() } /** @@ -440,15 +459,18 @@ export class ClineProvider const providerSettings = this.contextProxy.getProviderSettings() // Try view-specific keys first, then fall back to shared keys for backward compatibility. - const getViewSpecificValue = (sharedKey: "mode" | "currentApiConfigName") => { + const getViewSpecificValue = (sharedKey: "mode" | "currentApiConfigName" | "apiConfiguration") => { const viewKey = this.viewStateKeyFor(sharedKey) - return (this.contextProxy.getValue(viewKey as any) as any) ?? stateValues[sharedKey] + return ( + (this.contextProxy.getValue(viewKey as any) as any) ?? + (sharedKey === "apiConfiguration" ? providerSettings : stateValues[sharedKey]) + ) } this.viewLocalState = { mode: getViewSpecificValue("mode"), currentApiConfigName: getViewSpecificValue("currentApiConfigName"), - apiConfiguration: providerSettings, + apiConfiguration: getViewSpecificValue("apiConfiguration") ?? providerSettings, customModePrompts: stateValues.customModePrompts, modeApiConfigs: stateValues.modeApiConfigs, } diff --git a/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts index 1b3c180e06..cecbc9296f 100644 --- a/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts @@ -741,15 +741,16 @@ describe("ClineProvider - Parallel Mode Support", () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) const contextProxySpy = vi.spyOn(provider.contextProxy, "setValue") + await (provider as any).setViewStateId("stable-sidebar-view") await (provider as any).saveViewState("mode", "architect") // Verify viewLocalState was updated expect((provider as any).viewLocalState.mode).toBe("architect") - // saveViewState now uses view-specific key for mode/currentApiConfigName/apiConfiguration - const expectedViewKey = `__view_state_sidebar-${provider.viewId.split("-")[1]}_mode` - expect(contextProxySpy).toHaveBeenCalledWith(expectedViewKey, "architect") + // saveViewState uses the stable per-view id, not the construction-order viewId suffix. + expect(contextProxySpy).toHaveBeenCalledWith("__view_state_stable-sidebar-view_mode", "architect") + expect(contextProxySpy).not.toHaveBeenCalledWith(`__view_state_${provider.viewId}_mode`, expect.anything()) await provider.dispose() }) @@ -859,6 +860,37 @@ describe("ClineProvider - Parallel Mode Support", () => { await provider.dispose() }) + it("should restore mode, current API config name, and API configuration from stable per-view state", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + const stableViewId = "stable-editor-tab-a" + const persistedApiConfiguration = { + apiProvider: "openrouter" as const, + openRouterModelId: "openrouter/anthropic/claude-sonnet-4", + } + + await provider.contextProxy.setValue(`__view_state_${stableViewId}_mode` as any, "architect") + await provider.contextProxy.setValue( + `__view_state_${stableViewId}_currentApiConfigName` as any, + "profile-a", + ) + await provider.contextProxy.setValue( + `__view_state_${stableViewId}_apiConfiguration` as any, + persistedApiConfiguration, + ) + await provider.contextProxy.setValue("mode" as any, "debugger") + await provider.contextProxy.setValue("currentApiConfigName" as any, "profile-b") + await provider.contextProxy.setValue("apiConfiguration" as any, { apiProvider: "anthropic" }) + + await (provider as any).setViewStateId(stableViewId) + const state = await provider.getState() + + expect(state.mode).toBe("architect") + expect(state.currentApiConfigName).toBe("profile-a") + expect(state.apiConfiguration).toMatchObject(persistedApiConfiguration) + + await provider.dispose() + }) + it("should log and keep existing viewLocalState when loadViewState fails", async () => { const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) const logSpy = vi.spyOn(provider as any, "log") diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 01abc5db98..7647c4cb90 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -558,6 +558,8 @@ export const webviewMessageHandler = async ( switch (message.type) { case "webviewDidLaunch": + await provider.setViewStateId(message.viewStateId) + // Load custom modes first const customModes = await provider.customModesManager.getCustomModes() await updateGlobalState("customModes", customModes) diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 0521499dbb..5b1d6b04ff 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -191,7 +191,7 @@ const App = () => { }, [telemetrySetting, telemetryKey, machineId, didHydrateState]) // Tell the extension that we are ready to receive messages. - useEffect(() => vscode.postMessage({ type: "webviewDidLaunch" }), []) + useEffect(() => vscode.postMessage({ type: "webviewDidLaunch", viewStateId: vscode.getViewStateId() }), []) // Initialize source map support for better error reporting useEffect(() => { diff --git a/webview-ui/src/__tests__/App.spec.tsx b/webview-ui/src/__tests__/App.spec.tsx index 137bed5d70..2ddb3eb15c 100644 --- a/webview-ui/src/__tests__/App.spec.tsx +++ b/webview-ui/src/__tests__/App.spec.tsx @@ -8,6 +8,7 @@ import AppWithProviders from "../App" vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn(), + getViewStateId: vi.fn(() => "test-view-state-id"), }, })) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 29b958319c..90a117ba36 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -489,7 +489,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }, [handleMessage]) useEffect(() => { - vscode.postMessage({ type: "webviewDidLaunch" }) + vscode.postMessage({ type: "webviewDidLaunch", viewStateId: vscode.getViewStateId() }) }, []) // Apply the configurable chat font size as a CSS variable. When unset, the diff --git a/webview-ui/src/utils/vscode.ts b/webview-ui/src/utils/vscode.ts index 2cc0a58909..720647a33d 100644 --- a/webview-ui/src/utils/vscode.ts +++ b/webview-ui/src/utils/vscode.ts @@ -22,6 +22,31 @@ class VSCodeAPIWrapper { } } + private createViewStateId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID() + } + + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` + } + + public getViewStateId(): string { + const currentState = this.getState() + const stateObject = + currentState && typeof currentState === "object" && !Array.isArray(currentState) + ? (currentState as Record) + : {} + const existingViewStateId = stateObject.viewStateId + + if (typeof existingViewStateId === "string" && existingViewStateId.length > 0) { + return existingViewStateId + } + + const viewStateId = this.createViewStateId() + this.setState({ ...stateObject, viewStateId }) + return viewStateId + } + /** * Post a message (i.e. send arbitrary data) to the owner of the webview. * @@ -49,9 +74,11 @@ class VSCodeAPIWrapper { public getState(): unknown | undefined { if (this.vsCodeApi) { return this.vsCodeApi.getState() - } else { + } else if (typeof localStorage?.getItem === "function") { const state = localStorage.getItem("vscodeState") return state ? JSON.parse(state) : undefined + } else { + return undefined } } @@ -70,7 +97,9 @@ class VSCodeAPIWrapper { if (this.vsCodeApi) { return this.vsCodeApi.setState(newState) } else { - localStorage.setItem("vscodeState", JSON.stringify(newState)) + if (typeof localStorage?.setItem === "function") { + localStorage.setItem("vscodeState", JSON.stringify(newState)) + } return newState } } From e2bf0fd31c87ede1cb23798edcc42205d2125c7e Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 17 Jul 2026 04:16:24 +0800 Subject: [PATCH 07/11] fix(webview): tolerate missing view state id helper --- webview-ui/src/App.tsx | 9 ++++++++- webview-ui/src/context/ExtensionStateContext.tsx | 5 ++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 5b1d6b04ff..c270bdec07 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -191,7 +191,14 @@ const App = () => { }, [telemetrySetting, telemetryKey, machineId, didHydrateState]) // Tell the extension that we are ready to receive messages. - useEffect(() => vscode.postMessage({ type: "webviewDidLaunch", viewStateId: vscode.getViewStateId() }), []) + useEffect( + () => + vscode.postMessage({ + type: "webviewDidLaunch", + viewStateId: typeof vscode.getViewStateId === "function" ? vscode.getViewStateId() : undefined, + }), + [], + ) // Initialize source map support for better error reporting useEffect(() => { diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 90a117ba36..fba177b034 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -489,7 +489,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }, [handleMessage]) useEffect(() => { - vscode.postMessage({ type: "webviewDidLaunch", viewStateId: vscode.getViewStateId() }) + vscode.postMessage({ + type: "webviewDidLaunch", + viewStateId: typeof vscode.getViewStateId === "function" ? vscode.getViewStateId() : undefined, + }) }, []) // Apply the configurable chat font size as a CSS variable. When unset, the From 064afa9f66d3bdd2cfc42770136967c2717adc68 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 17 Jul 2026 04:33:56 +0800 Subject: [PATCH 08/11] fix(webview): use view-local launch state Read launch profile state from the active provider view and route mode changes through handleModeSwitch. Remove the duplicate webviewDidLaunch handshake from App so initialization only runs once via ExtensionStateContextProvider. --- .../__tests__/webviewMessageHandler.spec.ts | 111 ++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 7 +- webview-ui/src/App.tsx | 10 -- 3 files changed, 115 insertions(+), 13 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 12832da249..61657ad3a6 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -11,6 +11,17 @@ vi.mock("../../../api/providers/fetchers/lmstudio", () => ({ getLMStudioModels: vi.fn(), })) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + updateTelemetryState: vi.fn(), + captureCustomModeCreated: vi.fn(), + captureModeSettingChanged: vi.fn(), + }, + hasInstance: vi.fn(() => false), + }, +})) + vi.mock("../../../integrations/openai-codex/oauth", () => ({ openAiCodexOAuthManager: { getAccessToken: vi.fn(), @@ -82,6 +93,7 @@ const mockClineProvider = { postMessageToWebview: vi.fn(), customModesManager: { getCustomModes: vi.fn(), + updateCustomMode: vi.fn(), deleteCustomMode: vi.fn(), }, context: { @@ -102,6 +114,7 @@ const mockClineProvider = { getTaskWithId: vi.fn(), createTaskWithHistoryItem: vi.fn(), getSkillsManager: vi.fn(), + handleModeSwitch: vi.fn(), cwd: "/mock/workspace", } as unknown as ClineProvider @@ -177,6 +190,7 @@ import { getWorkspacePath } from "../../../utils/path" import { ensureSettingsDirectoryExists } from "../../../utils/globalContext" import { generateErrorDiagnostics } from "../diagnosticsHandler" import type { ModeConfig } from "@roo-code/types" +import { defaultModeSlug } from "../../../shared/modes" vi.mock("../../../utils/fs") vi.mock("../../../utils/path") @@ -193,6 +207,38 @@ import { resolveImageMentions } from "../../mentions/resolveImageMentions" import { Terminal } from "../../../integrations/terminal/Terminal" import { TerminalRegistry } from "../../../integrations/terminal/TerminalRegistry" +describe("webviewMessageHandler - webviewDidLaunch", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.getState).mockResolvedValue({ + apiConfiguration: { apiProvider: "anthropic" }, + currentApiConfigName: "view-local-profile", + } as any) + ;(mockClineProvider as any).setViewStateId = vi.fn().mockResolvedValue(undefined) + ;(mockClineProvider as any).workspaceTracker = { initializeFilePaths: vi.fn() } + ;(mockClineProvider as any).providerSettingsManager = { + listConfig: vi.fn().mockResolvedValue([{ name: "shared-profile", apiProvider: "anthropic" }]), + hasConfig: vi.fn().mockResolvedValue(false), + } + ;(mockClineProvider as any).activateProviderProfile = vi.fn().mockResolvedValue(undefined) + ;(mockClineProvider as any).getMcpHub = vi.fn().mockReturnValue(undefined) + ;(mockClineProvider as any).getStateToPostToWebview = vi + .fn() + .mockResolvedValue({ telemetrySetting: "disabled" }) + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([]) + vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue("shared-profile") + vi.mocked(mockClineProvider.contextProxy.setValue).mockResolvedValue(undefined) + }) + + it("validates the view-local currentApiConfigName on launch", async () => { + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch", viewStateId: "view-1" }) + await new Promise((resolve) => setImmediate(resolve)) + + expect((mockClineProvider as any).providerSettingsManager.hasConfig).toHaveBeenCalledWith("view-local-profile") + expect((mockClineProvider as any).providerSettingsManager.hasConfig).not.toHaveBeenCalledWith("shared-profile") + }) +}) + describe("webviewMessageHandler - requestLmStudioModels", () => { beforeEach(() => { vi.clearAllMocks() @@ -776,6 +822,71 @@ describe("webviewMessageHandler - requestOpenAiCodexRateLimits", () => { }) }) +describe("webviewMessageHandler - updateCustomMode", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.customModesManager.getCustomModes) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + name: "New Mode", + slug: "new-mode", + roleDefinition: "Test Role", + groups: [], + } as ModeConfig, + ]) + vi.mocked(mockClineProvider.customModesManager.updateCustomMode).mockResolvedValue(undefined) + vi.mocked(mockClineProvider.contextProxy.setValue).mockResolvedValue(undefined) + vi.mocked((mockClineProvider as any).handleModeSwitch).mockResolvedValue(undefined) + }) + + it("switches the active view to the saved custom mode", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateCustomMode", + modeConfig: { + name: "New Mode", + slug: "new-mode", + roleDefinition: "Test Role", + groups: [], + }, + }) + + expect(mockClineProvider.customModesManager.updateCustomMode).toHaveBeenCalledWith( + "new-mode", + expect.objectContaining({ slug: "new-mode" }), + ) + expect((mockClineProvider as any).handleModeSwitch).toHaveBeenCalledWith("new-mode") + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith("mode", "new-mode") + }) +}) + +describe("webviewMessageHandler - deleteCustomMode view-local mode", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([ + { + name: "Test Global Mode", + slug: "test-global-mode", + roleDefinition: "Test Role", + groups: [], + source: "global", + } as ModeConfig, + ]) + vi.spyOn(fsUtils, "fileExistsAtPath").mockResolvedValue(false) + vi.mocked(mockClineProvider.customModesManager.deleteCustomMode).mockResolvedValue(undefined) + vi.mocked(mockClineProvider.contextProxy.setValue).mockResolvedValue(undefined) + vi.mocked((mockClineProvider as any).handleModeSwitch).mockResolvedValue(undefined) + }) + + it("switches the active view back to the default mode", async () => { + await webviewMessageHandler(mockClineProvider, { type: "deleteCustomMode", slug: "test-global-mode" }) + + expect(mockClineProvider.customModesManager.deleteCustomMode).toHaveBeenCalledWith("test-global-mode") + expect((mockClineProvider as any).handleModeSwitch).toHaveBeenCalledWith(defaultModeSlug) + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalledWith("mode", defaultModeSlug) + }) +}) + describe("webviewMessageHandler - deleteCustomMode", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 7647c4cb90..1329a8983c 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -604,7 +604,8 @@ export const webviewMessageHandler = async ( } } - const currentConfigName = getGlobalState("currentApiConfigName") + const currentState = await provider.getState() + const currentConfigName = currentState.currentApiConfigName if (currentConfigName) { if (!(await provider.providerSettingsManager.hasConfig(currentConfigName))) { @@ -2197,7 +2198,7 @@ export const webviewMessageHandler = async ( // Update state after saving the mode const customModes = await provider.customModesManager.getCustomModes() await updateGlobalState("customModes", customModes) - await updateGlobalState("mode", message.modeConfig.slug) + await provider.handleModeSwitch(message.modeConfig.slug as Mode) await provider.postStateToWebview() // Track telemetry for custom mode creation or update @@ -2293,7 +2294,7 @@ export const webviewMessageHandler = async ( } // Switch back to default mode after deletion - await updateGlobalState("mode", defaultModeSlug) + await provider.handleModeSwitch(defaultModeSlug) await provider.postStateToWebview() } break diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index c270bdec07..8069e2d7fa 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -190,16 +190,6 @@ const App = () => { } }, [telemetrySetting, telemetryKey, machineId, didHydrateState]) - // Tell the extension that we are ready to receive messages. - useEffect( - () => - vscode.postMessage({ - type: "webviewDidLaunch", - viewStateId: typeof vscode.getViewStateId === "function" ? vscode.getViewStateId() : undefined, - }), - [], - ) - // Initialize source map support for better error reporting useEffect(() => { // Initialize source maps for better error reporting in production From bebe2c25340032ca6c86939b5cffaa0653a64bed Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 17 Jul 2026 15:26:44 +0800 Subject: [PATCH 09/11] fix(core): broadcast reset/import to parallel views, remove frozen shared data snapshot --- .../config/__tests__/importExport.spec.ts | 98 ++++++ src/core/config/importExport.ts | 8 + src/core/webview/ClineProvider.ts | 41 ++- .../ClineProvider.parallelMode.spec.ts | 313 ++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 6 +- 5 files changed, 462 insertions(+), 4 deletions(-) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 9cbb364c92..80a26944e5 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -836,6 +836,104 @@ describe("importExport", () => { expect(mockProvider.settingsImportedAt).toBeUndefined() }) + it("should call broadcastResetToAllInstances after successful import when available", async () => { + const filePath = "/mock/path/settings.json" + const mockFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "valid-profile", + apiConfigs: { + "valid-profile": { + apiProvider: "openai" as ProviderName, + apiKey: "test-key", + id: "valid-id", + }, + }, + }, + globalSettings: { mode: "code" }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) + ;(fs.access as Mock).mockResolvedValue(undefined) + + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName }, + ]) + + const broadcastResetSpy = vi.fn().mockResolvedValue(undefined) + const mockProvider = { + settingsImportedAt: 0, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + broadcastResetToAllInstances: broadcastResetSpy, + } + + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) + + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + expect(broadcastResetSpy).toHaveBeenCalledTimes(1) + + mockProviderSettingsManager.listConfig.mockResolvedValue([]) + }) + + it("should skip broadcastResetToAllInstances when provider does not have it", async () => { + const filePath = "/mock/path/settings.json" + const mockFileContent = JSON.stringify({ + providerProfiles: { + currentApiConfigName: "valid-profile", + apiConfigs: { + "valid-profile": { + apiProvider: "openai" as ProviderName, + apiKey: "test-key", + id: "valid-id", + }, + }, + }, + globalSettings: { mode: "code" }, + }) + + ;(fs.readFile as Mock).mockResolvedValue(mockFileContent) + ;(fs.access as Mock).mockResolvedValue(undefined) + + mockProviderSettingsManager.export.mockResolvedValue({ + currentApiConfigName: "default", + apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, + }) + mockProviderSettingsManager.listConfig.mockResolvedValue([ + { name: "valid-profile", id: "valid-id", apiProvider: "openai" as ProviderName }, + ]) + + const mockProvider = { + settingsImportedAt: 0, + postStateToWebview: vi.fn().mockResolvedValue(undefined), + // No broadcastResetToAllInstances property + } + + await importSettingsWithFeedback( + { + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + provider: mockProvider, + }, + filePath, + ) + + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + + mockProviderSettingsManager.listConfig.mockResolvedValue([]) + }) + it("should handle multiple profiles with mixed valid and invalid providers", async () => { ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index b5fd5fdf98..846a61d4ce 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -36,6 +36,7 @@ type ImportWithProviderOptions = ImportOptions & { provider: { settingsImportedAt?: number postStateToWebview: () => Promise + broadcastResetToAllInstances?(): Promise } } @@ -385,6 +386,13 @@ export const importSettingsWithFeedback = async ( if (result.success) { provider.settingsImportedAt = Date.now() await provider.postStateToWebview() + + // Broadcast invalidation to all other live ClineProvider instances so parallel + // tabs don't keep stale view-local state after a settings import. + if (provider.broadcastResetToAllInstances) { + await provider.broadcastResetToAllInstances() + } + provider.settingsImportedAt = undefined const warnings = "warnings" in result ? result.warnings : undefined diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 629cb1ca63..e130b028e8 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -432,6 +432,14 @@ export class ClineProvider * Derive a view-specific ContextProxy key for persisting view-local state. * Uses a stable per-view id so each restored tab reads and writes its own values * independent of provider construction order. + * + * PERSISTENCE NOTE (Finding #2): These keys (`__view_state_{id}_mode`, etc.) are NOT + * included in `GLOBAL_STATE_KEYS` from @roo-code/types, so they survive within a single + * extension session but do NOT persist across extension host restarts. If the extension is + * reloaded, view-local state falls back to the shared global keys (e.g. `mode`, + * `currentApiConfigName`). For cross-session persistence of view-specific values, either: + * - Add these keys to GLOBAL_STATE_KEYS, or + * - Use vscode.workspaceState instead of contextProxy.globalState. */ private viewStateKeyFor(key: "mode" | "currentApiConfigName" | "apiConfiguration"): string { return `__view_state_${this.viewStateId}_${key}` @@ -471,8 +479,10 @@ export class ClineProvider mode: getViewSpecificValue("mode"), currentApiConfigName: getViewSpecificValue("currentApiConfigName"), apiConfiguration: getViewSpecificValue("apiConfiguration") ?? providerSettings, - customModePrompts: stateValues.customModePrompts, - modeApiConfigs: stateValues.modeApiConfigs, + // customModePrompts / modeApiConfigs are intentionally excluded from viewLocalState. + // They are project-level shared data — always read directly from contextProxy via + // getState() so all parallel tabs see the same values without being frozen into a + // stale local snapshot. } this.log(`[loadViewState] Loaded state for viewId ${this.viewId}`) } catch (error) { @@ -2725,7 +2735,13 @@ export class ClineProvider const stateValues = this.contextProxy.getValues() // NEW: Merge viewLocalState on top of global state - // This allows each Provider instance to have its own mode/apiConfig for parallel mode support + // This allows each Provider instance to have its own mode/apiConfig for parallel mode support. + // + // CREDENTIAL MERGING NOTE (Finding #1): The spread `{ ...stateValues, ...this.viewLocalState }` + // means that if Tab A has a local `apiConfiguration` with an API key but Tab B only has the shared + // state, Tab B's `getState()` will still see Tab A's key (because both share the same ContextProxy). + // This is expected behavior — parallel tabs within the same extension host share memory by design. + // If isolation is needed in the future, consider per-tab secret storage via vscode.SecretStorage. const mergedStateValues = { ...stateValues, ...this.viewLocalState } const customModes = await this.customModesManager.getCustomModes() @@ -3089,6 +3105,21 @@ export class ClineProvider this.viewLocalState = {} } + /** + * Broadcast a reset event to all other live ClineProvider instances, clearing their + * view-local state caches and posting updated state so parallel tabs stay in sync. + * Also exposed for use by importSettingsWithFeedback (via broadcastResetToAllInstances callback). + */ + async broadcastResetToAllInstances(): Promise { + const allInstances = ClineProvider.getAllInstances() + for (const instance of allInstances) { + if (instance !== this) { + instance._clearViewLocalState() + await instance.postStateToWebview() + } + } + } + // dev async resetState() { @@ -3120,6 +3151,10 @@ export class ClineProvider await this.providerSettingsManager.resetAllConfigs() await this.customModesManager.resetCustomModes() await this.removeClineFromStack() + + // Broadcast reset to all other live instances so parallel tabs don't keep stale state. + await this.broadcastResetToAllInstances() + await this.postStateToWebview() await this.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) } diff --git a/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts index cecbc9296f..b36cc12cd5 100644 --- a/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts @@ -1467,4 +1467,317 @@ describe("ClineProvider - Parallel Mode Support", () => { await provider2.dispose() }) }) + + describe("broadcastResetToAllInstances", () => { + it("should clear viewLocalState in other instances and post updated state", async () => { + const mockPostMessage1 = vi.fn() + const mockPostMessage2 = vi.fn() + + const mockWebviewView1: any = { + webview: { + postMessage: mockPostMessage1, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } + + const mockWebviewView2: any = { + webview: { + postMessage: mockPostMessage2, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } + + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await (provider1 as any).resolveWebviewView(mockWebviewView1) + await (provider2 as any).resolveWebviewView(mockWebviewView2) + + // Set different modes for each provider + await (provider1 as any).saveViewState("mode", "architect") + await (provider2 as any).saveViewState("mode", "debugger") + + expect((provider1 as any).viewLocalState.mode).toBe("architect") + expect((provider2 as any).viewLocalState.mode).toBe("debugger") + + // Call broadcastResetToAllInstances on provider1 + await provider1.broadcastResetToAllInstances() + + // provider1 should NOT clear its own state + expect((provider1 as any).viewLocalState.mode).toBe("architect") + + // provider2 should have cleared its viewLocalState + expect(Object.prototype.hasOwnProperty.call((provider2 as any).viewLocalState, "mode")).toBe(false) + + // provider2 should have posted updated state to webview + expect(mockPostMessage2).toHaveBeenCalled() + }) + + it("should not broadcast to itself but clear others", async () => { + const mockPostMessage1 = vi.fn() + const mockPostMessage2 = vi.fn() + + const mockWebviewView1: any = { + webview: { + postMessage: mockPostMessage1, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } + + const mockWebviewView2: any = { + webview: { + postMessage: mockPostMessage2, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } + + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await (provider1 as any).resolveWebviewView(mockWebviewView1) + await (provider2 as any).resolveWebviewView(mockWebviewView2) + + await (provider1 as any).saveViewState("mode", "architect") + await (provider2 as any).saveViewState("mode", "debugger") + + // Call broadcastResetToAllInstances on provider2 + await provider2.broadcastResetToAllInstances() + + // provider2 should NOT have posted to its own webview (it's the broadcaster) + expect(mockPostMessage2).not.toHaveBeenCalled() + + // provider1 should have been cleared and posted state + expect((provider1 as any).viewLocalState.mode).toBeUndefined() + expect(mockPostMessage1).toHaveBeenCalled() + }) + + it("should handle single instance gracefully", async () => { + const mockPostMessage = vi.fn() + + const mockWebviewView: any = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).resolveWebviewView(mockWebviewView) + + await (provider as any).saveViewState("mode", "architect") + + // Single instance broadcast should not throw + await provider.broadcastResetToAllInstances() + + // Single instance should keep its own state + expect((provider as any).viewLocalState.mode).toBe("architect") + + // Should not have posted to webview (no other instances) + expect(mockPostMessage).not.toHaveBeenCalled() + }) + + it("should broadcast to all instances in getAllInstances", async () => { + const mockPostMessage1 = vi.fn() + const mockPostMessage2 = vi.fn() + const mockPostMessage3 = vi.fn() + + const createMockWebviewView = (postMessage: any) => ({ + webview: { + postMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + }) + + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + const provider3 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + + await (provider1 as any).resolveWebviewView(createMockWebviewView(mockPostMessage1)) + await (provider2 as any).resolveWebviewView(createMockWebviewView(mockPostMessage2)) + await (provider3 as any).resolveWebviewView(createMockWebviewView(mockPostMessage3)) + + await (provider1 as any).saveViewState("mode", "code") + await (provider2 as any).saveViewState("mode", "architect") + await (provider3 as any).saveViewState("mode", "debugger") + + // Broadcast from provider1 + await provider1.broadcastResetToAllInstances() + + // provider1 keeps its state + expect((provider1 as any).viewLocalState.mode).toBe("code") + + // provider2 and provider3 should be cleared + expect(Object.prototype.hasOwnProperty.call((provider2 as any).viewLocalState, "mode")).toBe(false) + expect(Object.prototype.hasOwnProperty.call((provider3 as any).viewLocalState, "mode")).toBe(false) + + // provider2 and provider3 should have posted state + expect(mockPostMessage2).toHaveBeenCalled() + expect(mockPostMessage3).toHaveBeenCalled() + }) + }) + + describe("_clearViewLocalState", () => { + it("should clear all view-local state values", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).saveViewState("mode", "architect") + await (provider as any).saveViewState("currentApiConfigName", "my-profile") + await (provider as any).saveViewState("apiConfiguration", { apiProvider: "openrouter" }) + + expect((provider as any).viewLocalState.mode).toBe("architect") + expect((provider as any).viewLocalState.currentApiConfigName).toBe("my-profile") + expect((provider as any).viewLocalState.apiConfiguration).toEqual({ apiProvider: "openrouter" }) + + // Call _clearViewLocalState + ;(provider as any)._clearViewLocalState() + + // All values should be cleared + expect((provider as any).viewLocalState).toEqual({}) + + await provider.dispose() + }) + + it("should cause getState to fall back to contextProxy values after clear", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).saveViewState("mode", "architect") + + let state = await provider.getState() + expect(state.mode).toBe("architect") + + // Clear viewLocalState + ;(provider as any)._clearViewLocalState() + + // getState should now fall back to contextProxy (global) state + state = await provider.getState() + expect(state.mode).toBe("code") // Default from mock context + + await provider.dispose() + }) + + it("should be safe to call on empty viewLocalState", async () => { + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Should not throw even if viewLocalState is already empty + expect((provider as any)._clearViewLocalState()).toBeUndefined() + expect((provider as any).viewLocalState).toEqual({}) + + await provider.dispose() + }) + }) + + describe("resetState broadcasts", () => { + it("should clear viewLocalState and call broadcastResetToAllInstances in resetState flow", async () => { + const mockPostMessage1 = vi.fn() + const mockPostMessage2 = vi.fn() + + const createMockWebviewView = (postMessage: any) => ({ + webview: { + postMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + }) + + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await (provider1 as any).resolveWebviewView(createMockWebviewView(mockPostMessage1)) + await (provider2 as any).resolveWebviewView(createMockWebviewView(mockPostMessage2)) + + // Set different modes + await (provider1 as any).saveViewState("mode", "architect") + await (provider2 as any).saveViewState("mode", "debugger") + + // Verify initial state + expect((provider1 as any).viewLocalState.mode).toBe("architect") + expect((provider2 as any).viewLocalState.mode).toBe("debugger") + + // Manually call the same sequence that resetState does after confirmation: + // 1. _clearViewLocalState on the calling instance + ;(provider1 as any)._clearViewLocalState() + expect((provider1 as any).viewLocalState).toStrictEqual({}) + + // 2. broadcastResetToAllInstances to clear other instances + await provider1.broadcastResetToAllInstances() + + // provider2 should have been cleared + expect(Object.hasOwn((provider2 as any).viewLocalState, "mode")).toBe(false) + expect(mockPostMessage2).toHaveBeenCalled() + }) + }) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 1329a8983c..b7b25fb406 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -914,7 +914,11 @@ export const webviewMessageHandler = async ( providerSettingsManager: provider.providerSettingsManager, contextProxy: provider.contextProxy, customModesManager: provider.customModesManager, - provider: provider, + provider: { + settingsImportedAt: provider.settingsImportedAt, + postStateToWebview: () => provider.postStateToWebview(), + broadcastResetToAllInstances: () => provider.broadcastResetToAllInstances(), + }, }) break From de1cb2fe14c043614c6ad6c6a6b427745a63ba54 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 17 Jul 2026 15:44:28 +0800 Subject: [PATCH 10/11] fix(importExport): wrap broadcastResetToAllInstances in try-catch after settings import --- src/core/config/importExport.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index 846a61d4ce..79f48b84c5 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -389,8 +389,13 @@ export const importSettingsWithFeedback = async ( // Broadcast invalidation to all other live ClineProvider instances so parallel // tabs don't keep stale view-local state after a settings import. - if (provider.broadcastResetToAllInstances) { - await provider.broadcastResetToAllInstances() + try { + if (provider.broadcastResetToAllInstances) { + await provider.broadcastResetToAllInstances() + } + } catch (error) { + // Log but do not fail the import if broadcast fails — the import itself succeeded. + console.warn(`Failed to broadcast reset after settings import: ${error}`) } provider.settingsImportedAt = undefined From 92ad6326a0f7f91224826ea789eae7e0b8fd639b Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Fri, 17 Jul 2026 16:30:52 +0800 Subject: [PATCH 11/11] fix(webview): use merged view-local state in deleteProviderProfile for parallel mode --- src/core/webview/ClineProvider.ts | 9 +- .../ClineProvider.parallelMode.spec.ts | 117 ++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e130b028e8..d2f3d1cc9a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1824,8 +1824,11 @@ export class ClineProvider } async deleteProviderProfile(profileToDelete: ProviderSettingsEntry) { - const globalSettings = this.contextProxy.getValues() - let profileToActivate: string | undefined = globalSettings.currentApiConfigName + // Use merged state (view-local + global) so we read THIS TAB's current profile choice, + // NOT the shared global state which may belong to another parallel tab. + const { currentApiConfigName: globalCurrentProfile } = await this.getState() + + let profileToActivate: string | undefined = globalCurrentProfile if (profileToDelete.name === profileToActivate) { profileToActivate = this.getProviderProfileEntries().find(({ name }) => name !== profileToDelete.name)?.name @@ -1837,6 +1840,8 @@ export class ClineProvider const entries = this.getProviderProfileEntries().filter(({ name }) => name !== profileToDelete.name) + const globalSettings = await this.getState() + await this.contextProxy.setValues({ ...globalSettings, currentApiConfigName: profileToActivate, diff --git a/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts index b36cc12cd5..85fdb418c4 100644 --- a/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.parallelMode.spec.ts @@ -1780,4 +1780,121 @@ describe("ClineProvider - Parallel Mode Support", () => { expect(mockPostMessage2).toHaveBeenCalled() }) }) + + describe("deleteProviderProfile", () => { + it("should use merged state (view-local) when determining profileToActivate in parallel mode", async () => { + const mockPostMessage1 = vi.fn() + const mockPostMessage2 = vi.fn() + + const createMockWebviewView = (postMessage: any) => ({ + webview: { + postMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + }) + + const provider1 = new ClineProvider( + mockContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockContext), + ) + const provider2 = new ClineProvider(mockContext, mockOutputChannel, "editor", new ContextProxy(mockContext)) + + await (provider1 as any).resolveWebviewView(createMockWebviewView(mockPostMessage1)) + await (provider2 as any).resolveWebviewView(createMockWebviewView(mockPostMessage2)) + + // Set different profiles for each provider via saveViewState + await (provider1 as any).saveViewState("currentApiConfigName", "profile-a") + await (provider2 as any).saveViewState("currentApiConfigName", "profile-b") + + // Verify view-local state is isolated + const state1 = await provider1.getState() + let state2 = await provider2.getState() + expect(state1.currentApiConfigName).toBe("profile-a") + expect(state2.currentApiConfigName).toBe("profile-b") + + // Set up global state listApiConfigMeta with 3 profiles (including profile-to-delete) + const profileToDelete = { id: "del-id", name: "profile-to-delete", apiProvider: "anthropic" as const } + await provider1.contextProxy.setValues({ + listApiConfigMeta: [ + { id: "a-id", name: "profile-a", apiProvider: "anthropic" }, + { id: "b-id", name: "profile-b", apiProvider: "anthropic" }, + profileToDelete, + ], + }) + + // provider2's viewLocalState has currentApiConfigName = "profile-b" + // When provider2 deletes "profile-to-delete", it should NOT activate "profile-a" (provider1's profile) + // It should keep "profile-b" because that's what THIS TAB is using + + // Spy on _updateViewLocalStateFromMutation to capture what profile gets activated + const updateViewLocalSpy = vi.spyOn(provider2 as any, "_updateViewLocalStateFromMutation") + + await provider2.deleteProviderProfile(profileToDelete) + + // The key assertion: profileToActivate should be "profile-b" (provider2's view-local), + // NOT "profile-a" (provider1's view-local from shared global state). + // _updateViewLocalStateFromMutation IS called with the merged state's currentApiConfigName. + expect(updateViewLocalSpy).toHaveBeenCalledWith({ currentApiConfigName: "profile-b" }) + + // Verify provider2's state is still "profile-b" after deletion + state2 = await provider2.getState() + expect(state2.currentApiConfigName).toBe("profile-b") + + await provider1.dispose() + await provider2.dispose() + }) + + it("should activate the correct fallback profile when deleting the current profile in parallel mode", async () => { + const mockPostMessage = vi.fn() + + const createMockWebviewView = (postMessage: any) => ({ + webview: { + postMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + onDidDispose: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + }) + + const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + await (provider as any).resolveWebviewView(createMockWebviewView(mockPostMessage)) + + // Set up view-local state to use "profile-a" + await (provider as any).saveViewState("currentApiConfigName", "profile-a") + + // Set up global state with multiple profiles + await provider.contextProxy.setValues({ + listApiConfigMeta: [ + { id: "a-id", name: "profile-a", apiProvider: "anthropic" }, + { id: "b-id", name: "profile-b", apiProvider: "openrouter" }, + { id: "c-id", name: "profile-c", apiProvider: "anthropic" }, + ], + }) + + // Delete the currently active profile (profile-a) + const profileToDelete = { id: "a-id", name: "profile-a", apiProvider: "anthropic" as const } + await provider.deleteProviderProfile(profileToDelete) + + // After deletion, should activate to the first remaining profile (profile-b) + const state = await provider.getState() + expect(state.currentApiConfigName).toBe("profile-b") + + await provider.dispose() + }) + }) })