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
This commit is contained in:
Roo Code 2026-04-29 20:13:32 +00:00
parent ad25634905
commit 68a5a7175b
8 changed files with 235 additions and 4 deletions

View file

@ -561,6 +561,7 @@ export interface WebviewMessage {
| "refreshCustomTools"
| "requestModes"
| "switchMode"
| "setDefaultModesForProfile"
| "debugSetting"
// Worktree messages
| "listWorktrees"

View file

@ -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<Record<string, string>> {
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.
*/

View file

@ -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({})
})
})
})

View file

@ -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") ?? {}

View file

@ -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<string, string>
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 (
<div className="flex flex-col gap-1">
<label className="block font-medium mb-1">{t("settings:providers.configProfile")}</label>
@ -298,6 +350,39 @@ const ApiConfigManager = ({
</>
)}
{/* Default for modes dropdown */}
{!isRenaming && currentProfileId && (
<div className="mt-3">
<label className="block font-medium mb-1">{t("settings:providers.defaultForModes")}</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="secondary"
className="w-full justify-between text-left font-normal"
data-testid="default-modes-trigger">
<span className="truncate">{assignedModesDisplayText}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[var(--radix-dropdown-menu-trigger-width)]">
{allModes.map((mode) => (
<DropdownMenuCheckboxItem
key={mode.slug}
checked={assignedModes.includes(mode.slug)}
onCheckedChange={() => handleToggleMode(mode.slug)}
onSelect={(e) => e.preventDefault()}
data-testid={`mode-checkbox-${mode.slug}`}>
{mode.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:providers.defaultForModesDescription")}
</div>
</div>
)}
<Dialog
open={isCreating}
onOpenChange={(open: boolean) => {

View file

@ -125,7 +125,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ 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<SettingsViewRef, SettingsViewProps>(({ onDone, t
<ApiConfigManager
currentApiConfigName={currentApiConfigName}
listApiConfigMeta={listApiConfigMeta}
modeApiConfigs={modeApiConfigs}
customModes={customModes}
onSelectConfig={(configName: string) =>
checkUnsaveChanges(() =>
vscode.postMessage({ type: "loadApiConfiguration", text: configName }),

View file

@ -104,6 +104,14 @@ vitest.mock("@/components/ui", () => ({
))}
</select>
),
DropdownMenu: ({ children }: any) => <div data-testid="dropdown-menu">{children}</div>,
DropdownMenuTrigger: ({ children }: any) => <div data-testid="dropdown-trigger">{children}</div>,
DropdownMenuContent: ({ children }: any) => <div data-testid="dropdown-content">{children}</div>,
DropdownMenuCheckboxItem: ({ children, checked, onCheckedChange, "data-testid": dataTestId }: any) => (
<div data-testid={dataTestId} data-checked={checked} onClick={() => onCheckedChange?.(!checked)}>
{children}
</div>
),
}))
describe("ApiConfigManager", () => {

View file

@ -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",