feat: keyboard waves A/B — global shortcut registry, question row machine, Esc-cancel dictation

Wave A (owner key map): registry in src/core/keyboard.ts + KeyboardShortcuts
provider; Cmd+D dictation, Cmd+K model picker, Shift+Tab thinking cycle,
Cmd+B sidebar, Cmd+Shift+F sessions search, Alt+arrows session cycle,
Alt+Enter open, Cmd+G graph, Cmd+N new, F2 rename, ? cheat sheet. Tab
fully suppressed; Esc chain popover > dictation > abort (local stream).
Esc now CANCELS dictation (discard) — flush stays on Cmd+D/Stop. Owner
bug reports fixed: focus outline removed, Alt+Enter setState-in-render,
Cmd+F graph node search (surface-scoped lh:kbd-find).

Wave B: question dock row machine — arrows walk options + dont-know/Other
with wrap + ring highlight, Space picks (mirrors click semantics), Enter
submits when canSubmit; typing surfaces and in-dock keys keep native
behavior. aria-checked={isSel} restored on option buttons.

docs/keyboard.md records the full map.
This commit is contained in:
amosblomqvist
2026-09-17 21:18:56 +03:00
parent 682edde844
commit 8b158972db
11 changed files with 232 additions and 38 deletions
+22 -6
View File
@@ -138,8 +138,14 @@ export type Dictation = {
levelRef: { current: number };
/** Not idle — from the click until the stop completes (the optimistic window). */
active: boolean;
/** End + KEEP the transcript: flush the deferred buffer into the primary
* input (the Stop pill and ⌘D). */
toggle: () => void;
stop: () => void;
/** End + DISCARD the transcript: no flush, immediate teardown. The Esc
* semantics (owner, 2026-09-17 round 3): Esc is a cancel, not a stop —
* before, it flushed exactly like the toggle did. */
cancel: () => void;
};
/**
@@ -358,6 +364,15 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
}
}, [phase, cleanup]);
const cancel = useCallback(() => {
if (phase === "idle") return;
// Discard first, teardown second — cleanup() flushes, and the now-empty
// buffer makes that flush a no-op. Also kills a "stopping" run: the
// flush already underway is abandoned (its timer is cleared inside).
bufferRef.current = [];
cleanup();
}, [phase, cleanup]);
const toggle = useCallback(() => {
if (phase === "idle") void start();
else if (phase === "recording" || phase === "connecting") stop();
@@ -365,18 +380,19 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
}, [phase, start, stop]);
// Shortcut seams (keyboard wave A): ⌘D toggles from anywhere on the
// page; the Esc chain's stop lands here too. Each page owns ONE hook
// instance, and only the mounted page's listener is attached.
// page; the Esc chain lands here as a CANCEL (owner, round 3). Each
// page owns ONE hook instance, and only the mounted page's listener is
// attached.
useEffect(() => {
window.addEventListener("lh:kbd-toggle-dictation", toggle);
return () => window.removeEventListener("lh:kbd-toggle-dictation", toggle);
}, [toggle]);
useEffect(() => {
window.addEventListener("lh:kbd-stop-dictation", stop);
return () => window.removeEventListener("lh:kbd-stop-dictation", stop);
}, [stop]);
window.addEventListener("lh:kbd-cancel-dictation", cancel);
return () => window.removeEventListener("lh:kbd-cancel-dictation", cancel);
}, [cancel]);
return { phase, error, levelRef, active: phase !== "idle", toggle, stop };
return { phase, error, levelRef, active: phase !== "idle", toggle, stop, cancel };
}
/**