diff --git a/docs/workbench/wave-1-evidence-spine.md b/docs/workbench/wave-1-evidence-spine.md index 227744da..913ddacb 100644 --- a/docs/workbench/wave-1-evidence-spine.md +++ b/docs/workbench/wave-1-evidence-spine.md @@ -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` diff --git a/workbench-ui/src/app/KeyboardHelp.tsx b/workbench-ui/src/app/KeyboardHelp.tsx new file mode 100644 index 00000000..99eb94e9 --- /dev/null +++ b/workbench-ui/src/app/KeyboardHelp.tsx @@ -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: "⌘1–0", action: "Navigate to route 1–10" }, + { 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 ( + + + + + + Keyboard Shortcuts + + + Available keyboard shortcuts for the workbench. + +
+ {SHORTCUTS.map((s) => ( +
+
+ + {s.keys} + +
+
+ {s.action} +
+
+ ))} +
+
+
+
+ ); +} diff --git a/workbench-ui/src/app/RightInspector.test.tsx b/workbench-ui/src/app/RightInspector.test.tsx new file mode 100644 index 00000000..90c451e0 --- /dev/null +++ b/workbench-ui/src/app/RightInspector.test.tsx @@ -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 ; +} + +function renderInspector(kind: "turn" | "none" = "none") { + return render( + + + , + ); +} + +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(); + }); +}); diff --git a/workbench-ui/src/app/RightInspector.tsx b/workbench-ui/src/app/RightInspector.tsx index 75c61f54..6c246cc2 100644 --- a/workbench-ui/src/app/RightInspector.tsx +++ b/workbench-ui/src/app/RightInspector.tsx @@ -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 }) { + const { data } = subject; + return ( +
+

Turn #{subject.turnId}

+ {data.trace_hash && ( + + )} + 80 ? `${data.surface.slice(0, 80)}...` : data.surface, + }, + { + key: "grounding", + value: , + }, + { + key: "epistemic", + value: , + }, + { + key: "clearance", + value: , + }, + { 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" }, + ]} + /> +
+ ); +} + +function ProposalInspector({ subject }: { subject: Extract }) { + const { data } = subject; + return ( +
+

Proposal

+ +
+ ); +} + +function ArtifactInspector({ subject }: { subject: Extract }) { + const { data } = subject; + return ( +
+

Artifact

+ {data.digest && } + }] : []), + ]} + /> +
+ ); +} + +function EvalInspector({ subject }: { subject: Extract }) { + const { data } = subject; + return ( +
+

Eval: {data.lane}

+ +
+ ); +} + +function NoneInspector() { + return ( +
+

Select an item to inspect its evidence.

+
+ ); +} + +function InspectorContent() { + const { subject } = useEvidenceSubject(); + + switch (subject.kind) { + case "turn": + return ; + case "proposal": + return ; + case "artifact": + return ; + case "eval_result": + return ; + case "none": + return ; + } +} + +export function RightInspector() { return ( ); } diff --git a/workbench-ui/src/app/Shell.tsx b/workbench-ui/src/app/Shell.tsx index 188b5810..0740812c 100644 --- a/workbench-ui/src/app/Shell.tsx +++ b/workbench-ui/src/app/Shell.tsx @@ -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 (
- +
@@ -38,15 +57,25 @@ export function Shell() { - {!inspectorCollapsed && ( + {inspectorOpen && (
- +
)}
+ +
); } + +export function Shell() { + return ( + + + + ); +} diff --git a/workbench-ui/src/app/TopBar.tsx b/workbench-ui/src/app/TopBar.tsx index 8b6f986c..2881e2ff 100644 --- a/workbench-ui/src/app/TopBar.tsx +++ b/workbench-ui/src/app/TopBar.tsx @@ -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) { - 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 */} CORE Workbench - {/* Search / Command Palette trigger */}
- {/* Connection pill */}
{connectionPill}
- + ); } diff --git a/workbench-ui/src/app/commandRegistry.ts b/workbench-ui/src/app/commandRegistry.ts new file mode 100644 index 00000000..26133880 --- /dev/null +++ b/workbench-ui/src/app/commandRegistry.ts @@ -0,0 +1,97 @@ +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(); + private listeners = new Set(); + 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) { + const items = getRecentItems().filter((r) => r.path !== item.path); + items.unshift({ ...item, timestamp: Date.now() }); + if (items.length > MAX_RECENT) items.length = MAX_RECENT; + try { + localStorage.setItem(RECENT_KEY, JSON.stringify(items)); + } catch { + // Recent-item persistence is best-effort. Restricted/private storage + // contexts must not break command activation. + } +} diff --git a/workbench-ui/src/app/evidenceContext.test.tsx b/workbench-ui/src/app/evidenceContext.test.tsx new file mode 100644 index 00000000..68082743 --- /dev/null +++ b/workbench-ui/src/app/evidenceContext.test.tsx @@ -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 ( +
+ {subject.kind} + {inspectorOpen ? "open" : "closed"} + + + +
+ ); +} + +describe("EvidenceContext", () => { + it("starts with kind=none and inspector closed", () => { + render( + + + , + ); + expect(screen.getByTestId("kind")).toHaveTextContent("none"); + expect(screen.getByTestId("inspector")).toHaveTextContent("closed"); + }); + + it("setSubject updates kind", () => { + render( + + + , + ); + fireEvent.click(screen.getByText("set-turn")); + expect(screen.getByTestId("kind")).toHaveTextContent("turn"); + }); + + it("clearSubject resets to none", () => { + render( + + + , + ); + 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( + + + , + ); + 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()).toThrow( + "useEvidenceSubject must be used within EvidenceProvider", + ); + spy.mockRestore(); + }); +}); diff --git a/workbench-ui/src/app/evidenceContext.tsx b/workbench-ui/src/app/evidenceContext.tsx new file mode 100644 index 00000000..6d84f0dc --- /dev/null +++ b/workbench-ui/src/app/evidenceContext.tsx @@ -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(null); + +export function EvidenceProvider({ children }: { children: ReactNode }) { + const [subject, setSubjectState] = useState(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 ( + + {children} + + ); +} + +export function useEvidenceSubject(): EvidenceContextValue { + const ctx = useContext(EvidenceContext); + if (!ctx) { + throw new Error("useEvidenceSubject must be used within EvidenceProvider"); + } + return ctx; +} diff --git a/workbench-ui/src/app/useGlobalKeyboard.ts b/workbench-ui/src/app/useGlobalKeyboard.ts new file mode 100644 index 00000000..bfe11d2c --- /dev/null +++ b/workbench-ui/src/app/useGlobalKeyboard.ts @@ -0,0 +1,71 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; + +const ROUTE_KEYS: Record = { + "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]); +} diff --git a/workbench-ui/src/design/components/primitives/CommandPalette.keyboard.test.tsx b/workbench-ui/src/design/components/primitives/CommandPalette.keyboard.test.tsx index 36e558c9..21a26601 100644 --- a/workbench-ui/src/design/components/primitives/CommandPalette.keyboard.test.tsx +++ b/workbench-ui/src/design/components/primitives/CommandPalette.keyboard.test.tsx @@ -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(); 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 () => { diff --git a/workbench-ui/src/design/components/primitives/CommandPalette.tsx b/workbench-ui/src/design/components/primitives/CommandPalette.tsx index f2420694..e1a82d97 100644 --- a/workbench-ui/src/design/components/primitives/CommandPalette.tsx +++ b/workbench-ui/src/design/components/primitives/CommandPalette.tsx @@ -1,65 +1,148 @@ 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 = { + "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"; +} + +interface SectionItem { + item: DisplayItem; + globalIndex: number; +} + 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 ( + ); - return ; } -// Fallback for design-system preview (no Router). function FallbackCommandPalette(props: { open: boolean; onOpenChange: (open: boolean) => void; }) { - const activate = useCallback( - (_cmd: Command) => { - props.onOpenChange(false); - }, - [props], + const commands: DisplayItem[] = NAV_COMMANDS.map((c) => ({ + ...c, + type: "command" as const, + })); + return ( + props.onOpenChange(false)} + /> ); - return ; } 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(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 +165,25 @@ 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(); + filtered.forEach((item, globalIndex) => { + const existing = map.get(item.section); + const sectionItem = { item, globalIndex }; + if (existing) { + existing.push(sectionItem); + } else { + map.set(item.section, [sectionItem]); + } + }); + return map; + }, [filtered]); + return ( @@ -113,23 +210,37 @@ function CommandPaletteContent({ {filtered.length === 0 ? (

No commands match.

) : ( -
    - {filtered.map((cmd, i) => ( -
  • - +
      + {Array.from(sections.entries()).map(([section, items]) => ( +
    • +
      + {section} +
      +
        + {items.map(({ item, globalIndex }) => ( +
      • + +
      • + ))} +
    • ))}