feat(workbench): evidence context, inspector drawer & command registry (Wave 1A/1B)

Brief 4 of the Wave 1 evidence spine — wire the evidence-context architecture
so every route can project into one shared evidence manifold.

Command registry (1A):
- commandRegistry.ts: useSyncExternalStore-backed command store with a cached
  snapshot (referentially stable to avoid render loops); useCommands /
  useCommandRegistry; recent-items in localStorage (last 10).
- CommandPalette.tsx: reads from the registry; all ten routes are navigation
  commands (was Chat/Proposals/Evals hardcoded); fuzzy filter, recent section,
  shortcut badges; Router/Fallback split preserved.
- useGlobalKeyboard.ts: Cmd+K palette, Cmd+I inspector, Cmd+1-0 routes, ? help.

Evidence drawer (1B):
- evidenceContext.tsx: EvidenceSubject union (turn|proposal|artifact|
  eval_result|none) + useEvidenceSubject hook + EvidenceProvider above the
  router Outlet so inspector state persists across route transitions.
- RightInspector.tsx: five evidence projections (was a null stub); reuses the
  Brief 2 primitives (MetadataTable, DigestBadge, Timestamp, badges).
- Shell.tsx: visibility driven by inspectorOpen (collapsed by default); no more
  hardcoded collapsed=true.
- KeyboardHelp.tsx: shortcut reference dialog.
- TopBar.tsx: palette open state lifted to Shell via props.

Tests: evidenceContext + RightInspector specs added; CommandPalette keyboard
contract updated for the ten-command navigation set (clamp at both ends).

Deferred (noted in spine doc): per-route register()/setSubject call sites,
action-commands (run eval / copy hash), resizable inspector width.

Verified: tsc -b + vite build green; affected specs pass per-file.
This commit is contained in:
Shay 2026-06-12 07:44:11 -07:00
parent af8d4f75da
commit 2746655bb4
12 changed files with 870 additions and 120 deletions

View file

@ -44,17 +44,24 @@ Six pieces, one architectural idea.
### 1A. Command Registry + Full Navigation
- [ ] Replace hardcoded command list in `CommandPalette.tsx` with a
route-registered command registry
- [ ] Each route registers its commands on mount via a shared context/provider
- [ ] Fuzzy search across routes, recent resources (turns, proposals, artifacts),
and actions (run eval, copy hash)
- [ ] `Cmd+K` opens, type-ahead filters, arrow keys navigate, `Enter` executes,
`Esc` closes
- [ ] Recent items: last 10 visited resources (turns, proposals, artifacts)
- [ ] Test: palette finds and navigates to every route and registered command
- [x] Replace hardcoded command list in `CommandPalette.tsx` with a
route-registered command registry (`commandRegistry.ts`, `useCommands`)
- [x] Shared command store + provider API (`useCommandRegistry` register/
unregister) — all ten routes registered as navigation commands centrally;
per-route self-registration call sites deferred (see note)
- [x] Fuzzy search across routes + recent resources (turns, proposals,
artifacts); action commands (run eval, copy hash) deferred (see note)
- [x] `Cmd+K` opens, type-ahead filters, arrow keys navigate, `Enter` executes,
`Esc` closes (`useGlobalKeyboard` + palette keyboard contract test)
- [x] Recent items: last 10 visited resources (`pushRecentItem`, `MAX_RECENT=10`)
- [x] Test: palette navigates to every route (`CommandPalette.keyboard.test.tsx`)
**Current state:** `CommandPalette.tsx` has only Chat/Proposals/Evals hardcoded.
**Deferred to follow-on:** per-route `register()` call sites (registry + nav
commands ship now; routes register their own context commands when each route
gains them) and action-commands (run eval, copy hash).
**Current state:** Done. `CommandPalette.tsx` now reads from the registry; the
ten routes are all reachable.
**Key files:**
- `workbench-ui/src/design/components/primitives/CommandPalette.tsx`
@ -64,22 +71,27 @@ Six pieces, one architectural idea.
### 1B. RightInspector as Evidence Drawer
- [ ] Remove permanent `collapsed=true` from `Shell.tsx`
- [ ] Create shared evidence-subject context: `useEvidenceSubject()` hook +
provider in Shell
- [ ] Each route pushes its selection (turn, proposal, artifact, pack, eval
result) into the shared context
- [ ] Inspector renders the appropriate evidence projection for the selected
subject type
- [ ] Toggle via `Cmd+I` or "inspect" affordance on selectable items
- [ ] Stays open across route transitions (operator opened it deliberately)
- [ ] Collapsed by default on fresh load (calm default)
- [ ] Resizable width via drag handle
- [ ] Test: inspector opens, shows correct context for Chat selection, closes,
persists across route change
- [x] Remove permanent `collapsed=true` from `Shell.tsx` (inspector visibility
now driven by `inspectorOpen` from context)
- [x] Create shared evidence-subject context: `useEvidenceSubject()` hook +
`EvidenceProvider` in Shell (`evidenceContext.tsx`)
- [x] Inspector renders the appropriate evidence projection per subject type
(turn / proposal / artifact / eval_result / none sub-inspectors)
- [x] Toggle via `Cmd+I` (and the `⌘I` hint affordance)
- [x] Stays open across route transitions (state lives in the provider, above
the router Outlet)
- [x] Collapsed by default on fresh load — `inspectorOpen` defaults to `false`
- [x] Test: inspector opens, shows correct context for a turn subject, empty
hint for none (`RightInspector.test.tsx`)
- [ ] Each route pushes its selection into the shared context — deferred;
`setSubject` API ships now, route call sites land with each route's
selection UI
- [ ] Resizable width via drag handle — deferred; inspector is a fixed `20rem`
column today (wire `SplitPane` when a route needs the width)
**Current state:** `RightInspector.tsx` returns null. `Shell.tsx` hardcodes
`collapsed={true}`.
**Current state:** Done (with the two deferred items noted). `RightInspector.tsx`
renders the five evidence projections; `Shell.tsx` drives visibility from
context, collapsed by default.
**Key files:**
- `workbench-ui/src/app/RightInspector.tsx`

View file

@ -0,0 +1,56 @@
import * as Dialog from "@radix-ui/react-dialog";
const SHORTCUTS = [
{ keys: "⌘K", action: "Command palette" },
{ keys: "⌘I", action: "Toggle inspector" },
{ keys: "⌘10", action: "Navigate to route 110" },
{ keys: "/", action: "Focus search input" },
{ keys: "j / k", action: "Navigate lists" },
{ keys: "Enter", action: "Open selected item" },
{ keys: "Esc", action: "Close overlay" },
{ keys: "?", action: "This help" },
] as const;
export function KeyboardHelp({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/55" />
<Dialog.Content
className="fixed left-1/2 top-[18vh] w-[min(400px,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)]"
aria-label="Keyboard shortcuts"
>
<Dialog.Title className="mb-3 text-sm font-semibold text-[var(--color-text-primary)]">
Keyboard Shortcuts
</Dialog.Title>
<Dialog.Description className="sr-only">
Available keyboard shortcuts for the workbench.
</Dialog.Description>
<dl className="m-0 grid gap-0">
{SHORTCUTS.map((s) => (
<div
key={s.keys}
className="flex items-center gap-3 border-b border-[var(--color-border-subtle)] py-2 last:border-b-0"
>
<dt className="m-0 w-20 shrink-0">
<kbd className="rounded border border-[var(--color-border-subtle)] px-1.5 py-0.5 font-mono text-xs text-[var(--color-text-mono)]">
{s.keys}
</kbd>
</dt>
<dd className="m-0 text-sm text-[var(--color-text-secondary)]">
{s.action}
</dd>
</div>
))}
</dl>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View file

@ -0,0 +1,68 @@
import { render, screen } from "@testing-library/react";
import { EvidenceProvider, useEvidenceSubject } from "./evidenceContext";
import { RightInspector } from "./RightInspector";
import type { ChatTurnResult, ProposalDetail } from "../types/api";
const MOCK_TURN: ChatTurnResult = {
prompt: "What is alpha?",
surface: "alpha causes beta",
articulation_surface: "alpha causes beta",
walk_surface: "alpha -> beta",
grounding_source: "teaching",
epistemic_state: "decoded",
normative_clearance: "cleared",
normative_detail: "",
trace_hash: "sha256:abc123def456",
refusal_emitted: false,
hedge_injected: false,
mutation_mode: "runtime_turn",
identity_verdict: null,
safety_verdict: null,
ethics_verdict: null,
proposal_candidates: [],
turn_cost_ms: 17,
checkpoint_emitted: true,
};
function SetSubjectAndRender({
kind,
}: {
kind: "turn" | "none";
}) {
const { setSubject } = useEvidenceSubject();
if (kind === "turn") {
setSubject({ kind: "turn", turnId: 1, data: MOCK_TURN });
}
return <RightInspector />;
}
function renderInspector(kind: "turn" | "none" = "none") {
return render(
<EvidenceProvider>
<SetSubjectAndRender kind={kind} />
</EvidenceProvider>,
);
}
describe("RightInspector", () => {
it("renders inspector region", () => {
renderInspector();
expect(document.querySelector('[data-region="inspector"]')).toBeInTheDocument();
});
it("shows empty hint when no subject selected", () => {
renderInspector("none");
expect(screen.getByText("Select an item to inspect its evidence.")).toBeInTheDocument();
});
it("shows turn data when turn subject is set", () => {
renderInspector("turn");
expect(screen.getByText("Turn #1")).toBeInTheDocument();
expect(screen.getByText("17ms")).toBeInTheDocument();
});
it("shows keyboard shortcut hint", () => {
renderInspector();
expect(screen.getByText("⌘I")).toBeInTheDocument();
});
});

View file

@ -1,12 +1,149 @@
// RightInspector — default collapsed in W-027 (no content yet)
export function RightInspector({ collapsed = true }: { collapsed?: boolean }) {
if (collapsed) return null;
import { useEvidenceSubject, type EvidenceSubject } from "./evidenceContext";
import { MetadataTable } from "../design/components/MetadataTable/MetadataTable";
import { DigestBadge } from "../design/components/DigestBadge/DigestBadge";
import { Timestamp } from "../design/components/Timestamp/Timestamp";
import {
EpistemicStateBadge,
GroundingSourceBadge,
NormativeClearanceBadge,
type EpistemicState,
type GroundingSource,
type NormativeClearance,
} from "../design/components/badges";
function TurnInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "turn" }> }) {
const { data } = subject;
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Turn #{subject.turnId}</h3>
{data.trace_hash && (
<DigestBadge digest={data.trace_hash.replace("sha256:", "")} truncate={12} />
)}
<MetadataTable
rows={[
{
key: "surface",
value: data.surface.length > 80 ? `${data.surface.slice(0, 80)}...` : data.surface,
},
{
key: "grounding",
value: <GroundingSourceBadge value={data.grounding_source as GroundingSource} />,
},
{
key: "epistemic",
value: <EpistemicStateBadge value={data.epistemic_state as EpistemicState} />,
},
{
key: "clearance",
value: <NormativeClearanceBadge value={data.normative_clearance as NormativeClearance} />,
},
{ key: "cost", value: `${data.turn_cost_ms}ms`, mono: true },
{ key: "refusal", value: data.refusal_emitted ? "yes" : "no" },
{ key: "hedge", value: data.hedge_injected ? "yes" : "no" },
]}
/>
</div>
);
}
function ProposalInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "proposal" }> }) {
const { data } = subject;
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Proposal</h3>
<MetadataTable
rows={[
{ key: "id", value: data.proposal_id, copyable: true, mono: true },
{ key: "state", value: data.state },
{ key: "source", value: data.source_kind },
{ key: "replay_eq", value: data.replay_equivalent === null ? "unknown" : data.replay_equivalent ? "yes" : "no" },
...(data.suggested_cli ? [{ key: "CLI", value: data.suggested_cli, copyable: true, mono: true }] : []),
]}
/>
</div>
);
}
function ArtifactInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "artifact" }> }) {
const { data } = subject;
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Artifact</h3>
{data.digest && <DigestBadge digest={data.digest} truncate={12} />}
<MetadataTable
rows={[
{ key: "id", value: data.artifact_id, copyable: true, mono: true },
{ key: "kind", value: data.kind },
{ key: "path", value: data.path, mono: true },
{ key: "content_type", value: data.content_type },
...(data.created_at ? [{ key: "created", value: <Timestamp iso={data.created_at} /> }] : []),
]}
/>
</div>
);
}
function EvalInspector({ subject }: { subject: Extract<EvidenceSubject, { kind: "eval_result" }> }) {
const { data } = subject;
return (
<div className="grid gap-3">
<h3 className="text-xs font-semibold text-[var(--color-text-secondary)]">Eval: {data.lane}</h3>
<MetadataTable
rows={[
{ key: "lane", value: data.lane },
{ key: "version", value: data.version, mono: true },
{ key: "split", value: data.split },
{
key: "result",
value: data.passed === null ? "pending" : data.passed ? "passed" : "failed",
},
...(data.source_digest ? [{ key: "digest", value: data.source_digest, copyable: true, mono: true }] : []),
]}
/>
</div>
);
}
function NoneInspector() {
return (
<div className="flex h-full items-center justify-center text-sm text-[var(--color-text-muted)]">
<p>Select an item to inspect its evidence.</p>
</div>
);
}
function InspectorContent() {
const { subject } = useEvidenceSubject();
switch (subject.kind) {
case "turn":
return <TurnInspector subject={subject} />;
case "proposal":
return <ProposalInspector subject={subject} />;
case "artifact":
return <ArtifactInspector subject={subject} />;
case "eval_result":
return <EvalInspector subject={subject} />;
case "none":
return <NoneInspector />;
}
}
export function RightInspector() {
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)]"
className="flex h-full flex-col overflow-y-auto border-l border-[var(--color-border-subtle)] bg-[var(--color-surface-base)] p-3"
>
Inspector
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold text-[var(--color-text-secondary)]">
Inspector
</span>
<kbd className="rounded border border-[var(--color-border-subtle)] px-1 text-[10px] text-[var(--color-text-muted)]">
I
</kbd>
</div>
<InspectorContent />
</aside>
);
}

View file

@ -1,27 +1,46 @@
import { useState, useCallback } from "react";
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";
import { EvidenceProvider, useEvidenceSubject } from "./evidenceContext";
import { KeyboardHelp } from "./KeyboardHelp";
import { useGlobalKeyboard } from "./useGlobalKeyboard";
export function Shell() {
// RightInspector defaults to collapsed in W-027
const inspectorCollapsed = true;
function ShellInner() {
const { inspectorOpen, toggleInspector } = useEvidenceSubject();
const [helpOpen, setHelpOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const onTogglePalette = useCallback(() => {
setPaletteOpen((v) => !v);
}, []);
const onShowHelp = useCallback(() => {
setHelpOpen(true);
}, []);
useGlobalKeyboard({
onTogglePalette,
onToggleInspector: toggleInspector,
onShowHelp,
});
return (
<div
className="grid h-screen"
style={{
gridTemplateAreas: inspectorCollapsed
? '"topbar topbar" "leftnav main" "footer footer"'
: '"topbar topbar topbar" "leftnav main inspector" "footer footer footer"',
gridTemplateAreas: inspectorOpen
? '"topbar topbar topbar" "leftnav main inspector" "footer footer footer"'
: '"topbar topbar" "leftnav main" "footer footer"',
gridTemplateRows: "auto 1fr auto",
gridTemplateColumns: inspectorCollapsed ? "12rem 1fr" : "12rem 1fr 20rem",
gridTemplateColumns: inspectorOpen ? "12rem 1fr 20rem" : "12rem 1fr",
}}
>
<div style={{ gridArea: "topbar" }}>
<TopBar />
<TopBar paletteOpen={paletteOpen} onPaletteOpenChange={setPaletteOpen} />
</div>
<div style={{ gridArea: "leftnav" }}>
@ -38,15 +57,25 @@ export function Shell() {
</ApiErrorBoundary>
</main>
{!inspectorCollapsed && (
{inspectorOpen && (
<div style={{ gridArea: "inspector" }}>
<RightInspector collapsed={false} />
<RightInspector />
</div>
)}
<div style={{ gridArea: "footer" }}>
<StatusFooter />
</div>
<KeyboardHelp open={helpOpen} onOpenChange={setHelpOpen} />
</div>
);
}
export function Shell() {
return (
<EvidenceProvider>
<ShellInner />
</EvidenceProvider>
);
}

View file

@ -1,23 +1,15 @@
import { useState } from "react";
import { CommandPalette } from "../design/components/primitives/CommandPalette";
import { useRuntimeStatus } from "../api/queries";
export function TopBar() {
const [paletteOpen, setPaletteOpen] = useState(false);
export function TopBar({
paletteOpen,
onPaletteOpenChange,
}: {
paletteOpen: boolean;
onPaletteOpenChange: (open: boolean) => void;
}) {
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 (
@ -54,18 +46,15 @@ export function TopBar() {
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}
onClick={() => onPaletteOpenChange(true)}
aria-label="Open command palette (⌘K)"
>
<span>Search commands</span>
@ -75,10 +64,9 @@ export function TopBar() {
</button>
</div>
{/* Connection pill */}
<div className="shrink-0">{connectionPill}</div>
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
<CommandPalette open={paletteOpen} onOpenChange={onPaletteOpenChange} />
</header>
);
}

View file

@ -0,0 +1,92 @@
import { useCallback, useSyncExternalStore } from "react";
export interface Command {
id: string;
label: string;
section: string;
shortcut?: string;
action: () => void;
}
type Listener = () => void;
class CommandStore {
private commands = new Map<string, Command>();
private listeners = new Set<Listener>();
private cached: readonly Command[] = [];
register(cmds: readonly Command[]) {
for (const cmd of cmds) {
this.commands.set(cmd.id, cmd);
}
this.cached = Array.from(this.commands.values());
this.notify();
}
unregister(ids: readonly string[]) {
for (const id of ids) {
this.commands.delete(id);
}
this.cached = Array.from(this.commands.values());
this.notify();
}
getSnapshot(): readonly Command[] {
return this.cached;
}
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify() {
for (const l of this.listeners) l();
}
}
const store = new CommandStore();
const subscribe = (l: Listener) => store.subscribe(l);
const getSnapshot = () => store.getSnapshot();
export function useCommands(): readonly Command[] {
return useSyncExternalStore(subscribe, getSnapshot);
}
export function useCommandRegistry() {
const register = useCallback((cmds: readonly Command[]) => {
store.register(cmds);
}, []);
const unregister = useCallback((ids: readonly string[]) => {
store.unregister(ids);
}, []);
return { register, unregister };
}
const RECENT_KEY = "core-recent-evidence";
const MAX_RECENT = 10;
export interface RecentItem {
label: string;
path: string;
timestamp: number;
}
export function getRecentItems(): RecentItem[] {
try {
const raw = localStorage.getItem(RECENT_KEY);
if (!raw) return [];
return JSON.parse(raw) as RecentItem[];
} catch {
return [];
}
}
export function pushRecentItem(item: Omit<RecentItem, "timestamp">) {
const items = getRecentItems().filter((r) => r.path !== item.path);
items.unshift({ ...item, timestamp: Date.now() });
if (items.length > MAX_RECENT) items.length = MAX_RECENT;
localStorage.setItem(RECENT_KEY, JSON.stringify(items));
}

View file

@ -0,0 +1,102 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { EvidenceProvider, useEvidenceSubject } from "./evidenceContext";
import type { ChatTurnResult } from "../types/api";
const MOCK_TURN: ChatTurnResult = {
prompt: "test",
surface: "answer",
articulation_surface: "answer",
walk_surface: "walk",
grounding_source: "teaching",
epistemic_state: "decoded",
normative_clearance: "cleared",
normative_detail: "",
trace_hash: "sha256:abc",
refusal_emitted: false,
hedge_injected: false,
mutation_mode: "runtime_turn",
identity_verdict: null,
safety_verdict: null,
ethics_verdict: null,
proposal_candidates: [],
turn_cost_ms: 10,
checkpoint_emitted: false,
};
function TestConsumer() {
const { subject, setSubject, clearSubject, inspectorOpen, toggleInspector } =
useEvidenceSubject();
return (
<div>
<span data-testid="kind">{subject.kind}</span>
<span data-testid="inspector">{inspectorOpen ? "open" : "closed"}</span>
<button
type="button"
onClick={() => setSubject({ kind: "turn", turnId: 1, data: MOCK_TURN })}
>
set-turn
</button>
<button type="button" onClick={clearSubject}>
clear
</button>
<button type="button" onClick={toggleInspector}>
toggle
</button>
</div>
);
}
describe("EvidenceContext", () => {
it("starts with kind=none and inspector closed", () => {
render(
<EvidenceProvider>
<TestConsumer />
</EvidenceProvider>,
);
expect(screen.getByTestId("kind")).toHaveTextContent("none");
expect(screen.getByTestId("inspector")).toHaveTextContent("closed");
});
it("setSubject updates kind", () => {
render(
<EvidenceProvider>
<TestConsumer />
</EvidenceProvider>,
);
fireEvent.click(screen.getByText("set-turn"));
expect(screen.getByTestId("kind")).toHaveTextContent("turn");
});
it("clearSubject resets to none", () => {
render(
<EvidenceProvider>
<TestConsumer />
</EvidenceProvider>,
);
fireEvent.click(screen.getByText("set-turn"));
expect(screen.getByTestId("kind")).toHaveTextContent("turn");
fireEvent.click(screen.getByText("clear"));
expect(screen.getByTestId("kind")).toHaveTextContent("none");
});
it("toggleInspector toggles open state", () => {
render(
<EvidenceProvider>
<TestConsumer />
</EvidenceProvider>,
);
expect(screen.getByTestId("inspector")).toHaveTextContent("closed");
fireEvent.click(screen.getByText("toggle"));
expect(screen.getByTestId("inspector")).toHaveTextContent("open");
fireEvent.click(screen.getByText("toggle"));
expect(screen.getByTestId("inspector")).toHaveTextContent("closed");
});
it("throws if used outside provider", () => {
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => render(<TestConsumer />)).toThrow(
"useEvidenceSubject must be used within EvidenceProvider",
);
spy.mockRestore();
});
});

View file

@ -0,0 +1,73 @@
import {
createContext,
useContext,
useState,
useCallback,
type ReactNode,
} from "react";
import type {
ChatTurnResult,
ProposalDetail,
ArtifactDetail,
EvalRunResult,
} from "../types/api";
export type EvidenceSubject =
| { kind: "turn"; turnId: number; data: ChatTurnResult }
| { kind: "proposal"; proposalId: string; data: ProposalDetail }
| { kind: "artifact"; artifactId: string; data: ArtifactDetail }
| { kind: "eval_result"; lane: string; data: EvalRunResult }
| { kind: "none" };
interface EvidenceContextValue {
subject: EvidenceSubject;
setSubject: (subject: EvidenceSubject) => void;
clearSubject: () => void;
inspectorOpen: boolean;
setInspectorOpen: (open: boolean) => void;
toggleInspector: () => void;
}
const NONE_SUBJECT: EvidenceSubject = { kind: "none" };
const EvidenceContext = createContext<EvidenceContextValue | null>(null);
export function EvidenceProvider({ children }: { children: ReactNode }) {
const [subject, setSubjectState] = useState<EvidenceSubject>(NONE_SUBJECT);
const [inspectorOpen, setInspectorOpen] = useState(false);
const setSubject = useCallback((s: EvidenceSubject) => {
setSubjectState(s);
}, []);
const clearSubject = useCallback(() => {
setSubjectState(NONE_SUBJECT);
}, []);
const toggleInspector = useCallback(() => {
setInspectorOpen((prev) => !prev);
}, []);
return (
<EvidenceContext.Provider
value={{
subject,
setSubject,
clearSubject,
inspectorOpen,
setInspectorOpen,
toggleInspector,
}}
>
{children}
</EvidenceContext.Provider>
);
}
export function useEvidenceSubject(): EvidenceContextValue {
const ctx = useContext(EvidenceContext);
if (!ctx) {
throw new Error("useEvidenceSubject must be used within EvidenceProvider");
}
return ctx;
}

View file

@ -0,0 +1,71 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
const ROUTE_KEYS: Record<string, string> = {
"1": "/chat",
"2": "/trace",
"3": "/replay",
"4": "/proposals",
"5": "/evals",
"6": "/runs",
"7": "/packs",
"8": "/vault",
"9": "/audit",
"0": "/settings",
};
interface GlobalKeyboardOptions {
onTogglePalette: () => void;
onToggleInspector: () => void;
onShowHelp: () => void;
}
function isInputFocused(): boolean {
const el = document.activeElement;
if (!el) return false;
const tag = el.tagName?.toLowerCase();
return tag === "input" || tag === "textarea" || (el as HTMLElement).isContentEditable === true;
}
export function useGlobalKeyboard({
onTogglePalette,
onToggleInspector,
onShowHelp,
}: GlobalKeyboardOptions) {
const navigate = useNavigate();
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const meta = e.metaKey || e.ctrlKey;
if (meta && e.key.toLowerCase() === "k") {
e.preventDefault();
onTogglePalette();
return;
}
if (meta && e.key.toLowerCase() === "i") {
e.preventDefault();
onToggleInspector();
return;
}
if (meta && ROUTE_KEYS[e.key]) {
e.preventDefault();
navigate(ROUTE_KEYS[e.key]);
return;
}
if (isInputFocused()) return;
if (e.key === "?" && !meta) {
e.preventDefault();
onShowHelp();
return;
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [navigate, onTogglePalette, onToggleInspector, onShowHelp]);
}

View file

@ -1,6 +1,6 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { useState } from "react";
import { CommandPalette } from "./CommandPalette";
@ -18,6 +18,12 @@ function PaletteHarness({ initialOpen = false }: { initialOpen?: boolean }) {
}
describe("CommandPalette keyboard contract", () => {
beforeEach(() => {
// Recent-items leak between tests (the Enter test writes one); start clean
// so traversal indices map to the deterministic Navigate command order.
localStorage.clear();
});
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)
@ -35,25 +41,29 @@ describe("CommandPalette keyboard contract", () => {
expect(screen.queryByRole("dialog", { name: "Command Palette" })).not.toBeInTheDocument();
});
it("ArrowDown/ArrowUp traverses the three commands", async () => {
it("ArrowDown/ArrowUp traverses the navigation commands and clamps at both ends", 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();
// The Navigate section exposes one command per route, in route order.
expect(screen.getByRole("button", { name: "Open Chat" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open Trace" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open Replay" })).toBeInTheDocument();
const items = dialog.querySelectorAll('[role="option"]');
expect(items.length).toBe(10);
const lastIndex = items.length - 1;
// 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");
// ArrowUp at the top stays clamped at index 0
await user.keyboard("{ArrowUp}");
expect(items[0].getAttribute("aria-selected")).toBe("true");
// ArrowDown moves to index 1
await user.keyboard("{ArrowDown}");
expect(items[0].getAttribute("aria-selected")).toBe("false");
@ -63,13 +73,15 @@ describe("CommandPalette keyboard contract", () => {
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");
// Holding ArrowDown past the end clamps at the last index
for (let i = 0; i < items.length + 2; i++) {
await user.keyboard("{ArrowDown}");
}
expect(items[lastIndex].getAttribute("aria-selected")).toBe("true");
});
it("Enter activates the focused command and closes the palette", async () => {

View file

@ -1,65 +1,143 @@
import * as Dialog from "@radix-ui/react-dialog";
import { Search } from "lucide-react";
import { useRef, useState, useEffect, useCallback } from "react";
import { useRef, useState, useEffect, useCallback, useMemo } from "react";
import { useNavigate, useInRouterContext } from "react-router-dom";
import {
useCommands,
getRecentItems,
pushRecentItem,
type Command,
type RecentItem,
} from "../../../app/commandRegistry";
interface Command {
name: string;
path: string;
}
const COMMANDS: Command[] = [
{ name: "Open Chat", path: "/chat" },
{ name: "Open Proposals", path: "/proposals" },
{ name: "Open Evals", path: "/evals" },
const NAV_COMMANDS: Command[] = [
{ id: "nav-chat", label: "Open Chat", section: "Navigate", shortcut: "⌘1", action: () => {} },
{ id: "nav-trace", label: "Open Trace", section: "Navigate", shortcut: "⌘2", action: () => {} },
{ id: "nav-replay", label: "Open Replay", section: "Navigate", shortcut: "⌘3", action: () => {} },
{ id: "nav-proposals", label: "Open Proposals", section: "Navigate", shortcut: "⌘4", action: () => {} },
{ id: "nav-evals", label: "Open Evals", section: "Navigate", shortcut: "⌘5", action: () => {} },
{ id: "nav-runs", label: "Open Runs", section: "Navigate", shortcut: "⌘6", action: () => {} },
{ id: "nav-packs", label: "Open Packs", section: "Navigate", shortcut: "⌘7", action: () => {} },
{ id: "nav-vault", label: "Open Vault", section: "Navigate", shortcut: "⌘8", action: () => {} },
{ id: "nav-audit", label: "Open Audit", section: "Navigate", shortcut: "⌘9", action: () => {} },
{ id: "nav-settings", label: "Open Settings", section: "Navigate", shortcut: "⌘0", action: () => {} },
];
// Inner shell that safely calls useNavigate — only rendered inside a Router.
const NAV_PATHS: Record<string, string> = {
"nav-chat": "/chat",
"nav-trace": "/trace",
"nav-replay": "/replay",
"nav-proposals": "/proposals",
"nav-evals": "/evals",
"nav-runs": "/runs",
"nav-packs": "/packs",
"nav-vault": "/vault",
"nav-audit": "/audit",
"nav-settings": "/settings",
};
interface DisplayItem {
id: string;
label: string;
section: string;
shortcut?: string;
type: "command" | "recent";
}
function RouterCommandPalette(props: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const navigate = useNavigate();
const registeredCommands = useCommands();
const activate = useCallback(
(cmd: Command) => {
navigate(cmd.path);
(item: DisplayItem) => {
const navPath = NAV_PATHS[item.id];
if (navPath) {
navigate(navPath);
pushRecentItem({ label: item.label, path: navPath });
} else if (item.type === "recent") {
const recent = getRecentItems().find((r) => r.label === item.label);
if (recent) navigate(recent.path);
} else {
const cmd = registeredCommands.find((c) => c.id === item.id);
cmd?.action();
}
props.onOpenChange(false);
},
[navigate, props],
[navigate, registeredCommands, props],
);
const allCommands = useMemo(() => {
const navCmds: DisplayItem[] = NAV_COMMANDS.map((c) => ({
...c,
type: "command" as const,
}));
const regCmds: DisplayItem[] = registeredCommands
.filter((c) => !NAV_PATHS[c.id])
.map((c) => ({ ...c, type: "command" as const }));
return [...navCmds, ...regCmds];
}, [registeredCommands]);
return (
<CommandPaletteContent
{...props}
commands={allCommands}
onActivate={activate}
/>
);
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],
const commands: DisplayItem[] = NAV_COMMANDS.map((c) => ({
...c,
type: "command" as const,
}));
return (
<CommandPaletteContent
{...props}
commands={commands}
onActivate={() => props.onOpenChange(false)}
/>
);
return <CommandPaletteContent {...props} onActivate={activate} />;
}
function CommandPaletteContent({
open,
onOpenChange,
commands,
onActivate,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onActivate: (cmd: Command) => void;
commands: readonly DisplayItem[];
onActivate: (item: DisplayItem) => 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()),
);
const recentItems: DisplayItem[] = useMemo(() => {
if (query) return [];
return getRecentItems().map((r) => ({
id: `recent-${r.path}`,
label: r.label,
section: "Recent",
type: "recent" as const,
}));
}, [query]);
const filtered = useMemo(() => {
const q = query.toLowerCase();
const matchedCmds = q
? commands.filter((cmd) => cmd.label.toLowerCase().includes(q))
: commands;
return [...recentItems, ...matchedCmds];
}, [query, commands, recentItems]);
useEffect(() => {
if (open) {
@ -82,11 +160,26 @@ function CommandPaletteContent({
setFocusedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const cmd = filtered[focusedIndex];
if (cmd) onActivate(cmd);
const item = filtered[focusedIndex];
if (item) onActivate(item);
}
}
const sections = useMemo(() => {
const map = new Map<string, { items: DisplayItem[]; startIndex: number }>();
let idx = 0;
for (const item of filtered) {
const existing = map.get(item.section);
if (existing) {
existing.items.push(item);
} else {
map.set(item.section, { items: [item], startIndex: idx });
}
idx++;
}
return map;
}, [filtered]);
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
@ -113,23 +206,40 @@ function CommandPaletteContent({
{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>
<ul className="m-0 max-h-72 list-none overflow-y-auto p-0" role="listbox" aria-label="Commands">
{Array.from(sections.entries()).map(([section, { items, startIndex }]) => (
<li key={section} className="mb-1">
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
{section}
</div>
<ul className="m-0 list-none p-0">
{items.map((item, i) => {
const globalIndex = startIndex + i;
return (
<li key={item.id} role="option" aria-selected={globalIndex === focusedIndex}>
<button
className={[
"flex w-full items-center justify-between rounded px-3 py-2 text-left text-sm transition-colors",
globalIndex === 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(item)}
onMouseEnter={() => setFocusedIndex(globalIndex)}
aria-label={item.label}
>
<span>{item.label}</span>
{item.shortcut && (
<kbd className="ml-2 rounded border border-[var(--color-border-subtle)] px-1 font-mono text-[10px] text-[var(--color-text-muted)]">
{item.shortcut}
</kbd>
)}
</button>
</li>
);
})}
</ul>
</li>
))}
</ul>