diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index ab952da949..d347791a83 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -157,6 +157,34 @@ export class ContextProxy { return this.originalContext.extensionMode } + /** + * Workspace-specific storage access + */ + public get workspaceState() { + return this.originalContext.workspaceState + } + + /** + * Get a value from workspace state + */ + public getWorkspaceValue(key: string): T | undefined { + return this.originalContext.workspaceState.get(key) + } + + /** + * Set a value in workspace state + */ + public async setWorkspaceValue(key: string, value: T): Promise { + await this.originalContext.workspaceState.update(key, value) + } + + /** + * Clear a value from workspace state + */ + public async clearWorkspaceValue(key: string): Promise { + await this.originalContext.workspaceState.update(key, undefined) + } + /** * ExtensionContext.globalState * https://code.visualstudio.com/api/references/vscode-api#ExtensionContext.globalState diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 21a7a060c1..ea8f503531 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" @@ -15,6 +15,8 @@ import { TelemetryService } from "@roo-code/telemetry" import { Mode, modes } from "../../shared/modes" +export type ConfigScope = "global" | "workspace" + export interface SyncCloudProfilesResult { hasChanges: boolean activeProfileChanged: boolean @@ -39,8 +41,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( @@ -61,6 +72,7 @@ export class ProviderSettingsManager { } private readonly context: ExtensionContext + private workspaceOverrides: WorkspaceOverrides | null = null constructor(context: ExtensionContext) { this.context = context @@ -374,17 +386,28 @@ 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 + const overrides = await this.getWorkspaceOverrides() + overrides.currentApiConfigName = name + await this.storeWorkspaceOverrides(overrides) + } else { + // Store globally + const providerProfiles = await this.load() + providerProfiles.currentApiConfigName = name + await this.store(providerProfiles) + } return { name, ...providerSettings } }) } catch (error) { @@ -392,6 +415,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. */ @@ -432,18 +482,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}`) @@ -451,11 +515,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] }) @@ -753,4 +834,63 @@ 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 {} + } + + // Try to load from workspace state + const stored = this.context.workspaceState.get(ProviderSettingsManager.WORKSPACE_KEY) + + if (stored) { + try { + return workspaceOverridesSchema.parse(stored) + } catch (error) { + // Invalid data, return empty + 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 { + // Check if we have workspace overrides + 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 e95d2b100b..6315c1be55 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", () => { @@ -1121,4 +1134,324 @@ describe("ProviderSettingsManager", () => { expect(result.activeProfileId).toBe("local-id") }) }) + + describe("Workspace-scoped functionality", () => { + beforeEach(() => { + // Reset workspace state mocks + 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 () => { + // Mock workspace folders to simulate being in a workspace + ;(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", + }) + + // Should NOT update global currentApiConfigName + const globalStoreCalls = mockSecrets.store.mock.calls + if (globalStoreCalls.length > 0) { + const lastGlobalConfig = JSON.parse(globalStoreCalls[globalStoreCalls.length - 1][1]) + expect(lastGlobalConfig.currentApiConfigName).toBe("default") + } + }) + + it("should fall back to global when not in a workspace", async () => { + // No workspace folders + ;(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() + }) + }) + + 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" }, + }) + + // Global config should remain unchanged + const globalStoreCalls = mockSecrets.store.mock.calls + if (globalStoreCalls.length > 0) { + const lastGlobalConfig = JSON.parse(globalStoreCalls[globalStoreCalls.length - 1][1]) + expect(lastGlobalConfig.modeApiConfigs.code).toBe("default-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") + + // Should merge with existing workspace configs + 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 9abddc6d96..a9612b01bf 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1288,8 +1288,8 @@ export class ClineProvider await this.postStateToWebview() } - async activateProviderProfile(args: { name: string } | { id: string }) { - const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args) + async activateProviderProfile(args: { name: string } | { id: string }, scope: "global" | "workspace" = "global") { + const { name, id, ...providerSettings } = await this.providerSettingsManager.activateProfile(args, scope) // See `upsertProviderProfile` for a description of what this is doing. await Promise.all([ @@ -1301,7 +1301,7 @@ export class ClineProvider const { mode } = await this.getState() if (id) { - await this.providerSettingsManager.setModeConfig(mode, id) + await this.providerSettingsManager.setModeConfig(mode, id, scope) } // Change the provider for the current task. @@ -1795,9 +1795,13 @@ export class ClineProvider const currentMode = mode ?? defaultModeSlug const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode) + // Get the current configuration scope + const currentConfigScope = await this.providerSettingsManager.getConfigScope() + return { version: this.context.extension?.packageJSON?.version ?? "", apiConfiguration, + currentConfigScope, customInstructions, alwaysAllowReadOnly: alwaysAllowReadOnly ?? false, alwaysAllowReadOnlyOutsideWorkspace: alwaysAllowReadOnlyOutsideWorkspace ?? false, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index bd4608c6eb..82c81d2461 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -889,6 +889,7 @@ describe("ClineProvider", () => { listConfig: vi.fn().mockResolvedValue([profile]), activateProfile: vi.fn().mockResolvedValue(profile), setModeConfig: vi.fn(), + getConfigScope: vi.fn().mockResolvedValue("global"), } as any // Switch to architect mode @@ -896,7 +897,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") }) @@ -910,6 +911,7 @@ describe("ClineProvider", () => { .fn() .mockResolvedValue([{ name: "current-config", id: "current-id", apiProvider: "anthropic" }]), setModeConfig: vi.fn(), + getConfigScope: vi.fn().mockResolvedValue("global"), } as any provider.setValue("currentApiConfigName", "current-config") @@ -932,6 +934,7 @@ describe("ClineProvider", () => { listConfig: vi.fn().mockResolvedValue([profile]), setModeConfig: vi.fn(), getModeConfigId: vi.fn().mockResolvedValue(undefined), + getConfigScope: vi.fn().mockResolvedValue("global"), } as any // First set the mode @@ -941,7 +944,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 () => { @@ -959,6 +962,7 @@ describe("ClineProvider", () => { listConfig: vi.fn().mockResolvedValue([profile]), setModeConfig: vi.fn(), getModeConfigId: vi.fn().mockResolvedValue(undefined), + getConfigScope: vi.fn().mockResolvedValue("global"), } as any // First set the mode @@ -968,10 +972,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 browserToolEnabled setting", async () => { @@ -1159,6 +1167,7 @@ describe("ClineProvider", () => { listConfig: vi.fn().mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), saveConfig: vi.fn().mockResolvedValue("test-id"), setModeConfig: vi.fn(), + getConfigScope: vi.fn().mockResolvedValue("global"), } as any // Update API configuration @@ -1626,6 +1635,7 @@ describe("ClineProvider", () => { listConfig: vi.fn().mockResolvedValue([profile]), activateProfile: vi.fn().mockResolvedValue(profile), setModeConfig: vi.fn(), + getConfigScope: vi.fn().mockResolvedValue("global"), } as any // Switch to architect mode @@ -1636,7 +1646,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 @@ -1650,6 +1663,7 @@ describe("ClineProvider", () => { .fn() .mockResolvedValue([{ name: "current-config", id: "current-id", apiProvider: "anthropic" }]), setModeConfig: vi.fn(), + getConfigScope: vi.fn().mockResolvedValue("global"), } as any // Mock the ContextProxy's getValue method to return the current config name @@ -1707,6 +1721,7 @@ describe("ClineProvider", () => { ;(provider as any).providerSettingsManager = { getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi.fn().mockResolvedValue([]), + getConfigScope: vi.fn().mockResolvedValue("global"), } // Spy on log method to verify warning was logged @@ -1776,6 +1791,7 @@ describe("ClineProvider", () => { activateProfile: vi .fn() .mockResolvedValue({ name: "test-config", id: "config-id", apiProvider: "anthropic" }), + getConfigScope: vi.fn().mockResolvedValue("global"), } // Spy on log method to verify no warning was logged @@ -1831,6 +1847,7 @@ describe("ClineProvider", () => { ;(provider as any).providerSettingsManager = { getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi.fn().mockResolvedValue([]), + getConfigScope: vi.fn().mockResolvedValue("global"), } // Create history item with built-in mode @@ -1862,6 +1879,7 @@ describe("ClineProvider", () => { ;(provider as any).providerSettingsManager = { getModeConfigId: vi.fn().mockResolvedValue(undefined), listConfig: vi.fn().mockResolvedValue([]), + getConfigScope: vi.fn().mockResolvedValue("global"), } // Create history item without mode @@ -1909,6 +1927,7 @@ describe("ClineProvider", () => { .fn() .mockResolvedValue([{ name: "test-config", id: "config-id", apiProvider: "anthropic" }]), activateProfile: vi.fn().mockRejectedValue(new Error("Failed to load config")), + getConfigScope: vi.fn().mockResolvedValue("global"), } // Spy on log method @@ -2008,6 +2027,7 @@ describe("ClineProvider", () => { listConfig: vi .fn() .mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), + getConfigScope: vi.fn().mockResolvedValue("global"), } as any // Mock getState to provide necessary data @@ -2040,6 +2060,7 @@ describe("ClineProvider", () => { listConfig: vi .fn() .mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), + getConfigScope: vi.fn().mockResolvedValue("global"), } as any const testApiConfig = { @@ -2083,6 +2104,7 @@ describe("ClineProvider", () => { listConfig: vi .fn() .mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), + getConfigScope: vi.fn().mockResolvedValue("global"), } as any // Setup Task instance with auto-mock from the top of the file @@ -2124,6 +2146,7 @@ describe("ClineProvider", () => { listConfig: vi .fn() .mockResolvedValue([{ name: "test-config", id: "test-id", apiProvider: "anthropic" }]), + getConfigScope: vi.fn().mockResolvedValue("global"), } as any const testApiConfig = { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index abdfae29fa..91d0b8ac98 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1848,7 +1848,9 @@ export const webviewMessageHandler = async ( case "loadApiConfiguration": if (message.text) { try { - await provider.activateProviderProfile({ name: message.text }) + // Use scope directly from message if provided, otherwise from values + const scope = message.scope || (message.values?.scope as "global" | "workspace" | undefined) + await provider.activateProviderProfile({ name: message.text }, scope) } catch (error) { provider.log( `Error load api configuration: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, @@ -1860,7 +1862,9 @@ export const webviewMessageHandler = async ( case "loadApiConfigurationById": if (message.text) { try { - await provider.activateProviderProfile({ id: message.text }) + // Use scope directly from message if provided, otherwise from values + const scope = message.scope || (message.values?.scope as "global" | "workspace" | undefined) + 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/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index e8c264ba68..bff695f5a4 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -209,6 +209,13 @@ "description": "Are you sure you want to delete this {{scope}} mode? This will also delete the associated rules folder at: {{rulesFolderPath}}", "descriptionNoRules": "Are you sure you want to delete this custom mode?", "confirm": "Delete" + }, + "apiConfiguration": { + "title": "API Configuration", + "select": "Select an API configuration", + "scope": "Configuration Scope", + "global": "Global", + "workspace": "Workspace" } }, "commands": { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index aaddc520cb..3cf4826e28 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -289,6 +289,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 diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 93d0b9bc45..794dc75b19 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -230,6 +230,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/webview-ui/src/components/chat/ApiConfigSelector.tsx b/webview-ui/src/components/chat/ApiConfigSelector.tsx index 786ad01dff..24a46319a4 100644 --- a/webview-ui/src/components/chat/ApiConfigSelector.tsx +++ b/webview-ui/src/components/chat/ApiConfigSelector.tsx @@ -20,6 +20,9 @@ interface ApiConfigSelectorProps { listApiConfigMeta: Array<{ id: string; name: string; modelId?: string }> pinnedApiConfigs?: Record togglePinnedApiConfig: (id: string) => void + isWorkspaceScoped?: boolean + onScopeChange?: (scope: "global" | "workspace") => void + hasWorkspace?: boolean } export const ApiConfigSelector = ({ @@ -32,6 +35,9 @@ export const ApiConfigSelector = ({ listApiConfigMeta, pinnedApiConfigs, togglePinnedApiConfig, + isWorkspaceScoped = false, + onScopeChange, + hasWorkspace = false, }: ApiConfigSelectorProps) => { const { t } = useAppTranslation() const [open, setOpen] = useState(false) @@ -85,6 +91,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 @@ -157,6 +170,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 3cc91b9a20..4d2d102798 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -87,6 +87,7 @@ export const ChatTextArea = forwardRef( taskHistory, clineMessages, commands, + currentConfigScope, } = useExtensionState() // Find the ID and display text for the currently selected API configuration. @@ -901,10 +902,39 @@ 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, scope?: "global" | "workspace") => { + vscode.postMessage({ type: "loadApiConfigurationById", text: value, scope }) + // Update local state to reflect the scope + if (scope) { + setIsWorkspaceScoped(scope === "workspace") + } }, []) + // 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) { + handleApiConfigChange(currentConfigId, scope) + } + setIsWorkspaceScoped(scope === "workspace") + }, + [currentConfigId, handleApiConfigChange], + ) + + // Check if we're in a workspace context + const hasWorkspace = useMemo(() => { + return !!(cwd && cwd.length > 0) + }, [cwd]) + + // Track if current config is workspace-scoped (from extension state) + const [isWorkspaceScoped, setIsWorkspaceScoped] = useState(currentConfigScope === "workspace") + + // Update local state when extension state changes + useEffect(() => { + setIsWorkspaceScoped(currentConfigScope === "workspace") + }, [currentConfigScope]) + return (
( displayName={displayName} disabled={selectApiConfigDisabled} title={t("chat:selectApiConfig")} - onChange={handleApiConfigChange} + onChange={(value) => { + // When selecting a new config, use the currently selected scope + handleApiConfigChange(value, isWorkspaceScoped ? "workspace" : "global") + }} triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink" listApiConfigMeta={listApiConfigMeta || []} pinnedApiConfigs={pinnedApiConfigs} togglePinnedApiConfig={togglePinnedApiConfig} + isWorkspaceScoped={isWorkspaceScoped} + onScopeChange={handleApiConfigScopeChange} + hasWorkspace={hasWorkspace} />