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/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..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,7 +999,18 @@ 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 (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)) const listApiConfig = await this.providerSettingsManager.listConfig() // Update listApiConfigMeta first to ensure UI has latest data. @@ -1433,8 +1444,19 @@ export class ClineProvider return } + // 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. - 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 +2585,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..c17ce674417 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.workspaceModeApiConfig.spec.ts @@ -0,0 +1,178 @@ +// 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: [], + experiments: { workspaceProfileOverrides: true }, + }), + postStateToWebview: vi.fn(), + providerSettingsManager: { + setModeConfig: vi.fn(), + }, + postMessageToWebview: vi.fn(), + getCurrentTask: vi.fn(), + } + }) + + 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", + 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..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" @@ -1650,6 +1650,47 @@ 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. + // 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 + + 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/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 e370296ec32..70beda16231 100644 --- a/webview-ui/src/components/chat/ApiConfigSelector.tsx +++ b/webview-ui/src/components/chat/ApiConfigSelector.tsx @@ -22,6 +22,9 @@ interface ApiConfigSelectorProps { togglePinnedApiConfig: (id: string) => void lockApiConfigAcrossModes: boolean onToggleLockApiConfig: () => void + currentMode?: string + workspaceModeApiConfigs?: Record + enableWorkspaceOverrides?: boolean } export const ApiConfigSelector = ({ @@ -36,6 +39,9 @@ export const ApiConfigSelector = ({ togglePinnedApiConfig, lockApiConfigAcrossModes, onToggleLockApiConfig, + currentMode, + workspaceModeApiConfigs, + enableWorkspaceOverrides, }: ApiConfigSelectorProps) => { const { t } = useAppTranslation() const [open, setOpen] = useState(false) @@ -242,6 +248,52 @@ export const ApiConfigSelector = ({ className={lockApiConfigAcrossModes ? "text-vscode-focusBorder" : "opacity-60"} onClick={onToggleLockApiConfig} /> + {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 e72c1726f35..bfff7f60830 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -99,6 +99,9 @@ export const ChatTextArea = forwardRef( cloudUserInfo, enterBehavior, lockApiConfigAcrossModes, + workspaceModeApiConfigs, + mode: currentMode, + experiments, } = useExtensionState() // Find the ID and display text for the currently selected API configuration. @@ -1319,6 +1322,9 @@ export const ChatTextArea = forwardRef( togglePinnedApiConfig={togglePinnedApiConfig} lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} onToggleLockApiConfig={handleToggleLockApiConfig} + currentMode={currentMode} + workspaceModeApiConfigs={workspaceModeApiConfigs} + enableWorkspaceOverrides={!!experiments?.workspaceProfileOverrides} /> 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..466dadeedab 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -142,6 +142,9 @@ "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)", + "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": {