From 673962fad86a14109c2a4660c5f2e308c7c5a222 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Sat, 11 Apr 2026 13:22:56 +0000 Subject: [PATCH] feat(nexus): CommandPaletteContext replaces Cmd+K shim Introduces a provider that owns the palette open state and installs a single document-level Cmd+K / Ctrl+K listener. Replaces the Phase 8 pattern where CmdKButton synthesized KeyboardEvents and CommandPalette attached its own listener inside a useEffect. Used by CmdKButton (setOpen(true)), CommandPalette (open/setOpen), and Layout's onSearch wiring. Tests cover direct toggle, provider-level keyboard listener (both metaKey and ctrlKey), and the missing-provider throw path. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/context/CommandPaletteContext.test.tsx | 153 ++++++++++++++++++ ui/src/context/CommandPaletteContext.tsx | 96 +++++++++++ 2 files changed, 249 insertions(+) create mode 100644 ui/src/context/CommandPaletteContext.test.tsx create mode 100644 ui/src/context/CommandPaletteContext.tsx diff --git a/ui/src/context/CommandPaletteContext.test.tsx b/ui/src/context/CommandPaletteContext.test.tsx new file mode 100644 index 00000000..b86556c0 --- /dev/null +++ b/ui/src/context/CommandPaletteContext.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + CommandPaletteProvider, + useCommandPalette, +} from "./CommandPaletteContext"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +describe("CommandPaletteContext", () => { + let container: HTMLDivElement; + let root: ReturnType | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(() => { + if (root) { + act(() => { + root!.unmount(); + }); + root = null; + } + if (container.parentNode) container.remove(); + }); + + function renderWithProvider( + Consumer: React.FC, + { registerKeyboardShortcut = true, initialOpen = false } = {}, + ) { + root = createRoot(container); + act(() => { + root!.render( + + + , + ); + }); + } + + it("starts closed and opens via setOpen(true)", () => { + let openValue = false; + let setOpenRef: ((v: boolean) => void) | null = null; + + const Consumer = () => { + const ctx = useCommandPalette(); + openValue = ctx.open; + setOpenRef = ctx.setOpen; + return
{ctx.open ? "open" : "closed"}
; + }; + + renderWithProvider(Consumer, { registerKeyboardShortcut: false }); + expect(openValue).toBe(false); + expect(container.textContent).toContain("closed"); + + act(() => { + setOpenRef!(true); + }); + expect(container.textContent).toContain("open"); + }); + + it("toggle() flips the open state", () => { + let toggleRef: (() => void) | null = null; + + const Consumer = () => { + const ctx = useCommandPalette(); + toggleRef = ctx.toggle; + return
{ctx.open ? "open" : "closed"}
; + }; + + renderWithProvider(Consumer, { registerKeyboardShortcut: false }); + expect(container.textContent).toContain("closed"); + + act(() => { + toggleRef!(); + }); + expect(container.textContent).toContain("open"); + + act(() => { + toggleRef!(); + }); + expect(container.textContent).toContain("closed"); + }); + + it("registers a global Cmd+K listener that toggles open", () => { + const Consumer = () => { + const ctx = useCommandPalette(); + return
{ctx.open ? "open" : "closed"}
; + }; + + renderWithProvider(Consumer, { registerKeyboardShortcut: true }); + expect(container.textContent).toContain("closed"); + + act(() => { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + metaKey: true, + bubbles: true, + cancelable: true, + }), + ); + }); + expect(container.textContent).toContain("open"); + }); + + it("also listens for Ctrl+K", () => { + const Consumer = () => { + const ctx = useCommandPalette(); + return
{ctx.open ? "open" : "closed"}
; + }; + + renderWithProvider(Consumer, { registerKeyboardShortcut: true }); + + act(() => { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "k", + ctrlKey: true, + bubbles: true, + cancelable: true, + }), + ); + }); + expect(container.textContent).toContain("open"); + }); + + it("throws when useCommandPalette is used outside a provider", () => { + const Consumer = () => { + useCommandPalette(); + return null; + }; + + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + root = createRoot(container); + expect(() => + act(() => { + root!.render(); + }), + ).toThrow(/CommandPaletteProvider/); + spy.mockRestore(); + }); +}); diff --git a/ui/src/context/CommandPaletteContext.tsx b/ui/src/context/CommandPaletteContext.tsx new file mode 100644 index 00000000..f9b2af97 --- /dev/null +++ b/ui/src/context/CommandPaletteContext.tsx @@ -0,0 +1,96 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; + +/** + * CommandPaletteContext — Phase 14 replacement for the Phase 8 keyboard + * shim. The provider owns the palette's open state and installs a single + * document-level keydown listener for Cmd+K / Ctrl+K. Consumers include: + * + * - `CmdKButton`: calls `setOpen(true)` instead of dispatching synthetic + * KeyboardEvents at document. + * - `CommandPalette`: reads `open` from context and closes via + * `setOpen(false)`. + * - `useKeyboardShortcuts.onSearch`: wired through Layout to + * `setOpen(true)` so the destructure bug no longer matters. + */ + +export interface CommandPaletteContextValue { + open: boolean; + setOpen: (next: boolean) => void; + toggle: () => void; +} + +const CommandPaletteContext = createContext< + CommandPaletteContextValue | undefined +>(undefined); + +interface CommandPaletteProviderProps { + children: ReactNode; + /** Optional initial state for tests. */ + initialOpen?: boolean; + /** + * When false the provider will not attach the global Cmd+K listener. Used + * by tests that want to exercise the API without racing on document + * events. + */ + registerKeyboardShortcut?: boolean; +} + +export function CommandPaletteProvider({ + children, + initialOpen = false, + registerKeyboardShortcut = true, +}: CommandPaletteProviderProps) { + const [open, setOpenState] = useState(initialOpen); + + const setOpen = useCallback((next: boolean) => { + setOpenState(next); + }, []); + + const toggle = useCallback(() => { + setOpenState((prev) => !prev); + }, []); + + useEffect(() => { + if (!registerKeyboardShortcut) return; + if (typeof document === "undefined") return; + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpenState((prev) => !prev); + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [registerKeyboardShortcut]); + + const value = useMemo( + () => ({ open, setOpen, toggle }), + [open, setOpen, toggle], + ); + + return ( + + {children} + + ); +} + +export function useCommandPalette(): CommandPaletteContextValue { + const ctx = useContext(CommandPaletteContext); + if (!ctx) { + throw new Error( + "useCommandPalette must be used within a CommandPaletteProvider", + ); + } + return ctx; +}