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
+54 -9
View File
@@ -20,7 +20,7 @@ import { AnsweredCard } from "@/components/answered-card";
import { artifactRenderers } from "@/components/artifacts"; import { artifactRenderers } from "@/components/artifacts";
import { SubagentSteerCard } from "@/components/artifacts/steer-card"; import { SubagentSteerCard } from "@/components/artifacts/steer-card";
import { Composer } from "@/components/composer"; import { Composer } from "@/components/composer";
import { DictationBar, useDictation } from "@/components/dictation"; import { DictationBar, joinDictation, useDictation } from "@/components/dictation";
import { JobsContext } from "@/components/jobs-context"; import { JobsContext } from "@/components/jobs-context";
import { StatsBar } from "@/components/stats-bar"; import { StatsBar } from "@/components/stats-bar";
import { useActivity } from "@/components/activity-dock"; import { useActivity } from "@/components/activity-dock";
@@ -383,6 +383,15 @@ export function Chat({
const streamingActivity = !pending ? streamingToolActivity(messages) : null; const streamingActivity = !pending ? streamingToolActivity(messages) : null;
const [text, setText] = useState(""); const [text, setText] = useState("");
/* The composer's value MIRROR ("nothing gets lost" round, owner
* 2026-10-01): a dictation flush lands via setState, which a closure
* waiting through `await finish()` cannot observe in time. Kept
* current at every write site via applyText. */
const textRef = useRef("");
const applyText = useCallback((v: string) => {
textRef.current = v;
setText(v);
}, []);
// The BYOK setup flow (owner, 2026-09-22): the persistent CTA and the // The BYOK setup flow (owner, 2026-09-22): the persistent CTA and the
// refusal interception share one SetupDialog mount. `configured` is // refusal interception share one SetupDialog mount. `configured` is
// seeded server-side (slotsConfigured on the record) and flips on Save. // seeded server-side (slotsConfigured on the record) and flips on Save.
@@ -437,7 +446,7 @@ export function Chat({
} }
if (cancelled) return; if (cancelled) return;
if (draftRef.current !== null) { if (draftRef.current !== null) {
setText(draftRef.current); applyText(draftRef.current);
draftRef.current = null; draftRef.current = null;
} }
clearError(); clearError();
@@ -453,14 +462,36 @@ export function Chat({
// the current text (the page owns the separator, dictate-flush style); // the current text (the page owns the separator, dictate-flush style);
// interims ghost in the stats bar's middle slot next to the meter. // interims ghost in the stats bar's middle slot next to the meter.
const dictation = useDictation({ const dictation = useDictation({
append: (t) => setText((v) => (v ? v.replace(/\s+$/, "") + " " + t : t)), // Ref-first (see textRef): the join is computed from the mirror so a
// send resuming after `await finish()` reads the dictated tail.
append: (t) => applyText(joinDictation(textRef.current, t)),
}); });
/* When a dictation run ends, the bare composer takes focus (owner
* 2026-10-01) — the hands are already on the keyboard from the stop.
* Falling edge of `active` only (idle→idle never focuses), and ONLY the
* composer: with a question pending the Composer is unmounted, the ref
* is null, and the thinking field is left alone by design. */
const dictationWasActiveRef = useRef(false);
const composerInputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (dictation.active) {
dictationWasActiveRef.current = true;
} else if (dictationWasActiveRef.current) {
dictationWasActiveRef.current = false;
composerInputRef.current?.focus();
}
}, [dictation.active]);
// Jobs never gate the composer (contracts.ts "THE ASYNC PROTOCOL" §4): job // Jobs never gate the composer (contracts.ts "THE ASYNC PROTOCOL" §4): job
// tools resolve with an ack mid-turn, so the learner can interject while // tools resolve with an ack mid-turn, so the learner can interject while
// sub-agents run — a feature, not a race: the wake pipeline queues steers // sub-agents run — a feature, not a race: the wake pipeline queues steers
// until the current turn ends. Only a QUESTION pending or a turn in // until the current turn ends. Only a QUESTION pending or a turn in
// flight (client or server-side wake turn) gates sending. // flight (client or server-side wake turn) gates sending.
const canSend = text.trim().length > 0 && !pending && !turnInFlight; // A LIVE dictation also makes the composer sendable (owner 2026-10-01):
// the spoken text is deferred in the run's buffer — Enter must be able
// to trigger the finish-first send (see onSend), not be gated out by an
// empty textarea. If nothing was said, the send is a harmless no-op.
const canSend = (text.trim().length > 0 || dictation.active) && !pending && !turnInFlight;
const syncFromServer = useCallback(async () => { const syncFromServer = useCallback(async () => {
const res = await fetch(`/api/chats/${chatId}`); const res = await fetch(`/api/chats/${chatId}`);
@@ -893,10 +924,23 @@ export function Chat({
}, [turnInFlight, chatId, syncFromServer, status, clearError]); }, [turnInFlight, chatId, syncFromServer, status, clearError]);
const onSend = () => { const onSend = () => {
const t = text.trim(); if (busy || pending) return;
if (!t || busy || pending) return; /* FINISH DICTATION FIRST (owner, 2026-10-01): a learner who keeps
* talking right through Enter must not lose the tail — stop the run,
* let the last finals land in the composer, then send the REF value
* (the flush lands via setState, invisible to this closure). */
if (!dictation.active) {
doSend();
return;
}
void dictation.finish().then(doSend);
};
const doSend = () => {
if (busy || pending) return;
const t = textRef.current.trim();
if (!t) return;
draftRef.current = t; // the refusal flow's preserve-and-replay seam draftRef.current = t; // the refusal flow's preserve-and-replay seam
setText(""); applyText("");
void send({ type: "text", text: t }); void send({ type: "text", text: t });
}; };
@@ -1202,7 +1246,8 @@ export function Chat({
// only detach our view. // only detach our view.
busy={busy} busy={busy}
value={text} value={text}
onChange={setText} onChange={applyText}
inputRef={composerInputRef}
onSend={onSend} onSend={onSend}
onStop={abortInFlightTurn} onStop={abortInFlightTurn}
canSend={canSend} canSend={canSend}
@@ -1250,7 +1295,7 @@ export function Chat({
// into the composer at refusal time; Save sends it onward. // into the composer at refusal time; Save sends it onward.
const t = text.trim(); const t = text.trim();
if (t) { if (t) {
setText(""); applyText("");
draftRef.current = t; draftRef.current = t;
void send({ type: "text", text: t }); void send({ type: "text", text: t });
} }
+5
View File
@@ -31,6 +31,7 @@ export function Composer({
onStop, onStop,
canSend, canSend,
autoFocus, autoFocus,
inputRef,
onEscape, onEscape,
placeholder = "Ask, answer, or steer the lesson…", placeholder = "Ask, answer, or steer the lesson…",
}: { }: {
@@ -41,6 +42,9 @@ export function Composer({
onStop: () => void; onStop: () => void;
canSend: boolean; canSend: boolean;
autoFocus?: 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 /** Escape while a dictation runs stops it (STT wave, 2026-09-17). No-op
* when absent — Esc otherwise means nothing in the composer. */ * when absent — Esc otherwise means nothing in the composer. */
onEscape?: () => void; onEscape?: () => void;
@@ -61,6 +65,7 @@ export function Composer({
}} }}
> >
<textarea <textarea
ref={inputRef}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
+60 -1
View File
@@ -214,6 +214,17 @@ export function isDictationActive(): boolean {
return dictationActiveFlag; return dictationActiveFlag;
} }
/* The finisher registry: submit paths FAR from the hook's owner (the
* pending question's dock) need finish() without a prop bridge. Same
* module-level pattern as questionTarget — one window, one instance,
* one finisher. Empty registry (no hook mounted) → resolve immediately. */
let dictationFinisher: (() => Promise<void>) | null = null;
/** Finish any live dictation run before continuing (see Dictation.finish). */
export function finishDictation(): Promise<void> {
return dictationFinisher ? dictationFinisher() : Promise.resolve();
}
/** Join a dictated final onto the current text (shared separator rule). */ /** Join a dictated final onto the current text (shared separator rule). */
export function joinDictation(current: string, final: string): string { export function joinDictation(current: string, final: string): string {
return current ? current.replace(/\s+$/, "") + " " + final : final; return current ? current.replace(/\s+$/, "") + " " + final : final;
@@ -248,6 +259,16 @@ export type Dictation = {
* input (the Stop pill and ⌘D). */ * input (the Stop pill and ⌘D). */
toggle: () => void; toggle: () => void;
stop: () => void; stop: () => void;
/** FINISH, THEN CONTINUE (owner, 2026-10-01 "nothing gets lost" round):
* if a run is live, stop it and resolve only when the run has fully
* ended — Deepgram's last finals have landed in the target field. A
* submit path awaits this before building its payload, so a learner
* who forgets to stop dictation before Enter never loses the spoken
* reasoning. Idle → resolves immediately (the common no-run submit
* pays nothing). Every terminal path runs cleanup(), and cleanup()
* resolves the waiters — cancel (Esc) resolves them too, by its
* discard semantics. */
finish: () => Promise<void>;
/** End + DISCARD the transcript: no flush, immediate teardown. The Esc /** End + DISCARD the transcript: no flush, immediate teardown. The Esc
* semantics (owner, 2026-09-17 round 3): Esc is a cancel, not a stop — * semantics (owner, 2026-09-17 round 3): Esc is a cancel, not a stop —
* before, it flushed exactly like the toggle did. */ * before, it flushed exactly like the toggle did. */
@@ -291,6 +312,9 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
/** The deferred transcript (owner round 2): finals land here during the /** The deferred transcript (owner round 2): finals land here during the
* run and flush into the primary input once, at teardown. */ * run and flush into the primary input once, at teardown. */
const bufferRef = useRef<string[]>([]); const bufferRef = useRef<string[]>([]);
/** Resolvers waiting for the run to fully end (finish(), owner
* 2026-10-01): released by cleanup() — every terminal path runs it. */
const waitersRef = useRef<Array<() => void>>([]);
/** Flush the buffered transcript into the primary input — question claim /** Flush the buffered transcript into the primary input — question claim
* first, composer fallback. Called exactly once per run, at cleanup; the * first, composer fallback. Called exactly once per run, at cleanup; the
@@ -327,6 +351,10 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
} catch {} } catch {}
} }
setPhase("idle"); setPhase("idle");
// The finish() waiters ride until NOW — post-flush, post-teardown:
// a submit path resuming after `await finishDictation()` reads the
// target field with the dictated tail already in it.
for (const w of waitersRef.current.splice(0)) w();
// Pre-mint for the next run (mitigation D) — best-effort; a keyless // Pre-mint for the next run (mitigation D) — best-effort; a keyless
// page just doesn't warm. // page just doesn't warm.
warmTokens.refresh(); warmTokens.refresh();
@@ -525,6 +553,27 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
cleanup(); cleanup();
}, [phase, cleanup]); }, [phase, cleanup]);
const finish = useCallback((): Promise<void> => {
return new Promise((resolve) => {
if (phase === "idle") {
resolve();
return;
}
waitersRef.current.push(resolve);
// "stopping" is already ending — the waiter just rides to cleanup.
if (phase === "recording" || phase === "connecting") stop();
});
}, [phase, stop]);
// Publish finish() for the sibling subtrees (the pending question's
// dock) — registered for as long as this page owns the hook.
useEffect(() => {
dictationFinisher = finish;
return () => {
if (dictationFinisher === finish) dictationFinisher = null;
};
}, [finish]);
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (phase === "idle") void start(); if (phase === "idle") void start();
else if (phase === "recording" || phase === "connecting") stop(); else if (phase === "recording" || phase === "connecting") stop();
@@ -547,7 +596,17 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
return () => window.removeEventListener("lh:kbd-cancel-dictation", cancel); return () => window.removeEventListener("lh:kbd-cancel-dictation", cancel);
}, [cancel]); }, [cancel]);
return { phase, error, levelRef, active: phase !== "idle", toggle, stop, cancel, clearError: () => setError(null) }; return {
phase,
error,
levelRef,
active: phase !== "idle",
toggle,
stop,
finish,
cancel,
clearError: () => setError(null),
};
} }
/** /**
+43 -7
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react"; import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { joinDictation, useDictationTarget } from "@/components/dictation"; import { finishDictation, isDictationActive, joinDictation, useDictationTarget } from "@/components/dictation";
import { MessageResponse } from "@/components/ai-elements/message"; import { MessageResponse } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -267,6 +267,12 @@ function QuestionDockForm({
const [thinking, setThinking] = useState(""); const [thinking, setThinking] = useState("");
const [other, setOther] = useState(""); const [other, setOther] = useState("");
const [text, setText] = useState(""); const [text, setText] = useState("");
/* Refs MIRROR the two dictation-target fields ("nothing gets lost"
* round, owner 2026-10-01): the run's flush lands via setState, which
* a closure waiting through `await finishDictation()` cannot observe
* in time — submit reads these, kept current at every write site. */
const thinkingTextRef = useRef("");
const answerTextRef = useRef("");
// Confidence (owner, 2026-10-01): an index into CONFIDENCE_STOPS, // Confidence (owner, 2026-10-01): an index into CONFIDENCE_STOPS,
// resting at 50 — a real coin flip by definition, untouched or // resting at 50 — a real coin flip by definition, untouched or
// deliberate (the same number, the same meaning). The dock unmounts // deliberate (the same number, the same meaning). The dock unmounts
@@ -282,8 +288,18 @@ function QuestionDockForm({
useDictationTarget( useDictationTarget(
useCallback( useCallback(
(t: string) => { (t: string) => {
if (qd.mode === "text") setText((v) => joinDictation(v, t)); // Ref-first (see the refs block above): the join is computed from
else setThinking((v) => joinDictation(v, t)); // the mirror, then both mirrors agree with state — a submit
// resuming post-finish reads the tail, never a stale closure.
if (qd.mode === "text") {
const v = joinDictation(answerTextRef.current, t);
answerTextRef.current = v;
setText(v);
} else {
const v = joinDictation(thinkingTextRef.current, t);
thinkingTextRef.current = v;
setThinking(v);
}
}, },
[qd.mode], [qd.mode],
), ),
@@ -298,13 +314,27 @@ function QuestionDockForm({
: sel.dontKnow || (sel.otherSelected && other.trim().length > 0) || sel.selected.size > 0; : sel.dontKnow || (sel.otherSelected && other.trim().length > 0) || sel.selected.size > 0;
const submit = () => { const submit = () => {
if (!canSubmit) return;
/* FINISH DICTATION FIRST (owner, 2026-10-01): Enter may arrive while
* a run is live with reasoning still in Deepgram's hands. Stop the
* run, let the last finals land in the target field, and only then
* build the answer — the spoken reasoning is never lost to a
* forgotten stop. The field values below come from the REF mirrors
* because the flush lands via setState after an await. */
if (!isDictationActive()) {
doSubmit();
return;
}
void finishDictation().then(doSubmit);
};
const doSubmit = () => {
if (!canSubmit) return; if (!canSubmit) return;
const req: AnswerRequest = { const req: AnswerRequest = {
dontKnow: sel.dontKnow, dontKnow: sel.dontKnow,
values: [...sel.selected].map((i) => qd.options[i]!.value), values: [...sel.selected].map((i) => qd.options[i]!.value),
other: sel.otherSelected ? other : undefined, other: sel.otherSelected ? other : undefined,
text: qd.mode === "text" ? text : undefined, text: qd.mode === "text" ? answerTextRef.current : undefined,
note: qd.mode !== "text" ? thinking.trim() || undefined : undefined, note: qd.mode !== "text" ? thinkingTextRef.current.trim() || undefined : undefined,
// Always sent on quiz answers (owner: 50 must mean 50, never // Always sent on quiz answers (owner: 50 must mean 50, never
// "untouched"); buildAnswer drops it on "I don't know". // "untouched"); buildAnswer drops it on "I don't know".
confidence: kind === "quiz" ? CONFIDENCE_STOPS[confIdx] : undefined, confidence: kind === "quiz" ? CONFIDENCE_STOPS[confIdx] : undefined,
@@ -720,7 +750,10 @@ function QuestionDockForm({
{qd.mode === "text" ? ( {qd.mode === "text" ? (
<textarea <textarea
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => {
answerTextRef.current = e.target.value;
setText(e.target.value);
}}
onKeyDown={(e) => { onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again // Esc ends dictation (the dictate decisions: click-again
// or Esc) — stopPropagation claims it (keyboard wave // or Esc) — stopPropagation claims it (keyboard wave
@@ -746,7 +779,10 @@ function QuestionDockForm({
<textarea <textarea
ref={thinkingRef} ref={thinkingRef}
value={thinking} value={thinking}
onChange={(e) => setThinking(e.target.value)} onChange={(e) => {
thinkingTextRef.current = e.target.value;
setThinking(e.target.value);
}}
onKeyDown={(e) => { onKeyDown={(e) => {
// Esc ends dictation (the dictate decisions: click-again // Esc ends dictation (the dictate decisions: click-again
// or Esc) — stopPropagation claims it (keyboard wave // or Esc) — stopPropagation claims it (keyboard wave