diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 47b574e4b7b..7eaa4c574fc 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -309,6 +309,7 @@ export type ExtensionState = Pick< currentTaskItem?: HistoryItem currentTaskTodos?: TodoItem[] // Initial todos for the current task apiConfiguration: ProviderSettings + currentConfigScope?: "global" | "workspace" // Current configuration scope uriScheme?: string shouldShowAnnouncement: boolean @@ -583,6 +584,7 @@ export interface WebviewMessage { tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" disabled?: boolean context?: string + scope?: "global" | "workspace" dataUri?: string askResponse?: ClineAskResponse apiConfiguration?: ProviderSettings diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 2825d1c9452..fe96576a0b2 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -506,6 +506,15 @@ export class ContextProxy { : this.updateGlobalState(key as GlobalStateKey, value) } + /** + * Update a value only in the in-memory cache without persisting to globalState. + * Use this when a value should be reflected locally (e.g. webview display) + * but should not leak to other VS Code windows via shared globalState. + */ + public setLocalValue(key: K, value: GlobalState[K]) { + this.stateCache[key] = value + } + public getValue(key: K): RooCodeSettings[K] { return isSecretStateKey(key) ? (this.getSecret(key as SecretStateKey) as RooCodeSettings[K]) diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 6088bd68fe2..b11aff6ea18 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -1,4 +1,4 @@ -import { ExtensionContext } from "vscode" +import { ExtensionContext, workspace } from "vscode" import { z, ZodError } from "zod" import deepEqual from "fast-deep-equal" @@ -19,6 +19,8 @@ import { TelemetryService } from "@roo-code/telemetry" import { Mode, modes } from "../../shared/modes" import { buildApiHandler } from "../../api" +export type ConfigScope = "global" | "workspace" + // Type-safe model migrations mapping type ModelMigrations = { [K in ProviderName]?: Record @@ -54,8 +56,17 @@ export const providerProfilesSchema = z.object({ export type ProviderProfiles = z.infer +// Schema for workspace-specific overrides +export const workspaceOverridesSchema = z.object({ + currentApiConfigName: z.string().optional(), + modeApiConfigs: z.record(z.string(), z.string()).optional(), +}) + +export type WorkspaceOverrides = z.infer + export class ProviderSettingsManager { private static readonly SCOPE_PREFIX = "roo_cline_config_" + private static readonly WORKSPACE_KEY = "roo_workspace_overrides" private readonly defaultConfigId = this.generateId() private readonly defaultModeApiConfigs: Record = Object.fromEntries( @@ -418,17 +429,31 @@ export class ProviderSettingsManager { /** * Activate a profile by name or ID. + * @param params Profile identifier + * @param scope Whether to apply globally or to workspace only */ public async activateProfile( params: { name: string } | { id: string }, + scope: ConfigScope = "global", ): Promise { const { name, ...providerSettings } = await this.getProfile(params) try { return await this.lock(async () => { - const providerProfiles = await this.load() - providerProfiles.currentApiConfigName = name - await this.store(providerProfiles) + if (scope === "workspace" && workspace.workspaceFolders?.length) { + // Store workspace-specific override only + const overrides = await this.getWorkspaceOverrides() + overrides.currentApiConfigName = name + await this.storeWorkspaceOverrides(overrides) + } else { + // Store globally and clear any workspace overrides + if (scope === "global") { + await this.clearWorkspaceOverrides() + } + const providerProfiles = await this.load() + providerProfiles.currentApiConfigName = name + await this.store(providerProfiles) + } return { name, ...providerSettings } }) } catch (error) { @@ -436,6 +461,33 @@ export class ProviderSettingsManager { } } + /** + * Get the currently active profile name, considering workspace overrides. + */ + public async getActiveProfileName(): Promise { + try { + return await this.lock(async () => { + // Check for workspace override first + if (workspace.workspaceFolders?.length) { + const overrides = await this.getWorkspaceOverrides() + if (overrides.currentApiConfigName) { + // Verify the profile still exists + const providerProfiles = await this.load() + if (providerProfiles.apiConfigs[overrides.currentApiConfigName]) { + return overrides.currentApiConfigName + } + } + } + + // Fall back to global setting + const providerProfiles = await this.load() + return providerProfiles.currentApiConfigName + }) + } catch (error) { + throw new Error(`Failed to get active profile: ${error}`) + } + } + /** * Delete a config by name. */ @@ -476,18 +528,32 @@ export class ProviderSettingsManager { /** * Set the API config for a specific mode. + * @param mode The mode to set config for + * @param configId The config ID to use + * @param scope Whether to apply globally or to workspace only */ - public async setModeConfig(mode: Mode, configId: string) { + public async setModeConfig(mode: Mode, configId: string, scope: ConfigScope = "global") { try { return await this.lock(async () => { - const providerProfiles = await this.load() - // Ensure the per-mode config map exists - if (!providerProfiles.modeApiConfigs) { - providerProfiles.modeApiConfigs = {} + if (scope === "workspace" && workspace.workspaceFolders?.length) { + // Store workspace-specific override + const overrides = await this.getWorkspaceOverrides() + if (!overrides.modeApiConfigs) { + overrides.modeApiConfigs = {} + } + overrides.modeApiConfigs[mode] = configId + await this.storeWorkspaceOverrides(overrides) + } else { + // Store globally + const providerProfiles = await this.load() + // Ensure the per-mode config map exists + if (!providerProfiles.modeApiConfigs) { + providerProfiles.modeApiConfigs = {} + } + // Assign the chosen config ID to this mode + providerProfiles.modeApiConfigs[mode] = configId + await this.store(providerProfiles) } - // Assign the chosen config ID to this mode - providerProfiles.modeApiConfigs[mode] = configId - await this.store(providerProfiles) }) } catch (error) { throw new Error(`Failed to set mode config: ${error}`) @@ -495,11 +561,28 @@ export class ProviderSettingsManager { } /** - * Get the API config ID for a specific mode. + * Get the API config ID for a specific mode, considering workspace overrides. */ public async getModeConfigId(mode: Mode) { try { return await this.lock(async () => { + // Check for workspace override first + if (workspace.workspaceFolders?.length) { + const overrides = await this.getWorkspaceOverrides() + if (overrides.modeApiConfigs?.[mode]) { + // Verify the config still exists + const providerProfiles = await this.load() + const configId = overrides.modeApiConfigs[mode] + const configExists = Object.values(providerProfiles.apiConfigs).some( + (config) => config.id === configId, + ) + if (configExists) { + return configId + } + } + } + + // Fall back to global setting const { modeApiConfigs } = await this.load() return modeApiConfigs?.[mode] }) @@ -878,4 +961,60 @@ export class ProviderSettingsManager { throw new Error(`Failed to sync cloud profiles: ${error}`) } } + + /** + * Get workspace-specific overrides. + */ + private async getWorkspaceOverrides(): Promise { + if (!workspace.workspaceFolders?.length) { + return {} + } + + const stored = this.context.workspaceState.get(ProviderSettingsManager.WORKSPACE_KEY) + + if (stored) { + try { + return workspaceOverridesSchema.parse(stored) + } catch { + return {} + } + } + + return {} + } + + /** + * Store workspace-specific overrides. + */ + private async storeWorkspaceOverrides(overrides: WorkspaceOverrides): Promise { + if (!workspace.workspaceFolders?.length) { + return + } + + await this.context.workspaceState.update(ProviderSettingsManager.WORKSPACE_KEY, overrides) + } + + /** + * Clear workspace-specific overrides. + */ + public async clearWorkspaceOverrides(): Promise { + if (!workspace.workspaceFolders?.length) { + return + } + + await this.context.workspaceState.update(ProviderSettingsManager.WORKSPACE_KEY, undefined) + } + + /** + * Get the current configuration scope preference. + */ + public async getConfigScope(): Promise { + if (workspace.workspaceFolders?.length) { + const overrides = await this.getWorkspaceOverrides() + if (overrides.currentApiConfigName || overrides.modeApiConfigs) { + return "workspace" + } + } + return "global" + } } diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 3f6b4f78478..a2b678ad227 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -1,11 +1,18 @@ // npx vitest src/core/config/__tests__/ProviderSettingsManager.spec.ts -import { ExtensionContext } from "vscode" +import { ExtensionContext, workspace } from "vscode" import type { ProviderSettings } from "@roo-code/types" import { ProviderSettingsManager, ProviderProfiles, SyncCloudProfilesResult } from "../ProviderSettingsManager" +// Mock VSCode workspace +vi.mock("vscode", () => ({ + workspace: { + workspaceFolders: undefined, + }, +})) + // Mock VSCode ExtensionContext const mockSecrets = { get: vi.fn(), @@ -18,9 +25,15 @@ const mockGlobalState = { update: vi.fn(), } +const mockWorkspaceState = { + get: vi.fn(), + update: vi.fn(), +} + const mockContext = { secrets: mockSecrets, globalState: mockGlobalState, + workspaceState: mockWorkspaceState, } as unknown as ExtensionContext describe("ProviderSettingsManager", () => { @@ -1397,4 +1410,331 @@ describe("ProviderSettingsManager", () => { expect(result.activeProfileId).toBe("local-id") }) }) + + describe("Workspace-scoped functionality", () => { + beforeEach(() => { + mockWorkspaceState.get.mockReturnValue(undefined) + mockWorkspaceState.update.mockResolvedValue(undefined) + }) + + describe("activateProfile with workspace scope", () => { + it("should store profile activation in workspace when scope is workspace", async () => { + ;(workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + const existingConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + test: { + apiProvider: "anthropic", + apiKey: "test-key", + id: "test-id", + }, + }, + modeApiConfigs: {}, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const { name } = await providerSettingsManager.activateProfile({ name: "test" }, "workspace") + + expect(name).toBe("test") + + // Should update workspace state, not global state + expect(mockWorkspaceState.update).toHaveBeenCalledWith("roo_workspace_overrides", { + currentApiConfigName: "test", + }) + }) + + it("should fall back to global when not in a workspace", async () => { + ;(workspace as any).workspaceFolders = undefined + + const existingConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + test: { apiProvider: "anthropic", id: "test-id" }, + }, + modeApiConfigs: {}, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + await providerSettingsManager.activateProfile({ name: "test" }, "workspace") + + // Should update global state since no workspace + const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) + expect(storedConfig.currentApiConfigName).toBe("test") + + // Should NOT update workspace state + expect(mockWorkspaceState.update).not.toHaveBeenCalled() + }) + + it("should clear workspace overrides when activating with global scope", async () => { + ;(workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + const existingConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + test: { apiProvider: "anthropic", id: "test-id" }, + }, + modeApiConfigs: {}, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + // Activate with global scope - should clear workspace overrides + await providerSettingsManager.activateProfile({ name: "test" }, "global") + + // Should clear workspace overrides + expect(mockWorkspaceState.update).toHaveBeenCalledWith("roo_workspace_overrides", undefined) + + // Should update global state + const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) + expect(storedConfig.currentApiConfigName).toBe("test") + }) + }) + + describe("getActiveProfileName with workspace overrides", () => { + it("should return workspace override when it exists", async () => { + ;(workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + const globalConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + workspace: { id: "workspace-id" }, + }, + modeApiConfigs: {}, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(globalConfig)) + mockWorkspaceState.get.mockReturnValue({ + currentApiConfigName: "workspace", + }) + + const activeProfile = await providerSettingsManager.getActiveProfileName() + expect(activeProfile).toBe("workspace") + }) + + it("should fall back to global when workspace override profile doesn't exist", async () => { + ;(workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + const globalConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + }, + modeApiConfigs: {}, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(globalConfig)) + mockWorkspaceState.get.mockReturnValue({ + currentApiConfigName: "non-existent", + }) + + const activeProfile = await providerSettingsManager.getActiveProfileName() + expect(activeProfile).toBe("default") + }) + + it("should return global profile when not in workspace", async () => { + ;(workspace as any).workspaceFolders = undefined + + const globalConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + }, + modeApiConfigs: {}, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(globalConfig)) + + const activeProfile = await providerSettingsManager.getActiveProfileName() + expect(activeProfile).toBe("default") + }) + }) + + describe("setModeConfig with workspace scope", () => { + it("should store mode config in workspace when scope is workspace", async () => { + ;(workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + const globalConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + test: { id: "test-id" }, + }, + modeApiConfigs: { + code: "default-id", + }, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(globalConfig)) + + await providerSettingsManager.setModeConfig("code", "test-id", "workspace") + + // Should update workspace state + expect(mockWorkspaceState.update).toHaveBeenCalledWith("roo_workspace_overrides", { + modeApiConfigs: { code: "test-id" }, + }) + }) + + it("should update existing workspace mode configs", async () => { + ;(workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + mockWorkspaceState.get.mockReturnValue({ + modeApiConfigs: { + architect: "architect-id", + }, + }) + + const globalConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: {}, + modeApiConfigs: {}, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(globalConfig)) + + await providerSettingsManager.setModeConfig("code", "test-id", "workspace") + + expect(mockWorkspaceState.update).toHaveBeenCalledWith("roo_workspace_overrides", { + modeApiConfigs: { + architect: "architect-id", + code: "test-id", + }, + }) + }) + }) + + describe("getModeConfigId with workspace overrides", () => { + it("should return workspace mode config when it exists", async () => { + ;(workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + const globalConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + global: { id: "global-id" }, + workspace: { id: "workspace-id" }, + }, + modeApiConfigs: { + code: "global-id", + }, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(globalConfig)) + mockWorkspaceState.get.mockReturnValue({ + modeApiConfigs: { + code: "workspace-id", + }, + }) + + const configId = await providerSettingsManager.getModeConfigId("code") + expect(configId).toBe("workspace-id") + }) + + it("should fall back to global when workspace config doesn't exist", async () => { + ;(workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + const globalConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + global: { id: "global-id" }, + }, + modeApiConfigs: { + code: "global-id", + }, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(globalConfig)) + mockWorkspaceState.get.mockReturnValue({ + modeApiConfigs: { + code: "non-existent-id", + }, + }) + + const configId = await providerSettingsManager.getModeConfigId("code") + expect(configId).toBe("global-id") + }) + + it("should return global config when not in workspace", async () => { + ;(workspace as any).workspaceFolders = undefined + + const globalConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + global: { id: "global-id" }, + }, + modeApiConfigs: { + code: "global-id", + }, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(globalConfig)) + + const configId = await providerSettingsManager.getModeConfigId("code") + expect(configId).toBe("global-id") + }) + }) + + describe("clearWorkspaceOverrides", () => { + it("should clear workspace overrides when in workspace", async () => { + ;(workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + await providerSettingsManager.clearWorkspaceOverrides() + + expect(mockWorkspaceState.update).toHaveBeenCalledWith("roo_workspace_overrides", undefined) + }) + + it("should do nothing when not in workspace", async () => { + ;(workspace as any).workspaceFolders = undefined + + await providerSettingsManager.clearWorkspaceOverrides() + + expect(mockWorkspaceState.update).not.toHaveBeenCalled() + }) + }) + + describe("getConfigScope", () => { + it("should return workspace when workspace overrides exist", async () => { + ;(workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + mockWorkspaceState.get.mockReturnValue({ + currentApiConfigName: "workspace-profile", + }) + + const scope = await providerSettingsManager.getConfigScope() + expect(scope).toBe("workspace") + }) + + it("should return workspace when mode configs exist in workspace", async () => { + ;(workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + mockWorkspaceState.get.mockReturnValue({ + modeApiConfigs: { code: "test-id" }, + }) + + const scope = await providerSettingsManager.getConfigScope() + expect(scope).toBe("workspace") + }) + + it("should return global when no workspace overrides", async () => { + ;(workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + mockWorkspaceState.get.mockReturnValue(undefined) + + const scope = await providerSettingsManager.getConfigScope() + expect(scope).toBe("global") + }) + + it("should return global when not in workspace", async () => { + ;(workspace as any).workspaceFolders = undefined + + const scope = await providerSettingsManager.getConfigScope() + expect(scope).toBe("global") + }) + }) + }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f3fddf22ae8..656d5965d76 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1628,24 +1628,35 @@ export class ClineProvider async activateProviderProfile( args: { name: string } | { id: string }, - options?: { persistModeConfig?: boolean; persistTaskHistory?: boolean }, + options?: { persistModeConfig?: boolean; persistTaskHistory?: boolean; scope?: "global" | "workspace" }, ) { - const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args) + const scope = options?.scope ?? "global" + const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args, scope) const persistModeConfig = options?.persistModeConfig ?? true const persistTaskHistory = options?.persistTaskHistory ?? true // See `upsertProviderProfile` for a description of what this is doing. - await Promise.all([ - this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), - this.contextProxy.setValue("currentApiConfigName", name), - this.contextProxy.setProviderSettings(providerSettings), - ]) + // When scope is "workspace", only update the local cache for currentApiConfigName + // to avoid leaking the selection to other VS Code windows via shared globalState. + if (scope === "workspace") { + await Promise.all([ + this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), + this.contextProxy.setProviderSettings(providerSettings), + ]) + this.contextProxy.setLocalValue("currentApiConfigName", name) + } else { + await Promise.all([ + this.contextProxy.setValue("listApiConfigMeta", await this.providerSettingsManager.listConfig()), + this.contextProxy.setValue("currentApiConfigName", name), + this.contextProxy.setProviderSettings(providerSettings), + ]) + } const { mode } = await this.getState() if (id && persistModeConfig) { - await this.providerSettingsManager.setModeConfig(mode, id) + await this.providerSettingsManager.setModeConfig(mode, id, scope) } // Change the provider for the current task. @@ -2276,6 +2287,7 @@ export class ClineProvider terminalZdotdir: terminalZdotdir ?? false, mcpEnabled: mcpEnabled ?? true, currentApiConfigName: currentApiConfigName ?? "default", + currentConfigScope: await this.providerSettingsManager.getConfigScope(), listApiConfigMeta: listApiConfigMeta ?? [], pinnedApiConfigs: pinnedApiConfigs ?? {}, mode: mode ?? defaultModeSlug, diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 9e57ae94b81..043db9c578e 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -213,6 +213,7 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { // Mock providerSettingsManager ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), saveConfig: vi.fn().mockResolvedValue("test-id"), listConfig: vi .fn() diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 9400ee34aad..9c625670648 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -879,6 +879,7 @@ describe("ClineProvider", () => { const profile: ProviderSettingsEntry = { name: "test-config", id: "test-id", apiProvider: "anthropic" } ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), getModeConfigId: vi.fn().mockResolvedValue("test-id"), listConfig: vi.fn().mockResolvedValue([profile]), activateProfile: vi.fn().mockResolvedValue(profile), @@ -891,7 +892,7 @@ describe("ClineProvider", () => { // Should load the saved config for architect mode expect(provider.providerSettingsManager.getModeConfigId).toHaveBeenCalledWith("architect") - expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledWith({ name: "test-config" }) + expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledWith({ name: "test-config" }, "global") expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "test-config") }) @@ -900,6 +901,7 @@ describe("ClineProvider", () => { const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi .fn() @@ -923,6 +925,7 @@ describe("ClineProvider", () => { const profile: ProviderSettingsEntry = { apiProvider: "anthropic", id: "new-id", name: "new-config" } ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), activateProfile: vi.fn().mockResolvedValue(profile), listConfig: vi.fn().mockResolvedValue([profile]), setModeConfig: vi.fn(), @@ -936,7 +939,7 @@ describe("ClineProvider", () => { await messageHandler({ type: "loadApiConfiguration", text: "new-config" }) // Should save new config as default for architect mode - expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("architect", "new-id") + expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("architect", "new-id", "global") }) it("load API configuration by ID works and updates mode config", async () => { @@ -950,6 +953,7 @@ describe("ClineProvider", () => { } ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), activateProfile: vi.fn().mockResolvedValue(profile), listConfig: vi.fn().mockResolvedValue([profile]), setModeConfig: vi.fn(), @@ -963,10 +967,14 @@ describe("ClineProvider", () => { await messageHandler({ type: "loadApiConfigurationById", text: "config-id-123" }) // Should save new config as default for architect mode - expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith("architect", "config-id-123") + expect(provider.providerSettingsManager.setModeConfig).toHaveBeenCalledWith( + "architect", + "config-id-123", + "global", + ) // Ensure the `activateProfile` method was called with the correct ID - expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledWith({ id: "config-id-123" }) + expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledWith({ id: "config-id-123" }, "global") }) test("handles showRooIgnoredFiles setting", async () => { @@ -1120,6 +1128,7 @@ describe("ClineProvider", () => { const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), listConfig: vi.fn().mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), saveConfig: vi.fn().mockResolvedValue("test-id"), setModeConfig: vi.fn(), @@ -1466,6 +1475,7 @@ describe("ClineProvider", () => { } ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), getModeConfigId: vi.fn().mockResolvedValue("saved-config-id"), listConfig: vi.fn().mockResolvedValue([profile]), activateProfile: vi.fn().mockResolvedValue(profile), @@ -1481,7 +1491,10 @@ describe("ClineProvider", () => { // Verify saved config was loaded expect(provider.providerSettingsManager.getModeConfigId).toHaveBeenCalledWith("architect") - expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledWith({ name: "saved-config" }) + expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledWith( + { name: "saved-config" }, + "global", + ) expect(mockContext.globalState.update).toHaveBeenCalledWith("currentApiConfigName", "saved-config") // Verify state was posted to webview @@ -1490,6 +1503,7 @@ describe("ClineProvider", () => { test("saves current config when switching to mode without config", async () => { ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi .fn() @@ -1550,6 +1564,7 @@ describe("ClineProvider", () => { // Mock provider settings manager ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi.fn().mockResolvedValue([]), } @@ -1614,6 +1629,7 @@ describe("ClineProvider", () => { // Mock provider settings manager ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), getModeConfigId: vi.fn().mockResolvedValue("config-id"), listConfig: vi .fn() @@ -1674,6 +1690,7 @@ describe("ClineProvider", () => { // Mock provider settings manager ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi.fn().mockResolvedValue([]), } @@ -1705,6 +1722,7 @@ describe("ClineProvider", () => { // Mock provider settings manager ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi.fn().mockResolvedValue([]), } @@ -1749,6 +1767,7 @@ describe("ClineProvider", () => { // Mock provider settings manager to throw error ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), getModeConfigId: vi.fn().mockResolvedValue("config-id"), listConfig: vi .fn() @@ -1849,6 +1868,7 @@ describe("ClineProvider", () => { const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), setModeConfig: vi.fn().mockRejectedValue(new Error("Failed to update mode config")), listConfig: vi .fn() @@ -1880,6 +1900,7 @@ describe("ClineProvider", () => { const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), setModeConfig: vi.fn(), saveConfig: vi.fn().mockResolvedValue(undefined), listConfig: vi @@ -1923,6 +1944,7 @@ describe("ClineProvider", () => { throw new Error("API handler error") }) ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), setModeConfig: vi.fn(), saveConfig: vi.fn().mockResolvedValue(undefined), listConfig: vi @@ -1964,6 +1986,7 @@ describe("ClineProvider", () => { const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] ;(provider as any).providerSettingsManager = { + getConfigScope: vi.fn().mockResolvedValue("global"), setModeConfig: vi.fn(), saveConfig: vi.fn().mockResolvedValue(undefined), listConfig: vi diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4f8d4317245..b1e30468c40 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1786,7 +1786,8 @@ export const webviewMessageHandler = async ( case "loadApiConfiguration": if (message.text) { try { - await provider.activateProviderProfile({ name: message.text }) + const scope = message.scope || "global" + await provider.activateProviderProfile({ name: message.text }, { scope }) } catch (error) { provider.log( `Error load api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, @@ -1798,7 +1799,8 @@ export const webviewMessageHandler = async ( case "loadApiConfigurationById": if (message.text) { try { - await provider.activateProviderProfile({ id: message.text }) + const scope = message.scope || "global" + await provider.activateProviderProfile({ id: message.text }, { scope }) } catch (error) { provider.log( `Error load api configuration by ID: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, diff --git a/webview-ui/src/components/chat/ApiConfigSelector.tsx b/webview-ui/src/components/chat/ApiConfigSelector.tsx index e370296ec32..4f523ac0fc4 100644 --- a/webview-ui/src/components/chat/ApiConfigSelector.tsx +++ b/webview-ui/src/components/chat/ApiConfigSelector.tsx @@ -22,6 +22,9 @@ interface ApiConfigSelectorProps { togglePinnedApiConfig: (id: string) => void lockApiConfigAcrossModes: boolean onToggleLockApiConfig: () => void + isWorkspaceScoped?: boolean + onScopeChange?: (scope: "global" | "workspace") => void + hasWorkspace?: boolean } export const ApiConfigSelector = ({ @@ -36,6 +39,9 @@ export const ApiConfigSelector = ({ togglePinnedApiConfig, lockApiConfigAcrossModes, onToggleLockApiConfig, + isWorkspaceScoped = false, + onScopeChange, + hasWorkspace = false, }: ApiConfigSelectorProps) => { const { t } = useAppTranslation() const [open, setOpen] = useState(false) @@ -89,6 +95,13 @@ export const ApiConfigSelector = ({ setOpen(false) }, []) + const handleScopeToggle = useCallback(() => { + if (onScopeChange) { + const newScope = isWorkspaceScoped ? "global" : "workspace" + onScopeChange(newScope) + } + }, [isWorkspaceScoped, onScopeChange]) + const renderConfigItem = useCallback( (config: { id: string; name: string; modelId?: string }, isPinned: boolean) => { const isCurrentConfig = config.id === value @@ -161,6 +174,7 @@ export const ApiConfigSelector = ({ triggerClassName, )}> {displayName} + {hasWorkspace && isWorkspaceScoped && [W]}
+ {/* Scope selector for workspace-enabled projects */} + {hasWorkspace && onScopeChange && ( +
+
+ + {t("prompts:apiConfiguration.scope")} + + +
+
+ )} + {/* Search input or info blurb */} {listApiConfigMeta.length > 6 ? (
diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index c5213882068..1054e999480 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -99,6 +99,7 @@ export const ChatTextArea = forwardRef( cloudUserInfo, enterBehavior, lockApiConfigAcrossModes, + currentConfigScope, } = useExtensionState() // Find the ID and display text for the currently selected API configuration. @@ -937,9 +938,29 @@ export const ChatTextArea = forwardRef( ) // Helper function to handle API config change - const handleApiConfigChange = useCallback((value: string) => { - vscode.postMessage({ type: "loadApiConfigurationById", text: value }) - }, []) + const handleApiConfigChange = useCallback( + (value: string) => { + const scope = currentConfigScope === "workspace" ? "workspace" : "global" + vscode.postMessage({ type: "loadApiConfigurationById", text: value, scope }) + }, + [currentConfigScope], + ) + + // Handle scope change for API configuration + const handleApiConfigScopeChange = useCallback( + (scope: "global" | "workspace") => { + // When user changes scope, re-apply the current config with the new scope + if (currentConfigId) { + vscode.postMessage({ type: "loadApiConfigurationById", text: currentConfigId, scope }) + } + }, + [currentConfigId], + ) + + // Check if we're in a workspace context + const hasWorkspace = useMemo(() => { + return !!(cwd && cwd.length > 0) + }, [cwd]) const handleToggleLockApiConfig = useCallback(() => { const newValue = !lockApiConfigAcrossModes @@ -1319,6 +1340,9 @@ export const ChatTextArea = forwardRef( togglePinnedApiConfig={togglePinnedApiConfig} lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} onToggleLockApiConfig={handleToggleLockApiConfig} + isWorkspaceScoped={currentConfigScope === "workspace"} + onScopeChange={handleApiConfigScopeChange} + hasWorkspace={hasWorkspace} />
diff --git a/webview-ui/src/i18n/locales/en/prompts.json b/webview-ui/src/i18n/locales/en/prompts.json index 82db8400c11..c682772bb52 100644 --- a/webview-ui/src/i18n/locales/en/prompts.json +++ b/webview-ui/src/i18n/locales/en/prompts.json @@ -14,7 +14,10 @@ }, "apiConfiguration": { "title": "API Configuration", - "select": "Select which API configuration to use for this mode" + "select": "Select which API configuration to use for this mode", + "scope": "Scope", + "global": "Global", + "workspace": "Workspace" }, "tools": { "title": "Available Tools",