diff --git a/components/chat.tsx b/components/chat.tsx index a9cea1a..ef34e48 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -26,7 +26,7 @@ import { Composer } from "@/components/composer"; import { useEnterSubmits } from "@/components/enter-submits"; import { dockStateKey } from "@/components/dock-state-key"; import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers"; -import { DictationBar, joinDictation, useDictation } from "@/components/dictation"; +import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation"; import { JobsContext } from "@/components/jobs-context"; import { StatsBar } from "@/components/stats-bar"; import { useServerEvent, useServerResync } from "@/components/server-events"; @@ -2027,6 +2027,39 @@ function DockOverlay({ setExpanded(true); void suppressFoldDuringReveal(); }; + const expandAndRevealRef = useRef(expandAndReveal); + expandAndRevealRef.current = expandAndReveal; + // ENTER OPENS THE WAITING CHECK (owner, keyboard round 2026-10-06): + // while a question waits with the dock CLOSED — behind its reveal + // invitation or collapsed by scroll — Enter is never dead. Any live + // dictation FINISHES FIRST (the flush lands in the reading-thinking + // field, its live claim — the nothing-gets-lost law), then the check + // opens. A typing surface or a focused button owns its own Enter (the + // reading-thinking field opens from INSIDE when the check is closed; + // a focused button activates natively) — this listener claims only + // the dead case: plain focus with Enter doing nothing. + useEffect(() => { + if (!pending || expanded) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Enter" || e.altKey) return; + if (e.shiftKey && !e.metaKey && !e.ctrlKey) return; + const t = e.target as HTMLElement | null; + if ( + t && + ((t.tagName === "INPUT" && (t as HTMLInputElement).type !== "range") || + t.tagName === "TEXTAREA" || + t.isContentEditable || + t.closest("button")) + ) + return; + e.preventDefault(); + void finishDictation().then(() => { + expandAndRevealRef.current(); + }); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [pending, expanded]); useEffect(() => { const el = scrollRef.current; @@ -2149,6 +2182,15 @@ function DockOverlay({ value={readingThinking} onChange={onReadingThinking} onEscape={dictationCancel} + onOpen={ + !expanded + ? () => { + void finishDictation().then(() => { + expandAndRevealRef.current(); + }); + } + : undefined + } claim={revealPending} /> )} diff --git a/components/question-dock.tsx b/components/question-dock.tsx index 40cca4b..24b4e74 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -667,6 +667,17 @@ function QuestionDockForm({ e.currentTarget.blur(); setKbdIdx(optionCount); } + // Enter submits directly (owner, keyboard round 2026-10-06): + // the custom answer is a single-line input — no newline + // to protect, and typing has already selected the row, so + // the payload is ready. Every Enter (shift or not — nothing + // else could ever happen in a one-line field) rides the + // same submit seam as the button, dictation finish-first + // included. + if (e.key === "Enter") { + e.preventDefault(); + submit(); + } }} placeholder="Type your own answer…" className="lh-input w-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground" diff --git a/components/reading-thinking.tsx b/components/reading-thinking.tsx index 061be15..0691c3e 100644 --- a/components/reading-thinking.tsx +++ b/components/reading-thinking.tsx @@ -22,12 +22,18 @@ export function ReadingThinkingField({ value, onChange, onEscape, + onOpen, claim, }: { value: string; onChange: (v: string) => void; /** Esc in the field stops dictation (threaded from the page's hook). */ onEscape?: () => void; + /** The check waits CLOSED — Enter opens it (owner, keyboard round + * 2026-10-06). The caller finishes any live dictation FIRST (the + * flush lands here — the field is the live claim), then reveals. + * Absent = the check is open: Enter keeps the dumping convention. */ + onOpen?: () => void; /** Primary dictation claim — true while the check is behind its reveal. */ claim: boolean; }) { @@ -60,6 +66,17 @@ export function ReadingThinkingField({ onEscape?.(); return; } + // ENTER OPENS THE WAITING CHECK (owner, keyboard round + // 2026-10-06): while the check sits closed, plain Enter + // (and the universal ⌘/Ctrl+Enter) opens it — the dictation + // flush lands HERE first via the caller's finishDictation, + // so spoken thoughts ride along. Shift+Enter stays a newline. + if (e.key === "Enter" && onOpen !== undefined && (!e.shiftKey || e.metaKey || e.ctrlKey)) { + e.preventDefault(); + e.stopPropagation(); + onOpen(); + return; + } // The dock's dumping convention: plain Enter is a newline. // No submit button here — the check's own submit is this // field's carrier — so ⌘↵ just blurs.