Merge pull request #711 from AssetOverflow/feat/wb-r0d-interaction-substrate

feat(workbench): interaction substrate — list nav, virtualization, panel chrome, inspector resize, palette verbs (Wave R R0d)
This commit is contained in:
Shay 2026-06-12 12:56:43 -07:00 committed by GitHub
commit 0885e7594f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 977 additions and 92 deletions

View file

@ -19,6 +19,7 @@
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-slot": "1.2.3",
"@tanstack/react-query": "5",
"@tanstack/react-virtual": "3.14.2",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "0.468.0",

View file

@ -20,6 +20,9 @@ importers:
'@tanstack/react-query':
specifier: '5'
version: 5.100.14(react@18.3.1)
'@tanstack/react-virtual':
specifier: 3.14.2
version: 3.14.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
class-variance-authority:
specifier: 0.7.1
version: 0.7.1
@ -885,6 +888,15 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-virtual@3.14.2':
resolution: {integrity: sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.17.0':
resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
@ -2349,6 +2361,14 @@ snapshots:
'@tanstack/query-core': 5.100.14
react: 18.3.1
'@tanstack/react-virtual@3.14.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@tanstack/virtual-core': 3.17.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@tanstack/virtual-core@3.17.0': {}
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.7

View file

@ -0,0 +1,41 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { KeyboardHelp } from "./KeyboardHelp";
import { useRegisterShortcuts } from "./shortcutRegistry";
import { useListNavigation } from "../design/hooks/useListNavigation";
function HostWithBinding() {
useRegisterShortcuts([
{ id: "test-binding", keys: "⌘T", action: "Test verb", order: 1 },
]);
return <KeyboardHelp open onOpenChange={() => {}} />;
}
function HostWithList() {
useListNavigation({ itemCount: 3 });
return <KeyboardHelp open onOpenChange={() => {}} />;
}
describe("KeyboardHelp (registry-driven)", () => {
it("renders rows from the shortcut registry, not a hand-maintained list", () => {
render(<HostWithBinding />);
expect(screen.getByText("Test verb")).toBeInTheDocument();
expect(screen.getByText("⌘T")).toBeInTheDocument();
});
it("shows nothing for shortcuts no mounted component binds", () => {
render(<KeyboardHelp open onOpenChange={() => {}} />);
// No binder mounted in this render — the overlay cannot advertise j/k.
expect(screen.queryByText("Navigate lists")).not.toBeInTheDocument();
});
it("list-navigation rows appear exactly while a navigable list is mounted", () => {
const { unmount } = render(<HostWithList />);
expect(screen.getByText("Navigate lists")).toBeInTheDocument();
expect(screen.getByText("Open selected item")).toBeInTheDocument();
unmount();
render(<KeyboardHelp open onOpenChange={() => {}} />);
expect(screen.queryByText("Navigate lists")).not.toBeInTheDocument();
});
});

View file

@ -1,13 +1,16 @@
import * as Dialog from "@radix-ui/react-dialog";
import { Kbd } from "../design/components/primitives/Kbd";
import { useShortcuts } from "./shortcutRegistry";
const SHORTCUTS = [
{ keys: "⌘K", action: "Command palette" },
{ keys: "⌘I", action: "Toggle inspector" },
{ keys: "⌘10", action: "Navigate to route 110" },
{ keys: "Esc", action: "Close overlay" },
{ keys: "?", action: "This help" },
] as const;
/**
* Keyboard help overlay registry-driven (Wave R brief R0d).
*
* Rows render from the live shortcut registry, where binding sites register
* exactly what they handle while mounted. Advertising an unimplemented
* shortcut is structurally impossible: there is no hand-maintained list to
* drift. (R0a removed three false rows by hand; this removes the failure
* mode itself.)
*/
export function KeyboardHelp({
open,
onOpenChange,
@ -15,6 +18,8 @@ export function KeyboardHelp({
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const shortcuts = useShortcuts();
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
@ -27,18 +32,16 @@ export function KeyboardHelp({
Keyboard Shortcuts
</Dialog.Title>
<Dialog.Description className="sr-only">
Available keyboard shortcuts for the workbench.
Keyboard shortcuts currently active in the workbench.
</Dialog.Description>
<dl className="m-0 grid gap-0">
{SHORTCUTS.map((s) => (
{shortcuts.map((s) => (
<div
key={s.keys}
key={s.id}
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>
<Kbd>{s.keys}</Kbd>
</dt>
<dd className="m-0 text-sm text-[var(--color-text-secondary)]">
{s.action}

View file

@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useEffect, useState, useCallback } from "react";
import { Outlet } from "react-router-dom";
import { TopBar } from "./TopBar";
import { LeftNav } from "./LeftNav";
@ -10,6 +10,8 @@ import { EvidenceUrlSync } from "./evidenceUrlSync";
import { isAddressable, subjectToUrl } from "./evidenceAddress";
import { KeyboardHelp } from "./KeyboardHelp";
import { useGlobalKeyboard } from "./useGlobalKeyboard";
import { useCommandRegistry } from "./commandRegistry";
import { SplitPane } from "../design/components/SplitPane/SplitPane";
function ShellInner() {
const { subject, inspectorOpen, toggleInspector, notifyAddressCopied } =
@ -42,15 +44,50 @@ function ShellInner() {
onCopyEvidenceLink,
});
// Action verbs in the palette (Wave R brief R0d): registered by the
// component that owns the behavior, unregistered on unmount.
const { register, unregister } = useCommandRegistry();
useEffect(() => {
register([
{
id: "action-toggle-inspector",
label: "Toggle inspector",
section: "Actions",
kind: "action",
shortcut: "\u2318I",
action: toggleInspector,
},
{
id: "action-copy-evidence-link",
label: "Copy evidence link",
section: "Actions",
kind: "action",
shortcut: "\u2318\u21E7C",
action: onCopyEvidenceLink,
},
]);
return () => unregister(["action-toggle-inspector", "action-copy-evidence-link"]);
}, [register, unregister, toggleInspector, onCopyEvidenceLink]);
const mainSurface = (
<main
data-region="main"
className="h-full overflow-y-auto bg-[var(--color-surface-base)] p-4"
>
<ApiErrorBoundary>
<Outlet />
</ApiErrorBoundary>
</main>
);
return (
<div
className="grid h-screen"
style={{
gridTemplateAreas: inspectorOpen
? '"topbar topbar topbar" "leftnav main inspector" "footer footer footer"'
: '"topbar topbar" "leftnav main" "footer footer"',
gridTemplateAreas:
'"topbar topbar" "leftnav center" "footer footer"',
gridTemplateRows: "auto 1fr auto",
gridTemplateColumns: inspectorOpen ? "12rem 1fr 20rem" : "12rem 1fr",
gridTemplateColumns: "12rem 1fr",
}}
>
<div style={{ gridArea: "topbar" }}>
@ -61,21 +98,18 @@ function ShellInner() {
<LeftNav />
</div>
<main
data-region="main"
style={{ gridArea: "main" }}
className="overflow-y-auto bg-[var(--color-surface-base)] p-4"
>
<ApiErrorBoundary>
<Outlet />
</ApiErrorBoundary>
</main>
{inspectorOpen && (
<div style={{ gridArea: "inspector" }}>
<RightInspector />
</div>
)}
<div style={{ gridArea: "center" }} className="min-h-0">
{inspectorOpen ? (
// Resizable main/inspector split; width persists via the
// SplitPane id (guarded storage access inside SplitPane).
<SplitPane direction="horizontal" id="inspector" defaultSplit={72} minSize={240}>
{mainSurface}
<RightInspector />
</SplitPane>
) : (
mainSurface
)}
</div>
<div style={{ gridArea: "footer" }}>
<StatusFooter />

View file

@ -4,6 +4,8 @@ export interface Command {
id: string;
label: string;
section: string;
/** "navigate" routes somewhere; "action" performs a verb. */
kind: "navigate" | "action";
shortcut?: string;
action: () => void;
}

View file

@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useEvidenceSubject } from "../evidenceContext";
import { useCommandRegistry } from "../commandRegistry";
import { subjectToUrl } from "../evidenceAddress";
import { useEvalLanes } from "../../api/queries";
import { useEvalLanes, useEvalRun } from "../../api/queries";
import { EvalLaneCard } from "./EvalLaneCard";
import { EvalRunButton } from "./EvalRunButton";
import { EvalMetricGrid } from "./EvalMetricGrid";
@ -38,6 +39,46 @@ export function EvalsRoute() {
? runStates[selectedLaneName]?.result
: undefined;
// Palette verbs (Wave R brief R0d): one "Run eval lane <lane>" command per
// read-only lane, registered while this route is mounted. Executes the
// same read-only POST /evals/run as EvalRunButton's defaults (first
// version, public split) and navigates here to show the run state.
const paletteRun = useEvalRun();
const paletteMutate = paletteRun.mutate;
const { register, unregister } = useCommandRegistry();
useEffect(() => {
const runnable = (lanes ?? []).filter((l) => l.read_only);
if (runnable.length === 0) return;
register(
runnable.map((l) => ({
id: `action-run-eval-${l.lane}`,
label: `Run eval lane ${l.lane}`,
section: "Actions",
kind: "action" as const,
action: () => {
navigate(`/evals/${encodeURIComponent(l.lane)}`);
setRunStates((prev) => ({ ...prev, [l.lane]: { isPending: true } }));
paletteMutate(
{ lane: l.lane, version: l.versions[0] || "v1", split: "public" },
{
onSuccess: (result) =>
setRunStates((prev) => ({
...prev,
[l.lane]: { isPending: false, result },
})),
onError: (err) =>
setRunStates((prev) => ({
...prev,
[l.lane]: { isPending: false, error: err },
})),
},
);
},
})),
);
return () => unregister(runnable.map((l) => `action-run-eval-${l.lane}`));
}, [lanes, register, unregister, navigate, paletteMutate]);
// Publish the selected lane as the evidence subject: identity immediately,
// run-result data once a run completes in this session.
useEffect(() => {

View file

@ -51,6 +51,13 @@ describe("W-030 Component Tests", () => {
beforeEach(() => {
vi.resetAllMocks();
// EvalsRoute now calls useEvalRun unconditionally (palette run verbs,
// Wave R R0d); give it a safe default so tests that never run a lane
// don't need their own mock.
vi.mocked(useEvalRun).mockReturnValue({
mutate: vi.fn(),
isPending: false,
} as any);
fetchMock.mockImplementation((url: any) => {
const urlStr = typeof url === "string" ? url : String(url?.url || url || "");
if (urlStr.endsWith("/evals")) {

View file

@ -8,6 +8,8 @@ import { useProposalDetail, useProposals, useMathProposals, useMathProposalDetai
import type { ProposalSummary, MathProposalDetail, ProposalState, DownstreamEffect, MathReasoningStep } from "../../types/api";
import { Button } from "../../design/components/primitives/Button";
import { EmptyState } from "../../design/components/states/EmptyState";
import { SearchInput } from "../../design/components/SearchInput/SearchInput";
import { useListNavigation } from "../../design/hooks/useListNavigation";
import { ErrorState } from "../../design/components/states/ErrorState";
import { LoadingState } from "../../design/components/states/LoadingState";
import { ProposalChainViewer } from "./ProposalChainViewer";
@ -49,8 +51,8 @@ export function ProposalsRoute() {
const [filter, setFilter] = useState<ProposalStateFilter>(
isProposalFilter(filterFromUrl) ? filterFromUrl : "pending",
);
const [search, setSearch] = useState("");
const [focusedIndex, setFocusedIndex] = useState<number>(0);
// Queries
const mathProposalsQuery = useMathProposals();
@ -90,7 +92,7 @@ export function ProposalsRoute() {
}, [domain, mathProposalsQuery.data, cognitionProposalsQuery.data]);
// Map to unified ProposalSummary structure
const proposals: ProposalSummary[] = useMemo(() => {
const allProposals: ProposalSummary[] = useMemo(() => {
if (domain === "math") {
return (rawProposals as any[]).map((mp) => ({
proposal_id: mp.proposal_id,
@ -105,6 +107,29 @@ export function ProposalsRoute() {
}
}, [domain, rawProposals]);
// Client-side narrowing via SearchInput ("/" focuses it while mounted).
const proposals: ProposalSummary[] = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return allProposals;
return allProposals.filter(
(p) =>
p.proposal_id.toLowerCase().includes(q) ||
p.source_kind.toLowerCase().includes(q),
);
}, [allProposals, search]);
// Shared list navigation (window scope — the queue IS the route's primary
// surface). Replaces the bespoke window keydown listener this route
// carried since W-029; one pattern app-wide (Wave R brief R0d).
const { focusedIndex, setFocusedIndex } = useListNavigation({
itemCount: proposals.length,
scope: "window",
onActivate: (index) => {
if (proposals[index]) selectProposal(proposals[index].proposal_id);
},
onEscape: () => updateRoute({ proposalId: null }),
});
const focusedProposalId = useMemo(() => {
if (proposals.length > 0 && focusedIndex >= 0 && focusedIndex < proposals.length) {
return proposals[focusedIndex].proposal_id;
@ -170,34 +195,6 @@ export function ProposalsRoute() {
updateRoute({ proposalId });
}
// Keyboard navigation wires
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (document.activeElement?.tagName === "INPUT" || document.activeElement?.tagName === "TEXTAREA") {
return;
}
if (e.key === "j" || e.key === "ArrowDown") {
e.preventDefault();
setFocusedIndex((prev) => Math.min(prev + 1, proposals.length - 1));
} else if (e.key === "k" || e.key === "ArrowUp") {
e.preventDefault();
setFocusedIndex((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (proposals[focusedIndex]) {
selectProposal(proposals[focusedIndex].proposal_id);
}
} else if (e.key === "Escape") {
e.preventDefault();
updateRoute({ proposalId: null });
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [proposals, focusedIndex]);
// Auto-advance focus on success
const autoAdvance = () => {
let nextIndex = -1;
@ -293,6 +290,12 @@ export function ProposalsRoute() {
)}
</div>
<SearchInput
placeholder="Filter by proposal id or source kind"
value={search}
onChange={setSearch}
/>
{proposals.length === 0 ? (
<EmptyState
statement={domain === "math" ? "No math proposals match this queue view." : "No proposals match this queue view."}

View file

@ -1,6 +1,7 @@
import type { ArtifactRef } from "../../types/api";
import { cn } from "../../design/lib";
import { EmptyState } from "../../design/components/states/EmptyState";
import { useListNavigation } from "../../design/hooks/useListNavigation";
interface ArtifactListProps {
artifacts: ArtifactRef[];
@ -21,17 +22,6 @@ const KINDS = [
type ArtifactKind = (typeof KINDS)[number];
export function ArtifactList({ artifacts, selectedId, onSelect }: ArtifactListProps) {
if (artifacts.length === 0) {
return (
<div className="p-2" data-testid="artifact-list-empty">
<EmptyState
statement="No artifacts available."
nextAction={{ kind: "cli", command: "core eval cognition" }}
/>
</div>
);
}
// Initialize grouped structure
const grouped: Record<ArtifactKind, ArtifactRef[]> = {
trace: [],
@ -62,11 +52,34 @@ export function ArtifactList({ artifacts, selectedId, onSelect }: ArtifactListPr
});
});
// Flat traversal order across groups for the keyboard spine (R0d).
const flatOrder = KINDS.flatMap((kind) => grouped[kind]);
const flatIndexById = new Map(flatOrder.map((a, i) => [a.artifact_id, i]));
const { listProps, itemProps } = useListNavigation({
itemCount: flatOrder.length,
onActivate: (index) => {
const art = flatOrder[index];
if (art) onSelect(art.artifact_id);
},
});
if (artifacts.length === 0) {
return (
<div className="p-2" data-testid="artifact-list-empty">
<EmptyState
statement="No artifacts available."
nextAction={{ kind: "cli", command: "core eval cognition" }}
/>
</div>
);
}
return (
<nav
className="flex h-full flex-col gap-4 overflow-y-auto pr-2"
className="flex h-full flex-col gap-4 overflow-y-auto pr-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
aria-label="Artifact list navigation"
data-testid="artifact-list"
{...listProps}
>
{KINDS.map((kind) => {
const items = grouped[kind];
@ -80,9 +93,14 @@ export function ArtifactList({ artifacts, selectedId, onSelect }: ArtifactListPr
<div className="space-y-0.5">
{items.map((art) => {
const isSelected = art.artifact_id === selectedId;
const { ref: rowRef, ...rowProps } = itemProps(
flatIndexById.get(art.artifact_id) ?? 0,
);
return (
<button
key={art.artifact_id}
{...rowProps}
ref={rowRef}
onClick={() => onSelect(art.artifact_id)}
type="button"
className={cn(

View file

@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useEvidenceSubject } from "../evidenceContext";
import { subjectToUrl } from "../evidenceAddress";
@ -8,6 +8,7 @@ import { ReplayComparisonPanel } from "./ReplayComparisonPanel";
import { LoadingState } from "../../design/components/states/LoadingState";
import { ErrorState } from "../../design/components/states/ErrorState";
import { EmptyState } from "../../design/components/states/EmptyState";
import { SearchInput } from "../../design/components/SearchInput/SearchInput";
import { WorkbenchApiError } from "../../api/client";
import { ReplayStatus } from "../../design/components/badges";
@ -17,6 +18,7 @@ export function ReplayRoute() {
const [searchParams] = useSearchParams();
const { setSubject } = useEvidenceSubject();
const selectedId = artifactId ?? null;
const [search, setSearch] = useState("");
const artifactsQuery = useArtifacts();
const detailQuery = useArtifactDetail(selectedId || "");
@ -97,6 +99,13 @@ export function ReplayRoute() {
>
{/* Left Pane: Artifact list */}
<div className="border-r border-[var(--color-border-subtle)] overflow-y-auto pr-2">
<div className="mb-2">
<SearchInput
placeholder="Filter by artifact id or kind"
value={search}
onChange={setSearch}
/>
</div>
{isLoadingArtifacts ? (
<LoadingState label="Loading artifacts..." />
) : artifactsQuery.isError ? (
@ -112,7 +121,14 @@ export function ReplayRoute() {
/>
) : (
<ArtifactList
artifacts={artifactsQuery.data || []}
artifacts={(artifactsQuery.data || []).filter((a) => {
const q = search.trim().toLowerCase();
if (!q) return true;
return (
a.artifact_id.toLowerCase().includes(q) ||
a.kind.toLowerCase().includes(q)
);
})}
selectedId={selectedId}
onSelect={handleSelect}
/>

View file

@ -0,0 +1,89 @@
import { useEffect } from "react";
import { useSyncExternalStore } from "react";
/**
* Live registry of keyboard shortcuts.
*
* Binding sites register exactly the shortcuts they handle, on mount, and
* unregister on unmount. KeyboardHelp renders FROM this registry, so the
* overlay structurally cannot advertise a shortcut nothing handles the
* failure mode R0a removed by hand is now impossible by construction.
*/
export interface ShortcutEntry {
/** Stable id; also the dedupe key (e.g. "list-nav" registered by any list). */
id: string;
/** Display chord, e.g. "⌘K", "j / k". */
keys: string;
/** What it does, e.g. "Navigate lists". */
action: string;
/** Display ordering (lower first); ties broken by keys. */
order: number;
}
type Listener = () => void;
class ShortcutStore {
// id -> refcount'd entry: two mounted lists both registering "list-nav"
// must not duplicate the row, and unmounting one must not remove it.
private entries = new Map<string, { entry: ShortcutEntry; count: number }>();
private listeners = new Set<Listener>();
private cached: readonly ShortcutEntry[] = [];
register(entries: readonly ShortcutEntry[]) {
for (const entry of entries) {
const existing = this.entries.get(entry.id);
if (existing) {
existing.count += 1;
} else {
this.entries.set(entry.id, { entry, count: 1 });
}
}
this.rebuild();
}
unregister(ids: readonly string[]) {
for (const id of ids) {
const existing = this.entries.get(id);
if (!existing) continue;
existing.count -= 1;
if (existing.count <= 0) this.entries.delete(id);
}
this.rebuild();
}
getSnapshot(): readonly ShortcutEntry[] {
return this.cached;
}
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private rebuild() {
this.cached = Array.from(this.entries.values())
.map((e) => e.entry)
.sort((a, b) => a.order - b.order || a.keys.localeCompare(b.keys));
for (const l of this.listeners) l();
}
}
const store = new ShortcutStore();
const subscribe = (l: Listener) => store.subscribe(l);
const getSnapshot = () => store.getSnapshot();
export function useShortcuts(): readonly ShortcutEntry[] {
return useSyncExternalStore(subscribe, getSnapshot);
}
/** Register shortcuts for the lifetime of the calling component. */
export function useRegisterShortcuts(entries: readonly ShortcutEntry[]) {
// Entries are expected to be module-level constants; the JSON key keeps
// the effect honest if a caller builds them inline.
const key = JSON.stringify(entries);
useEffect(() => {
store.register(entries);
return () => store.unregister(entries.map((e) => e.id));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
}

View file

@ -1,5 +1,6 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useRegisterShortcuts, type ShortcutEntry } from "./shortcutRegistry";
const ROUTE_KEYS: Record<string, string> = {
"1": "/chat",
@ -21,6 +22,18 @@ interface GlobalKeyboardOptions {
onCopyEvidenceLink: () => void;
}
// The binder registers exactly what it binds: KeyboardHelp renders from the
// shortcut registry, so an overlay row exists iff a handler exists.
const GLOBAL_SHORTCUTS: readonly ShortcutEntry[] = [
{ id: "global-palette", keys: "\u2318K", action: "Command palette", order: 10 },
{ id: "global-inspector", keys: "\u2318I", action: "Toggle inspector", order: 11 },
{ id: "global-routes", keys: "\u23181\u20130", action: "Navigate to route 1\u201310", order: 12 },
{ id: "global-copy-link", keys: "\u2318\u21E7C", action: "Copy evidence link", order: 13 },
// Esc is bound by every Radix overlay (palette, help, drawers).
{ id: "global-escape", keys: "Esc", action: "Close overlay", order: 50 },
{ id: "global-help", keys: "?", action: "This help", order: 60 },
];
function isInputFocused(): boolean {
const el = document.activeElement;
if (!el) return false;
@ -36,6 +49,8 @@ export function useGlobalKeyboard({
}: GlobalKeyboardOptions) {
const navigate = useNavigate();
useRegisterShortcuts(GLOBAL_SHORTCUTS);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
const meta = e.metaKey || e.ctrlKey;

View file

@ -0,0 +1,26 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Panel } from "./Panel";
describe("Panel", () => {
it("renders title as a heading, toolbar slot, and body", () => {
render(
<Panel title="Evidence" toolbar={<button type="button">Filter</button>}>
<p>body content</p>
</Panel>,
);
expect(screen.getByRole("heading", { name: "Evidence" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Filter" })).toBeInTheDocument();
expect(screen.getByText("body content")).toBeInTheDocument();
});
it("omits the toolbar container when no toolbar is given", () => {
render(
<Panel title="Plain">
<p>x</p>
</Panel>,
);
const header = screen.getByRole("heading", { name: "Plain" }).parentElement;
expect(header?.querySelectorAll("div").length).toBe(0);
});
});

View file

@ -0,0 +1,39 @@
import type { ReactNode } from "react";
export interface PanelProps {
title: string;
/** Right-aligned header slot for actions/filters. */
toolbar?: ReactNode;
children: ReactNode;
}
/**
* Standard panel chrome (Wave R brief R0d) header (title + toolbar slot)
* over a body. Routes compose this instead of hand-rolling borders.
*/
export function Panel({ title, toolbar, children }: PanelProps) {
return (
<section
className="flex min-h-0 flex-col rounded-lg border"
style={{
borderColor: "var(--color-border-subtle)",
background: "var(--color-surface-raised)",
}}
data-testid="panel"
>
<header
className="flex items-center justify-between gap-3 border-b px-3 py-2"
style={{ borderColor: "var(--color-border-subtle)" }}
>
<h2
className="m-0 text-sm font-semibold"
style={{ color: "var(--color-text-primary)" }}
>
{title}
</h2>
{toolbar ? <div className="flex items-center gap-2">{toolbar}</div> : null}
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-3">{children}</div>
</section>
);
}

View file

@ -1,4 +1,5 @@
import { useEffect, useRef, useCallback } from "react";
import { useRegisterShortcuts, type ShortcutEntry } from "../../../app/shortcutRegistry";
import { Search, X } from "lucide-react";
export interface SearchInputProps {
@ -8,6 +9,10 @@ export interface SearchInputProps {
shortcut?: string;
}
const SEARCH_SHORTCUT: readonly ShortcutEntry[] = [
{ id: "search-focus", keys: "/", action: "Focus search input", order: 42 },
];
export function SearchInput({
placeholder,
value,
@ -16,6 +21,7 @@ export function SearchInput({
}: SearchInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
useRegisterShortcuts(SEARCH_SHORTCUT);
const handleChange = useCallback(
(raw: string) => {

View file

@ -0,0 +1,89 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { VirtualizedList } from "./VirtualizedList";
const MANY = Array.from({ length: 1000 }, (_, i) => `item-${i}`);
// happy-dom has no layout engine; the virtualizer's rect observer reads
// offsetWidth/offsetHeight (0x0) on mount and renders nothing. Give
// elements a real-looking layout size.
const offsetDescriptors = {
offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"),
offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"),
};
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: () => 360,
});
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
configurable: true,
get: () => 600,
});
});
afterEach(() => {
if (offsetDescriptors.offsetHeight) {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", offsetDescriptors.offsetHeight);
}
if (offsetDescriptors.offsetWidth) {
Object.defineProperty(HTMLElement.prototype, "offsetWidth", offsetDescriptors.offsetWidth);
}
vi.restoreAllMocks();
});
function renderList(onActivate?: (item: string, index: number) => void) {
return render(
<VirtualizedList
items={MANY}
getKey={(item) => item}
renderItem={(item, _i, focused) => (
<span>
{item}
{focused ? " *" : ""}
</span>
)}
onActivate={onActivate}
estimateSize={36}
height={360}
ariaLabel="virtual list"
// happy-dom has no layout; seed the viewport rect explicitly
initialRect={{ width: 600, height: 360 }}
/>,
);
}
describe("VirtualizedList", () => {
it("renders O(viewport) rows, not all 1000", () => {
const { container } = renderList();
const rendered = container.querySelectorAll('[role="option"]').length;
expect(rendered).toBeGreaterThan(0);
expect(rendered).toBeLessThan(100);
});
it("total scroll height accounts for every item", () => {
renderList();
const list = screen.getByTestId("virtualized-list");
const sizer = list.firstElementChild as HTMLElement;
expect(sizer.style.height).toBe(`${1000 * 36}px`);
});
it("keyboard navigation works and Enter activates through the window", () => {
const onActivate = vi.fn();
renderList(onActivate);
const list = screen.getByTestId("virtualized-list");
fireEvent.keyDown(list, { key: "j" });
fireEvent.keyDown(list, { key: "j" });
fireEvent.keyDown(list, { key: "Enter" });
expect(onActivate).toHaveBeenCalledWith("item-2", 2);
});
it("keys are item-derived, not index-derived (deterministic identity)", () => {
const { container } = renderList();
const first = container.querySelector('[data-index="0"]');
expect(first).not.toBeNull();
expect(first!.textContent).toContain("item-0");
});
});

View file

@ -0,0 +1,95 @@
import { useRef, type ReactNode } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useListNavigation } from "../../hooks/useListNavigation";
export interface VirtualizedListProps<T> {
items: readonly T[];
/** Deterministic, stable key per item — never the array index. */
getKey: (item: T, index: number) => string;
renderItem: (item: T, index: number, focused: boolean) => ReactNode;
onActivate?: (item: T, index: number) => void;
/** Estimated row height in px (virtualizer measurement seed). */
estimateSize?: number;
/** Viewport height; the scroll container is this component. */
height: number | string;
ariaLabel: string;
/**
* Seed rect for environments without layout (happy-dom tests, SSR).
* Production layout measurement overrides it.
*/
initialRect?: { width: number; height: number };
}
/**
* Virtualized list (Wave R brief R0d): @tanstack/react-virtual for the
* windowing, useListNavigation for the keyboard spine. Long evidence lists
* (turn journals, audit timelines) render O(viewport), not O(n).
*/
export function VirtualizedList<T>({
items,
getKey,
renderItem,
onActivate,
estimateSize = 36,
height,
ariaLabel,
initialRect,
}: VirtualizedListProps<T>) {
const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => estimateSize,
getItemKey: (index) => getKey(items[index], index),
overscan: 8,
initialRect,
});
const { listProps, itemProps, focusedIndex } = useListNavigation({
itemCount: items.length,
onActivate: (index) => onActivate?.(items[index], index),
onFocusChange: (index) => virtualizer.scrollToIndex(index),
});
return (
<div
ref={scrollRef}
aria-label={ariaLabel}
className="overflow-y-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
style={{ height }}
data-testid="virtualized-list"
{...listProps}
>
<div
style={{
height: virtualizer.getTotalSize(),
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const index = virtualItem.index;
const { ref: itemRef, ...optionProps } = itemProps(index);
return (
<div
key={virtualItem.key}
{...optionProps}
data-index={index}
ref={itemRef}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItem.start}px)`,
}}
>
{renderItem(items[index], index, index === focusedIndex)}
</div>
);
})}
</div>
</div>
);
}

View file

@ -1,5 +1,6 @@
import * as Dialog from "@radix-ui/react-dialog";
import { Search } from "lucide-react";
import { Kbd } from "./Kbd";
import { useRef, useState, useEffect, useCallback, useMemo } from "react";
import { useNavigate, useInRouterContext } from "react-router-dom";
import {
@ -11,16 +12,16 @@ import {
} from "../../../app/commandRegistry";
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: () => {} },
{ id: "nav-chat", label: "Open Chat", section: "Navigate", kind: "navigate", shortcut: "⌘1", action: () => {} },
{ id: "nav-trace", label: "Open Trace", section: "Navigate", kind: "navigate", shortcut: "⌘2", action: () => {} },
{ id: "nav-replay", label: "Open Replay", section: "Navigate", kind: "navigate", shortcut: "⌘3", action: () => {} },
{ id: "nav-proposals", label: "Open Proposals", section: "Navigate", kind: "navigate", shortcut: "⌘4", action: () => {} },
{ id: "nav-evals", label: "Open Evals", section: "Navigate", kind: "navigate", shortcut: "⌘5", action: () => {} },
{ id: "nav-runs", label: "Open Runs", section: "Navigate", kind: "navigate", shortcut: "⌘6", action: () => {} },
{ id: "nav-packs", label: "Open Packs", section: "Navigate", kind: "navigate", shortcut: "⌘7", action: () => {} },
{ id: "nav-vault", label: "Open Vault", section: "Navigate", kind: "navigate", shortcut: "⌘8", action: () => {} },
{ id: "nav-audit", label: "Open Audit", section: "Navigate", kind: "navigate", shortcut: "⌘9", action: () => {} },
{ id: "nav-settings", label: "Open Settings", section: "Navigate", kind: "navigate", shortcut: "⌘0", action: () => {} },
];
const NAV_PATHS: Record<string, string> = {
@ -234,9 +235,9 @@ function CommandPaletteContent({
>
<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>
<span className="ml-2">
<Kbd>{item.shortcut}</Kbd>
</span>
)}
</button>
</li>

View file

@ -0,0 +1,18 @@
import type { ReactNode } from "react";
/** Keyboard-chord chip. Token-only; consumed by KeyboardHelp + palette hints. */
export function Kbd({ children }: { children: ReactNode }) {
return (
<kbd
className="rounded border px-1.5 py-0.5 text-xs"
style={{
borderColor: "var(--color-border-subtle)",
background: "var(--color-surface-inset)",
color: "var(--color-text-mono)",
fontFamily: "var(--font-mono)",
}}
>
{children}
</kbd>
);
}

View file

@ -0,0 +1,121 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useListNavigation } from "./useListNavigation";
function List({
items,
onActivate,
}: {
items: string[];
onActivate?: (index: number) => void;
}) {
const { listProps, itemProps, focusedIndex } = useListNavigation({
itemCount: items.length,
onActivate,
});
return (
<div aria-label="test list" {...listProps}>
<input aria-label="embedded search" />
{items.map((item, i) => (
<div key={item} {...itemProps(i)}>
{item}
{i === focusedIndex ? " *" : ""}
</div>
))}
</div>
);
}
const ITEMS = ["alpha", "beta", "gamma"];
function focusedOf(container: HTMLElement): string | null {
const focused = container.querySelector('[aria-selected="true"]');
return focused?.textContent?.replace(" *", "") ?? null;
}
describe("useListNavigation", () => {
it("j/ArrowDown move down, k/ArrowUp move up, clamped at both ends", () => {
const { container } = render(<List items={ITEMS} />);
const list = screen.getByLabelText("test list");
expect(focusedOf(container)).toBe("alpha");
fireEvent.keyDown(list, { key: "k" });
expect(focusedOf(container)).toBe("alpha");
fireEvent.keyDown(list, { key: "j" });
expect(focusedOf(container)).toBe("beta");
fireEvent.keyDown(list, { key: "ArrowDown" });
expect(focusedOf(container)).toBe("gamma");
fireEvent.keyDown(list, { key: "j" });
expect(focusedOf(container)).toBe("gamma");
fireEvent.keyDown(list, { key: "ArrowUp" });
expect(focusedOf(container)).toBe("beta");
});
it("Home/End jump to the ends", () => {
const { container } = render(<List items={ITEMS} />);
const list = screen.getByLabelText("test list");
fireEvent.keyDown(list, { key: "End" });
expect(focusedOf(container)).toBe("gamma");
fireEvent.keyDown(list, { key: "Home" });
expect(focusedOf(container)).toBe("alpha");
});
it("Enter activates the focused item", () => {
const onActivate = vi.fn();
render(<List items={ITEMS} onActivate={onActivate} />);
const list = screen.getByLabelText("test list");
fireEvent.keyDown(list, { key: "j" });
fireEvent.keyDown(list, { key: "Enter" });
expect(onActivate).toHaveBeenCalledWith(1);
});
it("typing in an embedded input never moves the list (input guard)", () => {
const { container } = render(<List items={ITEMS} />);
const input = screen.getByLabelText("embedded search");
fireEvent.keyDown(input, { key: "j" });
fireEvent.keyDown(input, { key: "Enter" });
expect(focusedOf(container)).toBe("alpha");
});
it("window scope: keys work globally, Escape clears, input guard holds", () => {
const onEscape = vi.fn();
function WindowList() {
const { focusedIndex } = (
// eslint-disable-next-line react-hooks/rules-of-hooks
useListNavigation({ itemCount: 3, scope: "window", onEscape })
);
return (
<div>
<input aria-label="search" />
<span data-testid="focused">{focusedIndex}</span>
</div>
);
}
render(<WindowList />);
fireEvent.keyDown(window, { key: "j" });
expect(screen.getByTestId("focused").textContent).toBe("1");
fireEvent.keyDown(window, { key: "Escape" });
expect(onEscape).toHaveBeenCalled();
screen.getByLabelText("search").focus();
fireEvent.keyDown(screen.getByLabelText("search"), { key: "j" });
expect(screen.getByTestId("focused").textContent).toBe("1");
});
it("roving tabindex: exactly the focused item is tabbable", () => {
const { container } = render(<List items={ITEMS} />);
const list = screen.getByLabelText("test list");
fireEvent.keyDown(list, { key: "j" });
const options = container.querySelectorAll('[role="option"]');
expect(options[0].getAttribute("tabindex")).toBe("-1");
expect(options[1].getAttribute("tabindex")).toBe("0");
expect(options[2].getAttribute("tabindex")).toBe("-1");
});
});

View file

@ -0,0 +1,167 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
useRegisterShortcuts,
type ShortcutEntry,
} from "../../app/shortcutRegistry";
/**
* Shared list keyboard navigation (Wave R brief R0d).
*
* j/k + ArrowUp/ArrowDown move focus, Home/End jump, Enter activates.
* Roving tabindex; focus is list-scoped (the handler lives on the list
* container, not window), with an input guard so typing in an embedded
* SearchInput never moves the list.
*
* Registers its shortcuts in the live shortcut registry while mounted
* the KeyboardHelp overlay shows "j / k" only when a navigable list exists.
*/
const LIST_NAV_SHORTCUTS: readonly ShortcutEntry[] = [
{ id: "list-nav-move", keys: "j / k", action: "Navigate lists", order: 40 },
{ id: "list-nav-open", keys: "Enter", action: "Open selected item", order: 41 },
];
function isTextInput(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName.toLowerCase();
return tag === "input" || tag === "textarea" || target.isContentEditable;
}
export interface UseListNavigationOptions {
itemCount: number;
onActivate?: (index: number) => void;
/** Called whenever focus moves (selection-follows-focus is the caller's choice). */
onFocusChange?: (index: number) => void;
/**
* "container" (default): keys bind to the list element via listProps.
* "window": keys bind globally while mounted for routes whose primary
* surface IS the list (Gmail-style), with the same input guard.
*/
scope?: "container" | "window";
/** Escape handler (window scope only) — e.g. clear the selection. */
onEscape?: () => void;
}
export interface UseListNavigationResult {
focusedIndex: number;
setFocusedIndex: (index: number) => void;
/** Spread onto the list container. */
listProps: {
role: "listbox";
tabIndex: 0;
onKeyDown: (e: React.KeyboardEvent) => void;
};
/** Per-item props: roving tabindex + focus tracking. */
itemProps: (index: number) => {
role: "option";
"aria-selected": boolean;
tabIndex: 0 | -1;
onFocus: () => void;
ref: (el: HTMLElement | null) => void;
};
}
export function useListNavigation({
itemCount,
onActivate,
onFocusChange,
scope = "container",
onEscape,
}: UseListNavigationOptions): UseListNavigationResult {
const [focusedIndex, setFocusedIndexState] = useState(0);
const itemRefs = useRef(new Map<number, HTMLElement>());
useRegisterShortcuts(LIST_NAV_SHORTCUTS);
const moveFocus = useCallback(
(index: number) => {
const clamped = Math.max(0, Math.min(index, itemCount - 1));
setFocusedIndexState(clamped);
onFocusChange?.(clamped);
if (scope === "container") {
const el = itemRefs.current.get(clamped);
el?.focus();
el?.scrollIntoView({ block: "nearest" });
}
},
[itemCount, onFocusChange, scope],
);
const handleKey = useCallback(
(key: string, preventDefault: () => void): void => {
if (itemCount === 0) return;
switch (key) {
case "j":
case "ArrowDown":
preventDefault();
moveFocus(focusedIndex + 1);
break;
case "k":
case "ArrowUp":
preventDefault();
moveFocus(focusedIndex - 1);
break;
case "Home":
preventDefault();
moveFocus(0);
break;
case "End":
preventDefault();
moveFocus(itemCount - 1);
break;
case "Enter":
preventDefault();
onActivate?.(focusedIndex);
break;
}
},
[focusedIndex, itemCount, moveFocus, onActivate],
);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (isTextInput(e.target)) return;
handleKey(e.key, () => e.preventDefault());
},
[handleKey],
);
useEffect(() => {
if (scope !== "window") return;
const onWindowKeyDown = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (isTextInput(e.target)) return;
if (e.key === "Escape") {
if (onEscape) {
e.preventDefault();
onEscape();
}
return;
}
handleKey(e.key, () => e.preventDefault());
};
window.addEventListener("keydown", onWindowKeyDown);
return () => window.removeEventListener("keydown", onWindowKeyDown);
}, [scope, handleKey, onEscape]);
const itemProps = useCallback(
(index: number) => ({
role: "option" as const,
"aria-selected": index === focusedIndex,
tabIndex: (index === focusedIndex ? 0 : -1) as 0 | -1,
onFocus: () => setFocusedIndexState(index),
ref: (el: HTMLElement | null) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
},
}),
[focusedIndex],
);
return {
focusedIndex,
setFocusedIndex: moveFocus,
listProps: { role: "listbox", tabIndex: 0, onKeyDown },
itemProps,
};
}

View file

@ -22,6 +22,9 @@ import { MetadataTable } from "../design/components/MetadataTable/MetadataTable"
import { DigestBadge } from "../design/components/DigestBadge/DigestBadge";
import { Timestamp } from "../design/components/Timestamp/Timestamp";
import { SearchInput } from "../design/components/SearchInput/SearchInput";
import { Kbd } from "../design/components/primitives/Kbd";
import { Panel } from "../design/components/Panel/Panel";
import { VirtualizedList } from "../design/components/VirtualizedList/VirtualizedList";
export function PreviewPage() {
const [paletteOpen, setPaletteOpen] = useState(false);
@ -206,6 +209,36 @@ export function PreviewPage() {
<StableJsonViewer source='{"trace_hash":"4f80f7e12c7e","scenes":[{"detail":{"object":"lens","epsilon":1e-6}}],"state":"decoded"}' />
</section>
<section aria-labelledby="kbd-heading" className="grid gap-3">
<h2 id="kbd-heading" className="text-sm font-semibold text-[var(--color-text-secondary)]">Kbd</h2>
<p className="text-sm text-[var(--color-text-secondary)]">
Press <Kbd>\u2318K</Kbd> for the palette, <Kbd>j / k</Kbd> to move, <Kbd>Enter</Kbd> to open.
</p>
</section>
<section aria-labelledby="panel-heading" className="grid gap-3">
<h2 id="panel-heading" className="text-sm font-semibold text-[var(--color-text-secondary)]">Panel</h2>
<Panel title="Evidence" toolbar={<Kbd>/</Kbd>}>
<p className="m-0 text-sm text-[var(--color-text-secondary)]">Panel body content.</p>
</Panel>
</section>
<section aria-labelledby="vlist-heading" className="grid gap-3">
<h2 id="vlist-heading" className="text-sm font-semibold text-[var(--color-text-secondary)]">VirtualizedList (1,000 rows)</h2>
<VirtualizedList
items={Array.from({ length: 1000 }, (_, i) => `turn-${i}`)}
getKey={(item) => item}
renderItem={(item, _i, focused) => (
<div className={focused ? "bg-[var(--color-surface-raised)] px-2 py-1 text-sm" : "px-2 py-1 text-sm text-[var(--color-text-secondary)]"}>
{item}
</div>
)}
estimateSize={28}
height={160}
ariaLabel="Preview virtual list"
/>
</section>
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
</main>
);