keyboard: Enter submits from the custom answer; Enter opens the waiting check (keyboard 1-2 of 4)

Slice 1 — the ask's custom-answer field: Enter submits directly. A
single-line input with no newline to protect, and typing has already
selected the row — every Enter (shift or not) rides the same submit()
seam as the button, dictation finish-first included. No more the
Esc-then-Enter dance to file a custom answer.

Slice 2 — 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, the live claim, per the nothing-gets-lost law — then the check
reveals/expands. Two seams: the reading-thinking field's own Enter
(plain or ⌘/Ctrl; Shift+Enter keeps the newline convention — the dump
convention survives for deliberate breaks, and Enter stays a newline
once the check is OPEN), and a document-level listener for plain-focus
Enter — gated to skip typing surfaces and focused buttons, which own
their own Enter.

1427 tests, tsc, lint, build green.
This commit is contained in:
amosblomqvist
2026-10-07 00:00:57 +03:00
parent 95ba9c8272
commit 9007e6eb7a
3 changed files with 71 additions and 1 deletions
+43 -1
View File
@@ -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}
/>
)}
+11
View File
@@ -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"
+17
View File
@@ -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.