feat(W-027): Workbench frontend shell — five-region grid + ten empty routes + live StatusFooter (ADR-0160 / ADR-0162) (#299)

- Five-region CSS grid shell (TopBar, LeftNav, Main, StatusFooter; Inspector collapsed)
- Ten placeholder routes with EmptyState cli/string variants and ApiErrorBoundary
- Extended EmptyState API: nextAction accepts string (button) | { kind: "cli", command }
- Updated CommandPalette: real fuzzy search over three commands (Chat, Proposals, Evals)
  with ↑/↓/Enter keyboard nav and useInRouterContext degradation for Branch 1 preview
- TanStack Query hooks for all W-026 endpoints; 30s polling on runtime/status
- TypeScript mirror of workbench/schemas.py at src/types/api.ts
- StatusFooter: mutation_mode badge, git_revision (copy on click), checkpoint_revision
  with amber warning + ADR-0157/ADR-0158 expansion note
- TopBar: CORE Workbench wordmark, ⌘K palette trigger, connection pill
- ApiErrorBoundary (class component) catches WorkbenchApiError → ErrorState
- scripts/dump-api-schemas.py: AST-based Python dataclass field extractor
- workbench-ui/api-schema-snapshot.json: checked-in drift sentinel
- 51 tests across 10 files (0 failures); enum-coverage 4/4; clean vite build
This commit is contained in:
Shay 2026-05-26 12:09:12 -07:00 committed by GitHub
parent 1bff5689db
commit cdead696ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1754 additions and 32 deletions

View file

@ -218,3 +218,39 @@ The correct operator reaction to the workbench should be:
not:
> “Cool chatbot.”
---
# W-027 Frontend Shell Runbook
Start the full local workbench (API + frontend):
```bash
# Terminal 1 — API
uv run core workbench api # http://127.0.0.1:8765
# Terminal 2 — Frontend
cd workbench-ui
pnpm install
pnpm dev # http://127.0.0.1:5173
```
Use a custom API URL:
```bash
VITE_WORKBENCH_API_URL=http://127.0.0.1:9000 pnpm dev
```
View the Branch 1 design baseline:
```bash
cd workbench-ui
pnpm preview # Navigate to /preview
```
Detect TypeScript ↔ Python schema drift:
```bash
uv run python scripts/dump-api-schemas.py
cd workbench-ui && pnpm test
```

View file

@ -0,0 +1,82 @@
"""
Dump workbench/schemas.py dataclass field shapes to a JSON snapshot.
Usage (from repo root):
uv run python scripts/dump-api-schemas.py
Output: workbench-ui/api-schema-snapshot.json
The snapshot is used by workbench-ui/src/types/api.test.ts to detect
TypeScript Python schema drift.
"""
from __future__ import annotations
import ast
import json
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent
SCHEMAS_PATH = REPO_ROOT / "workbench" / "schemas.py"
SNAPSHOT_PATH = REPO_ROOT / "workbench-ui" / "api-schema-snapshot.json"
def annotation_to_str(node: ast.expr) -> str:
"""Convert an AST annotation node to a canonical string."""
return ast.unparse(node)
def extract_dataclasses(source: str) -> dict[str, dict[str, str]]:
"""Parse source and extract fields from @dataclass-decorated classes."""
tree = ast.parse(source)
result: dict[str, dict[str, str]] = {}
for node in ast.walk(tree):
if not isinstance(node, ast.ClassDef):
continue
# Check for @dataclass decorator (any form)
is_dataclass = any(
(isinstance(d, ast.Name) and d.id == "dataclass")
or (isinstance(d, ast.Attribute) and d.attr == "dataclass")
or (
isinstance(d, ast.Call)
and (
(isinstance(d.func, ast.Name) and d.func.id == "dataclass")
or (isinstance(d.func, ast.Attribute) and d.func.attr == "dataclass")
)
)
for d in node.decorator_list
)
if not is_dataclass:
continue
fields: dict[str, str] = {}
for stmt in node.body:
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
field_name = stmt.target.id
annotation = annotation_to_str(stmt.annotation)
fields[field_name] = annotation
result[node.name] = fields
return result
def main() -> None:
if not SCHEMAS_PATH.exists():
print(f"ERROR: {SCHEMAS_PATH} not found", file=sys.stderr)
sys.exit(1)
source = SCHEMAS_PATH.read_text(encoding="utf-8")
dataclasses = extract_dataclasses(source)
snapshot = {"dataclasses": {name: {"fields": fields} for name, fields in dataclasses.items()}}
SNAPSHOT_PATH.parent.mkdir(parents=True, exist_ok=True)
SNAPSHOT_PATH.write_text(json.dumps(snapshot, indent=2) + "\n", encoding="utf-8")
print(f"Snapshot written to {SNAPSHOT_PATH}")
print(f"Dataclasses found: {', '.join(dataclasses.keys())}")
if __name__ == "__main__":
main()

61
workbench-ui/README.md Normal file
View file

@ -0,0 +1,61 @@
# CORE Workbench UI
React/Vite/TypeScript frontend for CORE Workbench — a local operator UI for
inspecting engine state, proposals, evals, and audit trails.
## Local development
```bash
# Terminal 1 — start the API (W-026)
uv run core workbench api
# Terminal 2 — start the frontend
cd workbench-ui
pnpm install
pnpm dev # http://127.0.0.1:5173
```
## Custom API URL
```bash
VITE_WORKBENCH_API_URL=http://127.0.0.1:9000 pnpm dev
```
## Design system baseline
```bash
pnpm preview # Serves dist/ on http://127.0.0.1:4173
# Navigate to /preview for the Branch 1 design-system baseline
```
## Tests
```bash
pnpm test # Vitest unit + component tests
pnpm test:enum-coverage # Badge enum coverage (requires uv)
```
## Schema drift detection
The TypeScript types in `src/types/api.ts` mirror `workbench/schemas.py`.
To check for drift after Python schema changes:
```bash
# From repo root
uv run python scripts/dump-api-schemas.py
# Then run: pnpm test (api.test.ts catches field-level drift)
```
## Architecture
- `src/app/` — Shell, TopBar, LeftNav, StatusFooter, ApiErrorBoundary
- `src/api/` — apiFetch client, TanStack Query hooks
- `src/types/` — TypeScript mirrors of Python schemas
- `src/routes/` — Route placeholder components
- `src/design/` — Branch 1 design-system substrate (DO NOT MODIFY except EmptyState and CommandPalette)
## ADR cross-references
- ADR-0160 — Workbench v1 architecture
- ADR-0162 — Design substrate (Branch 1)
- ADR-0156, ADR-0157, ADR-0158 — Engine-state checkpoint and reboot audit trail

View file

@ -0,0 +1,86 @@
{
"dataclasses": {
"RuntimeStatus": {
"fields": {
"backend": "Literal['numpy', 'mlx', 'rust', 'unknown']",
"git_revision": "str",
"engine_state_present": "bool",
"checkpoint_revision": "str",
"revision_warning": "bool",
"active_session_id": "str | None",
"mutation_mode": "Literal['read_only', 'runtime_turn']"
}
},
"ArtifactRef": {
"fields": {
"artifact_id": "str",
"kind": "Literal['trace', 'eval_result', 'proposal', 'contemplation_report', 'telemetry', 'engine_state_manifest', 'unknown']",
"path": "str",
"digest": "str | None",
"created_at": "str | None"
}
},
"ArtifactDetail": {
"fields": {
"content_type": "Literal['json', 'jsonl', 'text', 'unknown']",
"content": "Any"
}
},
"ProposalSummary": {
"fields": {
"proposal_id": "str",
"state": "Literal['pending', 'accepted', 'rejected', 'withdrawn', 'unknown']",
"source_kind": "str",
"replay_equivalent": "bool | None",
"created_at": "str | None",
"downstream_effect": "Literal['unknown', 'none', 'observed']"
}
},
"ProposalDetail": {
"fields": {
"proposed_chain": "Any",
"replay_evidence": "Any",
"source": "Any",
"evidence": "list[Any]",
"artifact_refs": "list[ArtifactRef]",
"suggested_cli": "str | None"
}
},
"EvalLaneSummary": {
"fields": {
"lane": "str",
"versions": "list[str]",
"read_only": "bool",
"description": "str | None"
}
},
"EvalRunResult": {
"fields": {
"lane": "str",
"version": "str",
"split": "str",
"passed": "bool | None",
"metrics": "dict[str, Any]",
"cases": "list[Any]",
"source_digest": "str | None"
}
},
"ReplayDivergence": {
"fields": {
"path": "str",
"original": "Any",
"replay": "Any",
"severity": "Literal['info', 'warning', 'failure']"
}
},
"ReplayComparison": {
"fields": {
"artifact_id": "str",
"original_hash": "str | None",
"replay_hash": "str | None",
"equivalent": "bool",
"divergences": "list[ReplayDivergence]"
}
}
}
}

View file

@ -17,11 +17,13 @@
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-slot": "1.2.3",
"@tanstack/react-query": "5",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "0.468.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6",
"tailwind-merge": "2.6.0"
},
"devDependencies": {

View file

@ -17,6 +17,9 @@ importers:
'@radix-ui/react-slot':
specifier: 1.2.3
version: 1.2.3(@types/react@18.3.17)(react@18.3.1)
'@tanstack/react-query':
specifier: '5'
version: 5.100.14(react@18.3.1)
class-variance-authority:
specifier: 0.7.1
version: 0.7.1
@ -32,6 +35,9 @@ importers:
react-dom:
specifier: 18.3.1
version: 18.3.1(react@18.3.1)
react-router-dom:
specifier: '6'
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tailwind-merge:
specifier: 2.6.0
version: 2.6.0
@ -734,6 +740,10 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@remix-run/router@1.23.2':
resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==}
engines: {node: '>=14.0.0'}
'@rollup/rollup-android-arm-eabi@4.60.4':
resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==}
cpu: [arm]
@ -859,6 +869,14 @@ packages:
cpu: [x64]
os: [win32]
'@tanstack/query-core@5.100.14':
resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==}
'@tanstack/react-query@5.100.14':
resolution: {integrity: sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==}
peerDependencies:
react: ^18 || ^19
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@ -1436,6 +1454,19 @@ packages:
'@types/react':
optional: true
react-router-dom@6.30.3:
resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-dom: '>=16.8'
react-router@6.30.3:
resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==}
engines: {node: '>=14.0.0'}
peerDependencies:
react: '>=16.8'
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@ -2207,6 +2238,8 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
'@remix-run/router@1.23.2': {}
'@rollup/rollup-android-arm-eabi@4.60.4':
optional: true
@ -2282,6 +2315,13 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.60.4':
optional: true
'@tanstack/query-core@5.100.14': {}
'@tanstack/react-query@5.100.14(react@18.3.1)':
dependencies:
'@tanstack/query-core': 5.100.14
react: 18.3.1
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.7
@ -2837,6 +2877,18 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.17
react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.2
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-router: 6.30.3(react@18.3.1)
react-router@6.30.3(react@18.3.1):
dependencies:
'@remix-run/router': 1.23.2
react: 18.3.1
react-style-singleton@2.2.3(@types/react@18.3.17)(react@18.3.1):
dependencies:
get-nonce: 1.0.1

View file

@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { apiFetch, WorkbenchApiError } from "./client";
describe("apiFetch", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("parses ok:true envelope and returns data", async () => {
const mockData = { backend: "numpy", git_revision: "abc123" };
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ ok: true, generated_at: "2026-01-01T00:00:00Z", data: mockData }),
}),
);
const result = await apiFetch("/runtime/status");
expect(result).toEqual(mockData);
});
it("throws WorkbenchApiError on ok:false envelope", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({
ok: false,
generated_at: "2026-01-01T00:00:00Z",
error: { code: "runtime_unavailable", message: "Server is offline" },
}),
}),
);
await expect(apiFetch("/runtime/status")).rejects.toThrow(WorkbenchApiError);
await expect(apiFetch("/runtime/status")).rejects.toMatchObject({
code: "runtime_unavailable",
message: "Server is offline",
});
});
it("AbortController fires on timeout (never-resolving fetch)", async () => {
vi.useFakeTimers();
const abortMock = vi.fn();
const originalAbortController = global.AbortController;
// Replace AbortController to track abort calls
const mockController = {
signal: { aborted: false } as AbortSignal,
abort: abortMock,
};
vi.stubGlobal("AbortController", vi.fn(() => mockController));
// fetch never resolves
vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {})));
const fetchPromise = apiFetch("/runtime/status");
// Advance past the 5s timeout
await vi.advanceTimersByTimeAsync(6000);
expect(abortMock).toHaveBeenCalled();
// Clean up
global.AbortController = originalAbortController;
vi.useRealTimers();
// fetchPromise will never resolve but that's expected
fetchPromise.catch(() => {});
});
});

View file

@ -0,0 +1,29 @@
import type { ApiResponse, ErrorCode } from "../types/api";
export class WorkbenchApiError extends Error {
constructor(
public readonly code: ErrorCode,
message: string,
) {
super(message);
this.name = "WorkbenchApiError";
}
}
export const API_URL: string =
import.meta.env.VITE_WORKBENCH_API_URL ?? "http://127.0.0.1:8765";
export async function apiFetch<T>(path: string): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${API_URL}${path}`, { signal: controller.signal });
const json: ApiResponse<T> = await res.json();
if (!json.ok) {
throw new WorkbenchApiError(json.error.code, json.error.message);
}
return json.data;
} finally {
clearTimeout(timeout);
}
}

View file

@ -0,0 +1,80 @@
import {
QueryClient,
useQuery,
QueryClientProvider,
} from "@tanstack/react-query";
import { apiFetch } from "./client";
import type {
RuntimeStatus,
ArtifactRef,
ArtifactDetail,
ProposalSummary,
ProposalDetail,
EvalLaneSummary,
EvalRunResult,
} from "../types/api";
export { QueryClientProvider };
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5000,
refetchOnWindowFocus: false,
},
},
});
export function useRuntimeStatus() {
return useQuery<RuntimeStatus>({
queryKey: ["api", "runtime-status"],
queryFn: () => apiFetch<RuntimeStatus>("/runtime/status"),
refetchInterval: 30_000,
});
}
export function useArtifacts(limit?: number) {
const params = limit !== undefined ? `?limit=${limit}` : "";
return useQuery<ArtifactRef[]>({
queryKey: ["api", "artifacts", limit],
queryFn: () => apiFetch<ArtifactRef[]>(`/artifacts${params}`),
});
}
export function useArtifact(id: string) {
return useQuery<ArtifactDetail>({
queryKey: ["api", "artifact", id],
queryFn: () => apiFetch<ArtifactDetail>(`/artifacts/${id}`),
enabled: !!id,
});
}
export function useProposals() {
return useQuery<ProposalSummary[]>({
queryKey: ["api", "proposals"],
queryFn: () => apiFetch<ProposalSummary[]>("/proposals"),
});
}
export function useProposal(id: string) {
return useQuery<ProposalDetail>({
queryKey: ["api", "proposal", id],
queryFn: () => apiFetch<ProposalDetail>(`/proposals/${id}`),
enabled: !!id,
});
}
export function useEvalLanes() {
return useQuery<EvalLaneSummary[]>({
queryKey: ["api", "evals"],
queryFn: () => apiFetch<EvalLaneSummary[]>("/evals"),
});
}
export function useEvalLane(name: string) {
return useQuery<EvalRunResult>({
queryKey: ["api", "eval", name],
queryFn: () => apiFetch<EvalRunResult>(`/evals/${name}`),
enabled: !!name,
});
}

View file

@ -0,0 +1,41 @@
import { Component, type ReactNode } from "react";
import { ErrorState } from "../design/components/states/ErrorState";
import { WorkbenchApiError } from "../api/client";
import { API_URL } from "../api/client";
interface Props {
children: ReactNode;
}
interface State {
error: WorkbenchApiError | null;
}
export class ApiErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error: unknown): State {
if (error instanceof WorkbenchApiError) {
return { error };
}
return { error: null };
}
render() {
const { error } = this.state;
if (error) {
return (
<ErrorState
whatFailed={`Workbench API unreachable at ${API_URL}`}
mutationStatus="No corpus mutation occurred."
reproducer="Run: core workbench api"
retrySafety="Retry: safe (read-only)"
/>
);
}
return this.props.children;
}
}

View file

@ -0,0 +1,40 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "../api/queries";
import { Shell } from "./Shell";
import { PreviewPage } from "../preview/PreviewPage";
import { ChatRoutePlaceholder } from "../routes/ChatRoutePlaceholder";
import { TraceRoutePlaceholder } from "../routes/TraceRoutePlaceholder";
import { ReplayRoutePlaceholder } from "../routes/ReplayRoutePlaceholder";
import { ProposalsRoutePlaceholder } from "../routes/ProposalsRoutePlaceholder";
import { EvalsRoutePlaceholder } from "../routes/EvalsRoutePlaceholder";
import { RunsRoutePlaceholder } from "../routes/RunsRoutePlaceholder";
import { PacksRoutePlaceholder } from "../routes/PacksRoutePlaceholder";
import { VaultRoutePlaceholder } from "../routes/VaultRoutePlaceholder";
import { AuditRoutePlaceholder } from "../routes/AuditRoutePlaceholder";
import { SettingsRoutePlaceholder } from "../routes/SettingsRoutePlaceholder";
export function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/" element={<Shell />}>
<Route index element={<Navigate to="/chat" replace />} />
<Route path="chat" element={<ChatRoutePlaceholder />} />
<Route path="trace" element={<TraceRoutePlaceholder />} />
<Route path="replay" element={<ReplayRoutePlaceholder />} />
<Route path="proposals" element={<ProposalsRoutePlaceholder />} />
<Route path="evals" element={<EvalsRoutePlaceholder />} />
<Route path="runs" element={<RunsRoutePlaceholder />} />
<Route path="packs" element={<PacksRoutePlaceholder />} />
<Route path="vault" element={<VaultRoutePlaceholder />} />
<Route path="audit" element={<AuditRoutePlaceholder />} />
<Route path="settings" element={<SettingsRoutePlaceholder />} />
</Route>
<Route path="/preview" element={<PreviewPage />} />
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
}

View file

@ -0,0 +1,41 @@
import { NavLink } from "react-router-dom";
const NAV_ITEMS = [
{ label: "Chat", to: "/chat" },
{ label: "Trace", to: "/trace" },
{ label: "Replay", to: "/replay" },
{ label: "Proposals", to: "/proposals" },
{ label: "Evals", to: "/evals" },
{ 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() {
return (
<nav
data-region="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>
);
}

View file

@ -0,0 +1,12 @@
// RightInspector — default collapsed in W-027 (no content yet)
export function RightInspector({ collapsed = true }: { collapsed?: boolean }) {
if (collapsed) return null;
return (
<aside
data-region="inspector"
className="h-full overflow-y-auto border-l border-[var(--color-border-subtle)] bg-[var(--color-surface-base)] p-4 text-sm text-[var(--color-text-secondary)]"
>
Inspector
</aside>
);
}

View file

@ -0,0 +1,99 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Shell } from "./Shell";
import { ChatRoutePlaceholder } from "../routes/ChatRoutePlaceholder";
import { WorkbenchApiError } from "../api/client";
// Mock the API queries module
vi.mock("../api/queries", async (importOriginal) => {
const actual = await importOriginal<typeof import("../api/queries")>();
return {
...actual,
useRuntimeStatus: vi.fn(),
};
});
import { useRuntimeStatus } from "../api/queries";
function makeClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
function renderShell() {
const client = makeClient();
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={["/chat"]}>
<Routes>
<Route path="/" element={<Shell />}>
<Route path="chat" element={<ChatRoutePlaceholder />} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("Shell error state", () => {
beforeEach(() => {
// Simulate API error on useRuntimeStatus
vi.mocked(useRuntimeStatus).mockReturnValue(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{
data: undefined,
isLoading: false,
isError: true,
error: new WorkbenchApiError("runtime_unavailable", "Server is offline"),
isPending: false,
isSuccess: false,
status: "error",
} as any,
);
});
it("TopBar connection pill shows unreachable when status errors", () => {
renderShell();
expect(screen.getByText("API: unreachable")).toBeInTheDocument();
});
it("StatusFooter shows Status unavailable in danger color", () => {
renderShell();
const footer = document.querySelector('[data-region="statusfooter"]')!;
expect(footer.textContent).toContain("Status unavailable");
});
it("routes that throw WorkbenchApiError show ErrorState with four required fields", () => {
// Render with ApiErrorBoundary catching a thrown WorkbenchApiError
// The ApiErrorBoundary wraps the Outlet in Shell.
// We need a child component that throws.
function ThrowingRoute(): React.ReactElement {
throw new WorkbenchApiError("runtime_unavailable", "API down");
}
const client = makeClient();
render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={["/chat"]}>
<Routes>
<Route path="/" element={<Shell />}>
<Route path="chat" element={<ThrowingRoute />} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
expect(screen.getByText("What failed")).toBeInTheDocument();
expect(screen.getByText("Mutation status")).toBeInTheDocument();
expect(screen.getByText("Reproducer")).toBeInTheDocument();
expect(screen.getByText("Retry safety")).toBeInTheDocument();
// Correct field values
expect(screen.getByText(/Workbench API unreachable at/)).toBeInTheDocument();
expect(screen.getByText("No corpus mutation occurred.")).toBeInTheDocument();
expect(screen.getByText("Run: core workbench api")).toBeInTheDocument();
expect(screen.getByText("Retry: safe (read-only)")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,168 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Shell } from "./Shell";
import { ChatRoutePlaceholder } from "../routes/ChatRoutePlaceholder";
import { ProposalsRoutePlaceholder } from "../routes/ProposalsRoutePlaceholder";
import type { RuntimeStatus } from "../types/api";
// Mock the API queries module
vi.mock("../api/queries", async (importOriginal) => {
const actual = await importOriginal<typeof import("../api/queries")>();
return {
...actual,
useRuntimeStatus: vi.fn(),
};
});
import { useRuntimeStatus } from "../api/queries";
const mockStatus: RuntimeStatus = {
backend: "numpy",
git_revision: "abcdef1234567890",
engine_state_present: true,
checkpoint_revision: "deadbeef12345678",
revision_warning: false,
active_session_id: null,
mutation_mode: "read_only",
};
function makeClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
function renderShell(initialPath = "/chat") {
const client = makeClient();
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/" element={<Shell />}>
<Route path="chat" element={<ChatRoutePlaceholder />} />
<Route path="proposals" element={<ProposalsRoutePlaceholder />} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("Shell", () => {
beforeEach(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(useRuntimeStatus).mockReturnValue({
data: mockStatus,
isLoading: false,
isError: false,
isPending: false,
isSuccess: true,
status: "success",
error: null,
} as any);
});
it("renders five named regions", () => {
renderShell();
expect(screen.getByRole("banner")).toBeInTheDocument(); // topbar is a header
expect(document.querySelector('[data-region="topbar"]')).toBeInTheDocument();
expect(document.querySelector('[data-region="leftnav"]')).toBeInTheDocument();
expect(document.querySelector('[data-region="main"]')).toBeInTheDocument();
expect(document.querySelector('[data-region="statusfooter"]')).toBeInTheDocument();
});
it("LeftNav has exactly 10 items in order", () => {
renderShell();
const nav = document.querySelector('[data-region="leftnav"]')!;
const links = nav.querySelectorAll("a");
expect(links).toHaveLength(10);
const labels = Array.from(links).map((l) => l.textContent);
expect(labels).toEqual([
"Chat",
"Trace",
"Replay",
"Proposals",
"Evals",
"Runs",
"Packs",
"Vault",
"Audit",
"Settings",
]);
});
it("clicking a nav item changes route (main shows new content)", () => {
renderShell("/chat");
// Initially on /chat route — EmptyState for Chat is present
expect(screen.getByText("Chat — no data loaded yet.")).toBeInTheDocument();
// Click Proposals nav link
const proposalsLink = screen.getByRole("link", { name: "Proposals" });
fireEvent.click(proposalsLink);
expect(screen.getByText("Proposals — no data loaded yet.")).toBeInTheDocument();
});
it("StatusFooter shows mutation_mode Read Only label for read_only", () => {
renderShell();
const footer = document.querySelector('[data-region="statusfooter"]')!;
expect(footer.textContent).toContain("Read Only");
expect(footer.textContent).not.toContain("Runtime Turn");
});
it("StatusFooter shows git_revision short SHA", () => {
renderShell();
const el = document.querySelector('[data-testid="git-revision"]');
expect(el).toBeInTheDocument();
expect(el!.textContent).toBe("abcdef12");
});
it("StatusFooter shows checkpoint_revision short SHA", () => {
renderShell();
const el = document.querySelector('[data-testid="checkpoint-revision"]');
expect(el).toBeInTheDocument();
expect(el!.textContent).toBe("deadbeef");
});
it("mutation_mode runtime_turn renders amber styling and Runtime Turn label", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(useRuntimeStatus).mockReturnValue({
data: { ...mockStatus, mutation_mode: "runtime_turn" },
isLoading: false,
isError: false,
isPending: false,
isSuccess: true,
status: "success",
error: null,
} as any);
renderShell();
const mutationEl = document.querySelector('[data-testid="mutation-mode"]')!;
expect(mutationEl.textContent).toBe("Runtime Turn");
// Should use warning color class (amber)
expect(mutationEl.className).toContain("warning");
});
it("revision_warning=true makes checkpoint_revision show warning attr, clicking expands note", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(useRuntimeStatus).mockReturnValue({
data: { ...mockStatus, revision_warning: true },
isLoading: false,
isError: false,
isPending: false,
isSuccess: true,
status: "success",
error: null,
} as any);
renderShell();
const cpBtn = document.querySelector('[data-testid="checkpoint-revision"]')!;
expect(cpBtn.getAttribute("data-warning")).toBe("true");
// Click to expand note
fireEvent.click(cpBtn);
const note = document.querySelector('[data-testid="revision-note"]');
expect(note).toBeInTheDocument();
expect(note!.textContent).toContain("ADR-0157 / ADR-0158");
});
});

View file

@ -0,0 +1,52 @@
import { Outlet } from "react-router-dom";
import { TopBar } from "./TopBar";
import { LeftNav } from "./LeftNav";
import { StatusFooter } from "./StatusFooter";
import { RightInspector } from "./RightInspector";
import { ApiErrorBoundary } from "./ApiErrorBoundary";
export function Shell() {
// RightInspector defaults to collapsed in W-027
const inspectorCollapsed = true;
return (
<div
className="grid h-screen"
style={{
gridTemplateAreas: inspectorCollapsed
? '"topbar topbar" "leftnav main" "footer footer"'
: '"topbar topbar topbar" "leftnav main inspector" "footer footer footer"',
gridTemplateRows: "auto 1fr auto",
gridTemplateColumns: inspectorCollapsed ? "12rem 1fr" : "12rem 1fr 20rem",
}}
>
<div style={{ gridArea: "topbar" }}>
<TopBar />
</div>
<div style={{ gridArea: "leftnav" }}>
<LeftNav />
</div>
<main
data-region="main"
style={{ gridArea: "main" }}
className="overflow-y-auto bg-[var(--color-surface-base)] p-4"
>
<ApiErrorBoundary>
<Outlet />
</ApiErrorBoundary>
</main>
{!inspectorCollapsed && (
<div style={{ gridArea: "inspector" }}>
<RightInspector collapsed={false} />
</div>
)}
<div style={{ gridArea: "footer" }}>
<StatusFooter />
</div>
</div>
);
}

View file

@ -0,0 +1,91 @@
import { useState } from "react";
import { useRuntimeStatus } from "../api/queries";
export function StatusFooter() {
const { data, isError } = useRuntimeStatus();
const [revisionExpanded, setRevisionExpanded] = useState(false);
if (isError) {
return (
<footer
data-region="statusfooter"
className="flex items-center border-t border-[var(--color-border-subtle)] bg-[var(--color-surface-base)] px-4 py-1 text-xs"
>
<span className="text-[var(--color-state-danger-text)]">Status unavailable</span>
</footer>
);
}
if (!data) return null;
const { mutation_mode, git_revision, checkpoint_revision, revision_warning } = data;
const shortSha = (sha: string) => sha.slice(0, 8);
const mutationModeEl =
mutation_mode === "read_only" ? (
<span
data-testid="mutation-mode"
className="rounded border border-[var(--color-border-subtle)] px-2 py-0.5 text-[var(--color-text-secondary)]"
aria-label="Mutation mode: Read Only"
>
Read Only
</span>
) : (
<span
data-testid="mutation-mode"
className="rounded border border-[var(--color-state-warning-border)] bg-[var(--color-state-warning-bg)] px-2 py-0.5 text-[var(--color-state-warning-text)]"
aria-label="Mutation mode: Runtime Turn"
>
Runtime Turn
</span>
);
return (
<footer
data-region="statusfooter"
className="flex items-center gap-4 border-t border-[var(--color-border-subtle)] bg-[var(--color-surface-base)] px-4 py-1 text-xs"
>
{mutationModeEl}
<button
type="button"
className="font-mono text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
onClick={() => navigator.clipboard.writeText(git_revision).catch(() => {})}
title="Click to copy full SHA"
aria-label={`git revision: ${git_revision}`}
data-testid="git-revision"
>
{shortSha(git_revision)}
</button>
<div className="flex flex-col">
<button
type="button"
className={[
"font-mono focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]",
revision_warning
? "text-[var(--color-state-warning-text)] hover:opacity-80"
: "text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]",
].join(" ")}
onClick={() => setRevisionExpanded((v) => !v)}
aria-expanded={revisionExpanded}
aria-label={`checkpoint revision: ${checkpoint_revision}${revision_warning ? " (warning)" : ""}`}
data-testid="checkpoint-revision"
data-warning={revision_warning ? "true" : undefined}
>
{shortSha(checkpoint_revision)}
</button>
{revisionExpanded && (
<p
className="mt-1 max-w-xs rounded border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-2 text-xs text-[var(--color-text-secondary)]"
data-testid="revision-note"
>
Engine state was written at {checkpoint_revision}; current revision is {git_revision}.
ADR-0157 / ADR-0158 govern this behavior.
</p>
)}
</div>
</footer>
);
}

View file

@ -0,0 +1,84 @@
import { useState } from "react";
import { CommandPalette } from "../design/components/primitives/CommandPalette";
import { useRuntimeStatus } from "../api/queries";
export function TopBar() {
const [paletteOpen, setPaletteOpen] = useState(false);
const { isLoading, isError } = useRuntimeStatus();
function openPalette() {
setPaletteOpen(true);
}
// ⌘K global shortcut
function handleKeyDown(e: React.KeyboardEvent<HTMLButtonElement>) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openPalette();
}
}
const connectionPill = (() => {
if (isLoading) {
return (
<span
className="rounded-full border border-[var(--color-border-subtle)] px-3 py-1 text-xs text-[var(--color-text-secondary)]"
aria-live="polite"
>
Connecting
</span>
);
}
if (isError) {
return (
<span
className="rounded-full border border-[var(--color-state-danger-border)] bg-[var(--color-state-danger-bg)] px-3 py-1 text-xs text-[var(--color-state-danger-text)]"
aria-live="polite"
>
API: unreachable
</span>
);
}
return (
<span
className="rounded-full border border-[var(--color-state-success-border)] bg-[var(--color-state-success-bg)] px-3 py-1 text-xs text-[var(--color-state-success-text)]"
aria-live="polite"
>
API: connected
</span>
);
})();
return (
<header
data-region="topbar"
className="flex items-center gap-4 border-b border-[var(--color-border-subtle)] bg-[var(--color-surface-base)] px-4 py-2"
>
{/* Wordmark */}
<span className="shrink-0 font-mono text-sm font-semibold text-[var(--color-text-primary)]">
CORE Workbench
</span>
{/* Search / Command Palette trigger */}
<div className="flex flex-1 justify-center">
<button
type="button"
className="flex items-center gap-2 rounded border border-[var(--color-border-subtle)] bg-[var(--color-surface-sunken)] px-3 py-1 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
onClick={openPalette}
onKeyDown={handleKeyDown}
aria-label="Open command palette (⌘K)"
>
<span>Search commands</span>
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 font-mono text-xs">
K
</kbd>
</button>
</div>
{/* Connection pill */}
<div className="shrink-0">{connectionPill}</div>
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
</header>
);
}

View file

@ -0,0 +1,120 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { useState } from "react";
import { CommandPalette } from "./CommandPalette";
function PaletteHarness({ initialOpen = false }: { initialOpen?: boolean }) {
const [open, setOpen] = useState(initialOpen);
return (
<MemoryRouter>
<button type="button" onClick={() => setOpen(true)} aria-label="Open palette">
Open
</button>
<CommandPalette open={open} onOpenChange={setOpen} />
</MemoryRouter>
);
}
describe("CommandPalette keyboard contract", () => {
it("⌘K still opens the palette (regression)", async () => {
const user = userEvent.setup();
// We test the open prop directly — ⌘K is handled by the host (TopBar/PreviewPage)
render(<PaletteHarness initialOpen={false} />);
// Open via button
await user.click(screen.getByRole("button", { name: "Open palette" }));
expect(screen.getByRole("dialog", { name: "Command Palette" })).toBeInTheDocument();
});
it("Escape closes the palette (regression)", async () => {
const user = userEvent.setup();
render(<PaletteHarness initialOpen={true} />);
expect(screen.getByRole("dialog", { name: "Command Palette" })).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog", { name: "Command Palette" })).not.toBeInTheDocument();
});
it("ArrowDown/ArrowUp traverses the three commands", async () => {
const user = userEvent.setup();
render(<PaletteHarness initialOpen={true} />);
const dialog = screen.getByRole("dialog", { name: "Command Palette" });
// All three commands should be present
const chatBtn = screen.getByRole("button", { name: "Open Chat" });
const proposalsBtn = screen.getByRole("button", { name: "Open Proposals" });
const evalsBtn = screen.getByRole("button", { name: "Open Evals" });
expect(chatBtn).toBeInTheDocument();
expect(proposalsBtn).toBeInTheDocument();
expect(evalsBtn).toBeInTheDocument();
// Initially first item (index 0) is focused — check aria-selected
const items = dialog.querySelectorAll('[role="option"]');
expect(items[0].getAttribute("aria-selected")).toBe("true");
expect(items[1].getAttribute("aria-selected")).toBe("false");
// ArrowDown moves to index 1
await user.keyboard("{ArrowDown}");
expect(items[0].getAttribute("aria-selected")).toBe("false");
expect(items[1].getAttribute("aria-selected")).toBe("true");
// ArrowDown moves to index 2
await user.keyboard("{ArrowDown}");
expect(items[2].getAttribute("aria-selected")).toBe("true");
// ArrowDown at end stays at 2 (clamped)
await user.keyboard("{ArrowDown}");
expect(items[2].getAttribute("aria-selected")).toBe("true");
// ArrowUp moves back to index 1
await user.keyboard("{ArrowUp}");
expect(items[1].getAttribute("aria-selected")).toBe("true");
});
it("Enter activates the focused command and closes the palette", async () => {
const user = userEvent.setup();
render(<PaletteHarness initialOpen={true} />);
// Focus is on index 0 = "Open Chat"
// Press Enter to activate
await user.keyboard("{Enter}");
// Palette should close
expect(screen.queryByRole("dialog", { name: "Command Palette" })).not.toBeInTheDocument();
});
it("fuzzy filter: typing 'ch' shows Open Chat", async () => {
const user = userEvent.setup();
render(<PaletteHarness initialOpen={true} />);
const input = screen.getByRole("textbox", { name: "Search commands" });
await user.type(input, "ch");
expect(screen.getByRole("button", { name: "Open Chat" })).toBeInTheDocument();
// Proposals and Evals should not match "ch"
expect(screen.queryByRole("button", { name: "Open Proposals" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Open Evals" })).not.toBeInTheDocument();
});
it("fuzzy filter: typing 'eval' shows Open Evals", async () => {
const user = userEvent.setup();
render(<PaletteHarness initialOpen={true} />);
const input = screen.getByRole("textbox", { name: "Search commands" });
await user.type(input, "eval");
expect(screen.getByRole("button", { name: "Open Evals" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Open Chat" })).not.toBeInTheDocument();
});
it("shows 'No commands match' when filter matches nothing", async () => {
const user = userEvent.setup();
render(<PaletteHarness initialOpen={true} />);
const input = screen.getByRole("textbox", { name: "Search commands" });
await user.type(input, "zzznomatch");
expect(screen.getByText("No commands match.")).toBeInTheDocument();
});
});

View file

@ -1,6 +1,144 @@
import * as Dialog from "@radix-ui/react-dialog";
import { Search } from "lucide-react";
import { EmptyState } from "../states/EmptyState";
import { useRef, useState, useEffect, useCallback } from "react";
import { useNavigate, useInRouterContext } from "react-router-dom";
interface Command {
name: string;
path: string;
}
const COMMANDS: Command[] = [
{ name: "Open Chat", path: "/chat" },
{ name: "Open Proposals", path: "/proposals" },
{ name: "Open Evals", path: "/evals" },
];
// Inner shell that safely calls useNavigate — only rendered inside a Router.
function RouterCommandPalette(props: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const navigate = useNavigate();
const activate = useCallback(
(cmd: Command) => {
navigate(cmd.path);
props.onOpenChange(false);
},
[navigate, props],
);
return <CommandPaletteContent {...props} onActivate={activate} />;
}
// Fallback for design-system preview (no Router).
function FallbackCommandPalette(props: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const activate = useCallback(
(_cmd: Command) => {
props.onOpenChange(false);
},
[props],
);
return <CommandPaletteContent {...props} onActivate={activate} />;
}
function CommandPaletteContent({
open,
onOpenChange,
onActivate,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onActivate: (cmd: Command) => void;
}) {
const [query, setQuery] = useState("");
const [focusedIndex, setFocusedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = COMMANDS.filter((cmd) =>
cmd.name.toLowerCase().includes(query.toLowerCase()),
);
useEffect(() => {
if (open) {
setQuery("");
setFocusedIndex(0);
setTimeout(() => inputRef.current?.focus(), 0);
}
}, [open]);
useEffect(() => {
setFocusedIndex(0);
}, [query]);
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
setFocusedIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const cmd = filtered[focusedIndex];
if (cmd) onActivate(cmd);
}
}
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/55" data-testid="overlay" />
<Dialog.Content
className="fixed left-1/2 top-[18vh] w-[min(560px,calc(100vw-32px))] -translate-x-1/2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-overlay)] p-4 shadow-[var(--shadow-overlay)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
onKeyDown={handleKeyDown}
>
<Dialog.Title className="mb-3 flex items-center gap-2 text-sm font-semibold">
<Search size={16} aria-hidden />
Command Palette
</Dialog.Title>
<Dialog.Description className="sr-only">
Search and activate commands. Use arrow keys to navigate, Enter to activate, Escape to close.
</Dialog.Description>
<input
ref={inputRef}
className="mb-3 w-full rounded border border-[var(--color-border-subtle)] bg-[var(--color-surface-sunken)] px-3 py-2 text-sm text-[var(--color-text-primary)] focus:outline focus:outline-2 focus:outline-[var(--color-focus-ring)]"
placeholder="Type to search commands…"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Search commands"
/>
{filtered.length === 0 ? (
<p className="text-sm text-[var(--color-text-secondary)]">No commands match.</p>
) : (
<ul className="m-0 list-none p-0" role="listbox" aria-label="Commands">
{filtered.map((cmd, i) => (
<li key={cmd.path} role="option" aria-selected={i === focusedIndex}>
<button
className={[
"w-full rounded px-3 py-2 text-left text-sm transition-colors",
i === focusedIndex
? "bg-[var(--color-surface-raised)] text-[var(--color-text-primary)]"
: "text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-primary)]",
].join(" ")}
type="button"
onClick={() => onActivate(cmd)}
onMouseEnter={() => setFocusedIndex(i)}
aria-label={cmd.name}
>
{cmd.name}
</button>
</li>
))}
</ul>
)}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
export function CommandPalette({
open,
@ -9,24 +147,10 @@ export function CommandPalette({
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/55" data-testid="overlay" />
<Dialog.Content className="fixed left-1/2 top-[18vh] w-[min(560px,calc(100vw-32px))] -translate-x-1/2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-overlay)] p-4 shadow-[var(--shadow-overlay)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]">
<Dialog.Title className="mb-3 flex items-center gap-2 text-sm font-semibold">
<Search size={16} aria-hidden />
Command Palette
</Dialog.Title>
<Dialog.Description className="sr-only">
Empty command palette stub for the Branch 1 keyboard contract.
</Dialog.Description>
<EmptyState
statement="No commands are registered in Branch 1."
nextAction="W-027 will add command content behind this keybinding."
/>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
const inRouter = useInRouterContext();
if (inRouter) {
return <RouterCommandPalette open={open} onOpenChange={onOpenChange} />;
}
return <FallbackCommandPalette open={open} onOpenChange={onOpenChange} />;
}

View file

@ -1,18 +1,45 @@
import { Button } from "../primitives/Button";
type CliAction = { kind: "cli"; command: string };
type NextAction = string | CliAction;
export function EmptyState({
statement,
nextAction,
}: {
statement: string;
nextAction: string;
nextAction: NextAction;
}) {
function handleCopy(command: string) {
navigator.clipboard.writeText(command).catch(() => {
// clipboard write may be unavailable in non-secure contexts; fail silently
});
}
return (
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
<p className="m-0 text-sm text-[var(--color-text-primary)]">{statement}</p>
<Button className="mt-3" variant="quiet" type="button">
{nextAction}
</Button>
{typeof nextAction === "string" ? (
<Button className="mt-3" variant="quiet" type="button">
{nextAction}
</Button>
) : (
<div className="mt-3 flex items-center gap-2">
<code className="flex-1 rounded bg-[var(--color-surface-sunken)] px-2 py-1 font-mono text-xs text-[var(--color-text-primary)]">
{nextAction.command}
</code>
<Button
className="shrink-0"
variant="quiet"
type="button"
aria-label="⌘C to copy"
title="⌘C to copy"
onClick={() => handleCopy(nextAction.command)}
>
Copy
</Button>
</div>
)}
</section>
);
}

View file

@ -1,5 +1,6 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { EmptyState } from "./EmptyState";
import { ErrorState } from "./ErrorState";
import { LoadingState } from "./LoadingState";
@ -11,6 +12,30 @@ describe("state components", () => {
expect(screen.getByRole("button", { name: "Select a trace." })).toBeInTheDocument();
});
it("renders cli-form nextAction as mono command row with copy button", async () => {
// navigator.clipboard is configured in setup.ts — spy on the existing mock
const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined);
render(
<EmptyState
statement="Chat — no data loaded yet."
nextAction={{ kind: "cli", command: "core chat" }}
/>,
);
expect(screen.getByText("Chat — no data loaded yet.")).toBeInTheDocument();
// command shown in mono row
expect(screen.getByText("core chat")).toBeInTheDocument();
// copy button present
const copyBtn = screen.getByRole("button", { name: /copy/i });
expect(copyBtn).toBeInTheDocument();
// clicking it writes to clipboard
await userEvent.click(copyBtn);
expect(writeText).toHaveBeenCalledWith("core chat");
writeText.mockRestore();
});
it("renders every error-state contract field", () => {
render(
<ErrorState

View file

@ -1,11 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import { PreviewPage } from "./preview/PreviewPage";
function App() {
return window.location.pathname === "/preview" ? <PreviewPage /> : <PreviewPage />;
}
import { App } from "./app/App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>

View file

@ -0,0 +1,10 @@
import { EmptyState } from "../design/components/states/EmptyState";
export function AuditRoutePlaceholder() {
return (
<EmptyState
statement="Audit — no data loaded yet."
nextAction={{ kind: "cli", command: "teaching/proposals/proposals.jsonl" }}
/>
);
}

View file

@ -0,0 +1,10 @@
import { EmptyState } from "../design/components/states/EmptyState";
export function ChatRoutePlaceholder() {
return (
<EmptyState
statement="Chat — no data loaded yet."
nextAction={{ kind: "cli", command: "core chat" }}
/>
);
}

View file

@ -0,0 +1,10 @@
import { EmptyState } from "../design/components/states/EmptyState";
export function EvalsRoutePlaceholder() {
return (
<EmptyState
statement="Evals — no data loaded yet."
nextAction={{ kind: "cli", command: "core eval --list" }}
/>
);
}

View file

@ -0,0 +1,10 @@
import { EmptyState } from "../design/components/states/EmptyState";
export function PacksRoutePlaceholder() {
return (
<EmptyState
statement="Packs — no data loaded yet."
nextAction={{ kind: "cli", command: "core pack list" }}
/>
);
}

View file

@ -0,0 +1,10 @@
import { EmptyState } from "../design/components/states/EmptyState";
export function ProposalsRoutePlaceholder() {
return (
<EmptyState
statement="Proposals — no data loaded yet."
nextAction={{ kind: "cli", command: "core teaching hitl-queue list" }}
/>
);
}

View file

@ -0,0 +1,10 @@
import { EmptyState } from "../design/components/states/EmptyState";
export function ReplayRoutePlaceholder() {
return (
<EmptyState
statement="Replay — no data loaded yet."
nextAction="Pending W-031"
/>
);
}

View file

@ -0,0 +1,10 @@
import { EmptyState } from "../design/components/states/EmptyState";
export function RunsRoutePlaceholder() {
return (
<EmptyState
statement="Runs — no data loaded yet."
nextAction="Pending W-030"
/>
);
}

View file

@ -0,0 +1,10 @@
import { EmptyState } from "../design/components/states/EmptyState";
export function SettingsRoutePlaceholder() {
return (
<EmptyState
statement="Settings — no data loaded yet."
nextAction={{ kind: "cli", command: "docs/runtime_contracts.md" }}
/>
);
}

View file

@ -0,0 +1,10 @@
import { EmptyState } from "../design/components/states/EmptyState";
export function TraceRoutePlaceholder() {
return (
<EmptyState
statement="Trace — no data loaded yet."
nextAction={{ kind: "cli", command: "core trace <prompt>" }}
/>
);
}

View file

@ -0,0 +1,10 @@
import { EmptyState } from "../design/components/states/EmptyState";
export function VaultRoutePlaceholder() {
return (
<EmptyState
statement="Vault — no data loaded yet."
nextAction="Pending W-029"
/>
);
}

View file

@ -0,0 +1,77 @@
/**
* Schema drift test: verifies that the TypeScript API types stay in sync with
* the Python dataclasses in workbench/schemas.py.
*
* Workflow:
* 1. Run `uv run python scripts/dump-api-schemas.py` to regenerate the snapshot.
* 2. If the snapshot changes, update this test (and api.ts) to match.
* 3. CI runs the snapshot diff to catch silent field additions on the Python side.
*/
import { describe, it, expect } from "vitest";
import snapshot from "../../api-schema-snapshot.json";
// PYTHON_FIELDS mirrors the expected snapshot structure for each dataclass.
// If dump-api-schemas.py produces different fields, tests below will fail,
// indicating TypeScript types need updating.
const EXPECTED_PYTHON_FIELDS: Record<string, string[]> = {
RuntimeStatus: [
"backend",
"git_revision",
"engine_state_present",
"checkpoint_revision",
"revision_warning",
"active_session_id",
"mutation_mode",
],
ArtifactRef: ["artifact_id", "kind", "path", "digest", "created_at"],
// ArtifactDetail inherits from ArtifactRef in Python; snapshot only shows own fields.
ArtifactDetail: ["content_type", "content"],
ProposalSummary: [
"proposal_id",
"state",
"source_kind",
"replay_equivalent",
"created_at",
"downstream_effect",
],
// ProposalDetail inherits from ProposalSummary in Python; snapshot only shows own fields.
ProposalDetail: [
"proposed_chain",
"replay_evidence",
"source",
"evidence",
"artifact_refs",
"suggested_cli",
],
EvalLaneSummary: ["lane", "versions", "read_only", "description"],
EvalRunResult: ["lane", "version", "split", "passed", "metrics", "cases", "source_digest"],
ReplayDivergence: ["path", "original", "replay", "severity"],
ReplayComparison: [
"artifact_id",
"original_hash",
"replay_hash",
"equivalent",
"divergences",
],
};
describe("api-schema-snapshot — Python ↔ TypeScript drift detection", () => {
it("snapshot file is present and has dataclasses key", () => {
expect(snapshot).toBeDefined();
expect(snapshot).toHaveProperty("dataclasses");
});
for (const [className, expectedFields] of Object.entries(EXPECTED_PYTHON_FIELDS)) {
it(`${className}: snapshot fields match expected TS mirror`, () => {
const dc = (snapshot as { dataclasses: Record<string, { fields: Record<string, string> }> })
.dataclasses[className];
expect(dc, `Dataclass ${className} missing from snapshot`).toBeDefined();
const snapshotFields = Object.keys(dc.fields);
for (const field of expectedFields) {
expect(snapshotFields, `Field ${field} missing from snapshot for ${className}`).toContain(
field,
);
}
});
}
});

View file

@ -0,0 +1,127 @@
// TypeScript mirror of workbench/schemas.py
// Field names are byte-identical to the Python dataclasses.
export type ErrorCode =
| "bad_request"
| "not_found"
| "unsupported"
| "read_error"
| "eval_failed"
| "runtime_unavailable";
export type Backend = "numpy" | "mlx" | "rust" | "unknown";
export type MutationMode = "read_only" | "runtime_turn";
export interface RuntimeStatus {
backend: Backend;
git_revision: string;
engine_state_present: boolean;
checkpoint_revision: string;
revision_warning: boolean;
active_session_id: string | null;
mutation_mode: MutationMode;
}
export type ArtifactKind =
| "trace"
| "eval_result"
| "proposal"
| "contemplation_report"
| "telemetry"
| "engine_state_manifest"
| "unknown";
export type ContentType = "json" | "jsonl" | "text" | "unknown";
export interface ArtifactRef {
artifact_id: string;
kind: ArtifactKind;
path: string;
digest: string | null;
created_at: string | null;
}
export interface ArtifactDetail extends ArtifactRef {
content_type: ContentType;
content: unknown;
}
export type ProposalState =
| "pending"
| "accepted"
| "rejected"
| "withdrawn"
| "unknown";
export type DownstreamEffect = "unknown" | "none" | "observed";
export interface ProposalSummary {
proposal_id: string;
state: ProposalState;
source_kind: string;
replay_equivalent: boolean | null;
created_at: string | null;
downstream_effect: DownstreamEffect;
}
export interface ProposalDetail extends ProposalSummary {
proposed_chain: unknown;
replay_evidence: unknown;
source: unknown;
evidence: unknown[];
artifact_refs: ArtifactRef[];
suggested_cli: string | null;
}
export interface EvalLaneSummary {
lane: string;
versions: string[];
read_only: boolean;
description: string | null;
}
export interface EvalRunResult {
lane: string;
version: string;
split: string;
passed: boolean | null;
metrics: Record<string, unknown>;
cases: unknown[];
source_digest: string | null;
}
export type ReplayDivergenceSeverity = "info" | "warning" | "failure";
export interface ReplayDivergence {
path: string;
original: unknown;
replay: unknown;
severity: ReplayDivergenceSeverity;
}
export interface ReplayComparison {
artifact_id: string;
original_hash: string | null;
replay_hash: string | null;
equivalent: boolean;
divergences: ReplayDivergence[];
}
// API envelope types
export interface ApiOk<T> {
ok: true;
generated_at: string;
data: T;
}
export interface ApiError {
ok: false;
generated_at: string;
error: {
code: ErrorCode;
message: string;
detail?: unknown;
};
}
export type ApiResponse<T> = ApiOk<T> | ApiError;

View file

@ -3,7 +3,7 @@
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"types": ["vitest/globals"],
"types": ["vitest/globals", "vite/client"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,