From b4b2463461a7cd126a79c06320a96e96d9f1f527 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 29 Apr 2026 20:02:37 +0000 Subject: [PATCH 1/2] 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 b20539afe49..26561c51801 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 1106d340050..6fa96b80127 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 00000000000..91e510d2766 --- /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 e3b8c1bea88..968346cf739 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 e370296ec32..33f0ddabe9a 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 e72c1726f35..749693bd045 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 ce7a607d9a8..a91e869812f 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 6f1badac1f1..fe6677e283e 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", From abafe2d4f596d729f9a0595f266992c74fc6d9c1 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 29 Apr 2026 20:22:46 +0000 Subject: [PATCH 2/2] fix: gate workspace profile overrides behind experimental flag and show conflict state - Add WORKSPACE_PROFILE_OVERRIDES experiment to types, schema, and config - Gate workspace override logic in ClineProvider behind experiment flag - Gate workspace override logic in webviewMessageHandler behind experiment flag - Gate workspace pin button in ApiConfigSelector behind experiment flag - Show warning icon when current mode is already pinned to a different profile - Add "reassignWorkspaceProfile" translation key for conflict state - Add "Project-specific profile usage" experimental feature setting - Add test for experiment-disabled scenario --- packages/types/src/experiment.ts | 9 ++- src/core/webview/ClineProvider.ts | 26 ++++-- ...sageHandler.workspaceModeApiConfig.spec.ts | 18 +++++ src/core/webview/webviewMessageHandler.ts | 12 ++- src/shared/__tests__/experiments.spec.ts | 3 + src/shared/experiments.ts | 2 + .../src/components/chat/ApiConfigSelector.tsx | 81 +++++++++++-------- .../src/components/chat/ChatTextArea.tsx | 2 + webview-ui/src/i18n/locales/en/chat.json | 1 + webview-ui/src/i18n/locales/en/settings.json | 4 + 10 files changed, 116 insertions(+), 42 deletions(-) diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index d7eb0b03d6c..6ab56cb5f10 100644 --- a/packages/types/src/experiment.ts +++ b/packages/types/src/experiment.ts @@ -6,7 +6,13 @@ import type { Keys, Equals, AssertEqual } from "./type-fu.js" * ExperimentId */ -export const experimentIds = ["preventFocusDisruption", "imageGeneration", "runSlashCommand", "customTools"] as const +export const experimentIds = [ + "preventFocusDisruption", + "imageGeneration", + "runSlashCommand", + "customTools", + "workspaceProfileOverrides", +] as const export const experimentIdsSchema = z.enum(experimentIds) @@ -21,6 +27,7 @@ export const experimentsSchema = z.object({ imageGeneration: z.boolean().optional(), runSlashCommand: z.boolean().optional(), customTools: z.boolean().optional(), + workspaceProfileOverrides: z.boolean().optional(), }) export type Experiments = z.infer diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6fa96b80127..ec7f690f5ba 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -56,7 +56,7 @@ import { findLast } from "../../shared/array" import { supportPrompt } from "../../shared/support-prompt" import { GlobalFileNames } from "../../shared/globalFileNames" import { Mode, defaultModeSlug, getModeBySlug } from "../../shared/modes" -import { experimentDefault } from "../../shared/experiments" +import { experimentDefault, experiments as experimentsUtil, EXPERIMENT_IDS } from "../../shared/experiments" import { formatLanguage } from "../../shared/language" import { WebviewMessage } from "../../shared/WebviewMessage" import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels" @@ -999,9 +999,15 @@ export class ClineProvider const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) if (!historyItem.apiConfigName && !lockApiConfigAcrossModes && !skipProfileRestoreFromHistory) { - // Check workspace-level override first, then fall back to global mode config. - const workspaceModeApiConfigs = - this.context.workspaceState.get>("workspaceModeApiConfigs") ?? {} + // Check workspace-level override first (if experiment enabled), then fall back to global mode config. + const { experiments: experimentsState } = await this.getState() + const workspaceOverridesEnabled = experimentsUtil.isEnabled( + experimentsState ?? experimentDefault, + EXPERIMENT_IDS.WORKSPACE_PROFILE_OVERRIDES, + ) + const workspaceModeApiConfigs = workspaceOverridesEnabled + ? (this.context.workspaceState.get>("workspaceModeApiConfigs") ?? {}) + : {} const workspaceConfigId = workspaceModeApiConfigs[historyItem.mode] const savedConfigId = workspaceConfigId ?? (await this.providerSettingsManager.getModeConfigId(historyItem.mode)) @@ -1438,9 +1444,15 @@ 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") ?? {} + // Check for workspace-level mode-to-profile override first (if experiment enabled), then fall back to global. + const { experiments: experimentsState } = await this.getState() + const workspaceOverridesEnabled = experimentsUtil.isEnabled( + experimentsState ?? experimentDefault, + EXPERIMENT_IDS.WORKSPACE_PROFILE_OVERRIDES, + ) + const workspaceModeApiConfigs = workspaceOverridesEnabled + ? (this.context.workspaceState.get>("workspaceModeApiConfigs") ?? {}) + : {} const workspaceConfigId = workspaceModeApiConfigs[newMode] // Load the saved API config for the new mode if it exists. diff --git a/src/core/webview/__tests__/webviewMessageHandler.workspaceModeApiConfig.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.workspaceModeApiConfig.spec.ts index 91e510d2766..c17ce674417 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.workspaceModeApiConfig.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.workspaceModeApiConfig.spec.ts @@ -43,6 +43,7 @@ describe("webviewMessageHandler - setWorkspaceModeApiConfig", () => { currentApiConfigName: "test-config", listApiConfigMeta: [{ name: "test-config", id: "config-123" }], customModes: [], + experiments: { workspaceProfileOverrides: true }, }), postStateToWebview: vi.fn(), providerSettingsManager: { @@ -53,6 +54,23 @@ describe("webviewMessageHandler - setWorkspaceModeApiConfig", () => { } }) + it("does nothing when experiment is disabled", async () => { + mockProvider.getState.mockResolvedValueOnce({ + currentApiConfigName: "test-config", + listApiConfigMeta: [{ name: "test-config", id: "config-123" }], + customModes: [], + experiments: { workspaceProfileOverrides: false }, + }) + + await webviewMessageHandler(mockProvider as unknown as ClineProvider, { + type: "setWorkspaceModeApiConfig", + mode: "code", + text: "config-123", + }) + + expect(mockProvider.context.workspaceState.update).not.toHaveBeenCalled() + }) + it("sets a workspace mode API config for a specific mode", async () => { await webviewMessageHandler(mockProvider as unknown as ClineProvider, { type: "setWorkspaceModeApiConfig", diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 968346cf739..bafdf81fb9e 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -47,7 +47,7 @@ import { MessageEnhancer } from "./messageEnhancer" import { CodeIndexManager } from "../../services/code-index/manager" import { checkExistKey } from "../../shared/checkExistApiConfig" -import { experimentDefault } from "../../shared/experiments" +import { experimentDefault, experiments as experimentsUtil, EXPERIMENT_IDS } from "../../shared/experiments" import { Terminal } from "../../integrations/terminal/Terminal" import { openFile } from "../../integrations/misc/open-file" import { openImage, saveImage } from "../../integrations/misc/image-handler" @@ -1653,6 +1653,16 @@ export const webviewMessageHandler = async ( case "setWorkspaceModeApiConfig": { // Set a workspace-level mode-to-profile override. // message.mode contains the mode slug, message.text contains the profile config ID. + // Only proceed if the workspace profile overrides experiment is enabled. + const { experiments: expState } = await provider.getState() + const wsOverridesEnabled = experimentsUtil.isEnabled( + expState ?? experimentDefault, + EXPERIMENT_IDS.WORKSPACE_PROFILE_OVERRIDES, + ) + if (!wsOverridesEnabled) { + break + } + const modeSlug = message.mode const configId = message.text diff --git a/src/shared/__tests__/experiments.spec.ts b/src/shared/__tests__/experiments.spec.ts index 92a7d7604ff..6f56af42eff 100644 --- a/src/shared/__tests__/experiments.spec.ts +++ b/src/shared/__tests__/experiments.spec.ts @@ -21,6 +21,7 @@ describe("experiments", () => { imageGeneration: false, runSlashCommand: false, customTools: false, + workspaceProfileOverrides: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false) }) @@ -31,6 +32,7 @@ describe("experiments", () => { imageGeneration: false, runSlashCommand: false, customTools: false, + workspaceProfileOverrides: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(true) }) @@ -41,6 +43,7 @@ describe("experiments", () => { imageGeneration: false, runSlashCommand: false, customTools: false, + workspaceProfileOverrides: false, } expect(Experiments.isEnabled(experiments, EXPERIMENT_IDS.PREVENT_FOCUS_DISRUPTION)).toBe(false) }) diff --git a/src/shared/experiments.ts b/src/shared/experiments.ts index e189f99e23d..d677683fba0 100644 --- a/src/shared/experiments.ts +++ b/src/shared/experiments.ts @@ -5,6 +5,7 @@ export const EXPERIMENT_IDS = { IMAGE_GENERATION: "imageGeneration", RUN_SLASH_COMMAND: "runSlashCommand", CUSTOM_TOOLS: "customTools", + WORKSPACE_PROFILE_OVERRIDES: "workspaceProfileOverrides", } as const satisfies Record type _AssertExperimentIds = AssertEqual>> @@ -20,6 +21,7 @@ export const experimentConfigsMap: Record = { IMAGE_GENERATION: { enabled: false }, RUN_SLASH_COMMAND: { enabled: false }, CUSTOM_TOOLS: { enabled: false }, + WORKSPACE_PROFILE_OVERRIDES: { enabled: false }, } export const experimentDefault = Object.fromEntries( diff --git a/webview-ui/src/components/chat/ApiConfigSelector.tsx b/webview-ui/src/components/chat/ApiConfigSelector.tsx index 33f0ddabe9a..70beda16231 100644 --- a/webview-ui/src/components/chat/ApiConfigSelector.tsx +++ b/webview-ui/src/components/chat/ApiConfigSelector.tsx @@ -24,6 +24,7 @@ interface ApiConfigSelectorProps { onToggleLockApiConfig: () => void currentMode?: string workspaceModeApiConfigs?: Record + enableWorkspaceOverrides?: boolean } export const ApiConfigSelector = ({ @@ -40,6 +41,7 @@ export const ApiConfigSelector = ({ onToggleLockApiConfig, currentMode, workspaceModeApiConfigs, + enableWorkspaceOverrides, }: ApiConfigSelectorProps) => { const { t } = useAppTranslation() const [open, setOpen] = useState(false) @@ -246,39 +248,52 @@ 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, - }) - } - }} - /> - )} + {currentMode && + enableWorkspaceOverrides && + (() => { + const pinnedConfigId = workspaceModeApiConfigs?.[currentMode] + const isPinnedToThis = pinnedConfigId === value + const isPinnedToOther = !!pinnedConfigId && pinnedConfigId !== value + return ( + { + if (isPinnedToThis) { + 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 749693bd045..bfff7f60830 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -101,6 +101,7 @@ export const ChatTextArea = forwardRef( lockApiConfigAcrossModes, workspaceModeApiConfigs, mode: currentMode, + experiments, } = useExtensionState() // Find the ID and display text for the currently selected API configuration. @@ -1323,6 +1324,7 @@ export const ChatTextArea = forwardRef( onToggleLockApiConfig={handleToggleLockApiConfig} currentMode={currentMode} workspaceModeApiConfigs={workspaceModeApiConfigs} + enableWorkspaceOverrides={!!experiments?.workspaceProfileOverrides} /> diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index fe6677e283e..466dadeedab 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -144,6 +144,7 @@ "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)", + "reassignWorkspaceProfile": "This mode is already pinned to a different profile in this workspace (click to reassign)", "enhancePrompt": "Enhance prompt with additional context", "modeSelector": { "title": "Modes", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 8ec42367f14..3a97ba03f04 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -886,6 +886,10 @@ "refreshSuccess": "Tools refreshed successfully", "refreshError": "Failed to refresh tools", "toolParameters": "Parameters" + }, + "WORKSPACE_PROFILE_OVERRIDES": { + "name": "Project-specific profile usage", + "description": "When enabled, you can pin provider profiles to specific modes on a per-workspace basis. Workspace overrides take priority over global mode-to-profile mappings." } }, "promptCaching": {