diff --git a/app/globals.css b/app/globals.css index 55b740c..f9594c6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -338,16 +338,3 @@ body { .lh-electron .lh-closed-toggle { 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; -} diff --git a/components/chat.tsx b/components/chat.tsx index 76084e6..d48a83a 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -899,7 +899,7 @@ export function Chat({ turns={turns} onReserve={setDockReserve} dictationSlot={} - dictationStop={dictation.stop} + dictationCancel={dictation.cancel} composer={ stop()} canSend={canSend} - onEscape={dictation.active ? dictation.stop : undefined} + onEscape={dictation.active ? dictation.cancel : undefined} verdict={ planVerdict ? { @@ -978,7 +978,7 @@ function DockOverlay({ plan, onOpenPlan, dictationSlot, - dictationStop, + dictationCancel, }: { pending: ReturnType; /** Teach-phase pending question: arrives collapsed behind the reveal button (W5). */ @@ -1006,7 +1006,7 @@ function DockOverlay({ dictationSlot?: React.ReactNode; /** Esc in a pending question's field stops dictation (the strip is the * stats bar's; the stop belongs to the page-owned hook, threaded down). */ - dictationStop?: () => void; + dictationCancel?: () => void; }) { const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext(); // Teach-phase mount (session reload with a pending check): the dock starts @@ -1257,7 +1257,7 @@ function DockOverlay({ phase={phase} onExpand={expandAndReveal} onSubmit={onSubmitAnswer} - onEscape={dictationStop} + onEscape={dictationCancel} /> ) : ( composer diff --git a/components/dictation.tsx b/components/dictation.tsx index 755ce81..01cea08 100644 --- a/components/dictation.tsx +++ b/components/dictation.tsx @@ -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 }; } /** diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 1e6bd30..ab14afe 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -598,6 +598,14 @@ export function GraphView() { // Search panel state: the query text + the settings section toggle. const [query, setQuery] = useState(""); + // ⌘F (shortcut wave C): the find event focuses the node search — + // surface-scoped, dispatched as lh:kbd-find (only this page listens). + const searchInputRef = useRef(null); + useEffect(() => { + const onFind = () => searchInputRef.current?.focus(); + window.addEventListener("lh:kbd-find", onFind); + return () => window.removeEventListener("lh:kbd-find", onFind); + }, []); // Render settings: SERVER-persisted (data/render-settings.json via // /api/graph/render-settings) — sizes AND the region/physics sections // (user-facing since 2026-09-13; the dev-session era is over). Loaded @@ -991,6 +999,7 @@ export function GraphView() {
setQuery(e.target.value)} placeholder="Search nodes…" diff --git a/components/home.tsx b/components/home.tsx index fa82895..ca53acf 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -109,7 +109,7 @@ export function Home() { if (e.key === "Escape" && dictation.active) { e.preventDefault(); e.stopPropagation(); - dictation.stop(); + dictation.cancel(); return; } if (e.key === "Enter" && !e.shiftKey) { diff --git a/components/keyboard-shortcuts.tsx b/components/keyboard-shortcuts.tsx index ea64165..3dc33bc 100644 --- a/components/keyboard-shortcuts.tsx +++ b/components/keyboard-shortcuts.tsx @@ -9,7 +9,8 @@ * 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. + * 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 @@ -71,7 +72,7 @@ export function KeyboardShortcuts() { // abort seam in Chat decides what's honestly stoppable). if (isDictationActive()) { e.preventDefault(); - window.dispatchEvent(new CustomEvent("lh:kbd-stop-dictation")); + window.dispatchEvent(new CustomEvent("lh:kbd-cancel-dictation")); return; } window.dispatchEvent(new CustomEvent("lh:kbd-abort-turn")); diff --git a/components/new-session.tsx b/components/new-session.tsx index d06e24f..f7b96bc 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -116,7 +116,7 @@ export function DraftShell({ onStop={() => {}} canSend={canSend} autoFocus - onEscape={dictation.active ? dictation.stop : undefined} + onEscape={dictation.active ? dictation.cancel : undefined} />
void; + /** The keyboard machine (wave B) is armed only while the dock is + * expanded — the collapsed bar keeps its native Enter-to-expand. */ + active?: boolean; /** Session phase at the question — scales the thinking field: probing * stays quick (one row), teaching invites dumping (three rows). */ phase?: Phase3; @@ -113,6 +116,7 @@ export function QuestionDock({ toolCallId={toolCallId} input={parsed} phase={phase} + active={expanded} onSubmit={onSubmit} onEscape={onEscape} /> @@ -194,9 +198,10 @@ function QuestionDockForm({ toolCallId, input, phase = "probe", + active, onSubmit, onEscape, -}: Omit) { +}: Omit) { const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const); // Canonical QuestionData, built once per question. Quiz options are @@ -289,8 +294,73 @@ function QuestionDockForm({ // answer and the reasoning behind it, side by side. const selectOther = () => setSel(toggleOther(selectMode, sel)); + /* ── The keyboard machine (shortcut wave B, 2026-09-17) ── + ↑/↓ walk EVERY answerable row in order — options first, then the + quiz's "I don't know" or the ask's "Other…" (whichever exists). + Space acts on the walked row exactly like a click (multi toggles, + single displaces; Other also focuses its input for typing), Enter + submits once an answer exists. Two guards keep native behavior + intact: keys from a TYPING SURFACE keep their meaning (arrows move + the caret, space types), and keys from INSIDE the dock keep the + DOM's semantics (Enter on a focused row/button clicks it). The + listener is document-level and lives only while the dock is + expanded — collapsed, the bar's native Enter expands it. */ + const [kbdIdx, setKbdIdx] = useState(null); + const rootRef = useRef(null); + const otherInputRef = useRef(null); + // One row past the options is the trailing row: quiz → don't-know, + // ask → Other (a question never has both). + const optionCount = qd.mode === "text" ? 0 : qd.options.length; + const rowCount = qd.mode === "text" ? 0 : optionCount + 1; + + const kbdAct = (idx: number) => { + if (idx < optionCount) toggle(idx); + else if (kind === "quiz") + setSel((prev) => ({ ...prev, selected: new Set(), otherSelected: false, dontKnow: !prev.dontKnow })); + else { + selectOther(); + otherInputRef.current?.focus(); + } + }; + + useEffect(() => { + if (!active || rowCount === 0) return; + const onKeyDown = (e: KeyboardEvent) => { + const t = e.target as HTMLElement | null; + if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; + // Keys from inside the dock keep DOM semantics (Enter clicks a + // focused row; Space presses a focused button). + if (t && rootRef.current?.contains(t)) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setKbdIdx((i) => (i === null ? 0 : (i + 1) % rowCount)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setKbdIdx((i) => (i === null ? rowCount - 1 : (i - 1 + rowCount) % rowCount)); + } else if (e.key === " ") { + if (kbdIdx === null) return; // plain Space keeps scrolling the page + e.preventDefault(); + kbdAct(kbdIdx); + } else if (e.key === "Enter") { + if (kbdIdx === null || !canSubmit) return; + e.preventDefault(); + submit(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }); + + // Keep the walked row visible (the question area scrolls internally). + useEffect(() => { + if (kbdIdx === null) return; + rootRef.current + ?.querySelector(`[data-kbdrow="${kbdIdx}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, [kbdIdx]); + return ( -
+
{/* Question area — scrolls internally if very tall; the bottom row stays put. */}
@@ -316,10 +386,12 @@ function QuestionDockForm({ type="button" role={qd.mode === "multi-select" ? "checkbox" : "radio"} aria-checked={isSel} + data-kbdrow={i} onClick={() => toggle(i)} className={cn( "flex items-start gap-3 rounded-lg border px-3 py-2 text-left text-sm transition-colors", isSel ? "border-primary bg-accent" : "hover:bg-accent/50", + i === kbdIdx && !isSel && "ring-1 ring-border bg-accent/40", )} > setSel((prev) => ({ ...prev, selected: new Set(), otherSelected: false, dontKnow: !prev.dontKnow })) } + data-kbdrow={optionCount} aria-pressed={sel.dontKnow} className={cn( // Option-row layout + radio circle (owner); the dashed // outline stays — this is the deliberate non-answer. "flex items-start gap-3 rounded-lg border border-dashed px-3 py-2 text-left text-sm transition-colors", sel.dontKnow ? "border-primary text-foreground" : "text-muted-foreground", + kbdIdx === optionCount && !sel.dontKnow && "ring-1 ring-border bg-accent/40", )} > { setOther(e.target.value); diff --git a/components/sidebar.tsx b/components/sidebar.tsx index e3913e3..a6a86d1 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -345,11 +345,11 @@ function SessionsSidebar() { cycle(1); }; const onOpenSelected = () => { - setKbdIndex((i) => { - const target = i !== null ? shown?.[i] : undefined; - if (target) router.push(`/chat/${target.id}`); - return target ? null : i; - }); + const target = kbdIndex !== null ? shown?.[kbdIndex] : undefined; + if (target) { + router.push(`/chat/${target.id}`); + setKbdIndex(null); + } }; window.addEventListener("lh:kbd-cycle-session-prev", onPrev); window.addEventListener("lh:kbd-cycle-session-next", onNext); @@ -359,7 +359,9 @@ function SessionsSidebar() { window.removeEventListener("lh:kbd-cycle-session-next", onNext); window.removeEventListener("lh:kbd-open-session", onOpenSelected); }; - }, [open, toggle, shown, router]); + // kbdIndex is read (not upserted) so Alt+Enter pushes OUTSIDE any + // state updater — a router.push inside one is setState-in-render. + }, [open, toggle, shown, router, kbdIndex]); // Keep the kbd-selected row in view (the rows are the container's only // buttons, so the index matches). diff --git a/docs/keyboard.md b/docs/keyboard.md new file mode 100644 index 0000000..5bd6e8d --- /dev/null +++ b/docs/keyboard.md @@ -0,0 +1,80 @@ +# Keyboard shortcuts + +Decided 2026-09-17 (owner key-map review after the first draft; the whole +app must be usable with the keyboard only — voice first, keyboard as the +second pillar of input). + +## Architecture + +- **Registry**: `src/core/keyboard.ts` — `KEYBOARD_ACTIONS` holds every + action: `{ id, label, scope, binding }`. The cheat sheet renders FROM + the registry, so it can never list a non-functional shortcut. +- **Provider**: `components/keyboard-shortcuts.tsx` — one window-level + keydown listener (bubble phase, mounted once in AppShell). Normalizes + keys to `mod+d`-style strings, resolves overrides, dispatches + `lh:kbd-` window CustomEvents. Surfaces listen for their + own events (matching the pre-existing `lh:chat-renamed` seam style). +- **Persistence**: overrides live under localStorage `lh:keymap` as + `{actionId: binding}` merged over defaults. No editor yet — the + settings milestone owns key map editing. +- **displayOnly entries**: the registry also lists SURFACE-owned keys + (the question dock's row machine) for the cheat sheet only — + `effectiveKeymap` skips them so bare arrows can never leak to pages + where the surface isn't mounted. + +## Rules + +- **Tab is dead everywhere** (owner decision: browser tab-focus + navigation abandoned). Shift+Tab cycles the thinking level — also + while typing in a field (modifier combos are the only keys that fire + from typing surfaces; bare keys never do). +- **Esc priority chain**: topmost popover → cheat sheet → cancel + dictation (DISCARD — the transcript flush is Cmd+D/Stop only, owner + round) → abort turn (local stream only — a server-side wake turn has + no client request to abort). Every element/document-level Esc handler + that acts calls `e.stopPropagation()` so the window chain never + double-fires. +- **Focus ring**: browser Tab-focus is gone, so `:focus-visible` stays + unstyled (an earlier experimental outline was live-rejected — inputs + showed a red ring on plain click-focus). + +## Key map (Wave A — global) + +| Action | Binding | Seam | +|---|---|---| +| Toggle dictation | ⌘D | `lh:kbd-toggle-dictation` (dictation.tsx) | +| Model selector | ⌘K | `lh:kbd-open-model-picker` (model-picker.tsx) | +| Cycle thinking level | Shift+Tab | `lh:kbd-cycle-thinking` (model-picker.tsx) | +| Toggle sidebar | ⌘B | `lh:toggle-sidebar` (sidebar.tsx) | +| Sessions search | ⌘⇧F | `lh:kbd-sessions-search` (sidebar.tsx) | +| Cycle session | ⌥↑ / ⌥↓ | `lh:kbd-cycle-session-prev/next` (sidebar.tsx) | +| Open selected session | ⌥Enter | `lh:kbd-open-session` (sidebar.tsx) | +| Jump to graph (home) | ⌘G | router.push in keyboard-shortcuts.tsx | +| New session | ⌘N | router.push in keyboard-shortcuts.tsx | +| Rename session | F2 | `lh:kbd-rename-session` (chat.tsx) | +| Cheat sheet | ? | keyboard-shortcuts.tsx | +| Find (graph: node search) | ⌘F | `lh:kbd-find` (graph-view.tsx; chat transcript search rides the same event when that feature lands) | + +## Question dock row machine (Wave B) + +While a question is expanded, the dock owns ↑/↓/Space/Enter: + +- **↑/↓** walk every answerable row in order — options first, then the + quiz's "I don't know" / the ask's "Other…" (never both). Wraps. The + walked row gets a subtle ring (`bg-accent/40`) and scrollIntoView. +- **Space** acts on the walked row exactly like a click: multi toggles, + single displaces; Other also focuses its inline input for typing. +- **Enter** submits once an answer exists (`canSubmit`). +- Guards: keys from a typing surface keep their native meaning (arrows + move the caret, space types — the thinking field is never hijacked), + and keys from inside the dock keep DOM semantics (Enter on a focused + button clicks it). The listener lives only while the dock is expanded + — collapsed, the bar's native Enter expands it. + +## Status + +- Wave A (global plumbing + shortcuts above) — landed, CDP-verified. +- Wave B (question row machine) — landed, CDP-verified. +- Wave C remainder: ⌘F chat transcript search (new feature, needs its own + design pass), ⌘I import notes on graph. ⌘F graph node search shipped + early (owner live-tested the gap). \ No newline at end of file diff --git a/src/core/keyboard.ts b/src/core/keyboard.ts index 4e7d5e3..9ae3ebf 100644 --- a/src/core/keyboard.ts +++ b/src/core/keyboard.ts @@ -31,7 +31,12 @@ export type KeyAction = | "jump-graph" | "new-session" | "rename-session" - | "cheat-sheet"; + | "cheat-sheet" + | "find" + | "question-next-row" + | "question-prev-row" + | "question-pick-row" + | "question-submit"; /** Where the cheat sheet groups an action (display only — dispatch is * global; a seam that isn't mounted simply no-ops). */ @@ -43,6 +48,10 @@ export interface KeyActionDef { label: string; scope: KeyScope; binding: string; + /** Display-only (cheat sheet) — contextual keys a SURFACE owns (the + * question dock's row machine); never dispatched from the registry, + * so they can't leak to pages where the surface isn't mounted. */ + displayOnly?: boolean; } export const KEYBOARD_ACTIONS: readonly KeyActionDef[] = [ @@ -57,7 +66,18 @@ export const KEYBOARD_ACTIONS: readonly KeyActionDef[] = [ { 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" }, + // ⌘F is surface-scoped (owner key map): the graph focuses node search; a + // chat-scoped transcript search rides the same event when it lands (the + // graph's listener simply isn't mounted on chat pages). + { id: "find", label: "Find (graph: node search)", scope: "graph", binding: "mod+f" }, { id: "cheat-sheet", label: "Shortcut cheat sheet", scope: "global", binding: "?" }, + // The question dock's row machine (wave B) — displayOnly: the dock owns + // the keys while a question is expanded; the registry never dispatches + // them, so bare arrows stay free everywhere else. + { id: "question-next-row", label: "Question · next row", scope: "chat", binding: "arrowdown", displayOnly: true }, + { id: "question-prev-row", label: "Question · previous row", scope: "chat", binding: "arrowup", displayOnly: true }, + { id: "question-pick-row", label: "Question · pick row", scope: "chat", binding: "space", displayOnly: true }, + { id: "question-submit", label: "Question · submit", scope: "chat", binding: "enter", displayOnly: true }, ]; const STORAGE_KEY = "lh:keymap"; @@ -83,6 +103,7 @@ export function effectiveKeymap(): Map { const overrides = loadOverrides(); const map = new Map(); for (const def of KEYBOARD_ACTIONS) { + if (def.displayOnly) continue; // surface-owned, never dispatched const binding = overrides[def.id] ?? def.binding; if (typeof binding === "string" && binding) map.set(binding, def.id); } @@ -148,6 +169,7 @@ const KEY_GLYPH: Record = { enter: "↵", tab: "⇥", escape: "Esc", + space: "Space", }; /** Format a binding for display: "mod+shift+f" → "⌘⇧F". */