The shortcut architecture (owner-approved plan, 2026-09-17):
- src/keyboard.ts: pure action registry (id, label, scope, default
binding) + lh:keymap localStorage overrides + event normalization.
The settings milestone renders this same registry as its editor.
- components/keyboard-shortcuts.tsx: the single document-level keydown
listener (window level, bubble) owning the pieces no surface owns:
- Tab is DEAD (owner decision): browser tab-focus navigation is
abandoned; Tab is suppressed everywhere, Shift+Tab cycles the
thinking level.
- Esc is a layered dismiss: topmost popover first (popovers
stopPropagation their own Esc — usePopoverDismiss + ModelControl),
then dictation stop (lh:kbd-stop-dictation), then abort of an
in-flight turn (lh:kbd-abort-turn; server-side wake turns are
honestly non-stoppable — the seam only stops local streams).
- Global actions dispatch lh:kbd-<action> window events (the
lh:chat-renamed pattern); surfaces that aren't mounted simply
don't listen.
- The input guard: inside a typing surface only mod/alt combos (and
Tab) fire; bare keys pass through to the field.
- Cheat sheet overlay (?) renders from the registry, grouped by scope.
Bindings wired this wave: cmd+d dictation toggle, cmd+k model picker,
shift+tab thinking cycle, cmd+b sidebar, cmd+shift+f sessions search,
alt+up/down + alt+enter session cycling, cmd+g graph view, cmd+n new
session, f2 rename (chat), ? cheat sheet. Wave B adds the question
keyboard machine; wave C the per-surface bindings (find, import).
Verified live in the dev shell (CDP): all of the above via
Input.dispatchKeyEvent.
Co-Authored-By: Claude Fable 4.5 plus-plus <noreply@anthropic.com>
39 lines
1.4 KiB
TypeScript
39 lines
1.4 KiB
TypeScript
import { useEffect, type RefObject } from "react";
|
|
|
|
/**
|
|
* Close-on-dismissal for floating panels (graph chrome, 2026-09-17):
|
|
* any pointerdown landing OUTSIDE the control's root closes it; Escape
|
|
* closes it too. The root contains both the toggle button and the panel,
|
|
* so clicks on the control's own button stay "inside" — its toggle keeps
|
|
* working — while a click on a sibling chrome button counts as outside:
|
|
* pointerdown closes the open panel first, then the sibling's click opens
|
|
* its own. Two dropdowns can therefore never be open at once.
|
|
*/
|
|
export function usePopoverDismiss(
|
|
rootRef: RefObject<HTMLElement | null>,
|
|
open: boolean,
|
|
close: () => void,
|
|
): void {
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onPointerDown = (e: PointerEvent) => {
|
|
if (rootRef.current && !rootRef.current.contains(e.target as Node)) close();
|
|
};
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
// Claim the Esc (keyboard wave A): the window-level dismiss
|
|
// chain must not ALSO stop dictation / abort a turn under
|
|
// this popover. Popovers are the chain's topmost layer.
|
|
e.stopPropagation();
|
|
close();
|
|
}
|
|
};
|
|
document.addEventListener("pointerdown", onPointerDown);
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => {
|
|
document.removeEventListener("pointerdown", onPointerDown);
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
};
|
|
}, [open, close, rootRef]);
|
|
}
|