diff --git a/workbench-ui/src/app/App.tsx b/workbench-ui/src/app/App.tsx index 77ef23cf..fc055acc 100644 --- a/workbench-ui/src/app/App.tsx +++ b/workbench-ui/src/app/App.tsx @@ -16,6 +16,26 @@ import { VaultRoute } from "./vault/VaultRoute"; import { CalibrationRoute } from "./calibration/CalibrationRoute"; import { SettingsRoute } from "./settings/SettingsRoute"; import { getWorkbenchPrefs } from "./workbenchPrefs"; +import { WORKBENCH_ROUTES, type RouteElementMap } from "./routes"; + +// The one place route id → element is bound (App owns the route-component +// imports). Every WORKBENCH_ROUTES id must have an entry here; routes.test +// asserts it, so a registry route without an element fails the suite instead +// of rendering `undefined`. +export const ROUTE_ELEMENTS: RouteElementMap = { + chat: , + trace: , + replay: , + demos: , + proposals: , + runs: , + vault: , + audit: , + evals: , + calibration: , + packs: , + settings: , +}; export function App() { return ( @@ -24,18 +44,13 @@ export function App() { }> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {WORKBENCH_ROUTES.map((route) => ( + + ))} } /> diff --git a/workbench-ui/src/app/LeftNav.tsx b/workbench-ui/src/app/LeftNav.tsx index 3baa8c94..c27ce656 100644 --- a/workbench-ui/src/app/LeftNav.tsx +++ b/workbench-ui/src/app/LeftNav.tsx @@ -1,19 +1,11 @@ import { NavLink } from "react-router-dom"; +import { leftNavSections } from "./routes"; -const NAV_ITEMS = [ - { label: "Chat", to: "/chat" }, - { label: "Trace", to: "/trace" }, - { label: "Replay", to: "/replay" }, - { label: "Demos", to: "/demos" }, - { label: "Proposals", to: "/proposals" }, - { label: "Evals", to: "/evals" }, - { label: "Calibration", to: "/calibration" }, - { label: "Runs", to: "/runs" }, - { label: "Packs", to: "/packs" }, - { label: "Vault", to: "/vault" }, - { label: "Audit", to: "/audit" }, - { label: "Settings", to: "/settings" }, -] as const; +// Routes derive from the single registry (routes.ts), grouped by wayfinding +// section. Adding a route in one place only is no longer possible — LeftNav, +// the command palette, ⌘-digits, and the landing dropdown all read the same +// list. +const NAV_SECTIONS = leftNavSections(); export function LeftNav() { return ( @@ -22,21 +14,29 @@ export function LeftNav() { className="flex h-full flex-col gap-1 overflow-y-auto border-r border-[var(--color-border-subtle)] bg-[var(--color-surface-base)] p-2" aria-label="Main navigation" > - {NAV_ITEMS.map((item) => ( - - [ - "block rounded px-3 py-2 text-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]", - isActive - ? "border-l-2 border-[var(--color-focus-ring)] pl-[10px] text-[var(--color-text-primary)] bg-[var(--color-surface-raised)]" - : "border-l-2 border-transparent pl-[10px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-raised)]", - ].join(" ") - } - > - {item.label} - + {NAV_SECTIONS.map(({ section, routes }) => ( +
+
+ {section} +
+ {routes.map((route) => ( + + [ + "block rounded px-3 py-2 text-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]", + isActive + ? "border-l-2 border-[var(--color-focus-ring)] pl-[10px] text-[var(--color-text-primary)] bg-[var(--color-surface-raised)]" + : "border-l-2 border-transparent pl-[10px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-raised)]", + ].join(" ") + } + > + {route.label} + + ))} +
))} ); diff --git a/workbench-ui/src/app/Shell.test.tsx b/workbench-ui/src/app/Shell.test.tsx index 8b29718e..4b14a7a5 100644 --- a/workbench-ui/src/app/Shell.test.tsx +++ b/workbench-ui/src/app/Shell.test.tsx @@ -76,24 +76,26 @@ describe("Shell", () => { expect(document.querySelector('[data-region="statusfooter"]')).toBeInTheDocument(); }); - it("LeftNav has exactly 12 items in order", () => { + it("LeftNav has exactly 12 items in section-grouped order", () => { renderShell(); const nav = document.querySelector('[data-region="leftnav"]')!; const links = nav.querySelectorAll("a"); expect(links).toHaveLength(12); const labels = Array.from(links).map((l) => l.textContent); + // Grouped by section (Converse → Cognition → Determinism → Evidence → + // Discipline → Substrate → Settings), derived from the route registry. expect(labels).toEqual([ "Chat", "Trace", "Replay", "Demos", "Proposals", - "Evals", - "Calibration", "Runs", - "Packs", "Vault", "Audit", + "Evals", + "Calibration", + "Packs", "Settings", ]); }); diff --git a/workbench-ui/src/app/routes.test.tsx b/workbench-ui/src/app/routes.test.tsx new file mode 100644 index 00000000..2a282bc5 --- /dev/null +++ b/workbench-ui/src/app/routes.test.tsx @@ -0,0 +1,113 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { MemoryRouter } from "react-router-dom"; +import { CommandPalette } from "../design/components/primitives/CommandPalette"; +import { ROUTE_ELEMENTS } from "./App"; +import { + WORKBENCH_ROUTES, + PALETTE_ROUTES, + LANDING_ROUTE_IDS, + ROUTE_DIGIT_MAP, + ROUTE_SECTIONS, + leftNavSections, +} from "./routes"; + +/** + * B3.5-a — the route registry is the single source of truth. These guards + * fail loudly if a route is added to App without the registry, if a registry + * route loses an element, or if a navigable route falls out of the command + * palette (the prior Demos/Calibration drift). + */ + +describe("route registry ↔ App element map", () => { + it("every route has exactly one element, and no element is orphaned", () => { + const routeIds = WORKBENCH_ROUTES.map((r) => r.id).sort(); + const elementIds = Object.keys(ROUTE_ELEMENTS).sort(); + expect(elementIds).toEqual(routeIds); + }); + + it("no route element is undefined (a registry route without an element)", () => { + for (const route of WORKBENCH_ROUTES) { + expect(ROUTE_ELEMENTS[route.id]).toBeDefined(); + } + }); + + it("route ids and paths are unique", () => { + const ids = WORKBENCH_ROUTES.map((r) => r.id); + const paths = WORKBENCH_ROUTES.map((r) => r.path); + expect(new Set(ids).size).toBe(ids.length); + expect(new Set(paths).size).toBe(paths.length); + }); +}); + +describe("keyboard-digit assignment is honest", () => { + it("digits are unique and within 0–9", () => { + const digits = WORKBENCH_ROUTES.map((r) => r.keyboardDigit).filter( + (d): d is string => d !== null, + ); + expect(new Set(digits).size).toBe(digits.length); + for (const d of digits) expect(d).toMatch(/^[0-9]$/); + }); + + it("ROUTE_DIGIT_MAP maps each pinned digit to its route path", () => { + for (const route of WORKBENCH_ROUTES) { + if (route.keyboardDigit === null) continue; + expect(ROUTE_DIGIT_MAP[route.keyboardDigit]).toBe(route.path); + } + const pinned = WORKBENCH_ROUTES.filter((r) => r.keyboardDigit !== null); + expect(Object.keys(ROUTE_DIGIT_MAP)).toHaveLength(pinned.length); + }); + + it("more routes than digits — at least one route is palette-only (honest model)", () => { + const paletteOnly = WORKBENCH_ROUTES.filter((r) => r.keyboardDigit === null); + expect(paletteOnly.length).toBeGreaterThan(0); + }); +}); + +describe("landing routes derive from the registry", () => { + it("includes Replay and Calibration (the prior drift)", () => { + expect(LANDING_ROUTE_IDS).toContain("replay"); + expect(LANDING_ROUTE_IDS).toContain("calibration"); + }); +}); + +describe("leftNavSections", () => { + it("covers every LeftNav route exactly once, in section order", () => { + const flattened = leftNavSections().flatMap((g) => g.routes.map((r) => r.id)); + const expected = WORKBENCH_ROUTES.filter((r) => r.leftNavVisible).map((r) => r.id); + expect(flattened).toEqual(expected); + }); + + it("only emits known sections", () => { + for (const group of leftNavSections()) { + expect(ROUTE_SECTIONS).toContain(group.section); + } + }); +}); + +describe("command palette reachability", () => { + it("every palette-visible route is reachable as a Navigate command", () => { + render( + + {}} /> + , + ); + for (const route of PALETTE_ROUTES) { + expect( + screen.getByRole("button", { name: `Open ${route.label}` }), + ).toBeInTheDocument(); + } + }); + + it("Demos and Calibration are present (the regression they fell out of)", () => { + render( + + {}} /> + , + ); + expect(screen.getByRole("button", { name: "Open Demos" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Open Calibration" }), + ).toBeInTheDocument(); + }); +}); diff --git a/workbench-ui/src/app/routes.ts b/workbench-ui/src/app/routes.ts new file mode 100644 index 00000000..1213e14d --- /dev/null +++ b/workbench-ui/src/app/routes.ts @@ -0,0 +1,272 @@ +import type { ReactElement } from "react"; + +/** + * Single source of truth for workbench navigation routes (Wave M B3.5-a). + * + * Every route list in the app derives from {@link WORKBENCH_ROUTES}: + * - App `` declarations (via {@link ROUTE_ELEMENTS}) + * - LeftNav (grouped by section) + * - the command palette Navigate section + * - ⌘-digit navigation shortcuts + * - the landing-route preference dropdown + * - route-conformance fixtures + * + * Adding a route in one place only was the prior failure mode: Demos and + * Calibration shipped to LeftNav but never reached the command palette, and + * Replay/Calibration were absent from the landing dropdown. With one list, + * that drift is structurally impossible. + * + * Wayfinding `section`s group the flat route set by the organism's loop + * (Converse → Cognition → Determinism → Evidence → Discipline → Substrate → + * Settings). This is a display skin only — one workbench, one address space, + * one Evidence Chain Rail; never a split into separate apps. + */ + +export const ROUTE_SECTIONS = [ + "Converse", + "Cognition", + "Determinism", + "Evidence", + "Discipline", + "Substrate", + "Settings", +] as const; + +export type RouteSection = (typeof ROUTE_SECTIONS)[number]; + +export interface WorkbenchRoute { + /** Stable id; React key and digit-shortcut anchor. */ + id: string; + /** Navigation target with no params, e.g. "/trace". */ + path: string; + /** App `` pattern; may carry optional params, e.g. "trace/:turnId?". */ + routePattern: string; + /** LeftNav / palette label. */ + label: string; + /** One line: what this route is for. */ + description: string; + /** Wayfinding group. */ + section: RouteSection; + /** Rendered in LeftNav. */ + leftNavVisible: boolean; + /** Listed as a Navigate command in the palette. */ + commandPaletteVisible: boolean; + /** Eligible as the landing route. */ + landingRouteAllowed: boolean; + /** + * Single-digit ⌘ shortcut, or `null` when the route is palette-only. + * There are more routes than digits (1–9, 0) — the honest model pins the + * first ten and leaves the rest searchable. KeyboardHelp says so; the + * palette shows no chord for palette-only routes. + */ + keyboardDigit: string | null; + /** Must pass ADR-0162 §6 route conformance (loading/error/empty). */ + routeConformanceRequired: boolean; +} + +// Array order is the LeftNav display order (already grouped by section). +export const WORKBENCH_ROUTES: readonly WorkbenchRoute[] = [ + { + id: "chat", + path: "/chat", + routePattern: "chat", + label: "Chat", + description: "Ask CORE a question and create turn evidence.", + section: "Converse", + leftNavVisible: true, + commandPaletteVisible: true, + landingRouteAllowed: true, + keyboardDigit: "1", + routeConformanceRequired: true, + }, + { + id: "trace", + path: "/trace", + routePattern: "trace/:turnId?", + label: "Trace", + description: "Inspect the cognitive turn pipeline for a turn.", + section: "Cognition", + leftNavVisible: true, + commandPaletteVisible: true, + landingRouteAllowed: true, + keyboardDigit: "2", + routeConformanceRequired: true, + }, + { + id: "replay", + path: "/replay", + routePattern: "replay/:turnId?", + label: "Replay", + description: "Re-run a turn and compare trace hashes.", + section: "Determinism", + leftNavVisible: true, + commandPaletteVisible: true, + landingRouteAllowed: true, + keyboardDigit: "3", + routeConformanceRequired: true, + }, + { + id: "demos", + path: "/demos", + routePattern: "demos/:demoId?", + label: "Demos", + description: "Run a registered determinism demo end to end.", + section: "Determinism", + leftNavVisible: true, + commandPaletteVisible: true, + landingRouteAllowed: true, + keyboardDigit: null, + routeConformanceRequired: true, + }, + { + id: "proposals", + path: "/proposals", + routePattern: "proposals/:proposalId?", + label: "Proposals", + description: "Review the teaching proposal queue and HITL ratification.", + section: "Evidence", + leftNavVisible: true, + commandPaletteVisible: true, + landingRouteAllowed: true, + keyboardDigit: "4", + routeConformanceRequired: true, + }, + { + id: "runs", + path: "/runs", + routePattern: "runs/:sessionId?", + label: "Runs", + description: "Browse recorded session runs.", + section: "Evidence", + leftNavVisible: true, + commandPaletteVisible: true, + landingRouteAllowed: true, + keyboardDigit: "6", + routeConformanceRequired: true, + }, + { + id: "vault", + path: "/vault", + routePattern: "vault", + label: "Vault", + description: "Inspect persisted session memory.", + section: "Evidence", + leftNavVisible: true, + commandPaletteVisible: true, + landingRouteAllowed: true, + keyboardDigit: "8", + routeConformanceRequired: true, + }, + { + id: "audit", + path: "/audit", + routePattern: "audit", + label: "Audit", + description: "Read the deterministic audit event log.", + section: "Evidence", + leftNavVisible: true, + commandPaletteVisible: true, + landingRouteAllowed: true, + keyboardDigit: "9", + routeConformanceRequired: true, + }, + { + id: "evals", + path: "/evals", + routePattern: "evals/:laneId?", + label: "Evals", + description: "Run eval lanes and read the wrong=0 ledger.", + section: "Discipline", + leftNavVisible: true, + commandPaletteVisible: true, + landingRouteAllowed: true, + keyboardDigit: "5", + routeConformanceRequired: true, + }, + { + id: "calibration", + path: "/calibration", + routePattern: "calibration", + label: "Calibration", + description: "See the gold-tether arena earn the right to guess.", + section: "Discipline", + leftNavVisible: true, + commandPaletteVisible: true, + landingRouteAllowed: true, + keyboardDigit: null, + routeConformanceRequired: true, + }, + { + id: "packs", + path: "/packs", + routePattern: "packs/:packId?", + label: "Packs", + description: "Browse language/identity packs (CORE-Logos studio).", + section: "Substrate", + leftNavVisible: true, + commandPaletteVisible: true, + landingRouteAllowed: true, + keyboardDigit: "7", + routeConformanceRequired: true, + }, + { + id: "settings", + path: "/settings", + routePattern: "settings", + label: "Settings", + description: "Local workbench preferences (read-only to the engine).", + section: "Settings", + leftNavVisible: true, + commandPaletteVisible: true, + landingRouteAllowed: true, + keyboardDigit: "0", + routeConformanceRequired: true, + }, +]; + +/** Routes shown in LeftNav, in display order. */ +export const LEFT_NAV_ROUTES = WORKBENCH_ROUTES.filter((r) => r.leftNavVisible); + +/** Routes listed in the command palette Navigate section, in display order. */ +export const PALETTE_ROUTES = WORKBENCH_ROUTES.filter( + (r) => r.commandPaletteVisible, +); + +/** Route ids eligible as the workbench landing route. */ +export const LANDING_ROUTE_IDS = WORKBENCH_ROUTES.filter( + (r) => r.landingRouteAllowed, +).map((r) => r.id); + +/** ⌘-digit → navigation path, for the ten pinned routes. */ +export const ROUTE_DIGIT_MAP: Record = Object.fromEntries( + WORKBENCH_ROUTES.filter((r) => r.keyboardDigit !== null).map((r) => [ + r.keyboardDigit as string, + r.path, + ]), +); + +/** LeftNav routes grouped by section, in section then route order. */ +export function leftNavSections(): { + section: RouteSection; + routes: WorkbenchRoute[]; +}[] { + const groups = new Map( + ROUTE_SECTIONS.map((s) => [s, []]), + ); + for (const route of LEFT_NAV_ROUTES) { + groups.get(route.section)!.push(route); + } + return ROUTE_SECTIONS.map((section) => ({ + section, + routes: groups.get(section)!, + })).filter((group) => group.routes.length > 0); +} + +/** + * The element registry consumed by `App`. Keyed by route id so that adding a + * route to {@link WORKBENCH_ROUTES} without giving it an element is caught by + * `routes.test.tsx` rather than rendering `undefined`. Populated in `App.tsx` + * (it owns the route-component imports); declared here so the contract — every + * route id has an element — lives next to the route list. + */ +export type RouteElementMap = Record; diff --git a/workbench-ui/src/app/useGlobalKeyboard.ts b/workbench-ui/src/app/useGlobalKeyboard.ts index a2ee455b..f4824eed 100644 --- a/workbench-ui/src/app/useGlobalKeyboard.ts +++ b/workbench-ui/src/app/useGlobalKeyboard.ts @@ -1,19 +1,13 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { useRegisterShortcuts, type ShortcutEntry } from "./shortcutRegistry"; +import { ROUTE_DIGIT_MAP } from "./routes"; -const ROUTE_KEYS: Record = { - "1": "/chat", - "2": "/trace", - "3": "/replay", - "4": "/proposals", - "5": "/evals", - "6": "/runs", - "7": "/packs", - "8": "/vault", - "9": "/audit", - "0": "/settings", -}; +// digit → path for the ten pinned routes; derived from the single route +// registry (routes.ts). Routes without a pinned digit (Demos, Calibration) +// are reachable via the command palette, not a chord — and KeyboardHelp's +// "global-routes" row stays honest about the 1–0 pinned set. +const ROUTE_KEYS: Record = ROUTE_DIGIT_MAP; interface GlobalKeyboardOptions { onTogglePalette: () => void; diff --git a/workbench-ui/src/app/workbenchPrefs.ts b/workbench-ui/src/app/workbenchPrefs.ts index 62f3ce8a..6b28d728 100644 --- a/workbench-ui/src/app/workbenchPrefs.ts +++ b/workbench-ui/src/app/workbenchPrefs.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from "react"; +import { LANDING_ROUTE_IDS } from "./routes"; // Local, single-operator workbench preferences (ADR-0160: local-only, no // cloud, no accounts). Persisted in localStorage; every pref here is read @@ -7,20 +8,12 @@ import { useCallback, useEffect, useState } from "react"; const PREFS_KEY = "core-workbench-prefs"; const PREFS_EVENT = "core-workbench-prefs-change"; -export const LANDING_ROUTES = [ - "chat", - "trace", - "demos", - "proposals", - "evals", - "runs", - "packs", - "vault", - "audit", - "settings", -] as const; +// Landing-eligible routes derive from the single route registry (routes.ts), +// so the Settings dropdown can never drift from the real route set. The prior +// hand-maintained tuple was missing Replay and Calibration. +export const LANDING_ROUTES: readonly string[] = LANDING_ROUTE_IDS; -export type LandingRoute = (typeof LANDING_ROUTES)[number]; +export type LandingRoute = string; export interface WorkbenchPrefs { /** Route the workbench opens to (consumed by the App index redirect). */ @@ -35,7 +28,7 @@ export const DEFAULT_PREFS: WorkbenchPrefs = { }; function isLandingRoute(value: unknown): value is LandingRoute { - return typeof value === "string" && (LANDING_ROUTES as readonly string[]).includes(value); + return typeof value === "string" && LANDING_ROUTES.includes(value); } export function getWorkbenchPrefs(): WorkbenchPrefs { diff --git a/workbench-ui/src/design/components/primitives/CommandPalette.keyboard.test.tsx b/workbench-ui/src/design/components/primitives/CommandPalette.keyboard.test.tsx index 21a26601..85091120 100644 --- a/workbench-ui/src/design/components/primitives/CommandPalette.keyboard.test.tsx +++ b/workbench-ui/src/design/components/primitives/CommandPalette.keyboard.test.tsx @@ -52,8 +52,11 @@ describe("CommandPalette keyboard contract", () => { expect(screen.getByRole("button", { name: "Open Trace" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Open Replay" })).toBeInTheDocument(); + // One Navigate command per palette-visible route (12), derived from the + // route registry — Demos and Calibration are now included (the prior + // hand-maintained list of 10 dropped them). const items = dialog.querySelectorAll('[role="option"]'); - expect(items.length).toBe(10); + expect(items.length).toBe(12); const lastIndex = items.length - 1; // Initially first item (index 0) is focused — check aria-selected diff --git a/workbench-ui/src/design/components/primitives/CommandPalette.tsx b/workbench-ui/src/design/components/primitives/CommandPalette.tsx index 459190f3..488f17d3 100644 --- a/workbench-ui/src/design/components/primitives/CommandPalette.tsx +++ b/workbench-ui/src/design/components/primitives/CommandPalette.tsx @@ -10,32 +10,24 @@ import { type Command, type RecentItem, } from "../../../app/commandRegistry"; +import { PALETTE_ROUTES } from "../../../app/routes"; -const NAV_COMMANDS: Command[] = [ - { id: "nav-chat", label: "Open Chat", section: "Navigate", kind: "navigate", shortcut: "⌘1", action: () => {} }, - { id: "nav-trace", label: "Open Trace", section: "Navigate", kind: "navigate", shortcut: "⌘2", action: () => {} }, - { id: "nav-replay", label: "Open Replay", section: "Navigate", kind: "navigate", shortcut: "⌘3", action: () => {} }, - { id: "nav-proposals", label: "Open Proposals", section: "Navigate", kind: "navigate", shortcut: "⌘4", action: () => {} }, - { id: "nav-evals", label: "Open Evals", section: "Navigate", kind: "navigate", shortcut: "⌘5", action: () => {} }, - { id: "nav-runs", label: "Open Runs", section: "Navigate", kind: "navigate", shortcut: "⌘6", action: () => {} }, - { id: "nav-packs", label: "Open Packs", section: "Navigate", kind: "navigate", shortcut: "⌘7", action: () => {} }, - { id: "nav-vault", label: "Open Vault", section: "Navigate", kind: "navigate", shortcut: "⌘8", action: () => {} }, - { id: "nav-audit", label: "Open Audit", section: "Navigate", kind: "navigate", shortcut: "⌘9", action: () => {} }, - { id: "nav-settings", label: "Open Settings", section: "Navigate", kind: "navigate", shortcut: "⌘0", action: () => {} }, -]; +// Navigate commands derive from the single route registry (routes.ts), so +// every palette-visible route is searchable here. The prior hand-maintained +// list dropped Demos and Calibration; deriving makes that drift impossible. +// Palette-only routes (no pinned ⌘-digit) appear with no chord — honest. +const NAV_COMMANDS: Command[] = PALETTE_ROUTES.map((route) => ({ + id: `nav-${route.id}`, + label: `Open ${route.label}`, + section: "Navigate", + kind: "navigate", + shortcut: route.keyboardDigit ? `⌘${route.keyboardDigit}` : undefined, + action: () => {}, +})); -const NAV_PATHS: Record = { - "nav-chat": "/chat", - "nav-trace": "/trace", - "nav-replay": "/replay", - "nav-proposals": "/proposals", - "nav-evals": "/evals", - "nav-runs": "/runs", - "nav-packs": "/packs", - "nav-vault": "/vault", - "nav-audit": "/audit", - "nav-settings": "/settings", -}; +const NAV_PATHS: Record = Object.fromEntries( + PALETTE_ROUTES.map((route) => [`nav-${route.id}`, route.path]), +); interface DisplayItem { id: string;