diff --git a/.changeset/views-settings-sections-api.md b/.changeset/views-settings-sections-api.md
new file mode 100644
index 0000000000..ef9dd1b557
--- /dev/null
+++ b/.changeset/views-settings-sections-api.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": minor
+---
+
+summary: Add read-only APIs that enumerate dashboard views and settings sections.
+category: feature
+dev: Shared dashboard metadata now drives both the UI and API, with sync tests preventing drift.
diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md
index f9a80dfe7f..06a65aa4f4 100644
--- a/docs/PLUGIN_AUTHORING.md
+++ b/docs/PLUGIN_AUTHORING.md
@@ -595,6 +595,71 @@ Route handlers receive the same loader-built `PluginContext` used by hooks/tools
- Route path: `/status`
- Full URL: `/api/plugins/fusion-plugin-notification/status`
+### UI metadata endpoints
+
+
+
+Frontend integrations can enumerate the host UI before presenting navigation or settings commands. Both endpoints inherit the dashboard's standard `/api` authentication, are read-only, return static project-independent metadata, and do not require a project id.
+
+`GET /api/views` returns every registered built-in view id in dashboard order. This is the full registry, not a live navigation menu: it includes ids that are flag-gated or experimental (for example `graph`, `todos`, `secrets`), and internal, non-navigable destinations (see `internal` below). Whether any given id is currently reachable in the UI depends on feature flags and plugins the endpoint does not evaluate, so treat the list as the set of known view ids rather than a guaranteed set of visible navigation entries.
+
+```json
+{
+ "views": [
+ { "id": "board", "label": "Board", "labelKey": "nav.board" },
+ { "id": "command-center", "label": "Dashboard", "labelKey": "nav.commandCenter" },
+ {
+ "id": "dev-server",
+ "label": "Dev Server",
+ "labelKey": "nav.devServer",
+ "aliases": ["devserver"]
+ },
+ {
+ "id": "task-detail",
+ "label": "Task Detail",
+ "internal": true
+ }
+ ]
+}
+```
+
+`aliases` lists accepted legacy ids; use the canonical `id` for new links. `internal: true` identifies a programmatic destination that is not a normal navigation entry. Optional fields are omitted when they do not apply.
+
+`label` is the guaranteed English display string. `labelKey` is present only for views whose title the dashboard itself renders through that translation key, so a few ids (for example `graph`, whose label comes from a plugin manifest, and the internal `task-detail`) carry no `labelKey` at all — fall back to `label` when it is absent. Note also that some keys are not yet present in the shipped translation catalogs because the dashboard supplies their English text inline; resolving such a key yields nothing, so treat `labelKey` as best-effort localization support rather than a guaranteed lookup.
+
+`GET /api/settings/sections` returns selectable Settings sections; non-selectable group-header rows are excluded:
+
+```json
+{
+ "sections": [
+ {
+ "id": "appearance",
+ "label": "Appearance",
+ "labelKey": "settings.nav.appearance",
+ "scope": "global",
+ "group": "Preferences",
+ "keywords": ["theme", "color", "sidebar"],
+ "searchableKeys": [],
+ "advanced": false
+ },
+ {
+ "id": "authentication",
+ "label": "Authentication",
+ "labelKey": "settings.nav.authentication",
+ "scope": null,
+ "group": "AI & Models",
+ "keywords": ["login", "OAuth", "API key"],
+ "searchableKeys": [],
+ "advanced": false
+ }
+ ]
+}
+```
+
+`scope` is `global`, `project`, or `null` for sections backed by a dedicated subsystem rather than a settings blob. `group` is the Settings navigation group label, `keywords` and `searchableKeys` support command/search matching, and `advanced` indicates whether the dashboard hides the section until Advanced settings are enabled.
+
+`keywords` and `searchableKeys` are best-effort, non-contractual search hints, not a stable API surface. `searchableKeys` in particular exposes raw i18n translation-key strings that back a section's searchable copy; their exact values, ordering, and presence may change between releases as the dashboard's internal translation keys evolve. Use them to widen local search matching, but do not treat any specific key string as a stable identifier or depend on it programmatically.
+
### Supported Methods
- `GET`
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 95ac09d10e..de443ef050 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -26,6 +26,9 @@ Never copy worktree files into the reported checkout, rebuild an unmerged branch
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.
+
+Plugin and command-palette authors can discover the dashboard's registered view ids through `GET /api/views` and selectable Settings sections through `GET /api/settings/sections`. These authenticated, read-only endpoints return static metadata; see the [Plugin Authoring Guide](./PLUGIN_AUTHORING.md#ui-metadata-endpoints) for response fields and examples.
+
On mobile, the search row starts collapsed behind a compact toggle icon beside the **Settings Section** dropdown to save vertical space; tap it to reveal the search input and tap again to hide it. An in-progress search query is preserved across collapse/expand. Desktop and tablet always show the search row with no toggle.
diff --git a/packages/dashboard/app/components/LeftSidebarNav.tsx b/packages/dashboard/app/components/LeftSidebarNav.tsx
index d615ff7fbc..38acf0383e 100644
--- a/packages/dashboard/app/components/LeftSidebarNav.tsx
+++ b/packages/dashboard/app/components/LeftSidebarNav.tsx
@@ -33,6 +33,7 @@ import type { TaskView } from "../hooks/useViewState";
import { buildPluginTaskViewId } from "../plugins/pluginViewRegistry";
import { getPluginDashboardViewNavIcon } from "./pluginNavIcon";
import { GithubIcon } from "./GithubIcon";
+import { getDashboardViewLabel } from "../../src/shared/dashboard-views";
export interface LeftSidebarExperimentalFeatures {
insights?: boolean;
@@ -296,7 +297,7 @@ export function LeftSidebarNav({
*/
{
id: "command-center",
- label: t("nav.commandCenter", "Dashboard"),
+ label: t("nav.commandCenter", getDashboardViewLabel("command-center")),
view: "command-center",
isActive: view === "command-center",
icon: Gauge,
@@ -305,7 +306,7 @@ export function LeftSidebarNav({
},
{
id: "board",
- label: t("nav.board", "Board"),
+ label: t("nav.board", getDashboardViewLabel("board")),
view: "board",
isActive: view === "board",
icon: LayoutGrid,
@@ -314,7 +315,7 @@ export function LeftSidebarNav({
},
{
id: "list",
- label: t("nav.list", "List"),
+ label: t("nav.list", getDashboardViewLabel("list")),
view: "list",
isActive: view === "list",
icon: List,
@@ -328,7 +329,7 @@ export function LeftSidebarNav({
*/
{
id: "planning",
- label: t("nav.planning", "Planning"),
+ label: t("nav.planning", getDashboardViewLabel("planning")),
view: "planning",
isActive: view === "planning",
icon: Lightbulb,
@@ -339,7 +340,7 @@ export function LeftSidebarNav({
},
{
id: "missions",
- label: t("nav.missions", "Missions"),
+ label: t("nav.missions", getDashboardViewLabel("missions")),
view: "missions",
isActive: view === "missions",
icon: Target,
@@ -350,7 +351,7 @@ export function LeftSidebarNav({
? [
{
id: "agents",
- label: t("nav.agents", "Agents"),
+ label: t("nav.agents", getDashboardViewLabel("agents")),
view: "agents" as TaskView,
isActive: view === "agents",
icon: Bot,
@@ -361,7 +362,7 @@ export function LeftSidebarNav({
: []),
{
id: "chat",
- label: t("nav.chat", "Chat"),
+ label: t("nav.chat", getDashboardViewLabel("chat")),
view: "chat",
isActive: view === "chat",
icon: MessageSquare,
@@ -371,7 +372,7 @@ export function LeftSidebarNav({
},
{
id: "mailbox",
- label: t("nav.mailbox", "Mailbox"),
+ label: t("nav.mailbox", getDashboardViewLabel("mailbox")),
view: "mailbox",
isActive: view === "mailbox",
icon: Mail,
@@ -385,10 +386,10 @@ export function LeftSidebarNav({
Skills and Memory sit directly after Mailbox (still flag-gated by showSkillsTab / memoryView).
*/
...(showSkillsTab
- ? [{ id: "skills", label: t("header.skillsView", "Skills"), view: "skills" as TaskView, isActive: view === "skills", icon: Zap, testId: "sidebar-nav-skills", onSelect: () => onChangeView("skills") }]
+ ? [{ id: "skills", label: t("header.skillsView", getDashboardViewLabel("skills")), view: "skills" as TaskView, isActive: view === "skills", icon: Zap, testId: "sidebar-nav-skills", onSelect: () => onChangeView("skills") }]
: []),
...(experimentalFeatures?.memoryView
- ? [{ id: "memory", label: t("header.memoryView", "Memory"), view: "memory" as TaskView, isActive: view === "memory", icon: Brain, testId: "sidebar-nav-memory", onSelect: () => onChangeView("memory") }]
+ ? [{ id: "memory", label: t("header.memoryView", getDashboardViewLabel("memory")), view: "memory" as TaskView, isActive: view === "memory", icon: Brain, testId: "sidebar-nav-memory", onSelect: () => onChangeView("memory") }]
: []),
{
id: "documents",
@@ -396,7 +397,7 @@ export function LeftSidebarNav({
FNXC:Navigation 2026-06-21-18:25:
FN-6890 renames the top-level Documents label to Artifacts while preserving the documents view id and sidebar-nav-documents test id.
*/
- label: t("nav.documents", "Artifacts"),
+ label: t("nav.documents", getDashboardViewLabel("documents")),
view: "documents",
isActive: view === "documents",
icon: FileText,
@@ -404,7 +405,7 @@ export function LeftSidebarNav({
onSelect: () => onChangeView("documents"),
},
...(experimentalFeatures?.goalsView
- ? [{ id: "goals", label: t("header.goalsView", "Goals"), view: "goalsView" as TaskView, isActive: view === "goalsView", icon: Target, testId: "sidebar-nav-goals", onSelect: () => onChangeView("goalsView") }]
+ ? [{ id: "goals", label: t("header.goalsView", getDashboardViewLabel("goalsView")), view: "goalsView" as TaskView, isActive: view === "goalsView", icon: Target, testId: "sidebar-nav-goals", onSelect: () => onChangeView("goalsView") }]
: []),
/*
FNXC:Navigation 2026-06-22-00:00 (reordered 2026-06-23-01:45):
@@ -412,7 +413,7 @@ export function LeftSidebarNav({
*/
{
id: "automations",
- label: t("nav.automations", "Automations"),
+ label: t("nav.automations", getDashboardViewLabel("automations")),
view: "automations" as TaskView,
isActive: view === "automations",
icon: Clock,
@@ -421,7 +422,7 @@ export function LeftSidebarNav({
},
{
id: "import-tasks",
- label: t("nav.importTasks", "Import Tasks"),
+ label: t("nav.importTasks", getDashboardViewLabel("import-tasks")),
view: "import-tasks" as TaskView,
isActive: view === "import-tasks",
icon: GithubIcon,
@@ -431,7 +432,7 @@ export function LeftSidebarNav({
...(compoundPluginEntry ? [mapPluginEntry(compoundPluginEntry)] : []),
{
id: "workflows",
- label: t("nav.workflows", "Workflows"),
+ label: t("nav.workflows", getDashboardViewLabel("workflows")),
view: "workflows" as TaskView,
isActive: view === "workflows",
icon: Workflow,
@@ -439,16 +440,16 @@ export function LeftSidebarNav({
onSelect: () => onChangeView("workflows"),
},
...(experimentalFeatures?.insights
- ? [{ id: "insights", label: t("header.insightsView", "Insights"), view: "insights" as TaskView, isActive: view === "insights", icon: Sparkles, testId: "sidebar-nav-insights", onSelect: () => onChangeView("insights") }]
+ ? [{ id: "insights", label: t("header.insightsView", getDashboardViewLabel("insights")), view: "insights" as TaskView, isActive: view === "insights", icon: Sparkles, testId: "sidebar-nav-insights", onSelect: () => onChangeView("insights") }]
: []),
...(experimentalFeatures?.researchView
- ? [{ id: "research", label: t("header.researchView", "Research"), view: "research" as TaskView, isActive: view === "research", icon: Search, testId: "sidebar-nav-research", onSelect: () => onChangeView("research") }]
+ ? [{ id: "research", label: t("header.researchView", getDashboardViewLabel("research")), view: "research" as TaskView, isActive: view === "research", icon: Search, testId: "sidebar-nav-research", onSelect: () => onChangeView("research") }]
: []),
...(experimentalFeatures?.ideationView
- ? [{ id: "ideation", label: t("nav.ideation", "Ideation"), view: "ideation" as TaskView, isActive: view === "ideation", icon: Lightbulb, testId: "sidebar-nav-ideation", onSelect: () => onChangeView("ideation") }]
+ ? [{ id: "ideation", label: t("nav.ideation", getDashboardViewLabel("ideation")), view: "ideation" as TaskView, isActive: view === "ideation", icon: Lightbulb, testId: "sidebar-nav-ideation", onSelect: () => onChangeView("ideation") }]
: []),
...(experimentalFeatures?.evalsView
- ? [{ id: "evals", label: t("header.evalsView", "Evals"), view: "evals" as TaskView, isActive: view === "evals", icon: Target, testId: "sidebar-nav-evals", onSelect: () => onChangeView("evals") }]
+ ? [{ id: "evals", label: t("header.evalsView", getDashboardViewLabel("evals")), view: "evals" as TaskView, isActive: view === "evals", icon: Target, testId: "sidebar-nav-evals", onSelect: () => onChangeView("evals") }]
: []),
...remainingPluginViews.map(mapPluginEntry),
];
@@ -529,14 +530,14 @@ export function LeftSidebarNav({
diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx
index 0f02098b1e..bee6560a69 100644
--- a/packages/dashboard/app/components/SettingsModal.tsx
+++ b/packages/dashboard/app/components/SettingsModal.tsx
@@ -91,6 +91,7 @@ import { SETTINGS_SEARCH_ENTRIES } from "./settings/search/entries";
import { rankSettingsSearchResults, matchedSectionIds } from "./settings/search/match";
import { SettingsSearchHighlightProvider } from "./settings/SettingsSearchHighlightContext";
import { subscribeSse } from "../sse-bus";
+import { SETTINGS_SECTION_METADATA } from "../../src/shared/settings-sections";
// ---------------------------------------------------------------------------
// GitHub star count — cached locally and refreshed only while Settings is visible.
@@ -313,28 +314,9 @@ const RUNTIME_PLUGIN_SECTION_IDS: ReadonlyMap = new Map([
const RUNTIME_SETTINGS_SECTION_IDS = new Set(RUNTIME_PLUGIN_SECTION_IDS.values());
-/* FNXC:VoiceInput 2026-07-28-12:00: Voice Input is an opt-in end-user feature, so it stays visible in Basic Settings rather than joining this advanced-only set. */
-export const ADVANCED_SETTINGS_SECTION_IDS = new Set([
- "node-sync",
- "global-mcp",
- "cli-agents",
- "research-global",
- "remote",
- "experimental",
- "hermes-runtime",
- "openclaw-runtime",
- "paperclip-runtime",
- "scheduled-evals",
- "node-routing",
- "agent-permissions",
- "memory",
- "backups",
- "research-project",
- "secrets",
- "mcp",
- "prompts",
- "plugins",
-]);
+export const ADVANCED_SETTINGS_SECTION_IDS = new Set(
+ SETTINGS_SECTION_METADATA.filter((section) => section.advanced).map((section) => section.id),
+);
function readAdvancedSettingsPreference(): boolean {
try {
@@ -483,197 +465,22 @@ function resolveNonNegativeExecutorToolFailureSetting(value: unknown, fallback:
return Number.isFinite(configured) && configured >= 0 ? Math.floor(configured) : fallback;
}
-export const SETTINGS_SECTIONS: SettingsSection[] = [
- { id: "__preferences_header", label: "Preferences", labelKey: "settings.nav.preferencesHeader", scope: undefined, isGroupHeader: true },
- { id: "appearance", label: "Appearance", labelKey: "settings.nav.appearance", scope: "global", searchableText: ["theme", "color", "sidebar", "dock", "task popup", "task popups", "board list popups", "popup view attachment", "open tasks as popups", "quick chat"] },
- { id: "keyboard-shortcuts", label: "Keyboard Shortcuts", labelKey: "settings.nav.keyboardShortcuts", scope: "global", searchableText: ["keyboard shortcuts", "hotkeys", "quick chat shortcut", "terminal shortcut", "open files", "open settings", "command center", "new task shortcut", "record shortcut"] },
- { id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global", searchableText: ["ntfy", "webhook", "events", "failure notifications", "sticky", "toast"] },
- { id: "global-general", label: "General · Global", labelKey: "settings.nav.globalGeneral", scope: "global", searchableText: ["global defaults", "modal outside dismiss", "agent logs", "persist tool output", "thinking logs"] },
- /*
- FNXC:SettingsNavigation 2026-07-16-12:00:
- FN-8128 keeps the `fn` binary panel as a dedicated section rather than re-inlining machine plumbing at the top of General · Global, while restoring it to the default-visible Global group. Operators need installation, version, path, and diagnostic controls in Basic mode when setup or repair is needed.
- */
- { id: "cli-binary", label: "CLI Binary", labelKey: "settings.nav.cliBinary", scope: "global", searchableText: ["fn binary", "cli", "install", "version", "path", "upgrade", "homebrew", "binary check"] },
+const SETTINGS_SECTION_ICONS: Readonly> = {
+ authentication: Globe,
+ "source-control-global": GitBranch,
+ "source-control": GitBranch,
+};
- { id: "__project_header", label: "Project", labelKey: "settings.nav.projectHeader", scope: undefined, isGroupHeader: true },
- /*
- FNXC:GitHubImportTranslate 2026-07-15-16:20:
- Import auto-translation lives in Project General beside the other import-scoped GitHub settings, but operators look for it by what it DOES ("translate", "language", "auto translate issues"), not by the section it happens to live in.
- FNXC:SettingsSearch 2026-07-15-19:10: the per-setting index now matches these controls on their own label and help text, so the terms that merely restate the copy are no longer load-bearing. The list is kept for the genuine vocabulary gaps — "localize", "localization", "foreign language issues" — which appear nowhere in the copy, and because unmigrated siblings in this section still rely on section-level keywords.
- */
- { id: "general", label: "General · Project", labelKey: "settings.nav.projectGeneral", scope: "project", searchableText: ["project general", "Completion Documentation Automation", "Quick Chat launcher", "ephemeral task-worker agents", "chat rooms", "auto-cleanup old chats", "translate", "translation", "auto translate", "auto-translate", "autotranslate", "auto translate issues", "translate issues", "translate imported issues", "githubImportAutoTranslate", "importTranslateTargetLocale", "target language", "translation target language", "translation language", "language", "foreign language issues", "import language", "localize", "localization", "report", "report bug", "send feedback", "share idea", "get help"], searchableKeys: ["settings.general.autoTranslateImportedIssues", "settings.general.autoTranslateImportedIssuesHelp", "settings.general.translationTargetLanguage", "settings.general.translationTargetLanguageHelp", "settings.general.followDashboardLanguage"] },
- { 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: "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"] },
- /*
- FNXC:SettingsNavigation 2026-07-18-12:30:
- FN-8350 makes configuration history a project Settings destination instead of a
- Command Center card. Register it in the shared section registry so desktop
- navigation, the mobile picker, and Settings search expose one canonical view.
- */
- { id: "config-versions", label: "Configuration Versions", labelKey: "settings.nav.configVersions", scope: "project", searchableText: ["configuration versions", "revision history", "roll back settings", "restore configuration", "config rollback"] },
-
- { id: "__ai_header", label: "AI & Models", labelKey: "settings.nav.aiHeader", scope: undefined, isGroupHeader: true },
- /*
- FNXC:SettingsNavigation 2026-07-16-01:30:
- Authentication leads the AI & Models group. It is a provider-credentials screen, so it belongs with the model settings it gates rather than under Integrations (where it sat among MCP/Plugins/runtimes) or floating above the groups as a special case — connecting a provider and choosing its models are one task, done in that order.
- First within the group because nothing else in AI & Models can be configured until it is done: with no provider connected there are no models to pick.
-
- FNXC:SettingsNavigation 2026-07-16-13:40:
- FN-8130 changes the Settings landing surface from Authentication to Appearance. Authentication remains first within its own AI & Models group, but the always-visible global Preferences section is the default instead.
- */
- { id: "authentication", label: "Authentication", labelKey: "settings.nav.authentication", scope: undefined, icon: Globe, searchableText: ["login", "OAuth", "API key", "custom providers", "Anthropic", "OpenAI", "provider credentials"] },
- { id: "global-models", label: "Models · Global", labelKey: "settings.nav.globalModels", scope: "global", searchableText: ["global models", "model presets", "favorite providers", "model pricing overrides", "LiteLLM pricing", "token pricing", "translate", "translation model", "import translation model", "import auto-translation model"] },
- /**
- * FNXC:SettingsNavigation 2026-07-13-00:00:
- * Project Models owns the FN-7907 Direct-chat default settings. Its shared Settings search index must advertise chat-default terms and i18n labels so desktop nav, the mobile section picker, and filtered search all surface this section when operators search for Chat defaults.
-
- * FNXC:SettingsNavigation 2026-07-14-20:15:
- * Title auto-summarization lives under Project Models but operators search for "summarize", "auto summarize", "title summarization", and related phrases that did not match the prior chat-only/summarization-model index. Advertise those terms and the control's i18n keys so Settings search finds this section.
- */
- {
- id: "project-models",
- label: "Models · Project",
- labelKey: "settings.nav.projectModels",
- scope: "project",
- searchableText: [
- "default provider",
- "default model",
- "workflow model lanes",
- "Plan/Triage",
- "Executor",
- "Reviewer",
- "summarization model",
- "summarize",
- "summarize titles",
- "auto summarize",
- "auto-summarize",
- "auto summarize titles",
- "auto-summarize titles",
- "autoSummarizeTitles",
- "task definition language",
- "task definitions input language",
- "taskDefinitionInInputLanguage",
- "localized task prose",
- "title summarization",
- "title summarizer",
- "AI title",
- "AI merge commit summaries",
- "merge commit summary",
- "chat",
- "new chat",
- "new chat behavior",
- "chat default",
- "chat default model",
- "chat default agent",
- "chat model",
- "chat agent",
- "prompt for model",
- "always use default",
- // FNXC:GitHubImportTranslate 2026-07-15-16:20: the import-translate lane is picked here.
- "translate",
- "translation",
- "translation model",
- "import translation model",
- "import auto-translation model",
- "auto-translate model",
- ],
- searchableKeys: [
- "settings.projectModels.chatHeading",
- "settings.projectModels.chatDescription",
- "settings.projectModels.chatNewSessionMode",
- "settings.projectModels.chatNewSessionModePrompt",
- "settings.projectModels.chatNewSessionModeAlwaysDefault",
- "settings.projectModels.chatDefaultKind",
- "settings.projectModels.chatDefaultModel",
- "settings.projectModels.chatDefaultAgent",
- "settings.projectModels.aITitleAndGitCommitMessageSummarization",
- "settings.projectModels.autoSummarizeLongDescriptionsAsTitles",
- "settings.projectModels.whenEnabledTasksCreatedWithoutATitleBut",
- "settings.projectModels.aIMergeCommitSummaries",
- "settings.projectModels.whenEnabledMergeCommitMessagesIncludeAnAI",
- ],
- },
- {
- 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: "agent-permissions", label: "Agents & Permissions", labelKey: "settings.nav.agentPermissions", scope: "project", searchableText: ["agent provisioning", "approval", "permissions", "policy", "agent creation"] },
- { id: "prompts", label: "Prompts", labelKey: "settings.nav.prompts", scope: "project", searchableText: ["prompt instructions", "PR title prompt", "PR description prompt", "custom prompts"] },
- { id: "memory", label: "Memory", labelKey: "settings.nav.memory", scope: "project", searchableText: ["memory backend", "Dreams", "long-term memory", "qmd", "memory file", "retrieval"] },
- { id: "research-global", label: "Research · Global", labelKey: "settings.nav.researchGlobal", scope: "global", searchableText: ["research providers", "external search providers", "fetch limits", "global research defaults", "citations"] },
- { id: "research-project", label: "Research · Project", labelKey: "settings.nav.researchProject", scope: "project", searchableText: ["project research", "research runs", "citations", "search limits", "fetch synthesis"] },
- { id: "voice-input", label: "Voice Input", labelKey: "settings.nav.voiceInput", scope: "project", searchableText: ["voice", "dictation", "microphone", "speech to text", "parakeet", "transcription"] },
-
- { id: "__automation_header", label: "Automation", labelKey: "settings.nav.automationHeader", scope: undefined, isGroupHeader: true },
- /*
- FNXC:SettingsNavigation 2026-07-15-18:52:
- Scheduling is split into a Global/Project pair rather than one section holding both authority levels behind in-section subheadings. The machine-wide concurrency cap and a project's scheduling posture are different questions, and a search result landing mid-section showed no subheading to disambiguate them.
- */
- { id: "scheduling-global", label: "Scheduling · Global", labelKey: "settings.nav.schedulingGlobal", scope: "global", searchableText: ["global max concurrent", "concurrency cap", "all projects", "machine wide", "parallel agents", "scheduler"] },
- { id: "scheduling", label: "Scheduling · Project", 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: "__integrations_header", label: "Integrations", labelKey: "settings.nav.integrationsHeader", scope: undefined, isGroupHeader: true },
- /*
- FNXC:SourceControl 2026-07-15-20:30:
- The Global/Project source-control pair sits under Integrations, not Project: these settings configure how Fusion talks to GitHub/GitLab, which is the same kind of thing as the MCP and provider entries beside them.
- The two are adjacent and ordered global-then-project to match the inheritance they model — the global entry holds the fallbacks the project entry overrides — mirroring the MCP Servers pair directly below.
- The GitLab/GitHub keywords below were curated on the `general` and `merge` nav entries before their controls moved here; a keyword left behind would send an operator searching "gitlab token" to a section that no longer renders one. The translate keywords deliberately did NOT move: `githubImportAutoTranslate`/`importTranslateTargetLocale` are Import Tasks panel settings and stay in General.
- */
- { id: "source-control-global", label: "Source Control · Global", labelKey: "settings.nav.sourceControlGlobal", scope: "global", icon: GitBranch, searchableText: ["GitLab instance URL", "global tracking repo", "GitLab", "GitHub", "global GitLab token", "GitLab fallback", "source control", "forge"] },
- { id: "source-control", label: "Source Control · Project", labelKey: "settings.nav.sourceControl", scope: "project", icon: GitBranch, searchableText: ["GitHub tracking", "GitLab integration", "GitHub auth mode", "GitLab access token", "GitHub personal access token", "tracking repo", "source control", "forge", "gh cli", "issue tracking"] },
- { id: "global-mcp", label: "MCP Servers · Global", labelKey: "settings.nav.globalMcp", scope: "global", searchableText: ["global MCP servers", "shared MCP", "user MCP", "tool servers"] },
- { id: "mcp", label: "MCP Servers · Project", labelKey: "settings.nav.mcp", scope: "project", searchableText: ["project MCP servers", "workspace MCP", "project tool servers", "mcp config"] },
- { id: "plugins", label: "Plugins", labelKey: "settings.nav.plugins", scope: "project", searchableText: ["Fusion plugins", "Pi extensions", "plugin manager", "extension marketplace"] },
- { 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"] },
- { id: "secrets", label: "Secrets", labelKey: "settings.nav.secrets", scope: "project", searchableText: ["secrets", "secret storage", "environment", "credentials"] },
-
- { id: "__infrastructure_header", label: "Infrastructure", labelKey: "settings.nav.infrastructureHeader", scope: undefined, isGroupHeader: true },
- { id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", scope: "global", searchableText: ["sync", "node", "distributed", "heartbeat", "coordination"] },
- { id: "node-routing", label: "Node Routing", labelKey: "settings.nav.nodeRouting", scope: "project", searchableText: ["node routing", "routing rules", "node selection", "execution nodes"] },
- /*
- 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", searchableText: ["cloudflared", "tunnel", "QR", "persistent token", "remote URL"] },
- { id: "backups-global", label: "Database Backups", labelKey: "settings.backups.databaseBackups", scope: "global", searchableText: ["database backup", "restore", "shared cluster"] },
- { id: "backups", label: "Memory Backups", labelKey: "settings.backups.memoryBackups", scope: "project", searchableText: ["memory backup", "memory snapshot"] },
-
- { id: "__advanced_header", label: "Advanced", labelKey: "settings.nav.advancedHeader", scope: undefined, isGroupHeader: true },
- { id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global", searchableText: ["feature flags", "experiments", "research view", "evals view", "sandbox", "subtask breakdown"] },
-];
+export const SETTINGS_SECTIONS: SettingsSection[] = SETTINGS_SECTION_METADATA.map((section) => ({
+ id: section.id,
+ label: section.label,
+ labelKey: section.labelKey,
+ scope: section.scope,
+ ...(SETTINGS_SECTION_ICONS[section.id] ? { icon: SETTINGS_SECTION_ICONS[section.id] } : {}),
+ ...(section.isGroupHeader ? { isGroupHeader: true } : {}),
+ ...(section.searchableText ? { searchableText: [...section.searchableText] } : {}),
+ ...(section.searchableKeys ? { searchableKeys: [...section.searchableKeys] } : {}),
+}));
// FNXC:SettingsNavigation 2026-07-04-00:00: sectionId -> owning group label ("Global"/"Runtimes"/"Project"),
// derived once from SETTINGS_SECTIONS order. Used by resolveSettingsSectionOptionLabel to prefix
diff --git a/packages/dashboard/app/components/__tests__/left-sidebar-nav-registry-parity.test.tsx b/packages/dashboard/app/components/__tests__/left-sidebar-nav-registry-parity.test.tsx
new file mode 100644
index 0000000000..26223950e8
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/left-sidebar-nav-registry-parity.test.tsx
@@ -0,0 +1,133 @@
+import { describe, expect, it, vi } from "vitest";
+import { render } from "@testing-library/react";
+import { DASHBOARD_VIEWS } from "../../../src/shared/dashboard-views";
+
+/*
+FNXC:UiMetadataApi 2026-07-14-00:00:
+`ui-metadata-sync.test.ts` proves the /api/views payload equals the shared view
+registry, but the desktop sidebar (LeftSidebarNav) still hand-writes one nav
+entry per view with a hardcoded i18n key + registry-derived English fallback.
+Nothing there is derived from the same source the sync test compares, so a
+registry↔sidebar drift (e.g. renaming a view's `labelKey`) would ship silently.
+
+This test renders LeftSidebarNav with every feature flag enabled and captures
+each `t(key, fallback)` call. For every view id the sidebar is contracted to
+surface, it asserts the sidebar invoked translation with EXACTLY the registry's
+`labelKey` and `label`. That pins the hardcoded keys/fallbacks to the registry:
+change a registry entry (or re-hardcode a wrong key in the sidebar) and this
+fails. The fallback half is registry-derived in the component already, but the
+key half is the real drift risk, and this is the assertion that closes it.
+*/
+
+const { tCalls } = vi.hoisted(() => ({
+ tCalls: [] as Array<{ key: string; fallback: unknown }>,
+}));
+
+vi.mock("react-i18next", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string, fallback?: unknown) => {
+ tCalls.push({ key, fallback });
+ return typeof fallback === "string" ? fallback : key;
+ },
+ }),
+ };
+});
+
+// Imported after the mock so LeftSidebarNav binds the recording `t`.
+import { LeftSidebarNav } from "../LeftSidebarNav";
+
+/*
+View ids the desktop sidebar renders with registry-derived labels, in the order
+declared by LeftSidebarNav. Deliberately excluded and why:
+ - graph / compound: surfaced via plugin dashboard-view entries whose labels
+ come from the plugin manifest, not this registry.
+ - todos / secrets / dev-server / pull-requests: intentionally NOT primary
+ sidebar destinations (right dock / overflow / mobile More-sheet).
+ - task-detail: internal, non-navigable destination.
+*/
+const SIDEBAR_REGISTRY_VIEW_IDS = [
+ "command-center",
+ "board",
+ "list",
+ "planning",
+ "missions",
+ "agents",
+ "chat",
+ "mailbox",
+ "skills",
+ "memory",
+ "documents",
+ "goalsView",
+ "automations",
+ "import-tasks",
+ "workflows",
+ "insights",
+ "research",
+ "ideation",
+ "evals",
+ "settings",
+] as const;
+
+describe("LeftSidebarNav ↔ dashboard view registry parity", () => {
+ it("renders each surfaced view with its registry labelKey and English fallback", () => {
+ tCalls.length = 0;
+
+ const { container } = render(
+ {}}
+ onNewTask={() => {}}
+ onOpenSettings={() => {}}
+ showAgentsTab
+ showSkillsTab
+ experimentalFeatures={{
+ insights: true,
+ memoryView: true,
+ devServerView: true,
+ researchView: true,
+ evalsView: true,
+ ideationView: true,
+ goalsView: true,
+ }}
+ />,
+ );
+
+ /*
+ FNXC:UiMetadataApi 2026-07-14-00:00:
+ SIDEBAR_REGISTRY_VIEW_IDS is hand-maintained, so pinning only the ids it already
+ lists would let a newly added sidebar destination skip this check entirely. Counting
+ the rendered non-plugin destinations makes enrolment mandatory: a new entry fails here
+ until it is added to the list above (and therefore to the registry parity assertions).
+ */
+ const NON_DESTINATION_CONTROLS = new Set(["new-task", "collapse-toggle", "resize-handle"]);
+ const renderedDestinationIds = [...container.querySelectorAll("[data-testid^='sidebar-nav-']")]
+ .map((element) => element.getAttribute("data-testid")!.replace("sidebar-nav-", ""))
+ .filter((id) => !id.startsWith("plugin-") && !NON_DESTINATION_CONTROLS.has(id));
+ expect(
+ renderedDestinationIds.length,
+ `LeftSidebarNav rendered ${renderedDestinationIds.length} destinations ` +
+ `(${renderedDestinationIds.join(", ")}) but SIDEBAR_REGISTRY_VIEW_IDS lists ` +
+ `${SIDEBAR_REGISTRY_VIEW_IDS.length}. Enrol the new destination above so its ` +
+ `registry labelKey and English fallback are pinned too.`,
+ ).toBe(SIDEBAR_REGISTRY_VIEW_IDS.length);
+
+ const registryById = new Map(DASHBOARD_VIEWS.map((view) => [view.id, view]));
+
+ for (const id of SIDEBAR_REGISTRY_VIEW_IDS) {
+ const registryEntry = registryById.get(id);
+ expect(registryEntry, `sidebar view id "${id}" is missing from the shared registry`).toBeDefined();
+
+ const matched = tCalls.some(
+ (call) => call.key === registryEntry!.labelKey && call.fallback === registryEntry!.label,
+ );
+ expect(
+ matched,
+ `LeftSidebarNav must render view "${id}" via t("${registryEntry!.labelKey}", "${registryEntry!.label}"); ` +
+ `no matching translation call was made. Sidebar and registry have drifted.`,
+ ).toBe(true);
+ }
+ });
+});
diff --git a/packages/dashboard/app/components/__tests__/ui-metadata-sync.test.ts b/packages/dashboard/app/components/__tests__/ui-metadata-sync.test.ts
new file mode 100644
index 0000000000..0d2b19e158
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/ui-metadata-sync.test.ts
@@ -0,0 +1,173 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+ DASHBOARD_VIEW_IDS,
+ DASHBOARD_VIEWS,
+} from "../../../src/shared/dashboard-views";
+import { SETTINGS_SECTION_METADATA } from "../../../src/shared/settings-sections";
+import {
+ buildSettingsSectionsPayload,
+ buildViewsPayload,
+} from "../../../src/routes/register-ui-metadata-routes";
+import {
+ ADVANCED_SETTINGS_SECTION_IDS,
+ SETTINGS_SECTIONS,
+} from "../SettingsModal";
+import { BUILT_IN_TASK_VIEWS } from "../../hooks/useViewState";
+import {
+ EXCLUDED_RESET_SECTIONS,
+ GLOBAL_SECTION_KEYS,
+ PROJECT_SECTION_KEYS,
+ getSectionKeyEntry,
+} from "../settings/section-keys";
+
+function uiComparable(section: (typeof SETTINGS_SECTIONS)[number]) {
+ return {
+ id: section.id,
+ label: section.label,
+ labelKey: section.labelKey,
+ scope: section.scope,
+ isGroupHeader: section.isGroupHeader,
+ searchableText: section.searchableText,
+ searchableKeys: section.searchableKeys,
+ };
+}
+
+function metadataComparable(section: (typeof SETTINGS_SECTION_METADATA)[number]) {
+ return {
+ id: section.id,
+ label: section.label,
+ labelKey: section.labelKey,
+ scope: section.scope,
+ isGroupHeader: section.isGroupHeader,
+ searchableText: section.searchableText,
+ searchableKeys: section.searchableKeys,
+ };
+}
+
+describe("shared UI metadata no-drift contract", () => {
+ it("keeps Settings navigation and advanced visibility derived from metadata", () => {
+ expect(SETTINGS_SECTIONS.map(uiComparable)).toEqual(SETTINGS_SECTION_METADATA.map(metadataComparable));
+ expect([...ADVANCED_SETTINGS_SECTION_IDS]).toEqual(
+ SETTINGS_SECTION_METADATA.filter((section) => section.advanced).map((section) => section.id),
+ );
+ });
+
+ it("keeps each section's served group equal to the group header it renders under", () => {
+ // `group` is the only metadata field the Settings list does not itself render, so it is
+ // pinned to the navigation structure the user actually sees: the nearest preceding header.
+ let renderedGroup: string | undefined;
+ for (const section of SETTINGS_SECTIONS) {
+ if (section.isGroupHeader) {
+ renderedGroup = section.label;
+ continue;
+ }
+ const metadata = SETTINGS_SECTION_METADATA.find((entry) => entry.id === section.id);
+ expect(metadata, `Missing Settings metadata for rendered section ${section.id}`).toBeDefined();
+ expect(metadata!.group, `Wrong group for ${section.id}`).toBe(renderedGroup);
+ }
+ expect(renderedGroup).toBeDefined();
+ });
+
+ it("keeps persisted built-in views equal to canonical ids plus declared aliases", () => {
+ const expectedViews = DASHBOARD_VIEWS.flatMap((view) => [...(view.aliases ?? []), view.id]);
+ expect(BUILT_IN_TASK_VIEWS).toEqual(expectedViews);
+ expect(DASHBOARD_VIEWS.map((view) => view.id)).toEqual(DASHBOARD_VIEW_IDS);
+ expect(new Set(BUILT_IN_TASK_VIEWS).size).toBe(BUILT_IN_TASK_VIEWS.length);
+ });
+
+ /*
+ FNXC:UiMetadataApi 2026-07-14-00:00:
+ A published labelKey is only useful to an external consumer if resolving it yields a
+ string. Pointing one at an i18n *namespace* (for example `taskDetail.title`, which owns
+ `taskDetail.title.summarize`) hands the consumer an object — and because the object
+ branch precedes defaultValue, the caller's fallback never applies. Neither registry may
+ advertise a key that collides with a non-leaf node in the shipped catalog.
+
+ This deliberately does NOT require every key to be present: the dashboard supplies much
+ of its English inline as a t() default, so most nav and settings keys are legitimately
+ absent from the catalog until they are extracted. An absent key degrades to the caller's
+ own fallback, which is why the payload always carries `label`; a namespace collision does
+ not degrade, it returns the wrong type.
+ */
+ it("never advertises a labelKey that resolves to a non-leaf i18n node", () => {
+ const catalog = JSON.parse(
+ readFileSync(resolve(__dirname, "../../../../i18n/locales/en/app.json"), "utf8"),
+ ) as Record;
+ const resolveKey = (key: string): unknown =>
+ key.split(".").reduce(
+ (node, part) =>
+ node && typeof node === "object" && part in (node as Record)
+ ? (node as Record)[part]
+ : undefined,
+ catalog,
+ );
+
+ const advertised = [
+ ...DASHBOARD_VIEWS.map((view) => ({ source: "view", id: view.id, labelKey: view.labelKey })),
+ ...SETTINGS_SECTION_METADATA.map((section) => ({
+ source: "settings section",
+ id: section.id,
+ labelKey: section.labelKey,
+ })),
+ ].filter((entry) => entry.labelKey);
+ expect(advertised.length).toBeGreaterThan(0);
+ for (const entry of advertised) {
+ const resolved = resolveKey(entry.labelKey!);
+ expect(
+ typeof resolved === "object" && resolved !== null,
+ `${entry.source} "${entry.id}" advertises labelKey "${entry.labelKey}", which is an i18n namespace, not a string`,
+ ).toBe(false);
+ }
+ });
+
+ it("keeps every settings reset-registry id backed by section metadata", () => {
+ const metadataIds = new Set(SETTINGS_SECTION_METADATA.map((section) => section.id));
+ const resetRegistryIds = new Set([
+ ...Object.keys(GLOBAL_SECTION_KEYS),
+ ...Object.keys(PROJECT_SECTION_KEYS),
+ ...Object.keys(EXCLUDED_RESET_SECTIONS),
+ ]);
+
+ for (const sectionId of resetRegistryIds) {
+ expect(metadataIds, `Missing Settings metadata for reset registry id ${sectionId}`).toContain(sectionId);
+ if (!EXCLUDED_RESET_SECTIONS[sectionId]) {
+ expect(getSectionKeyEntry(sectionId), `Missing reset entry for ${sectionId}`).not.toBeNull();
+ }
+ }
+ });
+
+ it("serves payloads built directly from the UI registries", () => {
+ const viewsPayload = buildViewsPayload();
+ const expectedViews = DASHBOARD_VIEWS.map((view) => ({
+ id: view.id,
+ label: view.label,
+ ...(view.labelKey ? { labelKey: view.labelKey } : {}),
+ ...(view.aliases ? { aliases: [...view.aliases] } : {}),
+ ...(view.internal ? { internal: true } : {}),
+ }));
+ expect(viewsPayload.views).toEqual(expectedViews);
+ expect(viewsPayload.views.map((view) => view.id)).toEqual(DASHBOARD_VIEW_IDS);
+
+ const sectionsPayload = buildSettingsSectionsPayload();
+ const selectableUiSections = SETTINGS_SECTIONS.filter((section) => !section.isGroupHeader);
+ expect(sectionsPayload.sections.map((section) => section.id)).toEqual(
+ selectableUiSections.map((section) => section.id),
+ );
+ for (const served of sectionsPayload.sections) {
+ const source = SETTINGS_SECTION_METADATA.find((section) => section.id === served.id);
+ expect(source).toBeDefined();
+ expect(served).toEqual({
+ id: source!.id,
+ label: source!.label,
+ labelKey: source!.labelKey,
+ scope: source!.scope ?? null,
+ group: source!.group,
+ keywords: source!.searchableText ? [...source!.searchableText] : [],
+ searchableKeys: source!.searchableKeys ? [...source!.searchableKeys] : [],
+ advanced: source!.advanced,
+ });
+ }
+ });
+});
diff --git a/packages/dashboard/app/components/settings/__tests__/VoiceInputSection.modal-visibility.test.tsx b/packages/dashboard/app/components/settings/__tests__/VoiceInputSection.modal-visibility.test.tsx
index 93e5d95e91..17d39f254a 100644
--- a/packages/dashboard/app/components/settings/__tests__/VoiceInputSection.modal-visibility.test.tsx
+++ b/packages/dashboard/app/components/settings/__tests__/VoiceInputSection.modal-visibility.test.tsx
@@ -2,17 +2,28 @@ import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { ADVANCED_SETTINGS_SECTION_IDS } from "../../SettingsModal";
+import { SETTINGS_SECTION_METADATA } from "../../../../src/shared/settings-sections";
/**
* The Voice Input nav entry must remain a Basic-mode setting. The modal's
* visibility filter is driven solely by ADVANCED_SETTINGS_SECTION_IDS, while
- * this source-level contract also protects the matching render-switch case.
+ * this contract also protects the matching render-switch case.
+ *
+ * FNXC:UiMetadataApi 2026-07-14-00:00: the nav entry itself now lives in the
+ * shared settings-sections registry that drives both Settings and
+ * GET /api/settings/sections, so the entry is asserted against the registry
+ * rather than grepped out of SettingsModal.tsx.
*/
describe("Voice Input SettingsModal visibility", () => {
it("keeps Voice Input visible outside Advanced settings and wires its render case", () => {
expect(ADVANCED_SETTINGS_SECTION_IDS.has("voice-input")).toBe(false);
+ expect(SETTINGS_SECTION_METADATA.find((section) => section.id === "voice-input")).toMatchObject({
+ label: "Voice Input",
+ labelKey: "settings.nav.voiceInput",
+ scope: "project",
+ advanced: false,
+ });
const modalSource = readFileSync(resolve(__dirname, "../../SettingsModal.tsx"), "utf8");
- expect(modalSource).toContain('id: "voice-input", label: "Voice Input"');
expect(modalSource).toContain('case "voice-input":');
expect(modalSource).toContain("");
});
diff --git a/packages/dashboard/app/components/settings/section-keys.ts b/packages/dashboard/app/components/settings/section-keys.ts
index 0924f8478e..da80cb8b50 100644
--- a/packages/dashboard/app/components/settings/section-keys.ts
+++ b/packages/dashboard/app/components/settings/section-keys.ts
@@ -54,7 +54,11 @@ export interface SectionKeyEntry {
* save-split.ts for the project-models lane overrides instead of duplicating
* them.
*/
-const PROJECT_SECTION_KEYS: Record = {
+/*
+FNXC:UiMetadataApi 2026-07-14-00:00:
+Expose project reset-registry ids for the no-drift contract test so a reset-owning section cannot exist without discoverable Settings metadata. This is read-only inspection and does not change reset ownership or behavior.
+*/
+export const PROJECT_SECTION_KEYS: Readonly> = {
general: [
"allowAbsoluteFileBrowserPaths",
"capacityRiskBannerEnabled",
diff --git a/packages/dashboard/app/hooks/useViewState.ts b/packages/dashboard/app/hooks/useViewState.ts
index 18d2e47a70..54a47cc20e 100644
--- a/packages/dashboard/app/hooks/useViewState.ts
+++ b/packages/dashboard/app/hooks/useViewState.ts
@@ -4,67 +4,21 @@ import type { ProjectInfo } from "../api";
import { getScopedItem, scopedKey, setScopedItem } from "../utils/projectStorage";
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
import { recordActivity } from "../utils/report-capture";
+import { DASHBOARD_VIEWS, type BuiltInTaskView } from "../../src/shared/dashboard-views";
+export type { BuiltInTaskView } from "../../src/shared/dashboard-views";
export type ViewMode = "overview" | "project";
-/*
-FNXC:ViewState 2026-06-22-00:00:
-Workflows, Import Tasks, and Automations are promoted to top-level main-content task views (left-sidebar destinations) instead of modal-only overlays, so they render in the main panel like Command Center.
-*/
-export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "ideation" | "goalsView" | "todos" | "planning" | "skills" | "mailbox" | "insights" | "memory" | "command-center" | "secrets" | "devserver" | "dev-server" | "pull-requests" | "workflows" | "import-tasks" | "automations" | "settings" | "task-detail";
export type PluginTaskView = `plugin:${string}:${string}`;
export type TaskView = BuiltInTaskView | PluginTaskView;
-const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
- "board",
- "list",
- "graph",
- "agents",
- "missions",
- "chat",
- "documents",
- "research",
- "evals",
- /*
- FNXC:Navigation 2026-08-01-00:00:
- FN-8352 promotes Ideation from a Command Center tab to a persisted,
- default-off experimental top-level view.
- */
- "ideation",
- "goalsView",
- /*
- FNXC:ViewState 2026-06-21-09:14:
- FN-6829 promotes project Todos from modal-only state into the persisted built-in task-view registry so dashboard navigation can dock it in the right content area.
- */
- "todos",
- /*
- FNXC:Navigation 2026-06-21-00:00:
- FN-6886 promotes Planning Mode into a persisted top-level docked task view instead of treating it as a modal-only overlay.
- */
- "planning",
-
- "skills",
- "mailbox",
- "insights",
- "memory",
- "command-center",
- "secrets",
- "devserver",
- "dev-server",
- "pull-requests",
- "workflows",
- "import-tasks",
- "automations",
- /*
- FNXC:ViewState 2026-06-22-00:00:
- Settings is promoted from a modal-only overlay into a top-level main-content task view so the header/sidebar Settings entry points dock it in the main panel like Command Center, while preserving deep-link section navigation.
- */
- "settings",
- /*
- FNXC:Navigation 2026-06-22-00:00:
- Clicking a task card on the Board opens its detail as a full main-content view ("Full main panel (replaces board)") with a Back-to-board button, instead of the TaskDetailModal overlay. The detail is hosted under this registered `task-detail` task view so navigation/persistence treat it like any other docked main-panel destination.
- */
- "task-detail",
-];
+/*
+FNXC:UiMetadataApi 2026-07-14-00:00:
+Persisted task-view validation includes every canonical shared view id and each declared legacy alias. This preserves the existing devserver migration while making the same registry authoritative for dashboard navigation and GET /api/views.
+*/
+export const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = DASHBOARD_VIEWS.flatMap((view) => [
+ ...(view.aliases ?? []),
+ view.id,
+] as BuiltInTaskView[]);
function isBuiltInTaskView(value: string | null): value is BuiltInTaskView {
return value !== null && BUILT_IN_TASK_VIEWS.includes(value as BuiltInTaskView);
diff --git a/packages/dashboard/src/__tests__/mcp-documentation.test.ts b/packages/dashboard/src/__tests__/mcp-documentation.test.ts
index edb5c6ff03..1be083b202 100644
--- a/packages/dashboard/src/__tests__/mcp-documentation.test.ts
+++ b/packages/dashboard/src/__tests__/mcp-documentation.test.ts
@@ -39,7 +39,13 @@ describe("MCP documentation contract", () => {
const mcpGuide = readDoc("docs/mcp.md");
const routeSource = readDoc("packages/dashboard/src/routes/register-config-mcp-pi-settings-routes.ts");
const cliSource = readDoc("packages/cli/src/commands/mcp.ts");
- const settingsModalSource = readDoc("packages/dashboard/app/components/SettingsModal.tsx");
+ /*
+ FNXC:UiMetadataApi 2026-07-14-00:00:
+ The MCP Settings sections are declared in the shared settings-sections registry that
+ drives both Settings navigation and GET /api/settings/sections, so this documentation
+ contract reads the registry rather than the modal that now consumes it.
+ */
+ const settingsSectionsSource = readDoc("packages/dashboard/src/shared/settings-sections.ts");
expect(routeSource).toContain('router.post("/mcp/validate"');
expect(routeSource).toContain("server?: unknown");
@@ -57,8 +63,8 @@ describe("MCP documentation contract", () => {
expect(mcpGuide).toContain(flag);
}
- expect(settingsModalSource).toContain('id: "global-mcp"');
- expect(settingsModalSource).toContain('id: "mcp"');
+ expect(settingsSectionsSource).toContain('id: "global-mcp"');
+ expect(settingsSectionsSource).toContain('id: "mcp"');
expect(mcpGuide).toContain("Settings → Global → MCP Servers");
expect(mcpGuide).toContain("Settings → Project → MCP Servers");
});
diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts
index 62278fe4a6..7b83491162 100644
--- a/packages/dashboard/src/routes.ts
+++ b/packages/dashboard/src/routes.ts
@@ -62,6 +62,7 @@ import { registerSettingsSyncRoutes } from "./routes/register-settings-sync-rout
import { registerSecretsSyncRoutes } from "./routes/register-secrets-sync-routes.js";
import { registerMeshRoutes } from "./routes/register-mesh-routes.js";
import { registerDiscoveryRoutes } from "./routes/register-discovery-routes.js";
+import { registerUiMetadataRoutes } from "./routes/register-ui-metadata-routes.js";
import { registerSettingsSyncInboundRoutes } from "./routes/register-settings-sync-inbound-routes.js";
import { registerSecretsSyncInboundRoutes } from "./routes/register-secrets-sync-inbound-routes.js";
import { registerAgentCoreListCreateRoutes, registerAgentCoreRoutes } from "./routes/register-agent-core-routes.js";
@@ -1985,6 +1986,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// ── Node Discovery Routes (mDNS / DNS-SD) ────────────────────────────────
registrarMounter.mount("registerDiscoveryRoutes", () => registerDiscoveryRoutes(routeContext));
+ registrarMounter.mount("registerUiMetadataRoutes", () => registerUiMetadataRoutes(routeContext));
// ── Inbound Settings/Auth Sync Routes ─────────────────────────────────────
diff --git a/packages/dashboard/src/routes/README.md b/packages/dashboard/src/routes/README.md
index 7c38ab4856..e2d2f75912 100644
--- a/packages/dashboard/src/routes/README.md
+++ b/packages/dashboard/src/routes/README.md
@@ -62,6 +62,7 @@ The following is the complete top-level registrar map currently imported by `rou
- `registerSecretsSyncRoutes` — domain registrar mounted by `createApiRoutes`.
- `registerMeshRoutes` — domain registrar mounted by `createApiRoutes`.
- `registerDiscoveryRoutes` — domain registrar mounted by `createApiRoutes`.
+- `registerUiMetadataRoutes` — static, project-independent dashboard view and settings-section discovery endpoints.
- `registerSettingsSyncInboundRoutes` — domain registrar mounted by `createApiRoutes`.
- `registerSecretsSyncInboundRoutes` — domain registrar mounted by `createApiRoutes`.
- `registerSetupActivityRoutes` — the late activity feed, concurrency, and setup split export from `register-setup-activity-routes.ts`.
@@ -128,12 +129,13 @@ Express matches in registration order. `create-api-routes-mount-sequence.ts` is
50. `registerSecretsSyncRoutes`
51. `registerMeshRoutes`
52. `registerDiscoveryRoutes`
-53. `registerSettingsSyncInboundRoutes`
-54. `registerSecretsSyncInboundRoutes`
-55. `registerSetupActivityRoutes`
-56. `registerIntegratedDevServerRouter`
-57. `registerAgentSkillsRoutes`
-58. `registerProxyRoutes`
+53. `registerUiMetadataRoutes`
+54. `registerSettingsSyncInboundRoutes`
+55. `registerSecretsSyncInboundRoutes`
+56. `registerSetupActivityRoutes`
+57. `registerIntegratedDevServerRouter`
+58. `registerAgentSkillsRoutes`
+59. `registerProxyRoutes`
## Ordering rules
diff --git a/packages/dashboard/src/routes/__tests__/register-ui-metadata-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-ui-metadata-routes.test.ts
new file mode 100644
index 0000000000..10720d5405
--- /dev/null
+++ b/packages/dashboard/src/routes/__tests__/register-ui-metadata-routes.test.ts
@@ -0,0 +1,72 @@
+// @vitest-environment node
+
+import express from "express";
+import { describe, expect, it, vi } from "vitest";
+import type { TaskStore } from "@fusion/core";
+import { createApiRoutes } from "../../routes.js";
+import { request } from "../../test-request.js";
+import { buildSettingsSectionsPayload, buildViewsPayload } from "../register-ui-metadata-routes.js";
+
+/** Payload as it survives the wire: JSON drops `undefined`-valued keys. */
+function overWire(payload: T): unknown {
+ return JSON.parse(JSON.stringify(payload));
+}
+
+function createMockStore(): TaskStore {
+ return { getRootDir: vi.fn(() => process.cwd()) } as unknown as TaskStore;
+}
+
+function createApp() {
+ const app = express();
+ app.use("/api", createApiRoutes(createMockStore()));
+ return app;
+}
+
+function expectJsonSafe(value: unknown): void {
+ expect(() => JSON.stringify(value)).not.toThrow();
+ const visit = (entry: unknown): void => {
+ expect(typeof entry).not.toBe("function");
+ expect(entry).not.toBeUndefined();
+ if (Array.isArray(entry)) {
+ entry.forEach(visit);
+ } else if (entry && typeof entry === "object") {
+ Object.values(entry).forEach(visit);
+ }
+ };
+ visit(value);
+}
+
+describe("UI metadata routes", () => {
+ it("enumerates stable dashboard views", async () => {
+ const response = await request(createApp(), "GET", "/api/views");
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ views: expect.any(Array) });
+ expect(response.body.views).toEqual(expect.arrayContaining([
+ expect.objectContaining({ id: "board", label: "Board" }),
+ expect.objectContaining({ id: "settings", label: "Settings" }),
+ expect.objectContaining({ id: "command-center", label: "Dashboard" }),
+ expect.objectContaining({ id: "dev-server", aliases: ["devserver"] }),
+ expect.objectContaining({ id: "task-detail", internal: true }),
+ ]));
+ // The route must serve the registry payload verbatim — no filtering, reshaping or reordering.
+ expect(response.body).toEqual(overWire(buildViewsPayload()));
+ expectJsonSafe(response.body);
+ });
+
+ it("enumerates selectable settings sections without group-header rows", async () => {
+ const response = await request(createApp(), "GET", "/api/settings/sections");
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ sections: expect.any(Array) });
+ expect(response.body.sections).toEqual(expect.arrayContaining([
+ expect.objectContaining({ id: "general", label: "General · Project", scope: "project", group: "Project" }),
+ expect.objectContaining({ id: "merge", keywords: expect.any(Array), searchableKeys: expect.any(Array) }),
+ expect.objectContaining({ id: "project-models", advanced: false }),
+ expect.objectContaining({ id: "authentication", scope: null, group: "AI & Models" }),
+ ]));
+ expect(response.body.sections.some((section: { id: string }) => section.id.startsWith("__"))).toBe(false);
+ expect(response.body).toEqual(overWire(buildSettingsSectionsPayload()));
+ expectJsonSafe(response.body);
+ });
+});
diff --git a/packages/dashboard/src/routes/create-api-routes-mount-sequence.ts b/packages/dashboard/src/routes/create-api-routes-mount-sequence.ts
index 402766ce2e..df965b0bf1 100644
--- a/packages/dashboard/src/routes/create-api-routes-mount-sequence.ts
+++ b/packages/dashboard/src/routes/create-api-routes-mount-sequence.ts
@@ -17,7 +17,7 @@ export const CREATE_API_ROUTES_REGISTRAR_MOUNT_SEQUENCE = [
"registerOrgPortabilityRoutes", "registerAgentCoreRoutes", "registerAgentRuntimeRoutes", "registerSystemRoutes",
"registerAgentReflectionRatingRoutes", "registerAgentGenerationRoutes", "registerIntegratedRouters", "registerProjectRoutes",
"registerNodeRoutes", "registerDockerNodeRoutes", "registerDockerProvisioningRoutes", "registerSettingsSyncRoutes",
- "registerSecretsSyncRoutes", "registerMeshRoutes", "registerDiscoveryRoutes", "registerSettingsSyncInboundRoutes",
+ "registerSecretsSyncRoutes", "registerMeshRoutes", "registerDiscoveryRoutes", "registerUiMetadataRoutes", "registerSettingsSyncInboundRoutes",
"registerSecretsSyncInboundRoutes", "registerSetupActivityRoutes", "registerIntegratedDevServerRouter", "registerAgentSkillsRoutes", "registerProxyRoutes",
] as const;
diff --git a/packages/dashboard/src/routes/register-ui-metadata-routes.ts b/packages/dashboard/src/routes/register-ui-metadata-routes.ts
new file mode 100644
index 0000000000..5ab55266a8
--- /dev/null
+++ b/packages/dashboard/src/routes/register-ui-metadata-routes.ts
@@ -0,0 +1,51 @@
+import { DASHBOARD_VIEWS } from "../shared/dashboard-views.js";
+import { SETTINGS_SECTION_METADATA } from "../shared/settings-sections.js";
+import type { ApiRouteRegistrar } from "./types.js";
+
+export function buildViewsPayload() {
+ return {
+ views: DASHBOARD_VIEWS.map((view) => ({
+ id: view.id,
+ label: view.label,
+ ...(view.labelKey ? { labelKey: view.labelKey } : {}),
+ ...(view.aliases ? { aliases: [...view.aliases] } : {}),
+ ...(view.internal ? { internal: true } : {}),
+ })),
+ };
+}
+
+export function buildSettingsSectionsPayload() {
+ return {
+ sections: SETTINGS_SECTION_METADATA
+ .filter((section) => !section.isGroupHeader)
+ .map((section) => ({
+ id: section.id,
+ label: section.label,
+ labelKey: section.labelKey,
+ scope: section.scope ?? null,
+ group: section.group,
+ keywords: section.searchableText ? [...section.searchableText] : [],
+ // `keywords` and `searchableKeys` are best-effort, non-contractual search
+ // hints, not a stable API surface. `searchableKeys` exposes raw i18n
+ // translation-key strings backing a section's searchable copy; their
+ // values/ordering/presence may change between releases. Consumers should
+ // use them to widen search matching, never as stable identifiers.
+ searchableKeys: section.searchableKeys ? [...section.searchableKeys] : [],
+ advanced: section.advanced,
+ })),
+ };
+}
+
+/*
+FNXC:UiMetadataApi 2026-07-14-00:00:
+These authenticated read-only routes return project-independent static metadata. They deliberately do not call getScopedStore or touch TaskStore; request-scoped store resolution is required only for routes that access project state, while standard /api authentication is inherited from the server mount.
+*/
+export const registerUiMetadataRoutes: ApiRouteRegistrar = ({ router }) => {
+ router.get("/views", (_req, res) => {
+ res.json(buildViewsPayload());
+ });
+
+ router.get("/settings/sections", (_req, res) => {
+ res.json(buildSettingsSectionsPayload());
+ });
+};
diff --git a/packages/dashboard/src/shared/dashboard-views.ts b/packages/dashboard/src/shared/dashboard-views.ts
new file mode 100644
index 0000000000..1a480bbf5c
--- /dev/null
+++ b/packages/dashboard/src/shared/dashboard-views.ts
@@ -0,0 +1,124 @@
+/*
+FNXC:UiMetadataApi 2026-07-14-00:00:
+Dashboard view ids, English fallback labels, and translation keys have one source of truth consumed by both the dashboard UI and GET /api/views. Edit this registry rather than either consumer so external discovery cannot drift from navigation.
+*/
+
+export const DASHBOARD_VIEW_IDS = [
+ "board",
+ "list",
+ "graph",
+ "agents",
+ "missions",
+ "chat",
+ "documents",
+ "research",
+ "evals",
+ "ideation",
+ "goalsView",
+ "todos",
+ "planning",
+ "skills",
+ "mailbox",
+ "insights",
+ "memory",
+ "command-center",
+ "secrets",
+ "dev-server",
+ "pull-requests",
+ "workflows",
+ "import-tasks",
+ "automations",
+ "settings",
+ "task-detail",
+] as const;
+
+export type CanonicalDashboardViewId = (typeof DASHBOARD_VIEW_IDS)[number];
+export type BuiltInTaskView = CanonicalDashboardViewId | "devserver";
+
+export interface DashboardViewMetadata {
+ id: CanonicalDashboardViewId;
+ label: string;
+ /*
+ FNXC:UiMetadataApi 2026-07-14-00:00:
+ Optional because a labelKey is only published when the dashboard itself renders
+ that view's title through it. Ids with no host-owned translation key (`graph`,
+ whose label comes from a plugin manifest, and the internal `task-detail`
+ destination) omit it rather than advertise a key that resolves to nothing —
+ `label` is the guaranteed display string.
+ */
+ labelKey?: string;
+ aliases?: readonly string[];
+ internal?: boolean;
+}
+
+export const DASHBOARD_VIEWS: readonly DashboardViewMetadata[] = [
+ { id: "board", label: "Board", labelKey: "nav.board" },
+ { id: "list", label: "List", labelKey: "nav.list" },
+ { id: "graph", label: "Graph" },
+ { id: "agents", label: "Agents", labelKey: "nav.agents" },
+ { id: "missions", label: "Missions", labelKey: "nav.missions" },
+ { id: "chat", label: "Chat", labelKey: "nav.chat" },
+ { id: "documents", label: "Artifacts", labelKey: "nav.documents" },
+ { id: "research", label: "Research", labelKey: "header.researchView" },
+ { id: "evals", label: "Evals", labelKey: "header.evalsView" },
+ /*
+ FNXC:Navigation 2026-08-01-00:00:
+ FN-8352 promotes Ideation from a Command Center tab to a persisted,
+ default-off experimental top-level view.
+ */
+ { id: "ideation", label: "Ideation", labelKey: "nav.ideation" },
+ { id: "goalsView", label: "Goals", labelKey: "header.goalsView" },
+ /*
+ FNXC:ViewState 2026-06-21-09:14:
+ FN-6829 promotes project Todos from modal-only state into the persisted built-in task-view registry so dashboard navigation can dock it in the right content area.
+ */
+ { id: "todos", label: "Todos", labelKey: "header.todosView" },
+ /*
+ FNXC:Navigation 2026-06-21-00:00:
+ FN-6886 promotes Planning Mode into a persisted top-level docked task view instead of treating it as a modal-only overlay.
+ */
+ { id: "planning", label: "Planning", labelKey: "nav.planning" },
+ { id: "skills", label: "Skills", labelKey: "header.skillsView" },
+ { id: "mailbox", label: "Mailbox", labelKey: "nav.mailbox" },
+ { id: "insights", label: "Insights", labelKey: "header.insightsView" },
+ { id: "memory", label: "Memory", labelKey: "header.memoryView" },
+ { id: "command-center", label: "Dashboard", labelKey: "nav.commandCenter" },
+ { id: "secrets", label: "Secrets", labelKey: "header.secretsView" },
+ { id: "dev-server", label: "Dev Server", labelKey: "nav.devServer", aliases: ["devserver"] },
+ { id: "pull-requests", label: "Pull Requests", labelKey: "pr.view.title" },
+ /*
+ FNXC:ViewState 2026-06-22-00:00:
+ Workflows, Import Tasks, and Automations are promoted to top-level main-content task views (left-sidebar destinations) instead of modal-only overlays, so they render in the main panel like Command Center.
+ */
+ { id: "workflows", label: "Workflows", labelKey: "nav.workflows" },
+ { id: "import-tasks", label: "Import Tasks", labelKey: "nav.importTasks" },
+ { id: "automations", label: "Automations", labelKey: "nav.automations" },
+ /*
+ FNXC:ViewState 2026-06-22-00:00:
+ Settings is promoted from a modal-only overlay into a top-level main-content task view so the header/sidebar Settings entry points dock it in the main panel like Command Center, while preserving deep-link section navigation.
+ */
+ { id: "settings", label: "Settings", labelKey: "header.settings" },
+ /*
+ FNXC:Navigation 2026-06-22-00:00:
+ Clicking a task card on the Board opens its detail as a full main-content view ("Full main panel (replaces board)") with a Back-to-board button, instead of the TaskDetailModal overlay. The detail is hosted under this registered `task-detail` task view so navigation/persistence treat it like any other docked main-panel destination.
+ */
+ { id: "task-detail", label: "Task Detail", internal: true },
+];
+
+// Indexed by canonical id and every legacy alias (e.g. "devserver" -> dev-server)
+// so lookups tolerate a persisted BuiltInTaskView value, not just canonical ids.
+const DASHBOARD_VIEW_BY_ID = new Map();
+for (const view of DASHBOARD_VIEWS) {
+ DASHBOARD_VIEW_BY_ID.set(view.id, view);
+ for (const alias of view.aliases ?? []) {
+ DASHBOARD_VIEW_BY_ID.set(alias, view);
+ }
+}
+
+export function getDashboardViewLabel(id: BuiltInTaskView): string {
+ const view = DASHBOARD_VIEW_BY_ID.get(id);
+ if (!view) {
+ throw new Error(`Unknown dashboard view id: ${id}`);
+ }
+ return view.label;
+}
diff --git a/packages/dashboard/src/shared/settings-sections.ts b/packages/dashboard/src/shared/settings-sections.ts
new file mode 100644
index 0000000000..00e1acdf8b
--- /dev/null
+++ b/packages/dashboard/src/shared/settings-sections.ts
@@ -0,0 +1,252 @@
+/*
+FNXC:UiMetadataApi 2026-07-14-00:00:
+Settings section ids, labels, scopes, groups, and search terms have one source of truth consumed by both the Settings UI and GET /api/settings/sections. Edit this registry rather than either consumer so plugin discovery and rendered Settings navigation cannot drift.
+*/
+
+export type SettingsSectionScope = "global" | "project" | undefined;
+export type SettingsSectionGroup = "Preferences" | "Project" | "AI & Models" | "Automation" | "Integrations" | "Infrastructure" | "Advanced";
+
+interface SettingsSectionDefinition {
+ id: string;
+ label: string;
+ labelKey: string;
+ scope: SettingsSectionScope;
+ isGroupHeader?: boolean;
+ searchableText?: readonly string[];
+ searchableKeys?: readonly string[];
+}
+
+export interface SettingsSectionMetadata extends SettingsSectionDefinition {
+ group: SettingsSectionGroup;
+ advanced: boolean;
+}
+
+/* FNXC:VoiceInput 2026-07-28-12:00: Voice Input is an opt-in end-user feature, so it stays visible in Basic Settings rather than joining this advanced-only set. */
+const ADVANCED_SECTION_IDS = new Set([
+ "node-sync",
+ "global-mcp",
+ "cli-agents",
+ "research-global",
+ "remote",
+ "experimental",
+ "hermes-runtime",
+ "openclaw-runtime",
+ "paperclip-runtime",
+ "scheduled-evals",
+ "node-routing",
+ "agent-permissions",
+ "memory",
+ "backups",
+ "research-project",
+ "secrets",
+ "mcp",
+ "prompts",
+ "plugins",
+]);
+
+const SETTINGS_SECTION_DEFINITIONS: readonly SettingsSectionDefinition[] = [
+ { id: "__preferences_header", label: "Preferences", labelKey: "settings.nav.preferencesHeader", scope: undefined, isGroupHeader: true },
+ { id: "appearance", label: "Appearance", labelKey: "settings.nav.appearance", scope: "global", searchableText: ["theme", "color", "sidebar", "dock", "task popup", "task popups", "board list popups", "popup view attachment", "open tasks as popups", "quick chat"] },
+ { id: "keyboard-shortcuts", label: "Keyboard Shortcuts", labelKey: "settings.nav.keyboardShortcuts", scope: "global", searchableText: ["keyboard shortcuts", "hotkeys", "quick chat shortcut", "terminal shortcut", "open files", "open settings", "command center", "new task shortcut", "record shortcut"] },
+ { id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global", searchableText: ["ntfy", "webhook", "events", "failure notifications", "sticky", "toast"] },
+ { id: "global-general", label: "General · Global", labelKey: "settings.nav.globalGeneral", scope: "global", searchableText: ["global defaults", "modal outside dismiss", "agent logs", "persist tool output", "thinking logs"] },
+ /*
+ FNXC:SettingsNavigation 2026-07-16-12:00:
+ FN-8128 keeps the `fn` binary panel as a dedicated section rather than re-inlining machine plumbing at the top of General · Global, while restoring it to the default-visible Global group. Operators need installation, version, path, and diagnostic controls in Basic mode when setup or repair is needed.
+ */
+ { id: "cli-binary", label: "CLI Binary", labelKey: "settings.nav.cliBinary", scope: "global", searchableText: ["fn binary", "cli", "install", "version", "path", "upgrade", "homebrew", "binary check"] },
+
+ { id: "__project_header", label: "Project", labelKey: "settings.nav.projectHeader", scope: undefined, isGroupHeader: true },
+ /*
+ FNXC:GitHubImportTranslate 2026-07-15-16:20:
+ Import auto-translation lives in Project General beside the other import-scoped GitHub settings, but operators look for it by what it DOES ("translate", "language", "auto translate issues"), not by the section it happens to live in.
+ FNXC:SettingsSearch 2026-07-15-19:10: the per-setting index now matches these controls on their own label and help text, so the terms that merely restate the copy are no longer load-bearing. The list is kept for the genuine vocabulary gaps — "localize", "localization", "foreign language issues" — which appear nowhere in the copy, and because unmigrated siblings in this section still rely on section-level keywords.
+ */
+ { id: "general", label: "General · Project", labelKey: "settings.nav.projectGeneral", scope: "project", searchableText: ["project general", "Completion Documentation Automation", "Quick Chat launcher", "ephemeral task-worker agents", "chat rooms", "auto-cleanup old chats", "translate", "translation", "auto translate", "auto-translate", "autotranslate", "auto translate issues", "translate issues", "translate imported issues", "githubImportAutoTranslate", "importTranslateTargetLocale", "target language", "translation target language", "translation language", "language", "foreign language issues", "import language", "localize", "localization", "report", "report bug", "send feedback", "share idea", "get help"], searchableKeys: ["settings.general.autoTranslateImportedIssues", "settings.general.autoTranslateImportedIssuesHelp", "settings.general.translationTargetLanguage", "settings.general.translationTargetLanguageHelp", "settings.general.followDashboardLanguage"] },
+ { 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: "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"] },
+ /*
+ FNXC:SettingsNavigation 2026-07-18-12:30:
+ FN-8350 makes configuration history a project Settings destination instead of a
+ Command Center card. Register it in the shared section registry so desktop
+ navigation, the mobile picker, and Settings search expose one canonical view.
+ */
+ { id: "config-versions", label: "Configuration Versions", labelKey: "settings.nav.configVersions", scope: "project", searchableText: ["configuration versions", "revision history", "roll back settings", "restore configuration", "config rollback"] },
+
+ { id: "__ai_header", label: "AI & Models", labelKey: "settings.nav.aiHeader", scope: undefined, isGroupHeader: true },
+ /*
+ FNXC:SettingsNavigation 2026-07-16-01:30:
+ Authentication leads the AI & Models group. It is a provider-credentials screen, so it belongs with the model settings it gates rather than under Integrations (where it sat among MCP/Plugins/runtimes) or floating above the groups as a special case — connecting a provider and choosing its models are one task, done in that order.
+ First within the group because nothing else in AI & Models can be configured until it is done: with no provider connected there are no models to pick.
+
+ FNXC:SettingsNavigation 2026-07-16-13:40:
+ FN-8130 changes the Settings landing surface from Authentication to Appearance. Authentication remains first within its own AI & Models group, but the always-visible global Preferences section is the default instead.
+ */
+ { id: "authentication", label: "Authentication", labelKey: "settings.nav.authentication", scope: undefined, searchableText: ["login", "OAuth", "API key", "custom providers", "Anthropic", "OpenAI", "provider credentials"] },
+ { id: "global-models", label: "Models · Global", labelKey: "settings.nav.globalModels", scope: "global", searchableText: ["global models", "model presets", "favorite providers", "model pricing overrides", "LiteLLM pricing", "token pricing", "translate", "translation model", "import translation model", "import auto-translation model"] },
+ /**
+ * FNXC:SettingsNavigation 2026-07-13-00:00:
+ * Project Models owns the FN-7907 Direct-chat default settings. Its shared Settings search index must advertise chat-default terms and i18n labels so desktop nav, the mobile section picker, and filtered search all surface this section when operators search for Chat defaults.
+
+ * FNXC:SettingsNavigation 2026-07-14-20:15:
+ * Title auto-summarization lives under Project Models but operators search for "summarize", "auto summarize", "title summarization", and related phrases that did not match the prior chat-only/summarization-model index. Advertise those terms and the control's i18n keys so Settings search finds this section.
+ */
+ {
+ id: "project-models",
+ label: "Models · Project",
+ labelKey: "settings.nav.projectModels",
+ scope: "project",
+ searchableText: [
+ "default provider",
+ "default model",
+ "workflow model lanes",
+ "Plan/Triage",
+ "Executor",
+ "Reviewer",
+ "summarization model",
+ "summarize",
+ "summarize titles",
+ "auto summarize",
+ "auto-summarize",
+ "auto summarize titles",
+ "auto-summarize titles",
+ "autoSummarizeTitles",
+ "task definition language",
+ "task definitions input language",
+ "taskDefinitionInInputLanguage",
+ "localized task prose",
+ "title summarization",
+ "title summarizer",
+ "AI title",
+ "AI merge commit summaries",
+ "merge commit summary",
+ "chat",
+ "new chat",
+ "new chat behavior",
+ "chat default",
+ "chat default model",
+ "chat default agent",
+ "chat model",
+ "chat agent",
+ "prompt for model",
+ "always use default",
+ // FNXC:GitHubImportTranslate 2026-07-15-16:20: the import-translate lane is picked here.
+ "translate",
+ "translation",
+ "translation model",
+ "import translation model",
+ "import auto-translation model",
+ "auto-translate model",
+ ],
+ searchableKeys: [
+ "settings.projectModels.chatHeading",
+ "settings.projectModels.chatDescription",
+ "settings.projectModels.chatNewSessionMode",
+ "settings.projectModels.chatNewSessionModePrompt",
+ "settings.projectModels.chatNewSessionModeAlwaysDefault",
+ "settings.projectModels.chatDefaultKind",
+ "settings.projectModels.chatDefaultModel",
+ "settings.projectModels.chatDefaultAgent",
+ "settings.projectModels.aITitleAndGitCommitMessageSummarization",
+ "settings.projectModels.autoSummarizeLongDescriptionsAsTitles",
+ "settings.projectModels.whenEnabledTasksCreatedWithoutATitleBut",
+ "settings.projectModels.aIMergeCommitSummaries",
+ "settings.projectModels.whenEnabledMergeCommitMessagesIncludeAnAI",
+ ],
+ },
+ {
+ 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: "agent-permissions", label: "Agents & Permissions", labelKey: "settings.nav.agentPermissions", scope: "project", searchableText: ["agent provisioning", "approval", "permissions", "policy", "agent creation"] },
+ { id: "prompts", label: "Prompts", labelKey: "settings.nav.prompts", scope: "project", searchableText: ["prompt instructions", "PR title prompt", "PR description prompt", "custom prompts"] },
+ { id: "memory", label: "Memory", labelKey: "settings.nav.memory", scope: "project", searchableText: ["memory backend", "Dreams", "long-term memory", "qmd", "memory file", "retrieval"] },
+ { id: "research-global", label: "Research · Global", labelKey: "settings.nav.researchGlobal", scope: "global", searchableText: ["research providers", "external search providers", "fetch limits", "global research defaults", "citations"] },
+ { id: "research-project", label: "Research · Project", labelKey: "settings.nav.researchProject", scope: "project", searchableText: ["project research", "research runs", "citations", "search limits", "fetch synthesis"] },
+ { id: "voice-input", label: "Voice Input", labelKey: "settings.nav.voiceInput", scope: "project", searchableText: ["voice", "dictation", "microphone", "speech to text", "parakeet", "transcription"] },
+
+ { id: "__automation_header", label: "Automation", labelKey: "settings.nav.automationHeader", scope: undefined, isGroupHeader: true },
+ /*
+ FNXC:SettingsNavigation 2026-07-15-18:52:
+ Scheduling is split into a Global/Project pair rather than one section holding both authority levels behind in-section subheadings. The machine-wide concurrency cap and a project's scheduling posture are different questions, and a search result landing mid-section showed no subheading to disambiguate them.
+ */
+ { id: "scheduling-global", label: "Scheduling · Global", labelKey: "settings.nav.schedulingGlobal", scope: "global", searchableText: ["global max concurrent", "concurrency cap", "all projects", "machine wide", "parallel agents", "scheduler"] },
+ { id: "scheduling", label: "Scheduling · Project", 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: "__integrations_header", label: "Integrations", labelKey: "settings.nav.integrationsHeader", scope: undefined, isGroupHeader: true },
+ /*
+ FNXC:SourceControl 2026-07-15-20:30:
+ The Global/Project source-control pair sits under Integrations, not Project: these settings configure how Fusion talks to GitHub/GitLab, which is the same kind of thing as the MCP and provider entries beside them.
+ The two are adjacent and ordered global-then-project to match the inheritance they model — the global entry holds the fallbacks the project entry overrides — mirroring the MCP Servers pair directly below.
+ The GitLab/GitHub keywords below were curated on the `general` and `merge` nav entries before their controls moved here; a keyword left behind would send an operator searching "gitlab token" to a section that no longer renders one. The translate keywords deliberately did NOT move: `githubImportAutoTranslate`/`importTranslateTargetLocale` are Import Tasks panel settings and stay in General.
+ */
+ { id: "source-control-global", label: "Source Control · Global", labelKey: "settings.nav.sourceControlGlobal", scope: "global", searchableText: ["GitLab instance URL", "global tracking repo", "GitLab", "GitHub", "global GitLab token", "GitLab fallback", "source control", "forge"] },
+ { id: "source-control", label: "Source Control · Project", labelKey: "settings.nav.sourceControl", scope: "project", searchableText: ["GitHub tracking", "GitLab integration", "GitHub auth mode", "GitLab access token", "GitHub personal access token", "tracking repo", "source control", "forge", "gh cli", "issue tracking"] },
+ { id: "global-mcp", label: "MCP Servers · Global", labelKey: "settings.nav.globalMcp", scope: "global", searchableText: ["global MCP servers", "shared MCP", "user MCP", "tool servers"] },
+ { id: "mcp", label: "MCP Servers · Project", labelKey: "settings.nav.mcp", scope: "project", searchableText: ["project MCP servers", "workspace MCP", "project tool servers", "mcp config"] },
+ { id: "plugins", label: "Plugins", labelKey: "settings.nav.plugins", scope: "project", searchableText: ["Fusion plugins", "Pi extensions", "plugin manager", "extension marketplace"] },
+ { 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"] },
+ { id: "secrets", label: "Secrets", labelKey: "settings.nav.secrets", scope: "project", searchableText: ["secrets", "secret storage", "environment", "credentials"] },
+
+ { id: "__infrastructure_header", label: "Infrastructure", labelKey: "settings.nav.infrastructureHeader", scope: undefined, isGroupHeader: true },
+ { id: "node-sync", label: "Node Sync", labelKey: "settings.nav.nodeSync", scope: "global", searchableText: ["sync", "node", "distributed", "heartbeat", "coordination"] },
+ { id: "node-routing", label: "Node Routing", labelKey: "settings.nav.nodeRouting", scope: "project", searchableText: ["node routing", "routing rules", "node selection", "execution nodes"] },
+ /*
+ 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", searchableText: ["cloudflared", "tunnel", "QR", "persistent token", "remote URL"] },
+ { id: "backups-global", label: "Database Backups", labelKey: "settings.backups.databaseBackups", scope: "global", searchableText: ["database backup", "restore", "shared cluster"] },
+ { id: "backups", label: "Memory Backups", labelKey: "settings.backups.memoryBackups", scope: "project", searchableText: ["memory backup", "memory snapshot"] },
+
+ { id: "__advanced_header", label: "Advanced", labelKey: "settings.nav.advancedHeader", scope: undefined, isGroupHeader: true },
+ { id: "experimental", label: "Experimental Features", labelKey: "settings.nav.experimental", scope: "global", searchableText: ["feature flags", "experiments", "research view", "evals view", "sandbox", "subtask breakdown"] },
+];
+
+let currentGroup: SettingsSectionGroup | undefined;
+export const SETTINGS_SECTION_METADATA: readonly SettingsSectionMetadata[] = SETTINGS_SECTION_DEFINITIONS.map((section) => {
+ if (section.isGroupHeader) {
+ currentGroup = section.label as SettingsSectionGroup;
+ }
+ if (!currentGroup) {
+ throw new Error(`Settings section ${section.id} appears before its group header`);
+ }
+ return {
+ ...section,
+ group: currentGroup,
+ advanced: ADVANCED_SECTION_IDS.has(section.id),
+ };
+});