Files
learnharness/components/keyboard-shortcuts.tsx
T
amosblomqvist b3c07f89f4 Fix ⌘B: dispatch/listen event-name mismatch; thinking pill → "Thinking (⇧⇥)"
Root cause (interlude, owner 2026-09-25): keyboard-shortcuts dispatches
registry actions on the lh:kbd-<action> convention, but the sidebar's
listener sat on the pre-convention name "lh:toggle-sidebar" — a name
nothing ever dispatches, so ⌘B silently did nothing while its tooltip
and cheat-sheet row kept claiming the binding. A sweep of every lh:
listener confirmed the sidebar was the lone stray (chat-renamed and
graph-revision are deliberate non-kbd events).

Fix, structural: kbdEventForAction(action) exported from
src/core/keyboard.ts — the ONE spelling — and both sides compose
through it (dispatcher's default branch, sidebar's toggle listener);
keyboard-shortcuts re-exports the helper so the contract pin can assert
no second spelling exists. The mismatch is now impossible by
construction.

Thinking pill: label trimmed to "Thinking" with its real shortcut via
the registry-composition path — "Thinking (⇧⇥)" (cycle-thinking,
rebind-live like every Hint suffix).

Pins (tests/keyboard-toggle-sidebar.test.ts): the full chain — mod+b →
actionForBinding → toggle-sidebar → kbdEventForAction →
lh:kbd-toggle-sidebar; formatBinding("shift+tab") → ⇧⇥; the
dispatcher-imports-the-same-helper contract.

Live-verified on :3001: ⌘B visually toggles on home, /chat/[id], and
/chat/new (sidebar 0↔288px, hamburger slot 0↔36); with the rebind
lh:keymap {"toggle-sidebar":"mod+j"}: ⌘J toggles and ⌘B is inert
(rebind-live); thinking pill renders "Thinking (⇧⇥)"; keymap restored,
sidebar left open.
2026-09-25 02:39:11 +03:00

178 lines
6.5 KiB
TypeScript

"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-<action> 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<typeof useRouter>;
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"));
return;
}
if (e.key === "Escape") {
// Our own overlay is the topmost layer.
if (cheatOpen) {
setCheatOpen(false);
e.preventDefault();
return;
}
// Popovers stopPropagation on their own Esc, so reaching here
// means none was open. Dictation first, then abort the turn
// (a server-side turn has no client request to abort — the
// abort seam in Chat decides what's honestly stoppable).
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 <CheatSheet open={cheatOpen} onClose={() => 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 (
<div
data-testid="cheat-sheet"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={onClose}
>
<div
className="max-h-[80vh] w-full max-w-[36rem] overflow-y-auto rounded-xl border bg-card p-5 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-baseline justify-between">
<span className="text-sm font-medium text-card-foreground">Keyboard shortcuts</span>
<span className="text-xs text-muted-foreground">Esc to close</span>
</div>
{scopes.map((scope) => {
const actions = KEYBOARD_ACTIONS.filter((a) => a.scope === scope);
if (actions.length === 0) return null;
return (
<div key={scope} className="mb-4">
<div className="mb-1.5 text-xs uppercase tracking-widest text-muted-foreground/70">
{scopeLabels[scope]}
</div>
<div className="flex flex-col gap-1">
{actions.map((a) => (
<div key={a.id} className="flex items-center justify-between gap-4 text-sm">
<span className="text-muted-foreground">{a.label}</span>
<kbd className="rounded-md border border-border bg-background px-1.5 py-0.5 font-mono text-xs text-card-foreground">
{formatBinding(bindingForAction(a.id))}
</kbd>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
);
}