diff --git a/workbench-ui/package.json b/workbench-ui/package.json index 33cd5e52..acfb96d8 100644 --- a/workbench-ui/package.json +++ b/workbench-ui/package.json @@ -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", diff --git a/workbench-ui/pnpm-lock.yaml b/workbench-ui/pnpm-lock.yaml index c54cba3f..92dd99fb 100644 --- a/workbench-ui/pnpm-lock.yaml +++ b/workbench-ui/pnpm-lock.yaml @@ -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 diff --git a/workbench-ui/src/app/KeyboardHelp.test.tsx b/workbench-ui/src/app/KeyboardHelp.test.tsx new file mode 100644 index 00000000..eed26db2 --- /dev/null +++ b/workbench-ui/src/app/KeyboardHelp.test.tsx @@ -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 {}} />; +} + +function HostWithList() { + useListNavigation({ itemCount: 3 }); + return {}} />; +} + +describe("KeyboardHelp (registry-driven)", () => { + it("renders rows from the shortcut registry, not a hand-maintained list", () => { + render(); + expect(screen.getByText("Test verb")).toBeInTheDocument(); + expect(screen.getByText("⌘T")).toBeInTheDocument(); + }); + + it("shows nothing for shortcuts no mounted component binds", () => { + render( {}} />); + // 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(); + expect(screen.getByText("Navigate lists")).toBeInTheDocument(); + expect(screen.getByText("Open selected item")).toBeInTheDocument(); + unmount(); + + render( {}} />); + expect(screen.queryByText("Navigate lists")).not.toBeInTheDocument(); + }); +}); diff --git a/workbench-ui/src/app/KeyboardHelp.tsx b/workbench-ui/src/app/KeyboardHelp.tsx index 5288d6fd..c3243194 100644 --- a/workbench-ui/src/app/KeyboardHelp.tsx +++ b/workbench-ui/src/app/KeyboardHelp.tsx @@ -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 ( @@ -27,18 +32,16 @@ export function KeyboardHelp({ Keyboard Shortcuts - Available keyboard shortcuts for the workbench. + Keyboard shortcuts currently active in the workbench.
- {SHORTCUTS.map((s) => ( + {shortcuts.map((s) => (
- - {s.keys} - + {s.keys}
{s.action} diff --git a/workbench-ui/src/app/Shell.tsx b/workbench-ui/src/app/Shell.tsx index 80cd91b8..366810e5 100644 --- a/workbench-ui/src/app/Shell.tsx +++ b/workbench-ui/src/app/Shell.tsx @@ -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 = ( +
+ + + +
+ ); + return (
@@ -61,21 +98,18 @@ function ShellInner() {
-
- - - -
- - {inspectorOpen && ( -
- -
- )} +
+ {inspectorOpen ? ( + // Resizable main/inspector split; width persists via the + // SplitPane id (guarded storage access inside SplitPane). + + {mainSurface} + + + ) : ( + mainSurface + )} +
diff --git a/workbench-ui/src/app/commandRegistry.ts b/workbench-ui/src/app/commandRegistry.ts index 26133880..01c9118c 100644 --- a/workbench-ui/src/app/commandRegistry.ts +++ b/workbench-ui/src/app/commandRegistry.ts @@ -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; } diff --git a/workbench-ui/src/app/evals/EvalsRoute.tsx b/workbench-ui/src/app/evals/EvalsRoute.tsx index 7f504ab9..f366eba9 100644 --- a/workbench-ui/src/app/evals/EvalsRoute.tsx +++ b/workbench-ui/src/app/evals/EvalsRoute.tsx @@ -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 " 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(() => { diff --git a/workbench-ui/src/app/evals/evals.test.tsx b/workbench-ui/src/app/evals/evals.test.tsx index b6aa28a0..b1feb85c 100644 --- a/workbench-ui/src/app/evals/evals.test.tsx +++ b/workbench-ui/src/app/evals/evals.test.tsx @@ -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")) { diff --git a/workbench-ui/src/app/proposals/ProposalsRoute.tsx b/workbench-ui/src/app/proposals/ProposalsRoute.tsx index 540415f1..642adfe1 100644 --- a/workbench-ui/src/app/proposals/ProposalsRoute.tsx +++ b/workbench-ui/src/app/proposals/ProposalsRoute.tsx @@ -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( isProposalFilter(filterFromUrl) ? filterFromUrl : "pending", ); + const [search, setSearch] = useState(""); - const [focusedIndex, setFocusedIndex] = useState(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() { )}
+ + {proposals.length === 0 ? ( - -
- ); - } - // Initialize grouped structure const grouped: Record = { 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 ( +
+ +
+ ); + } + return (