Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,7 @@ export interface WebviewMessage {
| "refreshCustomTools"
| "requestModes"
| "switchMode"
| "setDefaultModesForProfile"
| "debugSetting"
// Worktree messages
| "listWorktrees"
Expand Down
32 changes: 32 additions & 0 deletions src/core/config/ProviderSettingsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
79 changes: 79 additions & 0 deletions src/core/config/__tests__/ProviderSettingsManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({})
})
})
})
20 changes: 20 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") ?? {}
Expand Down
91 changes: 88 additions & 3 deletions webview-ui/src/components/settings/ApiConfigManager.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -30,6 +38,8 @@ const ApiConfigManager = ({
currentApiConfigName = "",
listApiConfigMeta = [],
organizationAllowList,
modeApiConfigs = {},
customModes = [],
onSelectConfig,
onDeleteConfig,
onRenameConfig,
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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) => {
Expand Down
5 changes: 4 additions & 1 deletion webview-ui/src/components/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
3 changes: 3 additions & 0 deletions webview-ui/src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading