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:
commit
658e677e26
9 changed files with 480 additions and 96 deletions
|
|
@ -16,6 +16,26 @@ import { VaultRoute } from "./vault/VaultRoute";
|
||||||
import { CalibrationRoute } from "./calibration/CalibrationRoute";
|
import { CalibrationRoute } from "./calibration/CalibrationRoute";
|
||||||
import { SettingsRoute } from "./settings/SettingsRoute";
|
import { SettingsRoute } from "./settings/SettingsRoute";
|
||||||
import { getWorkbenchPrefs } from "./workbenchPrefs";
|
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() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -24,18 +44,13 @@ export function App() {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Shell />}>
|
<Route path="/" element={<Shell />}>
|
||||||
<Route index element={<Navigate to={`/${getWorkbenchPrefs().landingRoute}`} replace />} />
|
<Route index element={<Navigate to={`/${getWorkbenchPrefs().landingRoute}`} replace />} />
|
||||||
<Route path="chat" element={<ChatRoute />} />
|
{WORKBENCH_ROUTES.map((route) => (
|
||||||
<Route path="trace/:turnId?" element={<TraceRoute />} />
|
<Route
|
||||||
<Route path="replay/:turnId?" element={<ReplayRoute />} />
|
key={route.id}
|
||||||
<Route path="demos/:demoId?" element={<DemoTheaterRoute />} />
|
path={route.routePattern}
|
||||||
<Route path="proposals/:proposalId?" element={<ProposalsRoute />} />
|
element={ROUTE_ELEMENTS[route.id]}
|
||||||
<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 />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/preview" element={<PreviewPage />} />
|
<Route path="/preview" element={<PreviewPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,11 @@
|
||||||
import { NavLink } from "react-router-dom";
|
import { NavLink } from "react-router-dom";
|
||||||
|
import { leftNavSections } from "./routes";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
// Routes derive from the single registry (routes.ts), grouped by wayfinding
|
||||||
{ label: "Chat", to: "/chat" },
|
// section. Adding a route in one place only is no longer possible — LeftNav,
|
||||||
{ label: "Trace", to: "/trace" },
|
// the command palette, ⌘-digits, and the landing dropdown all read the same
|
||||||
{ label: "Replay", to: "/replay" },
|
// list.
|
||||||
{ label: "Demos", to: "/demos" },
|
const NAV_SECTIONS = leftNavSections();
|
||||||
{ 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;
|
|
||||||
|
|
||||||
export function LeftNav() {
|
export function LeftNav() {
|
||||||
return (
|
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"
|
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"
|
aria-label="Main navigation"
|
||||||
>
|
>
|
||||||
{NAV_ITEMS.map((item) => (
|
{NAV_SECTIONS.map(({ section, routes }) => (
|
||||||
<NavLink
|
<div key={section} role="group" aria-label={section} className="flex flex-col gap-1">
|
||||||
key={item.to}
|
<div className="px-3 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
to={item.to}
|
{section}
|
||||||
className={({ isActive }) =>
|
</div>
|
||||||
[
|
{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)]",
|
<NavLink
|
||||||
isActive
|
key={route.path}
|
||||||
? "border-l-2 border-[var(--color-focus-ring)] pl-[10px] text-[var(--color-text-primary)] bg-[var(--color-surface-raised)]"
|
to={route.path}
|
||||||
: "border-l-2 border-transparent pl-[10px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-raised)]",
|
title={route.description}
|
||||||
].join(" ")
|
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)]",
|
||||||
{item.label}
|
isActive
|
||||||
</NavLink>
|
? "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>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -76,24 +76,26 @@ describe("Shell", () => {
|
||||||
expect(document.querySelector('[data-region="statusfooter"]')).toBeInTheDocument();
|
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();
|
renderShell();
|
||||||
const nav = document.querySelector('[data-region="leftnav"]')!;
|
const nav = document.querySelector('[data-region="leftnav"]')!;
|
||||||
const links = nav.querySelectorAll("a");
|
const links = nav.querySelectorAll("a");
|
||||||
expect(links).toHaveLength(12);
|
expect(links).toHaveLength(12);
|
||||||
const labels = Array.from(links).map((l) => l.textContent);
|
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([
|
expect(labels).toEqual([
|
||||||
"Chat",
|
"Chat",
|
||||||
"Trace",
|
"Trace",
|
||||||
"Replay",
|
"Replay",
|
||||||
"Demos",
|
"Demos",
|
||||||
"Proposals",
|
"Proposals",
|
||||||
"Evals",
|
|
||||||
"Calibration",
|
|
||||||
"Runs",
|
"Runs",
|
||||||
"Packs",
|
|
||||||
"Vault",
|
"Vault",
|
||||||
"Audit",
|
"Audit",
|
||||||
|
"Evals",
|
||||||
|
"Calibration",
|
||||||
|
"Packs",
|
||||||
"Settings",
|
"Settings",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
113
workbench-ui/src/app/routes.test.tsx
Normal file
113
workbench-ui/src/app/routes.test.tsx
Normal 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 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(
|
||||||
|
<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();
|
||||||
|
});
|
||||||
|
});
|
||||||
272
workbench-ui/src/app/routes.ts
Normal file
272
workbench-ui/src/app/routes.ts
Normal 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 (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<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>;
|
||||||
|
|
@ -1,19 +1,13 @@
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useRegisterShortcuts, type ShortcutEntry } from "./shortcutRegistry";
|
import { useRegisterShortcuts, type ShortcutEntry } from "./shortcutRegistry";
|
||||||
|
import { ROUTE_DIGIT_MAP } from "./routes";
|
||||||
|
|
||||||
const ROUTE_KEYS: Record<string, string> = {
|
// digit → path for the ten pinned routes; derived from the single route
|
||||||
"1": "/chat",
|
// registry (routes.ts). Routes without a pinned digit (Demos, Calibration)
|
||||||
"2": "/trace",
|
// are reachable via the command palette, not a chord — and KeyboardHelp's
|
||||||
"3": "/replay",
|
// "global-routes" row stays honest about the 1–0 pinned set.
|
||||||
"4": "/proposals",
|
const ROUTE_KEYS: Record<string, string> = ROUTE_DIGIT_MAP;
|
||||||
"5": "/evals",
|
|
||||||
"6": "/runs",
|
|
||||||
"7": "/packs",
|
|
||||||
"8": "/vault",
|
|
||||||
"9": "/audit",
|
|
||||||
"0": "/settings",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface GlobalKeyboardOptions {
|
interface GlobalKeyboardOptions {
|
||||||
onTogglePalette: () => void;
|
onTogglePalette: () => void;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { LANDING_ROUTE_IDS } from "./routes";
|
||||||
|
|
||||||
// Local, single-operator workbench preferences (ADR-0160: local-only, no
|
// Local, single-operator workbench preferences (ADR-0160: local-only, no
|
||||||
// cloud, no accounts). Persisted in localStorage; every pref here is read
|
// 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_KEY = "core-workbench-prefs";
|
||||||
const PREFS_EVENT = "core-workbench-prefs-change";
|
const PREFS_EVENT = "core-workbench-prefs-change";
|
||||||
|
|
||||||
export const LANDING_ROUTES = [
|
// Landing-eligible routes derive from the single route registry (routes.ts),
|
||||||
"chat",
|
// so the Settings dropdown can never drift from the real route set. The prior
|
||||||
"trace",
|
// hand-maintained tuple was missing Replay and Calibration.
|
||||||
"demos",
|
export const LANDING_ROUTES: readonly string[] = LANDING_ROUTE_IDS;
|
||||||
"proposals",
|
|
||||||
"evals",
|
|
||||||
"runs",
|
|
||||||
"packs",
|
|
||||||
"vault",
|
|
||||||
"audit",
|
|
||||||
"settings",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type LandingRoute = (typeof LANDING_ROUTES)[number];
|
export type LandingRoute = string;
|
||||||
|
|
||||||
export interface WorkbenchPrefs {
|
export interface WorkbenchPrefs {
|
||||||
/** Route the workbench opens to (consumed by the App index redirect). */
|
/** 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 {
|
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 {
|
export function getWorkbenchPrefs(): WorkbenchPrefs {
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,11 @@ describe("CommandPalette keyboard contract", () => {
|
||||||
expect(screen.getByRole("button", { name: "Open Trace" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Open Trace" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Open Replay" })).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"]');
|
const items = dialog.querySelectorAll('[role="option"]');
|
||||||
expect(items.length).toBe(10);
|
expect(items.length).toBe(12);
|
||||||
const lastIndex = items.length - 1;
|
const lastIndex = items.length - 1;
|
||||||
|
|
||||||
// Initially first item (index 0) is focused — check aria-selected
|
// Initially first item (index 0) is focused — check aria-selected
|
||||||
|
|
|
||||||
|
|
@ -10,32 +10,24 @@ import {
|
||||||
type Command,
|
type Command,
|
||||||
type RecentItem,
|
type RecentItem,
|
||||||
} from "../../../app/commandRegistry";
|
} from "../../../app/commandRegistry";
|
||||||
|
import { PALETTE_ROUTES } from "../../../app/routes";
|
||||||
|
|
||||||
const NAV_COMMANDS: Command[] = [
|
// Navigate commands derive from the single route registry (routes.ts), so
|
||||||
{ id: "nav-chat", label: "Open Chat", section: "Navigate", kind: "navigate", shortcut: "⌘1", action: () => {} },
|
// every palette-visible route is searchable here. The prior hand-maintained
|
||||||
{ id: "nav-trace", label: "Open Trace", section: "Navigate", kind: "navigate", shortcut: "⌘2", action: () => {} },
|
// list dropped Demos and Calibration; deriving makes that drift impossible.
|
||||||
{ id: "nav-replay", label: "Open Replay", section: "Navigate", kind: "navigate", shortcut: "⌘3", action: () => {} },
|
// Palette-only routes (no pinned ⌘-digit) appear with no chord — honest.
|
||||||
{ id: "nav-proposals", label: "Open Proposals", section: "Navigate", kind: "navigate", shortcut: "⌘4", action: () => {} },
|
const NAV_COMMANDS: Command[] = PALETTE_ROUTES.map((route) => ({
|
||||||
{ id: "nav-evals", label: "Open Evals", section: "Navigate", kind: "navigate", shortcut: "⌘5", action: () => {} },
|
id: `nav-${route.id}`,
|
||||||
{ id: "nav-runs", label: "Open Runs", section: "Navigate", kind: "navigate", shortcut: "⌘6", action: () => {} },
|
label: `Open ${route.label}`,
|
||||||
{ id: "nav-packs", label: "Open Packs", section: "Navigate", kind: "navigate", shortcut: "⌘7", action: () => {} },
|
section: "Navigate",
|
||||||
{ id: "nav-vault", label: "Open Vault", section: "Navigate", kind: "navigate", shortcut: "⌘8", action: () => {} },
|
kind: "navigate",
|
||||||
{ id: "nav-audit", label: "Open Audit", section: "Navigate", kind: "navigate", shortcut: "⌘9", action: () => {} },
|
shortcut: route.keyboardDigit ? `⌘${route.keyboardDigit}` : undefined,
|
||||||
{ id: "nav-settings", label: "Open Settings", section: "Navigate", kind: "navigate", shortcut: "⌘0", action: () => {} },
|
action: () => {},
|
||||||
];
|
}));
|
||||||
|
|
||||||
const NAV_PATHS: Record<string, string> = {
|
const NAV_PATHS: Record<string, string> = Object.fromEntries(
|
||||||
"nav-chat": "/chat",
|
PALETTE_ROUTES.map((route) => [`nav-${route.id}`, route.path]),
|
||||||
"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",
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DisplayItem {
|
interface DisplayItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue