feat: add per-workspace API profile selection

Implements per-workspace API profile selection as requested in #8010.
Addresses critical issues from review by @daniel-lxs:

1. Fix: Workspace selection no longer overwrites global config across windows.
   When scope="workspace", only the local cache is updated via setLocalValue(),
   avoiding leaking currentApiConfigName to other windows via shared globalState.

2. Fix: Toggling scope back to Global now clears workspace overrides via
   clearWorkspaceOverrides(), preventing stale workspace state from keeping
   the scope stuck on "workspace".

Backend changes:
- Add ConfigScope type and WorkspaceOverrides schema to ProviderSettingsManager
- Add workspace override methods (getWorkspaceOverrides, storeWorkspaceOverrides,
  clearWorkspaceOverrides, getConfigScope, getActiveProfileName)
- Modify activateProfile and setModeConfig to accept scope parameter
- Modify getModeConfigId to check workspace overrides first
- Add setLocalValue to ContextProxy for cache-only state updates
- Add scope field to WebviewMessage and currentConfigScope to ExtensionState

Frontend changes:
- Add scope toggle UI to ApiConfigSelector with [W] indicator
- Wire scope through ChatTextArea to pass with config changes
- Add i18n translation keys for scope UI elements

Testing:
- Add comprehensive workspace-scoped tests to ProviderSettingsManager.spec.ts
- Update ClineProvider test mocks with getConfigScope method
- All tests passing (175 across 5 test files)

Fixes #8010
This commit is contained in:
Roo Code 2026-02-12 20:05:16 +00:00
parent d8cfbfdb05
commit 44c495da2d
11 changed files with 633 additions and 33 deletions

View file

@ -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

View file

@ -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<K extends GlobalStateKey>(key: K, value: GlobalState[K]) {
this.stateCache[key] = value
}
public getValue<K extends RooCodeSettingsKey>(key: K): RooCodeSettings[K] {
return isSecretStateKey(key)
? (this.getSecret(key as SecretStateKey) as RooCodeSettings[K])

View file

@ -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<string, string>
@ -54,8 +56,17 @@ export const providerProfilesSchema = z.object({
export type ProviderProfiles = z.infer<typeof providerProfilesSchema>
// 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<typeof workspaceOverridesSchema>
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<string, string> = 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<ProviderSettingsWithId & { name: string }> {
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<string> {
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<WorkspaceOverrides> {
if (!workspace.workspaceFolders?.length) {
return {}
}
const stored = this.context.workspaceState.get<WorkspaceOverrides>(ProviderSettingsManager.WORKSPACE_KEY)
if (stored) {
try {
return workspaceOverridesSchema.parse(stored)
} catch {
return {}
}
}
return {}
}
/**
* Store workspace-specific overrides.
*/
private async storeWorkspaceOverrides(overrides: WorkspaceOverrides): Promise<void> {
if (!workspace.workspaceFolders?.length) {
return
}
await this.context.workspaceState.update(ProviderSettingsManager.WORKSPACE_KEY, overrides)
}
/**
* Clear workspace-specific overrides.
*/
public async clearWorkspaceOverrides(): Promise<void> {
if (!workspace.workspaceFolders?.length) {
return
}
await this.context.workspaceState.update(ProviderSettingsManager.WORKSPACE_KEY, undefined)
}
/**
* Get the current configuration scope preference.
*/
public async getConfigScope(): Promise<ConfigScope> {
if (workspace.workspaceFolders?.length) {
const overrides = await this.getWorkspaceOverrides()
if (overrides.currentApiConfigName || overrides.modeApiConfigs) {
return "workspace"
}
}
return "global"
}
}

View file

@ -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")
})
})
})
})

View file

@ -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,

View file

@ -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()

View file

@ -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

View file

@ -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)}`,

View file

@ -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,
)}>
<span className="truncate">{displayName}</span>
{hasWorkspace && isWorkspaceScoped && <span className="ml-1 text-[10px] opacity-60">[W]</span>}
</PopoverTrigger>
</StandardTooltip>
<PopoverContent
@ -169,6 +183,37 @@ export const ApiConfigSelector = ({
container={portalContainer}
className="p-0 overflow-hidden w-[300px]">
<div className="flex flex-col w-full">
{/* Scope selector for workspace-enabled projects */}
{hasWorkspace && onScopeChange && (
<div className="p-2 border-b border-vscode-dropdown-border">
<div className="flex items-center justify-between">
<span className="text-xs text-vscode-descriptionForeground">
{t("prompts:apiConfiguration.scope")}
</span>
<button
onClick={handleScopeToggle}
className={cn(
"px-2 py-0.5 text-xs rounded",
"border border-vscode-dropdown-border",
"hover:bg-vscode-list-hoverBackground",
"transition-colors duration-150",
)}>
{isWorkspaceScoped ? (
<>
<span className="codicon codicon-folder text-xs mr-1" />
{t("prompts:apiConfiguration.workspace")}
</>
) : (
<>
<span className="codicon codicon-globe text-xs mr-1" />
{t("prompts:apiConfiguration.global")}
</>
)}
</button>
</div>
</div>
)}
{/* Search input or info blurb */}
{listApiConfigMeta.length > 6 ? (
<div className="relative p-2 border-b border-vscode-dropdown-border">

View file

@ -99,6 +99,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
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<HTMLTextAreaElement, ChatTextAreaProps>(
)
// 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<HTMLTextAreaElement, ChatTextAreaProps>(
togglePinnedApiConfig={togglePinnedApiConfig}
lockApiConfigAcrossModes={!!lockApiConfigAcrossModes}
onToggleLockApiConfig={handleToggleLockApiConfig}
isWorkspaceScoped={currentConfigScope === "workspace"}
onScopeChange={handleApiConfigScopeChange}
hasWorkspace={hasWorkspace}
/>
<AutoApproveDropdown triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink" />
</div>

View file

@ -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",