Merge pull request #728 from AssetOverflow/feat/wb-route-registry

feat(workbench): unify route truth into one registry (Wave M B3.5-a)
This commit is contained in:
Shay 2026-06-13 04:52:24 -07:00 committed by GitHub
commit 658e677e26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 480 additions and 96 deletions

View file

@ -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: <ChatRoute />,
trace: <TraceRoute />,
replay: <ReplayRoute />,
demos: <DemoTheaterRoute />,
proposals: <ProposalsRoute />,
runs: <RunsRoute />,
vault: <VaultRoute />,
audit: <AuditRoute />,
evals: <EvalsRoute />,
calibration: <CalibrationRoute />,
packs: <PacksRoute />,
settings: <SettingsRoute />,
};
export function App() {
return (
@ -24,18 +44,13 @@ export function App() {
<Routes>
<Route path="/" element={<Shell />}>
<Route index element={<Navigate to={`/${getWorkbenchPrefs().landingRoute}`} replace />} />
<Route path="chat" element={<ChatRoute />} />
<Route path="trace/:turnId?" element={<TraceRoute />} />
<Route path="replay/:turnId?" element={<ReplayRoute />} />
<Route path="demos/:demoId?" element={<DemoTheaterRoute />} />
<Route path="proposals/:proposalId?" element={<ProposalsRoute />} />
<Route path="evals/:laneId?" element={<EvalsRoute />} />
<Route path="runs/:sessionId?" element={<RunsRoute />} />
<Route path="packs/:packId?" element={<PacksRoute />} />
<Route path="vault" element={<VaultRoute />} />
<Route path="calibration" element={<CalibrationRoute />} />
<Route path="audit" element={<AuditRoute />} />
<Route path="settings" element={<SettingsRoute />} />
{WORKBENCH_ROUTES.map((route) => (
<Route
key={route.id}
path={route.routePattern}
element={ROUTE_ELEMENTS[route.id]}
/>
))}
</Route>
<Route path="/preview" element={<PreviewPage />} />
</Routes>

View file

@ -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) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
[
"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}
</NavLink>
{NAV_SECTIONS.map(({ section, routes }) => (
<div key={section} role="group" aria-label={section} className="flex flex-col gap-1">
<div className="px-3 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
{section}
</div>
{routes.map((route) => (
<NavLink
key={route.path}
to={route.path}
title={route.description}
className={({ isActive }) =>
[
"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}
</NavLink>
))}
</div>
))}
</nav>
);

View file

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

View file

@ -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 09", () => {
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(
<MemoryRouter>
<CommandPalette open onOpenChange={() => {}} />
</MemoryRouter>,
);
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(
<MemoryRouter>
<CommandPalette open onOpenChange={() => {}} />
</MemoryRouter>,
);
expect(screen.getByRole("button", { name: "Open Demos" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Open Calibration" }),
).toBeInTheDocument();
});
});

View file

@ -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 `<Route>` 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 `<Route path>` 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 (19, 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<string, string> = 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<RouteSection, WorkbenchRoute[]>(
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<string, ReactElement>;

View file

@ -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<string, string> = {
"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 10 pinned set.
const ROUTE_KEYS: Record<string, string> = ROUTE_DIGIT_MAP;
interface GlobalKeyboardOptions {
onTogglePalette: () => void;

View file

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

View file

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

View file

@ -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<string, string> = {
"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<string, string> = Object.fromEntries(
PALETTE_ROUTES.map((route) => [`nav-${route.id}`, route.path]),
);
interface DisplayItem {
id: string;