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}
/>
)}