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:
@@ -294,20 +294,25 @@ 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) ──
|
||||
/* ── The keyboard machine (shortcut wave B, owner rounds 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
|
||||
quiz's "I don't know" or the ask's "Other…" (whichever exists) —
|
||||
CLAMPED at the ends, no wrap (owner: faster to navigate when the
|
||||
walk stops at the last row). 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.
|
||||
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
|
||||
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
|
||||
the caret, space types), and modifier combos are never ours
|
||||
(Alt+arrows cycle sessions — they must not ALSO walk the rows).
|
||||
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<number | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const otherInputRef = useRef<HTMLInputElement>(null);
|
||||
const thinkingRef = useRef<HTMLTextAreaElement>(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;
|
||||
@@ -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(() => {
|
||||
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;
|
||||
const typing = !!t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable);
|
||||
// Alt/Cmd/Ctrl combos are never ours (Alt+arrows cycle sessions
|
||||
// globally — they must not ALSO walk the question rows).
|
||||
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") {
|
||||
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") {
|
||||
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 === " ") {
|
||||
if (kbdIdx === null) return; // plain Space keeps scrolling the page
|
||||
e.preventDefault();
|
||||
@@ -542,6 +579,7 @@ function QuestionDockForm({
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
ref={thinkingRef}
|
||||
value={thinking}
|
||||
onChange={(e) => setThinking(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
|
||||
@@ -436,6 +436,15 @@ function SessionsSidebar() {
|
||||
data-testid="session-search"
|
||||
value={query}
|
||||
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..."
|
||||
aria-label="Search sessions"
|
||||
className="h-6 rounded-sm border-border bg-background pl-6 text-xs placeholder:text-muted-foreground/60"
|
||||
|
||||
+16
-8
@@ -33,7 +33,7 @@ second pillar of input).
|
||||
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.
|
||||
double-fires. The session search claims Esc to blur back to normal.
|
||||
- **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).
|
||||
@@ -55,21 +55,29 @@ second pillar of input).
|
||||
| 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)
|
||||
## 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
|
||||
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.
|
||||
quiz's "I don't know" / the ask's "Other…" (never both) — **clamped at
|
||||
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,
|
||||
single displaces; Other also focuses its inline input for typing.
|
||||
- **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
|
||||
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.
|
||||
and modifier combos are never the dock's — Alt+arrows cycle sessions
|
||||
without ALSO walking the question rows (owner round 3). The listener
|
||||
lives only while the dock is expanded — collapsed, the bar's native
|
||||
Enter expands it.
|
||||
|
||||
## Status
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export type KeyAction =
|
||||
| "rename-session"
|
||||
| "cheat-sheet"
|
||||
| "find"
|
||||
| "question-focus"
|
||||
| "question-next-row"
|
||||
| "question-prev-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 keys while a question is expanded; the registry never dispatches
|
||||
// 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-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 },
|
||||
|
||||
Reference in New Issue
Block a user