Files
fusion/packages/dashboard/app/components/LeftSidebarNav.tsx
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

562 lines
23 KiB
TypeScript

import "./LeftSidebarNav.css";
/*
FNXC:Navigation 2026-06-19-00:00:
When the leftSidebarNav experiment is active, this component owns the non-mobile primary navigation destinations that Header previously exposed through inline and overflow view controls. Mobile remains owned by MobileNavBar, so this sidebar keeps the desktop/tablet contract only.
*/
import { useCallback, useEffect, useMemo, useState, type ComponentType, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import {
Bot,
Brain,
ChevronLeft,
ChevronRight,
Clock,
FileText,
Gauge,
Lightbulb,
LayoutGrid,
List,
Mail,
MessageSquare,
Plus,
Search,
Settings,
Sparkles,
Target,
Workflow,
Zap,
type LucideProps,
} from "lucide-react";
import type { ProjectInfo, PluginDashboardViewEntry } from "../api";
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;
memoryView?: boolean;
devServerView?: boolean;
researchView?: boolean;
evalsView?: boolean;
ideationView?: boolean;
goalsView?: boolean;
}
interface SidebarNavEntry {
id: string;
label: string;
view?: TaskView;
isActive: boolean;
icon: ComponentType<LucideProps>;
testId: string;
badge?: number;
dot?: "pending" | "online";
onSelect: () => void;
}
/*
FNXC:Navigation 2026-06-20-00:00:
The experimental sidebar default is intentionally narrower than the original 256px layout so desktop/tablet navigation preserves more board content while keeping the existing resize clamps.
FNXC:Navigation 2026-06-21-00:00:
The minimum resizable width is lowered so users can recover board/content space without forcing full rail collapse. Keep the floor at the narrowest label-legible width; below this point users should switch to collapse/rail mode to preserve icons, badges, and labels.
*/
const LEFT_SIDEBAR_DEFAULT_WIDTH = 224;
const LEFT_SIDEBAR_MIN_WIDTH = 160;
const LEFT_SIDEBAR_MAX_WIDTH = 384;
const LEFT_SIDEBAR_WIDTH_STORAGE_KEY = "fusion:left-sidebar-width";
const LEFT_SIDEBAR_COLLAPSED_STORAGE_KEY = "fusion:left-sidebar-collapsed";
function clampSidebarWidth(width: number): number {
return Math.max(LEFT_SIDEBAR_MIN_WIDTH, Math.min(LEFT_SIDEBAR_MAX_WIDTH, width));
}
function readStoredSidebarWidth(): number {
if (typeof window === "undefined") return LEFT_SIDEBAR_DEFAULT_WIDTH;
const stored = window.localStorage.getItem(LEFT_SIDEBAR_WIDTH_STORAGE_KEY);
const parsed = stored ? Number(stored) : NaN;
return Number.isFinite(parsed) ? clampSidebarWidth(parsed) : LEFT_SIDEBAR_DEFAULT_WIDTH;
}
function readStoredCollapsed(): boolean {
if (typeof window === "undefined") return false;
return window.localStorage.getItem(LEFT_SIDEBAR_COLLAPSED_STORAGE_KEY) === "true";
}
function persistSidebarWidth(width: number): void {
try {
window.localStorage.setItem(LEFT_SIDEBAR_WIDTH_STORAGE_KEY, String(width));
} catch {
// Ignore storage errors.
}
}
function persistCollapsed(collapsed: boolean): void {
try {
window.localStorage.setItem(LEFT_SIDEBAR_COLLAPSED_STORAGE_KEY, String(collapsed));
} catch {
// Ignore storage errors.
}
}
export interface LeftSidebarNavProps {
view: TaskView;
onChangeView: (view: TaskView) => void;
onNewTask?: () => void;
onOpenSettings?: () => void;
todosEnabled?: boolean;
mailboxUnreadCount?: number;
mailboxPendingApprovalCount?: number;
chatHasUnreadResponse?: boolean;
/*
FNXC:Navigation 2026-07-05-00:00:
Planning Mode "awaiting input" no longer shows a top-of-board banner (its Resume button did not reliably
redirect). Instead this flag drives a yellow `status-dot--pending` dot on the Planning nav destination,
mirroring `chatHasUnreadResponse` exactly, so the click target is always the working `planning` nav item.
*/
planningNeedsInput?: boolean;
experimentalFeatures?: LeftSidebarExperimentalFeatures;
pluginDashboardViews?: PluginDashboardViewEntry[];
showAgentsTab?: boolean;
showSkillsTab?: boolean;
projects?: ProjectInfo[];
currentProject?: ProjectInfo | null;
onSelectProject?: (project: ProjectInfo) => void;
onViewAllProjects?: () => void;
footerVisible?: boolean;
}
function formatCount(count: number): string {
return count > 99 ? "99+" : String(count);
}
function getPluginEntryView(entry: PluginDashboardViewEntry): TaskView {
if (entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph") {
return "graph";
}
return buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
}
function isPluginEntryActive(view: TaskView, entry: PluginDashboardViewEntry): boolean {
const pluginTaskView = buildPluginTaskViewId(entry.pluginId, entry.view.viewId);
return view === pluginTaskView || (view === "graph" && entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph");
}
function sortPluginViews(entries: PluginDashboardViewEntry[]): PluginDashboardViewEntry[] {
return [...entries].sort((a, b) => (a.view.order ?? Number.MAX_SAFE_INTEGER) - (b.view.order ?? Number.MAX_SAFE_INTEGER));
}
/*
FNXC:Navigation 2026-06-20-00:00:
Experimental sidebar plugin labels must read as plain navigation nouns without an appended "view" suffix. The Compound Engineering plugin is intentionally shortened to "Compound" so its label fits the narrower sidebar.
*/
function getSidebarPluginLabel(entry: PluginDashboardViewEntry): string {
return entry.pluginId === "fusion-plugin-compound-engineering" ? "Compound Eng" : entry.view.label;
}
export function LeftSidebarNav({
view,
onChangeView,
onNewTask,
onOpenSettings,
mailboxUnreadCount = 0,
mailboxPendingApprovalCount = 0,
chatHasUnreadResponse = false,
planningNeedsInput = false,
experimentalFeatures,
pluginDashboardViews = [],
showAgentsTab = false,
showSkillsTab = false,
footerVisible = false,
}: LeftSidebarNavProps) {
const { t } = useTranslation("app");
const [sidebarWidth, setSidebarWidth] = useState(readStoredSidebarWidth);
const [isCollapsed, setIsCollapsed] = useState(readStoredCollapsed);
/*
FNXC:Navigation 2026-06-23-02:15:
Optimistic active highlight: when a nav item is clicked, paint the active color IMMEDIATELY instead of waiting for the (possibly lazy-loaded via Suspense) target view to mount and flip `isActive`. Without this the clicked row lingers on the hover/highlight color until the view swaps. `optimisticView` is set on click and cleared once the real `view` prop catches up.
*/
const [optimisticView, setOptimisticView] = useState<string | null>(null);
useEffect(() => {
setOptimisticView(null);
}, [view]);
const toggleCollapsed = useCallback(() => {
setIsCollapsed((current) => {
const next = !current;
persistCollapsed(next);
return next;
});
}, []);
const handleResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (isCollapsed) return;
event.preventDefault();
event.stopPropagation();
const resizeHandle = event.currentTarget;
if (typeof resizeHandle.setPointerCapture === "function") {
resizeHandle.setPointerCapture(event.pointerId);
}
const startX = event.clientX;
const startWidth = sidebarWidth;
let latestWidth = startWidth;
document.body.style.userSelect = "none";
const onPointerMove = (moveEvent: PointerEvent) => {
const nextWidth = clampSidebarWidth(startWidth + moveEvent.clientX - startX);
latestWidth = nextWidth;
setSidebarWidth(nextWidth);
};
const onPointerUp = (upEvent: PointerEvent) => {
if (typeof resizeHandle.releasePointerCapture === "function") {
resizeHandle.releasePointerCapture(upEvent.pointerId);
}
document.body.style.userSelect = "";
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
persistSidebarWidth(latestWidth);
};
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
}, [isCollapsed, sidebarWidth]);
const handleResizeKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
if (isCollapsed) return;
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const step = event.shiftKey ? 48 : 16;
const delta = event.key === "ArrowLeft" ? -step : step;
const nextWidth = clampSidebarWidth(sidebarWidth + delta);
setSidebarWidth(nextWidth);
persistSidebarWidth(nextWidth);
}, [isCollapsed, sidebarWidth]);
const newTaskLabel = t("nav.newTask", "New Task");
/*
FNXC:Navigation 2026-06-22-12:00:
All plugin dashboard views are flattened into a single sorted pool. Placement no longer splits the sidebar into primary/secondary sections; the sidebar is now ONE explicitly-ordered list (FN navigation reorder). The dependency-graph and compound-engineering plugin views are hoisted into fixed positions (graph after List, compound after Goals), so they must be excluded from the trailing "remaining plugin views" append to avoid duplication.
*/
const sortedPluginViews = useMemo(
() => sortPluginViews(pluginDashboardViews),
[pluginDashboardViews],
);
const mapPluginEntry = useCallback(
(entry: PluginDashboardViewEntry): SidebarNavEntry => {
const PluginIcon = getPluginDashboardViewNavIcon(entry);
const targetView = getPluginEntryView(entry);
return {
id: `plugin-${entry.pluginId}-${entry.view.viewId}`,
label: getSidebarPluginLabel(entry),
view: targetView,
isActive: isPluginEntryActive(view, entry),
icon: PluginIcon,
testId: `sidebar-nav-plugin-${entry.pluginId}-${entry.view.viewId}`,
onSelect: () => onChangeView(targetView),
};
},
[view, onChangeView],
);
const graphPluginEntry = sortedPluginViews.find(
(entry) => entry.pluginId === "fusion-plugin-dependency-graph" && entry.view.viewId === "graph",
);
const compoundPluginEntry = sortedPluginViews.find(
(entry) => entry.pluginId === "fusion-plugin-compound-engineering",
);
/*
FNXC:RoadmapsNavigation 2026-07-19-12:00:
The bundled registry now hosts the manifest-advertised roadmaps view. Keep it in the
normal plugin pool so roadmap-item previews have a live callback navigation destination.
*/
const remainingPluginViews = sortedPluginViews.filter(
(entry) => entry !== graphPluginEntry && entry !== compoundPluginEntry,
);
/*
FNXC:Navigation 2026-06-22-12:00:
Single explicit sidebar order (top to bottom): board, list, graph, agents, chat, mailbox, planning, missions, goals, compound, automation, import, workflows, insight, research, ideation, command-center, documents (Artifacts), skills, memory, evals, then any remaining plugin views in their sorted order.
Dev Server is intentionally absent: it moved to the right dock. Secrets and Todos remain omitted (they live in the right dock / mobile More-sheet / Header overflow).
Flag gates preserved verbatim from the prior layout: agents (showAgentsTab), goals (goalsView), insight (insights), research (researchView), ideation (ideationView), skills (showSkillsTab), memory (memoryView), evals (evalsView). graph and compound are skipped when their plugin view is absent.
*/
const navEntries: SidebarNavEntry[] = [
/*
FNXC:Navigation 2026-06-22-01:15:
Command Center is labeled "Dashboard" and sits at the very top of the sidebar. The board remains the default view on load (useViewState initial taskView is still "board").
*/
{
id: "command-center",
label: t("nav.commandCenter", getDashboardViewLabel("command-center")),
view: "command-center",
isActive: view === "command-center",
icon: Gauge,
testId: "sidebar-nav-command-center",
onSelect: () => onChangeView("command-center"),
},
{
id: "board",
label: t("nav.board", getDashboardViewLabel("board")),
view: "board",
isActive: view === "board",
icon: LayoutGrid,
testId: "sidebar-nav-board",
onSelect: () => onChangeView("board"),
},
{
id: "list",
label: t("nav.list", getDashboardViewLabel("list")),
view: "list",
isActive: view === "list",
icon: List,
testId: "sidebar-nav-list",
onSelect: () => onChangeView("list"),
},
...(graphPluginEntry ? [mapPluginEntry(graphPluginEntry)] : []),
/*
FNXC:Navigation 2026-06-23-01:30:
Planning and Missions sit directly below Graph and above Agents (moved up from after Memory) per user request, so the planning/mission destinations sit next to the structural Board/List/Graph group.
*/
{
id: "planning",
label: t("nav.planning", getDashboardViewLabel("planning")),
view: "planning",
isActive: view === "planning",
icon: Lightbulb,
testId: "sidebar-nav-planning",
// FNXC:Navigation 2026-07-05-00:00: mirrors the chat item's `dot` below — replaces the broken-Resume banner.
dot: planningNeedsInput && view !== "planning" ? "pending" : undefined,
onSelect: () => onChangeView("planning"),
},
{
id: "missions",
label: t("nav.missions", getDashboardViewLabel("missions")),
view: "missions",
isActive: view === "missions",
icon: Target,
testId: "sidebar-nav-missions",
onSelect: () => onChangeView("missions"),
},
...(showAgentsTab
? [
{
id: "agents",
label: t("nav.agents", getDashboardViewLabel("agents")),
view: "agents" as TaskView,
isActive: view === "agents",
icon: Bot,
testId: "sidebar-nav-agents",
onSelect: () => onChangeView("agents"),
},
]
: []),
{
id: "chat",
label: t("nav.chat", getDashboardViewLabel("chat")),
view: "chat",
isActive: view === "chat",
icon: MessageSquare,
testId: "sidebar-nav-chat",
dot: chatHasUnreadResponse && view !== "chat" ? "pending" : undefined,
onSelect: () => onChangeView("chat"),
},
{
id: "mailbox",
label: t("nav.mailbox", getDashboardViewLabel("mailbox")),
view: "mailbox",
isActive: view === "mailbox",
icon: Mail,
testId: "sidebar-nav-mailbox",
badge: mailboxUnreadCount > 0 ? mailboxUnreadCount : undefined,
dot: view !== "mailbox" && mailboxPendingApprovalCount > 0 ? "pending" : view !== "mailbox" && mailboxUnreadCount > 0 ? "online" : undefined,
onSelect: () => onChangeView("mailbox"),
},
/*
FNXC:Navigation 2026-06-22-00:50:
Skills and Memory sit directly after Mailbox (still flag-gated by showSkillsTab / memoryView).
*/
...(showSkillsTab
? [{ 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", getDashboardViewLabel("memory")), view: "memory" as TaskView, isActive: view === "memory", icon: Brain, testId: "sidebar-nav-memory", onSelect: () => onChangeView("memory") }]
: []),
{
id: "documents",
/*
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", getDashboardViewLabel("documents")),
view: "documents",
isActive: view === "documents",
icon: FileText,
testId: "sidebar-nav-documents",
onSelect: () => onChangeView("documents"),
},
...(experimentalFeatures?.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):
Workflows, Import Tasks, and Automations are left-sidebar destinations that load in the main content area (not modals). Import Tasks is the GitHub import view (labeled "Import Tasks", not "Import from GitHub"). Automations + Import Tasks sit directly ABOVE Compound Eng per user request.
*/
{
id: "automations",
label: t("nav.automations", getDashboardViewLabel("automations")),
view: "automations" as TaskView,
isActive: view === "automations",
icon: Clock,
testId: "sidebar-nav-automations",
onSelect: () => onChangeView("automations"),
},
{
id: "import-tasks",
label: t("nav.importTasks", getDashboardViewLabel("import-tasks")),
view: "import-tasks" as TaskView,
isActive: view === "import-tasks",
icon: GithubIcon,
testId: "sidebar-nav-import-tasks",
onSelect: () => onChangeView("import-tasks"),
},
...(compoundPluginEntry ? [mapPluginEntry(compoundPluginEntry)] : []),
{
id: "workflows",
label: t("nav.workflows", getDashboardViewLabel("workflows")),
view: "workflows" as TaskView,
isActive: view === "workflows",
icon: Workflow,
testId: "sidebar-nav-workflows",
onSelect: () => onChangeView("workflows"),
},
...(experimentalFeatures?.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", 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", 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", getDashboardViewLabel("evals")), view: "evals" as TaskView, isActive: view === "evals", icon: Target, testId: "sidebar-nav-evals", onSelect: () => onChangeView("evals") }]
: []),
...remainingPluginViews.map(mapPluginEntry),
];
const renderEntry = (entry: SidebarNavEntry) => {
const Icon = entry.icon;
// Active the moment it's clicked (optimistic), then the real `view` confirms it.
const isActive = entry.isActive || (optimisticView !== null && entry.view === optimisticView);
return (
<button
key={entry.id}
type="button"
className={`left-sidebar-nav__item${isActive ? " left-sidebar-nav__item--active" : ""}`}
aria-label={entry.label}
aria-current={isActive && entry.view ? "page" : undefined}
title={entry.label}
data-testid={entry.testId}
onClick={() => {
if (entry.view) setOptimisticView(entry.view);
entry.onSelect();
}}
>
<span className="left-sidebar-nav__icon-wrap">
<Icon size={16} />
{entry.dot ? <span className={`status-dot status-dot--${entry.dot} left-sidebar-nav__dot`} aria-hidden="true" /> : null}
</span>
<span className="left-sidebar-nav__label">{entry.label}</span>
{entry.badge ? <span className="btn-badge left-sidebar-nav__badge">{formatCount(entry.badge)}</span> : null}
</button>
);
};
return (
<aside
className={`left-sidebar-nav${isCollapsed ? " left-sidebar-nav--collapsed" : ""}${footerVisible ? " left-sidebar-nav--with-footer" : ""}`}
data-testid="left-sidebar-nav"
aria-label={t("nav.sidebarAriaLabel", "Sidebar navigation")}
style={isCollapsed ? undefined : { width: sidebarWidth, minWidth: sidebarWidth }}
>
<nav className="left-sidebar-nav__list" aria-label={t("nav.primaryNavAriaLabel", "Primary navigation")}>
<div className="left-sidebar-nav__section">{navEntries.map(renderEntry)}</div>
</nav>
<div className="left-sidebar-nav__footer">
{/*
FNXC:Navigation 2026-06-23-02:30:
New Task now lives in the footer, directly ABOVE Collapse (and Settings), per user request — the primary create action sits with the other persistent footer affordances instead of at the top of the rail.
*/}
{onNewTask ? (
<button
type="button"
className="btn left-sidebar-nav__item left-sidebar-nav__new-task"
aria-label={newTaskLabel}
title={newTaskLabel}
data-testid="sidebar-nav-new-task"
onClick={onNewTask}
>
<Plus size={16} />
<span className="left-sidebar-nav__label">{newTaskLabel}</span>
</button>
) : null}
{/*
FNXC:Navigation 2026-06-21-00:00:
The sidebar collapse affordance belongs in the footer immediately above Settings, using the same row-item visual language. Expanded mode shows the Collapse label, while rail mode relies on the shared label-hiding rule so the button remains icon-only like Settings.
*/}
<button
type="button"
className="btn left-sidebar-nav__item left-sidebar-nav__collapse-toggle"
aria-label={isCollapsed ? t("nav.expandSidebar", "Expand sidebar") : t("nav.collapseSidebar", "Collapse sidebar")}
title={isCollapsed ? t("nav.expandSidebar", "Expand sidebar") : t("nav.collapseSidebar", "Collapse sidebar")}
aria-pressed={isCollapsed}
data-testid="sidebar-nav-collapse-toggle"
onClick={toggleCollapsed}
>
{isCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
<span className="left-sidebar-nav__label">{t("nav.collapse", "Collapse")}</span>
</button>
<button
type="button"
className="btn left-sidebar-nav__item left-sidebar-nav__settings"
aria-label={t("header.settings", getDashboardViewLabel("settings"))}
title={t("header.settings", getDashboardViewLabel("settings"))}
data-testid="sidebar-nav-settings"
/* FNXC:Navigation 2026-06-22-12:00: Wrap so React's MouseEvent is not forwarded as onOpenSettings' settingsInitialSection arg. */
onClick={() => onOpenSettings?.()}
>
<Settings size={16} />
<span className="left-sidebar-nav__label">{t("header.settings", getDashboardViewLabel("settings"))}</span>
</button>
</div>
{!isCollapsed && (
<div
className="left-sidebar-nav__resize-handle"
role="separator"
aria-orientation="vertical"
aria-valuemin={LEFT_SIDEBAR_MIN_WIDTH}
aria-valuemax={LEFT_SIDEBAR_MAX_WIDTH}
aria-valuenow={sidebarWidth}
aria-label={t("nav.resizeSidebar", "Resize sidebar")}
tabIndex={0}
data-testid="sidebar-nav-resize-handle"
onPointerDown={handleResizeStart}
onKeyDown={handleResizeKeyDown}
/>
)}
</aside>
);
}