feat: keyboard shortcuts wave A — registry, provider, global bindings
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>
This commit is contained in:
co-authored by
Claude Fable 4.5 plus-plus
parent
d1ba0c5c3c
commit
682edde844
@@ -338,3 +338,16 @@ body {
|
|||||||
.lh-electron .lh-closed-toggle {
|
.lh-electron .lh-closed-toggle {
|
||||||
margin-left: 92px;
|
margin-left: 92px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────────────────
|
||||||
|
Keyboard focus ring (shortcut wave A, 2026-09-17). Browser Tab-focus
|
||||||
|
navigation is ABANDONED by owner decision (Tab is suppressed at the
|
||||||
|
window level — see components/keyboard-shortcuts.tsx; Shift+Tab cycles
|
||||||
|
the thinking level instead), so this ring should almost never appear.
|
||||||
|
It exists as belt-and-braces: if focus ever lands somewhere via script,
|
||||||
|
it glows in the app's own accent, not Chromium's default blue.
|
||||||
|
───────────────────────────────────────────────────────────────────────── */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 1.5px solid color-mix(in srgb, var(--lh-ink-strong, #c96b4a) 75%, transparent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|||||||
+28
-1
@@ -200,6 +200,28 @@ export function Chat({
|
|||||||
}
|
}
|
||||||
}, [renaming]);
|
}, [renaming]);
|
||||||
|
|
||||||
|
// ── Keyboard seams (shortcut wave A, 2026-09-17) ──
|
||||||
|
// F2 opens the title rename (the chat header owns the editing state).
|
||||||
|
useEffect(() => {
|
||||||
|
const onRename = () => {
|
||||||
|
setTitleDraft(titleText);
|
||||||
|
setRenaming(true);
|
||||||
|
};
|
||||||
|
window.addEventListener("lh:kbd-rename-session", onRename);
|
||||||
|
return () => window.removeEventListener("lh:kbd-rename-session", onRename);
|
||||||
|
}, [titleText]);
|
||||||
|
|
||||||
|
// The Esc chain's last link: abort the turn. Honest scope (the composer's
|
||||||
|
// Stop has the same rule): only a LOCAL stream has a client request to
|
||||||
|
// stop — a server-side wake turn aborts nothing (the stop would be a
|
||||||
|
// no-op lie), so Esc is simply inert while one runs.
|
||||||
|
useEffect(() => {
|
||||||
|
const onAbort = () => {
|
||||||
|
if (localStreamRef.current) stop();
|
||||||
|
};
|
||||||
|
window.addEventListener("lh:kbd-abort-turn", onAbort);
|
||||||
|
return () => window.removeEventListener("lh:kbd-abort-turn", onAbort);
|
||||||
|
}, [stop]);
|
||||||
// Mount continuation: if the stored transcript ends with a fulfilled tool
|
// Mount continuation: if the stored transcript ends with a fulfilled tool
|
||||||
// (a job result landed while no client was open), resume the lesson — the
|
// (a job result landed while no client was open), resume the lesson — the
|
||||||
// SDK's sendAutomaticallyWhen only fires inside addToolOutput, so reloads
|
// SDK's sendAutomaticallyWhen only fires inside addToolOutput, so reloads
|
||||||
@@ -670,7 +692,12 @@ export function Chat({
|
|||||||
onChange={(e) => setTitleDraft(e.target.value)}
|
onChange={(e) => setTitleDraft(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") void commitRename(titleDraft);
|
if (e.key === "Enter") void commitRename(titleDraft);
|
||||||
else if (e.key === "Escape") setRenaming(false);
|
// stopPropagation claims the Esc (keyboard wave A):
|
||||||
|
// closing rename must not also abort a turn.
|
||||||
|
else if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
setRenaming(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onBlur={() => void commitRename(titleDraft)}
|
onBlur={() => void commitRename(titleDraft)}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
|||||||
@@ -53,8 +53,11 @@ export function Composer({
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Esc ends dictation before anything else Esc might someday mean
|
// Esc ends dictation before anything else Esc might someday mean
|
||||||
// here (the dictate decisions: click-again or Esc to stop).
|
// here (the dictate decisions: click-again or Esc to stop).
|
||||||
|
// stopPropagation claims the Esc (keyboard wave A): the
|
||||||
|
// window-level chain must not also abort a turn under us.
|
||||||
if (e.key === "Escape" && onEscape) {
|
if (e.key === "Escape" && onEscape) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
onEscape();
|
onEscape();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,16 @@ export function rmsToMeter(rms: number): number {
|
|||||||
*/
|
*/
|
||||||
let questionTarget: ((text: string) => void) | null = null;
|
let questionTarget: ((text: string) => void) | null = null;
|
||||||
|
|
||||||
|
/* Is a dictation run live on this page? The Esc chain (keyboard wave A,
|
||||||
|
* components/keyboard-shortcuts.tsx) needs a SYNCHRONOUS answer — the
|
||||||
|
* stop handler can't report back through a CustomEvent. Set at start(),
|
||||||
|
* cleared in cleanup() (every terminal path runs through cleanup). */
|
||||||
|
let dictationActiveFlag = false;
|
||||||
|
|
||||||
|
export function isDictationActive(): boolean {
|
||||||
|
return dictationActiveFlag;
|
||||||
|
}
|
||||||
|
|
||||||
/** Join a dictated final onto the current text (shared separator rule). */
|
/** Join a dictated final onto the current text (shared separator rule). */
|
||||||
export function joinDictation(current: string, final: string): string {
|
export function joinDictation(current: string, final: string): string {
|
||||||
return current ? current.replace(/\s+$/, "") + " " + final : final;
|
return current ? current.replace(/\s+$/, "") + " " + final : final;
|
||||||
@@ -172,6 +182,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
|
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
|
||||||
const cleanup = useCallback(() => {
|
const cleanup = useCallback(() => {
|
||||||
genRef.current++;
|
genRef.current++;
|
||||||
|
dictationActiveFlag = false;
|
||||||
flush(); // the deferred transcript appears when the run ends (owner round 2)
|
flush(); // the deferred transcript appears when the run ends (owner round 2)
|
||||||
if (safetyRef.current) {
|
if (safetyRef.current) {
|
||||||
clearTimeout(safetyRef.current);
|
clearTimeout(safetyRef.current);
|
||||||
@@ -260,6 +271,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
);
|
);
|
||||||
|
|
||||||
const start = useCallback(async () => {
|
const start = useCallback(async () => {
|
||||||
|
dictationActiveFlag = true;
|
||||||
setError(null);
|
setError(null);
|
||||||
setPhase("connecting");
|
setPhase("connecting");
|
||||||
reconnectsRef.current = 0;
|
reconnectsRef.current = 0;
|
||||||
@@ -352,6 +364,18 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
|
|||||||
// "stopping" presses are ignored — the flush is in flight.
|
// "stopping" presses are ignored — the flush is in flight.
|
||||||
}, [phase, start, stop]);
|
}, [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.
|
||||||
|
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]);
|
||||||
|
|
||||||
return { phase, error, levelRef, active: phase !== "idle", toggle, stop };
|
return { phase, error, levelRef, active: phase !== "idle", toggle, stop };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -103,9 +103,12 @@ export function Home() {
|
|||||||
value={topic}
|
value={topic}
|
||||||
onChange={(e) => setTopic(e.target.value)}
|
onChange={(e) => setTopic(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Esc ends dictation (the dictate decisions: click-again or Esc).
|
// Esc ends dictation (the dictate decisions: click-again or
|
||||||
|
// Esc) — stopPropagation claims it (keyboard wave A) so
|
||||||
|
// the window chain doesn't also abort a turn.
|
||||||
if (e.key === "Escape" && dictation.active) {
|
if (e.key === "Escape" && dictation.active) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
dictation.stop();
|
dictation.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"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
|
||||||
|
* stop, 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,
|
||||||
|
KEYBOARD_ACTIONS,
|
||||||
|
} from "@/src/core";
|
||||||
|
import { isDictationActive } from "@/components/dictation";
|
||||||
|
|
||||||
|
export function KeyboardShortcuts() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [cheatOpen, setCheatOpen] = useState(false);
|
||||||
|
|
||||||
|
// Router + cheat state inside a ref so the keydown listener (attached
|
||||||
|
// once) never goes stale.
|
||||||
|
const handlersRef = useRef<{
|
||||||
|
router: ReturnType<typeof useRouter>;
|
||||||
|
setCheatOpen: (v: boolean) => void;
|
||||||
|
}>({ router, setCheatOpen });
|
||||||
|
handlersRef.current = { router, setCheatOpen };
|
||||||
|
|
||||||
|
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-stop-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 "jump-graph":
|
||||||
|
handlersRef.current.router.push("/");
|
||||||
|
break;
|
||||||
|
case "cheat-sheet":
|
||||||
|
handlersRef.current.setCheatOpen(true);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
window.dispatchEvent(new CustomEvent(`lh:kbd-${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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -127,7 +127,12 @@ export function ModelControl({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") setOpen(null);
|
if (e.key === "Escape") {
|
||||||
|
// Claim the Esc (keyboard wave A): the window-level chain must
|
||||||
|
// not ALSO stop dictation / abort the turn under this menu.
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("pointerdown", onPointerDown);
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
@@ -168,6 +173,30 @@ export function ModelControl({
|
|||||||
[chatId, onSaved],
|
[chatId, onSaved],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Shortcut seams (keyboard wave A): ⌘K opens the model menu (the
|
||||||
|
// universal command convention); Shift+Tab cycles the thinking level
|
||||||
|
// up the ladder (wrapping) — the owner's replacement for browser
|
||||||
|
// tab-focus. Both work from any focus, including mid-typing.
|
||||||
|
useEffect(() => {
|
||||||
|
const onOpenPicker = () => {
|
||||||
|
setOpen("model");
|
||||||
|
setQuery("");
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
window.addEventListener("lh:kbd-open-model-picker", onOpenPicker);
|
||||||
|
return () => window.removeEventListener("lh:kbd-open-model-picker", onOpenPicker);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onCycleThinking = () => {
|
||||||
|
if (levels.length === 0) return;
|
||||||
|
const idx = effectiveLevel === undefined ? -1 : levels.indexOf(effectiveLevel);
|
||||||
|
void save({ ...(settings ?? {}), thinkingLevel: levels[(idx + 1) % levels.length] });
|
||||||
|
};
|
||||||
|
window.addEventListener("lh:kbd-cycle-thinking", onCycleThinking);
|
||||||
|
return () => window.removeEventListener("lh:kbd-cycle-thinking", onCycleThinking);
|
||||||
|
}, [levels, effectiveLevel, save, settings]);
|
||||||
|
|
||||||
// The filtered, ranked model list.
|
// The filtered, ranked model list.
|
||||||
const results = (catalog?.models ?? [])
|
const results = (catalog?.models ?? [])
|
||||||
.map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) }))
|
.map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) }))
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ export function usePopoverDismiss(
|
|||||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) close();
|
if (rootRef.current && !rootRef.current.contains(e.target as Node)) close();
|
||||||
};
|
};
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") close();
|
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("pointerdown", onPointerDown);
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|||||||
@@ -443,9 +443,12 @@ function QuestionDockForm({
|
|||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Esc ends dictation (the dictate decisions: click-again or Esc).
|
// Esc ends dictation (the dictate decisions: click-again
|
||||||
|
// or Esc) — stopPropagation claims it (keyboard wave
|
||||||
|
// A) so the window chain doesn't also abort a turn.
|
||||||
if (e.key === "Escape" && onEscape) {
|
if (e.key === "Escape" && onEscape) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
onEscape();
|
onEscape();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -465,9 +468,12 @@ function QuestionDockForm({
|
|||||||
value={thinking}
|
value={thinking}
|
||||||
onChange={(e) => setThinking(e.target.value)}
|
onChange={(e) => setThinking(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
// Esc ends dictation (the dictate decisions: click-again or Esc).
|
// Esc ends dictation (the dictate decisions: click-again
|
||||||
|
// or Esc) — stopPropagation claims it (keyboard wave
|
||||||
|
// A) so the window chain doesn't also abort a turn.
|
||||||
if (e.key === "Escape" && onEscape) {
|
if (e.key === "Escape" && onEscape) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
onEscape();
|
onEscape();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-4
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon } from "lucide-react";
|
import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon } from "lucide-react";
|
||||||
import { ActivitySection } from "@/components/activity-dock";
|
import { ActivitySection } from "@/components/activity-dock";
|
||||||
import { LhMark } from "@/components/lh-mark";
|
import { LhMark } from "@/components/lh-mark";
|
||||||
import { BrailleSpinner } from "@/components/braille-spinner";
|
import { BrailleSpinner } from "@/components/braille-spinner";
|
||||||
|
import { KeyboardShortcuts } from "@/components/keyboard-shortcuts";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -111,6 +112,14 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
|||||||
});
|
});
|
||||||
}, [narrow, open]);
|
}, [narrow, open]);
|
||||||
|
|
||||||
|
// ⌘B (keyboard wave A): the global shortcut dispatches lh:toggle-sidebar;
|
||||||
|
// the context is the one place that owns open state, so it listens here.
|
||||||
|
useEffect(() => {
|
||||||
|
const onToggle = () => toggle();
|
||||||
|
window.addEventListener("lh:toggle-sidebar", onToggle);
|
||||||
|
return () => window.removeEventListener("lh:toggle-sidebar", onToggle);
|
||||||
|
}, [toggle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarContext.Provider value={{ open: open && !narrow, toggle }}>
|
<SidebarContext.Provider value={{ open: open && !narrow, toggle }}>
|
||||||
{/* transitions gated on mount: no slide-in flash on page load */}
|
{/* transitions gated on mount: no slide-in flash on page load */}
|
||||||
@@ -216,18 +225,24 @@ export function SessionRow({
|
|||||||
chat,
|
chat,
|
||||||
active,
|
active,
|
||||||
onOpen,
|
onOpen,
|
||||||
|
kbdSelected = false,
|
||||||
}: {
|
}: {
|
||||||
chat: ChatListItem;
|
chat: ChatListItem;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
|
/** Keyboard-cycle highlight (shortcut wave A: Alt+↑/↓ + Alt+Enter) —
|
||||||
|
* visually distinct from the active session, which stays filled. */
|
||||||
|
kbdSelected?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
|
data-testid={kbdSelected ? "session-row-kbd" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative mb-0.5 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition-colors hover:bg-accent",
|
"relative mb-0.5 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition-colors hover:bg-accent",
|
||||||
active && "bg-accent",
|
active && "bg-accent",
|
||||||
|
kbdSelected && !active && "bg-accent/60 ring-1 ring-border",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SessionStatus busy={chat.busy} />
|
<SessionStatus busy={chat.busy} />
|
||||||
@@ -289,6 +304,70 @@ function SessionsSidebar() {
|
|||||||
return () => window.removeEventListener("lh:chat-renamed", onRenamed);
|
return () => window.removeEventListener("lh:chat-renamed", onRenamed);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/* ── Keyboard seams (shortcut wave A, 2026-09-17) ── The sidebar owns
|
||||||
|
every session-list interaction, so the shortcuts land here: ⌘⇧F
|
||||||
|
focuses the search field; Alt+↑/↓ cycle the list (opening the sidebar
|
||||||
|
if it's closed — the keys work from anywhere, like the toggle does);
|
||||||
|
Alt+Enter opens the selected session. The selection is a row index
|
||||||
|
into `shown`, so a typed query narrows the cycle to the matches.
|
||||||
|
Mouse and keyboard coexist: hovering doesn't claim, a click or an
|
||||||
|
open clears the selection. */
|
||||||
|
const [kbdIndex, setKbdIndex] = useState<number | null>(null);
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onFocusSearch = () => {
|
||||||
|
if (!open) toggle();
|
||||||
|
// A clipped (overflow-hidden) input still focuses — the sidebar's
|
||||||
|
// slide-in doesn't gate the keystroke.
|
||||||
|
requestAnimationFrame(() => searchRef.current?.focus());
|
||||||
|
};
|
||||||
|
window.addEventListener("lh:kbd-sessions-search", onFocusSearch);
|
||||||
|
return () => window.removeEventListener("lh:kbd-sessions-search", onFocusSearch);
|
||||||
|
}, [open, toggle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cycle = (dir: 1 | -1) => {
|
||||||
|
setKbdIndex((i) => {
|
||||||
|
const n = shown?.length ?? 0;
|
||||||
|
if (n === 0) return null;
|
||||||
|
if (i === null) return dir === 1 ? 0 : n - 1;
|
||||||
|
return (i + dir + n) % n;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onPrev = () => {
|
||||||
|
if (!open) toggle();
|
||||||
|
cycle(-1);
|
||||||
|
};
|
||||||
|
const onNext = () => {
|
||||||
|
if (!open) toggle();
|
||||||
|
cycle(1);
|
||||||
|
};
|
||||||
|
const onOpenSelected = () => {
|
||||||
|
setKbdIndex((i) => {
|
||||||
|
const target = i !== null ? shown?.[i] : undefined;
|
||||||
|
if (target) router.push(`/chat/${target.id}`);
|
||||||
|
return target ? null : i;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("lh:kbd-cycle-session-prev", onPrev);
|
||||||
|
window.addEventListener("lh:kbd-cycle-session-next", onNext);
|
||||||
|
window.addEventListener("lh:kbd-open-session", onOpenSelected);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("lh:kbd-cycle-session-prev", onPrev);
|
||||||
|
window.removeEventListener("lh:kbd-cycle-session-next", onNext);
|
||||||
|
window.removeEventListener("lh:kbd-open-session", onOpenSelected);
|
||||||
|
};
|
||||||
|
}, [open, toggle, shown, router]);
|
||||||
|
|
||||||
|
// Keep the kbd-selected row in view (the rows are the container's only
|
||||||
|
// buttons, so the index matches).
|
||||||
|
useEffect(() => {
|
||||||
|
if (kbdIndex === null) return;
|
||||||
|
listRef.current?.querySelectorAll("button")[kbdIndex]?.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [kbdIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
void refresh();
|
void refresh();
|
||||||
@@ -351,6 +430,7 @@ function SessionsSidebar() {
|
|||||||
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Sessions</span>
|
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Sessions</span>
|
||||||
<span className="relative flex-1">
|
<span className="relative flex-1">
|
||||||
<Input
|
<Input
|
||||||
|
ref={searchRef}
|
||||||
data-testid="session-search"
|
data-testid="session-search"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
@@ -362,7 +442,7 @@ function SessionsSidebar() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div ref={listRef} className="flex-1 overflow-y-auto p-2">
|
||||||
{shown === null ? (
|
{shown === null ? (
|
||||||
<div className="px-2 py-4 text-xs text-muted-foreground">Loading…</div>
|
<div className="px-2 py-4 text-xs text-muted-foreground">Loading…</div>
|
||||||
) : shown.length === 0 ? (
|
) : shown.length === 0 ? (
|
||||||
@@ -370,12 +450,16 @@ function SessionsSidebar() {
|
|||||||
{query.trim() ? "No sessions match." : "No sessions yet."}
|
{query.trim() ? "No sessions match." : "No sessions yet."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
shown.map((c) => (
|
shown.map((c, i) => (
|
||||||
<SessionRow
|
<SessionRow
|
||||||
key={c.id}
|
key={c.id}
|
||||||
chat={c}
|
chat={c}
|
||||||
active={pathname === `/chat/${c.id}`}
|
active={pathname === `/chat/${c.id}`}
|
||||||
onOpen={() => router.push(`/chat/${c.id}`)}
|
kbdSelected={i === kbdIndex}
|
||||||
|
onOpen={() => {
|
||||||
|
setKbdIndex(null);
|
||||||
|
router.push(`/chat/${c.id}`);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -398,6 +482,10 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
<>
|
<>
|
||||||
<SessionsSidebar />
|
<SessionsSidebar />
|
||||||
<main className="min-w-0 flex-1">{children}</main>
|
<main className="min-w-0 flex-1">{children}</main>
|
||||||
|
{/* The single document-level keydown listener (keyboard wave A) —
|
||||||
|
after the content in the tree, but it's window-level so order only
|
||||||
|
matters for the events it dispatches, never for DOM priority. */}
|
||||||
|
<KeyboardShortcuts />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,5 +34,7 @@ export * from "./ui-message.ts";
|
|||||||
export * from "./tool-parts.ts";
|
export * from "./tool-parts.ts";
|
||||||
export * from "./prices.ts";
|
export * from "./prices.ts";
|
||||||
export * from "./fuzzy.ts";
|
export * from "./fuzzy.ts";
|
||||||
|
// keyboard.ts: the shortcut registry + binding normalization (pure, browser-safe).
|
||||||
|
export * from "./keyboard.ts";
|
||||||
export * from "./coverage.ts";
|
export * from "./coverage.ts";
|
||||||
export * from "./graph-context.ts";
|
export * from "./graph-context.ts";
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Keyboard shortcuts (shortcut wave, 2026-09-17) — the single source of truth
|
||||||
|
* for every app action that has a binding. This module is PURE (no React, no
|
||||||
|
* window access at module scope beyond the guarded helpers) so it can render
|
||||||
|
* a cheat sheet, match events, and — at the settings milestone — drive a
|
||||||
|
* user-editable key map without touching any component.
|
||||||
|
*
|
||||||
|
* Design (owner-approved plan):
|
||||||
|
* - Tab is DEAD: browser tab-focus navigation is abandoned entirely. Tab
|
||||||
|
* does nothing; Shift+Tab alone cycles the thinking level.
|
||||||
|
* - Esc is a layered dismiss: topmost popover (each popover stopPropagation's
|
||||||
|
* its own Esc) → dictation stop → abort in-flight turn.
|
||||||
|
* - The key map persists overrides in localStorage (`lh:keymap`, overrides
|
||||||
|
* only) merged over DEFAULT_BINDINGS — the settings wave adds the editor.
|
||||||
|
*
|
||||||
|
* Binding strings are normalized, cross-platform:
|
||||||
|
* "mod+d" — mod = ⌘ on macOS, Ctrl elsewhere (both are accepted live)
|
||||||
|
* "shift+tab", "alt+arrowdown", "mod+shift+f", "?", "f2", "escape"
|
||||||
|
* Order is fixed: mod, alt, shift, then the key.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type KeyAction =
|
||||||
|
| "toggle-dictation"
|
||||||
|
| "open-model-picker"
|
||||||
|
| "cycle-thinking"
|
||||||
|
| "toggle-sidebar"
|
||||||
|
| "sessions-search"
|
||||||
|
| "cycle-session-prev"
|
||||||
|
| "cycle-session-next"
|
||||||
|
| "open-session"
|
||||||
|
| "jump-graph"
|
||||||
|
| "new-session"
|
||||||
|
| "rename-session"
|
||||||
|
| "cheat-sheet";
|
||||||
|
|
||||||
|
/** Where the cheat sheet groups an action (display only — dispatch is
|
||||||
|
* global; a seam that isn't mounted simply no-ops). */
|
||||||
|
export type KeyScope = "global" | "chat" | "graph";
|
||||||
|
|
||||||
|
export interface KeyActionDef {
|
||||||
|
id: KeyAction;
|
||||||
|
/** Cheat-sheet label. */
|
||||||
|
label: string;
|
||||||
|
scope: KeyScope;
|
||||||
|
binding: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KEYBOARD_ACTIONS: readonly KeyActionDef[] = [
|
||||||
|
{ id: "toggle-dictation", label: "Toggle dictation", scope: "global", binding: "mod+d" },
|
||||||
|
{ id: "open-model-picker", label: "Model selector", scope: "global", binding: "mod+k" },
|
||||||
|
{ id: "cycle-thinking", label: "Cycle thinking level", scope: "global", binding: "shift+tab" },
|
||||||
|
{ id: "toggle-sidebar", label: "Toggle sessions sidebar", scope: "global", binding: "mod+b" },
|
||||||
|
{ id: "sessions-search", label: "Search sessions", scope: "global", binding: "mod+shift+f" },
|
||||||
|
{ id: "cycle-session-prev", label: "Previous session", scope: "global", binding: "alt+arrowup" },
|
||||||
|
{ id: "cycle-session-next", label: "Next session", scope: "global", binding: "alt+arrowdown" },
|
||||||
|
{ id: "open-session", label: "Open selected session", scope: "global", binding: "alt+enter" },
|
||||||
|
{ id: "jump-graph", label: "Graph view", scope: "global", binding: "mod+g" },
|
||||||
|
{ id: "new-session", label: "New session", scope: "global", binding: "mod+n" },
|
||||||
|
{ id: "rename-session", label: "Rename session", scope: "chat", binding: "f2" },
|
||||||
|
{ id: "cheat-sheet", label: "Shortcut cheat sheet", scope: "global", binding: "?" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STORAGE_KEY = "lh:keymap";
|
||||||
|
|
||||||
|
/** localStorage overrides ({actionId: binding}) — read once per module
|
||||||
|
* instance; the settings editor will write this key. Guarded: SSR and a
|
||||||
|
* corrupt/absent store both fall back to defaults. */
|
||||||
|
function loadOverrides(): Partial<Record<KeyAction, string>> {
|
||||||
|
if (typeof window === "undefined") return {};
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw) as Partial<Record<KeyAction, string>>;
|
||||||
|
if (parsed && typeof parsed === "object") return parsed;
|
||||||
|
} catch {
|
||||||
|
/* corrupt store = defaults */
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live key map: defaults with user overrides applied. */
|
||||||
|
export function effectiveKeymap(): Map<string, KeyAction> {
|
||||||
|
const overrides = loadOverrides();
|
||||||
|
const map = new Map<string, KeyAction>();
|
||||||
|
for (const def of KEYBOARD_ACTIONS) {
|
||||||
|
const binding = overrides[def.id] ?? def.binding;
|
||||||
|
if (typeof binding === "string" && binding) map.set(binding, def.id);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which action a normalized binding fires, if any (override-aware). */
|
||||||
|
export function actionForBinding(binding: string): KeyAction | undefined {
|
||||||
|
return effectiveKeymap().get(binding);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The effective (override-aware) binding for one action — display use. */
|
||||||
|
export function bindingForAction(id: KeyAction): string {
|
||||||
|
const overrides = loadOverrides();
|
||||||
|
const def = KEYBOARD_ACTIONS.find((a) => a.id === id);
|
||||||
|
if (!def) return "";
|
||||||
|
return overrides[id] ?? def.binding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a KeyboardEvent to a canonical binding string. Rules:
|
||||||
|
* - mod matches BOTH meta (⌘) and ctrl — Ctrl+K works on a Mac keyboard too.
|
||||||
|
* - a printable key ("?") bakes its shift in: "shift+/?" collapses to "?".
|
||||||
|
*/
|
||||||
|
export function eventBinding(e: KeyboardEvent): string {
|
||||||
|
const mod = e.metaKey || e.ctrlKey;
|
||||||
|
// A bare printable key bakes its shift into the character ("?" not
|
||||||
|
// "shift+/"), so it returns alone. With a real modifier riding along,
|
||||||
|
// shift is explicit (⌘⇧F = "mod+shift+f"); non-printable keys (Tab,
|
||||||
|
// arrows) always keep their shift (Shift+Tab = "shift+tab").
|
||||||
|
if (e.key.length === 1 && !mod && !e.altKey) return e.key.toLowerCase();
|
||||||
|
const mods: string[] = [];
|
||||||
|
if (mod) mods.push("mod");
|
||||||
|
if (e.altKey) mods.push("alt");
|
||||||
|
if (e.shiftKey) mods.push("shift");
|
||||||
|
return [...mods, e.key.toLowerCase()].join("+");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the event lands in a typing surface (input, textarea, or a
|
||||||
|
* contentEditable). In one, only mod/alt combos (and Tab, which types
|
||||||
|
* nothing) may fire — bare keys belong to the field (typing, caret). */
|
||||||
|
export function isTypingSurface(e: KeyboardEvent): boolean {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (!target) return false;
|
||||||
|
const tag = target.tagName;
|
||||||
|
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||||||
|
return target.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** macOS styling for binding display ("⌘D", "⇧⇥", "⌥↑"). */
|
||||||
|
export const IS_MAC =
|
||||||
|
typeof navigator !== "undefined" && /mac/i.test(navigator.platform || navigator.userAgent);
|
||||||
|
|
||||||
|
const MOD_GLYPH = IS_MAC ? "⌘" : "Ctrl+";
|
||||||
|
const ALT_GLYPH = IS_MAC ? "⌥" : "Alt+";
|
||||||
|
const SHIFT_GLYPH = IS_MAC ? "⇧" : "Shift+";
|
||||||
|
|
||||||
|
const KEY_GLYPH: Record<string, string> = {
|
||||||
|
arrowup: "↑",
|
||||||
|
arrowdown: "↓",
|
||||||
|
arrowleft: "←",
|
||||||
|
arrowright: "→",
|
||||||
|
enter: "↵",
|
||||||
|
tab: "⇥",
|
||||||
|
escape: "Esc",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Format a binding for display: "mod+shift+f" → "⌘⇧F". */
|
||||||
|
export function formatBinding(binding: string): string {
|
||||||
|
const parts = binding.split("+");
|
||||||
|
const key = parts.pop()!; // last part is always the key
|
||||||
|
const glyphs: string[] = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part === "mod") glyphs.push(MOD_GLYPH);
|
||||||
|
else if (part === "alt") glyphs.push(ALT_GLYPH);
|
||||||
|
else if (part === "shift") glyphs.push(SHIFT_GLYPH);
|
||||||
|
}
|
||||||
|
const keyGlyph = KEY_GLYPH[key] ?? key.toUpperCase();
|
||||||
|
return glyphs.join("") + keyGlyph;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user