From cdead696edc8339dcbd4bc4dab44c746a58feb5c Mon Sep 17 00:00:00 2001 From: Shay Date: Tue, 26 May 2026 12:09:12 -0700 Subject: [PATCH] =?UTF-8?q?feat(W-027):=20Workbench=20frontend=20shell=20?= =?UTF-8?q?=E2=80=94=20five-region=20grid=20+=20ten=20empty=20routes=20+?= =?UTF-8?q?=20live=20StatusFooter=20(ADR-0160=20/=20ADR-0162)=20(#299)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/workbench/README.md | 36 ++++ scripts/dump-api-schemas.py | 82 +++++++++ workbench-ui/README.md | 61 +++++++ workbench-ui/api-schema-snapshot.json | 86 +++++++++ workbench-ui/package.json | 2 + workbench-ui/pnpm-lock.yaml | 52 ++++++ workbench-ui/src/api/client.test.ts | 70 ++++++++ workbench-ui/src/api/client.ts | 29 +++ workbench-ui/src/api/queries.ts | 80 +++++++++ workbench-ui/src/app/ApiErrorBoundary.tsx | 41 +++++ workbench-ui/src/app/App.tsx | 40 +++++ workbench-ui/src/app/LeftNav.tsx | 41 +++++ workbench-ui/src/app/RightInspector.tsx | 12 ++ workbench-ui/src/app/Shell.error.test.tsx | 99 +++++++++++ workbench-ui/src/app/Shell.test.tsx | 168 ++++++++++++++++++ workbench-ui/src/app/Shell.tsx | 52 ++++++ workbench-ui/src/app/StatusFooter.tsx | 91 ++++++++++ workbench-ui/src/app/TopBar.tsx | 84 +++++++++ .../CommandPalette.keyboard.test.tsx | 120 +++++++++++++ .../components/primitives/CommandPalette.tsx | 166 ++++++++++++++--- .../design/components/states/EmptyState.tsx | 35 +++- .../design/components/states/states.test.tsx | 27 ++- workbench-ui/src/main.tsx | 6 +- .../src/routes/AuditRoutePlaceholder.tsx | 10 ++ .../src/routes/ChatRoutePlaceholder.tsx | 10 ++ .../src/routes/EvalsRoutePlaceholder.tsx | 10 ++ .../src/routes/PacksRoutePlaceholder.tsx | 10 ++ .../src/routes/ProposalsRoutePlaceholder.tsx | 10 ++ .../src/routes/ReplayRoutePlaceholder.tsx | 10 ++ .../src/routes/RunsRoutePlaceholder.tsx | 10 ++ .../src/routes/SettingsRoutePlaceholder.tsx | 10 ++ .../src/routes/TraceRoutePlaceholder.tsx | 10 ++ .../src/routes/VaultRoutePlaceholder.tsx | 10 ++ workbench-ui/src/types/api.test.ts | 77 ++++++++ workbench-ui/src/types/api.ts | 127 +++++++++++++ workbench-ui/tsconfig.json | 2 +- 36 files changed, 1754 insertions(+), 32 deletions(-) create mode 100644 scripts/dump-api-schemas.py create mode 100644 workbench-ui/README.md create mode 100644 workbench-ui/api-schema-snapshot.json create mode 100644 workbench-ui/src/api/client.test.ts create mode 100644 workbench-ui/src/api/client.ts create mode 100644 workbench-ui/src/api/queries.ts create mode 100644 workbench-ui/src/app/ApiErrorBoundary.tsx create mode 100644 workbench-ui/src/app/App.tsx create mode 100644 workbench-ui/src/app/LeftNav.tsx create mode 100644 workbench-ui/src/app/RightInspector.tsx create mode 100644 workbench-ui/src/app/Shell.error.test.tsx create mode 100644 workbench-ui/src/app/Shell.test.tsx create mode 100644 workbench-ui/src/app/Shell.tsx create mode 100644 workbench-ui/src/app/StatusFooter.tsx create mode 100644 workbench-ui/src/app/TopBar.tsx create mode 100644 workbench-ui/src/design/components/primitives/CommandPalette.keyboard.test.tsx create mode 100644 workbench-ui/src/routes/AuditRoutePlaceholder.tsx create mode 100644 workbench-ui/src/routes/ChatRoutePlaceholder.tsx create mode 100644 workbench-ui/src/routes/EvalsRoutePlaceholder.tsx create mode 100644 workbench-ui/src/routes/PacksRoutePlaceholder.tsx create mode 100644 workbench-ui/src/routes/ProposalsRoutePlaceholder.tsx create mode 100644 workbench-ui/src/routes/ReplayRoutePlaceholder.tsx create mode 100644 workbench-ui/src/routes/RunsRoutePlaceholder.tsx create mode 100644 workbench-ui/src/routes/SettingsRoutePlaceholder.tsx create mode 100644 workbench-ui/src/routes/TraceRoutePlaceholder.tsx create mode 100644 workbench-ui/src/routes/VaultRoutePlaceholder.tsx create mode 100644 workbench-ui/src/types/api.test.ts create mode 100644 workbench-ui/src/types/api.ts diff --git a/docs/workbench/README.md b/docs/workbench/README.md index 406b2c0a..7193f473 100644 --- a/docs/workbench/README.md +++ b/docs/workbench/README.md @@ -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 +``` diff --git a/scripts/dump-api-schemas.py b/scripts/dump-api-schemas.py new file mode 100644 index 00000000..803fb424 --- /dev/null +++ b/scripts/dump-api-schemas.py @@ -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() diff --git a/workbench-ui/README.md b/workbench-ui/README.md new file mode 100644 index 00000000..543fe404 --- /dev/null +++ b/workbench-ui/README.md @@ -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 diff --git a/workbench-ui/api-schema-snapshot.json b/workbench-ui/api-schema-snapshot.json new file mode 100644 index 00000000..29d0499a --- /dev/null +++ b/workbench-ui/api-schema-snapshot.json @@ -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]" + } + } + } +} diff --git a/workbench-ui/package.json b/workbench-ui/package.json index 11b43eb7..1a193791 100644 --- a/workbench-ui/package.json +++ b/workbench-ui/package.json @@ -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": { diff --git a/workbench-ui/pnpm-lock.yaml b/workbench-ui/pnpm-lock.yaml index 2acb0a13..1f625a9e 100644 --- a/workbench-ui/pnpm-lock.yaml +++ b/workbench-ui/pnpm-lock.yaml @@ -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 diff --git a/workbench-ui/src/api/client.test.ts b/workbench-ui/src/api/client.test.ts new file mode 100644 index 00000000..318039e7 --- /dev/null +++ b/workbench-ui/src/api/client.test.ts @@ -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(() => {}); + }); +}); diff --git a/workbench-ui/src/api/client.ts b/workbench-ui/src/api/client.ts new file mode 100644 index 00000000..924b9e3f --- /dev/null +++ b/workbench-ui/src/api/client.ts @@ -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(path: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + const res = await fetch(`${API_URL}${path}`, { signal: controller.signal }); + const json: ApiResponse = await res.json(); + if (!json.ok) { + throw new WorkbenchApiError(json.error.code, json.error.message); + } + return json.data; + } finally { + clearTimeout(timeout); + } +} diff --git a/workbench-ui/src/api/queries.ts b/workbench-ui/src/api/queries.ts new file mode 100644 index 00000000..2a00b503 --- /dev/null +++ b/workbench-ui/src/api/queries.ts @@ -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({ + queryKey: ["api", "runtime-status"], + queryFn: () => apiFetch("/runtime/status"), + refetchInterval: 30_000, + }); +} + +export function useArtifacts(limit?: number) { + const params = limit !== undefined ? `?limit=${limit}` : ""; + return useQuery({ + queryKey: ["api", "artifacts", limit], + queryFn: () => apiFetch(`/artifacts${params}`), + }); +} + +export function useArtifact(id: string) { + return useQuery({ + queryKey: ["api", "artifact", id], + queryFn: () => apiFetch(`/artifacts/${id}`), + enabled: !!id, + }); +} + +export function useProposals() { + return useQuery({ + queryKey: ["api", "proposals"], + queryFn: () => apiFetch("/proposals"), + }); +} + +export function useProposal(id: string) { + return useQuery({ + queryKey: ["api", "proposal", id], + queryFn: () => apiFetch(`/proposals/${id}`), + enabled: !!id, + }); +} + +export function useEvalLanes() { + return useQuery({ + queryKey: ["api", "evals"], + queryFn: () => apiFetch("/evals"), + }); +} + +export function useEvalLane(name: string) { + return useQuery({ + queryKey: ["api", "eval", name], + queryFn: () => apiFetch(`/evals/${name}`), + enabled: !!name, + }); +} diff --git a/workbench-ui/src/app/ApiErrorBoundary.tsx b/workbench-ui/src/app/ApiErrorBoundary.tsx new file mode 100644 index 00000000..384b2036 --- /dev/null +++ b/workbench-ui/src/app/ApiErrorBoundary.tsx @@ -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 { + 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 ( + + ); + } + return this.props.children; + } +} diff --git a/workbench-ui/src/app/App.tsx b/workbench-ui/src/app/App.tsx new file mode 100644 index 00000000..7d4aa1d3 --- /dev/null +++ b/workbench-ui/src/app/App.tsx @@ -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 ( + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + + + ); +} diff --git a/workbench-ui/src/app/LeftNav.tsx b/workbench-ui/src/app/LeftNav.tsx new file mode 100644 index 00000000..e56820db --- /dev/null +++ b/workbench-ui/src/app/LeftNav.tsx @@ -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 ( + + ); +} diff --git a/workbench-ui/src/app/RightInspector.tsx b/workbench-ui/src/app/RightInspector.tsx new file mode 100644 index 00000000..75c61f54 --- /dev/null +++ b/workbench-ui/src/app/RightInspector.tsx @@ -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 ( + + ); +} diff --git a/workbench-ui/src/app/Shell.error.test.tsx b/workbench-ui/src/app/Shell.error.test.tsx new file mode 100644 index 00000000..bfa24a89 --- /dev/null +++ b/workbench-ui/src/app/Shell.error.test.tsx @@ -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(); + 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( + + + + }> + } /> + + + + , + ); +} + +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( + + + + }> + } /> + + + + , + ); + + 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(); + }); +}); diff --git a/workbench-ui/src/app/Shell.test.tsx b/workbench-ui/src/app/Shell.test.tsx new file mode 100644 index 00000000..f6e4f32a --- /dev/null +++ b/workbench-ui/src/app/Shell.test.tsx @@ -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(); + 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( + + + + }> + } /> + } /> + + + + , + ); +} + +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"); + }); +}); diff --git a/workbench-ui/src/app/Shell.tsx b/workbench-ui/src/app/Shell.tsx new file mode 100644 index 00000000..188b5810 --- /dev/null +++ b/workbench-ui/src/app/Shell.tsx @@ -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 ( +
+
+ +
+ +
+ +
+ +
+ + + +
+ + {!inspectorCollapsed && ( +
+ +
+ )} + +
+ +
+
+ ); +} diff --git a/workbench-ui/src/app/StatusFooter.tsx b/workbench-ui/src/app/StatusFooter.tsx new file mode 100644 index 00000000..ffb01539 --- /dev/null +++ b/workbench-ui/src/app/StatusFooter.tsx @@ -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 ( +
+ Status unavailable +
+ ); + } + + 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" ? ( + + Read Only + + ) : ( + + Runtime Turn + + ); + + return ( +
+ {mutationModeEl} + + + +
+ + {revisionExpanded && ( +

+ Engine state was written at {checkpoint_revision}; current revision is {git_revision}. + ADR-0157 / ADR-0158 govern this behavior. +

+ )} +
+
+ ); +} diff --git a/workbench-ui/src/app/TopBar.tsx b/workbench-ui/src/app/TopBar.tsx new file mode 100644 index 00000000..8b6f986c --- /dev/null +++ b/workbench-ui/src/app/TopBar.tsx @@ -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) { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + openPalette(); + } + } + + const connectionPill = (() => { + if (isLoading) { + return ( + + Connecting… + + ); + } + if (isError) { + return ( + + API: unreachable + + ); + } + return ( + + API: connected + + ); + })(); + + return ( +
+ {/* Wordmark */} + + CORE Workbench + + + {/* Search / Command Palette trigger */} +
+ +
+ + {/* Connection pill */} +
{connectionPill}
+ + +
+ ); +} diff --git a/workbench-ui/src/design/components/primitives/CommandPalette.keyboard.test.tsx b/workbench-ui/src/design/components/primitives/CommandPalette.keyboard.test.tsx new file mode 100644 index 00000000..36e558c9 --- /dev/null +++ b/workbench-ui/src/design/components/primitives/CommandPalette.keyboard.test.tsx @@ -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 ( + + + + + ); +} + +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(); + // 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(); + 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(); + + 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(); + + // 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(); + + 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(); + + 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(); + + const input = screen.getByRole("textbox", { name: "Search commands" }); + await user.type(input, "zzznomatch"); + + expect(screen.getByText("No commands match.")).toBeInTheDocument(); + }); +}); diff --git a/workbench-ui/src/design/components/primitives/CommandPalette.tsx b/workbench-ui/src/design/components/primitives/CommandPalette.tsx index bb2c91c6..f2420694 100644 --- a/workbench-ui/src/design/components/primitives/CommandPalette.tsx +++ b/workbench-ui/src/design/components/primitives/CommandPalette.tsx @@ -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 ; +} + +// 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 ; +} + +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(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 ( + + + + + + + Command Palette + + + Search and activate commands. Use arrow keys to navigate, Enter to activate, Escape to close. + + setQuery(e.target.value)} + aria-label="Search commands" + /> + {filtered.length === 0 ? ( +

No commands match.

+ ) : ( +
    + {filtered.map((cmd, i) => ( +
  • + +
  • + ))} +
+ )} +
+
+
+ ); +} export function CommandPalette({ open, @@ -9,24 +147,10 @@ export function CommandPalette({ open: boolean; onOpenChange: (open: boolean) => void; }) { - return ( - - - - - - - Command Palette - - - Empty command palette stub for the Branch 1 keyboard contract. - - - - - - ); + const inRouter = useInRouterContext(); + + if (inRouter) { + return ; + } + return ; } diff --git a/workbench-ui/src/design/components/states/EmptyState.tsx b/workbench-ui/src/design/components/states/EmptyState.tsx index 3a299fcc..c9906d99 100644 --- a/workbench-ui/src/design/components/states/EmptyState.tsx +++ b/workbench-ui/src/design/components/states/EmptyState.tsx @@ -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 (

{statement}

- + {typeof nextAction === "string" ? ( + + ) : ( +
+ + {nextAction.command} + + +
+ )}
); } diff --git a/workbench-ui/src/design/components/states/states.test.tsx b/workbench-ui/src/design/components/states/states.test.tsx index 1c41efd8..484e8e15 100644 --- a/workbench-ui/src/design/components/states/states.test.tsx +++ b/workbench-ui/src/design/components/states/states.test.tsx @@ -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( + , + ); + + 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( : ; -} +import { App } from "./app/App"; ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/workbench-ui/src/routes/AuditRoutePlaceholder.tsx b/workbench-ui/src/routes/AuditRoutePlaceholder.tsx new file mode 100644 index 00000000..a2a9fdc1 --- /dev/null +++ b/workbench-ui/src/routes/AuditRoutePlaceholder.tsx @@ -0,0 +1,10 @@ +import { EmptyState } from "../design/components/states/EmptyState"; + +export function AuditRoutePlaceholder() { + return ( + + ); +} diff --git a/workbench-ui/src/routes/ChatRoutePlaceholder.tsx b/workbench-ui/src/routes/ChatRoutePlaceholder.tsx new file mode 100644 index 00000000..ae3074b0 --- /dev/null +++ b/workbench-ui/src/routes/ChatRoutePlaceholder.tsx @@ -0,0 +1,10 @@ +import { EmptyState } from "../design/components/states/EmptyState"; + +export function ChatRoutePlaceholder() { + return ( + + ); +} diff --git a/workbench-ui/src/routes/EvalsRoutePlaceholder.tsx b/workbench-ui/src/routes/EvalsRoutePlaceholder.tsx new file mode 100644 index 00000000..1ccc2476 --- /dev/null +++ b/workbench-ui/src/routes/EvalsRoutePlaceholder.tsx @@ -0,0 +1,10 @@ +import { EmptyState } from "../design/components/states/EmptyState"; + +export function EvalsRoutePlaceholder() { + return ( + + ); +} diff --git a/workbench-ui/src/routes/PacksRoutePlaceholder.tsx b/workbench-ui/src/routes/PacksRoutePlaceholder.tsx new file mode 100644 index 00000000..ba028e24 --- /dev/null +++ b/workbench-ui/src/routes/PacksRoutePlaceholder.tsx @@ -0,0 +1,10 @@ +import { EmptyState } from "../design/components/states/EmptyState"; + +export function PacksRoutePlaceholder() { + return ( + + ); +} diff --git a/workbench-ui/src/routes/ProposalsRoutePlaceholder.tsx b/workbench-ui/src/routes/ProposalsRoutePlaceholder.tsx new file mode 100644 index 00000000..5273ad6b --- /dev/null +++ b/workbench-ui/src/routes/ProposalsRoutePlaceholder.tsx @@ -0,0 +1,10 @@ +import { EmptyState } from "../design/components/states/EmptyState"; + +export function ProposalsRoutePlaceholder() { + return ( + + ); +} diff --git a/workbench-ui/src/routes/ReplayRoutePlaceholder.tsx b/workbench-ui/src/routes/ReplayRoutePlaceholder.tsx new file mode 100644 index 00000000..77663892 --- /dev/null +++ b/workbench-ui/src/routes/ReplayRoutePlaceholder.tsx @@ -0,0 +1,10 @@ +import { EmptyState } from "../design/components/states/EmptyState"; + +export function ReplayRoutePlaceholder() { + return ( + + ); +} diff --git a/workbench-ui/src/routes/RunsRoutePlaceholder.tsx b/workbench-ui/src/routes/RunsRoutePlaceholder.tsx new file mode 100644 index 00000000..505ad9bf --- /dev/null +++ b/workbench-ui/src/routes/RunsRoutePlaceholder.tsx @@ -0,0 +1,10 @@ +import { EmptyState } from "../design/components/states/EmptyState"; + +export function RunsRoutePlaceholder() { + return ( + + ); +} diff --git a/workbench-ui/src/routes/SettingsRoutePlaceholder.tsx b/workbench-ui/src/routes/SettingsRoutePlaceholder.tsx new file mode 100644 index 00000000..3ba9e87a --- /dev/null +++ b/workbench-ui/src/routes/SettingsRoutePlaceholder.tsx @@ -0,0 +1,10 @@ +import { EmptyState } from "../design/components/states/EmptyState"; + +export function SettingsRoutePlaceholder() { + return ( + + ); +} diff --git a/workbench-ui/src/routes/TraceRoutePlaceholder.tsx b/workbench-ui/src/routes/TraceRoutePlaceholder.tsx new file mode 100644 index 00000000..2a9a7fa0 --- /dev/null +++ b/workbench-ui/src/routes/TraceRoutePlaceholder.tsx @@ -0,0 +1,10 @@ +import { EmptyState } from "../design/components/states/EmptyState"; + +export function TraceRoutePlaceholder() { + return ( + " }} + /> + ); +} diff --git a/workbench-ui/src/routes/VaultRoutePlaceholder.tsx b/workbench-ui/src/routes/VaultRoutePlaceholder.tsx new file mode 100644 index 00000000..68e300a2 --- /dev/null +++ b/workbench-ui/src/routes/VaultRoutePlaceholder.tsx @@ -0,0 +1,10 @@ +import { EmptyState } from "../design/components/states/EmptyState"; + +export function VaultRoutePlaceholder() { + return ( + + ); +} diff --git a/workbench-ui/src/types/api.test.ts b/workbench-ui/src/types/api.test.ts new file mode 100644 index 00000000..326b4e07 --- /dev/null +++ b/workbench-ui/src/types/api.test.ts @@ -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 = { + 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 }> }) + .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, + ); + } + }); + } +}); diff --git a/workbench-ui/src/types/api.ts b/workbench-ui/src/types/api.ts new file mode 100644 index 00000000..fb1c79c1 --- /dev/null +++ b/workbench-ui/src/types/api.ts @@ -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; + 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 { + 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 = ApiOk | ApiError; diff --git a/workbench-ui/tsconfig.json b/workbench-ui/tsconfig.json index 39136000..c96af3d2 100644 --- a/workbench-ui/tsconfig.json +++ b/workbench-ui/tsconfig.json @@ -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,