From 68a5a7175bebaa4f000b3695acf68472e5b9b6c8 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 29 Apr 2026 20:13:32 +0000 Subject: [PATCH] feat: add default modes for profile dropdown in provider settings Adds a "Default for modes" multi-select dropdown in the provider settings section. Users can now see and configure which modes should use a specific provider profile as their default. Changes: - Add setDefaultModesForProfile message type to WebviewMessage - Add unsetModeConfig and getAllModeConfigs to ProviderSettingsManager - Add dropdown UI to ApiConfigManager using DropdownMenuCheckboxItem - Add message handler in webviewMessageHandler.ts - Add translation strings for the new UI - Add tests for new ProviderSettingsManager methods - Update existing ApiConfigManager test mocks Closes #12227 --- packages/types/src/vscode-extension-host.ts | 1 + src/core/config/ProviderSettingsManager.ts | 32 +++++++ .../__tests__/ProviderSettingsManager.spec.ts | 79 ++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 20 ++++ .../components/settings/ApiConfigManager.tsx | 91 ++++++++++++++++++- .../src/components/settings/SettingsView.tsx | 5 +- .../__tests__/ApiConfigManager.spec.tsx | 8 ++ webview-ui/src/i18n/locales/en/settings.json | 3 + 8 files changed, 235 insertions(+), 4 deletions(-) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index b20539afe49..0c07eb47757 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -561,6 +561,7 @@ export interface WebviewMessage { | "refreshCustomTools" | "requestModes" | "switchMode" + | "setDefaultModesForProfile" | "debugSetting" // Worktree messages | "listWorktrees" diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 6088bd68fe2..398c298c1dc 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -494,6 +494,38 @@ export class ProviderSettingsManager { } } + /** + * Remove the API config assignment for a specific mode. + * When no assignment exists, the system falls back to the current active profile. + */ + public async unsetModeConfig(mode: Mode) { + try { + return await this.lock(async () => { + const providerProfiles = await this.load() + if (providerProfiles.modeApiConfigs) { + delete providerProfiles.modeApiConfigs[mode] + await this.store(providerProfiles) + } + }) + } catch (error) { + throw new Error(`Failed to unset mode config: ${error}`) + } + } + + /** + * Get the full mode-to-config mapping. + */ + public async getAllModeConfigs(): Promise> { + try { + return await this.lock(async () => { + const { modeApiConfigs } = await this.load() + return modeApiConfigs ?? {} + }) + } catch (error) { + throw new Error(`Failed to get all mode configs: ${error}`) + } + } + /** * Get the API config ID for a specific mode. */ diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 3f6b4f78478..dde92def7bd 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -1397,4 +1397,83 @@ describe("ProviderSettingsManager", () => { expect(result.activeProfileId).toBe("local-id") }) }) + + describe("unsetModeConfig", () => { + it("should remove a mode from modeApiConfigs", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + }, + modeApiConfigs: { + code: "default-id", + architect: "default-id", + ask: "default-id", + }, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + await providerSettingsManager.unsetModeConfig("architect") + + const storedData = JSON.parse(mockSecrets.store.mock.calls[0][1]) + expect(storedData.modeApiConfigs).not.toHaveProperty("architect") + expect(storedData.modeApiConfigs.code).toBe("default-id") + expect(storedData.modeApiConfigs.ask).toBe("default-id") + }) + + it("should be a no-op when modeApiConfigs is undefined", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + }, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + await providerSettingsManager.unsetModeConfig("code") + + // Should not throw and should not store anything since nothing changed + expect(mockSecrets.store).not.toHaveBeenCalled() + }) + }) + + describe("getAllModeConfigs", () => { + it("should return all mode-to-config mappings", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + custom: { id: "custom-id", apiProvider: "anthropic" }, + }, + modeApiConfigs: { + code: "default-id", + architect: "custom-id", + }, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const result = await providerSettingsManager.getAllModeConfigs() + expect(result).toEqual({ + code: "default-id", + architect: "custom-id", + }) + }) + + it("should return empty object when modeApiConfigs is undefined", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + }, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const result = await providerSettingsManager.getAllModeConfigs() + expect(result).toEqual({}) + }) + }) }) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e3b8c1bea88..94a728244e4 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1650,6 +1650,26 @@ export const webviewMessageHandler = async ( break } + case "setDefaultModesForProfile": { + const { profileId, modeSlug, assign } = (message.values ?? {}) as { + profileId?: string + modeSlug?: string + assign?: boolean + } + if (profileId && modeSlug) { + if (assign) { + await provider.providerSettingsManager.setModeConfig(modeSlug, profileId) + } else { + await provider.providerSettingsManager.unsetModeConfig(modeSlug) + } + // Sync modeApiConfigs to global state so the webview picks up the change + const updatedModeApiConfigs = await provider.providerSettingsManager.getAllModeConfigs() + await updateGlobalState("modeApiConfigs", updatedModeApiConfigs) + await provider.postStateToWebview() + } + break + } + case "toggleApiConfigPin": if (message.text) { const currentPinned = getGlobalState("pinnedApiConfigs") ?? {} diff --git a/webview-ui/src/components/settings/ApiConfigManager.tsx b/webview-ui/src/components/settings/ApiConfigManager.tsx index cd97c4ab1d2..c6faa2fba02 100644 --- a/webview-ui/src/components/settings/ApiConfigManager.tsx +++ b/webview-ui/src/components/settings/ApiConfigManager.tsx @@ -1,10 +1,12 @@ -import { memo, useEffect, useRef, useState } from "react" +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { AlertTriangle } from "lucide-react" +import { AlertTriangle, ChevronDown } from "lucide-react" -import type { ProviderSettingsEntry, OrganizationAllowList } from "@roo-code/types" +import type { ProviderSettingsEntry, OrganizationAllowList, ModeConfig } from "@roo-code/types" +import { getAllModes } from "@roo/modes" import { useAppTranslation } from "@/i18n/TranslationContext" +import { vscode } from "@src/utils/vscode" import { type SearchableSelectOption, Button, @@ -14,12 +16,18 @@ import { DialogTitle, StandardTooltip, SearchableSelect, + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuCheckboxItem, } from "@/components/ui" interface ApiConfigManagerProps { currentApiConfigName?: string listApiConfigMeta?: ProviderSettingsEntry[] organizationAllowList?: OrganizationAllowList + modeApiConfigs?: Record + customModes?: ModeConfig[] onSelectConfig: (configName: string) => void onDeleteConfig: (configName: string) => void onRenameConfig: (oldName: string, newName: string) => void @@ -30,6 +38,8 @@ const ApiConfigManager = ({ currentApiConfigName = "", listApiConfigMeta = [], organizationAllowList, + modeApiConfigs = {}, + customModes = [], onSelectConfig, onDeleteConfig, onRenameConfig, @@ -180,6 +190,48 @@ const ApiConfigManager = ({ const isOnlyProfile = listApiConfigMeta?.length === 1 + // Get the current profile's ID from listApiConfigMeta + const currentProfileId = useMemo(() => { + const entry = listApiConfigMeta?.find((c) => c.name === currentApiConfigName) + return entry?.id + }, [listApiConfigMeta, currentApiConfigName]) + + // Get all available modes (built-in + custom) + const allModes = useMemo(() => getAllModes(customModes), [customModes]) + + // Find which modes are assigned to the current profile + const assignedModes = useMemo(() => { + if (!currentProfileId || !modeApiConfigs) return [] + return allModes.filter((mode) => modeApiConfigs[mode.slug] === currentProfileId).map((mode) => mode.slug) + }, [currentProfileId, modeApiConfigs, allModes]) + + // Build display text for assigned modes + const assignedModesDisplayText = useMemo(() => { + if (assignedModes.length === 0) return t("settings:providers.noModesAssigned") + return assignedModes + .map((slug) => { + const mode = allModes.find((m) => m.slug === slug) + return mode?.name ?? slug + }) + .join(", ") + }, [assignedModes, allModes, t]) + + const handleToggleMode = useCallback( + (modeSlug: string) => { + if (!currentProfileId) return + const isCurrentlyAssigned = assignedModes.includes(modeSlug) + vscode.postMessage({ + type: "setDefaultModesForProfile", + values: { + profileId: currentProfileId, + modeSlug, + assign: !isCurrentlyAssigned, + }, + }) + }, + [currentProfileId, assignedModes], + ) + return (
@@ -298,6 +350,39 @@ const ApiConfigManager = ({ )} + {/* Default for modes dropdown */} + {!isRenaming && currentProfileId && ( +
+ + + + + + + {allModes.map((mode) => ( + handleToggleMode(mode.slug)} + onSelect={(e) => e.preventDefault()} + data-testid={`mode-checkbox-${mode.slug}`}> + {mode.name} + + ))} + + +
+ {t("settings:providers.defaultForModesDescription")} +
+
+ )} + { diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 47e087615e3..aad530c7998 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -125,7 +125,8 @@ const SettingsView = forwardRef(({ onDone, t const { t } = useAppTranslation() const extensionState = useExtensionState() - const { currentApiConfigName, listApiConfigMeta, uriScheme, settingsImportedAt } = extensionState + const { currentApiConfigName, listApiConfigMeta, uriScheme, settingsImportedAt, modeApiConfigs, customModes } = + extensionState const [isDiscardDialogShow, setDiscardDialogShow] = useState(false) const [isChangeDetected, setChangeDetected] = useState(false) @@ -742,6 +743,8 @@ const SettingsView = forwardRef(({ onDone, t checkUnsaveChanges(() => vscode.postMessage({ type: "loadApiConfiguration", text: configName }), diff --git a/webview-ui/src/components/settings/__tests__/ApiConfigManager.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiConfigManager.spec.tsx index 152d8534442..29155e28b2f 100644 --- a/webview-ui/src/components/settings/__tests__/ApiConfigManager.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiConfigManager.spec.tsx @@ -104,6 +104,14 @@ vitest.mock("@/components/ui", () => ({ ))} ), + DropdownMenu: ({ children }: any) =>
{children}
, + DropdownMenuTrigger: ({ children }: any) =>
{children}
, + DropdownMenuContent: ({ children }: any) =>
{children}
, + DropdownMenuCheckboxItem: ({ children, checked, onCheckedChange, "data-testid": dataTestId }: any) => ( +
onCheckedChange?.(!checked)}> + {children} +
+ ), })) describe("ApiConfigManager", () => { diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 8ec42367f14..793d8b34f13 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -365,6 +365,9 @@ "enterProfileName": "Enter profile name", "createProfile": "Create Profile", "cannotDeleteOnlyProfile": "Cannot delete the only profile", + "defaultForModes": "Default for modes", + "defaultForModesDescription": "Select which modes should use this profile by default. When switching to a selected mode, this profile will be activated automatically.", + "noModesAssigned": "None", "searchPlaceholder": "Search profiles", "searchProviderPlaceholder": "Search providers", "noProviderMatchFound": "No providers found",