Search for buttons, visual tweaks

This commit is contained in:
Bruno Bergher 2026-01-10 23:17:21 +00:00
parent 60cb90230d
commit 07ab2c1295
28 changed files with 271 additions and 161 deletions

View file

@ -35,7 +35,7 @@ const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {
hideAnnouncement()
}
}}>
<DialogContent className="max-w-96">
<DialogContent>
<DialogHeader>
<DialogTitle>{t("chat:announcement.title", { version: Package.version })}</DialogTitle>
</DialogHeader>

View file

@ -88,8 +88,10 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
variant="ghost"
className="px-1.5 -ml-2"
onClick={onDone}
aria-label={t("settings:back")}>
aria-label={t("history:done")}
data-testid="history-done-button">
<ArrowLeft />
<span className="sr-only">{t("history:done")}</span>
</Button>
<h3 className="text-vscode-foreground m-0">{t("history:history")}</h3>
</div>

View file

@ -128,12 +128,12 @@ export function MarketplaceView({ stateManager, onDone, targetTab }: Marketplace
/>
</div>
<button
className="flex items-center justify-center gap-2 flex-1 text-sm font-medium rounded-sm transition-colors duration-300 relative z-10 text-vscode-foreground"
className="cursor-pointer flex items-center justify-center gap-2 flex-1 text-sm font-medium rounded-sm transition-colors duration-300 relative z-10 text-vscode-foreground"
onClick={() => manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "mcp" } })}>
MCP
</button>
<button
className="flex items-center justify-center gap-2 flex-1 text-sm font-medium rounded-sm transition-colors duration-300 relative z-10 text-vscode-foreground"
className="cursor-pointer flex items-center justify-center gap-2 flex-1 text-sm font-medium rounded-sm transition-colors duration-300 relative z-10 text-vscode-foreground"
onClick={() =>
manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "mode" } })
}>

View file

@ -91,7 +91,7 @@ export const MarketplaceItemCard: React.FC<MarketplaceItemCardProps> = ({ item,
return (
<>
<div className="border border-vscode-panel-border rounded-sm p-3 bg-vscode-editor-background">
<div className="border border-vscode-panel-border rounded-xl cursor-default p-3 transition-colors bg-vscode-editor-background hover:bg-vscode-editor-foreground/5">
<div className="flex gap-2 items-start justify-between">
<div className="flex gap-2 items-start">
<div>

View file

@ -7,7 +7,6 @@ import {
VSCodePanelTab,
VSCodePanelView,
} from "@vscode/webview-ui-toolkit/react"
import { Webhook } from "lucide-react"
import { McpServer } from "@roo/mcp"
@ -49,7 +48,6 @@ const McpView = () => {
<div>
<SectionHeader>
<div className="flex items-center gap-2">
<Webhook className="w-4" />
<div>{t("mcp:title")}</div>
</div>
</SectionHeader>

View file

@ -8,7 +8,7 @@ import {
VSCodeTextField,
} from "@vscode/webview-ui-toolkit/react"
import { Trans } from "react-i18next"
import { ChevronDown, X, Upload, Download, MessageSquare } from "lucide-react"
import { ChevronDown, X, Upload, Download } from "lucide-react"
import { ModeConfig, GroupEntry, PromptComponent, ToolGroup, modeConfigSchema } from "@roo-code/types"
@ -29,7 +29,6 @@ import { buildDocLink } from "@src/utils/docLinks"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { Section } from "@src/components/settings/Section"
import { SectionHeader } from "@src/components/settings/SectionHeader"
import {
Button,
Select,
@ -593,16 +592,9 @@ const ModesView = () => {
return (
<div>
<SectionHeader>
<div className="flex items-center gap-2">
<MessageSquare className="w-4" />
<div>{t("prompts:title")}</div>
</div>
</SectionHeader>
<Section>
<div>
<div onClick={(e) => e.stopPropagation()} className="flex justify-between items-center mb-3">
<div onClick={(e) => e.stopPropagation()} className="flex justify-between items-center mt-1 mb-3">
<h3 className="text-vscode-foreground m-0">{t("prompts:modes.title")}</h3>
<div className="flex gap-2">
<div className="relative inline-block">

View file

@ -1,17 +1,7 @@
import { HTMLAttributes } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Trans } from "react-i18next"
import {
Info,
Download,
Upload,
TriangleAlert,
Bug,
Lightbulb,
Shield,
MessageCircle,
MessagesSquare,
} from "lucide-react"
import { Download, Upload, TriangleAlert, Bug, Lightbulb, Shield, MessageCircle, MessagesSquare } from "lucide-react"
import { VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import type { TelemetrySetting } from "@roo-code/types"
@ -35,19 +25,14 @@ export const About = ({ telemetrySetting, setTelemetrySetting, className, ...pro
return (
<div className={cn("flex flex-col gap-2", className)} {...props}>
<SectionHeader
description={
Package.sha
? `Version: ${Package.version} (${Package.sha.slice(0, 8)})`
: `Version: ${Package.version}`
}>
<div className="flex items-center gap-2">
<Info className="w-4" />
<div>{t("settings:sections.about")}</div>
</div>
</SectionHeader>
<SectionHeader>{t("settings:sections.about")}</SectionHeader>
<Section>
<p>
{Package.sha
? `Version: ${Package.version} (${Package.sha.slice(0, 8)})`
: `Version: ${Package.version}`}
</p>
<div>
<VSCodeCheckbox
checked={telemetrySetting !== "disabled"}

View file

@ -1,5 +1,5 @@
import { HTMLAttributes, useState } from "react"
import { X, CheckCheck } from "lucide-react"
import { X } from "lucide-react"
import { Trans } from "react-i18next"
import { Package } from "@roo/package"
@ -107,12 +107,7 @@ export const AutoApproveSettings = ({
return (
<div {...props}>
<SectionHeader>
<div className="flex items-center gap-2">
<CheckCheck className="w-4 h-4" />
<div>{t("settings:sections.autoApprove")}</div>
</div>
</SectionHeader>
<SectionHeader>{t("settings:sections.autoApprove")}</SectionHeader>
<Section>
<div className="space-y-4">

View file

@ -1,5 +1,4 @@
import { VSCodeCheckbox, VSCodeTextField, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { SquareMousePointer } from "lucide-react"
import { HTMLAttributes, useEffect, useMemo, useState } from "react"
import { Trans } from "react-i18next"
@ -108,12 +107,7 @@ export const BrowserSettings = ({
return (
<div {...props}>
<SectionHeader>
<div className="flex items-center gap-2">
<SquareMousePointer className="w-4" />
<div>{t("settings:sections.browser")}</div>
</div>
</SectionHeader>
<SectionHeader>{t("settings:sections.browser")}</SectionHeader>
<Section>
<div data-setting-id="browser.enable">

View file

@ -1,7 +1,6 @@
import { HTMLAttributes } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { GitBranch } from "lucide-react"
import { Trans } from "react-i18next"
import { buildDocLink } from "@src/utils/docLinks"
import { Slider } from "@/components/ui"
@ -30,12 +29,7 @@ export const CheckpointSettings = ({
const { t } = useAppTranslation()
return (
<div {...props}>
<SectionHeader>
<div className="flex items-center gap-2">
<GitBranch className="w-4" />
<div>{t("settings:sections.checkpoints")}</div>
</div>
</SectionHeader>
<SectionHeader>{t("settings:sections.checkpoints")}</SectionHeader>
<Section>
<div data-setting-id="checkpoints.enable">

View file

@ -2,7 +2,7 @@ import { HTMLAttributes } from "react"
import React from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { Database, FoldVertical } from "lucide-react"
import { FoldVertical } from "lucide-react"
import { cn } from "@/lib/utils"
import { Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Slider, Button } from "@/components/ui"
@ -106,14 +106,12 @@ export const ContextManagementSettings = ({
}
return (
<div className={cn("flex flex-col gap-2", className)} {...props}>
<SectionHeader description={t("settings:contextManagement.description")}>
<div className="flex items-center gap-2">
<Database className="w-4" />
<div>{t("settings:sections.contextManagement")}</div>
</div>
</SectionHeader>
<SectionHeader>{t("settings:sections.contextManagement")}</SectionHeader>
<Section>
<p className="text-sm text-vscode-descriptionForeground">
{t("settings:contextManagement.description")}
</p>
<div data-setting-id="contextManagement.openTabs">
<span className="block font-medium mb-1">{t("settings:contextManagement.openTabs.label")}</span>
<div className="flex items-center gap-2">

View file

@ -1,5 +1,4 @@
import { HTMLAttributes } from "react"
import { FlaskConical } from "lucide-react"
import type { Experiments, ImageGenerationProvider } from "@roo-code/types"
@ -46,12 +45,7 @@ export const ExperimentalSettings = ({
return (
<div className={cn("flex flex-col gap-2", className)} {...props}>
<SectionHeader>
<div className="flex items-center gap-2">
<FlaskConical className="w-4" />
<div>{t("settings:sections.experimental")}</div>
</div>
</SectionHeader>
<SectionHeader>{t("settings:sections.experimental")}</SectionHeader>
<Section>
{Object.entries(experimentConfigsMap)

View file

@ -1,6 +1,5 @@
import { HTMLAttributes } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Globe } from "lucide-react"
import type { Language } from "@roo-code/types"
@ -23,12 +22,7 @@ export const LanguageSettings = ({ language, setCachedStateField, className, ...
return (
<div className={cn("flex flex-col gap-2", className)} {...props}>
<SectionHeader>
<div className="flex items-center gap-2">
<Globe className="w-4" />
<div>{t("settings:sections.language")}</div>
</div>
</SectionHeader>
<SectionHeader>{t("settings:sections.language")}</SectionHeader>
<Section>
<div data-setting-id="language">

View file

@ -1,7 +1,6 @@
import { HTMLAttributes } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { Bell } from "lucide-react"
import { SetCachedStateField } from "./types"
import { SectionHeader } from "./SectionHeader"
@ -27,12 +26,7 @@ export const NotificationSettings = ({
const { t } = useAppTranslation()
return (
<div {...props}>
<SectionHeader>
<div className="flex items-center gap-2">
<Bell className="w-4" />
<div>{t("settings:sections.notifications")}</div>
</div>
</SectionHeader>
<SectionHeader>{t("settings:sections.notifications")}</SectionHeader>
<Section>
<div data-setting-id="notifications.tts">

View file

@ -1,6 +1,5 @@
import { useState, useEffect, FormEvent } from "react"
import { VSCodeTextArea, VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { MessageSquare } from "lucide-react"
import { supportPrompt, SupportPromptType } from "@roo/support-prompt"
@ -137,14 +136,10 @@ const PromptsSettings = ({
return (
<div>
<SectionHeader description={t("settings:prompts.description")}>
<div className="flex items-center gap-2">
<MessageSquare className="w-4" />
<div>{t("settings:sections.prompts")}</div>
</div>
</SectionHeader>
<SectionHeader>{t("settings:sections.prompts")}</SectionHeader>
<Section>
<p className="text-sm text-vscode-descriptionForeground">{t("settings:prompts.description")}</p>
<div>
<Select
value={activeSupportOption}

View file

@ -10,12 +10,9 @@ type SectionHeaderProps = HTMLAttributes<HTMLDivElement> & {
export const SectionHeader = ({ description, children, className, ...props }: SectionHeaderProps) => {
return (
<div
className={cn(
"sticky top-0 z-10 text-vscode-sideBar-foreground bg-vscode-sideBar-background brightness-90 px-5 py-4",
className,
)}
className={cn("sticky top-0 z-10 text-vscode-foreground bg-vscode-sideBar-background px-5 py-4", className)}
{...props}>
<h4 className="m-0">{children}</h4>
<h3 className="m-0">{children}</h3>
{description && <p className="text-vscode-descriptionForeground text-sm mt-2 mb-0">{description}</p>}
</div>
)

View file

@ -78,14 +78,14 @@ export function SettingsSearchResults({
// If no results, show a message
if (results.length === 0) {
return (
<div className="max-h-80 overflow-y-auto p-4 bg-vscode-sideBar-background text-vscode-descriptionForeground text-sm">
<div className="max-h-80 overflow-y-auto p-4 text-vscode-descriptionForeground text-sm">
{t("settings:search.noResults", { query })}
</div>
)
}
return (
<div className="max-h-80 overflow-y-auto bg-vscode-sideBar-background" role="listbox">
<div className="max-h-80 overflow-y-auto" role="listbox">
{Object.entries(groupedResults).map(([tab, tabResults]) => {
const Icon = sectionIconMap.get(tab as SectionName)

View file

@ -12,7 +12,6 @@ import React, {
import {
CheckCheck,
SquareMousePointer,
Webhook,
GitBranch,
Bell,
Database,
@ -675,6 +674,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
<StandardTooltip content={t("settings:header.doneButtonTooltip")}>
<Button variant="ghost" className="px-1.5 -ml-2" onClick={() => checkUnsaveChanges(onDone)}>
<ArrowLeft />
<span className="sr-only">{t("settings:common.done")}</span>
</Button>
</StandardTooltip>
<h3 className="text-vscode-foreground m-0 flex-shrink-0">{t("settings:header.title")}</h3>
@ -689,7 +689,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
inputRef={searchInputRef}
/>
{searchQuery && isSearchFocused && (
<div className="absolute top-full w-full min-w-50 right-0 mt-1 bg-vscode-dropdown-background border border-vscode-dropdown-border rounded shadow-lg z-50">
<div className="absolute top-full w-full min-w-50 right-0 mt-1 border border-vscode-dropdown-border bg-vscode-sideBar-background rounded-xl overflow-clip shadow-lg z-50">
<SettingsSearchResults
results={searchResults}
query={searchQuery}
@ -784,12 +784,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
{/* Providers Section */}
{activeTab === "providers" && (
<div>
<SectionHeader>
<div className="flex items-center gap-2">
<Webhook className="w-4" />
<div>{t("settings:sections.providers")}</div>
</div>
</SectionHeader>
<SectionHeader>{t("settings:sections.providers")}</SectionHeader>
<Section>
<ApiConfigManager

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect } from "react"
import { Plus, Globe, Folder, Settings, SquareSlash } from "lucide-react"
import { Plus, Globe, Folder, Settings } from "lucide-react"
import { Trans } from "react-i18next"
import type { Command } from "@roo/ExtensionMessage"
@ -102,12 +102,7 @@ export const SlashCommandsSettings: React.FC = () => {
return (
<div>
<SectionHeader>
<div className="flex items-center gap-2">
<SquareSlash className="w-4" />
<div>{t("settings:sections.slashCommands")}</div>
</div>
</SectionHeader>
<SectionHeader>{t("settings:sections.slashCommands")}</SectionHeader>
<Section>
{/* Description section */}

View file

@ -1,7 +1,6 @@
import { HTMLAttributes, useState, useCallback } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { vscode } from "@/utils/vscode"
import { SquareTerminal } from "lucide-react"
import { VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { Trans } from "react-i18next"
import { buildDocLink } from "@src/utils/docLinks"
@ -87,12 +86,7 @@ export const TerminalSettings = ({
return (
<div className={cn("flex flex-col", className)} {...props}>
<SectionHeader>
<div className="flex items-center gap-2">
<SquareTerminal className="w-4" />
<div>{t("settings:sections.terminal")}</div>
</div>
</SectionHeader>
<SectionHeader>{t("settings:sections.terminal")}</SectionHeader>
<Section>
{/* Basic Settings */}

View file

@ -1,7 +1,6 @@
import { HTMLAttributes, useMemo } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { Glasses } from "lucide-react"
import { telemetryClient } from "@/utils/TelemetryClient"
import { SetCachedStateField } from "./types"
@ -50,12 +49,7 @@ export const UISettings = ({
return (
<div {...props}>
<SectionHeader>
<div className="flex items-center gap-2">
<Glasses className="w-4" />
<div>{t("settings:sections.ui")}</div>
</div>
</SectionHeader>
<SectionHeader>{t("settings:sections.ui")}</SectionHeader>
<Section>
<div className="space-y-6">

View file

@ -109,8 +109,8 @@ describe("SettingsSearchResults", () => {
/>,
)
const buttons = screen.queryAllByRole("button")
expect(buttons).toHaveLength(0)
const options = screen.queryAllByRole("option")
expect(options).toHaveLength(0)
})
})
@ -254,8 +254,8 @@ describe("SettingsSearchResults", () => {
/>,
)
// Label should be present - check that button exists
const checkpointButton = screen.getByRole("button")
// Label should be present - check that option exists
const checkpointButton = screen.getByRole("option")
expect(checkpointButton).toBeInTheDocument()
expect(checkpointButton.textContent).toContain("Checkpoint timeout")
@ -264,7 +264,7 @@ describe("SettingsSearchResults", () => {
expect(descriptionElements).toHaveLength(0)
})
it("should render results as clickable buttons", () => {
it("should render results as clickable listbox options", () => {
const onSelectResult = vi.fn()
render(
<SettingsSearchResults
@ -275,8 +275,8 @@ describe("SettingsSearchResults", () => {
/>,
)
const buttons = screen.getAllByRole("button")
expect(buttons.length).toBe(mockBrowserResults.length)
const options = screen.getAllByRole("option")
expect(options.length).toBe(mockBrowserResults.length)
})
})
@ -292,9 +292,9 @@ describe("SettingsSearchResults", () => {
/>,
)
// Click the first button (the result item itself is a button)
const buttons = screen.getAllByRole("button")
fireEvent.click(buttons[0])
// Click the first option (the result item itself is a button with role option)
const options = screen.getAllByRole("option")
fireEvent.click(options[0])
expect(onSelectResult).toHaveBeenCalledTimes(1)
expect(onSelectResult).toHaveBeenCalledWith(mockBrowserResults[0])
@ -311,14 +311,14 @@ describe("SettingsSearchResults", () => {
/>,
)
const buttons = screen.getAllByRole("button")
const options = screen.getAllByRole("option")
// Click first result
fireEvent.click(buttons[0])
fireEvent.click(options[0])
expect(onSelectResult).toHaveBeenLastCalledWith(mockBrowserResults[0])
// Click second result
fireEvent.click(buttons[1])
fireEvent.click(options[1])
expect(onSelectResult).toHaveBeenLastCalledWith(mockBrowserResults[1])
expect(onSelectResult).toHaveBeenCalledTimes(2)
@ -429,8 +429,8 @@ describe("SettingsSearchResults", () => {
expect(screen.getByText("Checkpoint timeout")).toBeInTheDocument()
// Should have correct number of clickable results
const buttons = screen.getAllByRole("button")
expect(buttons).toHaveLength(allResults.length)
const options = screen.getAllByRole("option")
expect(options).toHaveLength(allResults.length)
})
})
})

View file

@ -36,7 +36,7 @@ function AlertDialogContent({ className, ...props }: React.ComponentProps<typeof
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-vscode-editor-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-3 rounded-sm border border-vscode-panel-border p-4 shadow-lg duration-200 sm:max-w-md",
"bg-vscode-editor-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-4rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border p-4 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
@ -51,11 +51,7 @@ function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">)
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn("flex flex-row justify-end gap-2 mt-4", className)}
{...props}
/>
<div data-slot="alert-dialog-footer" className={cn("flex flex-row justify-end gap-2", className)} {...props} />
)
}
@ -63,10 +59,7 @@ function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof A
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-base font-medium text-vscode-editor-foreground flex items-center gap-2 text-left",
className,
)}
className={cn("text-[1.25em] leading-none font-semibold mt-0.5 mb-1.5 cursor-default", className)}
{...props}
/>
)
@ -79,7 +72,7 @@ function AlertDialogDescription({
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-vscode-descriptionForeground text-sm text-left", className)}
className={cn("text-vscode-descriptionForeground text-md text-left", className)}
{...props}
/>
)

View file

@ -40,7 +40,7 @@ function DialogContent({ className, children, ...props }: React.ComponentProps<t
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-vscode-editor-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"bg-vscode-editor-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-4rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border p-4 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}>
@ -78,7 +78,7 @@ function DialogTitle({ className, ...props }: React.ComponentProps<typeof Dialog
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold my-0 cursor-default", className)}
className={cn("text-[1.25em] leading-none font-semibold mt-0.5 mb-1.5 cursor-default", className)}
{...props}
/>
)

View file

@ -30,6 +30,10 @@ vi.mock("@/i18n/locales/en/settings.json", () => ({
label: "Browser viewport",
description: "Configure the browser window size",
},
remote: {
label: "Use remote browser",
testButton: "Test Connection",
},
},
notifications: {
sound: {
@ -42,6 +46,13 @@ vi.mock("@/i18n/locales/en/settings.json", () => ({
label: "Checkpoint timeout",
},
},
footer: {
settings: {
import: "Import settings",
export: "Export settings",
reset: "Reset settings",
},
},
},
}))
@ -57,9 +68,14 @@ describe("useSettingsSearch", () => {
"settings:browser.enable.description": "Allows Roo to use a browser",
"settings:browser.viewport.label": "Browser viewport",
"settings:browser.viewport.description": "Configure the browser window size",
"settings:browser.remote.label": "Use remote browser",
"settings:browser.remote.testButton": "Test Connection",
"settings:notifications.sound.label": "Sound effects",
"settings:notifications.sound.description": "Play sound when Roo needs attention",
"settings:checkpoints.timeout.label": "Checkpoint timeout",
"settings:footer.settings.import": "Import settings",
"settings:footer.settings.export": "Export settings",
"settings:footer.settings.reset": "Reset settings",
}
const mockT = (key: string) => mockTranslations[key] || key
@ -208,6 +224,23 @@ describe("useSettingsSearch", () => {
}
})
it("should calculate matchScore when matching extra text like buttons", () => {
const { result } = renderHook(() => useSettingsSearch("test connection"))
const buttonResult = result.current.find((r) => r.id === "browser.remote")
expect(buttonResult).toBeDefined()
expect(buttonResult?.matchScore).toBe(4) // extra match only
})
it("should return standalone footer strings with label match score", () => {
const { result } = renderHook(() => useSettingsSearch("export"))
const exportResult = result.current.find((r) => r.id === "footer.settings.export")
expect(exportResult).toBeDefined()
expect(exportResult?.tab).toBe("about")
expect(exportResult?.matchScore).toBe(10)
})
it("should have higher matchScore for label+description than description only", () => {
const { result } = renderHook(() => useSettingsSearch("browser"))

View file

@ -11,6 +11,8 @@ export interface SearchResult extends ParsedSetting {
translatedLabel: string
/** Translated description for the setting (if available) */
translatedDescription?: string
/** Translated extra texts (e.g., button labels) for the setting */
translatedExtraTexts?: string[]
/** Match score for sorting results (higher = better match) */
matchScore: number
}
@ -52,15 +54,19 @@ export function useSettingsSearch(query: string): SearchResult[] {
const translatedLabel = t(setting.labelKey)
// Get translated description if it exists
const translatedDescription = setting.descriptionKey ? t(setting.descriptionKey) : undefined
// Get translated extra texts (e.g., button labels) if they exist
const translatedExtraTexts = setting.extraTextKeys?.map((key) => t(key)).filter(Boolean)
// Check for matches (case-insensitive)
const labelMatch = translatedLabel.toLowerCase().includes(normalizedQuery)
const descriptionMatch = translatedDescription
? translatedDescription.toLowerCase().includes(normalizedQuery)
: false
const extraMatch =
translatedExtraTexts?.some((text) => text.toLowerCase().includes(normalizedQuery)) ?? false
// If no match, return null
if (!labelMatch && !descriptionMatch) {
if (!labelMatch && !descriptionMatch && !extraMatch) {
return null
}
@ -72,11 +78,15 @@ export function useSettingsSearch(query: string): SearchResult[] {
if (descriptionMatch) {
matchScore += 5
}
if (extraMatch) {
matchScore += 4
}
return {
...setting,
translatedLabel,
translatedDescription,
translatedExtraTexts,
matchScore,
}
})

View file

@ -91,6 +91,64 @@ describe("parseSettingsI18nKeys", () => {
descriptionKey: "settings:autoApprove.readOnly.outsideWorkspace.description",
})
})
it("should collect extra searchable text keys like button labels", () => {
const translations = {
browser: {
remote: {
label: "Use remote browser",
testButton: "Test Connection",
},
},
}
const results = parseSettingsI18nKeys(translations)
expect(results).toContainEqual({
id: "browser.remote",
tab: "browser",
labelKey: "settings:browser.remote.label",
descriptionKey: undefined,
extraTextKeys: ["settings:browser.remote.testButton"],
})
})
it("should create standalone entries for footer settings string leaves in about tab", () => {
const translations = {
footer: {
settings: {
import: "Import settings",
export: "Export settings",
reset: "Reset settings",
},
},
}
const results = parseSettingsI18nKeys(translations)
expect(results).toEqual(
expect.arrayContaining([
{
id: "footer.settings.import",
tab: "about",
labelKey: "settings:footer.settings.import",
descriptionKey: undefined,
},
{
id: "footer.settings.export",
tab: "about",
labelKey: "settings:footer.settings.export",
descriptionKey: undefined,
},
{
id: "footer.settings.reset",
tab: "about",
labelKey: "settings:footer.settings.reset",
descriptionKey: undefined,
},
]),
)
})
})
describe("special tab entries", () => {

View file

@ -42,6 +42,8 @@ export interface ParsedSetting {
labelKey: string
/** i18n key for the description (optional), e.g., 'settings:browser.enable.description' */
descriptionKey?: string
/** Additional i18n keys within the same setting (e.g., button labels) to include in search */
extraTextKeys?: string[]
}
/**
@ -117,6 +119,7 @@ const sectionToTabMapping: Record<string, SectionName | undefined> = {
// Additional mappings for nested sections that should map to specific tabs
advanced: "providers", // advanced settings are part of providers tab
codeIndex: "experimental", // codebase indexing is in experimental
footer: "about", // footer controls live in About tab
}
/**
@ -138,6 +141,87 @@ function isSettingObject(obj: Record<string, unknown>): boolean {
return typeof obj.label === "string"
}
/**
* Collects additional string keys within a setting that should be searchable (e.g., button labels).
* Skips nested setting objects (those with their own labels) so keys stay scoped to the current setting.
*/
function collectSearchableTextKeys(obj: Record<string, unknown>, path: string[], namespace: string): string[] {
const collected: string[] = []
const walk = (current: Record<string, unknown>, currentPath: string[]) => {
for (const [key, value] of Object.entries(current)) {
if (key === "label" || key === "description") {
continue
}
if (isPlainObject(value)) {
// If this nested object represents its own setting, don't collect from it here
if (isSettingObject(value)) {
continue
}
walk(value, [...currentPath, key])
continue
}
if (typeof value === "string") {
collected.push(`${namespace}:${[...currentPath, key].join(".")}`)
}
}
}
walk(obj, path)
return collected
}
/**
* Roots that should generate standalone searchable entries for string leaves, even when they are
* not full "settings" objects (i.e., they lack a label/description). This helps surface buttons
* like Import/Export/Reset in the About tab's footer controls.
*/
const standaloneStringRoots: Record<string, SectionName> = {
"footer.settings": "about",
}
function collectStandaloneStringEntries(
obj: unknown,
basePath: string[],
namespace: string,
tab: SectionName,
existingIds: Set<string>,
results: ParsedSetting[],
): void {
const walk = (value: unknown, currentPath: string[]) => {
if (typeof value === "string") {
const id = currentPath.join(".")
if (!existingIds.has(id)) {
results.push({
id,
tab,
labelKey: `${namespace}:${id}`,
descriptionKey: undefined,
})
existingIds.add(id)
}
return
}
if (!isPlainObject(value)) {
return
}
// Don't recurse into nested setting objects to avoid duplicating their own entries
if (isSettingObject(value)) {
return
}
for (const [key, child] of Object.entries(value)) {
walk(child, [...currentPath, key])
}
}
walk(obj, basePath)
}
/**
* Gets the tab for a given section name.
*/
@ -193,6 +277,10 @@ function traverseTranslations(
tab,
labelKey,
descriptionKey,
...(() => {
const keys = collectSearchableTextKeys(obj, path, namespace)
return keys.length ? { extraTextKeys: keys } : {}
})(),
})
}
@ -267,7 +355,25 @@ export function parseSettingsI18nKeys(
traverseTranslations(sectionValue, [sectionKey], namespace, results, sectionKey)
}
// Collect tabs that already have settings from parsing
// Add standalone string leaves for specific roots (e.g., footer.settings.import/export/reset)
const existingIds = new Set(results.map((r) => r.id))
for (const [rootPath, tab] of Object.entries(standaloneStringRoots)) {
const parts = rootPath.split(".")
let current: unknown = translations
for (const part of parts) {
if (!isPlainObject(current) || !(part in current)) {
current = undefined
break
}
current = (current as Record<string, unknown>)[part]
}
if (current !== undefined) {
collectStandaloneStringEntries(current, parts, namespace, tab, existingIds, results)
}
}
// Collect tabs that already have settings from parsing (including standalone entries)
const tabsWithSettings = new Set(results.map((r) => r.tab))
// Add special tab entries for tabs that don't have any parsed settings