mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
ad25634905
commit
68a5a7175b
8 changed files with 235 additions and 4 deletions
|
|
@ -561,6 +561,7 @@ export interface WebviewMessage {
|
|||
| "refreshCustomTools"
|
||||
| "requestModes"
|
||||
| "switchMode"
|
||||
| "setDefaultModesForProfile"
|
||||
| "debugSetting"
|
||||
// Worktree messages
|
||||
| "listWorktrees"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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") ?? {}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue