"use client"; /** * Keyboard shortcuts (shortcut wave A, 2026-09-17) — the single document-level * keydown listener. Owns the pieces that belong to no surface in particular: * * - Tab is DEAD: browser tab-focus navigation is abandoned by owner decision * (2026-09-17). Tab is suppressed everywhere (preventDefault, no-op) and * Shift+Tab cycles the thinking level instead. * - Esc is a layered dismiss: topmost popover first (popovers stopPropagation * on their own Esc — usePopoverDismiss + ModelControl), then dictation * CANCEL (discard the transcript, owner 2026-09-17 round 3), then abort of * an in-flight turn. * - Global actions dispatch lh:kbd- window events; surfaces that are * mounted listen (the lh:chat-renamed pattern). A surface that isn't * mounted simply doesn't listen — an F2 on the graph view is a no-op, not * an error. Actions live in src/core/keyboard.ts (registry + persisted * overrides), the ONE table the settings milestone will render as its * editor. * * Listeners attach at the WINDOW level (bubble's last stop) so every * component-level handler — popovers' own Esc, the composer's Esc — gets the * event first and can claim it with stopPropagation. */ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { actionForBinding, bindingForAction, eventBinding, formatBinding, isTypingSurface, kbdEventForAction, KEYBOARD_ACTIONS, } from "@/src/core"; import { isDictationActive } from "@/components/dictation"; import { useSettingsDialog } from "@/components/settings/settings-dialog.tsx"; // Re-exported for the contract pin (tests/keyboard-toggle-sidebar.test.ts): // the dispatcher composes event names through the ONE helper — a second // spelling here is how the ⌘B bug happened. export { kbdEventForAction }; export function KeyboardShortcuts() { const router = useRouter(); const [cheatOpen, setCheatOpen] = useState(false); const { open: openSettings } = useSettingsDialog(); // Router + cheat + settings state inside a ref so the keydown listener // (attached once) never goes stale. const handlersRef = useRef<{ router: ReturnType; setCheatOpen: (v: boolean) => void; openSettings: () => void; }>({ router, setCheatOpen, openSettings }); handlersRef.current = { router, setCheatOpen, openSettings }; useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { // ── Tab is dead (owner, 2026-09-17): preventDefault even when // nothing is bound to it — browser tab-focus is abandoned, and // the default action runs after dispatch, so suppressing it here // works from any focus. if (e.key === "Tab") { e.preventDefault(); if (e.shiftKey) window.dispatchEvent(new CustomEvent("lh:kbd-cycle-thinking")); // Plain Tab cycles the active surface's text fields (owner, // keyboard round 2026-10-06) — forward-only, wrapping; each // composer-bearing page answers with its own chain. The quiz // dock's rows↔thinking claim stops propagation at document // level, so a claimed Tab never reaches this dispatch. else if (!e.metaKey && !e.ctrlKey && !e.altKey) window.dispatchEvent(new CustomEvent("lh:kbd-cycle-fields")); return; } if (e.key === "Escape") { // Our own overlay is the topmost layer. if (cheatOpen) { setCheatOpen(false); e.preventDefault(); return; } // THE ONE-ESC LAW (owner, keyboard round 2026-10-06): one // Esc, one action, on the topmost active layer. Field-level // handlers claim at their element (focus in a field: the // field's action); every SURFACED layer (popovers, plan, // overlays, dialogs) claims at the DOCUMENT level, which // fires before this window listener — so reaching here means // NOTHING is surfaced and no field is focused. This chain is // the fallback, bottom-up: dictation (a live capture, not a // surface — it survives Esc that closes a plan or overlay), // then abort the turn (a server-side turn has no client // request to abort — the abort seam in Chat decides what's // honestly stoppable, layered by escAbortTarget). if (isDictationActive()) { e.preventDefault(); window.dispatchEvent(new CustomEvent("lh:kbd-cancel-dictation")); return; } window.dispatchEvent(new CustomEvent("lh:kbd-abort-turn")); return; } // ── Registry dispatch. const action = actionForBinding(eventBinding(e)); if (!action) return; // The input guard: inside a typing surface only mod/alt combos // fire (they never type a character). Bare keys — "?" included — // pass through so the field keeps them. Tab already returned above. if (isTypingSurface(e) && !(e.metaKey || e.ctrlKey || e.altKey)) return; // Dispatch: global commands we own run here; surface commands // ride the window event for the mounted surface to catch. switch (action) { case "new-session": handlersRef.current.router.push("/chat/new"); break; case "open-settings": // Settings-app wave: the dialog is the surface, the URL is // gone — /settings redirects to the draft shell. handlersRef.current.openSettings(); break; case "jump-graph": handlersRef.current.router.push("/"); break; case "cheat-sheet": handlersRef.current.setCheatOpen(true); break; default: window.dispatchEvent(new CustomEvent(kbdEventForAction(action))); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [cheatOpen]); return setCheatOpen(false)} />; } /** The ? overlay — the registry rendered as a table (grouped by scope). * Until the settings milestone ships an editor, this is how shortcuts * stay discoverable. */ function CheatSheet({ open, onClose }: { open: boolean; onClose: () => void }) { if (!open) return null; const scopes = ["global", "chat", "graph"] as const; const scopeLabels: Record<(typeof scopes)[number], string> = { global: "Global", chat: "Chat", graph: "Graph view", }; return (
e.stopPropagation()} >
Keyboard shortcuts Esc to close
{scopes.map((scope) => { const actions = KEYBOARD_ACTIONS.filter((a) => a.scope === scope); if (actions.length === 0) return null; return (
{scopeLabels[scope]}
{actions.map((a) => (
{a.label} {formatBinding(bindingForAction(a.id))}
))}
); })}
); }