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:
parent
1bff5689db
commit
cdead696ed
36 changed files with 1754 additions and 32 deletions
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
82
scripts/dump-api-schemas.py
Normal file
82
scripts/dump-api-schemas.py
Normal 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
61
workbench-ui/README.md
Normal 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
|
||||
86
workbench-ui/api-schema-snapshot.json
Normal file
86
workbench-ui/api-schema-snapshot.json
Normal 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]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
70
workbench-ui/src/api/client.test.ts
Normal file
70
workbench-ui/src/api/client.test.ts
Normal 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(() => {});
|
||||
});
|
||||
});
|
||||
29
workbench-ui/src/api/client.ts
Normal file
29
workbench-ui/src/api/client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
80
workbench-ui/src/api/queries.ts
Normal file
80
workbench-ui/src/api/queries.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
41
workbench-ui/src/app/ApiErrorBoundary.tsx
Normal file
41
workbench-ui/src/app/ApiErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
40
workbench-ui/src/app/App.tsx
Normal file
40
workbench-ui/src/app/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
workbench-ui/src/app/LeftNav.tsx
Normal file
41
workbench-ui/src/app/LeftNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
workbench-ui/src/app/RightInspector.tsx
Normal file
12
workbench-ui/src/app/RightInspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
workbench-ui/src/app/Shell.error.test.tsx
Normal file
99
workbench-ui/src/app/Shell.error.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
168
workbench-ui/src/app/Shell.test.tsx
Normal file
168
workbench-ui/src/app/Shell.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
52
workbench-ui/src/app/Shell.tsx
Normal file
52
workbench-ui/src/app/Shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
workbench-ui/src/app/StatusFooter.tsx
Normal file
91
workbench-ui/src/app/StatusFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
workbench-ui/src/app/TopBar.tsx
Normal file
84
workbench-ui/src/app/TopBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
10
workbench-ui/src/routes/AuditRoutePlaceholder.tsx
Normal file
10
workbench-ui/src/routes/AuditRoutePlaceholder.tsx
Normal 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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
workbench-ui/src/routes/ChatRoutePlaceholder.tsx
Normal file
10
workbench-ui/src/routes/ChatRoutePlaceholder.tsx
Normal 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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
workbench-ui/src/routes/EvalsRoutePlaceholder.tsx
Normal file
10
workbench-ui/src/routes/EvalsRoutePlaceholder.tsx
Normal 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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
workbench-ui/src/routes/PacksRoutePlaceholder.tsx
Normal file
10
workbench-ui/src/routes/PacksRoutePlaceholder.tsx
Normal 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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
workbench-ui/src/routes/ProposalsRoutePlaceholder.tsx
Normal file
10
workbench-ui/src/routes/ProposalsRoutePlaceholder.tsx
Normal 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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
workbench-ui/src/routes/ReplayRoutePlaceholder.tsx
Normal file
10
workbench-ui/src/routes/ReplayRoutePlaceholder.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
workbench-ui/src/routes/RunsRoutePlaceholder.tsx
Normal file
10
workbench-ui/src/routes/RunsRoutePlaceholder.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
workbench-ui/src/routes/SettingsRoutePlaceholder.tsx
Normal file
10
workbench-ui/src/routes/SettingsRoutePlaceholder.tsx
Normal 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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
workbench-ui/src/routes/TraceRoutePlaceholder.tsx
Normal file
10
workbench-ui/src/routes/TraceRoutePlaceholder.tsx
Normal 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>" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
workbench-ui/src/routes/VaultRoutePlaceholder.tsx
Normal file
10
workbench-ui/src/routes/VaultRoutePlaceholder.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
77
workbench-ui/src/types/api.test.ts
Normal file
77
workbench-ui/src/types/api.test.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
127
workbench-ui/src/types/api.ts
Normal file
127
workbench-ui/src/types/api.ts
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue