feat(dashboard): add view and settings-section enumeration API (#2453)

## Summary

Gives external integrations (command palettes, plugin launchers,
alternate dashboard shells) a supported way to **discover the host UI**
— instead of hardcoding the dashboard's view ids, labels and settings
search terms and hand-syncing them on every release. This is the
read-only metadata slice of the "constrained by a stable host context
and API client" idea in
`docs/proposals/2026-07-01-dashboard-theme-plugin-system.md`, and the
follow-on to #2415 (theme tokens + overlay layering).

Two additions, both inert unless called:

| Endpoint | Returns |
|---|---|
| `GET /api/views` | Every registered built-in view id, in dashboard
order — `id`, English `label`, plus optional i18n `labelKey`, legacy
`aliases` and `internal` flag. |
| `GET /api/settings/sections` | Selectable Settings sections — `id`,
`label`, `labelKey`, `scope`, `group`, `keywords`, `searchableKeys`,
`advanced`. |

Both are read-only, return static project-independent metadata, take no
project id, and are mounted inside `createApiRoutes` so they sit behind
exactly the same `/api` authentication as every other dashboard route —
no more, no less.

## What actually changed — one source of truth

The endpoints are the small part. The core of the diff is **collapsing
duplicated UI metadata into two shared registries that now drive both
the dashboard UI and the API**:

- `packages/dashboard/src/shared/dashboard-views.ts` — canonical view
ids + English labels + i18n keys + legacy aliases.
- `packages/dashboard/src/shared/settings-sections.ts` — canonical
settings sections + scope/group/search metadata, with `group` and
`advanced` derived from the list's own structure.

`LeftSidebarNav`, `SettingsModal` and `useViewState` were rewritten to
consume those registries instead of carrying their own copies (net
**−230 lines** in `SettingsModal` alone). Edit the registry and the
rendered UI and the API move together.

## Drift protection

Being precise about what each test can and cannot catch, because
"no-drift" claims are easy to overstate:

- `left-sidebar-nav-registry-parity.test.tsx` — the one test that
catches drift the registry does not already determine. It **renders**
the sidebar with a recording `t()` spy and pins each entry's translation
key and English fallback to the registry (the sidebar still hardcodes
its keys). It also asserts the rendered destination count equals the
enrolled id list, so a newly added sidebar view fails until it is
enrolled.
- `ui-metadata-sync.test.ts` — pins the Settings navigation list,
advanced-visibility set, persisted view list, reset-key registry and
both endpoint payloads to the registries. Since those consumers are now
*derived* from the registries, these assertions mainly guard against a
future consumer **re-hardcoding** its own copy. Two of them do stand on
their own: each section's served `group` is pinned to the group header
it actually renders under, and no published `labelKey` may resolve to a
non-leaf i18n node.
- `register-ui-metadata-routes.test.ts` — drives the real Express router
and asserts each endpoint serves the registry payload verbatim, with no
filtering or reshaping.
- Exactly two **existing** tests are updated, both for the same reason:
they asserted that `SettingsModal.tsx`'s *source text* contains a
section literal that now lives in the registry.
`VoiceInputSection.modal-visibility.test.tsx` now asserts Voice Input's
Basic-mode contract against `SETTINGS_SECTION_METADATA`, and
`mcp-documentation.test.ts` reads the registry for the two MCP section
ids. No other existing test in the package changes.

## Design notes / decisions for review

- **`GET /api/views` returns the full registry, not the live menu.** It
includes flag-gated / experimental ids and `internal` (non-navigable)
destinations; reachability depends on flags and plugins this endpoint
does not evaluate. Documented as "known view ids", not "visible nav
entries".
- **`labelKey` is optional and best-effort; `label` is the guarantee.**
A `labelKey` is published only where the dashboard itself renders that
view's title through it. `graph` (labelled from a plugin manifest) and
the internal `task-detail` carry none rather than advertise a key that
resolves to nothing — and `task-detail` in particular must not point at
`taskDetail.title`, which is an occupied i18n *namespace* whose lookup
returns an object rather than falling through to a default. A guard test
now enforces that. Separately, a few published keys (`nav.ideation`,
`nav.importTasks`, `nav.automations`, `pr.view.title`) are the
dashboard's real keys but aren't in the shipped catalogs yet because the
host supplies their English inline; the docs say plainly that consumers
must fall back to `label`.
- **`keywords` / `searchableKeys` are explicitly non-contractual.**
`searchableKeys` exposes the raw i18n translation-key strings backing a
section's searchable copy; values, ordering and presence may change
between releases. Documented as best-effort search hints, never stable
identifiers.
- **Migration is deliberately partial.** The desktop sidebar, Settings
navigation and persisted view list now come from the registries;
`Header.tsx` and the mobile More sheet still hardcode a few of the same
labels. They can still drift from what `GET /api/views` reports;
converting them is left to a follow-up so this diff stays reviewable.
- **No project scoping, deliberately.** The proposal doc rightly pushes
plugin traffic through a project-scoped client — these two endpoints are
the exception that proves the rule: they return static registry metadata
that is identical for every project, so threading a `projectId` would
imply a scoping guarantee that does not exist here. They never touch
`getScopedStore` / `TaskStore`.
- **Two endpoints rather than one `/api/ui-metadata` envelope.** Views
and Settings sections are independent registries with different
consumers, and `/settings/sections` sits naturally beside the existing
`/settings/*` routes. A consumer that only needs navigation doesn't pay
for settings metadata.
- **The registry extraction ships with the endpoints rather than as a
separate PR.** The registries *are* the mechanism that keeps the API
honest — split apart, the first half is a refactor with no observable
effect and the second can't land without it.
- **Placement:** `packages/dashboard/src/shared/` is a new directory,
and these are the first *production* `app/ → src/` imports in the
package (today the only one is in `ProviderIcon.test.tsx`). They sit
under `src/` because `src/`'s tsconfig cannot import `app/`, so a module
both sides consume has nowhere else to go; both registries are
dependency-free data leaves, and `vite build` plus
`check-no-node-only-core-imports-in-dashboard` confirm the client bundle
is unaffected. The considered alternative was `packages/core/src` behind
the `dashboard-browser-safe-core-modules.json` allowlist, where
`mobile-nav-primary-items.ts` keeps a destination→labelKey table — these
stayed out of `core` because they are dashboard-owned UI ids, and
because the two tables describe different surfaces (core mirrors the
mobile nav's `nav.skills`/`nav.settings`; this registry mirrors the
desktop sidebar's `header.skillsView`/`header.settings`).
- Ships a `@runfusion/fusion` **minor** changeset (`category: feature`).

Happy to adjust any of the above — shape, placement, or dropping
`searchableKeys` — if you'd rather it landed differently.

## Verification

- Rebased onto `main@26dcccb7c`. Two conflicts, both resolved by
absorbing upstream's work rather than reverting it:
- `SettingsModal.tsx` — upstream's `voice-input` section (and the
`FNXC:VoiceInput` decision comment explaining it stays out of the
advanced-only set) moved into the registry. The registry's section list
is byte-identical to `main`'s `SETTINGS_SECTIONS` (45/45 entries, all
fields), and the registry-derived `ADVANCED_SETTINGS_SECTION_IDS` is
identical to `main`'s hardcoded set (19/19, same order) — both verified
mechanically, not by eye. Upstream's `RUNTIME_*`
hide-uninstalled-runtimes sets are untouched.
- `routes/README.md` — the `mount-sequence` list regenerated from
`CREATE_API_ROUTES_REGISTRAR_MOUNT_SEQUENCE`, so `registerVoiceRoutes`
and `registerUiMetadataRoutes` are both in place and the contract test
passes.
- `DASHBOARD_VIEWS` covers exactly `main`'s `BuiltInTaskView` union,
aliases included, and `BUILT_IN_TASK_VIEWS` reproduces `main`'s 27-entry
array in order (`devserver` still preceding `dev-server` for the
migration path).
- Every one of the 20 sidebar labels the refactor rewrote was checked to
be byte-identical to `main`'s hardcoded fallback, and every `FNXC:`
decision comment displaced by the move was accounted for — all 75 in
`SettingsModal.tsx` and all 11 in `useViewState.ts` survive, relocated
onto the registry entries they document.
- The full `dashboard-app` + `dashboard-api` suites were run at this
commit (**20,706 passing**) and again on unmodified `main@26dcccb7c`,
and the failing-file sets compared: **every file that fails here also
fails on `main`** — nothing regresses. The overlap is environment-driven
(Postgres-backed `*.pg.test.ts`, tests needing built `dist` artifacts,
and `SettingsModalNodeRouting.test.tsx`'s `No "fetchSystemInfo" export
is defined on the "../../api" mock`), none of it touched by this change.
- `tsc --noEmit` clean for both dashboard projects, `eslint` clean on
every changed file, and `vite build` of the client bundle succeeds (the
two pre-existing `@fusion-plugin-examples/claude-runtime` /
`playwright-core` module-resolution errors reproduce on unmodified
`main`).
- Repo gate scripts pass: `check-changeset-format`,
`check-routes-modular`, `check-no-node-only-core-imports-in-dashboard`,
`check-no-cwd-relative-dashboard-test-reads`, `check-mock-completeness`.
- The three new assertions were mutation-tested rather than assumed
load-bearing: breaking the registry's `group` derivation, dropping an
enrolled sidebar id, and re-pointing `task-detail` at the
`taskDetail.title` namespace each make their test fail.
- Local CodeRabbit review over two passes: 3 minor findings, all
addressed (parity projection missing `group`; route tests asserting
partial instead of exact payloads; the `labelKey` guard not covering the
settings registry).


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added authenticated, read-only APIs for discovering dashboard views
and selectable Settings sections.
* Added dashboard view metadata, including labels, aliases, internal
status, and translation keys.
* Added Settings metadata with grouping, scope, advanced status, and
search-related information.
  * Updated navigation and Settings UI labels to use shared metadata.

* **Documentation**
  * Documented the new metadata endpoints and integration guidance.

* **Bug Fixes**
* Added safeguards and automated checks to keep UI navigation and API
metadata synchronized.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Victor Canô
2026-07-27 02:34:18 -03:00
committed by GitHub
parent 00778cb10f
commit d6c917d726
18 changed files with 970 additions and 303 deletions

View File

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

View File

@@ -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
<!-- FNXC:UiMetadataApi 2026-07-14-00:00: Frontend plugins and command palettes must discover the host's registered view ids and Settings metadata through authenticated, read-only APIs instead of hardcoding dashboard-owned ids, labels, or search terms. -->
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`

View File

@@ -26,6 +26,9 @@ Never copy worktree files into the reported checkout, rebuild an unmerged branch
<!-- 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.
<!-- FNXC:UiMetadataApi 2026-07-14-00:00: External dashboard integrations need the same view and Settings-section registry that the UI renders, exposed without duplicating labels or search terms. -->
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.
<!-- FNXC:Settings 2026-07-09-12:00: FN-7751 keeps FN-7713's collapsed mobile search row but moves the toggle inline beside the section dropdown so mobile Settings exposes one compact section/search control row; desktop/tablet keep the row always visible with no toggle. -->
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.

View File

@@ -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({
<button
type="button"
className="btn left-sidebar-nav__item left-sidebar-nav__settings"
aria-label={t("header.settings", "Settings")}
title={t("header.settings", "Settings")}
aria-label={t("header.settings", getDashboardViewLabel("settings"))}
title={t("header.settings", getDashboardViewLabel("settings"))}
data-testid="sidebar-nav-settings"
/* FNXC:Navigation 2026-06-22-12:00: Wrap so React's MouseEvent is not forwarded as onOpenSettings' settingsInitialSection arg. */
onClick={() => onOpenSettings?.()}
>
<Settings size={16} />
<span className="left-sidebar-nav__label">{t("header.settings", "Settings")}</span>
<span className="left-sidebar-nav__label">{t("header.settings", getDashboardViewLabel("settings"))}</span>
</button>
</div>

View File

@@ -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<string, string> = 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<Record<string, typeof Globe>> = {
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

View File

@@ -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<typeof import("react-i18next")>();
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(
<LeftSidebarNav
view="board"
onChangeView={() => {}}
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);
}
});
});

View File

@@ -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<string, unknown>;
const resolveKey = (key: string): unknown =>
key.split(".").reduce<unknown>(
(node, part) =>
node && typeof node === "object" && part in (node as Record<string, unknown>)
? (node as Record<string, unknown>)[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,
});
}
});
});

View File

@@ -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("<VoiceInputSection form={form} setForm={setForm} />");
});

View File

@@ -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<string, readonly string[]> = {
/*
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<Record<string, readonly string[]>> = {
general: [
"allowAbsoluteFileBrowserPaths",
"capacityRiskBannerEnabled",

View File

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

View File

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

View File

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

View File

@@ -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`
<!-- mount-sequence:end -->
## Ordering rules

View File

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

View File

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

View File

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

View File

@@ -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<string, DashboardViewMetadata>();
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;
}

View File

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