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:
|
not:
|
||||||
|
|
||||||
> “Cool chatbot.”
|
> “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-dialog": "1.1.15",
|
||||||
"@radix-ui/react-popover": "1.1.15",
|
"@radix-ui/react-popover": "1.1.15",
|
||||||
"@radix-ui/react-slot": "1.2.3",
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@tanstack/react-query": "5",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"lucide-react": "0.468.0",
|
"lucide-react": "0.468.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
|
"react-router-dom": "6",
|
||||||
"tailwind-merge": "2.6.0"
|
"tailwind-merge": "2.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ importers:
|
||||||
'@radix-ui/react-slot':
|
'@radix-ui/react-slot':
|
||||||
specifier: 1.2.3
|
specifier: 1.2.3
|
||||||
version: 1.2.3(@types/react@18.3.17)(react@18.3.1)
|
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:
|
class-variance-authority:
|
||||||
specifier: 0.7.1
|
specifier: 0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
|
|
@ -32,6 +35,9 @@ importers:
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: 18.3.1
|
specifier: 18.3.1
|
||||||
version: 18.3.1(react@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:
|
tailwind-merge:
|
||||||
specifier: 2.6.0
|
specifier: 2.6.0
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
|
|
@ -734,6 +740,10 @@ packages:
|
||||||
'@radix-ui/rect@1.1.1':
|
'@radix-ui/rect@1.1.1':
|
||||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
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':
|
'@rollup/rollup-android-arm-eabi@4.60.4':
|
||||||
resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==}
|
resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
|
|
@ -859,6 +869,14 @@ packages:
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
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':
|
'@testing-library/dom@10.4.1':
|
||||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -1436,6 +1454,19 @@ packages:
|
||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
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:
|
react-style-singleton@2.2.3:
|
||||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -2207,6 +2238,8 @@ snapshots:
|
||||||
|
|
||||||
'@radix-ui/rect@1.1.1': {}
|
'@radix-ui/rect@1.1.1': {}
|
||||||
|
|
||||||
|
'@remix-run/router@1.23.2': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.60.4':
|
'@rollup/rollup-android-arm-eabi@4.60.4':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -2282,6 +2315,13 @@ snapshots:
|
||||||
'@rollup/rollup-win32-x64-msvc@4.60.4':
|
'@rollup/rollup-win32-x64-msvc@4.60.4':
|
||||||
optional: true
|
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':
|
'@testing-library/dom@10.4.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
|
|
@ -2837,6 +2877,18 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 18.3.17
|
'@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):
|
react-style-singleton@2.2.3(@types/react@18.3.17)(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
get-nonce: 1.0.1
|
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 * as Dialog from "@radix-ui/react-dialog";
|
||||||
import { Search } from "lucide-react";
|
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({
|
export function CommandPalette({
|
||||||
open,
|
open,
|
||||||
|
|
@ -9,24 +147,10 @@ export function CommandPalette({
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
const inRouter = useInRouterContext();
|
||||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
|
||||||
<Dialog.Portal>
|
if (inRouter) {
|
||||||
<Dialog.Overlay className="fixed inset-0 bg-black/55" data-testid="overlay" />
|
return <RouterCommandPalette open={open} onOpenChange={onOpenChange} />;
|
||||||
<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">
|
return <FallbackCommandPalette open={open} onOpenChange={onOpenChange} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,45 @@
|
||||||
import { Button } from "../primitives/Button";
|
import { Button } from "../primitives/Button";
|
||||||
|
|
||||||
|
type CliAction = { kind: "cli"; command: string };
|
||||||
|
type NextAction = string | CliAction;
|
||||||
|
|
||||||
export function EmptyState({
|
export function EmptyState({
|
||||||
statement,
|
statement,
|
||||||
nextAction,
|
nextAction,
|
||||||
}: {
|
}: {
|
||||||
statement: string;
|
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 (
|
return (
|
||||||
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
|
<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>
|
<p className="m-0 text-sm text-[var(--color-text-primary)]">{statement}</p>
|
||||||
<Button className="mt-3" variant="quiet" type="button">
|
{typeof nextAction === "string" ? (
|
||||||
{nextAction}
|
<Button className="mt-3" variant="quiet" type="button">
|
||||||
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { render, screen } from "@testing-library/react";
|
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 { EmptyState } from "./EmptyState";
|
||||||
import { ErrorState } from "./ErrorState";
|
import { ErrorState } from "./ErrorState";
|
||||||
import { LoadingState } from "./LoadingState";
|
import { LoadingState } from "./LoadingState";
|
||||||
|
|
@ -11,6 +12,30 @@ describe("state components", () => {
|
||||||
expect(screen.getByRole("button", { name: "Select a trace." })).toBeInTheDocument();
|
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", () => {
|
it("renders every error-state contract field", () => {
|
||||||
render(
|
render(
|
||||||
<ErrorState
|
<ErrorState
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { PreviewPage } from "./preview/PreviewPage";
|
import { App } from "./app/App";
|
||||||
|
|
||||||
function App() {
|
|
||||||
return window.location.pathname === "/preview" ? <PreviewPage /> : <PreviewPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<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",
|
"target": "ES2023",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
||||||
"types": ["vitest/globals"],
|
"types": ["vitest/globals", "vite/client"],
|
||||||
"allowJs": false,
|
"allowJs": false,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue