}) {
+ const { data } = subject;
+ return (
+
+
Turn #{subject.turnId}
+ {data.trace_hash && (
+
+ )}
+ 80 ? `${data.surface.slice(0, 80)}...` : data.surface,
+ },
+ {
+ key: "grounding",
+ value: ,
+ },
+ {
+ key: "epistemic",
+ value: ,
+ },
+ {
+ key: "clearance",
+ value: ,
+ },
+ { key: "cost", value: `${data.turn_cost_ms}ms`, mono: true },
+ { key: "refusal", value: data.refusal_emitted ? "yes" : "no" },
+ { key: "hedge", value: data.hedge_injected ? "yes" : "no" },
+ ]}
+ />
+
+ );
+}
+
+function ProposalInspector({ subject }: { subject: Extract }) {
+ const { data } = subject;
+ return (
+
+
Proposal
+
+
+ );
+}
+
+function ArtifactInspector({ subject }: { subject: Extract }) {
+ const { data } = subject;
+ return (
+
+
Artifact
+ {data.digest && }
+ }] : []),
+ ]}
+ />
+
+ );
+}
+
+function EvalInspector({ subject }: { subject: Extract }) {
+ const { data } = subject;
+ return (
+
+
Eval: {data.lane}
+
+
+ );
+}
+
+function NoneInspector() {
+ return (
+
+
Select an item to inspect its evidence.
+
+ );
+}
+
+function InspectorContent() {
+ const { subject } = useEvidenceSubject();
+
+ switch (subject.kind) {
+ case "turn":
+ return ;
+ case "proposal":
+ return ;
+ case "artifact":
+ return ;
+ case "eval_result":
+ return ;
+ case "none":
+ return ;
+ }
+}
+
+export function RightInspector() {
return (
);
}
diff --git a/workbench-ui/src/app/Shell.tsx b/workbench-ui/src/app/Shell.tsx
index 188b5810..0740812c 100644
--- a/workbench-ui/src/app/Shell.tsx
+++ b/workbench-ui/src/app/Shell.tsx
@@ -1,27 +1,46 @@
+import { useState, useCallback } from "react";
import { Outlet } from "react-router-dom";
import { TopBar } from "./TopBar";
import { LeftNav } from "./LeftNav";
import { StatusFooter } from "./StatusFooter";
import { RightInspector } from "./RightInspector";
import { ApiErrorBoundary } from "./ApiErrorBoundary";
+import { EvidenceProvider, useEvidenceSubject } from "./evidenceContext";
+import { KeyboardHelp } from "./KeyboardHelp";
+import { useGlobalKeyboard } from "./useGlobalKeyboard";
-export function Shell() {
- // RightInspector defaults to collapsed in W-027
- const inspectorCollapsed = true;
+function ShellInner() {
+ const { inspectorOpen, toggleInspector } = useEvidenceSubject();
+ const [helpOpen, setHelpOpen] = useState(false);
+ const [paletteOpen, setPaletteOpen] = useState(false);
+
+ const onTogglePalette = useCallback(() => {
+ setPaletteOpen((v) => !v);
+ }, []);
+
+ const onShowHelp = useCallback(() => {
+ setHelpOpen(true);
+ }, []);
+
+ useGlobalKeyboard({
+ onTogglePalette,
+ onToggleInspector: toggleInspector,
+ onShowHelp,
+ });
return (
-
+
@@ -38,15 +57,25 @@ export function Shell() {
- {!inspectorCollapsed && (
+ {inspectorOpen && (
-
+
)}
+
+
);
}
+
+export function Shell() {
+ return (
+
+
+
+ );
+}
diff --git a/workbench-ui/src/app/TopBar.tsx b/workbench-ui/src/app/TopBar.tsx
index 8b6f986c..2881e2ff 100644
--- a/workbench-ui/src/app/TopBar.tsx
+++ b/workbench-ui/src/app/TopBar.tsx
@@ -1,23 +1,15 @@
-import { useState } from "react";
import { CommandPalette } from "../design/components/primitives/CommandPalette";
import { useRuntimeStatus } from "../api/queries";
-export function TopBar() {
- const [paletteOpen, setPaletteOpen] = useState(false);
+export function TopBar({
+ paletteOpen,
+ onPaletteOpenChange,
+}: {
+ paletteOpen: boolean;
+ onPaletteOpenChange: (open: boolean) => void;
+}) {
const { isLoading, isError } = useRuntimeStatus();
- function openPalette() {
- setPaletteOpen(true);
- }
-
- // ⌘K global shortcut
- function handleKeyDown(e: React.KeyboardEvent
) {
- if (e.key === "Enter" || e.key === " ") {
- e.preventDefault();
- openPalette();
- }
- }
-
const connectionPill = (() => {
if (isLoading) {
return (
@@ -54,18 +46,15 @@ export function TopBar() {
data-region="topbar"
className="flex items-center gap-4 border-b border-[var(--color-border-subtle)] bg-[var(--color-surface-base)] px-4 py-2"
>
- {/* Wordmark */}
CORE Workbench
- {/* Search / Command Palette trigger */}
- {/* Connection pill */}
{connectionPill}
-
+
);
}
diff --git a/workbench-ui/src/app/commandRegistry.ts b/workbench-ui/src/app/commandRegistry.ts
new file mode 100644
index 00000000..26133880
--- /dev/null
+++ b/workbench-ui/src/app/commandRegistry.ts
@@ -0,0 +1,97 @@
+import { useCallback, useSyncExternalStore } from "react";
+
+export interface Command {
+ id: string;
+ label: string;
+ section: string;
+ shortcut?: string;
+ action: () => void;
+}
+
+type Listener = () => void;
+
+class CommandStore {
+ private commands = new Map();
+ private listeners = new Set();
+ 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) {
+ 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.
+ }
+}
diff --git a/workbench-ui/src/app/evidenceContext.test.tsx b/workbench-ui/src/app/evidenceContext.test.tsx
new file mode 100644
index 00000000..68082743
--- /dev/null
+++ b/workbench-ui/src/app/evidenceContext.test.tsx
@@ -0,0 +1,102 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { EvidenceProvider, useEvidenceSubject } from "./evidenceContext";
+import type { ChatTurnResult } from "../types/api";
+
+const MOCK_TURN: ChatTurnResult = {
+ prompt: "test",
+ surface: "answer",
+ articulation_surface: "answer",
+ walk_surface: "walk",
+ grounding_source: "teaching",
+ epistemic_state: "decoded",
+ normative_clearance: "cleared",
+ normative_detail: "",
+ trace_hash: "sha256:abc",
+ refusal_emitted: false,
+ hedge_injected: false,
+ mutation_mode: "runtime_turn",
+ identity_verdict: null,
+ safety_verdict: null,
+ ethics_verdict: null,
+ proposal_candidates: [],
+ turn_cost_ms: 10,
+ checkpoint_emitted: false,
+};
+
+function TestConsumer() {
+ const { subject, setSubject, clearSubject, inspectorOpen, toggleInspector } =
+ useEvidenceSubject();
+ return (
+
+ {subject.kind}
+ {inspectorOpen ? "open" : "closed"}
+
+
+
+
+ );
+}
+
+describe("EvidenceContext", () => {
+ it("starts with kind=none and inspector closed", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByTestId("kind")).toHaveTextContent("none");
+ expect(screen.getByTestId("inspector")).toHaveTextContent("closed");
+ });
+
+ it("setSubject updates kind", () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByText("set-turn"));
+ expect(screen.getByTestId("kind")).toHaveTextContent("turn");
+ });
+
+ it("clearSubject resets to none", () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByText("set-turn"));
+ expect(screen.getByTestId("kind")).toHaveTextContent("turn");
+ fireEvent.click(screen.getByText("clear"));
+ expect(screen.getByTestId("kind")).toHaveTextContent("none");
+ });
+
+ it("toggleInspector toggles open state", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByTestId("inspector")).toHaveTextContent("closed");
+ fireEvent.click(screen.getByText("toggle"));
+ expect(screen.getByTestId("inspector")).toHaveTextContent("open");
+ fireEvent.click(screen.getByText("toggle"));
+ expect(screen.getByTestId("inspector")).toHaveTextContent("closed");
+ });
+
+ it("throws if used outside provider", () => {
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ expect(() => render()).toThrow(
+ "useEvidenceSubject must be used within EvidenceProvider",
+ );
+ spy.mockRestore();
+ });
+});
diff --git a/workbench-ui/src/app/evidenceContext.tsx b/workbench-ui/src/app/evidenceContext.tsx
new file mode 100644
index 00000000..6d84f0dc
--- /dev/null
+++ b/workbench-ui/src/app/evidenceContext.tsx
@@ -0,0 +1,73 @@
+import {
+ createContext,
+ useContext,
+ useState,
+ useCallback,
+ type ReactNode,
+} from "react";
+import type {
+ ChatTurnResult,
+ ProposalDetail,
+ ArtifactDetail,
+ EvalRunResult,
+} from "../types/api";
+
+export type EvidenceSubject =
+ | { kind: "turn"; turnId: number; data: ChatTurnResult }
+ | { kind: "proposal"; proposalId: string; data: ProposalDetail }
+ | { kind: "artifact"; artifactId: string; data: ArtifactDetail }
+ | { kind: "eval_result"; lane: string; data: EvalRunResult }
+ | { kind: "none" };
+
+interface EvidenceContextValue {
+ subject: EvidenceSubject;
+ setSubject: (subject: EvidenceSubject) => void;
+ clearSubject: () => void;
+ inspectorOpen: boolean;
+ setInspectorOpen: (open: boolean) => void;
+ toggleInspector: () => void;
+}
+
+const NONE_SUBJECT: EvidenceSubject = { kind: "none" };
+
+const EvidenceContext = createContext(null);
+
+export function EvidenceProvider({ children }: { children: ReactNode }) {
+ const [subject, setSubjectState] = useState(NONE_SUBJECT);
+ const [inspectorOpen, setInspectorOpen] = useState(false);
+
+ const setSubject = useCallback((s: EvidenceSubject) => {
+ setSubjectState(s);
+ }, []);
+
+ const clearSubject = useCallback(() => {
+ setSubjectState(NONE_SUBJECT);
+ }, []);
+
+ const toggleInspector = useCallback(() => {
+ setInspectorOpen((prev) => !prev);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useEvidenceSubject(): EvidenceContextValue {
+ const ctx = useContext(EvidenceContext);
+ if (!ctx) {
+ throw new Error("useEvidenceSubject must be used within EvidenceProvider");
+ }
+ return ctx;
+}
diff --git a/workbench-ui/src/app/useGlobalKeyboard.ts b/workbench-ui/src/app/useGlobalKeyboard.ts
new file mode 100644
index 00000000..bfe11d2c
--- /dev/null
+++ b/workbench-ui/src/app/useGlobalKeyboard.ts
@@ -0,0 +1,71 @@
+import { useEffect } from "react";
+import { useNavigate } from "react-router-dom";
+
+const ROUTE_KEYS: Record = {
+ "1": "/chat",
+ "2": "/trace",
+ "3": "/replay",
+ "4": "/proposals",
+ "5": "/evals",
+ "6": "/runs",
+ "7": "/packs",
+ "8": "/vault",
+ "9": "/audit",
+ "0": "/settings",
+};
+
+interface GlobalKeyboardOptions {
+ onTogglePalette: () => void;
+ onToggleInspector: () => void;
+ onShowHelp: () => void;
+}
+
+function isInputFocused(): boolean {
+ const el = document.activeElement;
+ if (!el) return false;
+ const tag = el.tagName?.toLowerCase();
+ return tag === "input" || tag === "textarea" || (el as HTMLElement).isContentEditable === true;
+}
+
+export function useGlobalKeyboard({
+ onTogglePalette,
+ onToggleInspector,
+ onShowHelp,
+}: GlobalKeyboardOptions) {
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ function handleKeyDown(e: KeyboardEvent) {
+ const meta = e.metaKey || e.ctrlKey;
+
+ if (meta && e.key.toLowerCase() === "k") {
+ e.preventDefault();
+ onTogglePalette();
+ return;
+ }
+
+ if (meta && e.key.toLowerCase() === "i") {
+ e.preventDefault();
+ onToggleInspector();
+ return;
+ }
+
+ if (meta && ROUTE_KEYS[e.key]) {
+ e.preventDefault();
+ navigate(ROUTE_KEYS[e.key]);
+ return;
+ }
+
+ if (isInputFocused()) return;
+
+ if (e.key === "?" && !meta) {
+ e.preventDefault();
+ onShowHelp();
+ return;
+ }
+ }
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [navigate, onTogglePalette, onToggleInspector, onShowHelp]);
+}
diff --git a/workbench-ui/src/design/components/primitives/CommandPalette.keyboard.test.tsx b/workbench-ui/src/design/components/primitives/CommandPalette.keyboard.test.tsx
index 36e558c9..21a26601 100644
--- a/workbench-ui/src/design/components/primitives/CommandPalette.keyboard.test.tsx
+++ b/workbench-ui/src/design/components/primitives/CommandPalette.keyboard.test.tsx
@@ -1,6 +1,6 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { useState } from "react";
import { CommandPalette } from "./CommandPalette";
@@ -18,6 +18,12 @@ function PaletteHarness({ initialOpen = false }: { initialOpen?: boolean }) {
}
describe("CommandPalette keyboard contract", () => {
+ beforeEach(() => {
+ // Recent-items leak between tests (the Enter test writes one); start clean
+ // so traversal indices map to the deterministic Navigate command order.
+ localStorage.clear();
+ });
+
it("⌘K still opens the palette (regression)", async () => {
const user = userEvent.setup();
// We test the open prop directly — ⌘K is handled by the host (TopBar/PreviewPage)
@@ -35,25 +41,29 @@ describe("CommandPalette keyboard contract", () => {
expect(screen.queryByRole("dialog", { name: "Command Palette" })).not.toBeInTheDocument();
});
- it("ArrowDown/ArrowUp traverses the three commands", async () => {
+ it("ArrowDown/ArrowUp traverses the navigation commands and clamps at both ends", async () => {
const user = userEvent.setup();
render();
const dialog = screen.getByRole("dialog", { name: "Command Palette" });
- // All three commands should be present
- const chatBtn = screen.getByRole("button", { name: "Open Chat" });
- const proposalsBtn = screen.getByRole("button", { name: "Open Proposals" });
- const evalsBtn = screen.getByRole("button", { name: "Open Evals" });
- expect(chatBtn).toBeInTheDocument();
- expect(proposalsBtn).toBeInTheDocument();
- expect(evalsBtn).toBeInTheDocument();
+ // The Navigate section exposes one command per route, in route order.
+ expect(screen.getByRole("button", { name: "Open Chat" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Open Trace" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Open Replay" })).toBeInTheDocument();
+
+ const items = dialog.querySelectorAll('[role="option"]');
+ expect(items.length).toBe(10);
+ const lastIndex = items.length - 1;
// Initially first item (index 0) is focused — check aria-selected
- const items = dialog.querySelectorAll('[role="option"]');
expect(items[0].getAttribute("aria-selected")).toBe("true");
expect(items[1].getAttribute("aria-selected")).toBe("false");
+ // ArrowUp at the top stays clamped at index 0
+ await user.keyboard("{ArrowUp}");
+ expect(items[0].getAttribute("aria-selected")).toBe("true");
+
// ArrowDown moves to index 1
await user.keyboard("{ArrowDown}");
expect(items[0].getAttribute("aria-selected")).toBe("false");
@@ -63,13 +73,15 @@ describe("CommandPalette keyboard contract", () => {
await user.keyboard("{ArrowDown}");
expect(items[2].getAttribute("aria-selected")).toBe("true");
- // ArrowDown at end stays at 2 (clamped)
- await user.keyboard("{ArrowDown}");
- expect(items[2].getAttribute("aria-selected")).toBe("true");
-
// ArrowUp moves back to index 1
await user.keyboard("{ArrowUp}");
expect(items[1].getAttribute("aria-selected")).toBe("true");
+
+ // Holding ArrowDown past the end clamps at the last index
+ for (let i = 0; i < items.length + 2; i++) {
+ await user.keyboard("{ArrowDown}");
+ }
+ expect(items[lastIndex].getAttribute("aria-selected")).toBe("true");
});
it("Enter activates the focused command and closes the palette", async () => {
diff --git a/workbench-ui/src/design/components/primitives/CommandPalette.tsx b/workbench-ui/src/design/components/primitives/CommandPalette.tsx
index f2420694..e1a82d97 100644
--- a/workbench-ui/src/design/components/primitives/CommandPalette.tsx
+++ b/workbench-ui/src/design/components/primitives/CommandPalette.tsx
@@ -1,65 +1,148 @@
import * as Dialog from "@radix-ui/react-dialog";
import { Search } from "lucide-react";
-import { useRef, useState, useEffect, useCallback } from "react";
+import { useRef, useState, useEffect, useCallback, useMemo } from "react";
import { useNavigate, useInRouterContext } from "react-router-dom";
+import {
+ useCommands,
+ getRecentItems,
+ pushRecentItem,
+ type Command,
+ type RecentItem,
+} from "../../../app/commandRegistry";
-interface Command {
- name: string;
- path: string;
-}
-
-const COMMANDS: Command[] = [
- { name: "Open Chat", path: "/chat" },
- { name: "Open Proposals", path: "/proposals" },
- { name: "Open Evals", path: "/evals" },
+const NAV_COMMANDS: Command[] = [
+ { id: "nav-chat", label: "Open Chat", section: "Navigate", shortcut: "⌘1", action: () => {} },
+ { id: "nav-trace", label: "Open Trace", section: "Navigate", shortcut: "⌘2", action: () => {} },
+ { id: "nav-replay", label: "Open Replay", section: "Navigate", shortcut: "⌘3", action: () => {} },
+ { id: "nav-proposals", label: "Open Proposals", section: "Navigate", shortcut: "⌘4", action: () => {} },
+ { id: "nav-evals", label: "Open Evals", section: "Navigate", shortcut: "⌘5", action: () => {} },
+ { id: "nav-runs", label: "Open Runs", section: "Navigate", shortcut: "⌘6", action: () => {} },
+ { id: "nav-packs", label: "Open Packs", section: "Navigate", shortcut: "⌘7", action: () => {} },
+ { id: "nav-vault", label: "Open Vault", section: "Navigate", shortcut: "⌘8", action: () => {} },
+ { id: "nav-audit", label: "Open Audit", section: "Navigate", shortcut: "⌘9", action: () => {} },
+ { id: "nav-settings", label: "Open Settings", section: "Navigate", shortcut: "⌘0", action: () => {} },
];
-// Inner shell that safely calls useNavigate — only rendered inside a Router.
+const NAV_PATHS: Record = {
+ "nav-chat": "/chat",
+ "nav-trace": "/trace",
+ "nav-replay": "/replay",
+ "nav-proposals": "/proposals",
+ "nav-evals": "/evals",
+ "nav-runs": "/runs",
+ "nav-packs": "/packs",
+ "nav-vault": "/vault",
+ "nav-audit": "/audit",
+ "nav-settings": "/settings",
+};
+
+interface DisplayItem {
+ id: string;
+ label: string;
+ section: string;
+ shortcut?: string;
+ type: "command" | "recent";
+}
+
+interface SectionItem {
+ item: DisplayItem;
+ globalIndex: number;
+}
+
function RouterCommandPalette(props: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const navigate = useNavigate();
+ const registeredCommands = useCommands();
+
const activate = useCallback(
- (cmd: Command) => {
- navigate(cmd.path);
+ (item: DisplayItem) => {
+ const navPath = NAV_PATHS[item.id];
+ if (navPath) {
+ navigate(navPath);
+ pushRecentItem({ label: item.label, path: navPath });
+ } else if (item.type === "recent") {
+ const recent = getRecentItems().find((r) => r.label === item.label);
+ if (recent) navigate(recent.path);
+ } else {
+ const cmd = registeredCommands.find((c) => c.id === item.id);
+ cmd?.action();
+ }
props.onOpenChange(false);
},
- [navigate, props],
+ [navigate, registeredCommands, props],
+ );
+
+ const allCommands = useMemo(() => {
+ const navCmds: DisplayItem[] = NAV_COMMANDS.map((c) => ({
+ ...c,
+ type: "command" as const,
+ }));
+ const regCmds: DisplayItem[] = registeredCommands
+ .filter((c) => !NAV_PATHS[c.id])
+ .map((c) => ({ ...c, type: "command" as const }));
+ return [...navCmds, ...regCmds];
+ }, [registeredCommands]);
+
+ return (
+
);
- return ;
}
-// Fallback for design-system preview (no Router).
function FallbackCommandPalette(props: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
- const activate = useCallback(
- (_cmd: Command) => {
- props.onOpenChange(false);
- },
- [props],
+ const commands: DisplayItem[] = NAV_COMMANDS.map((c) => ({
+ ...c,
+ type: "command" as const,
+ }));
+ return (
+ props.onOpenChange(false)}
+ />
);
- return ;
}
function CommandPaletteContent({
open,
onOpenChange,
+ commands,
onActivate,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
- onActivate: (cmd: Command) => void;
+ commands: readonly DisplayItem[];
+ onActivate: (item: DisplayItem) => void;
}) {
const [query, setQuery] = useState("");
const [focusedIndex, setFocusedIndex] = useState(0);
const inputRef = useRef(null);
- const filtered = COMMANDS.filter((cmd) =>
- cmd.name.toLowerCase().includes(query.toLowerCase()),
- );
+ const recentItems: DisplayItem[] = useMemo(() => {
+ if (query) return [];
+ return getRecentItems().map((r) => ({
+ id: `recent-${r.path}`,
+ label: r.label,
+ section: "Recent",
+ type: "recent" as const,
+ }));
+ }, [query]);
+
+ const filtered = useMemo(() => {
+ const q = query.toLowerCase();
+ const matchedCmds = q
+ ? commands.filter((cmd) => cmd.label.toLowerCase().includes(q))
+ : commands;
+ return [...recentItems, ...matchedCmds];
+ }, [query, commands, recentItems]);
useEffect(() => {
if (open) {
@@ -82,11 +165,25 @@ function CommandPaletteContent({
setFocusedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
- const cmd = filtered[focusedIndex];
- if (cmd) onActivate(cmd);
+ const item = filtered[focusedIndex];
+ if (item) onActivate(item);
}
}
+ const sections = useMemo(() => {
+ const map = new Map();
+ filtered.forEach((item, globalIndex) => {
+ const existing = map.get(item.section);
+ const sectionItem = { item, globalIndex };
+ if (existing) {
+ existing.push(sectionItem);
+ } else {
+ map.set(item.section, [sectionItem]);
+ }
+ });
+ return map;
+ }, [filtered]);
+
return (
@@ -113,23 +210,37 @@ function CommandPaletteContent({
{filtered.length === 0 ? (
No commands match.
) : (
-
- {filtered.map((cmd, i) => (
- -
-
+
+ {Array.from(sections.entries()).map(([section, items]) => (
+ -
+
+ {section}
+
+
+ {items.map(({ item, globalIndex }) => (
+ -
+
+
+ ))}
+
))}