From efa8105036015efeefcb4c5eb136af16f03cc4aa Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 09:49:18 -0700 Subject: [PATCH] FN-7495: add settings search Add a searchable Settings navigation that filters visible sections by setting names and keywords. - Add a Settings search field with clear and empty-state affordances for desktop and mobile layouts. - Filter only the already-visible Settings sections while preserving matching group headers and mobile section labels. - Add searchable section metadata, localized labels, tests, docs, and a release changeset. Files changed: .changeset/fn-7495-settings-search.md | 7 + docs/dashboard-guide.md | 5 + .../dashboard/app/components/SettingsModal.css | 97 +++++- .../dashboard/app/components/SettingsModal.tsx | 346 ++++++++++++++++----- .../__tests__/SettingsModal.general.test.tsx | 71 +++++ .../components/__tests__/settings-mobile.test.tsx | 25 ++ .../settings/sections/McpServersCard.tsx | 2 +- packages/i18n/locales/en/app.json | 11 + 8 files changed, 475 insertions(+), 89 deletions(-) Fusion-Task-Id: FN-7495 Fusion-Task-Lineage: 1d703059-6156-4d20-afbd-3cdb64e3e9f3 Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7495-settings-search.md | 7 + docs/dashboard-guide.md | 5 + .../app/components/SettingsModal.css | 97 ++++- .../app/components/SettingsModal.tsx | 346 +++++++++++++----- .../__tests__/SettingsModal.general.test.tsx | 71 ++++ .../__tests__/settings-mobile.test.tsx | 25 ++ .../settings/sections/McpServersCard.tsx | 2 +- packages/i18n/locales/en/app.json | 11 + 8 files changed, 475 insertions(+), 89 deletions(-) create mode 100644 .changeset/fn-7495-settings-search.md diff --git a/.changeset/fn-7495-settings-search.md b/.changeset/fn-7495-settings-search.md new file mode 100644 index 0000000000..e5ff54d07d --- /dev/null +++ b/.changeset/fn-7495-settings-search.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add search in Settings so operators can find settings faster. +category: feature +dev: Dashboard Settings filters visible sections by setting labels and keywords. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index ed6a343bf0..b7423eae1f 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -8,6 +8,11 @@ The Fusion dashboard is the main control plane for tasks, agents, missions, sett When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. **Update now** installs the latest global package with npm; after it succeeds, restart Fusion to apply the new version because the already-running dashboard server is unchanged until restart. +## Settings discovery + + +Use **Search settings** at the top of Settings to find the section that contains a setting by name or keyword. The same search works in the Settings modal and embedded Settings page, filters both the desktop section list and mobile section picker, and only searches sections currently visible for enabled feature flags. + ## Mobile/PWA app icons The installed mobile/PWA home-screen icons are generated from `packages/dashboard/app/public/logo.svg` by the desktop icon generator. When the Fusion brand mark changes, run `pnpm --filter @fusion/desktop generate:icons` so `packages/dashboard/app/public/icons/icon-192.png` and `packages/dashboard/app/public/icons/icon-512.png` stay aligned with the canonical logo. Also bump `CACHE_NAME` in `packages/dashboard/app/public/sw.js` whenever those icon assets change so installed PWAs refresh the cached launcher images. diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index b67d1f9f90..e627a5993d 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -524,20 +524,84 @@ The embedded title reads like other embedded-view titles (Planning modal-header- overflow: hidden; } +.settings-navigation { + width: calc(var(--space-xl) * 7 + var(--space-sm)); + min-width: calc(var(--space-xl) * 7 + var(--space-sm)); + border-right: var(--btn-border-width) solid var(--border); + display: flex; + flex-direction: column; + min-height: 0; + background: color-mix(in srgb, var(--text) 10%, transparent); +} + +.settings-search { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-sm); + border-bottom: var(--btn-border-width) solid var(--border); +} + +.settings-search-label { + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.settings-search-input-wrap { + display: flex; + align-items: center; + gap: var(--space-xs); + min-width: 0; +} + +.settings-search-input { + min-width: 0; + width: 100%; +} + +.settings-search-clear { + flex-shrink: 0; +} + +.settings-search-results, +.settings-search-empty-hint { + color: var(--text-muted); + font-size: 0.75rem; + margin: 0; +} + +.settings-search-empty { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-md); + color: var(--text-muted); +} + +.settings-search-empty p, +.settings-search-content-empty p { + margin: 0; +} + +.settings-search-content-empty { + gap: var(--space-md); +} + .settings-mobile-section-picker { display: none; } .settings-sidebar { - width: 170px; - min-width: 170px; - border-right: 1px solid var(--border); display: flex; + flex: 1; flex-direction: column; + min-height: 0; overflow-y: auto; padding: var(--space-sm) var(--space-sm); gap: calc(var(--space-xs) / 2); - background: color-mix(in srgb, var(--text) 10%, transparent); scrollbar-color: var(--border) transparent; scrollbar-width: thin; } @@ -2053,12 +2117,33 @@ The header row wraps so the badge drops below the heading on narrow widths inste min-height: 0; } + .settings-navigation { + width: 100%; + min-width: 0; + flex: 0 0 auto; + border-right: none; + border-bottom: var(--btn-border-width) solid var(--border); + background: var(--surface); + } + + .settings-search { + padding: var(--space-md) var(--space-lg) var(--space-sm); + } + + .settings-search-input-wrap { + align-items: stretch; + } + + .settings-search-input { + font-size: 16px; + } + .settings-mobile-section-picker { display: flex; flex-direction: column; gap: var(--space-xs); - padding: var(--space-md) var(--space-lg); - border-bottom: var(--btn-border-width) solid var(--border); + padding: var(--space-sm) var(--space-lg) var(--space-md); + border-bottom: none; background: var(--surface); } diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 9af4f73244..8fa9067710 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef, type CSSProperties, type MouseEvent } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef, type CSSProperties, type MouseEvent } from "react"; import { Globe, Folder, RefreshCw, Star, HelpCircle, Settings as SettingsIcon } from "lucide-react"; import { getErrorMessage, @@ -218,11 +218,83 @@ type SettingsSection = { scope: "global" | "project" | undefined; icon?: typeof Globe; isGroupHeader?: boolean; + searchableText?: string[]; + searchableKeys?: string[]; }; const MOBILE_SETTINGS_MEDIA_QUERY = "(max-width: 768px)"; const DEFAULT_MEMORY_EDITOR_PATH = ".fusion/memory/DREAMS.md"; +function normalizeSettingsSearchText(value: string): string { + return value.trim().toLocaleLowerCase(); +} + +function sectionMatchesSettingsSearch( + section: SettingsSection, + query: string, + label: string, + translateSearchKey: (key: string) => string, +): boolean { + if (!query || section.isGroupHeader) { + return true; + } + + return [ + label, + ...(section.searchableText ?? []), + ...(section.searchableKeys ?? []).map((key) => translateSearchKey(key)), + ] + .map(normalizeSettingsSearchText) + .some((candidate) => candidate.includes(query)); +} + +function filterSettingsSectionsForSearch( + sections: SettingsSection[], + query: string, + translateLabel: (section: SettingsSection) => string, + translateSearchKey: (key: string) => string, +): SettingsSection[] { + if (!query) { + return sections; + } + + const matchedIds = new Set( + sections + .filter((section) => !section.isGroupHeader && sectionMatchesSettingsSearch(section, query, translateLabel(section), translateSearchKey)) + .map((section) => section.id), + ); + + return sections.filter((section, index) => { + if (!section.isGroupHeader) { + return matchedIds.has(section.id); + } + + for (const candidate of sections.slice(index + 1)) { + if (candidate.isGroupHeader) { + return false; + } + if (matchedIds.has(candidate.id)) { + return true; + } + } + return false; + }); +} + +function resolveFirstSelectableSettingsSection(sections: SettingsSection[], fallback: string): string { + return sections.find((section) => !section.isGroupHeader)?.id ?? fallback; +} + +function resolveSettingsSectionOptionLabel(section: SettingsSection, label: string): string { + if (section.scope === "global") { + return `Global — ${label}`; + } + if (section.scope === "project") { + return `Project — ${label}`; + } + return label; +} + function resolveMaxAutoMergeRetriesForSettingsForm(settings?: { maxAutoMergeRetries?: unknown } | null): number { const configured = Number(settings?.maxAutoMergeRetries); return Number.isFinite(configured) && configured > 0 ? Math.floor(configured) : 3; @@ -231,46 +303,78 @@ function resolveMaxAutoMergeRetriesForSettingsForm(settings?: { maxAutoMergeRetr const SETTINGS_SECTIONS: SettingsSection[] = [ // Global group (shared across all Fusion projects) { id: "__global_header", label: "Global", labelKey: "settings.nav.globalHeader", scope: undefined, isGroupHeader: true }, - { id: "global-general", label: "General", labelKey: "settings.nav.globalGeneral", scope: "global" }, - { id: "authentication", label: "Authentication", labelKey: "settings.nav.authentication", scope: undefined, icon: Globe }, - { id: "appearance", label: "Appearance", labelKey: "settings.nav.appearance", scope: "global" }, - { id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global" }, - { id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", scope: "global" }, - { id: "global-models", label: "Models", labelKey: "settings.nav.globalModels", scope: "global" }, - { id: "global-mcp", label: "MCP Servers", labelKey: "settings.nav.globalMcp", scope: "global" }, - { id: "cli-agents", label: "CLI Agents", labelKey: "settings.nav.cliAgents", scope: "global" }, - { id: "research-global", label: "Research Defaults", labelKey: "settings.nav.researchGlobal", scope: "global" }, + { id: "global-general", label: "General", labelKey: "settings.nav.globalGeneral", scope: "global", searchableText: ["global defaults", "modal outside dismiss", "agent logs", "persist tool output", "thinking logs", "GitLab instance URL", "global tracking repo"] }, + { id: "authentication", label: "Authentication", labelKey: "settings.nav.authentication", scope: undefined, icon: Globe, searchableText: ["login", "OAuth", "API key", "custom providers", "Anthropic", "OpenAI", "provider credentials"] }, + { id: "appearance", label: "Appearance", labelKey: "settings.nav.appearance", scope: "global", searchableText: ["theme", "color", "sidebar", "dock", "task popup", "open tasks as popups", "quick chat"] }, + { id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global", searchableText: ["ntfy", "webhook", "events", "failure notifications", "sticky", "toast"] }, + { id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", scope: "global", searchableText: ["sync", "node", "distributed", "heartbeat", "coordination"] }, + { id: "global-models", label: "Models", labelKey: "settings.nav.globalModels", scope: "global", searchableText: ["global models", "model presets", "favorite providers", "model pricing overrides", "LiteLLM pricing", "token pricing"] }, + { id: "global-mcp", label: "MCP Servers", labelKey: "settings.nav.globalMcp", scope: "global", searchableText: ["global MCP servers", "shared MCP", "user MCP", "tool servers"] }, + { + id: "cli-agents", + label: "CLI Agents", + labelKey: "settings.nav.cliAgents", + scope: "global", + searchableText: [ + "Droid CLI", + "Cursor CLI", + "agent runtime", + "command line agents", + "Adapter", + "Command override", + "Path or name of the binary to launch", + "Extra arguments", + "Appended after the adapter's computed arguments", + "Environment variable additions", + "Comma-separated variable names forwarded", + "Autonomy mode", + "Elevated autonomy requires a per-project approval", + ], + searchableKeys: [ + "settings.cliAgents.adapterLabel", + "settings.cliAgents.commandLabel", + "settings.cliAgents.commandHelp", + "settings.cliAgents.extraArgsLabel", + "settings.cliAgents.extraArgsHelp", + "settings.cliAgents.envLabel", + "settings.cliAgents.envHelp", + "settings.cliAgents.autonomyLabel", + "settings.cliAgents.autonomyHelp", + "settings.cliAgents.approvedNote", + ], + }, + { id: "research-global", label: "Research Defaults", labelKey: "settings.nav.researchGlobal", scope: "global", searchableText: ["research providers", "external search providers", "fetch limits", "global research defaults", "citations"] }, /* FNXC:SettingsNavigation 2026-06-26-09:20: FN-7062 requires the remote settings nav entry to read "Remote Access" only. The stale "& Node Sync" suffix belongs to the separate Node Sync settings section, while this section body already uses the Remote Access heading. */ - { id: "remote", label: "Remote Access", labelKey: "settings.nav.remote", scope: "global" }, - { id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global" }, + { id: "remote", label: "Remote Access", labelKey: "settings.nav.remote", scope: "global", searchableText: ["cloudflared", "tunnel", "QR", "persistent token", "remote URL"] }, + { id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global", searchableText: ["feature flags", "experiments", "research view", "evals view", "sandbox", "subtask breakdown"] }, // Runtimes group (plugin runtimes with their own settings) { id: "__runtimes_header", label: "Runtimes", labelKey: "settings.nav.runtimesHeader", scope: undefined, isGroupHeader: true }, - { id: "hermes-runtime", label: "Hermes", labelKey: "settings.nav.hermesRuntime", scope: "global" }, - { id: "openclaw-runtime", label: "OpenClaw", labelKey: "settings.nav.openclawRuntime", scope: "global" }, - { id: "paperclip-runtime", label: "Paperclip", labelKey: "settings.nav.paperclipRuntime", scope: "global" }, + { id: "hermes-runtime", label: "Hermes", labelKey: "settings.nav.hermesRuntime", scope: "global", searchableText: ["Hermes runtime", "plugin runtime", "printer runtime"] }, + { id: "openclaw-runtime", label: "OpenClaw", labelKey: "settings.nav.openclawRuntime", scope: "global", searchableText: ["OpenClaw runtime", "plugin runtime", "open claw"] }, + { id: "paperclip-runtime", label: "Paperclip", labelKey: "settings.nav.paperclipRuntime", scope: "global", searchableText: ["Paperclip runtime", "plugin runtime"] }, // Project group (specific to this project) { id: "__project_header", label: "Project", labelKey: "settings.nav.projectHeader", scope: undefined, isGroupHeader: true }, - { id: "general", label: "Project General", labelKey: "settings.nav.projectGeneral", scope: "project" }, - { id: "commands", label: "Commands & Scripts", labelKey: "settings.nav.commands", scope: "project" }, - { id: "worktrees", label: "Worktrees", labelKey: "settings.nav.worktrees", scope: "project" }, - { id: "scheduling", label: "Scheduling & Capacity", labelKey: "settings.nav.scheduling", scope: "project" }, - { id: "scheduled-evals", label: "Scheduled Evals", labelKey: "settings.nav.scheduledEvals", scope: "project" }, - { id: "node-routing", label: "Node Routing", labelKey: "settings.nav.nodeRouting", scope: "project" }, - { id: "merge", label: "Merge", labelKey: "settings.nav.merge", scope: "project" }, - { id: "agent-permissions", label: "Agents & Permissions", labelKey: "settings.nav.agentPermissions", scope: "project" }, - { id: "memory", label: "Memory", labelKey: "settings.nav.memory", scope: "project" }, - { id: "backups", label: "Backups", labelKey: "settings.nav.backups", scope: "project" }, - { id: "research-project", label: "Research", labelKey: "settings.nav.researchProject", scope: "project" }, - { id: "project-models", label: "Project Models", labelKey: "settings.nav.projectModels", scope: "project" }, - { id: "secrets", label: "Secrets", labelKey: "settings.nav.secrets", scope: "project" }, - { id: "mcp", label: "MCP Servers", labelKey: "settings.nav.mcp", scope: "project" }, - { id: "prompts", label: "Prompts", labelKey: "settings.nav.prompts", scope: "project" }, - { id: "plugins", label: "Plugins", labelKey: "settings.nav.plugins", scope: "project" }, + { id: "general", label: "Project General", labelKey: "settings.nav.projectGeneral", scope: "project", searchableText: ["project general", "Completion Documentation Automation", "Quick Chat launcher", "ephemeral task-worker agents", "GitHub tracking", "GitLab integration", "chat rooms", "auto-cleanup old chats"] }, + { id: "commands", label: "Commands & Scripts", labelKey: "settings.nav.commands", scope: "project", searchableText: ["test command", "build command", "verification command", "workflow scripts", "commands"] }, + { id: "worktrees", label: "Worktrees", labelKey: "settings.nav.worktrees", scope: "project", searchableText: ["worktree directory", "copy files", "recycle worktrees", "branch naming", "sibling branch rename"] }, + { id: "scheduling", label: "Scheduling & Capacity", labelKey: "settings.nav.scheduling", scope: "project", searchableText: ["max concurrent", "capacity", "stuck tasks", "poll interval", "parallel steps", "scheduler"] }, + { id: "scheduled-evals", label: "Scheduled Evals", labelKey: "settings.nav.scheduledEvals", scope: "project", searchableText: ["scheduled evals", "evaluation schedule", "eval runs", "quality jobs"] }, + { id: "node-routing", label: "Node Routing", labelKey: "settings.nav.nodeRouting", scope: "project", searchableText: ["node routing", "routing rules", "node selection", "execution nodes"] }, + { id: "merge", label: "Merge", labelKey: "settings.nav.merge", scope: "project", searchableText: ["auto merge", "AI merge", "merge strategy", "plan approval", "direct merge", "integration branch", "push after merge"] }, + { id: "agent-permissions", label: "Agents & Permissions", labelKey: "settings.nav.agentPermissions", scope: "project", searchableText: ["agent provisioning", "approval", "permissions", "policy", "agent creation"] }, + { id: "memory", label: "Memory", labelKey: "settings.nav.memory", scope: "project", searchableText: ["memory backend", "Dreams", "long-term memory", "qmd", "memory file", "retrieval"] }, + { id: "backups", label: "Backups", labelKey: "settings.nav.backups", scope: "project", searchableText: ["backup", "restore", "settings export", "settings import"] }, + { id: "research-project", label: "Research", labelKey: "settings.nav.researchProject", scope: "project", searchableText: ["project research", "research runs", "citations", "search limits", "fetch synthesis"] }, + { id: "project-models", label: "Project Models", labelKey: "settings.nav.projectModels", scope: "project", searchableText: ["default provider", "default model", "workflow model lanes", "Plan/Triage", "Executor", "Reviewer", "summarization model"] }, + { id: "secrets", label: "Secrets", labelKey: "settings.nav.secrets", scope: "project", searchableText: ["secrets", "secret storage", "environment", "credentials"] }, + { id: "mcp", label: "MCP Servers", labelKey: "settings.nav.mcp", scope: "project", searchableText: ["project MCP servers", "workspace MCP", "project tool servers", "mcp config"] }, + { id: "prompts", label: "Prompts", labelKey: "settings.nav.prompts", scope: "project", searchableText: ["prompt instructions", "PR title prompt", "PR description prompt", "custom prompts"] }, + { id: "plugins", label: "Plugins", labelKey: "settings.nav.plugins", scope: "project", searchableText: ["Fusion plugins", "Pi extensions", "plugin manager", "extension marketplace"] }, ]; /** Well-known experimental feature flags with display labels. @@ -812,6 +916,7 @@ export function SettingsModal({ ? window.matchMedia(MOBILE_SETTINGS_MEDIA_QUERY)?.matches === true : false, ); + const [settingsSearchQuery, setSettingsSearchQuery] = useState(""); const [appVersion, setAppVersion] = useState(null); const [updateCheckLoading, setUpdateCheckLoading] = useState(false); const [updateCheckResult, setUpdateCheckResult] = useState(null); @@ -856,7 +961,7 @@ export function SettingsModal({ const experimentalFeatures = form.experimentalFeatures ?? {}; const researchViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "researchView"); const evalsViewEnabled = isExperimentalFeatureEnabled(experimentalFeatures, "evalsView"); - const visibleSections = SETTINGS_SECTIONS.filter((section) => { + const visibleSections = useMemo(() => SETTINGS_SECTIONS.filter((section) => { if (section.id === "research-global" || section.id === "research-project") { return researchViewEnabled; } @@ -866,10 +971,25 @@ export function SettingsModal({ } return true; - }); + }), [researchViewEnabled, evalsViewEnabled]); const firstVisibleSectionId = visibleSections.some((section) => section.id === DEFAULT_SETTINGS_SECTION) ? DEFAULT_SETTINGS_SECTION - : (visibleSections.find((section) => !section.isGroupHeader)?.id ?? firstNonHeaderSection?.id ?? "general"); + : resolveFirstSelectableSettingsSection(visibleSections, firstNonHeaderSection?.id ?? "general"); + const normalizedSettingsSearchQuery = normalizeSettingsSearchText(settingsSearchQuery); + /* + FNXC:SettingsSearch 2026-07-04-00:00: + Operators need Settings search to find the section containing a setting without bypassing feature gates. Search filters only the already-visible section list, matches section labels plus real setting-label/help i18n keys and curated keywords, suppresses empty group headers, and keeps duplicate global/project labels distinguishable in the mobile picker. + */ + const searchMatchedSections = useMemo(() => filterSettingsSectionsForSearch( + visibleSections, + normalizedSettingsSearchQuery, + (section) => t(section.labelKey, section.label), + (key) => t(key), + ), [normalizedSettingsSearchQuery, t, visibleSections]); + const searchableSectionOptions = searchMatchedSections.filter((section) => !section.isGroupHeader); + const hasSettingsSearchQuery = normalizedSettingsSearchQuery.length > 0; + const hasSettingsSearchResults = searchableSectionOptions.length > 0; + const firstSearchMatchedSectionId = resolveFirstSelectableSettingsSection(searchMatchedSections, firstVisibleSectionId); /** Get the scope of the currently active section */ const activeSectionScope = visibleSections.find((s) => s.id === activeSection)?.scope; @@ -887,8 +1007,13 @@ export function SettingsModal({ if (!visibleSections.some((section) => section.id === activeSection)) { setActiveSection(firstVisibleSectionId); + return; } - }, [activeSection, researchViewEnabled, evalsViewEnabled, firstVisibleSectionId, visibleSections]); + + if (hasSettingsSearchQuery && hasSettingsSearchResults && !searchMatchedSections.some((section) => section.id === activeSection)) { + setActiveSection(firstSearchMatchedSectionId); + } + }, [activeSection, researchViewEnabled, evalsViewEnabled, firstVisibleSectionId, firstSearchMatchedSectionId, hasSettingsSearchQuery, hasSettingsSearchResults, searchMatchedSections, visibleSections]); // Auth state (independent of the settings save flow) const [authProviders, setAuthProviders] = useState([]); @@ -3276,58 +3401,115 @@ export function SettingsModal({
) : (
- {showMobileSectionPicker && ( -
- - +
- {renderSectionFields()} + {hasSettingsSearchResults ? renderSectionFields() : ( +
+

{t("settings.search.noResults", "No settings sections match \"{{query}}\".", { query: settingsSearchQuery.trim() })}

+ +
+ )}
)} diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index 8c42fa2e76..c4a2388890 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -236,6 +236,61 @@ describe("SettingsModal", () => { expect(screen.getByRole("heading", { name: "Authentication" })).toBeInTheDocument(); }); + it("filters settings navigation by section and setting-level keywords without exposing hidden sections", async () => { + renderModal(); + await waitForSettingsModalReady(); + + const search = screen.getByTestId("settings-search-input"); + expect(screen.getByRole("button", { name: /^General$/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Project General$/ })).toBeInTheDocument(); + + await settingsModalUser.type(search, " "); + expect(screen.getByRole("button", { name: /^General$/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Project General$/ })).toBeInTheDocument(); + + await settingsModalUser.clear(search); + await settingsModalUser.type(search, "completion documentation"); + + expect(screen.queryByRole("button", { name: /^General$/ })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Project General$/ })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "General" })).toBeInTheDocument(); + expect(screen.getByText("1 matching sections")).toBeInTheDocument(); + + await settingsModalUser.clear(search); + await settingsModalUser.type(search, "Autonomy mode"); + + expect(screen.queryByRole("button", { name: /^Project General$/ })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^CLI Agents$/ })).toBeInTheDocument(); + expect(screen.getByTestId("cli-agents-settings")).toBeInTheDocument(); + + await settingsModalUser.clear(search); + await settingsModalUser.type(search, "research providers"); + + expect(screen.queryByRole("button", { name: /^Research Defaults$/ })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Research$/ })).not.toBeInTheDocument(); + expect(screen.getAllByText(/No settings sections match/).length).toBeGreaterThan(0); + }); + + it("keeps duplicate global and project labels searchable while preserving no-results clearing", async () => { + renderModal(); + await waitForSettingsModalReady(); + + const search = screen.getByTestId("settings-search-input"); + await settingsModalUser.type(search, "mcp"); + + const matches = screen.getAllByRole("button", { name: /^MCP Servers$/ }); + expect(matches).toHaveLength(2); + expect(screen.getByText("2 matching sections")).toBeInTheDocument(); + + await settingsModalUser.clear(search); + await settingsModalUser.type(search, "definitely not a setting"); + + expect(screen.queryByRole("button", { name: /^MCP Servers$/ })).not.toBeInTheDocument(); + await settingsModalUser.click(screen.getAllByRole("button", { name: "Clear settings search" })[0]); + expect(screen.getByRole("button", { name: /^General$/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Project General$/ })).toBeInTheDocument(); + }); + it("keeps settings file pickers workspace-confined even when absolute browsing exists", async () => { renderModal({ initialSection: "worktrees" }); await waitForSettingsModalReady(); @@ -285,6 +340,22 @@ describe("SettingsModal", () => { expect(onClose).not.toHaveBeenCalled(); }); + it("renders settings search and clears Escape without closing embedded Settings", async () => { + const onClose = vi.fn(); + renderModal({ presentation: "embedded", onClose }); + await waitForSettingsModalReady(); + + const search = screen.getByTestId("settings-search-input"); + await settingsModalUser.type(search, "model pricing"); + expect(screen.getByRole("button", { name: /^Models$/ })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^Project General$/ })).not.toBeInTheDocument(); + + fireEvent.keyDown(search, { key: "Escape" }); + expect(onClose).not.toHaveBeenCalled(); + expect(search).toHaveValue(""); + expect(screen.getByRole("button", { name: /^Project General$/ })).toBeInTheDocument(); + }); + it("keeps the overlay and Escape-to-close in modal mode", async () => { const onClose = vi.fn(); const { container } = renderModal({ onClose }); diff --git a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx index 5a0e7991d7..a55edf3986 100644 --- a/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/settings-mobile.test.tsx @@ -277,6 +277,27 @@ describe("SettingsModal mobile adaptations", () => { expect(optionValues).toContain("research-project"); }); + it("filters the mobile section picker from settings search results with distinct duplicate labels", async () => { + mockSettingsViewport(true); + const user = userEvent.setup(); + const { getByLabelText, getByTestId, queryByLabelText, getByText } = render(); + await waitFor(() => expect(fetchSettings).toHaveBeenCalled()); + + const search = getByTestId("settings-search-input"); + await user.type(search, "mcp"); + + const picker = getByLabelText("Settings Section") as HTMLSelectElement; + const labels = Array.from(picker.options).map((opt) => opt.textContent); + expect(labels).toEqual(["Global — MCP Servers", "Project — MCP Servers"]); + expect(Array.from(picker.options).map((opt) => opt.value)).toEqual(["global-mcp", "mcp"]); + + await user.clear(search); + await user.type(search, "research providers"); + + expect(queryByLabelText("Settings Section")).toBeNull(); + expect(getByText("No sections match this search.")).toBeTruthy(); + }); + it("can open memory settings from the mobile section picker", async () => { mockSettingsViewport(true); const user = userEvent.setup(); @@ -441,6 +462,8 @@ describe("SettingsModal mobile adaptations", () => { expectMobileRule(css, ".settings-layout", "flex-direction: column;"); expectMobileRule(css, ".settings-mobile-section-picker", "display: flex;"); + expectMobileRule(css, ".settings-navigation", "width: 100%;"); + expectMobileRule(css, ".settings-search", "padding: var(--space-md) var(--space-lg) var(--space-sm);"); expectMobileRule(css, ".settings-sidebar", "display: none;"); expectMobileRule(css, ".settings-nav-item", "display: flex;"); expectMobileRule(css, ".settings-nav-item", "align-items: center;"); @@ -502,6 +525,8 @@ describe("SettingsModal mobile adaptations", () => { it("styles settings scrollbar rules for sidebar and content", () => { const css = loadAllAppCss(); + expectBaseRule(css, ".settings-navigation", "border-right: var(--btn-border-width) solid var(--border);"); + expectBaseRule(css, ".settings-search", "border-bottom: var(--btn-border-width) solid var(--border);"); expectBaseRule(css, ".settings-sidebar", "scrollbar-color: var(--border) transparent;"); expectBaseRule(css, ".settings-sidebar", "scrollbar-width: thin;"); expectBaseRule(css, ".settings-sidebar::-webkit-scrollbar", "width: 6px;"); diff --git a/packages/dashboard/app/components/settings/sections/McpServersCard.tsx b/packages/dashboard/app/components/settings/sections/McpServersCard.tsx index c704cfdbec..1cd5b27196 100644 --- a/packages/dashboard/app/components/settings/sections/McpServersCard.tsx +++ b/packages/dashboard/app/components/settings/sections/McpServersCard.tsx @@ -547,7 +547,7 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
{discoveryError ?

{discoveryError}

: null} - {(discovered?.errors.length ?? 0) > 0 ?
{discovered?.errors.map((error) =>

{error}

)}
: null} + {(discovered?.errors?.length ?? 0) > 0 ?
{discovered?.errors?.map((error) =>

{error}

)}
: null} {!discoveryLoading && discoveredGroups.length === 0 ?

{t("settings.mcp.discoveryEmpty", "No MCP servers found in supported tool configs yet.")}

: null} {discoveredGroups.map(([label, entries]) => (
diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 168ab300c9..20d8d4d7a8 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -5,6 +5,7 @@ "back": "Back", "cancel": "Cancel", "close": "Close", + "clear": "Clear", "closeInsightsView": "Close insights view", "closeModal": "Close modal", "confirm": "Confirm", @@ -5790,6 +5791,16 @@ "checkUpdates": "Check for updates", "helpDiscussions": "Help and discussions" }, + "search": { + "allSections": "Showing all settings sections", + "clear": "Clear settings search", + "label": "Search settings", + "navigationLabel": "Settings navigation", + "noMobileOptions": "No sections match this search.", + "noResults": "No settings sections match \"{{query}}\".", + "placeholder": "Search by setting or section", + "resultCount": "{{count}} matching sections" + }, "general": { "25": "25", "200": "200",