feat: add lock API config toggle to Settings providers tab (#12237)

Adds a "Use the same API configuration for all modes" checkbox to the
Providers section in Settings, making the existing lockApiConfigAcrossModes
feature discoverable without needing to find the small lock icon inside
the API Configuration popover.

- Adds VSCodeCheckbox to SettingsView providers tab bound to cachedState
- Sends lockApiConfigAcrossModes message on save (same handler as popover)
- Adds i18n strings for the label and description
- Adds test coverage for rendering, toggling, and save behavior
This commit is contained in:
Roo Code 2026-04-30 13:56:30 +00:00
parent ad25634905
commit c7d1230913
3 changed files with 382 additions and 0 deletions

View file

@ -39,6 +39,8 @@ import {
ImageGenerationProvider,
} from "@roo-code/types"
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { vscode } from "@src/utils/vscode"
import { cn } from "@src/lib/utils"
import { useAppTranslation } from "@src/i18n/TranslationContext"
@ -203,6 +205,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
includeCurrentTime,
includeCurrentCost,
maxGitStatusFiles,
lockApiConfigAcrossModes,
} = cachedState
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
@ -430,6 +433,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting })
vscode.postMessage({ type: "debugSetting", bool: cachedState.debug })
vscode.postMessage({ type: "lockApiConfigAcrossModes", bool: lockApiConfigAcrossModes ?? false })
setChangeDetected(false)
}
@ -766,6 +770,21 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
})
}
/>
<div className="mt-2 mb-3">
<VSCodeCheckbox
checked={lockApiConfigAcrossModes ?? false}
onChange={(e: any) =>
setCachedStateField("lockApiConfigAcrossModes", e.target.checked)
}
data-testid="lock-api-config-across-modes-checkbox">
<span className="font-medium">
{t("settings:providers.lockApiConfigAcrossModes")}
</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1 ml-5">
{t("settings:providers.lockApiConfigAcrossModesDescription")}
</div>
</div>
<ApiOptions
uriScheme={uriScheme}
apiConfiguration={apiConfiguration}

View file

@ -0,0 +1,361 @@
// pnpm --filter @roo-code/vscode-webview test src/components/settings/__tests__/SettingsView.lockApiConfig.spec.tsx
import { render, screen, fireEvent, within } from "@/utils/test-utils"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { vscode } from "@/utils/vscode"
import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext"
import SettingsView from "../SettingsView"
vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } }))
vi.mock("../ApiConfigManager", () => ({
__esModule: true,
default: ({ currentApiConfigName }: any) => (
<div data-testid="api-config-management">
<span>Current config: {currentApiConfigName}</span>
</div>
),
}))
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeButton: ({ children, onClick, appearance, "data-testid": dataTestId }: any) =>
appearance === "icon" ? (
<button
onClick={onClick}
className="codicon codicon-close"
aria-label="Remove command"
data-testid={dataTestId}>
<span className="codicon codicon-close" />
</button>
) : (
<button onClick={onClick} data-appearance={appearance} data-testid={dataTestId}>
{children}
</button>
),
VSCodeCheckbox: ({ children, onChange, checked, "data-testid": dataTestId }: any) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
aria-label={typeof children === "string" ? children : undefined}
data-testid={dataTestId}
/>
{children}
</label>
),
VSCodeTextField: ({ value, onInput, placeholder, "data-testid": dataTestId }: any) => (
<input
type="text"
value={value}
onChange={(e) => onInput?.({ target: { value: e.target.value } })}
placeholder={placeholder}
data-testid={dataTestId}
/>
),
VSCodeLink: ({ children, href }: any) => <a href={href || "#"}>{children}</a>,
VSCodeRadio: ({ value, checked, onChange }: any) => (
<input type="radio" value={value} checked={checked} onChange={onChange} />
),
VSCodeRadioGroup: ({ children, onChange }: any) => <div onChange={onChange}>{children}</div>,
VSCodeTextArea: ({ value, onChange, rows, className, "data-testid": dataTestId }: any) => (
<textarea
value={value}
onChange={onChange}
rows={rows}
className={className}
data-testid={dataTestId}
role="textbox"
/>
),
}))
vi.mock("../../../components/common/Tab", () => ({
...vi.importActual("../../../components/common/Tab"),
Tab: ({ children }: any) => <div data-testid="tab-container">{children}</div>,
TabHeader: ({ children }: any) => <div data-testid="tab-header">{children}</div>,
TabContent: ({ children, "data-testid": dataTestId }: any) => (
<div data-testid={dataTestId || "tab-content"}>{children}</div>
),
TabList: ({ children, value, onValueChange, "data-testid": dataTestId }: any) => {
;(window as any).__onValueChange = onValueChange
return (
<div data-testid={dataTestId} data-value={value}>
{children}
</div>
)
},
TabTrigger: ({ children, value, "data-testid": dataTestId, onClick, isSelected }: any) => {
const handleClick = () => {
if (onClick) onClick()
const onValueChange = (window as any).__onValueChange
if (onValueChange) onValueChange(value)
}
return (
<button data-testid={dataTestId} data-value={value} data-selected={isSelected} onClick={handleClick}>
{children}
</button>
)
},
}))
vi.mock("@/components/ui", async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as any),
ToggleSwitch: ({ checked, onChange, "aria-label": ariaLabel, "data-testid": dataTestId }: any) => (
<button
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
data-testid={dataTestId}
onClick={onChange}>
Toggle
</button>
),
Checkbox: ({ checked, onCheckedChange, id, className, ...props }: any) => (
<input
type="checkbox"
checked={checked}
onChange={(e) => onCheckedChange?.(e.target.checked)}
id={id}
className={className}
{...props}
/>
),
Textarea: ({ value, onChange, placeholder, id, className, ...props }: any) => (
<textarea
value={value}
onChange={onChange}
placeholder={placeholder}
id={id}
className={className}
{...props}
/>
),
StandardTooltip: ({ children }: any) => <>{children}</>,
Tooltip: ({ children }: any) => <>{children}</>,
TooltipContent: ({ children }: any) => <>{children}</>,
TooltipProvider: ({ children }: any) => <>{children}</>,
TooltipTrigger: ({ children }: any) => <>{children}</>,
Input: ({ value, onChange, placeholder, "data-testid": dataTestId }: any) => (
<input type="text" value={value} onChange={onChange} placeholder={placeholder} data-testid={dataTestId} />
),
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
SearchableSelect: ({ value, onValueChange, options, placeholder }: any) => (
<select value={value} onChange={(e) => onValueChange(e.target.value)} data-testid="searchable-select">
{placeholder && <option value="">{placeholder}</option>}
{options?.map((opt: any) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
),
AlertDialog: ({ children, open }: any) => (
<div data-testid="alert-dialog" data-open={open}>
{children}
</div>
),
AlertDialogContent: ({ children }: any) => <div data-testid="alert-dialog-content">{children}</div>,
AlertDialogHeader: ({ children }: any) => <div data-testid="alert-dialog-header">{children}</div>,
AlertDialogTitle: ({ children }: any) => <div data-testid="alert-dialog-title">{children}</div>,
AlertDialogDescription: ({ children }: any) => <div data-testid="alert-dialog-description">{children}</div>,
AlertDialogFooter: ({ children }: any) => <div data-testid="alert-dialog-footer">{children}</div>,
AlertDialogAction: ({ children, onClick }: any) => (
<button data-testid="alert-dialog-action" onClick={onClick}>
{children}
</button>
),
AlertDialogCancel: ({ children, onClick }: any) => (
<button data-testid="alert-dialog-cancel" onClick={onClick}>
{children}
</button>
),
Collapsible: ({ children, open }: any) => (
<div className="collapsible-mock" data-open={open}>
{children}
</div>
),
CollapsibleTrigger: ({ children, className, onClick }: any) => (
<div className={`collapsible-trigger-mock ${className || ""}`} onClick={onClick}>
{children}
</div>
),
CollapsibleContent: ({ children, className }: any) => (
<div className={`collapsible-content-mock ${className || ""}`}>{children}</div>
),
Dialog: ({ children, ...props }: any) => (
<div data-testid="dialog" {...props}>
{children}
</div>
),
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children, ...props }: any) => (
<div data-testid="dialog-header" {...props}>
{children}
</div>
),
DialogTitle: ({ children, ...props }: any) => (
<div data-testid="dialog-title" {...props}>
{children}
</div>
),
DialogDescription: ({ children, ...props }: any) => (
<div data-testid="dialog-description" {...props}>
{children}
</div>
),
DialogFooter: ({ children, ...props }: any) => (
<div data-testid="dialog-footer" {...props}>
{children}
</div>
),
}
})
vi.mock("vscrui", () => ({
Checkbox: ({ children, checked, onChange }: any) => (
<label>
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
{children}
</label>
),
}))
vi.mock("../ApiOptions", () => ({
__esModule: true,
default: () => <div data-testid="api-options">ApiOptions</div>,
}))
const mockPostMessage = (state: any) => {
window.postMessage(
{
type: "state",
state: {
version: "1.0.0",
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
alwaysAllowExecute: false,
ttsEnabled: false,
ttsSpeed: 1,
soundEnabled: false,
soundVolume: 0.5,
lockApiConfigAcrossModes: false,
...state,
},
},
"*",
)
}
const renderSettingsView = (initialState: any = {}) => {
const onDone = vi.fn()
const queryClient = new QueryClient()
const result = render(
<ExtensionStateContextProvider>
<QueryClientProvider client={queryClient}>
<SettingsView onDone={onDone} targetSection="providers" />
</QueryClientProvider>
</ExtensionStateContextProvider>,
)
// Hydrate initial state
mockPostMessage(initialState)
const getSettingsContent = () => screen.getByTestId("settings-content")
return { onDone, getSettingsContent, result, queryClient }
}
describe("SettingsView - Lock API Config Across Modes", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("renders the lock API config checkbox in the providers tab", () => {
const { getSettingsContent } = renderSettingsView()
const content = getSettingsContent()
const checkbox = within(content).getByTestId("lock-api-config-across-modes-checkbox")
expect(checkbox).toBeInTheDocument()
})
it("initializes with lockApiConfigAcrossModes unchecked by default", () => {
const { getSettingsContent } = renderSettingsView()
const content = getSettingsContent()
const checkbox = within(content).getByTestId("lock-api-config-across-modes-checkbox")
expect(checkbox).not.toBeChecked()
})
it("toggles the checkbox when clicked", () => {
const { getSettingsContent } = renderSettingsView()
const content = getSettingsContent()
const checkbox = within(content).getByTestId("lock-api-config-across-modes-checkbox")
// Initially unchecked
expect(checkbox).not.toBeChecked()
// Click to enable
fireEvent.click(checkbox)
expect(checkbox).toBeChecked()
// Click to disable
fireEvent.click(checkbox)
expect(checkbox).not.toBeChecked()
})
it("sends lockApiConfigAcrossModes=true message when saving with checkbox enabled", () => {
const { getSettingsContent } = renderSettingsView()
const content = getSettingsContent()
// Enable the lock checkbox
const checkbox = within(content).getByTestId("lock-api-config-across-modes-checkbox")
fireEvent.click(checkbox)
// Click Save
const saveButton = screen.getByTestId("save-button")
fireEvent.click(saveButton)
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "lockApiConfigAcrossModes",
bool: true,
}),
)
})
it("sends lockApiConfigAcrossModes=false message when saving with checkbox disabled", () => {
const { getSettingsContent } = renderSettingsView()
const content = getSettingsContent()
// Enable then disable the lock checkbox
const checkbox = within(content).getByTestId("lock-api-config-across-modes-checkbox")
fireEvent.click(checkbox) // enable
fireEvent.click(checkbox) // disable
// Click Save
const saveButton = screen.getByTestId("save-button")
fireEvent.click(saveButton)
expect(vscode.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "lockApiConfigAcrossModes",
bool: false,
}),
)
})
})

View file

@ -351,6 +351,8 @@
"providerDocumentation": "{{provider}} documentation",
"configProfile": "Configuration Profile",
"description": "Save different API configurations to quickly switch between providers and settings.",
"lockApiConfigAcrossModes": "Use the same API configuration for all modes",
"lockApiConfigAcrossModesDescription": "When enabled, switching modes (e.g. Ask, Code, Architect) will not change the selected provider or model. The lock icon in the API Configuration popover does the same thing.",
"apiProvider": "API Provider",
"apiProviderDocs": "Provider Docs",
"model": "Model",