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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 09:49:18 -07:00
parent 5689346afc
commit efa8105036
8 changed files with 475 additions and 89 deletions

View File

@@ -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.

View File

@@ -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
<!-- FNXC:SettingsSearchDocs 2026-07-04-00:00: Settings search is section-discovery, not a global command palette. Document that it filters visible Settings sections by section names and setting keywords while preserving feature-gated hidden sections. -->
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.

View File

@@ -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);
}

View File

@@ -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<string | null>(null);
const [updateCheckLoading, setUpdateCheckLoading] = useState(false);
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(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<AuthProvider[]>([]);
@@ -3276,58 +3401,115 @@ export function SettingsModal({
<div className="settings-empty-state settings-loading"><LoadingSpinner label={t("settings.loading", "Loading…")} /></div>
) : (
<div className="settings-layout">
{showMobileSectionPicker && (
<div className="settings-mobile-section-picker">
<label htmlFor="settings-mobile-section">{t("settings.mobileNav.label", "Settings Section")}</label>
<select
id="settings-mobile-section"
className="select touch-target"
value={activeSection}
onChange={(event) => setActiveSection(event.target.value as SectionId)}
>
{visibleSections.filter((section) => !section.isGroupHeader).map((section) => (
<option key={section.id} value={section.id}>
{t(section.labelKey, section.label)}
</option>
))}
</select>
<aside className="settings-navigation" aria-label={t("settings.search.navigationLabel", "Settings navigation")}>
<div className="settings-search" data-testid="settings-search">
<label className="settings-search-label" htmlFor="settings-search-input">
{t("settings.search.label", "Search settings")}
</label>
<div className="settings-search-input-wrap">
<input
id="settings-search-input"
data-testid="settings-search-input"
className="input settings-search-input"
type="search"
value={settingsSearchQuery}
onChange={(event) => setSettingsSearchQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape" && hasSettingsSearchQuery) {
event.stopPropagation();
setSettingsSearchQuery("");
}
}}
placeholder={t("settings.search.placeholder", "Search by setting or section")}
aria-describedby="settings-search-results"
/>
{hasSettingsSearchQuery && (
<button
type="button"
className="btn btn-sm settings-search-clear"
onClick={() => setSettingsSearchQuery("")}
aria-label={t("settings.search.clear", "Clear settings search")}
>
{t("actions.clear", "Clear")}
</button>
)}
</div>
<div id="settings-search-results" className="settings-search-results" aria-live="polite">
{hasSettingsSearchQuery
? t("settings.search.resultCount", "{{count}} matching sections", { count: searchableSectionOptions.length })
: t("settings.search.allSections", "Showing all settings sections")}
</div>
</div>
)}
<nav className="settings-sidebar">
{visibleSections.map((section) => {
// Render group headers as non-clickable styled divs
if (section.isGroupHeader) {
{showMobileSectionPicker && (
<div className="settings-mobile-section-picker">
<label htmlFor="settings-mobile-section">{t("settings.mobileNav.label", "Settings Section")}</label>
{hasSettingsSearchResults ? (
<select
id="settings-mobile-section"
className="select touch-target"
value={activeSection}
onChange={(event) => setActiveSection(event.target.value as SectionId)}
>
{searchableSectionOptions.map((section) => {
const label = t(section.labelKey, section.label);
return (
<option key={section.id} value={section.id}>
{resolveSettingsSectionOptionLabel(section, label)}
</option>
);
})}
</select>
) : (
<p className="settings-search-empty-hint">{t("settings.search.noMobileOptions", "No sections match this search.")}</p>
)}
</div>
)}
<nav className="settings-sidebar">
{hasSettingsSearchResults ? searchMatchedSections.map((section) => {
// Render group headers as non-clickable styled divs
if (section.isGroupHeader) {
return (
<div key={section.id} className="settings-group-header">
{t(section.labelKey, section.label)}
</div>
);
}
return (
<div key={section.id} className="settings-group-header">
<button
key={section.id}
className={`settings-nav-item${activeSection === section.id ? " active" : ""}`}
onClick={() => setActiveSection(section.id)}
title={
section.scope === "global"
? t("settings.nav.tooltip.global", "Shared across all projects")
: section.scope === "project"
? t("settings.nav.tooltip.project", "Specific to this project")
: undefined
}
>
{section.scope === "global" && <Globe className="settings-scope-icon" aria-label={t("settings.nav.aria.global", "Global setting")} size={16} />}
{section.scope === "project" && <Folder className="settings-scope-icon" aria-label={t("settings.nav.aria.project", "Project setting")} size={16} />}
{section.icon && !section.scope && (
<section.icon className="settings-scope-icon" aria-label={t("settings.nav.aria.global", "Global setting")} size={16} />
)}
{t(section.labelKey, section.label)}
</div>
</button>
);
}
return (
<button
key={section.id}
className={`settings-nav-item${activeSection === section.id ? " active" : ""}`}
onClick={() => setActiveSection(section.id)}
title={
section.scope === "global"
? t("settings.nav.tooltip.global", "Shared across all projects")
: section.scope === "project"
? t("settings.nav.tooltip.project", "Specific to this project")
: undefined
}
>
{section.scope === "global" && <Globe className="settings-scope-icon" aria-label={t("settings.nav.aria.global", "Global setting")} size={16} />}
{section.scope === "project" && <Folder className="settings-scope-icon" aria-label={t("settings.nav.aria.project", "Project setting")} size={16} />}
{section.icon && !section.scope && (
<section.icon className="settings-scope-icon" aria-label={t("settings.nav.aria.global", "Global setting")} size={16} />
)}
{t(section.labelKey, section.label)}
</button>
);
})}
</nav>
}) : (
<div className="settings-search-empty" role="status">
<p>{t("settings.search.noResults", "No settings sections match \"{{query}}\".", { query: settingsSearchQuery.trim() })}</p>
<button type="button" className="btn btn-sm" onClick={() => setSettingsSearchQuery("")}>{t("settings.search.clear", "Clear settings search")}</button>
</div>
)}
</nav>
</aside>
<div className="settings-content" ref={settingsContentRef}>
{renderSectionFields()}
{hasSettingsSearchResults ? renderSectionFields() : (
<div className="settings-empty-state settings-search-content-empty" role="status">
<p>{t("settings.search.noResults", "No settings sections match \"{{query}}\".", { query: settingsSearchQuery.trim() })}</p>
<button type="button" className="btn" onClick={() => setSettingsSearchQuery("")}>{t("settings.search.clear", "Clear settings search")}</button>
</div>
)}
</div>
</div>
)}

View File

@@ -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 });

View File

@@ -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(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
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;");

View File

@@ -547,7 +547,7 @@ export function McpServersCard({ scope, form, setForm, globalSettings, projectId
<button type="button" className="btn btn-sm touch-target" onClick={() => void scanDiscoveredServers()} disabled={discoveryLoading}><RefreshCw aria-hidden="true" /> {discoveryLoading ? t("settings.mcp.scanning", "Scanning…") : t("settings.mcp.scanAgain", "Scan again")}</button>
</div>
{discoveryError ? <p className="form-error">{discoveryError}</p> : null}
{(discovered?.errors.length ?? 0) > 0 ? <div className="mcp-discovery__notes" role="note">{discovered?.errors.map((error) => <p key={error}>{error}</p>)}</div> : null}
{(discovered?.errors?.length ?? 0) > 0 ? <div className="mcp-discovery__notes" role="note">{discovered?.errors?.map((error) => <p key={error}>{error}</p>)}</div> : null}
{!discoveryLoading && discoveredGroups.length === 0 ? <p className="mcp-empty" data-testid={`mcp-discovery-empty-${scope}`}>{t("settings.mcp.discoveryEmpty", "No MCP servers found in supported tool configs yet.")}</p> : null}
{discoveredGroups.map(([label, entries]) => (
<div className="mcp-discovery__group" key={label}>

View File

@@ -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",