Files
fusion/packages/dashboard/app/hooks/useViewState.ts
Victor Canô d6c917d726 feat(dashboard): add view and settings-section enumeration API (#2453)
## Summary

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

Two additions, both inert unless called:

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

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

## What actually changed — one source of truth

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

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

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

## Drift protection

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

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

## Design notes / decisions for review

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

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

## Verification

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


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

## Summary by CodeRabbit

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

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

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

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

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-26 22:34:18 -07:00

309 lines
14 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import type { ThemeMode } from "@fusion/core";
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";
export type PluginTaskView = `plugin:${string}:${string}`;
export type TaskView = BuiltInTaskView | PluginTaskView;
/*
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);
}
function isTaskView(value: string | null): value is TaskView {
return value !== null && (isBuiltInTaskView(value) || isPluginViewId(value));
}
const LEGACY_ROADMAPS_PLUGIN_VIEW = getPluginViewId("fusion-plugin-roadmap", "roadmaps");
function normalizeTaskView(value: TaskView): TaskView {
return value === "devserver" ? "dev-server" : value;
}
/*
FNXC:ViewState 2026-06-22-15:30:
Fusion must land on the Board on load, never the Command Center "Dashboard" view. A persisted/normalized `command-center` value resolves to `board` for the auto-restored landing view only (initializer + project-hydration effect). Deep links (`?view=command-center`) and explicit user navigation still reach the Command Center — this only governs the restored landing surface.
FNXC:ViewState 2026-07-07-00:00:
FN-7649: an auto-restored/hydrated landing surface must never be Settings either. Once a project's per-project persisted `kb-dashboard-task-view` becomes `settings` (a common state after configuring a project through Settings), switching projects re-hydrates that scoped value and — without this guard — restores straight to Settings instead of the Board. `settings`, like `command-center`, now resolves to `board` for the auto-restored landing view only (initializer + project-hydration effect). Deep links (`?view=settings`) and explicit header/sidebar navigation to Settings still work because the `?view=` URL effect and `setTaskView`/`handleChangeTaskView` paths use `normalizeTaskView`, not this guard.
*/
function resolveLandingTaskView(value: TaskView): TaskView {
return value === "command-center" || value === "settings" ? "board" : value;
}
/*
FNXC:ViewState 2026-07-26-10:35:
Mobile browsers DISCARD a backgrounded dashboard tab and reload it when the user returns. That reload is indistinguishable from a fresh boot to `localStorage`, so the landing-view guard above bounced an operator who was reading Command Center or Settings back to the Board every time they took a phone call — losing their place through no action of their own.
The two cases ARE distinguishable by storage lifetime: `sessionStorage` is per-tab and survives reload AND discard-restore, but is never inherited by a newly opened tab. So a session-scoped copy of the live view means "this tab was already running and came back", while its absence means "genuinely fresh boot".
Deliberately conservative: the session copy bypasses `resolveLandingTaskView` ONLY on the first hydration of a tab that already had a view. A new tab, a cleared session, and every explicit project switch (`hasHydratedScopedTaskViewRef` already true) all keep the FN-7649 bounce to Board. The stored value is a view name only — never a URL, task id, or content.
*/
const SESSION_TASK_VIEW_KEY = "kb-dashboard-task-view-session";
/*
FNXC:ViewState 2026-07-26-10:44:
`task-detail` is the one view a same-tab restore must NOT reproduce: the detail's task snapshot is in-memory only, so a restored `task-detail` renders MainContent's empty-detail Board fallback — a Board wearing the wrong view name, which also suppresses the board scroll replay. Resolve it to `board` instead. A `?task=` deep link still re-opens the real detail on reload via useDeepLink.
*/
function resolveSessionTaskView(value: TaskView): TaskView {
return value === "task-detail" ? "board" : normalizeTaskView(value);
}
function getSessionStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
const storage = window.sessionStorage;
if (!storage || typeof storage.getItem !== "function" || typeof storage.setItem !== "function") return null;
return storage;
} catch {
// Safari private mode / storage disabled: fall back to the fresh-boot landing behavior.
return null;
}
}
/*
FNXC:ViewState 2026-07-26-19:22:
Symmetric with the writer: with no project there is no project-scoped key to read, and reading the
bare one would restore the previous project's view (including any bare key a pre-fix build left in
this tab's sessionStorage). Absent a project, the same-tab restore simply does not apply.
*/
function getScopedSessionTaskView(projectId?: string): string | null {
if (typeof projectId !== "string" || projectId.length === 0) return null;
const storage = getSessionStorage();
if (!storage) return null;
try {
return storage.getItem(scopedKey(SESSION_TASK_VIEW_KEY, projectId));
} catch {
return null;
}
}
/*
FNXC:ViewState 2026-07-26-19:15:
Project-scoped ONLY, enforced here rather than merely documented at the call site.
`scopedKey(base, undefined)` returns the BARE key, and the persist effect runs during boot and the
project-switch window when `currentProject` is undefined — so the previous version DID write the
unscoped mirror the adjacent comment claimed was never written, and the initializer then read it for
first paint, leaking the previous project's view into the next project's landing. Dropping the write
costs nothing: the value is re-persisted the moment a project resolves.
*/
function setScopedSessionTaskView(value: TaskView, projectId?: string): void {
if (typeof projectId !== "string" || projectId.length === 0) return;
const storage = getSessionStorage();
if (!storage) return;
try {
storage.setItem(scopedKey(SESSION_TASK_VIEW_KEY, projectId), value);
} catch {
// Quota failures must never break navigation.
}
}
function migrateLegacyRoadmapsView(value: string): TaskView {
if (value !== "roadmaps") {
return "board";
}
return isPluginViewRegistered("fusion-plugin-roadmap", "roadmaps") ? LEGACY_ROADMAPS_PLUGIN_VIEW : "board";
}
/*
FNXC:ViewState 2026-06-19-00:00:
FN-6702 removed the top-level Reliability task view after moving the page into Command Center. Persisted or linked legacy `reliability` values must land users on `command-center` instead of falling back to the board or becoming invalid.
*/
function migrateLegacyReliabilityView(value: string | null): TaskView | null {
return value === "reliability" ? "command-center" : null;
}
/*
FNXC:ViewState 2026-06-21-00:00:
FN-6881 removed the standalone Stash Recovery task view after moving recovery into Git Manager. Persisted or linked `stash-recovery` values must land on Board instead of restoring an orphaned route.
*/
function migrateRetiredStashRecoveryView(value: string | null): TaskView | null {
return value === "stash-recovery" ? "board" : null;
}
interface UseViewStateOptions {
projectsLoading: boolean;
projectsError: string | null;
currentProjectLoading: boolean;
currentProject: ProjectInfo | null;
projectsLength: number;
setupWizardOpen: boolean;
openSetupWizard: () => void;
themeMode: ThemeMode;
setThemeMode: (mode: ThemeMode) => void;
}
export interface UseViewStateResult {
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
taskView: TaskView;
setTaskView: (view: TaskView) => void;
handleChangeTaskView: (newView: TaskView) => void;
handleToggleTheme: () => void;
}
export function useViewState(options: UseViewStateOptions): UseViewStateResult {
const {
projectsLoading,
currentProjectLoading,
currentProject,
themeMode,
setThemeMode,
} = options;
const [viewMode, setViewMode] = useState<ViewMode>(() => {
if (typeof window !== "undefined") {
const saved = window.localStorage.getItem("kb-dashboard-view-mode");
if (saved === "overview" || saved === "project") return saved;
}
return "overview";
});
const [taskView, setTaskView] = useState<TaskView>(() => {
/*
FNXC:ViewState 2026-07-26-19:18:
No unscoped session read here. The initializer runs before the project is known, so the only key
it could read is the bare one — which is precisely the cross-project leak the session copy is not
allowed to have (and which nothing writes any more). The same-tab restore therefore happens in
the project-hydration effect below, where the project id exists; first paint until then falls
back to the persisted/landing view exactly as it did before the session copy existed.
*/
const saved = getScopedItem("kb-dashboard-task-view");
const legacyReliabilityView = migrateLegacyReliabilityView(saved);
if (legacyReliabilityView) return legacyReliabilityView;
const retiredStashRecoveryView = migrateRetiredStashRecoveryView(saved);
if (retiredStashRecoveryView) return retiredStashRecoveryView;
if (saved === "roadmaps") return migrateLegacyRoadmapsView(saved);
if (isTaskView(saved)) return resolveLandingTaskView(normalizeTaskView(saved));
return "board";
});
const hasHydratedScopedTaskViewRef = useRef(false);
useEffect(() => {
window.localStorage.setItem("kb-dashboard-view-mode", viewMode);
}, [viewMode]);
useEffect(() => {
/*
First hydration of a tab that was already running (reload / discard-restore): the per-tab
session copy is the operator's real last view, so honor it verbatim. Every later run of this
effect is an explicit project switch and keeps the FN-7649 landing bounce.
*/
if (!hasHydratedScopedTaskViewRef.current) {
const sessionView = getScopedSessionTaskView(currentProject?.id);
if (isTaskView(sessionView)) {
setTaskView(resolveSessionTaskView(sessionView));
if (currentProject?.id) {
hasHydratedScopedTaskViewRef.current = true;
}
return;
}
}
const saved = getScopedItem("kb-dashboard-task-view", currentProject?.id);
const legacyReliabilityView = migrateLegacyReliabilityView(saved);
const retiredStashRecoveryView = migrateRetiredStashRecoveryView(saved);
if (legacyReliabilityView) {
setTaskView(legacyReliabilityView);
} else if (retiredStashRecoveryView) {
setTaskView(retiredStashRecoveryView);
} else if (saved === "roadmaps") {
setTaskView(migrateLegacyRoadmapsView(saved));
} else if (isTaskView(saved)) {
const preserveLegacyOnFirstScopedHydration =
!hasHydratedScopedTaskViewRef.current && saved === "devserver";
setTaskView(
preserveLegacyOnFirstScopedHydration ? "devserver" : resolveLandingTaskView(normalizeTaskView(saved)),
);
} else {
setTaskView("board");
}
if (currentProject?.id) {
hasHydratedScopedTaskViewRef.current = true;
}
}, [currentProject?.id]);
useEffect(() => {
setScopedItem("kb-dashboard-task-view", taskView, currentProject?.id);
/*
Per-tab copy: what THIS tab is showing right now, for a reload/discard-restore of this tab.
Project-scoped, and skipped entirely while the project is unknown (boot and project-switch
windows) — an unscoped mirror would let the previous project's view leak into the next project's
landing. That skip is enforced inside setScopedSessionTaskView, not assumed here: this effect
deliberately still runs with `currentProject?.id === undefined`.
*/
setScopedSessionTaskView(taskView, currentProject?.id);
}, [currentProject?.id, taskView]);
useEffect(() => {
// FNXC:ReportPipeline 2026-07-18-12:30: Report traces describe only the
// selected view name; never capture URLs, embedded identifiers, or content.
if (isBuiltInTaskView(taskView)) recordActivity(taskView);
}, [taskView]);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const viewParam = new URLSearchParams(window.location.search).get("view");
const legacyReliabilityView = migrateLegacyReliabilityView(viewParam);
const retiredStashRecoveryView = migrateRetiredStashRecoveryView(viewParam);
if (legacyReliabilityView) {
setTaskView(legacyReliabilityView);
} else if (retiredStashRecoveryView) {
setTaskView(retiredStashRecoveryView);
} else if (viewParam && isTaskView(viewParam)) {
setTaskView(normalizeTaskView(viewParam));
}
}, []);
useEffect(() => {
if (projectsLoading || currentProjectLoading) return;
if (currentProject && viewMode === "overview") {
setViewMode("project");
}
}, [projectsLoading, currentProjectLoading, currentProject, viewMode]);
/*
FNXC:Onboarding 2026-06-22-05:06:
Brand-new users should enter the unified onboarding sequence first: AI setup, GitHub, Project, Agent, then First Task.
Do not auto-open the project-only setup wizard just because there are zero projects; that wizard is opened from the Project step or explicit Add Project actions.
*/
const handleChangeTaskView = useCallback((newView: TaskView) => {
setTaskView(newView);
}, []);
const handleToggleTheme = useCallback(() => {
const cycle: ThemeMode[] = ["dark", "light", "system"];
const currentIndex = cycle.indexOf(themeMode);
const nextMode = cycle[(currentIndex + 1) % cycle.length];
setThemeMode(nextMode);
}, [themeMode, setThemeMode]);
return {
viewMode,
setViewMode,
taskView,
setTaskView,
handleChangeTaskView,
handleToggleTheme,
};
}