UI improvements

This commit is contained in:
Bruno Bergher 2026-01-10 09:44:11 +00:00
parent 461494a3cc
commit 89a94b885a
4 changed files with 363 additions and 25 deletions

View file

@ -9,9 +9,10 @@ export interface SettingsSearchInputProps {
onChange: (value: string) => void
onFocus?: () => void
onBlur?: () => void
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
}
export function SettingsSearchInput({ value, onChange, onFocus, onBlur }: SettingsSearchInputProps) {
export function SettingsSearchInput({ value, onChange, onFocus, onBlur, onKeyDown }: SettingsSearchInputProps) {
const { t } = useAppTranslation()
return (
@ -24,10 +25,11 @@ export function SettingsSearchInput({ value, onChange, onFocus, onBlur }: Settin
onChange={(e) => onChange(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
placeholder={t("settings:search.placeholder")}
className={cn(
"pl-6 w-[0px] focus:pl-8 focus:min-w-[130px] focus:w-full active:w-full",
value && "pr-8",
"pl-6 w-[0px] border-none focus:border-vscode-input-border focus:pl-8 focus:min-w-[130px] focus:w-full",
value && "pr-4 min-w-[150px]",
)}
/>
{value && (

View file

@ -4,12 +4,14 @@ import type { LucideIcon } from "lucide-react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import type { SearchResult } from "@/hooks/useSettingsSearch"
import type { SectionName } from "@/utils/parseSettingsI18nKeys"
import { cn } from "@/lib/utils"
export interface SettingsSearchResultsProps {
results: SearchResult[]
query: string
onSelectResult: (result: SearchResult) => void
sections: { id: SectionName; icon: LucideIcon }[]
highlightedResultId?: string
}
interface HighlightMatchProps {
@ -44,7 +46,13 @@ function HighlightMatch({ text, query }: HighlightMatchProps) {
)
}
export function SettingsSearchResults({ results, query, onSelectResult, sections }: SettingsSearchResultsProps) {
export function SettingsSearchResults({
results,
query,
onSelectResult,
sections,
highlightedResultId,
}: SettingsSearchResultsProps) {
const { t } = useAppTranslation()
// Group results by tab
@ -70,42 +78,55 @@ export function SettingsSearchResults({ results, query, onSelectResult, sections
// If no results, show a message
if (results.length === 0) {
return (
<div className="max-h-80 overflow-y-auto p-4 text-vscode-descriptionForeground text-sm">
<div className="max-h-80 overflow-y-auto p-4 bg-vscode-sideBar-background text-vscode-descriptionForeground text-sm">
{t("settings:search.noResults", { query })}
</div>
)
}
return (
<div className="max-h-80 overflow-y-auto">
<div className="max-h-80 overflow-y-auto bg-vscode-sideBar-background" role="listbox">
{Object.entries(groupedResults).map(([tab, tabResults]) => {
const Icon = sectionIconMap.get(tab as SectionName)
return (
<div key={tab}>
{/* Tab header */}
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-vscode-descriptionForeground bg-vscode-sideBar-background sticky top-0">
<div className="flex items-center gap-2 px-3 py-1.5 mt-4 text-xs text-vscode-descriptionForeground bg-vscode-sideBar-background border-b-vscode-panel-border sticky top-0">
{Icon && <Icon className="h-3.5 w-3.5" />}
<span>{t(`settings:sections.${tab}`)}</span>
</div>
{/* Result items */}
{tabResults.map((result) => (
<button
key={result.id}
type="button"
onClick={() => onSelectResult(result)}
className="w-full text-left px-3 py-2 hover:bg-vscode-list-hoverBackground focus:bg-vscode-list-hoverBackground focus:outline-none">
<div className="text-sm text-vscode-foreground">
<HighlightMatch text={result.translatedLabel} query={query} />
</div>
{result.translatedDescription && (
<div className="text-xs text-vscode-descriptionForeground truncate mt-0.5">
<HighlightMatch text={result.translatedDescription} query={query} />
{tabResults.map((result) => {
const isHighlighted = highlightedResultId === result.id
const resultDomId = `settings-search-result-${result.id}`
return (
<button
key={result.id}
id={resultDomId}
type="button"
role="option"
aria-selected={isHighlighted}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onSelectResult(result)}
className={cn(
"w-full cursor-pointer text-left px-3 py-2 hover:bg-vscode-list-hoverBackground focus:bg-vscode-list-hoverBackground focus:outline-none",
isHighlighted &&
"bg-vscode-list-activeSelectionBackground text-vscode-foreground",
)}>
<div className="text-sm text-vscode-foreground">
<HighlightMatch text={result.translatedLabel} query={query} />
</div>
)}
</button>
))}
{result.translatedDescription && (
<div className="text-xs text-vscode-descriptionForeground truncate mt-0.5">
<HighlightMatch text={result.translatedDescription} query={query} />
</div>
)}
</button>
)
})}
</div>
)
})}

View file

@ -136,6 +136,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
)
const [searchQuery, setSearchQuery] = useState("")
const [isSearchFocused, setIsSearchFocused] = useState(false)
const [highlightedResultId, setHighlightedResultId] = useState<string | undefined>(undefined)
const scrollPositions = useRef<Record<SectionName, number>>(
Object.fromEntries(sectionNames.map((s) => [s, 0])) as Record<SectionName, number>,
@ -590,6 +591,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
(result: SearchResult) => {
setSearchQuery("")
setIsSearchFocused(false)
setHighlightedResultId(undefined)
handleTabChange(result.tab)
// Small delay to allow tab switch and render
setTimeout(() => scrollToSetting(result.id), 150)
@ -597,10 +599,76 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
[handleTabChange, scrollToSetting],
)
// Keyboard navigation inside search results
const moveHighlight = useCallback(
(direction: 1 | -1) => {
if (!searchResults.length) return
const flatIds = searchResults.map((r) => r.id)
const currentIndex = highlightedResultId ? flatIds.indexOf(highlightedResultId) : -1
const nextIndex = (currentIndex + direction + flatIds.length) % flatIds.length
setHighlightedResultId(flatIds[nextIndex])
},
[highlightedResultId, searchResults],
)
const handleSearchKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (!searchResults.length) return
if (event.key === "ArrowDown") {
event.preventDefault()
moveHighlight(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
moveHighlight(-1)
return
}
if (event.key === "Enter" && highlightedResultId) {
event.preventDefault()
const selected = searchResults.find((r) => r.id === highlightedResultId)
if (selected) {
handleSelectResult(selected)
}
return
}
if (event.key === "Escape") {
setIsSearchFocused(false)
setHighlightedResultId(undefined)
return
}
},
[handleSelectResult, highlightedResultId, moveHighlight, searchResults],
)
// Reset highlight based on focus and available results
useEffect(() => {
if (!isSearchFocused || !searchResults.length) {
setHighlightedResultId(undefined)
return
}
setHighlightedResultId((current) =>
current && searchResults.some((r) => r.id === current) ? current : searchResults[0]?.id,
)
}, [isSearchFocused, searchResults])
// Ensure highlighted search result stays visible within dropdown
useEffect(() => {
if (!highlightedResultId || !isSearchFocused) return
const element = document.getElementById(`settings-search-result-${highlightedResultId}`)
element?.scrollIntoView({ block: "nearest" })
}, [highlightedResultId, isSearchFocused])
return (
<Tab>
<TabHeader className="flex justify-between items-center gap-2">
<div className="flex items-center gap-2 grow">
<div className="flex items-center gap-2 grow truncate">
<StandardTooltip content={t("settings:header.doneButtonTooltip")}>
<Button variant="ghost" className="px-1.5 -ml-2" onClick={() => checkUnsaveChanges(onDone)}>
<ArrowLeft />
@ -614,19 +682,21 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
onChange={setSearchQuery}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setTimeout(() => setIsSearchFocused(false), 200)}
onKeyDown={handleSearchKeyDown}
/>
{searchQuery && isSearchFocused && (
<div className="absolute top-full left-0 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 bg-vscode-dropdown-background border border-vscode-dropdown-border rounded shadow-lg z-50">
<SettingsSearchResults
results={searchResults}
query={searchQuery}
onSelectResult={handleSelectResult}
sections={sections}
highlightedResultId={highlightedResultId}
/>
</div>
)}
</div>
<div className="flex gap-2 flex-shrink-0">
<div className="flex gap-2 shrink-0">
<StandardTooltip
content={
!isSettingValid

View file

@ -0,0 +1,245 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { vi, describe, it, beforeEach } from "vitest"
import SettingsView from "../SettingsView"
import type { SearchResult } from "@/hooks/useSettingsSearch"
import type { SectionName } from "@/utils/parseSettingsI18nKeys"
const mockUseExtensionState = vi.fn()
const mockUseSettingsSearch = vi.fn<(query: string) => SearchResult[]>()
// Minimal ResizeObserver polyfill for jsdom
class ResizeObserverPolyfill {
callback: ResizeObserverCallback
constructor(callback: ResizeObserverCallback) {
this.callback = callback
}
observe() {
// no-op
}
disconnect() {
// no-op
}
}
const defaultExtensionState = {
currentApiConfigName: "default",
listApiConfigMeta: [],
uriScheme: "vscode",
settingsImportedAt: undefined as number | undefined,
apiConfiguration: {},
alwaysAllowReadOnly: false,
alwaysAllowReadOnlyOutsideWorkspace: false,
allowedCommands: [] as string[],
deniedCommands: [] as string[],
allowedMaxRequests: undefined as number | undefined,
allowedMaxCost: undefined as number | undefined,
language: "en",
alwaysAllowBrowser: false,
alwaysAllowExecute: false,
alwaysAllowMcp: false,
alwaysAllowModeSwitch: false,
alwaysAllowSubtasks: false,
alwaysAllowWrite: false,
alwaysAllowWriteOutsideWorkspace: false,
alwaysAllowWriteProtected: false,
autoCondenseContext: false,
autoCondenseContextPercent: 50,
browserToolEnabled: true,
browserViewportSize: "900x600",
enableCheckpoints: false,
checkpointTimeout: 15,
diffEnabled: true,
experiments: {},
fuzzyMatchThreshold: 1,
maxOpenTabsContext: 20,
maxWorkspaceFiles: 200,
mcpEnabled: false,
remoteBrowserHost: "",
screenshotQuality: 75,
soundEnabled: false,
ttsEnabled: false,
ttsSpeed: 1,
soundVolume: 0.5,
telemetrySetting: "unset" as const,
terminalOutputLineLimit: 500,
terminalOutputCharacterLimit: 50000,
terminalShellIntegrationTimeout: 3000,
terminalShellIntegrationDisabled: false,
terminalCommandDelay: 0,
terminalPowershellCounter: false,
terminalZshClearEolMark: false,
terminalZshOhMy: false,
terminalZshP10k: false,
terminalZdotdir: false,
writeDelayMs: 0,
showRooIgnoredFiles: true,
enableSubfolderRules: false,
remoteBrowserEnabled: false,
maxReadFileLine: -1,
maxImageFileSize: 5,
maxTotalImageSize: 20,
terminalCompressProgressBar: false,
maxConcurrentFileReads: 5,
condensingApiConfigId: "",
customCondensingPrompt: "",
customSupportPrompts: {},
profileThresholds: {},
alwaysAllowFollowupQuestions: false,
followupAutoApproveTimeoutMs: undefined as number | undefined,
includeDiagnosticMessages: true,
maxDiagnosticMessages: 50,
includeTaskHistoryInEnhance: true,
imageGenerationProvider: "openrouter",
openRouterImageApiKey: "",
openRouterImageGenerationSelectedModel: "",
reasoningBlockCollapsed: true,
enterBehavior: "send" as const,
includeCurrentTime: true,
includeCurrentCost: true,
maxGitStatusFiles: 0,
}
const mockSearchResults: SearchResult[] = [
{
id: "browser.enable",
tab: "browser" as SectionName,
labelKey: "settings:browser.enable.label",
descriptionKey: "settings:browser.enable.description",
translatedLabel: "Enable browser tool",
translatedDescription: "Allows Roo to use a browser",
matchScore: 10,
},
{
id: "browser.viewport",
tab: "browser" as SectionName,
labelKey: "settings:browser.viewport.label",
descriptionKey: "settings:browser.viewport.description",
translatedLabel: "Browser viewport",
translatedDescription: "Configure viewport size",
matchScore: 9,
},
]
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: () => mockUseExtensionState(),
}))
vi.mock("@/hooks/useSettingsSearch", () => ({
useSettingsSearch: (query: string) => mockUseSettingsSearch(query),
}))
vi.mock("@/i18n/TranslationContext", () => ({
useAppTranslation: () => ({ t: (key: string) => key }),
}))
vi.mock("@/components/ui", () => ({
Button: ({ children, onClick, disabled, "data-testid": dataTestId }: any) => (
<button onClick={onClick} disabled={disabled} data-testid={dataTestId}>
{children}
</button>
),
StandardTooltip: ({ children }: any) => <>{children}</>,
Input: ({ value, onChange, onFocus, onBlur, onKeyDown, "data-testid": dataTestId }: any) => (
<input
value={value}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
data-testid={dataTestId}
/>
),
AlertDialog: ({ children }: any) => <div>{children}</div>,
AlertDialogContent: ({ children }: any) => <div>{children}</div>,
AlertDialogTitle: ({ children }: any) => <div>{children}</div>,
AlertDialogDescription: ({ children }: any) => <div>{children}</div>,
AlertDialogCancel: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
AlertDialogAction: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
AlertDialogHeader: ({ children }: any) => <div>{children}</div>,
AlertDialogFooter: ({ children }: any) => <div>{children}</div>,
Tooltip: ({ children }: any) => <>{children}</>,
TooltipContent: ({ children }: any) => <div>{children}</div>,
TooltipProvider: ({ children }: any) => <>{children}</>,
TooltipTrigger: ({ children, onClick }: any) => <div onClick={onClick}>{children}</div>,
}))
vi.mock("../../common/Tab", () => ({
Tab: ({ children }: any) => <div>{children}</div>,
TabHeader: ({ children }: any) => <div>{children}</div>,
TabContent: ({ children }: any) => <div>{children}</div>,
TabList: ({ children }: any) => <div>{children}</div>,
TabTrigger: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
}))
vi.mock("../ApiConfigManager", () => ({
__esModule: true,
default: () => <div>ApiConfigManager</div>,
}))
vi.mock("../ApiOptions", () => ({
__esModule: true,
default: () => <div>ApiOptions</div>,
}))
// Mock all settings subsections to inert components
vi.mock("../AutoApproveSettings", () => ({ AutoApproveSettings: () => <div>AutoApproveSettings</div> }))
vi.mock("../BrowserSettings", () => ({ BrowserSettings: () => <div>BrowserSettings</div> }))
vi.mock("../CheckpointSettings", () => ({ CheckpointSettings: () => <div>CheckpointSettings</div> }))
vi.mock("../NotificationSettings", () => ({ NotificationSettings: () => <div>NotificationSettings</div> }))
vi.mock("../ContextManagementSettings", () => ({
ContextManagementSettings: () => <div>ContextManagementSettings</div>,
}))
vi.mock("../TerminalSettings", () => ({ TerminalSettings: () => <div>TerminalSettings</div> }))
vi.mock("../ExperimentalSettings", () => ({ ExperimentalSettings: () => <div>ExperimentalSettings</div> }))
vi.mock("../LanguageSettings", () => ({ LanguageSettings: () => <div>LanguageSettings</div> }))
vi.mock("../About", () => ({ About: () => <div>About</div> }))
vi.mock("../PromptsSettings", () => ({ __esModule: true, default: () => <div>PromptsSettings</div> }))
vi.mock("../SlashCommandsSettings", () => ({ SlashCommandsSettings: () => <div>SlashCommandsSettings</div> }))
vi.mock("../UISettings", () => ({ UISettings: () => <div>UISettings</div> }))
describe("SettingsView search interactions", () => {
beforeEach(() => {
;(global as any).ResizeObserver = (global as any).ResizeObserver || ResizeObserverPolyfill
mockUseExtensionState.mockReturnValue(defaultExtensionState)
mockUseSettingsSearch.mockImplementation(() => mockSearchResults)
})
it("allows clicking a search result without closing before selection", async () => {
render(<SettingsView onDone={vi.fn()} />)
const input = screen.getByTestId("settings-search-input") as HTMLInputElement
fireEvent.focus(input)
fireEvent.change(input, { target: { value: "browser" } })
const listbox = await screen.findByRole("listbox")
expect(listbox).toBeInTheDocument()
const options = screen.getAllByRole("option")
fireEvent.mouseDown(options[0])
fireEvent.click(options[0])
expect(input.value).toBe("")
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
})
it("supports keyboard navigation and enter selection from search input", async () => {
render(<SettingsView onDone={vi.fn()} />)
const input = screen.getByTestId("settings-search-input") as HTMLInputElement
fireEvent.focus(input)
fireEvent.change(input, { target: { value: "browser" } })
await screen.findByRole("listbox")
let options = screen.getAllByRole("option")
expect(options[0]).toHaveAttribute("aria-selected", "true")
fireEvent.keyDown(input, { key: "ArrowDown" })
options = screen.getAllByRole("option")
expect(options[1]).toHaveAttribute("aria-selected", "true")
fireEvent.keyDown(input, { key: "Enter" })
expect(screen.queryByRole("listbox")).not.toBeInTheDocument()
expect(input.value).toBe("")
})
})