feat: add "Apply to All Modes" button for API configuration

- Added new button in ApiConfigSelector dropdown to apply current config to all modes
- Implemented confirmation dialog to prevent accidental changes
- Added backend handler to apply configuration across all built-in and custom modes
- Added comprehensive tests for the new functionality
- Added all necessary translation keys

Fixes #7898
This commit is contained in:
Roo Code 2025-09-11 13:49:02 +00:00
parent 8fee3127ff
commit f7b2952bb1
6 changed files with 264 additions and 95 deletions

View file

@ -3038,5 +3038,40 @@ export const webviewMessageHandler = async (
})
break
}
case "applyConfigToAllModes": {
if (message.configId) {
try {
// Get all available modes
const { modes } = await import("../../shared/modes")
// Apply the config to all modes
for (const mode of modes) {
await provider.providerSettingsManager.setModeConfig(mode.slug, message.configId)
}
// Also apply to custom modes
const customModes = await provider.customModesManager.getCustomModes()
for (const customMode of customModes) {
await provider.providerSettingsManager.setModeConfig(customMode.slug, message.configId)
}
// Update the global state with the new mode configs
const providerProfiles = await provider.providerSettingsManager.export()
await updateGlobalState("modeApiConfigs", providerProfiles.modeApiConfigs)
// Show success message
vscode.window.showInformationMessage(t("common:info.api_config_applied_to_all_modes"))
// Update the webview state
await provider.postStateToWebview()
} catch (error) {
provider.log(
`Error applying config to all modes: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
)
vscode.window.showErrorMessage(t("common:errors.apply_config_to_all_modes_failed"))
}
}
break
}
}
}

View file

@ -43,6 +43,7 @@
"delete_api_config": "Failed to delete api configuration",
"list_api_config": "Failed to get list api configuration",
"update_server_timeout": "Failed to update server timeout",
"apply_config_to_all_modes_failed": "Failed to apply configuration to all modes",
"hmr_not_running": "Local development server is not running, HMR will not work. Please run 'npm run dev' before launching the extension to enable HMR.",
"retrieve_current_mode": "Error: failed to retrieve current mode from state.",
"failed_delete_repo": "Failed to delete associated shadow repository or branch: {{error}}",
@ -142,7 +143,8 @@
"image_copied_to_clipboard": "Image data URI copied to clipboard",
"image_saved": "Image saved to {{path}}",
"mode_exported": "Mode '{{mode}}' exported successfully",
"mode_imported": "Mode imported successfully"
"mode_imported": "Mode imported successfully",
"api_config_applied_to_all_modes": "API configuration applied to all modes successfully"
},
"answers": {
"yes": "Yes",

View file

@ -225,6 +225,7 @@ export interface WebviewMessage {
| "editQueuedMessage"
| "dismissUpsell"
| "getDismissedUpsells"
| "applyConfigToAllModes"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
@ -272,6 +273,7 @@ export interface WebviewMessage {
checkOnly?: boolean // For deleteCustomMode check
upsellId?: string // For dismissUpsell
list?: string[] // For dismissedUpsells response
configId?: string // For applyConfigToAllModes
codeIndexSettings?: {
// Global state settings
codebaseIndexEnabled: boolean

View file

@ -8,6 +8,16 @@ import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/comp
import { useAppTranslation } from "@/i18n/TranslationContext"
import { vscode } from "@/utils/vscode"
import { Button } from "@/components/ui"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { IconButton } from "./IconButton"
@ -37,6 +47,7 @@ export const ApiConfigSelector = ({
const { t } = useAppTranslation()
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState("")
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const portalContainer = useRooPortal("roo-portal")
// Create searchable items for fuzzy search.
@ -86,6 +97,16 @@ export const ApiConfigSelector = ({
setOpen(false)
}, [])
const handleApplyToAllModes = useCallback(() => {
setOpen(false)
setShowConfirmDialog(true)
}, [])
const handleConfirmApplyToAllModes = useCallback(() => {
vscode.postMessage({ type: "applyConfigToAllModes", configId: value })
setShowConfirmDialog(false)
}, [value])
const renderConfigItem = useCallback(
(config: { id: string; name: string; modelId?: string }, isPinned: boolean) => {
const isCurrentConfig = config.id === value
@ -143,110 +164,139 @@ export const ApiConfigSelector = ({
)
return (
<Popover open={open} onOpenChange={setOpen} data-testid="api-config-selector-root">
<StandardTooltip content={title}>
<PopoverTrigger
disabled={disabled}
data-testid="dropdown-trigger"
className={cn(
"w-full min-w-0 max-w-full inline-flex items-center gap-1.5 relative whitespace-nowrap px-1.5 py-1 text-xs",
"bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground",
"transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset",
disabled
? "opacity-50 cursor-not-allowed"
: "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer",
triggerClassName,
)}>
<ChevronUp
<>
<Popover open={open} onOpenChange={setOpen} data-testid="api-config-selector-root">
<StandardTooltip content={title}>
<PopoverTrigger
disabled={disabled}
data-testid="dropdown-trigger"
className={cn(
"pointer-events-none opacity-80 flex-shrink-0 size-3 transition-transform duration-200",
open && "rotate-180",
"w-full min-w-0 max-w-full inline-flex items-center gap-1.5 relative whitespace-nowrap px-1.5 py-1 text-xs",
"bg-transparent border border-[rgba(255,255,255,0.08)] rounded-md text-vscode-foreground",
"transition-all duration-150 focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder focus-visible:ring-inset",
disabled
? "opacity-50 cursor-not-allowed"
: "opacity-90 hover:opacity-100 hover:bg-[rgba(255,255,255,0.03)] hover:border-[rgba(255,255,255,0.15)] cursor-pointer",
triggerClassName,
)}>
<ChevronUp
className={cn(
"pointer-events-none opacity-80 flex-shrink-0 size-3 transition-transform duration-200",
open && "rotate-180",
)}
/>
<span className="truncate">{displayName}</span>
</PopoverTrigger>
</StandardTooltip>
<PopoverContent
align="start"
sideOffset={4}
container={portalContainer}
className="p-0 overflow-hidden w-[300px]">
<div className="flex flex-col w-full">
{/* Search input or info blurb */}
{listApiConfigMeta.length > 6 ? (
<div className="relative p-2 border-b border-vscode-dropdown-border">
<input
aria-label={t("common:ui.search_placeholder")}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
placeholder={t("common:ui.search_placeholder")}
className="w-full h-8 px-2 py-1 text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded focus:outline-0"
autoFocus
/>
{searchValue.length > 0 && (
<div className="absolute right-4 top-0 bottom-0 flex items-center justify-center">
<span
className="codicon codicon-close text-vscode-input-foreground opacity-50 hover:opacity-100 text-xs cursor-pointer"
onClick={() => setSearchValue("")}
/>
</div>
)}
</div>
) : (
<div className="p-3 border-b border-vscode-dropdown-border">
<p className="text-xs text-vscode-descriptionForeground m-0">
{t("prompts:apiConfiguration.select")}
</p>
</div>
)}
/>
<span className="truncate">{displayName}</span>
</PopoverTrigger>
</StandardTooltip>
<PopoverContent
align="start"
sideOffset={4}
container={portalContainer}
className="p-0 overflow-hidden w-[300px]">
<div className="flex flex-col w-full">
{/* Search input or info blurb */}
{listApiConfigMeta.length > 6 ? (
<div className="relative p-2 border-b border-vscode-dropdown-border">
<input
aria-label={t("common:ui.search_placeholder")}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
placeholder={t("common:ui.search_placeholder")}
className="w-full h-8 px-2 py-1 text-xs bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded focus:outline-0"
autoFocus
/>
{searchValue.length > 0 && (
<div className="absolute right-4 top-0 bottom-0 flex items-center justify-center">
<span
className="codicon codicon-close text-vscode-input-foreground opacity-50 hover:opacity-100 text-xs cursor-pointer"
onClick={() => setSearchValue("")}
/>
{/* Config list */}
<div className="max-h-[300px] overflow-y-auto">
{filteredConfigs.length === 0 && searchValue ? (
<div className="py-2 px-3 text-sm text-vscode-foreground/70">
{t("common:ui.no_results")}
</div>
) : (
<div className="py-1">
{/* Pinned configs */}
{pinnedConfigs.map((config) => renderConfigItem(config, true))}
{/* Separator between pinned and unpinned */}
{pinnedConfigs.length > 0 && unpinnedConfigs.length > 0 && (
<div className="mx-1 my-1 h-px bg-vscode-dropdown-foreground/10" />
)}
{/* Unpinned configs */}
{unpinnedConfigs.map((config) => renderConfigItem(config, false))}
</div>
)}
</div>
) : (
<div className="p-3 border-b border-vscode-dropdown-border">
<p className="text-xs text-vscode-descriptionForeground m-0">
{t("prompts:apiConfiguration.select")}
</p>
</div>
)}
{/* Config list */}
<div className="max-h-[300px] overflow-y-auto">
{filteredConfigs.length === 0 && searchValue ? (
<div className="py-2 px-3 text-sm text-vscode-foreground/70">
{t("common:ui.no_results")}
{/* Bottom bar with buttons on left and title on right */}
<div className="flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border">
<div className="flex flex-row gap-1">
<IconButton
iconClass="codicon-settings-gear"
title={t("chat:edit")}
onClick={handleEditClick}
tooltip={false}
/>
<IconButton
iconClass="codicon-layers"
title={t("prompts:apiConfiguration.applyToAllModes")}
onClick={handleApplyToAllModes}
tooltip={false}
/>
</div>
) : (
<div className="py-1">
{/* Pinned configs */}
{pinnedConfigs.map((config) => renderConfigItem(config, true))}
{/* Separator between pinned and unpinned */}
{pinnedConfigs.length > 0 && unpinnedConfigs.length > 0 && (
<div className="mx-1 my-1 h-px bg-vscode-dropdown-foreground/10" />
{/* Info icon and title on the right with matching spacing */}
<div className="flex items-center gap-1 pr-1">
{listApiConfigMeta.length > 6 && (
<StandardTooltip content={t("prompts:apiConfiguration.select")}>
<span className="codicon codicon-info text-xs text-vscode-descriptionForeground opacity-70 hover:opacity-100 cursor-help" />
</StandardTooltip>
)}
{/* Unpinned configs */}
{unpinnedConfigs.map((config) => renderConfigItem(config, false))}
<h4 className="m-0 font-medium text-sm text-vscode-descriptionForeground">
{t("prompts:apiConfiguration.title")}
</h4>
</div>
)}
</div>
{/* Bottom bar with buttons on left and title on right */}
<div className="flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border">
<div className="flex flex-row gap-1">
<IconButton
iconClass="codicon-settings-gear"
title={t("chat:edit")}
onClick={handleEditClick}
tooltip={false}
/>
</div>
{/* Info icon and title on the right with matching spacing */}
<div className="flex items-center gap-1 pr-1">
{listApiConfigMeta.length > 6 && (
<StandardTooltip content={t("prompts:apiConfiguration.select")}>
<span className="codicon codicon-info text-xs text-vscode-descriptionForeground opacity-70 hover:opacity-100 cursor-help" />
</StandardTooltip>
)}
<h4 className="m-0 font-medium text-sm text-vscode-descriptionForeground">
{t("prompts:apiConfiguration.title")}
</h4>
</div>
</div>
</div>
</PopoverContent>
</Popover>
</PopoverContent>
</Popover>
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("prompts:apiConfiguration.confirmApplyToAllModes.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("prompts:apiConfiguration.confirmApplyToAllModes.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("prompts:apiConfiguration.confirmApplyToAllModes.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmApplyToAllModes}>
{t("prompts:apiConfiguration.confirmApplyToAllModes.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View file

@ -54,6 +54,14 @@ vi.mock("@/components/ui", () => ({
{children}
</button>
),
AlertDialog: ({ children, open }: any) => (open ? <div data-testid="alert-dialog">{children}</div> : null),
AlertDialogContent: ({ children }: any) => <div data-testid="alert-dialog-content">{children}</div>,
AlertDialogHeader: ({ children }: any) => <div>{children}</div>,
AlertDialogTitle: ({ children }: any) => <h2>{children}</h2>,
AlertDialogDescription: ({ children }: any) => <p>{children}</p>,
AlertDialogFooter: ({ children }: any) => <div>{children}</div>,
AlertDialogCancel: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
AlertDialogAction: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
}))
describe("ApiConfigSelector", () => {
@ -444,4 +452,69 @@ describe("ApiConfigSelector", () => {
// Search value should be maintained
expect(searchInput.value).toBe("Config")
})
test("shows Apply to All Modes button and handles click with confirmation", async () => {
render(<ApiConfigSelector {...defaultProps} />)
const trigger = screen.getByTestId("dropdown-trigger")
fireEvent.click(trigger)
// Find the Apply to All Modes button by its aria-label
const popoverContent = screen.getByTestId("popover-content")
const applyToAllButton = popoverContent.querySelector('[aria-label="prompts:apiConfiguration.applyToAllModes"]')
expect(applyToAllButton).toBeInTheDocument()
// Click the button
if (applyToAllButton) {
fireEvent.click(applyToAllButton)
}
// Check that confirmation dialog appears
await waitFor(() => {
expect(screen.getByText("prompts:apiConfiguration.confirmApplyToAllModes.title")).toBeInTheDocument()
expect(screen.getByText("prompts:apiConfiguration.confirmApplyToAllModes.description")).toBeInTheDocument()
})
// Find and click the confirm button
const confirmButton = screen.getByText("prompts:apiConfiguration.confirmApplyToAllModes.confirm")
fireEvent.click(confirmButton)
// Verify the message was posted
await waitFor(() => {
expect(vi.mocked(vscode.postMessage)).toHaveBeenCalledWith({
type: "applyConfigToAllModes",
configId: "config1",
})
})
})
test("cancels Apply to All Modes when cancel is clicked in confirmation dialog", async () => {
render(<ApiConfigSelector {...defaultProps} />)
const trigger = screen.getByTestId("dropdown-trigger")
fireEvent.click(trigger)
// Find and click the Apply to All Modes button by its aria-label
const popoverContent = screen.getByTestId("popover-content")
const applyToAllButton = popoverContent.querySelector('[aria-label="prompts:apiConfiguration.applyToAllModes"]')
if (applyToAllButton) {
fireEvent.click(applyToAllButton)
}
// Wait for confirmation dialog
await waitFor(() => {
expect(screen.getByText("prompts:apiConfiguration.confirmApplyToAllModes.title")).toBeInTheDocument()
})
// Find and click the cancel button
const cancelButton = screen.getByText("prompts:apiConfiguration.confirmApplyToAllModes.cancel")
fireEvent.click(cancelButton)
// Verify the message was NOT posted
expect(vi.mocked(vscode.postMessage)).not.toHaveBeenCalledWith({
type: "applyConfigToAllModes",
configId: "config1",
})
})
})

View file

@ -14,7 +14,14 @@
},
"apiConfiguration": {
"title": "API Configuration",
"select": "Select which API configuration to use for this mode"
"select": "Select which API configuration to use for this mode",
"applyToAllModes": "Apply to all modes",
"confirmApplyToAllModes": {
"title": "Apply Configuration to All Modes",
"description": "This will apply the currently selected API configuration to all modes (built-in and custom). Are you sure you want to continue?",
"confirm": "Apply to All",
"cancel": "Cancel"
}
},
"tools": {
"title": "Available Tools",