From 57260e258099bd90fd2ad112d17f9b69a8f4a3d2 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sun, 11 Jan 2026 12:59:45 -0500 Subject: [PATCH 1/8] Prototype of a simpler searchable settings --- webview-ui/src/components/settings/About.tsx | 57 +++--- .../settings/AutoApproveSettings.tsx | 117 +++++++----- .../components/settings/BrowserSettings.tsx | 29 ++- .../settings/CheckpointSettings.tsx | 16 +- .../settings/ContextManagementSettings.tsx | 116 ++++++++---- .../settings/ExperimentalSettings.tsx | 95 ++++++---- .../components/settings/LanguageSettings.tsx | 38 ++-- .../settings/NotificationSettings.tsx | 29 ++- .../components/settings/PromptsSettings.tsx | 8 +- .../components/settings/SearchableSetting.tsx | 67 +++++++ .../components/settings/SettingsSearch.tsx | 121 ++++++++++++ .../src/components/settings/SettingsView.tsx | 173 +++++++++++++++++- .../settings/SlashCommandsSettings.tsx | 33 +++- .../components/settings/TerminalSettings.tsx | 85 ++++++--- .../src/components/settings/UISettings.tsx | 55 +++--- .../components/settings/useSettingsSearch.ts | 97 ++++++++++ webview-ui/src/i18n/locales/ca/settings.json | 4 + webview-ui/src/i18n/locales/de/settings.json | 4 + webview-ui/src/i18n/locales/en/settings.json | 4 + webview-ui/src/i18n/locales/es/settings.json | 4 + webview-ui/src/i18n/locales/fr/settings.json | 4 + webview-ui/src/i18n/locales/hi/settings.json | 4 + webview-ui/src/i18n/locales/id/settings.json | 4 + webview-ui/src/i18n/locales/it/settings.json | 4 + webview-ui/src/i18n/locales/ja/settings.json | 4 + webview-ui/src/i18n/locales/ko/settings.json | 4 + webview-ui/src/i18n/locales/nl/settings.json | 4 + webview-ui/src/i18n/locales/pl/settings.json | 4 + .../src/i18n/locales/pt-BR/settings.json | 4 + webview-ui/src/i18n/locales/ru/settings.json | 4 + webview-ui/src/i18n/locales/tr/settings.json | 4 + webview-ui/src/i18n/locales/vi/settings.json | 4 + .../src/i18n/locales/zh-CN/settings.json | 4 + .../src/i18n/locales/zh-TW/settings.json | 4 + webview-ui/src/index.css | 17 ++ 35 files changed, 996 insertions(+), 229 deletions(-) create mode 100644 webview-ui/src/components/settings/SearchableSetting.tsx create mode 100644 webview-ui/src/components/settings/SettingsSearch.tsx create mode 100644 webview-ui/src/components/settings/useSettingsSearch.ts diff --git a/webview-ui/src/components/settings/About.tsx b/webview-ui/src/components/settings/About.tsx index 3ace32d33de..b16841e1d86 100644 --- a/webview-ui/src/components/settings/About.tsx +++ b/webview-ui/src/components/settings/About.tsx @@ -24,6 +24,7 @@ import { Button } from "@/components/ui" import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" +import { SearchableSetting } from "./SearchableSetting" type AboutProps = HTMLAttributes & { telemetrySetting: TelemetrySetting @@ -50,7 +51,10 @@ export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug,
-
+ { @@ -67,7 +71,7 @@ export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug, }} />

-
+
@@ -120,7 +124,11 @@ export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug, {setDebug && ( -
+ { @@ -132,30 +140,35 @@ export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug,

{t("settings:about.debugMode.description")}

-
+ )}
-

{t("settings:about.manageSettings")}

-
- - - -
+ +

{t("settings:about.manageSettings")}

+
+ + + +
+
) diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx index 335a616a3df..fe77029afcb 100644 --- a/webview-ui/src/components/settings/AutoApproveSettings.tsx +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -11,6 +11,7 @@ import { Button, Input, Slider } from "@/components/ui" import { SetCachedStateField } from "./types" import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" +import { SearchableSetting } from "./SearchableSetting" import { AutoApproveToggle } from "./AutoApproveToggle" import { MaxLimitInputs } from "./MaxLimitInputs" import { useExtensionState } from "@/context/ExtensionStateContext" @@ -116,40 +117,45 @@ export const AutoApproveSettings = ({
- { - const newValue = !(autoApprovalEnabled ?? false) - setAutoApprovalEnabled(newValue) - vscode.postMessage({ type: "autoApprovalEnabled", bool: newValue }) - }}> - {t("settings:autoApprove.enabled")} - -
-

{t("settings:autoApprove.description")}

-

- { - e.preventDefault() - // Send message to open keyboard shortcuts with search for toggle command - vscode.postMessage({ - type: "openKeyboardShortcuts", - text: `${Package.name}.toggleAutoApprove`, - }) - }} - /> - ), - }} - /> -

-
+ + { + const newValue = !(autoApprovalEnabled ?? false) + setAutoApprovalEnabled(newValue) + vscode.postMessage({ type: "autoApprovalEnabled", bool: newValue }) + }}> + {t("settings:autoApprove.enabled")} + +
+

{t("settings:autoApprove.description")}

+

+ { + e.preventDefault() + // Send message to open keyboard shortcuts with search for toggle command + vscode.postMessage({ + type: "openKeyboardShortcuts", + text: `${Package.name}.toggleAutoApprove`, + }) + }} + /> + ), + }} + /> +

+
+
{t("settings:autoApprove.readOnly.label")}
-
+ @@ -193,7 +202,7 @@ export const AutoApproveSettings = ({
{t("settings:autoApprove.readOnly.outsideWorkspace.description")}
-
+ )} @@ -203,7 +212,10 @@ export const AutoApproveSettings = ({
{t("settings:autoApprove.write.label")}
-
+ @@ -217,8 +229,11 @@ export const AutoApproveSettings = ({
{t("settings:autoApprove.write.outsideWorkspace.description")}
-
-
+ + @@ -230,7 +245,7 @@ export const AutoApproveSettings = ({
{t("settings:autoApprove.write.protected.description")}
-
+ )} @@ -240,7 +255,10 @@ export const AutoApproveSettings = ({
{t("settings:autoApprove.followupQuestions.label")}
-
+
{t("settings:autoApprove.followupQuestions.timeoutLabel")}
-
+ )} @@ -268,14 +286,17 @@ export const AutoApproveSettings = ({
{t("settings:autoApprove.execute.label")}
-
+
{t("settings:autoApprove.execute.allowedCommandsDescription")}
-
+
{/* Denied Commands Section */} -
+
{t("settings:autoApprove.execute.deniedCommandsDescription")}
-
+
-
+ setCachedStateField("browserToolEnabled", e.target.checked)}> @@ -131,11 +135,14 @@ export const BrowserSettings = ({
-
+ {browserToolEnabled && (
-
+ setCachedStateField("language", value as Language)}> - - - - - - {Object.entries(LANGUAGES).map(([code, name]) => ( - - {name} - ({code}) - - ))} - - - + + +
) diff --git a/webview-ui/src/components/settings/NotificationSettings.tsx b/webview-ui/src/components/settings/NotificationSettings.tsx index 9610cabad8b..db11e22c6b3 100644 --- a/webview-ui/src/components/settings/NotificationSettings.tsx +++ b/webview-ui/src/components/settings/NotificationSettings.tsx @@ -6,6 +6,7 @@ import { Bell } from "lucide-react" import { SetCachedStateField } from "./types" import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" +import { SearchableSetting } from "./SearchableSetting" import { Slider } from "../ui" type NotificationSettingsProps = HTMLAttributes & { @@ -35,7 +36,10 @@ export const NotificationSettings = ({
-
+ setCachedStateField("ttsEnabled", e.target.checked)} @@ -45,11 +49,14 @@ export const NotificationSettings = ({
{t("settings:notifications.tts.description")}
-
+ {ttsEnabled && (
-
+ @@ -64,11 +71,14 @@ export const NotificationSettings = ({ /> {((ttsSpeed ?? 1.0) * 100).toFixed(0)}%
-
+ )} -
+ setCachedStateField("soundEnabled", e.target.checked)} @@ -78,11 +88,14 @@ export const NotificationSettings = ({
{t("settings:notifications.sound.description")}
-
+ {soundEnabled && (
-
+ @@ -97,7 +110,7 @@ export const NotificationSettings = ({ /> {((soundVolume ?? 0.5) * 100).toFixed(0)}%
-
+ )}
diff --git a/webview-ui/src/components/settings/PromptsSettings.tsx b/webview-ui/src/components/settings/PromptsSettings.tsx index bca240c731a..85fb0ce8e36 100644 --- a/webview-ui/src/components/settings/PromptsSettings.tsx +++ b/webview-ui/src/components/settings/PromptsSettings.tsx @@ -19,6 +19,7 @@ import { import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" +import { SearchableSetting } from "./SearchableSetting" interface PromptsSettingsProps { customSupportPrompts: Record @@ -145,7 +146,10 @@ const PromptsSettings = ({
-
+ { + setSearchQuery(e.target.value) + setIsOpen(true) + }} + onFocus={() => searchQuery && setIsOpen(true)} + onKeyDown={handleKeyDown} + className="pl-8 pr-8 h-8 w-48" + /> + {searchQuery && ( + + )} +
+ + {isOpen && results.length > 0 && ( +
+ {results.map((result, index) => ( + + ))} +
+ )} + + {isOpen && searchQuery && results.length === 0 && ( +
+ {t("settings:search.noResults")} +
+ )} + + ) +} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 85143312dc1..f70c6b33c37 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -79,6 +79,8 @@ import { SlashCommandsSettings } from "./SlashCommandsSettings" import { UISettings } from "./UISettings" import ModesView from "../modes/ModesView" import McpView from "../mcp/McpView" +import { SettingsSearch } from "./SettingsSearch" +import { scanDOMForSearchableSettings, SearchableSettingData } from "./useSettingsSearch" export const settingsTabsContainer = "flex flex-1 overflow-hidden [&.narrow_.tab-label]:hidden" export const settingsTabList = @@ -91,7 +93,7 @@ export interface SettingsViewRef { checkUnsaveChanges: (then: () => void) => void } -const sectionNames = [ +export const sectionNames = [ "providers", "autoApprove", "slashCommands", @@ -109,7 +111,7 @@ const sectionNames = [ "about", ] as const -type SectionName = (typeof sectionNames)[number] +export type SectionName = (typeof sectionNames)[number] type SettingsViewProps = { onDone: () => void @@ -577,11 +579,60 @@ const SettingsView = forwardRef(({ onDone, t } }, [scrollToActiveTab]) + // Search index state - built once on mount + const [searchIndex, setSearchIndex] = useState([]) + const indexingContainerRef = useRef(null) + const [isIndexing, setIsIndexing] = useState(true) + + // Build the search index by scanning a hidden container that renders all sections + useEffect(() => { + if (!isIndexing || !indexingContainerRef.current) return + + // Wait for the hidden content to render + requestAnimationFrame(() => { + setTimeout(() => { + if (indexingContainerRef.current) { + const index = scanDOMForSearchableSettings(indexingContainerRef.current, (section) => + t(`settings:sections.${section}`), + ) + setSearchIndex(index) + setIsIndexing(false) + } + }, 100) + }) + }, [isIndexing, t]) + + // Handle search navigation - switch to the correct tab and scroll to the element + const handleSearchNavigate = useCallback( + (section: SectionName, settingId: string) => { + // Switch to the correct tab + handleTabChange(section) + + // Wait for the tab to render, then find element by settingId and scroll to it + requestAnimationFrame(() => { + setTimeout(() => { + const element = document.querySelector(`[data-setting-id="${settingId}"]`) + if (element) { + element.scrollIntoView({ behavior: "smooth", block: "center" }) + + // Add highlight animation + element.classList.add("settings-highlight") + setTimeout(() => { + element.classList.remove("settings-highlight") + }, 1500) + } + }, 100) // Small delay to ensure tab content is rendered + }) + }, + [handleTabChange], + ) + return ( -
+

{t("settings:header.title")}

+ {!isIndexing && }
(({ onDone, t
+ {/* Hidden container for indexing searchable settings - rendered once on mount */} + {isIndexing && ( +
+ {/* Render all settings sections for indexing */} + + + + + + + + + setCachedStateField("includeTaskHistoryInEnhance", value) + } + /> + + + + +
+ )} + diff --git a/webview-ui/src/components/settings/SlashCommandsSettings.tsx b/webview-ui/src/components/settings/SlashCommandsSettings.tsx index 58869fe8cc6..5b97df4fa09 100644 --- a/webview-ui/src/components/settings/SlashCommandsSettings.tsx +++ b/webview-ui/src/components/settings/SlashCommandsSettings.tsx @@ -22,6 +22,7 @@ import { buildDocLink } from "@/utils/docLinks" import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" +import { SearchableSetting } from "./SearchableSetting" import { SlashCommandItem } from "../chat/SlashCommandItem" export const SlashCommandsSettings: React.FC = () => { @@ -111,7 +112,11 @@ export const SlashCommandsSettings: React.FC = () => {
{/* Description section */} -
+

{ }} />

-
+ {/* Global Commands Section */} -
+

{t("chat:slashCommands.globalCommands")}

@@ -169,11 +178,15 @@ export const SlashCommandsSettings: React.FC = () => {
-
+ {/* Workspace Commands Section - Only show if in a workspace */} {hasWorkspace && ( -
+

{t("chat:slashCommands.workspaceCommands")}

@@ -211,12 +224,16 @@ export const SlashCommandsSettings: React.FC = () => {
- + )} {/* Built-in Commands Section */} {builtInCommands.length > 0 && ( -
+

{t("chat:slashCommands.builtInCommands")}

@@ -231,7 +248,7 @@ export const SlashCommandsSettings: React.FC = () => { /> ))}
-
+ )}
diff --git a/webview-ui/src/components/settings/TerminalSettings.tsx b/webview-ui/src/components/settings/TerminalSettings.tsx index d7ae25f0a07..2b68e003bfa 100644 --- a/webview-ui/src/components/settings/TerminalSettings.tsx +++ b/webview-ui/src/components/settings/TerminalSettings.tsx @@ -15,6 +15,7 @@ import { Slider } from "@/components/ui" import { SetCachedStateField } from "./types" import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" +import { SearchableSetting } from "./SearchableSetting" type TerminalSettingsProps = HTMLAttributes & { terminalOutputLineLimit?: number @@ -104,7 +105,10 @@ export const TerminalSettings = ({
-
+ @@ -131,8 +135,11 @@ export const TerminalSettings = ({
-
-
+ + @@ -161,8 +168,11 @@ export const TerminalSettings = ({
- -
+ + @@ -183,7 +193,7 @@ export const TerminalSettings = ({
- + @@ -199,7 +209,10 @@ export const TerminalSettings = ({
-
+ @@ -221,11 +234,14 @@ export const TerminalSettings = ({
-
+ {!terminalShellIntegrationDisabled && ( <> -
+ { @@ -251,9 +267,12 @@ export const TerminalSettings = ({
- + -
+ @@ -286,9 +305,12 @@ export const TerminalSettings = ({
- + -
+ @@ -319,9 +341,12 @@ export const TerminalSettings = ({
- + -
+ @@ -344,9 +369,12 @@ export const TerminalSettings = ({
- + -
+ @@ -369,9 +397,12 @@ export const TerminalSettings = ({
- + -
+ setCachedStateField("terminalZshOhMy", e.target.checked)} @@ -390,9 +421,12 @@ export const TerminalSettings = ({
- + -
+ setCachedStateField("terminalZshP10k", e.target.checked)} @@ -411,9 +445,12 @@ export const TerminalSettings = ({
- + -
+ setCachedStateField("terminalZdotdir", e.target.checked)} @@ -432,7 +469,7 @@ export const TerminalSettings = ({
- + )} diff --git a/webview-ui/src/components/settings/UISettings.tsx b/webview-ui/src/components/settings/UISettings.tsx index b4e5a4e861a..162d727260b 100644 --- a/webview-ui/src/components/settings/UISettings.tsx +++ b/webview-ui/src/components/settings/UISettings.tsx @@ -7,6 +7,7 @@ import { telemetryClient } from "@/utils/TelemetryClient" import { SetCachedStateField } from "./types" import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" +import { SearchableSetting } from "./SearchableSetting" import { ExtensionStateContextType } from "@/context/ExtensionStateContext" interface UISettingsProps extends HTMLAttributes { @@ -60,32 +61,42 @@ export const UISettings = ({
{/* Collapse Thinking Messages Setting */} -
- handleReasoningBlockCollapsedChange(e.target.checked)} - data-testid="collapse-thinking-checkbox"> - {t("settings:ui.collapseThinking.label")} - -
- {t("settings:ui.collapseThinking.description")} + +
+ handleReasoningBlockCollapsedChange(e.target.checked)} + data-testid="collapse-thinking-checkbox"> + {t("settings:ui.collapseThinking.label")} + +
+ {t("settings:ui.collapseThinking.description")} +
-
+ {/* Enter Key Behavior Setting */} -
- handleEnterBehaviorChange(e.target.checked)} - data-testid="enter-behavior-checkbox"> - - {t("settings:ui.requireCtrlEnterToSend.label", { primaryMod })} - - -
- {t("settings:ui.requireCtrlEnterToSend.description", { primaryMod })} + +
+ handleEnterBehaviorChange(e.target.checked)} + data-testid="enter-behavior-checkbox"> + + {t("settings:ui.requireCtrlEnterToSend.label", { primaryMod })} + + +
+ {t("settings:ui.requireCtrlEnterToSend.description", { primaryMod })} +
-
+
diff --git a/webview-ui/src/components/settings/useSettingsSearch.ts b/webview-ui/src/components/settings/useSettingsSearch.ts new file mode 100644 index 00000000000..6e4495c3a05 --- /dev/null +++ b/webview-ui/src/components/settings/useSettingsSearch.ts @@ -0,0 +1,97 @@ +import { useState, useMemo, useCallback } from "react" +import { Fzf } from "fzf" + +import { SectionName } from "./SettingsView" + +export interface SearchableSettingData { + settingId: string + section: SectionName + label: string + sectionLabel: string +} + +export interface SearchResult { + settingId: string + section: SectionName + label: string + sectionLabel: string +} + +/** + * Scan the DOM for searchable settings within a container. + * This is called once on mount to build the index. + */ +export function scanDOMForSearchableSettings( + container: Element, + getSectionLabel: (section: SectionName) => string, +): SearchableSettingData[] { + const settings: SearchableSettingData[] = [] + const elements = container.querySelectorAll("[data-searchable]") + + elements.forEach((el) => { + const settingId = el.getAttribute("data-setting-id") + const section = el.getAttribute("data-setting-section") as SectionName | null + const label = el.getAttribute("data-setting-label") + + if (settingId && section && label) { + settings.push({ + settingId, + section, + label, + sectionLabel: getSectionLabel(section), + }) + } + }) + + return settings +} + +interface UseSettingsSearchOptions { + index: SearchableSettingData[] +} + +/** + * Hook for searching settings using fuzzy matching. + */ +export function useSettingsSearch({ index }: UseSettingsSearchOptions) { + const [searchQuery, setSearchQuery] = useState("") + const [isOpen, setIsOpen] = useState(false) + + // Create Fzf instance for fuzzy searching + const fzf = useMemo( + () => + new Fzf(index, { + selector: (item) => `${item.label} ${item.sectionLabel}`, + }), + [index], + ) + + // Search results + const results = useMemo((): SearchResult[] => { + if (!searchQuery.trim()) { + return [] + } + + const fzfResults = fzf.find(searchQuery) + return fzfResults.slice(0, 10).map((result) => ({ + settingId: result.item.settingId, + section: result.item.section, + label: result.item.label, + sectionLabel: result.item.sectionLabel, + })) + }, [fzf, searchQuery]) + + const clearSearch = useCallback(() => { + setSearchQuery("") + setIsOpen(false) + }, []) + + return { + searchQuery, + setSearchQuery, + results, + isOpen, + setIsOpen, + clearSearch, + } +} diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 4f92caf7f7e..7c6e7560aaa 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -8,6 +8,10 @@ "add": "Afegir capçalera", "remove": "Eliminar" }, + "search": { + "placeholder": "Cercar configuració...", + "noResults": "No s'ha trobat cap configuració" + }, "header": { "title": "Configuració", "saveButtonTooltip": "Desar canvis", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 30e482c820f..8f95ca54ff5 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -8,6 +8,10 @@ "add": "Header hinzufügen", "remove": "Entfernen" }, + "search": { + "placeholder": "Einstellungen durchsuchen...", + "noResults": "Keine Einstellungen gefunden" + }, "header": { "title": "Einstellungen", "saveButtonTooltip": "Änderungen speichern", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 17d14da8bdf..551b8ab7eee 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -14,6 +14,10 @@ "nothingChangedTooltip": "Nothing changed", "doneButtonTooltip": "Discard unsaved changes and close settings panel" }, + "search": { + "placeholder": "Search settings...", + "noResults": "No settings found" + }, "unsavedChangesDialog": { "title": "Unsaved Changes", "description": "Do you want to discard changes and continue?", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index f514ba12fc8..852ea83a842 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -8,6 +8,10 @@ "add": "Añadir encabezado", "remove": "Eliminar" }, + "search": { + "placeholder": "Buscar configuración...", + "noResults": "No se encontró ninguna configuración" + }, "header": { "title": "Configuración", "saveButtonTooltip": "Guardar cambios", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 415bfa52202..a53a60e9937 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -8,6 +8,10 @@ "add": "Ajouter un en-tête", "remove": "Supprimer" }, + "search": { + "placeholder": "Rechercher les paramètres...", + "noResults": "Aucun paramètre trouvé" + }, "header": { "title": "Paramètres", "saveButtonTooltip": "Enregistrer les modifications", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 96a33fafeee..028aec0b6ff 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -8,6 +8,10 @@ "add": "हेडर जोड़ें", "remove": "हटाएं" }, + "search": { + "placeholder": "सेटिंग्स खोजें...", + "noResults": "कोई सेटिंग नहीं मिली" + }, "header": { "title": "सेटिंग्स", "saveButtonTooltip": "परिवर्तन सहेजें", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index a184c5c092a..9934b3a9d19 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -8,6 +8,10 @@ "add": "Tambah Header", "remove": "Hapus" }, + "search": { + "placeholder": "Cari pengaturan...", + "noResults": "Tidak ada pengaturan yang ditemukan" + }, "header": { "title": "Pengaturan", "saveButtonTooltip": "Simpan perubahan", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 69fd4e40c12..0a9fab0f883 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -8,6 +8,10 @@ "add": "Aggiungi intestazione", "remove": "Rimuovi" }, + "search": { + "placeholder": "Cerca impostazioni...", + "noResults": "Nessuna impostazione trovata" + }, "header": { "title": "Impostazioni", "saveButtonTooltip": "Salva modifiche", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 66d1b6579e0..8eb0185d8eb 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -8,6 +8,10 @@ "add": "ヘッダーを追加", "remove": "削除" }, + "search": { + "placeholder": "設定を検索...", + "noResults": "設定が見つかりません" + }, "header": { "title": "設定", "saveButtonTooltip": "変更を保存", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 691d756fed8..e4c6f059277 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -8,6 +8,10 @@ "add": "헤더 추가", "remove": "삭제" }, + "search": { + "placeholder": "설정 검색...", + "noResults": "설정을 찾을 수 없습니다" + }, "header": { "title": "설정", "saveButtonTooltip": "변경 사항 저장", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index d0c3a782161..4dc398a52f0 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -8,6 +8,10 @@ "add": "Header toevoegen", "remove": "Verwijderen" }, + "search": { + "placeholder": "Instellingen zoeken...", + "noResults": "Geen instellingen gevonden" + }, "header": { "title": "Instellingen", "saveButtonTooltip": "Wijzigingen opslaan", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 218cdf60e82..05a9f71cb89 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -8,6 +8,10 @@ "add": "Dodaj nagłówek", "remove": "Usuń" }, + "search": { + "placeholder": "Szukaj ustawień...", + "noResults": "Nie znaleziono ustawień" + }, "header": { "title": "Ustawienia", "saveButtonTooltip": "Zapisz zmiany", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index e96dc4c3454..c33a7b2a96d 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -8,6 +8,10 @@ "add": "Adicionar cabeçalho", "remove": "Remover" }, + "search": { + "placeholder": "Pesquisar configurações...", + "noResults": "Nenhuma configuração encontrada" + }, "header": { "title": "Configurações", "saveButtonTooltip": "Salvar alterações", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index aaae476d9c9..87ab2bc6b1d 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -8,6 +8,10 @@ "add": "Добавить заголовок", "remove": "Удалить" }, + "search": { + "placeholder": "Поиск параметров...", + "noResults": "Параметры не найдены" + }, "header": { "title": "Настройки", "saveButtonTooltip": "Сохранить изменения", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 0eabf2782fa..37987cb74da 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -8,6 +8,10 @@ "add": "Başlık Ekle", "remove": "Kaldır" }, + "search": { + "placeholder": "Ayarları ara...", + "noResults": "Ayar bulunamadı" + }, "header": { "title": "Ayarlar", "saveButtonTooltip": "Değişiklikleri kaydet", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index debb9fe8602..d18a29dde1e 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -8,6 +8,10 @@ "add": "Thêm tiêu đề", "remove": "Xóa" }, + "search": { + "placeholder": "Tìm kiếm cài đặt...", + "noResults": "Không tìm thấy cài đặt" + }, "header": { "title": "Cài đặt", "saveButtonTooltip": "Lưu thay đổi", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 6137b091e74..a5416704ebc 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -8,6 +8,10 @@ "add": "添加标头", "remove": "移除" }, + "search": { + "placeholder": "搜索设置...", + "noResults": "未找到设置" + }, "header": { "title": "设置", "saveButtonTooltip": "保存更改", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 84dfdb75274..fe55c4fa796 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -8,6 +8,10 @@ "add": "新增標頭", "remove": "移除" }, + "search": { + "placeholder": "搜尋設定...", + "noResults": "找不到設定" + }, "header": { "title": "設定", "saveButtonTooltip": "儲存變更", diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index b696261f91f..28dbf06aef1 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -569,3 +569,20 @@ input[cmdk-input]:focus { .animate-sun { animation: sun 30s linear infinite; } + +/* Settings search highlight animation */ +@keyframes settings-highlight-fade { + 0% { + background-color: color-mix(in srgb, var(--vscode-focusBorder) 40%, transparent); + } + 100% { + background-color: transparent; + } +} + +.settings-highlight { + animation: settings-highlight-fade 1.5s ease-out forwards; + border-radius: 4px; + padding: 8px; + margin: -8px; +} From 4310f20b7126ec331f59e0f6d837ac13912cc22d Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sun, 11 Jan 2026 13:33:41 -0500 Subject: [PATCH 2/8] Fix tests --- .../src/components/settings/SettingsView.tsx | 2 +- .../settings/__tests__/SettingsView.spec.tsx | 139 ++++++++++-------- 2 files changed, 80 insertions(+), 61 deletions(-) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index f70c6b33c37..2fc0a01f614 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -719,7 +719,7 @@ const SettingsView = forwardRef(({ onDone, t {/* Content area */} - + {/* Providers Section */} {activeTab === "providers" && (
diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index 80dfc7850f9..913cce743e6 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -1,6 +1,6 @@ // pnpm --filter @roo-code/vscode-webview test src/components/settings/__tests__/SettingsView.spec.tsx -import { render, screen, fireEvent } from "@/utils/test-utils" +import { render, screen, fireEvent, within } from "@/utils/test-utils" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { vscode } from "@/utils/vscode" @@ -76,7 +76,9 @@ vi.mock("../../../components/common/Tab", () => ({ ...vi.importActual("../../../components/common/Tab"), Tab: ({ children }: any) =>
{children}
, TabHeader: ({ children }: any) =>
{children}
, - TabContent: ({ children }: any) =>
{children}
, + TabContent: ({ children, "data-testid": dataTestId }: any) => ( +
{children}
+ ), TabList: ({ children, value, onValueChange, "data-testid": dataTestId }: any) => { // Store onValueChange in a global variable so TabTrigger can access it ;(window as any).__onValueChange = onValueChange @@ -132,8 +134,8 @@ vi.mock("@/components/ui", () => ({ Slider: ({ value, onValueChange, "data-testid": dataTestId }: any) => ( onValueChange([parseFloat(e.target.value)])} + value={value?.[0] ?? 0} + onChange={(e) => onValueChange?.([parseFloat(e.target.value)])} data-testid={dataTestId} /> ), @@ -258,7 +260,10 @@ const renderSettingsView = () => { ) } - return { onDone, activateTab } + // Helper to get elements within the settings content (not the indexing container) + const getSettingsContent = () => screen.getByTestId("settings-content") + + return { onDone, activateTab, getSettingsContent } } describe("SettingsView - Sound Settings", () => { @@ -268,40 +273,43 @@ describe("SettingsView - Sound Settings", () => { it("initializes with tts disabled by default", () => { // Render once and get the activateTab helper - const { activateTab } = renderSettingsView() + const { activateTab, getSettingsContent } = renderSettingsView() // Activate the notifications tab activateTab("notifications") - const ttsCheckbox = screen.getByTestId("tts-enabled-checkbox") + const content = getSettingsContent() + const ttsCheckbox = within(content).getByTestId("tts-enabled-checkbox") expect(ttsCheckbox).not.toBeChecked() // Speed slider should not be visible when tts is disabled - expect(screen.queryByTestId("tts-speed-slider")).not.toBeInTheDocument() + expect(within(content).queryByTestId("tts-speed-slider")).not.toBeInTheDocument() }) it("initializes with sound disabled by default", () => { // Render once and get the activateTab helper - const { activateTab } = renderSettingsView() + const { activateTab, getSettingsContent } = renderSettingsView() // Activate the notifications tab activateTab("notifications") - const soundCheckbox = screen.getByTestId("sound-enabled-checkbox") + const content = getSettingsContent() + const soundCheckbox = within(content).getByTestId("sound-enabled-checkbox") expect(soundCheckbox).not.toBeChecked() // Volume slider should not be visible when sound is disabled - expect(screen.queryByTestId("sound-volume-slider")).not.toBeInTheDocument() + expect(within(content).queryByTestId("sound-volume-slider")).not.toBeInTheDocument() }) it("toggles tts setting and sends message to VSCode", () => { // Render once and get the activateTab helper - const { activateTab } = renderSettingsView() + const { activateTab, getSettingsContent } = renderSettingsView() // Activate the notifications tab activateTab("notifications") - const ttsCheckbox = screen.getByTestId("tts-enabled-checkbox") + const content = getSettingsContent() + const ttsCheckbox = within(content).getByTestId("tts-enabled-checkbox") // Enable tts fireEvent.click(ttsCheckbox) @@ -323,12 +331,13 @@ describe("SettingsView - Sound Settings", () => { it("toggles sound setting and sends message to VSCode", () => { // Render once and get the activateTab helper - const { activateTab } = renderSettingsView() + const { activateTab, getSettingsContent } = renderSettingsView() // Activate the notifications tab activateTab("notifications") - const soundCheckbox = screen.getByTestId("sound-enabled-checkbox") + const content = getSettingsContent() + const soundCheckbox = within(content).getByTestId("sound-enabled-checkbox") // Enable sound fireEvent.click(soundCheckbox) @@ -350,51 +359,54 @@ describe("SettingsView - Sound Settings", () => { it("shows tts slider when sound is enabled", () => { // Render once and get the activateTab helper - const { activateTab } = renderSettingsView() + const { activateTab, getSettingsContent } = renderSettingsView() // Activate the notifications tab activateTab("notifications") + const content = getSettingsContent() // Enable tts - const ttsCheckbox = screen.getByTestId("tts-enabled-checkbox") + const ttsCheckbox = within(content).getByTestId("tts-enabled-checkbox") fireEvent.click(ttsCheckbox) // Speed slider should be visible - const speedSlider = screen.getByTestId("tts-speed-slider") + const speedSlider = within(content).getByTestId("tts-speed-slider") expect(speedSlider).toBeInTheDocument() expect(speedSlider).toHaveValue("1") }) it("shows volume slider when sound is enabled", () => { // Render once and get the activateTab helper - const { activateTab } = renderSettingsView() + const { activateTab, getSettingsContent } = renderSettingsView() // Activate the notifications tab activateTab("notifications") + const content = getSettingsContent() // Enable sound - const soundCheckbox = screen.getByTestId("sound-enabled-checkbox") + const soundCheckbox = within(content).getByTestId("sound-enabled-checkbox") fireEvent.click(soundCheckbox) // Volume slider should be visible - const volumeSlider = screen.getByTestId("sound-volume-slider") + const volumeSlider = within(content).getByTestId("sound-volume-slider") expect(volumeSlider).toBeInTheDocument() expect(volumeSlider).toHaveValue("0.5") }) it("updates speed and sends message to VSCode when slider changes", () => { // Render once and get the activateTab helper - const { activateTab } = renderSettingsView() + const { activateTab, getSettingsContent } = renderSettingsView() // Activate the notifications tab activateTab("notifications") + const content = getSettingsContent() // Enable tts - const ttsCheckbox = screen.getByTestId("tts-enabled-checkbox") + const ttsCheckbox = within(content).getByTestId("tts-enabled-checkbox") fireEvent.click(ttsCheckbox) // Change speed - const speedSlider = screen.getByTestId("tts-speed-slider") + const speedSlider = within(content).getByTestId("tts-speed-slider") fireEvent.change(speedSlider, { target: { value: "0.75" } }) // Click Save to save settings @@ -414,22 +426,23 @@ describe("SettingsView - Sound Settings", () => { it("updates volume and sends message to VSCode when slider changes", () => { // Render once and get the activateTab helper - const { activateTab } = renderSettingsView() + const { activateTab, getSettingsContent } = renderSettingsView() // Activate the notifications tab activateTab("notifications") + const content = getSettingsContent() // Enable sound - const soundCheckbox = screen.getByTestId("sound-enabled-checkbox") + const soundCheckbox = within(content).getByTestId("sound-enabled-checkbox") fireEvent.click(soundCheckbox) // Change volume - const volumeSlider = screen.getByTestId("sound-volume-slider") + const volumeSlider = within(content).getByTestId("sound-volume-slider") fireEvent.change(volumeSlider, { target: { value: "0.75" } }) - // Click Save to save settings - use getAllByTestId to handle multiple elements - const saveButtons = screen.getAllByTestId("save-button") - fireEvent.click(saveButtons[0]) + // Click Save to save settings + const saveButton = screen.getByTestId("save-button") + fireEvent.click(saveButton) // Verify message sent to VSCode expect(vscode.postMessage).toHaveBeenCalledWith( @@ -462,39 +475,41 @@ describe("SettingsView - Allowed Commands", () => { it("shows allowed commands section when alwaysAllowExecute is enabled", () => { // Render once and get the activateTab helper - const { activateTab } = renderSettingsView() + const { activateTab, getSettingsContent } = renderSettingsView() // Activate the autoApprove tab activateTab("autoApprove") + const content = getSettingsContent() // Enable always allow execute - const executeCheckbox = screen.getByTestId("always-allow-execute-toggle") + const executeCheckbox = within(content).getByTestId("always-allow-execute-toggle") fireEvent.click(executeCheckbox) // Verify allowed commands section appears - expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument() - expect(screen.getByTestId("command-input")).toBeInTheDocument() + expect(within(content).getByTestId("allowed-commands-heading")).toBeInTheDocument() + expect(within(content).getByTestId("command-input")).toBeInTheDocument() }) it("adds new command to the list", () => { // Render once and get the activateTab helper - const { activateTab } = renderSettingsView() + const { activateTab, getSettingsContent } = renderSettingsView() // Activate the autoApprove tab activateTab("autoApprove") + const content = getSettingsContent() // Enable always allow execute - const executeCheckbox = screen.getByTestId("always-allow-execute-toggle") + const executeCheckbox = within(content).getByTestId("always-allow-execute-toggle") fireEvent.click(executeCheckbox) // Add a new command - const input = screen.getByTestId("command-input") + const input = within(content).getByTestId("command-input") fireEvent.change(input, { target: { value: "npm test" } }) - const addButton = screen.getByTestId("add-command-button") + const addButton = within(content).getByTestId("add-command-button") fireEvent.click(addButton) // Verify command was added - expect(screen.getByText("npm test")).toBeInTheDocument() + expect(within(content).getByText("npm test")).toBeInTheDocument() // Verify VSCode message was sent expect(vscode.postMessage).toHaveBeenCalledWith({ @@ -507,27 +522,28 @@ describe("SettingsView - Allowed Commands", () => { it("removes command from the list", () => { // Render once and get the activateTab helper - const { activateTab } = renderSettingsView() + const { activateTab, getSettingsContent } = renderSettingsView() // Activate the autoApprove tab activateTab("autoApprove") + const content = getSettingsContent() // Enable always allow execute - const executeCheckbox = screen.getByTestId("always-allow-execute-toggle") + const executeCheckbox = within(content).getByTestId("always-allow-execute-toggle") fireEvent.click(executeCheckbox) // Add a command - const input = screen.getByTestId("command-input") + const input = within(content).getByTestId("command-input") fireEvent.change(input, { target: { value: "npm test" } }) - const addButton = screen.getByTestId("add-command-button") + const addButton = within(content).getByTestId("add-command-button") fireEvent.click(addButton) // Remove the command - const removeButton = screen.getByTestId("remove-command-0") + const removeButton = within(content).getByTestId("remove-command-0") fireEvent.click(removeButton) // Verify command was removed - expect(screen.queryByText("npm test")).not.toBeInTheDocument() + expect(within(content).queryByText("npm test")).not.toBeInTheDocument() // Verify VSCode message was sent expect(vscode.postMessage).toHaveBeenLastCalledWith({ @@ -556,13 +572,14 @@ describe("SettingsView - Allowed Commands", () => { it("shows unsaved changes dialog when clicking Done with unsaved changes", () => { // Render once and get the activateTab helper - const { activateTab } = renderSettingsView() + const { activateTab, getSettingsContent } = renderSettingsView() // Activate the notifications tab activateTab("notifications") + const content = getSettingsContent() // Make a change to create unsaved changes - const soundCheckbox = screen.getByTestId("sound-enabled-checkbox") + const soundCheckbox = within(content).getByTestId("sound-enabled-checkbox") fireEvent.click(soundCheckbox) // Click the Done button @@ -599,18 +616,19 @@ describe("SettingsView - Duplicate Commands", () => { it("prevents duplicate commands", () => { // Render once and get the activateTab helper - const { activateTab } = renderSettingsView() + const { activateTab, getSettingsContent } = renderSettingsView() // Activate the autoApprove tab activateTab("autoApprove") + const content = getSettingsContent() // Enable always allow execute - const executeCheckbox = screen.getByTestId("always-allow-execute-toggle") + const executeCheckbox = within(content).getByTestId("always-allow-execute-toggle") fireEvent.click(executeCheckbox) // Add a command twice - const input = screen.getByTestId("command-input") - const addButton = screen.getByTestId("add-command-button") + const input = within(content).getByTestId("command-input") + const addButton = within(content).getByTestId("add-command-button") // First addition fireEvent.change(input, { target: { value: "npm test" } }) @@ -620,31 +638,32 @@ describe("SettingsView - Duplicate Commands", () => { fireEvent.change(input, { target: { value: "npm test" } }) fireEvent.click(addButton) - // Verify command appears only once - const commands = screen.getAllByText("npm test") + // Verify command appears only once in active tab + const commands = within(content).getAllByText("npm test") expect(commands).toHaveLength(1) }) it("saves allowed commands when clicking Save", () => { // Render once and get the activateTab helper - const { activateTab } = renderSettingsView() + const { activateTab, getSettingsContent } = renderSettingsView() // Activate the autoApprove tab activateTab("autoApprove") + const content = getSettingsContent() // Enable always allow execute - const executeCheckbox = screen.getByTestId("always-allow-execute-toggle") + const executeCheckbox = within(content).getByTestId("always-allow-execute-toggle") fireEvent.click(executeCheckbox) // Add a command - const input = screen.getByTestId("command-input") + const input = within(content).getByTestId("command-input") fireEvent.change(input, { target: { value: "npm test" } }) - const addButton = screen.getByTestId("add-command-button") + const addButton = within(content).getByTestId("add-command-button") fireEvent.click(addButton) - // Click Save - use getAllByTestId to handle multiple elements - const saveButtons = screen.getAllByTestId("save-button") - fireEvent.click(saveButtons[0]) + // Click Save + const saveButton = screen.getByTestId("save-button") + fireEvent.click(saveButton) // Verify VSCode messages were sent expect(vscode.postMessage).toHaveBeenCalledWith( From 4d8765e147eb60eaf2ddc0b244af426c942f6b59 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sun, 11 Jan 2026 14:00:12 -0500 Subject: [PATCH 3/8] UI improvements --- .../components/settings/SettingsSearch.tsx | 190 +++++++++--------- .../settings/SettingsSearchInput.tsx | 56 ++++++ .../settings/SettingsSearchResults.tsx | 150 ++++++++++++++ .../src/components/settings/SettingsView.tsx | 8 +- .../components/settings/useSettingsSearch.ts | 3 + 5 files changed, 310 insertions(+), 97 deletions(-) create mode 100644 webview-ui/src/components/settings/SettingsSearchInput.tsx create mode 100644 webview-ui/src/components/settings/SettingsSearchResults.tsx diff --git a/webview-ui/src/components/settings/SettingsSearch.tsx b/webview-ui/src/components/settings/SettingsSearch.tsx index d742406f563..0f23a88b201 100644 --- a/webview-ui/src/components/settings/SettingsSearch.tsx +++ b/webview-ui/src/components/settings/SettingsSearch.tsx @@ -1,119 +1,121 @@ -import { useRef, useEffect, useState } from "react" -import { Search, X } from "lucide-react" - -import { Input } from "@/components/ui" -import { useAppTranslation } from "@/i18n/TranslationContext" -import { cn } from "@/lib/utils" +import { useRef, useEffect, useState, useCallback } from "react" +import type { LucideIcon } from "lucide-react" import { useSettingsSearch, SearchResult, SearchableSettingData } from "./useSettingsSearch" import { SectionName } from "./SettingsView" +import { SettingsSearchInput } from "./SettingsSearchInput" +import { SettingsSearchResults } from "./SettingsSearchResults" interface SettingsSearchProps { index: SearchableSettingData[] onNavigate: (section: SectionName, settingId: string) => void + sections: { id: SectionName; icon: LucideIcon }[] } -export function SettingsSearch({ index, onNavigate }: SettingsSearchProps) { - const { t } = useAppTranslation() +export function SettingsSearch({ index, onNavigate, sections }: SettingsSearchProps) { const inputRef = useRef(null) - const dropdownRef = useRef(null) const { searchQuery, setSearchQuery, results, isOpen, setIsOpen, clearSearch } = useSettingsSearch({ index }) - const [selectedIndex, setSelectedIndex] = useState(0) + const [highlightedResultId, setHighlightedResultId] = useState(undefined) - // Handle keyboard navigation - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "ArrowDown") { - e.preventDefault() - setSelectedIndex((i) => Math.min(i + 1, results.length - 1)) - } else if (e.key === "ArrowUp") { - e.preventDefault() - setSelectedIndex((i) => Math.max(i - 1, 0)) - } else if (e.key === "Enter" && results[selectedIndex]) { - e.preventDefault() - handleSelect(results[selectedIndex]) - } else if (e.key === "Escape") { + // Handle selection of a search result + const handleSelectResult = useCallback( + (result: SearchResult) => { + onNavigate(result.section, result.settingId) clearSearch() - inputRef.current?.blur() - } - } + setHighlightedResultId(undefined) + // Keep focus in the input so dropdown remains open for follow-up search + setIsOpen(true) + requestAnimationFrame(() => inputRef.current?.focus()) + }, + [onNavigate, clearSearch, setIsOpen], + ) - const handleSelect = (result: SearchResult) => { - onNavigate(result.section, result.settingId) - clearSearch() - } + // Keyboard navigation inside search results + const moveHighlight = useCallback( + (direction: 1 | -1) => { + if (!results.length) return + const flatIds = results.map((r) => r.settingId) + const currentIndex = highlightedResultId ? flatIds.indexOf(highlightedResultId) : -1 + const nextIndex = (currentIndex + direction + flatIds.length) % flatIds.length + setHighlightedResultId(flatIds[nextIndex]) + }, + [highlightedResultId, results], + ) - // Reset selected index when results change - useEffect(() => { - setSelectedIndex(0) - }, [results]) + const handleSearchKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (!results.length) return - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { - if ( - dropdownRef.current && - !dropdownRef.current.contains(e.target as Node) && - !inputRef.current?.contains(e.target as Node) - ) { + 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 = results.find((r) => r.settingId === highlightedResultId) + if (selected) { + handleSelectResult(selected) + } + return + } + + if (event.key === "Escape") { setIsOpen(false) + setHighlightedResultId(undefined) + inputRef.current?.blur() + return } + }, + [handleSelectResult, highlightedResultId, moveHighlight, results, setIsOpen], + ) + + // Reset highlight based on focus and available results + useEffect(() => { + if (!isOpen || !results.length) { + setHighlightedResultId(undefined) + return } - document.addEventListener("mousedown", handleClickOutside) - return () => document.removeEventListener("mousedown", handleClickOutside) - }, [setIsOpen]) - return ( -
-
- - { - setSearchQuery(e.target.value) - setIsOpen(true) - }} - onFocus={() => searchQuery && setIsOpen(true)} - onKeyDown={handleKeyDown} - className="pl-8 pr-8 h-8 w-48" - /> - {searchQuery && ( - - )} -
+ setHighlightedResultId((current) => + current && results.some((r) => r.settingId === current) ? current : results[0]?.settingId, + ) + }, [isOpen, results]) - {isOpen && results.length > 0 && ( -
- {results.map((result, index) => ( - - ))} -
- )} + // Ensure highlighted search result stays visible within dropdown + useEffect(() => { + if (!highlightedResultId || !isOpen) return - {isOpen && searchQuery && results.length === 0 && ( -
- {t("settings:search.noResults")} + const element = document.getElementById(`settings-search-result-${highlightedResultId}`) + element?.scrollIntoView({ block: "nearest" }) + }, [highlightedResultId, isOpen]) + + return ( +
+ setIsOpen(true)} + onBlur={() => setTimeout(() => setIsOpen(false), 200)} + onKeyDown={handleSearchKeyDown} + inputRef={inputRef} + /> + {searchQuery && isOpen && ( +
+
)}
diff --git a/webview-ui/src/components/settings/SettingsSearchInput.tsx b/webview-ui/src/components/settings/SettingsSearchInput.tsx new file mode 100644 index 00000000000..8ccc996fa5e --- /dev/null +++ b/webview-ui/src/components/settings/SettingsSearchInput.tsx @@ -0,0 +1,56 @@ +import { type RefObject } from "react" +import { Search, X } from "lucide-react" + +import { cn } from "@/lib/utils" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { Input } from "@/components/ui" + +export interface SettingsSearchInputProps { + value: string + onChange: (value: string) => void + onFocus?: () => void + onBlur?: () => void + onKeyDown?: React.KeyboardEventHandler + inputRef?: RefObject +} + +export function SettingsSearchInput({ + value, + onChange, + onFocus, + onBlur, + onKeyDown, + inputRef, +}: SettingsSearchInputProps) { + const { t } = useAppTranslation() + + return ( +
+ + onChange(e.target.value)} + onFocus={onFocus} + onBlur={onBlur} + onKeyDown={onKeyDown} + placeholder={t("settings:search.placeholder")} + className={cn( + "pl-8 pr-2.5 h-7 text-sm rounded-full border border-vscode-input-border bg-vscode-input-background focus:border-vscode-focusBorder", + value && "pr-7", + )} + /> + {value && ( + + )} +
+ ) +} diff --git a/webview-ui/src/components/settings/SettingsSearchResults.tsx b/webview-ui/src/components/settings/SettingsSearchResults.tsx new file mode 100644 index 00000000000..bf9d74aefa1 --- /dev/null +++ b/webview-ui/src/components/settings/SettingsSearchResults.tsx @@ -0,0 +1,150 @@ +import { useMemo } from "react" +import type { LucideIcon } from "lucide-react" + +import { useAppTranslation } from "@/i18n/TranslationContext" +import { cn } from "@/lib/utils" + +import { SectionName } from "./SettingsView" +import { SearchResult } from "./useSettingsSearch" + +export interface SettingsSearchResultsProps { + results: SearchResult[] + query: string + onSelectResult: (result: SearchResult) => void + sections: { id: SectionName; icon: LucideIcon }[] + highlightedResultId?: string +} + +interface HighlightMatchProps { + text: string + /** Character positions to highlight (from fzf) */ + positions: Set +} + +/** + * Highlights matching characters using fzf's position data. + */ +function HighlightMatch({ text, positions }: HighlightMatchProps) { + if (positions.size === 0) { + return <>{text} + } + + // Build segments of highlighted and non-highlighted text + const segments: { text: string; highlighted: boolean }[] = [] + let currentSegment = "" + let currentHighlighted = positions.has(0) + + for (let i = 0; i < text.length; i++) { + const isHighlighted = positions.has(i) + if (isHighlighted === currentHighlighted) { + currentSegment += text[i] + } else { + if (currentSegment) { + segments.push({ text: currentSegment, highlighted: currentHighlighted }) + } + currentSegment = text[i] + currentHighlighted = isHighlighted + } + } + + if (currentSegment) { + segments.push({ text: currentSegment, highlighted: currentHighlighted }) + } + + return ( + <> + {segments.map((segment, index) => + segment.highlighted ? ( + + {segment.text} + + ) : ( + {segment.text} + ), + )} + + ) +} + +export function SettingsSearchResults({ + results, + query, + onSelectResult, + sections, + highlightedResultId, +}: SettingsSearchResultsProps) { + const { t } = useAppTranslation() + + // Group results by section/tab + const groupedResults = useMemo(() => { + return results.reduce( + (acc, result) => { + const section = result.section + if (!acc[section]) { + acc[section] = [] + } + acc[section].push(result) + return acc + }, + {} as Record, + ) + }, [results]) + + // Create a map of section id to icon for quick lookup + const sectionIconMap = useMemo(() => { + return new Map(sections.map((section) => [section.id, section.icon])) + }, [sections]) + + // If no results, show a message + if (results.length === 0) { + return ( +
+ {t("settings:search.noResults", { query })} +
+ ) + } + + return ( +
+ {Object.entries(groupedResults).map(([section, sectionResults]) => { + const Icon = sectionIconMap.get(section as SectionName) + + return ( +
+ {/* Section header */} +
+ {Icon && } + {t(`settings:sections.${section}`)} +
+ + {/* Result items */} + {sectionResults.map((result) => { + const isHighlighted = highlightedResultId === result.settingId + const resultDomId = `settings-search-result-${result.settingId}` + + return ( + + ) + })} +
+ ) + })} +
+ ) +} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 2fc0a01f614..e530ba87437 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -630,9 +630,11 @@ const SettingsView = forwardRef(({ onDone, t return ( -
-

{t("settings:header.title")}

- {!isIndexing && } +
+

{t("settings:header.title")}

+ {!isIndexing && ( + + )}
} /** @@ -78,6 +80,7 @@ export function useSettingsSearch({ index }: UseSettingsSearchOptions) { section: result.item.section, label: result.item.label, sectionLabel: result.item.sectionLabel, + positions: result.positions, })) }, [fzf, searchQuery]) From fedd2532084291543fe27507df59ca413674edfb Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sun, 11 Jan 2026 14:05:13 -0500 Subject: [PATCH 4/8] Input tweaks --- .../components/settings/SettingsSearch.tsx | 2 +- .../settings/SettingsSearchInput.tsx | 29 +++++++++++++++---- .../src/components/settings/SettingsView.tsx | 6 ++-- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/webview-ui/src/components/settings/SettingsSearch.tsx b/webview-ui/src/components/settings/SettingsSearch.tsx index 0f23a88b201..db3583f4b7d 100644 --- a/webview-ui/src/components/settings/SettingsSearch.tsx +++ b/webview-ui/src/components/settings/SettingsSearch.tsx @@ -108,7 +108,7 @@ export function SettingsSearch({ index, onNavigate, sections }: SettingsSearchPr inputRef={inputRef} /> {searchQuery && isOpen && ( -
+
{ + setIsExpanded(true) + onFocus?.() + } + + const handleBlur = () => { + // Only collapse if there's no value + if (!value) { + setIsExpanded(false) + } + onBlur?.() + } + + const isWide = isExpanded || !!value return ( -
+
onChange(e.target.value)} - onFocus={onFocus} - onBlur={onBlur} + onFocus={handleFocus} + onBlur={handleBlur} onKeyDown={onKeyDown} - placeholder={t("settings:search.placeholder")} + placeholder={isWide ? t("settings:search.placeholder") : ""} className={cn( - "pl-8 pr-2.5 h-7 text-sm rounded-full border border-vscode-input-border bg-vscode-input-background focus:border-vscode-focusBorder", + "pl-8 h-7 text-sm rounded-full border border-vscode-input-border bg-vscode-input-background focus:border-vscode-focusBorder transition-all duration-200 ease-in-out", + isWide ? "w-40 pr-2.5" : "w-8 pr-0 cursor-pointer", value && "pr-7", )} /> diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index e530ba87437..d27d7bcc61b 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -630,13 +630,11 @@ const SettingsView = forwardRef(({ onDone, t return ( -
-

{t("settings:header.title")}

+

{t("settings:header.title")}

+
{!isIndexing && ( )} -
-
Date: Sun, 11 Jan 2026 14:10:09 -0500 Subject: [PATCH 5/8] Update webview-ui/src/components/settings/SettingsSearch.tsx Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com> --- webview-ui/src/components/settings/SettingsSearch.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/webview-ui/src/components/settings/SettingsSearch.tsx b/webview-ui/src/components/settings/SettingsSearch.tsx index db3583f4b7d..84411999f86 100644 --- a/webview-ui/src/components/settings/SettingsSearch.tsx +++ b/webview-ui/src/components/settings/SettingsSearch.tsx @@ -44,6 +44,13 @@ export function SettingsSearch({ index, onNavigate, sections }: SettingsSearchPr const handleSearchKeyDown = useCallback( (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + setIsOpen(false) + setHighlightedResultId(undefined) + inputRef.current?.blur() + return + } + if (!results.length) return if (event.key === "ArrowDown") { From 65a40c701225a7b64a54b40e3c6d82416b63033d Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 11 Jan 2026 19:18:00 +0000 Subject: [PATCH 6/8] fix: remove duplicate Escape key handler dead code --- webview-ui/src/components/settings/SettingsSearch.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/webview-ui/src/components/settings/SettingsSearch.tsx b/webview-ui/src/components/settings/SettingsSearch.tsx index 84411999f86..a0cce1fefac 100644 --- a/webview-ui/src/components/settings/SettingsSearch.tsx +++ b/webview-ui/src/components/settings/SettingsSearch.tsx @@ -73,13 +73,6 @@ export function SettingsSearch({ index, onNavigate, sections }: SettingsSearchPr } return } - - if (event.key === "Escape") { - setIsOpen(false) - setHighlightedResultId(undefined) - inputRef.current?.blur() - return - } }, [handleSelectResult, highlightedResultId, moveHighlight, results, setIsOpen], ) From f84b8a83f8fb7aad43ebbe4ceed94d12b28fbe16 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sun, 11 Jan 2026 14:40:27 -0500 Subject: [PATCH 7/8] Cleanup --- .../components/settings/SearchableSetting.tsx | 18 +- .../src/components/settings/SettingsView.tsx | 591 +++++++----------- .../components/settings/useSettingsSearch.ts | 55 +- 3 files changed, 310 insertions(+), 354 deletions(-) diff --git a/webview-ui/src/components/settings/SearchableSetting.tsx b/webview-ui/src/components/settings/SearchableSetting.tsx index 80cf12fe391..2c55e35a1e2 100644 --- a/webview-ui/src/components/settings/SearchableSetting.tsx +++ b/webview-ui/src/components/settings/SearchableSetting.tsx @@ -1,8 +1,9 @@ -import { HTMLAttributes } from "react" +import { HTMLAttributes, useEffect } from "react" import { cn } from "@/lib/utils" import { SectionName } from "./SettingsView" +import { useSearchIndexContext } from "./useSettingsSearch" interface SearchableSettingProps extends HTMLAttributes { /** @@ -26,8 +27,8 @@ interface SearchableSettingProps extends HTMLAttributes { /** * Wrapper component that marks a setting as searchable. * - * The search system scans the DOM for elements with `data-searchable` attribute - * and reads the metadata from data attributes to build the search index. + * The component registers itself with the search index context on mount, + * allowing the search system to index settings as they are rendered. * * @example * ```tsx @@ -53,6 +54,17 @@ export function SearchableSetting({ className, ...props }: SearchableSettingProps) { + const searchContext = useSearchIndexContext() + + // Register this setting with the search index on mount + // Note: We don't unregister on unmount because settings are indexed once + // during the initial tab cycling phase and remain in the index + useEffect(() => { + if (searchContext) { + searchContext.registerSetting({ settingId, section, label }) + } + }, [searchContext, settingId, section, label]) + return (
(({ onDone, t } }, [scrollToActiveTab]) - // Search index state - built once on mount - const [searchIndex, setSearchIndex] = useState([]) - const indexingContainerRef = useRef(null) - const [isIndexing, setIsIndexing] = useState(true) + // Search index registry - settings register themselves on mount + const getSectionLabel = useCallback((section: SectionName) => t(`settings:sections.${section}`), [t]) + const { contextValue: searchContextValue, index: searchIndex } = useSearchIndexRegistry(getSectionLabel) - // Build the search index by scanning a hidden container that renders all sections - useEffect(() => { - if (!isIndexing || !indexingContainerRef.current) return - - // Wait for the hidden content to render - requestAnimationFrame(() => { - setTimeout(() => { - if (indexingContainerRef.current) { - const index = scanDOMForSearchableSettings(indexingContainerRef.current, (section) => - t(`settings:sections.${section}`), - ) - setSearchIndex(index) - setIsIndexing(false) - } - }, 100) - }) - }, [isIndexing, t]) + // Track which tabs have been indexed (visited at least once) + const [indexingTabIndex, setIndexingTabIndex] = useState(0) + const initialTab = useRef(activeTab) + const isIndexing = indexingTabIndex < sectionNames.length + const isIndexingComplete = !isIndexing + + // Index all tabs by cycling through them on mount + useLayoutEffect(() => { + if (indexingTabIndex >= sectionNames.length) { + // All tabs indexed, return to initial tab + setActiveTab(initialTab.current) + return + } + + // Move to the next tab on next render + setIndexingTabIndex((prev) => prev + 1) + }, [indexingTabIndex]) + + // Determine which tab content to render (for indexing or active display) + const renderTab = isIndexing ? sectionNames[indexingTabIndex] : activeTab // Handle search navigation - switch to the correct tab and scroll to the element const handleSearchNavigate = useCallback( @@ -632,7 +634,7 @@ const SettingsView = forwardRef(({ onDone, t

{t("settings:header.title")}

- {!isIndexing && ( + {isIndexingComplete && ( )} (({ onDone, t })} - {/* Content area */} - - {/* Providers Section */} - {activeTab === "providers" && ( -
- -
- -
{t("settings:sections.providers")}
-
-
- -
- - checkUnsaveChanges(() => - vscode.postMessage({ type: "loadApiConfiguration", text: configName }), - ) - } - onDeleteConfig={(configName: string) => - vscode.postMessage({ type: "deleteApiConfiguration", text: configName }) - } - onRenameConfig={(oldName: string, newName: string) => { - vscode.postMessage({ - type: "renameApiConfiguration", - values: { oldName, newName }, - apiConfiguration, - }) - prevApiConfigName.current = newName - }} - onUpsertConfig={(configName: string) => - vscode.postMessage({ - type: "upsertApiConfiguration", - text: configName, - apiConfiguration, - }) - } - /> - -
-
- )} - - {/* Auto-Approve Section */} - {activeTab === "autoApprove" && ( - - )} - - {/* Slash Commands Section */} - {activeTab === "slashCommands" && } - - {/* Browser Section */} - {activeTab === "browser" && ( - - )} - - {/* Checkpoints Section */} - {activeTab === "checkpoints" && ( - - )} - - {/* Notifications Section */} - {activeTab === "notifications" && ( - - )} - - {/* Context Management Section */} - {activeTab === "contextManagement" && ( - - )} - - {/* Terminal Section */} - {activeTab === "terminal" && ( - - )} - - {/* Modes Section */} - {activeTab === "modes" && } - - {/* MCP Section */} - {activeTab === "mcp" && } - - {/* Prompts Section */} - {activeTab === "prompts" && ( - - setCachedStateField("includeTaskHistoryInEnhance", value) - } - /> - )} - - {/* UI Section */} - {activeTab === "ui" && ( - - )} - - {/* Experimental Section */} - {activeTab === "experimental" && ( - - )} - - {/* Language Section */} - {activeTab === "language" && ( - - )} - - {/* About Section */} - {activeTab === "about" && ( - - )} + {/* Content area - renders only the active tab (or indexing tab during initial indexing) */} + + + {/* Providers Section */} + {renderTab === "providers" && ( +
+ +
+ +
{t("settings:sections.providers")}
+
+
+ +
+ + checkUnsaveChanges(() => + vscode.postMessage({ type: "loadApiConfiguration", text: configName }), + ) + } + onDeleteConfig={(configName: string) => + vscode.postMessage({ type: "deleteApiConfiguration", text: configName }) + } + onRenameConfig={(oldName: string, newName: string) => { + vscode.postMessage({ + type: "renameApiConfiguration", + values: { oldName, newName }, + apiConfiguration, + }) + prevApiConfigName.current = newName + }} + onUpsertConfig={(configName: string) => + vscode.postMessage({ + type: "upsertApiConfiguration", + text: configName, + apiConfiguration, + }) + } + /> + +
+
+ )} + + {/* Auto-Approve Section */} + {renderTab === "autoApprove" && ( + + )} + + {/* Slash Commands Section */} + {renderTab === "slashCommands" && } + + {/* Browser Section */} + {renderTab === "browser" && ( + + )} + + {/* Checkpoints Section */} + {renderTab === "checkpoints" && ( + + )} + + {/* Notifications Section */} + {renderTab === "notifications" && ( + + )} + + {/* Context Management Section */} + {renderTab === "contextManagement" && ( + + )} + + {/* Terminal Section */} + {renderTab === "terminal" && ( + + )} + + {/* Modes Section */} + {renderTab === "modes" && } + + {/* MCP Section */} + {renderTab === "mcp" && } + + {/* Prompts Section */} + {renderTab === "prompts" && ( + + setCachedStateField("includeTaskHistoryInEnhance", value) + } + /> + )} + + {/* UI Section */} + {renderTab === "ui" && ( + + )} + + {/* Experimental Section */} + {renderTab === "experimental" && ( + + )} + + {/* Language Section */} + {renderTab === "language" && ( + + )} + + {/* About Section */} + {renderTab === "about" && ( + + )} +
- {/* Hidden container for indexing searchable settings - rendered once on mount */} - {isIndexing && ( -
- {/* Render all settings sections for indexing */} - - - - - - - - - setCachedStateField("includeTaskHistoryInEnhance", value) - } - /> - - - - -
- )} - diff --git a/webview-ui/src/components/settings/useSettingsSearch.ts b/webview-ui/src/components/settings/useSettingsSearch.ts index f675e2a8e1a..9b2c4648c57 100644 --- a/webview-ui/src/components/settings/useSettingsSearch.ts +++ b/webview-ui/src/components/settings/useSettingsSearch.ts @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback } from "react" +import { useState, useMemo, useCallback, useRef, createContext, useContext } from "react" import { Fzf } from "fzf" import { SectionName } from "./SettingsView" @@ -19,6 +19,59 @@ export interface SearchResult { positions: Set } +/** + * Context for collecting searchable settings as they mount. + * This allows building the search index without rendering all sections. + */ +interface SearchIndexContextValue { + registerSetting: (setting: Omit) => void +} + +const SearchIndexContext = createContext(null) + +export const SearchIndexProvider = SearchIndexContext.Provider + +export function useSearchIndexContext() { + return useContext(SearchIndexContext) +} + +/** + * Hook to create a search index registry. + * Returns the context value and the current index. + */ +export function useSearchIndexRegistry(getSectionLabel: (section: SectionName) => string) { + const settingsRef = useRef>>(new Map()) + const [index, setIndex] = useState([]) + const updateScheduled = useRef(false) + + const scheduleUpdate = useCallback(() => { + if (updateScheduled.current) return + updateScheduled.current = true + + // Batch updates to avoid frequent re-renders + requestAnimationFrame(() => { + const settings = Array.from(settingsRef.current.values()).map((s) => ({ + ...s, + sectionLabel: getSectionLabel(s.section), + })) + setIndex(settings) + updateScheduled.current = false + }) + }, [getSectionLabel]) + + const contextValue = useMemo( + () => ({ + registerSetting: (setting) => { + settingsRef.current.set(setting.settingId, setting) + scheduleUpdate() + }, + }), + [scheduleUpdate], + ) + + return { contextValue, index } +} + /** * Scan the DOM for searchable settings within a container. * This is called once on mount to build the index. From 0e012f762f21287546a8e7d94d353b9726e615e4 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sun, 11 Jan 2026 14:44:07 -0500 Subject: [PATCH 8/8] Fix tests --- .../SettingsView.change-detection.spec.tsx | 20 +++++++++++++++++++ .../SettingsView.unsaved-changes.spec.tsx | 15 ++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx index 494070a1350..cd68c371d6c 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx @@ -42,6 +42,22 @@ vi.mock("@src/components/ui", () => ({ ), StandardTooltip: ({ children }: any) => <>{children}, + Popover: ({ children }: any) => <>{children}, + PopoverTrigger: ({ children }: any) => <>{children}, + PopoverContent: ({ children }: any) =>
{children}
, + Tooltip: ({ children }: any) => <>{children}, + TooltipProvider: ({ children }: any) => <>{children}, + TooltipTrigger: ({ children }: any) => <>{children}, + TooltipContent: ({ children }: any) =>
{children}
, +})) + +// Mock ModesView and McpView since they're rendered during indexing +vi.mock("@src/components/modes/ModesView", () => ({ + default: () => null, +})) + +vi.mock("@src/components/mcp/McpView", () => ({ + default: () => null, })) // Mock Tab components @@ -109,6 +125,10 @@ vi.mock("../UISettings", () => ({ UISettings: () => null, })) +vi.mock("../SettingsSearch", () => ({ + SettingsSearch: () => null, +})) + describe("SettingsView - Change Detection Fix", () => { let queryClient: QueryClient diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx index b40e908f642..6bc6c60167c 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx @@ -47,6 +47,18 @@ vi.mock("@src/components/ui", () => ({ TooltipProvider: ({ children }: any) => <>{children}, TooltipTrigger: ({ children }: any) => <>{children}, StandardTooltip: ({ children, content }: any) =>
{children}
, + Popover: ({ children }: any) => <>{children}, + PopoverTrigger: ({ children }: any) => <>{children}, + PopoverContent: ({ children }: any) =>
{children}
, +})) + +// Mock ModesView and McpView since they're rendered during indexing +vi.mock("@src/components/modes/ModesView", () => ({ + default: () => null, +})) + +vi.mock("@src/components/mcp/McpView", () => ({ + default: () => null, })) // Mock Tab components @@ -115,6 +127,9 @@ vi.mock("../SectionHeader", () => ({ vi.mock("../Section", () => ({ Section: ({ children }: any) =>
{children}
, })) +vi.mock("../SettingsSearch", () => ({ + SettingsSearch: () => null, +})) import { useExtensionState } from "@src/context/ExtensionStateContext" import ApiOptions from "../ApiOptions"