dictation feel: finish-first submits, composer focus on run end

Two owner rulings, 2026-10-01 ('nothing gets lost' round):

1. SUBMIT FINISHES DICTATION FIRST. Every submit path — the dock's
   answer (Enter/Cmd+Enter/button) and the composer's send — stops any
   live run and lets Deepgram's last finals land in the target field
   BEFORE building the payload. Dictation.finish() resolves at cleanup,
   post-flush; a module finisher registry (same pattern as the question
   target) hands it to the dock without a prop bridge. Field values are
   read from REF MIRRORS (thinkingTextRef/answerTextRef, the composer's
   textRef): the flush lands via setState after an await, invisible to
   the closure. The quiz flow the owner described now holds: dictate
   reasoning → pick answer → pick confidence → Enter → dictation ends,
   reasoning saved into the thinking field, THEN the answer submits.
   The composer stays sendable while a run is live (its spoken text is
   deferred in the run's buffer) so Enter can trigger the finish-first
   send at all; nothing said → the send no-ops.

2. RUN END FOCUSES THE BARE COMPOSER. The falling edge of
   dictation.active focuses the composer's textarea — only when the
   composer is mounted: a pending question unmounts it (ref null, no
   focus), so the thinking field is deliberately left alone. Composer
   gains an optional inputRef for the page's focus handle.
This commit is contained in:
amosblomqvist
2026-10-02 01:37:18 +03:00
parent 6da192bb22
commit 8460e96d6c
4 changed files with 162 additions and 17 deletions
+5
View File
@@ -31,6 +31,7 @@ export function Composer({
onStop,
canSend,
autoFocus,
inputRef,
onEscape,
placeholder = "Ask, answer, or steer the lesson…",
}: {
@@ -41,6 +42,9 @@ export function Composer({
onStop: () => void;
canSend: boolean;
autoFocus?: boolean;
/** The page's handle to the textarea — the dictation-end focus (owner,
* 2026-10-01): a run ending hands the cursor to the bare composer. */
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
/** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op
* when absent — Esc otherwise means nothing in the composer. */
onEscape?: () => void;
@@ -61,6 +65,7 @@ export function Composer({
}}
>
<textarea
ref={inputRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {