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:
parent
af8d4f75da
commit
2746655bb4
12 changed files with 870 additions and 120 deletions
|
|
@ -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`
|
||||
|
|
|
|||
56
workbench-ui/src/app/KeyboardHelp.tsx
Normal file
56
workbench-ui/src/app/KeyboardHelp.tsx
Normal 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: "⌘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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
68
workbench-ui/src/app/RightInspector.test.tsx
Normal file
68
workbench-ui/src/app/RightInspector.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
92
workbench-ui/src/app/commandRegistry.ts
Normal file
92
workbench-ui/src/app/commandRegistry.ts
Normal 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));
|
||||
}
|
||||
102
workbench-ui/src/app/evidenceContext.test.tsx
Normal file
102
workbench-ui/src/app/evidenceContext.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
73
workbench-ui/src/app/evidenceContext.tsx
Normal file
73
workbench-ui/src/app/evidenceContext.tsx
Normal 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;
|
||||
}
|
||||
71
workbench-ui/src/app/useGlobalKeyboard.ts
Normal file
71
workbench-ui/src/app/useGlobalKeyboard.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue