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:
commit
0885e7594f
23 changed files with 977 additions and 92 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
41
workbench-ui/src/app/KeyboardHelp.test.tsx
Normal file
41
workbench-ui/src/app/KeyboardHelp.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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: "⌘1–0", action: "Navigate to route 1–10" },
|
||||
{ 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}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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")) {
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
89
workbench-ui/src/app/shortcutRegistry.ts
Normal file
89
workbench-ui/src/app/shortcutRegistry.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
26
workbench-ui/src/design/components/Panel/Panel.test.tsx
Normal file
26
workbench-ui/src/design/components/Panel/Panel.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
39
workbench-ui/src/design/components/Panel/Panel.tsx
Normal file
39
workbench-ui/src/design/components/Panel/Panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
18
workbench-ui/src/design/components/primitives/Kbd.tsx
Normal file
18
workbench-ui/src/design/components/primitives/Kbd.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
workbench-ui/src/design/hooks/useListNavigation.test.tsx
Normal file
121
workbench-ui/src/design/hooks/useListNavigation.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
167
workbench-ui/src/design/hooks/useListNavigation.ts
Normal file
167
workbench-ui/src/design/hooks/useListNavigation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue