core/workbench-ui/src/app/commandRegistry.ts
Shay c54a942871 feat(workbench): interaction substrate — list nav, virtualization, panel chrome, inspector resize, palette verbs (Wave R R0d)
- useListNavigation: shared j/k/arrows/Home/End/Enter keyboard spine,
  container or window scope, input guard, roving tabindex. Replaces
  ProposalsRoute's bespoke window listener (one pattern app-wide).
- VirtualizedList over @tanstack/react-virtual composing the hook;
  evidence lists render O(viewport).
- Panel: standard header/toolbar/body chrome for routes.
- Inspector resize: Shell center cell becomes SplitPane(main, inspector)
  when open; width persists via guarded storage (SplitPane id).
- Palette action verbs: Command gains kind (navigate|action); Shell
  registers Toggle inspector + Copy evidence link; EvalsRoute registers
  'Run eval lane <lane>' per read-only lane (same POST /evals/run).
- shortcutRegistry: binding sites register what they handle while
  mounted; KeyboardHelp renders FROM the registry — advertising an
  unbound shortcut is structurally impossible. j/k, /, Enter rows
  (removed in R0a as false) return as live registry entries.
- Kbd primitive (front-loaded from R1); SearchInput registers '/';
  SearchInput wired into Proposals + Replay lists with client filters.
- Preview entries for Kbd/Panel/VirtualizedList.

Verified: build green; vitest 33 files / 245 tests EXIT=0 (~5.5s);
playwright 12/12.
2026-06-12 12:48:02 -07:00

99 lines
2.4 KiB
TypeScript

import { useCallback, useSyncExternalStore } from "react";
export interface Command {
id: string;
label: string;
section: string;
/** "navigate" routes somewhere; "action" performs a verb. */
kind: "navigate" | "action";
shortcut?: string;
action: () => void;
}
type Listener = () => void;
class CommandStore {
private commands = new Map<string, Command>();
private listeners = new Set<Listener>();
private cached: readonly Command[] = [];
register(cmds: readonly Command[]) {
for (const cmd of cmds) {
this.commands.set(cmd.id, cmd);
}
this.cached = Array.from(this.commands.values());
this.notify();
}
unregister(ids: readonly string[]) {
for (const id of ids) {
this.commands.delete(id);
}
this.cached = Array.from(this.commands.values());
this.notify();
}
getSnapshot(): readonly Command[] {
return this.cached;
}
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify() {
for (const l of this.listeners) l();
}
}
const store = new CommandStore();
const subscribe = (l: Listener) => store.subscribe(l);
const getSnapshot = () => store.getSnapshot();
export function useCommands(): readonly Command[] {
return useSyncExternalStore(subscribe, getSnapshot);
}
export function useCommandRegistry() {
const register = useCallback((cmds: readonly Command[]) => {
store.register(cmds);
}, []);
const unregister = useCallback((ids: readonly string[]) => {
store.unregister(ids);
}, []);
return { register, unregister };
}
const RECENT_KEY = "core-recent-evidence";
const MAX_RECENT = 10;
export interface RecentItem {
label: string;
path: string;
timestamp: number;
}
export function getRecentItems(): RecentItem[] {
try {
const raw = localStorage.getItem(RECENT_KEY);
if (!raw) return [];
return JSON.parse(raw) as RecentItem[];
} catch {
return [];
}
}
export function pushRecentItem(item: Omit<RecentItem, "timestamp">) {
const items = getRecentItems().filter((r) => r.path !== item.path);
items.unshift({ ...item, timestamp: Date.now() });
if (items.length > MAX_RECENT) items.length = MAX_RECENT;
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(items));
} catch {
// Recent-item persistence is best-effort. Restricted/private storage
// contexts must not break command activation.
}
}