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