From 94470815ab69d5c0683a90cb530244b05b32b1cd Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 7 Oct 2026 00:04:03 +0300 Subject: [PATCH] keyboard: Tab cycles the surface's text fields, forward-only (keyboard 3 of 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain Tab now navigates instead of being pure dead weight: the global handler dispatches lh:kbd-cycle-fields and each composer-bearing surface answers with its own chain, gathered from live DOM at event time (the docks mount and unmount — refs would go stale): - chat: reading-thinking → the active dock's field → the composer, visual order, wrap; Tab from anywhere else focuses the first live field (navigate in) - new-session + home: the composer is the whole chain — Tab navigates into it Shift+Tab is untouched (the thinking-level cycle keeps it — owner ruling); the quiz dock's rows↔thinking Tab claim stops propagation at document level, so a claimed Tab never reaches the dispatch — no double act. 1427 tests, tsc, lint, build green. --- components/chat.tsx | 17 +++++++++++++++ components/field-cycle.ts | 36 +++++++++++++++++++++++++++++++ components/home.tsx | 9 +++++++- components/keyboard-shortcuts.tsx | 7 ++++++ components/new-session.tsx | 7 ++++++ 5 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 components/field-cycle.ts diff --git a/components/chat.tsx b/components/chat.tsx index ef34e48..18a9957 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -27,6 +27,7 @@ import { useEnterSubmits } from "@/components/enter-submits"; import { dockStateKey } from "@/components/dock-state-key"; import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers"; import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation"; +import { useFieldCycle } from "@/components/field-cycle"; import { JobsContext } from "@/components/jobs-context"; import { StatsBar } from "@/components/stats-bar"; import { useServerEvent, useServerResync } from "@/components/server-events"; @@ -591,6 +592,22 @@ export function Chat({ // Voice dictation (STT wave, 2026-09-17): finals append at the end of // the current text (the page owns the separator, dictate-flush style); // interims ghost in the stats bar's middle slot next to the meter. + // TAB CYCLES THE CHAT'S FIELDS (owner, keyboard round 2026-10-06): + // reading-thinking → the active dock's field → the composer, in + // visual order, forward-only with wrap; Tab from anywhere else + // focuses the first live field (navigate IN). Gathered from live + // DOM at event time — the docks mount and unmount, refs would go + // stale. The quiz dock's rows↔thinking claim never reaches here. + const cycleChatFields = useCallback((): readonly HTMLElement[] => { + const pick = (sel: string) => Array.from(document.querySelectorAll(sel)); + return [ + ...pick('[data-testid="reading-thinking-field"] textarea'), + ...pick('[data-testid="question-dock"] textarea'), + ...pick('[data-testid="practice-dock"] textarea'), + ...pick('[data-testid="composer"] textarea'), + ]; + }, []); + useFieldCycle(cycleChatFields); const dictation = useDictation({ // Ref-first (see textRef): the join is computed from the mirror so a // send resuming after `await finish()` reads the dictated tail. diff --git a/components/field-cycle.ts b/components/field-cycle.ts new file mode 100644 index 0000000..98cacea --- /dev/null +++ b/components/field-cycle.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * TAB CYCLES THE ACTIVE SURFACE'S TEXT FIELDS (owner, keyboard round + * 2026-10-06): forward-only, wrapping; Tab from outside the chain + * focuses the FIRST field — "navigate in". Shift+Tab is NOT ours (the + * global thinking-level cycle owns it — owner ruling the same round), + * so there is no reverse gear; the chain wraps. + * + * The event (not a prop bridge): the global Tab handler dispatches + * `lh:kbd-cycle-fields` and each composer-bearing surface answers with + * its own chain, gathered at event time from live DOM — the fields + * mount/unmount with the docks, so a query beats stale refs. The quiz + * dock's own Tab claim (rows ↔ thinking, while an option question is + * expanded) stops propagation at the document level, so this hook only + * ever hears Tab on surfaces WITHOUT an active claim — no double act. + */ +export function useFieldCycle(fields: () => readonly HTMLElement[]) { + useEffect(() => { + const onCycle = () => { + const list = fields().filter((el) => el.isConnected); + if (list.length === 0) return; + const active = document.activeElement; + const idx = active ? list.findIndex((el) => el === active || el.contains(active)) : -1; + if (idx < 0) { + list[0]!.focus(); + return; + } + list[(idx + 1) % list.length]!.focus(); + }; + window.addEventListener("lh:kbd-cycle-fields", onCycle); + return () => window.removeEventListener("lh:kbd-cycle-fields", onCycle); + }, [fields]); +} diff --git a/components/home.tsx b/components/home.tsx index 2a574a1..4ff9101 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { CornerDownLeftIcon, DumbbellIcon } from "lucide-react"; import { GraphView } from "@/components/graph-view"; @@ -8,6 +8,7 @@ import { MinimalHome } from "@/components/minimal-home"; import { SetupDialog } from "@/components/setup-dialog"; import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; import { DictationBar, joinDictation, useDictation } from "@/components/dictation"; +import { useFieldCycle } from "@/components/field-cycle"; import { SidebarToggle } from "@/components/sidebar"; import { StatsBar } from "@/components/stats-bar"; import type { TurnUsage } from "@/src/core"; @@ -51,6 +52,12 @@ export function Home({ const [setupOpen, setSetupOpen] = useState(false); // Voice dictation (STT wave, 2026-09-17): finals append at the end of the // topic draft; the strip lives in the stats bar's middle slot. + // Tab navigates INTO the composer (owner, keyboard round 2026-10-06): + // the only text field on this surface — the cycle's whole chain. + const cycleComposer = useCallback((): readonly HTMLElement[] => + Array.from(document.querySelectorAll('[data-testid="composer"] textarea')), + []); + useFieldCycle(cycleComposer); const dictation = useDictation({ // Ref-first: the join reads the mirror so the send resuming after // `await finish()` sees the dictated tail. diff --git a/components/keyboard-shortcuts.tsx b/components/keyboard-shortcuts.tsx index 19e6977..729f70e 100644 --- a/components/keyboard-shortcuts.tsx +++ b/components/keyboard-shortcuts.tsx @@ -65,6 +65,13 @@ export function KeyboardShortcuts() { if (e.key === "Tab") { e.preventDefault(); if (e.shiftKey) window.dispatchEvent(new CustomEvent("lh:kbd-cycle-thinking")); + // Plain Tab cycles the active surface's text fields (owner, + // keyboard round 2026-10-06) — forward-only, wrapping; each + // composer-bearing page answers with its own chain. The quiz + // dock's rows↔thinking claim stops propagation at document + // level, so a claimed Tab never reaches this dispatch. + else if (!e.metaKey && !e.ctrlKey && !e.altKey) + window.dispatchEvent(new CustomEvent("lh:kbd-cycle-fields")); return; } diff --git a/components/new-session.tsx b/components/new-session.tsx index a7b19d0..b9051e4 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { Conversation, ConversationContent, ConversationEmptyState } from "@/components/ai-elements/conversation"; import { Composer } from "@/components/composer"; import { DictationBar, joinDictation, useDictation } from "@/components/dictation"; +import { useFieldCycle } from "@/components/field-cycle"; import { SidebarToggle } from "@/components/sidebar"; import { SetupDialog } from "@/components/setup-dialog"; import { useStrongKeyless, useZeroKeys, ZeroKeysPill } from "@/components/setup-cta"; @@ -112,6 +113,12 @@ export function DraftShell({ // Voice dictation (STT wave, 2026-09-17): finals append at the end of the // draft text (via the parent's onChange — the truth lives there); the // strip lives in the stats bar's middle slot. + // Tab navigates INTO the composer (owner, keyboard round 2026-10-06): + // the only text field on this surface — the cycle's whole chain. + const cycleComposer = useCallback((): readonly HTMLElement[] => + Array.from(document.querySelectorAll('[data-testid="composer"] textarea')), + []); + useFieldCycle(cycleComposer); const dictation = useDictation({ append: (t) => onChange(joinDictation(text, t)), });