feat: add ResetToDefault component for settings with defaults

- Add ResetToDefault component with ↺ icon that shows when a setting
  differs from its default value
- Tooltip shows the default value (e.g., 'Reset to default (true)')
- Component only renders when current value !== default
- Add translation keys for boolean/empty/resetToDefault formatting
- Integrate with BrowserSettings as proof of concept:
  - browserToolEnabled checkbox
  - browserViewportSize dropdown
  - screenshotQuality slider
- Add comprehensive tests for visibility, functionality, and styling
This commit is contained in:
Hannes Rudolph 2026-01-23 20:03:01 -07:00
parent f8a20c3154
commit 504a71ac72
4 changed files with 230 additions and 10 deletions

View file

@ -18,6 +18,7 @@ import { useAppTranslation } from "@/i18n/TranslationContext"
import { vscode } from "@/utils/vscode"
import { buildDocLink } from "@src/utils/docLinks"
import { ResetToDefault } from "./ResetToDefault"
import { SearchableSetting } from "./SearchableSetting"
import { Section } from "./Section"
import { SectionHeader } from "./SectionHeader"
@ -117,11 +118,18 @@ export const BrowserSettings = ({
settingId="browser-enable"
section="browser"
label={t("settings:browser.enable.label")}>
<VSCodeCheckbox
checked={browserToolEnabled}
onChange={(e: any) => setCachedStateField("browserToolEnabled", e.target.checked)}>
<span className="font-medium">{t("settings:browser.enable.label")}</span>
</VSCodeCheckbox>
<div className="flex items-center gap-1">
<VSCodeCheckbox
checked={browserToolEnabled}
onChange={(e: any) => setCachedStateField("browserToolEnabled", e.target.checked)}>
<span className="font-medium">{t("settings:browser.enable.label")}</span>
</VSCodeCheckbox>
<ResetToDefault
settingKey="browserToolEnabled"
currentValue={browserToolEnabled}
onReset={() => setCachedStateField("browserToolEnabled", undefined)}
/>
</div>
<div className="text-vscode-descriptionForeground text-sm mt-1">
<Trans i18nKey="settings:browser.enable.description">
<VSCodeLink
@ -139,7 +147,14 @@ export const BrowserSettings = ({
settingId="browser-viewport"
section="browser"
label={t("settings:browser.viewport.label")}>
<label className="block font-medium mb-1">{t("settings:browser.viewport.label")}</label>
<div className="flex items-center gap-1 mb-1">
<label className="block font-medium">{t("settings:browser.viewport.label")}</label>
<ResetToDefault
settingKey="browserViewportSize"
currentValue={browserViewportSize}
onReset={() => setCachedStateField("browserViewportSize", undefined)}
/>
</div>
<Select
value={browserViewportSize}
onValueChange={(value) => setCachedStateField("browserViewportSize", value)}>
@ -165,9 +180,16 @@ export const BrowserSettings = ({
settingId="browser-screenshot-quality"
section="browser"
label={t("settings:browser.screenshotQuality.label")}>
<label className="block font-medium mb-1">
{t("settings:browser.screenshotQuality.label")}
</label>
<div className="flex items-center gap-1 mb-1">
<label className="block font-medium">
{t("settings:browser.screenshotQuality.label")}
</label>
<ResetToDefault
settingKey="screenshotQuality"
currentValue={screenshotQuality}
onReset={() => setCachedStateField("screenshotQuality", undefined)}
/>
</div>
<div className="flex items-center gap-2">
<Slider
min={1}

View file

@ -0,0 +1,80 @@
import { RotateCcw } from "lucide-react"
import { settingDefaults, type SettingWithDefault } from "@roo-code/types"
import { Button, StandardTooltip } from "@/components/ui"
import { useAppTranslation } from "@/i18n/TranslationContext"
// Widen literal types to their base types for comparison
type WidenType<T> = T extends boolean ? boolean : T extends number ? number : T extends string ? string : T
interface ResetToDefaultProps<K extends SettingWithDefault> {
/** The setting key from settingDefaults */
settingKey: K
/** The current value of the setting (accepts wider types for flexibility) */
currentValue: WidenType<(typeof settingDefaults)[K]> | undefined
/** Callback to reset the value (called with undefined to reset) */
onReset: () => void
/** Optional className for the button */
className?: string
}
/**
* A small reset button that appears only when a setting differs from its default.
* Shows a icon with a tooltip displaying the default value.
*
* @example
* <ResetToDefault
* settingKey="browserToolEnabled"
* currentValue={browserToolEnabled}
* onReset={() => setCachedStateField("browserToolEnabled", undefined)}
* />
*/
export function ResetToDefault<K extends SettingWithDefault>({
settingKey,
currentValue,
onReset,
className,
}: ResetToDefaultProps<K>) {
const { t } = useAppTranslation()
const defaultValue = settingDefaults[settingKey]
// Don't show the button if the current value matches the default
// undefined is treated as "using default"
const isDefault = currentValue === undefined || currentValue === defaultValue
if (isDefault) {
return null
}
// Format the default value for display in the tooltip
const formatDefaultValue = (value: unknown): string => {
if (typeof value === "boolean") {
return value ? t("settings:common.true") : t("settings:common.false")
}
if (typeof value === "number") {
return String(value)
}
if (typeof value === "string") {
return value || t("settings:common.empty")
}
return JSON.stringify(value)
}
const tooltipContent = t("settings:common.resetToDefault", {
defaultValue: formatDefaultValue(defaultValue),
})
return (
<StandardTooltip content={tooltipContent}>
<Button
variant="ghost"
size="icon"
className={className ?? "h-6 w-6 p-1"}
onClick={onReset}
data-testid={`reset-${settingKey}`}>
<RotateCcw className="h-3.5 w-3.5 text-vscode-descriptionForeground hover:text-vscode-foreground" />
</Button>
</StandardTooltip>
)
}

View file

@ -0,0 +1,114 @@
import { render, screen, fireEvent } from "@testing-library/react"
import { ReactNode } from "react"
import { TooltipProvider } from "@/components/ui"
import { ResetToDefault } from "../ResetToDefault"
// Mock the translation hook
vi.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({
t: (key: string, options?: { defaultValue?: string }) =>
options?.defaultValue ? `${key} (${JSON.stringify(options)})` : key,
}),
}))
// Wrapper component to provide TooltipProvider context
const TestWrapper = ({ children }: { children: ReactNode }) => <TooltipProvider>{children}</TooltipProvider>
const renderWithWrapper = (ui: ReactNode) => render(ui, { wrapper: TestWrapper })
describe("ResetToDefault", () => {
const mockOnReset = vi.fn()
beforeEach(() => {
mockOnReset.mockClear()
})
describe("visibility", () => {
it("should not render when currentValue matches default", () => {
// browserToolEnabled defaults to true in settingDefaults
const { container } = render(
<ResetToDefault settingKey="browserToolEnabled" currentValue={true} onReset={mockOnReset} />,
)
expect(container.firstChild).toBeNull()
})
it("should not render when currentValue is undefined (using default)", () => {
const { container } = render(
<ResetToDefault settingKey="browserToolEnabled" currentValue={undefined} onReset={mockOnReset} />,
)
expect(container.firstChild).toBeNull()
})
it("should render when currentValue differs from default (boolean)", () => {
// browserToolEnabled defaults to true, so false should show reset button
renderWithWrapper(
<ResetToDefault settingKey="browserToolEnabled" currentValue={false} onReset={mockOnReset} />,
)
expect(screen.getByTestId("reset-browserToolEnabled")).toBeInTheDocument()
})
it("should render when currentValue differs from default (number)", () => {
// screenshotQuality defaults to 75
renderWithWrapper(<ResetToDefault settingKey="screenshotQuality" currentValue={50} onReset={mockOnReset} />)
expect(screen.getByTestId("reset-screenshotQuality")).toBeInTheDocument()
})
it("should render when currentValue differs from default (string)", () => {
// browserViewportSize defaults to "900x600"
renderWithWrapper(
<ResetToDefault settingKey="browserViewportSize" currentValue="1280x800" onReset={mockOnReset} />,
)
expect(screen.getByTestId("reset-browserViewportSize")).toBeInTheDocument()
})
})
describe("functionality", () => {
it("should call onReset when clicked", () => {
renderWithWrapper(
<ResetToDefault settingKey="browserToolEnabled" currentValue={false} onReset={mockOnReset} />,
)
fireEvent.click(screen.getByTestId("reset-browserToolEnabled"))
expect(mockOnReset).toHaveBeenCalledTimes(1)
})
it("should have the correct aria role (button)", () => {
renderWithWrapper(<ResetToDefault settingKey="screenshotQuality" currentValue={50} onReset={mockOnReset} />)
expect(screen.getByRole("button")).toBeInTheDocument()
})
})
describe("custom className", () => {
it("should apply custom className when provided", () => {
renderWithWrapper(
<ResetToDefault
settingKey="browserToolEnabled"
currentValue={false}
onReset={mockOnReset}
className="custom-class"
/>,
)
const button = screen.getByTestId("reset-browserToolEnabled")
expect(button).toHaveClass("custom-class")
})
it("should use default className when not provided", () => {
renderWithWrapper(
<ResetToDefault settingKey="browserToolEnabled" currentValue={false} onReset={mockOnReset} />,
)
const button = screen.getByTestId("reset-browserToolEnabled")
expect(button).toHaveClass("h-6", "w-6", "p-1")
})
})
})

View file

@ -7,7 +7,11 @@
"reset": "Reset",
"select": "Select",
"add": "Add Header",
"remove": "Remove"
"remove": "Remove",
"true": "true",
"false": "false",
"empty": "(empty)",
"resetToDefault": "Reset to default ({{defaultValue}})"
},
"header": {
"title": "Settings",