From b4b2463461a7cd126a79c06320a96e96d9f1f527 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 29 Apr 2026 20:02:37 +0000 Subject: [PATCH] feat: add workspace-scoped mode-to-profile overrides Adds the ability to pin a provider profile to a specific mode on a per-workspace basis. When switching modes, the system checks workspace- level overrides first and falls back to global mode-to-profile mappings if no workspace override exists. Changes: - Add workspaceModeApiConfigs to ExtensionState type - Add setWorkspaceModeApiConfig/clearWorkspaceModeApiConfig message types - Update ClineProvider.handleModeSwitch to check workspace overrides - Update ClineProvider.getState to include workspace configs - Add webview message handler for workspace profile operations - Add workspace profile pin button to ApiConfigSelector UI - Add translation keys for new UI elements - Add tests for workspace mode API config message handling Closes #12227 --- packages/types/src/vscode-extension-host.ts | 3 + src/core/webview/ClineProvider.ts | 18 +- ...sageHandler.workspaceModeApiConfig.spec.ts | 160 ++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 31 ++++ .../src/components/chat/ApiConfigSelector.tsx | 37 ++++ .../src/components/chat/ChatTextArea.tsx | 4 + .../src/context/ExtensionStateContext.tsx | 1 + webview-ui/src/i18n/locales/en/chat.json | 2 + 8 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 src/core/webview/__tests__/webviewMessageHandler.workspaceModeApiConfig.spec.ts diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index b20539afe4..26561c5180 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -308,6 +308,7 @@ export type ExtensionState = Pick< | "disabledTools" > & { lockApiConfigAcrossModes?: boolean + workspaceModeApiConfigs?: Record version: string clineMessages: ClineMessage[] currentTaskId?: string @@ -499,6 +500,8 @@ export interface WebviewMessage { | "toggleApiConfigPin" | "hasOpenedModeSelector" | "lockApiConfigAcrossModes" + | "setWorkspaceModeApiConfig" + | "clearWorkspaceModeApiConfig" | "clearCloudAuthSkipModel" | "cloudButtonClicked" | "rooCloudSignIn" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 1106d34005..6fa96b8012 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -999,7 +999,12 @@ export class ClineProvider const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) if (!historyItem.apiConfigName && !lockApiConfigAcrossModes && !skipProfileRestoreFromHistory) { - const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode) + // Check workspace-level override first, then fall back to global mode config. + const workspaceModeApiConfigs = + this.context.workspaceState.get>("workspaceModeApiConfigs") ?? {} + const workspaceConfigId = workspaceModeApiConfigs[historyItem.mode] + const savedConfigId = + workspaceConfigId ?? (await this.providerSettingsManager.getModeConfigId(historyItem.mode)) const listApiConfig = await this.providerSettingsManager.listConfig() // Update listApiConfigMeta first to ensure UI has latest data. @@ -1433,8 +1438,13 @@ export class ClineProvider return } + // Check for workspace-level mode-to-profile override first, then fall back to global. + const workspaceModeApiConfigs = + this.context.workspaceState.get>("workspaceModeApiConfigs") ?? {} + const workspaceConfigId = workspaceModeApiConfigs[newMode] + // Load the saved API config for the new mode if it exists. - const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) + const savedConfigId = workspaceConfigId ?? (await this.providerSettingsManager.getModeConfigId(newMode)) const listApiConfig = await this.providerSettingsManager.listConfig() // Update listApiConfigMeta first to ensure UI has latest data. @@ -2563,6 +2573,10 @@ export class ClineProvider }, profileThresholds: stateValues.profileThresholds ?? {}, lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false), + workspaceModeApiConfigs: this.context.workspaceState.get>( + "workspaceModeApiConfigs", + {}, + ), includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true, diff --git a/src/core/webview/__tests__/webviewMessageHandler.workspaceModeApiConfig.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.workspaceModeApiConfig.spec.ts new file mode 100644 index 0000000000..91e510d276 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.workspaceModeApiConfig.spec.ts @@ -0,0 +1,160 @@ +// npx vitest run core/webview/__tests__/webviewMessageHandler.workspaceModeApiConfig.spec.ts + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +describe("webviewMessageHandler - setWorkspaceModeApiConfig", () => { + let mockProvider: { + context: { + workspaceState: { + get: ReturnType + update: ReturnType + } + } + getState: ReturnType + postStateToWebview: ReturnType + providerSettingsManager: { + setModeConfig: ReturnType + } + postMessageToWebview: ReturnType + getCurrentTask: ReturnType + } + + let workspaceStateStore: Record + + beforeEach(() => { + vi.clearAllMocks() + + workspaceStateStore = {} + + mockProvider = { + context: { + workspaceState: { + get: vi.fn().mockImplementation((key: string, defaultValue?: unknown) => { + return key in workspaceStateStore ? workspaceStateStore[key] : defaultValue + }), + update: vi.fn().mockImplementation((key: string, value: unknown) => { + workspaceStateStore[key] = value + return Promise.resolve() + }), + }, + }, + getState: vi.fn().mockResolvedValue({ + currentApiConfigName: "test-config", + listApiConfigMeta: [{ name: "test-config", id: "config-123" }], + customModes: [], + }), + postStateToWebview: vi.fn(), + providerSettingsManager: { + setModeConfig: vi.fn(), + }, + postMessageToWebview: vi.fn(), + getCurrentTask: vi.fn(), + } + }) + + it("sets a workspace mode API config for a specific mode", async () => { + await webviewMessageHandler(mockProvider as unknown as ClineProvider, { + type: "setWorkspaceModeApiConfig", + mode: "code", + text: "config-123", + }) + + expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("workspaceModeApiConfigs", { + code: "config-123", + }) + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("clears a workspace mode API config when text is undefined", async () => { + // Pre-populate with an existing mapping + workspaceStateStore["workspaceModeApiConfigs"] = { code: "config-123", architect: "config-456" } + + await webviewMessageHandler(mockProvider as unknown as ClineProvider, { + type: "setWorkspaceModeApiConfig", + mode: "code", + // text is undefined - clears the override + }) + + expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("workspaceModeApiConfigs", { + architect: "config-456", + }) + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("preserves existing workspace configs when adding a new one", async () => { + workspaceStateStore["workspaceModeApiConfigs"] = { architect: "config-456" } + + await webviewMessageHandler(mockProvider as unknown as ClineProvider, { + type: "setWorkspaceModeApiConfig", + mode: "code", + text: "config-789", + }) + + expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("workspaceModeApiConfigs", { + architect: "config-456", + code: "config-789", + }) + }) + + it("does nothing if mode is not provided", async () => { + await webviewMessageHandler(mockProvider as unknown as ClineProvider, { + type: "setWorkspaceModeApiConfig", + // mode is undefined + text: "config-123", + }) + + expect(mockProvider.context.workspaceState.update).not.toHaveBeenCalled() + }) +}) + +describe("webviewMessageHandler - clearWorkspaceModeApiConfig", () => { + let mockProvider: { + context: { + workspaceState: { + get: ReturnType + update: ReturnType + } + } + getState: ReturnType + postStateToWebview: ReturnType + providerSettingsManager: { + setModeConfig: ReturnType + } + postMessageToWebview: ReturnType + getCurrentTask: ReturnType + } + + beforeEach(() => { + vi.clearAllMocks() + + mockProvider = { + context: { + workspaceState: { + get: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + }, + }, + getState: vi.fn().mockResolvedValue({ + currentApiConfigName: "test-config", + listApiConfigMeta: [{ name: "test-config", id: "config-123" }], + customModes: [], + }), + postStateToWebview: vi.fn(), + providerSettingsManager: { + setModeConfig: vi.fn(), + }, + postMessageToWebview: vi.fn(), + getCurrentTask: vi.fn(), + } + }) + + it("clears all workspace mode API configs", async () => { + await webviewMessageHandler(mockProvider as unknown as ClineProvider, { + type: "clearWorkspaceModeApiConfig", + }) + + expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("workspaceModeApiConfigs", {}) + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e3b8c1bea8..968346cf73 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1650,6 +1650,37 @@ export const webviewMessageHandler = async ( break } + case "setWorkspaceModeApiConfig": { + // Set a workspace-level mode-to-profile override. + // message.mode contains the mode slug, message.text contains the profile config ID. + const modeSlug = message.mode + const configId = message.text + + if (modeSlug) { + const workspaceModeApiConfigs = provider.context.workspaceState.get>( + "workspaceModeApiConfigs", + {}, + ) + + if (configId) { + workspaceModeApiConfigs[modeSlug] = configId + } else { + delete workspaceModeApiConfigs[modeSlug] + } + + await provider.context.workspaceState.update("workspaceModeApiConfigs", workspaceModeApiConfigs) + await provider.postStateToWebview() + } + break + } + + case "clearWorkspaceModeApiConfig": { + // Clear all workspace-level mode-to-profile overrides. + await provider.context.workspaceState.update("workspaceModeApiConfigs", {}) + await provider.postStateToWebview() + break + } + case "toggleApiConfigPin": if (message.text) { const currentPinned = getGlobalState("pinnedApiConfigs") ?? {} diff --git a/webview-ui/src/components/chat/ApiConfigSelector.tsx b/webview-ui/src/components/chat/ApiConfigSelector.tsx index e370296ec3..33f0ddabe9 100644 --- a/webview-ui/src/components/chat/ApiConfigSelector.tsx +++ b/webview-ui/src/components/chat/ApiConfigSelector.tsx @@ -22,6 +22,8 @@ interface ApiConfigSelectorProps { togglePinnedApiConfig: (id: string) => void lockApiConfigAcrossModes: boolean onToggleLockApiConfig: () => void + currentMode?: string + workspaceModeApiConfigs?: Record } export const ApiConfigSelector = ({ @@ -36,6 +38,8 @@ export const ApiConfigSelector = ({ togglePinnedApiConfig, lockApiConfigAcrossModes, onToggleLockApiConfig, + currentMode, + workspaceModeApiConfigs, }: ApiConfigSelectorProps) => { const { t } = useAppTranslation() const [open, setOpen] = useState(false) @@ -242,6 +246,39 @@ export const ApiConfigSelector = ({ className={lockApiConfigAcrossModes ? "text-vscode-focusBorder" : "opacity-60"} onClick={onToggleLockApiConfig} /> + {currentMode && ( + { + if (workspaceModeApiConfigs?.[currentMode]) { + vscode.postMessage({ + type: "setWorkspaceModeApiConfig", + mode: currentMode, + }) + } else { + vscode.postMessage({ + type: "setWorkspaceModeApiConfig", + mode: currentMode, + text: value, + }) + } + }} + /> + )} {/* Info icon and title on the right with matching spacing */} diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index e72c1726f3..749693bd04 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -99,6 +99,8 @@ export const ChatTextArea = forwardRef( cloudUserInfo, enterBehavior, lockApiConfigAcrossModes, + workspaceModeApiConfigs, + mode: currentMode, } = useExtensionState() // Find the ID and display text for the currently selected API configuration. @@ -1319,6 +1321,8 @@ export const ChatTextArea = forwardRef( togglePinnedApiConfig={togglePinnedApiConfig} lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} onToggleLockApiConfig={handleToggleLockApiConfig} + currentMode={currentMode} + workspaceModeApiConfigs={workspaceModeApiConfigs} /> diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ce7a607d9a..a91e869812 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -263,6 +263,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode includeCurrentTime: true, includeCurrentCost: true, lockApiConfigAcrossModes: false, + workspaceModeApiConfigs: {}, }) const [didHydrateState, setDidHydrateState] = useState(false) diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 6f1badac1f..fe6677e283 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -142,6 +142,8 @@ "selectApiConfig": "Select API configuration", "lockApiConfigAcrossModes": "Lock API configuration across all modes in this workspace", "unlockApiConfigAcrossModes": "API configuration is locked across all modes in this workspace (click to unlock)", + "setWorkspaceProfile": "Pin this profile to the current mode for this workspace", + "clearWorkspaceProfile": "This profile is pinned to the current mode for this workspace (click to unpin)", "enhancePrompt": "Enhance prompt with additional context", "modeSelector": { "title": "Modes",