fix: keyboard round 3 — question walk clamps, Tab focus toggle, Alt+arrow isolation, search Esc

Owner live-test rounds on waves A/B:
- Alt+arrows no longer ALSO walk the question rows (dock ignores
  alt/meta/ctrl combos; session cycling keeps them exclusively)
- question walk clamps at the ends — no wrap (faster to navigate)
- plain Tab toggles focus between the thinking field and the answer
  rows (walked row by default); focus follows the walk while in the
  rows; Shift+Tab stays the global thinking-level cycle
- Esc in the sessions search blurs back to normal (claims the key so
  the window Esc chain does not fire)
- cheat sheet gains the Tab-in-question display entry
This commit is contained in:
amosblomqvist
2026-09-18 08:34:46 +03:00
parent 8b158972db
commit ab5d5da9fb
4 changed files with 79 additions and 22 deletions
+52 -14
View File
@@ -294,20 +294,25 @@ function QuestionDockForm({
// answer and the reasoning behind it, side by side. // answer and the reasoning behind it, side by side.
const selectOther = () => setSel(toggleOther(selectMode, sel)); const selectOther = () => setSel(toggleOther(selectMode, sel));
/* ── The keyboard machine (shortcut wave B, 2026-09-17) ── /* ── The keyboard machine (shortcut wave B, owner rounds 2026-09-17) ──
↑/↓ walk EVERY answerable row in order — options first, then the ↑/↓ walk EVERY answerable row in order — options first, then the
quiz's "I don't know" or the ask's "Other…" (whichever exists). 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, CLAMPED at the ends, no wrap (owner: faster to navigate when the
single displaces; Other also focuses its input for typing), Enter walk stops at the last row). Space acts on the walked row exactly
submits once an answer exists. Two guards keep native behavior like a click (multi toggles, single displaces; Other also focuses
its input for typing), Enter submits once an answer exists.
Tab toggles FOCUS between the thinking field and the answer rows
(owner round 3) — nothing else; Shift+Tab is NOT ours (the global
thinking-level cycle owns it). Two guards keep native behavior
intact: keys from a TYPING SURFACE keep their meaning (arrows move intact: keys from a TYPING SURFACE keep their meaning (arrows move
the caret, space types), and keys from INSIDE the dock keep the the caret, space types), and modifier combos are never ours
DOM's semantics (Enter on a focused row/button clicks it). The (Alt+arrows cycle sessions — they must not ALSO walk the rows).
listener is document-level and lives only while the dock is The listener is document-level and lives only while the dock is
expanded — collapsed, the bar's native Enter expands it. */ expanded — collapsed, the bar's native Enter expands it. */
const [kbdIdx, setKbdIdx] = useState<number | null>(null); const [kbdIdx, setKbdIdx] = useState<number | null>(null);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const otherInputRef = useRef<HTMLInputElement>(null); const otherInputRef = useRef<HTMLInputElement>(null);
const thinkingRef = useRef<HTMLTextAreaElement>(null);
// One row past the options is the trailing row: quiz → don't-know, // One row past the options is the trailing row: quiz → don't-know,
// ask → Other (a question never has both). // ask → Other (a question never has both).
const optionCount = qd.mode === "text" ? 0 : qd.options.length; const optionCount = qd.mode === "text" ? 0 : qd.options.length;
@@ -323,20 +328,52 @@ function QuestionDockForm({
} }
}; };
// Focus the walked row's focusable element — the Other row is a div,
// so its inline input is the focus target there.
const focusRow = (idx: number) => {
const row = rootRef.current?.querySelector(`[data-kbdrow="${idx}"]`) as HTMLElement | null;
if (!row) return;
const el = (row.querySelector("input") as HTMLElement | null) ?? row;
el.focus();
};
useEffect(() => { useEffect(() => {
if (!active || rowCount === 0) return; if (!active || rowCount === 0) return;
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
const t = e.target as HTMLElement | null; const t = e.target as HTMLElement | null;
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; const typing = !!t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable);
// Keys from inside the dock keep DOM semantics (Enter clicks a // Alt/Cmd/Ctrl combos are never ours (Alt+arrows cycle sessions
// focused row; Space presses a focused button). // globally — they must not ALSO walk the question rows).
if (t && rootRef.current?.contains(t)) return; const modded = e.altKey || e.metaKey || e.ctrlKey;
// Plain Tab toggles focus: rows → thinking, anything else → the
// walked (or first) row. Shift+Tab falls through (the global
// thinking-level cycle owns it), so the shift check matters.
if (e.key === "Tab" && !e.shiftKey && !modded) {
e.preventDefault();
e.stopPropagation();
if (t?.closest?.("[data-kbdrow]")) thinkingRef.current?.focus();
else {
const idx = kbdIdx ?? 0;
setKbdIdx(idx);
focusRow(idx);
}
return;
}
// Keys from a typing surface keep their meaning (arrows move
// the caret, space types).
if (typing || modded) return;
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
setKbdIdx((i) => (i === null ? 0 : (i + 1) % rowCount)); const next = Math.min((kbdIdx ?? -1) + 1, rowCount - 1);
setKbdIdx(next);
// Focus follows the walk while the user is IN the rows (Tab
// put them there) so focused and walked never diverge.
if (t?.closest?.("[data-kbdrow]")) focusRow(next);
} else if (e.key === "ArrowUp") { } else if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
setKbdIdx((i) => (i === null ? rowCount - 1 : (i - 1 + rowCount) % rowCount)); const next = Math.max((kbdIdx ?? rowCount) - 1, 0);
setKbdIdx(next);
if (t?.closest?.("[data-kbdrow]")) focusRow(next);
} else if (e.key === " ") { } else if (e.key === " ") {
if (kbdIdx === null) return; // plain Space keeps scrolling the page if (kbdIdx === null) return; // plain Space keeps scrolling the page
e.preventDefault(); e.preventDefault();
@@ -542,6 +579,7 @@ function QuestionDockForm({
/> />
) : ( ) : (
<textarea <textarea
ref={thinkingRef}
value={thinking} value={thinking}
onChange={(e) => setThinking(e.target.value)} onChange={(e) => setThinking(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
+9
View File
@@ -436,6 +436,15 @@ function SessionsSidebar() {
data-testid="session-search" data-testid="session-search"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
// Esc leaves the search (owner, keyboard wave): blur back
// to normal, and claim the key so the window Esc chain
// (dictation / abort) doesn't also fire.
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
e.currentTarget.blur();
}
}}
placeholder="Search..." placeholder="Search..."
aria-label="Search sessions" aria-label="Search sessions"
className="h-6 rounded-sm border-border bg-background pl-6 text-xs placeholder:text-muted-foreground/60" className="h-6 rounded-sm border-border bg-background pl-6 text-xs placeholder:text-muted-foreground/60"
+16 -8
View File
@@ -33,7 +33,7 @@ second pillar of input).
round) → abort turn (local stream only — a server-side wake turn has round) → abort turn (local stream only — a server-side wake turn has
no client request to abort). Every element/document-level Esc handler no client request to abort). Every element/document-level Esc handler
that acts calls `e.stopPropagation()` so the window chain never that acts calls `e.stopPropagation()` so the window chain never
double-fires. double-fires. The session search claims Esc to blur back to normal.
- **Focus ring**: browser Tab-focus is gone, so `:focus-visible` stays - **Focus ring**: browser Tab-focus is gone, so `:focus-visible` stays
unstyled (an earlier experimental outline was live-rejected — inputs unstyled (an earlier experimental outline was live-rejected — inputs
showed a red ring on plain click-focus). showed a red ring on plain click-focus).
@@ -55,21 +55,29 @@ second pillar of input).
| Cheat sheet | ? | keyboard-shortcuts.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) | | 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) ## Question dock row machine (Wave B, owner rounds 2026-09-17)
While a question is expanded, the dock owns ↑/↓/Space/Enter: While a question is expanded, the dock owns ↑/↓/Space/Enter/Tab:
- **↑/↓** walk every answerable row in order — options first, then the - **↑/↓** walk every answerable row in order — options first, then the
quiz's "I don't know" / the ask's "Other…" (never both). Wraps. The quiz's "I don't know" / the ask's "Other…" (never both) — **clamped at
walked row gets a subtle ring (`bg-accent/40`) and scrollIntoView. the ends, no wrap** (owner: faster to navigate when the walk stops).
The walked row gets a subtle ring (`bg-accent/40`) and scrollIntoView.
- **Space** acts on the walked row exactly like a click: multi toggles, - **Space** acts on the walked row exactly like a click: multi toggles,
single displaces; Other also focuses its inline input for typing. single displaces; Other also focuses its inline input for typing.
- **Enter** submits once an answer exists (`canSubmit`). - **Enter** submits once an answer exists (`canSubmit`).
- **Tab (plain)** toggles focus between the thinking field and the
answer rows — nothing else (owner round 3). From the thinking side it
focuses the walked (or first) row; from the rows it returns to the
thinking field. Focus follows the walk while the user is in the rows,
so focused and walked never diverge. Shift+Tab is NOT the dock's —
the global thinking-level cycle owns it everywhere.
- Guards: keys from a typing surface keep their native meaning (arrows - Guards: keys from a typing surface keep their native meaning (arrows
move the caret, space types — the thinking field is never hijacked), move the caret, space types — the thinking field is never hijacked),
and keys from inside the dock keep DOM semantics (Enter on a focused and modifier combos are never the dock's — Alt+arrows cycle sessions
button clicks it). The listener lives only while the dock is expanded without ALSO walking the question rows (owner round 3). The listener
— collapsed, the bar's native Enter expands it. lives only while the dock is expanded — collapsed, the bar's native
Enter expands it.
## Status ## Status
+2
View File
@@ -33,6 +33,7 @@ export type KeyAction =
| "rename-session" | "rename-session"
| "cheat-sheet" | "cheat-sheet"
| "find" | "find"
| "question-focus"
| "question-next-row" | "question-next-row"
| "question-prev-row" | "question-prev-row"
| "question-pick-row" | "question-pick-row"
@@ -74,6 +75,7 @@ export const KEYBOARD_ACTIONS: readonly KeyActionDef[] = [
// The question dock's row machine (wave B) — displayOnly: the dock owns // The question dock's row machine (wave B) — displayOnly: the dock owns
// the keys while a question is expanded; the registry never dispatches // the keys while a question is expanded; the registry never dispatches
// them, so bare arrows stay free everywhere else. // them, so bare arrows stay free everywhere else.
{ id: "question-focus", label: "Question · focus thinking ↔ rows", scope: "chat", binding: "tab", displayOnly: true },
{ id: "question-next-row", label: "Question · next row", scope: "chat", binding: "arrowdown", displayOnly: true }, { 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-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-pick-row", label: "Question · pick row", scope: "chat", binding: "space", displayOnly: true },