diff --git a/packages/types/src/experiment.ts b/packages/types/src/experiment.ts index d7eb0b03d6..6ab56cb5f1 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 6fa96b8012..ec7f690f5b 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 91e510d276..c17ce67441 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 968346cf73..bafdf81fb9 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 92a7d7604f..6f56af42ef 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 e189f99e23..d677683fba 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 33f0ddabe9..70beda1623 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 749693bd04..bfff7f6083 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 fe6677e283..466dadeeda 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 8ec42367f1..3a97ba03f0 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": {