close
Skip to content

fix(voice): keep the draining transcript after a manual dictation stop - #2469

Merged
CrysisDeu merged 1 commit into
mainfrom
fix/stt-drain-final
Aug 10, 2026
Merged

fix(voice): keep the draining transcript after a manual dictation stop#2469
CrysisDeu merged 1 commit into
mainfrom
fix/stt-drain-final

Conversation

@CrysisDeu

@CrysisDeu CrysisDeu commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Problem

Stop a streaming dictation by hand — the mic button, or releasing the push-to-talk key — and whatever Transcribe stabilises after that moment never reaches the composer. The user is left holding the last unstable hypothesis instead of the real transcript: a missing tail, an uncorrected word.

On a push-to-talk hold this is the common case rather than a corner. A hold is short, so the tail of the utterance is exactly the part still unstable at release. Say "remind me to call Ana", let go, and the composer can read remind me to.

Why it matters

Dictation is only worth using if what lands is what you said. Silently keeping a half-finished hypothesis is worse than a visible failure, because nothing tells the user to look — they send the truncated text.

Fix (symptom → root cause → change)

Symptom: the composer keeps a truncated hypothesis after a manual stop.

Root cause: one boolean, sttDisarmedRef, was doing two different jobs, and a manual stop only wants one of them. The two delivery routes need opposite treatment:

route semantics what a manual stop wants
applyVoiceText (close-time) appends base + ' ' + text suppressed — the composer already holds the hypothesis, so appending duplicates the utterance ("hello hello")
onPartial (drain-time) replaces the region at the frozen boundary kept armed — stop() deliberately leaves the socket draining, and the hook re-emits finals.join(' ') through this route as each segment stabilises

Because both were gated by the same flag, suppressing the duplicate append also threw away every correction the drain produced.

Change, in two parts.

1. Split the flag.

  • sttDisarmedRef keeps its meaning — suppress every route. Still what cancel (Esc), send(), and slot-switch set: the user discarded, already sent, or left.
  • sttAppendDisarmedRef is new and narrower — suppress the append only. This is what a manual stop sets, and only once frozenInputRef is non-null, i.e. once the composer actually holds a copy of the speech.

The pre-existing conditional is preserved exactly: with frozenInputRef still null no partial has landed, the close-time final is the only copy of the utterance, and it must land. That is the cold-stream case where the release beats the server's first partial.

onEndpoint gates on all the suppression flags, including a third one, sttEndpointDisarmedRef, which every streaming manual stop sets unconditionally. A manual stop means "stop capturing", not "send". The append flag cannot carry that property, because the cold-stream case deliberately leaves it unset — so without a separate flag a short press against a cold stream left the endpointer armed, and a trailing final's verdict submitted whatever draft was already in the composer.

2. Preserve what the user types after the release.

Keeping onPartial armed is not enough on its own: it rebuilds from the frozen snapshot, so a correction arriving while the user is already typing would delete that typing. After a release the user has every reason to believe dictation is over, so their text has to survive.

lastDictationAnchorRef records the composer content up to and including the region onPartial last inserted. A drain-time update verifies cur.startsWith(anchor) and rebuilds as newAnchor + cur.slice(anchor.length) — the corrected region, then everything that now follows it, verbatim.

The prefix (rather than the whole value) is the anchor because dictation splices at the caret, so it can sit mid-draft with an existing tail after it, and typing after the release lands at the restored caret — between the dictated words and that tail. Anchoring on the whole value fails its own startsWith check in exactly that case and drops the correction; anchoring on the prefix keeps the typed text and the original tail both intact.

This branch is gated on the endpoint flag, not the append flag: a cold-stream stop deliberately leaves the append armed, so keying off it would skip the preservation in exactly the case that still needs it. A null anchor (no partial has landed — the cold-stream stop) falls through to a plain write rather than returning, since that write IS the first one and returning would drop the utterance.

A cold-stream stop also freezes the caret (not the text) at the release, plus the value fingerprint. Without that, a drain partial arriving after the user has started typing would snapshot the live caret and insert the transcript after their new text instead of where they were speaking. The text is deliberately left un-pinned: with no partial landed, applyVoiceText must splice into the LIVE composer, and a pinned snapshot there would delete post-release typing — a worse failure than a wrong insertion point.

A frozen caret is only valid against the text it was taken from, so before either splice it is rebased onto the current composer. Using the release-time fingerprint, the user's edit is bounded between the longest common prefix and suffix: an edit after the offset leaves it alone, an edit before it shifts it by the length delta, and an edit straddling it falls back to the user's live caret. This covers both ways a frozen position goes stale — a range whose selection they have since typed over (which would otherwise delete that replacement) and an offset whose meaning moved because they edited earlier text (which would otherwise cut mid-word). An untouched composer keeps the range intact, because replacing a selection is the intended behaviour.

Entering this branch also sets the append flag. stopVoice could not decide that: with frozenInputRef null the close-time final really was the only copy of the utterance, so the append had to stay armed. That premise expires the moment a drain partial lands — and on the streaming path applyVoiceText overwrites (it re-splices from frozenInputRef) rather than appending, so letting the final through afterwards would delete post-release typing. Deciding it here keeps the original invariant intact: the final is allowed through exactly while it is the only copy.

When the anchor cannot be verified the user edited inside the dictated region, and the update leaves the composer untouched rather than guessing; a heuristic there deletes user-authored text. This is the same verify-then-slice shape, and the same leave-it-alone policy, that cancelVoice already uses to roll a region back. The caret is handled the same way. While the composer is still exactly what we wrote it goes to the end of the dictated region; once the user has typed it is re-armed at their own LOGICAL position, shifted by how much the region ahead of it grew. Simply not arming it is not the same as leaving it alone — React replaces the textarea value and the browser resets the DOM caret to the end, which mid-draft is past the tail. That edited state is sticky for the whole drain (postStopEditedRef): a drain emits several corrections, and the first one rewrites the composer to include the typed suffix — so a per-update "was it edited?" test would say no on the second correction and pull the caret back in front of the user's text.

This applies to the post-stop drain only. During recording the region is still being actively rewritten and that behaviour is unchanged.

Tests

website/src/test/ChatPage.sendDuringDictation.test.tsx, 10 existing → 22. Each new one is red-before-green against the specific hunk it covers:

  • lets a drain-time correction replace the hypothesis after a manual stop — dictate remind me to, stop, deliver the stabilised remind me to call Ana through the drain route, assert the composer takes it; then fire the close-time route with the same text and assert no duplication. Reverting the split fails with expected 'remind me to' to be 'remind me to call Ana'.
  • keeps text typed after the release when a drain correction lands — type — urgent after releasing, then let the correction arrive; the composer must read remind me to call Ana — urgent. Reverting the suffix preservation fails with expected 'remind me to call Ana' to be 'remind me to call Ana — urgent' — the typing is gone, which is the defect this half fixes.
  • keeps a mid-draft correction and everything after it — dictate into the middle of hello world, stop, type NOW at the restored caret, then let the correction arrive; the composer must read hello remind me to call Ana NOW world — correction applied, typed text and original tail both intact. Anchoring on the whole value instead of the prefix fails with expected 'hello remind me NOW world' to be 'hello remind me to call Ana NOW world', i.e. the correction is silently dropped.
  • leaves the composer alone when the dictated region was edited — replace the dictation with never mind, then let a correction arrive; the composer must stay never mind.
  • does not reclaim the caret on a later correction in the same drain — type a suffix, then deliver two successive corrections; the caret must not end up at the end of the dictated region (offset 21), which sits in front of the typed text. Making the edited state per-update instead of sticky fails with expected 21 not to be 21.
  • does not auto-send on a drain-time endpoint verdict after a manual stop — an endpoint verdict after a manual stop must not call api.sendChat.
  • does not auto-send on an endpoint verdict after a cold-stream stop — hold, type a draft, release before any partial arrives, then let an endpoint verdict fire; the draft must neither be sent nor altered. Dropping sttEndpointDisarmedRef fails with expected "vi.fn()" to not be called at all, but actually been called 1 times — the user's draft sent for them.

The three tests pinning behaviour this change must not alter pass unmodified: the cold-stream case where the draining final is the only copy, the close-time drop once partials populated the composer, and hand-typed text surviving the drain.

Manual verification

N/A — no microphone in this environment, and the change is entirely in which of two already-mocked delivery routes is gated and how the composer value is reassembled. The tests drive both routes directly, which is the actual contract.

Screenshots

N/A — no user-visible surface changed; the only difference is which text ends up in the composer, covered by the tests above. The path-based Screenshot Evidence gate flags any website/src change, so this PR carries the no-screenshots label the gate itself prescribes for a change with no visual delta.

@CrysisDeu
CrysisDeu requested a review from a team August 10, 2026 03:03
@CrysisDeu
CrysisDeu requested a review from a team as a code owner August 10, 2026 03:03
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 10, 2026
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Advisory design-level review of 61070c7b66421f02e77cf2850e370763a886ad84 — updated in place on each push; does not block merge.

Design-Verdict: CONCERNS

Sound fix aimed at the real cause, but the dictation state machine has now outgrown ChatPage — an implicit protocol spread over eight refs and five reset sites.

Watch

  • The change is the right shape locally (splitting the conflated flag per delivery route matches the two routes' opposite semantics), but it brings the composer's voice state to ~8 coordinated refs (sttDisarmedRef, sttAppendDisarmedRef, sttEndpointDisarmedRef, frozenInputRef, frozenCaretRef, lastDictationAnchorRef, lastDictationValueRef, postStopEditedRef) reset in lock-step at five separate sites in a 6,800-line component. Mechanism → consequence: the next voice change (send-during-drain, slot switch, a new stop path) must correctly touch every flag at every site; one missed reset silently corrupts or drops composer text — the exact failure class this PR is fixing. Extract this into a dedicated hook/reducer (single reset, named states like recording | draining | idle) as a near-term follow-up before another flag lands.
  • rebaseFrozenCaret's LCP/LCS diffing is a small operational-transform embedded in a page callback, justified only by comments; if the drain protocol in useVoiceInput ever changes (e.g. stops re-emitting finals.join(' ') through onPartial), nothing but prose ties the two sides together. The added tests mitigate this — keep them as the contract.

Suggestions

  • Fold the append-suppression decision into the hook boundary eventually: useVoiceInput knows whether finals were already delivered via onPartial, so it could stop double-delivering through onText, deleting sttAppendDisarmedRef from the page entirely.

[DESIGN-REVIEWED] 61070c7

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 61070c7b66421f02e77cf2850e370763a886ad84 — this comment is updated in place on each push.

Review details

I've thoroughly traced the state machine across all call sites (applyVoiceText, onPartial, onEndpoint, stopVoice, startVoice, cancelVoice, slot-switch effect, and the send() path), the three new flags, the anchor/value refs, and rebaseFrozenCaret.

Key falsification checks that held up:

  • Manual-stop path never reaches the reset block in applyVoiceText (returns early on sttAppendDisarmedRef), leaving frozenInputRef/lastDictation*/frozenCaretRef stale — but every consuming route (onPartial/onEndpoint/append) is disarmed until the next startVoice, which resets all eight refs. No stale-state leak.
  • Hot-stream stop keeps lastDictationAnchorRef/lastDictationValueRef fresh from the last recording partial, so the first drain partial's startsWith(prev) and rebase operate on correct values.
  • Cold-stream stop seeds frozenCaretRef/lastDictationValueRef at release; the null-anchor fall-through correctly performs the first write, and sttEndpointDisarmedRef (set unconditionally for streaming stops) blocks the auto-submit that the append flag deliberately can't.
  • Normal (non-manual) endpointing still fires sendRef — no flag set during live recording. Batch path untouched.
  • postStopEditedRef stickiness and the caret-shift math match the test assertions I re-derived by hand.

The change is confined to one file, has no security/data-integrity surface, and every branch is covered by a red-before-green test whose expectations I verified against the code.

No findings.

[OPUS-REVIEWED] 61070c7

Verdict parsed from the review's SHA-scoped output markers for commit 61070c7b66421f02e77cf2850e370763a886ad84.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 61070c7b66421f02e77cf2850e370763a886ad84: <one-sentence reason>

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 61070c7b66421f02e77cf2850e370763a886ad84 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 61070c7

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 61070c7b66421f02e77cf2850e370763a886ad84: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

Advisory UX-level review of 61070c7b66421f02e77cf2850e370763a886ad84 — updated in place on each push; does not block merge.

All the evidence is in: this PR changes no strings, no visuals, no controls — it's purely which dictation text lands in the composer after a manual stop, fully covered by behavioral tests. My assessment:

UX-Verdict: PASS

Post-release corrections now land where the user was speaking, preserve their typing and caret, and never auto-send — the composer converges on what was actually said.

Suggestions

  • Residual race: nothing marks the post-stop drain (the mic in ChatInput.tsx goes idle instantly, unlike batch's "Transcribing…" state), so a fast Enter still sends the truncated hypothesis and send()'s sttDisarmedRef then suppresses the correction — a brief transcribing-style indicator on the mic during the streaming drain would close the same failure this PR fixes via its last remaining path. Follow-up-sized (needs hook state), but worth a ticket.

[UX-REVIEWED] 61070c7

@CrysisDeu CrysisDeu added the no-screenshots PR has no visual delta; screenshot gate exempt label Aug 10, 2026
@CrysisDeu
CrysisDeu force-pushed the fix/stt-drain-final branch from 13d402f to 1fdd231 Compare August 10, 2026 03:36
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Prior reviewed SHA: 13d402f13 -> now 1fdd231b8.

ChatPage.tsx:1866 — drain-time partials delete post-stop typing — FIXED, but not by the prescribed change.

The finding is correct and it is mine: keeping onPartial armed through the drain left it rebuilding from frozenInputRef, so a correction arriving while the user was already typing deleted that typing. Releasing a push-to-talk key and continuing to type is the obvious thing to do, which makes this reachable on the first hold.

I did not apply the prescribed fix — Restore sttDisarmedRef.current = true — because that is a revert. It puts back the defect this PR exists to remove: the composer keeps the last unstable hypothesis, losing whatever Transcribe stabilised after the release. Trading one silent data loss for the other is not a fix, and the two are not actually exclusive.

What the code now does. lastDictationRegionRef records the exact region onPartial last wrote. The composer is always region + suffix, so a drain-time update verifies cur.startsWith(region) and carries cur.slice(region.length) across instead of rebuilding blind. That is the same verify-then-slice shape cancelVoice already uses in this file to roll a region back, so it is the established pattern here rather than a new mechanism. When the region cannot be verified the user rewrote it, and the update leaves the composer untouched — a suffix-match heuristic there deletes user-authored text, which is the reasoning cancelVoice's own comment records. The caret is only steered when there is no typed suffix, so it is never pulled out from under someone mid-sentence. Scope is the post-stop drain only; during recording the region is still being actively rewritten and that behaviour is unchanged.

Two new tests, both red-before-green against the specific hunk:

  • keeps text typed after the release when a drain correction lands — type — urgent after releasing, then let the correction land; expects remind me to call Ana — urgent. Reverting the suffix preservation fails with expected 'remind me to call Ana' to be 'remind me to call Ana — urgent', i.e. it reproduces exactly the deletion this finding describes.
  • leaves the composer alone when the dictated region was edited — replace the dictation with never mind, then let a correction land; expects never mind unchanged. Reverting fails with expected 'remind me to call Ana' to be 'never mind'.

The PR body previously listed this clobber as an accepted tradeoff. That paragraph is now wrong, so I rewrote the description rather than leave a fixed problem documented as a known one.

Screenshot Evidence — resolved with the label the gate prescribes. The gate keys off any website/src path, and this PR changes only which of two delivery routes is gated and how the composer string is reassembled — there is no visual delta to photograph. The workflow's own guidance for that case is the no-screenshots label ("If the change genuinely has no visual delta … add the no-screenshots label and re-run"), and the label's description in the repo is "PR has no visual delta; screenshot gate exempt". Added.

Verified on 1fdd231b8: frontend 876 files / 11791 tests, 0 failures (14 in this file, up from 10; the 10 pre-existing pass unmodified); tsc -b clean; eslint 0 errors; i18n:check 13/13 (no locale files touched). One commit, level with main.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@CrysisDeu
CrysisDeu force-pushed the fix/stt-drain-final branch from 1fdd231 to 92a42ca Compare August 10, 2026 04:12
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Prior reviewed SHA: 1fdd231b8 -> now 92a42ca52 (rebased onto current main; the four intervening commits are backend-only, no website/ files).

ChatPage.tsx:1794 — mid-draft drain corrections are discarded — FIXED, exactly as prescribed.

The finding is correct and the prescribed fix is the right one. spliceDictation returns value = before + insert + trail + after with caret = before.length + insert.length, so dictation can sit mid-draft with an existing tail after it, and typing after the release lands at the restored caret — between the dictated words and that tail. My previous anchor was the whole spliced.value with the suffix sliced off the end, which only holds when dictation is the last thing in the composer. Mid-draft, cur.startsWith(anchor) fails, my guard returns, and the stabilised correction is dropped entirely — the exact reachable path described.

Anchoring only through spliced.caret fixes it: lastDictationAnchorRef now records the composer content up to and including the inserted region, and a drain-time update rebuilds as newAnchor + cur.slice(anchor.length) — the corrected region, then everything that now follows it verbatim, whether that is typed text, the original tail, or both. lastDictationValueRef (the last full value we wrote) is what distinguishes "the user typed" from "nothing changed", so the caret is steered only while the composer is still exactly ours. The unverifiable-anchor case still leaves the composer untouched, same policy as cancelVoice.

New test, red-before-green against this hunk alone:

  • keeps a mid-draft correction and everything after it — dictate into the middle of hello world, stop, type NOW at the restored caret, then deliver remind me to call Ana. Expects hello remind me to call Ana NOW world. Reverting the anchor to the whole value (the pre-fix form) fails with expected 'hello remind me NOW world' to be 'hello remind me to call Ana NOW world' — the correction silently dropped, which is precisely this finding.

The PR body's description of the anchor was written for the old whole-value form and is now updated to describe the prefix anchor and why mid-draft dictation forces it.

Verified on 92a42ca52: frontend 876 files / 11792 tests, 0 failures (this file 15, up from 10; all 10 pre-existing pass unmodified); tsc -b clean; eslint 0 errors; i18n:check OK (19 manifests, 168 strings; no locale files touched). One commit, level with main.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@CrysisDeu
CrysisDeu force-pushed the fix/stt-drain-final branch from 92a42ca to 6d8643c Compare August 10, 2026 04:56
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Prior reviewed SHA: 92a42ca52 -> now 6d8643c9b (rebased onto current main, which includes #2471's voice changes to the same file; no conflicts, and the full frontend suite was re-run after the rebase because main moved 16 website/ files this time).

ChatPage.tsx:1795 + :1804 — user-edited state resets during a multi-update drain / drain updates can seize the caret — FIXED. Both findings describe the same defect from two angles, and both are correct. lastDictationValueRef was updated to the value including the typed suffix, so on the second drain correction the composer matched what we wrote again, userEdited fell back to false, and the caret was armed to spliced.caret — the end of the dictated region, which sits in front of the user's typed text. The next keystroke then lands in the wrong place.

Fixed with the first of the two prescribed shapes: postStopEditedRef makes the edited state sticky for the whole drain, reset only where dictation starts or is cleared. I did not take the second shape ("never arm voicePendingCaretRef while sttAppendDisarmedRef is set") because for an untouched post-stop composer that loses caret restoration entirely — mid-draft the caret would land past the tail instead of after the dictation, which is worse than the pre-existing behaviour. Sticky keeps the correct caret when nothing was typed and hands the caret to the user permanently once it was.

New test, red-before-green: does not reclaim the caret on a later correction in the same drain — type a suffix, deliver two successive corrections, assert the caret is not at offset 21 (end of remind me to call Ana). Reverting to the per-update test fails with expected 21 not to be 21.

ChatPage.tsx:1819 — cold-stream stop leaves the endpointer armed — FIXED (advisory, but taken because the PR body itself claims a manual stop never becomes an unrequested send, and this was a hole in exactly that claim).

Verified first that this is pre-existing, not introduced here: on origin/main stopVoice also disarms only under streamEnabledRef.current && frozenInputRef.current !== null, so a cold-stream stop left the endpointer armed there too. The condition itself is load-bearing and must stay — with no partial landed the close-time final is the only copy of the utterance. So the fix is a separate sttEndpointDisarmedRef, set unconditionally by every streaming manual stop and checked by onEndpoint alongside the other two, which closes the auto-submit route while leaving the append route open.

Worth recording how the test landed, because the first version of it passed vacuously: with an empty composer send() bails on empty text, so the hole is unreachable that way. It is reachable with an existing draft — press, type, release before the first partial, and the endpoint verdict sends the draft. The test also had to interleave the typing between the two mic clicks, since the harness's voice.recording mutation does not notify React and the button otherwise re-reads a stale state (the same quirk the other tests in this file document). With that shape, dropping the flag fails with expected "vi.fn()" to not be called at all, but actually been called 1 times.

Verified on 6d8643c9b: frontend 876 files / 11794 tests, 0 failures (this file 17, up from 10; all 10 pre-existing pass unmodified) — re-run after the rebase, not carried over; tsc -b clean; eslint 0 errors; i18n:check OK. One commit, level with main.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@CrysisDeu
CrysisDeu force-pushed the fix/stt-drain-final branch from 6d8643c to c0042b8 Compare August 10, 2026 05:14
@github-actions github-actions Bot removed the readiness: action required A blocking check or review needs attention label Aug 10, 2026
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@CrysisDeu
CrysisDeu force-pushed the fix/stt-drain-final branch from ba92566 to 47a3902 Compare August 10, 2026 09:29
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Prior reviewed SHA: ba925664 -> now 47a39028 (level with main).

ChatPage.tsx:1831 — cold-stream typing is reordered before the dictated text — FIXED, but with a narrower seed than prescribed. The finding is correct. On a cold stream nothing has pinned the insertion point at release, so if the user types before the drain's first partial arrives, that partial snapshots both the edited text and the live caret — and the transcript lands after the text they wrote afterwards instead of where they were speaking.

I did not seed frozenInputRef as prescribed, because pinning the text at stop time reintroduces data loss on the sibling path: when no partial ever lands (socket closes with only a final), applyVoiceText's streaming branch splices from frozenInputRef.current ?? inputRef.current — a seeded snapshot would make it rebuild from the release-time text and delete everything typed after the release. That trades a wrong insertion point for lost text, which is the worse of the two.

What is seeded at a cold-stream stop is the caret and the value fingerprint:

frozenCaretRef.current = voiceCaretRef.current
lastDictationValueRef.current = inputRef.current

The text still comes from the live composer at the first partial, so post-release typing is inside the snapshot and nothing is lost; the caret is the one frozen at the release, so the transcript inserts where the user was speaking. Two supporting changes make that hold:

  • The first partial's snapshot block no longer clobbers a frozen caret (frozenCaretRef.current ?? voiceCaretRef.current) — otherwise it would overwrite the release-time point with the live one.
  • The "did the user type?" test moved outside the anchor guard. On a cold stream the first drain partial has no anchor yet, but the user may already have typed, and postStopEditedRef has to latch so their caret is left alone.

frozenCaretRef is already cleared at every site that clears frozenInputRef (start / cancel / slot-switch / send), so the seed cannot leak into a later dictation.

New test, red-before-green: inserts a cold-stream transcript where the user was speaking — park the caret at offset 5 in call later, release before any partial, type today (which moves the live caret to the end), then deliver Ana. Expects call Ana later today. Removing the seed fails with expected 'call later today Ana' to be 'call Ana later today' — the reordering this finding describes.

(Its first draft also failed with the fix in place: I re-used the same string for the interleaving change, so React never fired onChange, the caret was never recorded and the button never re-read recording — the second click re-ran start. The interleave has to be a genuinely different value.)

Frontend Tests / Coverage Gate on the prior SHA — same transient as before. src/i18n/unitLiterals.test.ts again failed with Error: Test timed out in 15000ms (no assertion failure); the file is byte-identical to main and runs in 2.9s locally, and it passed on the SHA in between with no change to it. That is 2 of 3 runs on this PR, so the 15s ceiling looks genuinely marginal on loaded runners — worth raising on main, but not something this PR should edit. I re-ran the failed jobs. Coverage Gate is derived (frontend-test=failure -- failing closed).

Verified on 47a39028: frontend 876 files / 11804 tests, 0 failures (this file 19; all 10 pre-existing pass unmodified); tsc -b clean; eslint 0 errors; i18n:check OK. One commit.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@CrysisDeu
CrysisDeu force-pushed the fix/stt-drain-final branch from 47a3902 to 4591179 Compare August 10, 2026 10:02
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Prior reviewed SHA: 47a39028 -> now 4591179f (level with main).

ChatPage.tsx:1790 — stale selection deletes / corrupts post-stop typing (both findings are the same defect) — FIXED, as prescribed. Correct, and it is a consequence of the caret seed I added last round. voiceCaretRef holds a {start, end} range, not a point, because dictating over a selection is meant to replace it. Freezing that range at a cold-stream release makes it stale the moment the user types over the same selection: spliceDictation then takes base.slice(0, start) + insert + base.slice(end) against the new text and deletes exactly the replacement they just wrote.

The collapse is gated on the signal already seeded at stop time — lastDictationValueRef holds the release-time composer, so "the composer differs" means the user has edited since. In that case the range collapses to its start, turning the splice into an insertion at the point they were speaking:

collapseStaleFrozenSelection()   // called before BOTH splices

An untouched selection is left alone: it is still a legitimate replacement target, which is the behaviour dictating-over-a-selection is supposed to have.

Applied at both splice sites, per "before any cold-stream splice": onPartial's drain path and applyVoiceText's streaming branch. The latter matters because a cold stream can close with only a final and no partial at all, and it reads the same frozen range.

New test, red-before-green: does not delete a typed replacement for a selection frozen at release — select Bob (offsets 5..8) in call Bob later, release, type over it so the composer reads call Ana later, then deliver remind. Expects call remind Ana later. Making the helper a no-op fails with expected 'call remind later' to contain 'Ana' — the replacement deleted, which is precisely this finding.

react-hooks/exhaustive-deps was satisfied rather than suppressed: the helper is added to the dep arrays of both applyVoiceText and onPartial.

Prior round's Frontend Tests / Coverage Gate: green again on this SHA with no change to src/i18n/unitLiterals.test.ts, consistent with the 15s-timeout diagnosis (3 of 4 runs now).

Verified on 4591179f: frontend 876 files / 11805 tests, 0 failures (this file 20; all 10 pre-existing pass unmodified); tsc -b clean; eslint 0 errors and no new warnings; i18n:check OK. One commit.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@CrysisDeu
CrysisDeu force-pushed the fix/stt-drain-final branch from 4591179 to b3cd0e6 Compare August 10, 2026 10:27
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Prior reviewed SHA: 4591179f -> now b3cd0e66 (level with main).

ChatPage.tsx:1868 — drain correction loses the user's logical caret position — FIXED, as prescribed. Correct, and it invalidates the reasoning behind my own earlier choice. I had been treating "don't arm voicePendingCaretRef" as leaving the caret alone. It is not: React replaces the textarea value on every correction and the browser resets the DOM caret to the end of the new value. Mid-draft that end is past the tail — nowhere near where the user was typing — so every drain correction moved their caret.

The caret is now re-armed at the same logical position, shifted by how much the region ahead of it changed:

caretTarget = live.start + (anchor.length - prev.length)

live.start >= prev.length guards it: their caret must sit at or after the old anchor's end for a pure shift to be valid. (An edit inside the region would already have failed startsWith and returned, so this is belt-and-braces rather than a second policy.)

Two sub-cases stay deliberate: with no typing the caret still goes to the end of the dictated region, which is the ordinary "we own the composer" behaviour; and on a cold-stream first write with typing already done there is no old anchor to measure a shift against, so it is left unarmed — there the value commit leaves the caret at the end, which is past their text and a sane place to be.

New test, red-before-green: carries the user caret across a drain correction — dictate mid-draft into call later, stop, type NOW (caret at 15), then let the region grow from call remind (11) to call remind me to call Ana (26). Expects the caret at 30, right after NOW. Reverting to the unarmed form fails with expected 36 to be 30 — reset to the end of the value, past the later tail.

Worth recording: the first version of this test was vacuous. I asserted on an end-of-draft scenario where the browser's own reset lands on exactly the offset the shift computes, so both behaviours agreed. Only a mid-draft case (with a tail after the typed text) distinguishes them — the assertion had to be moved there before it could fail.

Verified on b3cd0e66: frontend 876 files / 11806 tests, 0 failures (this file 21; all 10 pre-existing pass unmodified, including the sibling caret test does not reclaim the caret on a later correction in the same drain); tsc -b clean; eslint 0 errors; i18n:check OK. One commit.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 10, 2026
Stopping a streaming dictation by hand -- mic button or push-to-talk
release -- dropped everything Transcribe stabilised afterwards, leaving
the composer holding the last UNSTABLE hypothesis. On a short
push-to-talk hold that is the common case: the tail of the utterance is
exactly the part still unstable at release.

One flag was gating two routes that need opposite treatment.
applyVoiceText APPENDS, so the close-time final landing on a composer
that already holds the hypothesis duplicates the utterance. onPartial
REPLACES the region at the frozen boundary, and the hook re-emits
finals.join(' ') through it as the drain stabilises each segment -- that
is the authoritative text.

Split the flag: sttDisarmedRef keeps suppressing every route (cancel,
send, slot-switch), and the new sttAppendDisarmedRef suppresses only the
append, which is what a manual stop now sets. onEndpoint honours both, so
an endpoint verdict during the drain still cannot turn a stop into an
unrequested send.
@CrysisDeu
CrysisDeu force-pushed the fix/stt-drain-final branch from b3cd0e6 to 61070c7 Compare August 10, 2026 10:58
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 10, 2026
@CrysisDeu

Copy link
Copy Markdown
Collaborator Author

Prior reviewed SHA: b3cd0e66 -> now 61070c7b (level with main).

ChatPage.tsx:2020 — frozen caret is stale after edits before its offset (both findings are the same defect) — FIXED, but by rebasing rather than by switching to the live caret. Correct: a frozen offset only means what it meant at the release if the text before it is unchanged. Prepend after releasing and offset 5 points somewhere else entirely, so the splice cuts into the word the user just wrote (Hi, c remind all later).

I did not adopt "use the live caret when the composer differs", because that is the exact inverse of the previous round's fix and just swaps which case breaks: the live caret is right when the user typed before the frozen point and wrong when they typed after it (the transcript then lands behind text they wrote later — the reordering that seed was added to prevent). Neither position is correct unconditionally.

The frozen caret is now rebased onto the current text. lastDictationValueRef already holds the release-time composer, so the edit can be bounded between the longest common prefix and suffix, and the offset moved accordingly:

  • edit entirely after the offset (start <= lcp) -> unchanged
  • edit entirely before it (start >= released.length - lcs) -> shifted by the length delta
  • edit straddles it -> fall back to the user's live caret, since the frozen point no longer exists in the new text

This also subsumes last round's collapseStaleFrozenSelection, which was the same concern (a frozen position gone stale) in its range form: any edited composer now yields a collapsed, rebased point, while an untouched composer still keeps the range intact so that dictating over a selection replaces it, as intended. The two helpers are merged into one rebaseFrozenCaret, called at both splice sites.

New test, red-before-green: rebases a frozen caret when the user edits before it — caret before later in call later, cold release, prepend Hi, , then deliver remind. Expects Hi, call remind later. Trusting the stale offset fails with expected 'Hi, c remind all later' to be 'Hi, call remind later'. Last round's selection test and the caret-carry test both still pass unmodified, which is what confirms the merge did not trade one case for another.

Frontend Tests / Coverage Gate on the prior SHA — the recurring 15s timeout again on src/i18n/unitLiterals.test.ts (Error: Test timed out in 15000ms, no assertion failure). That is 3 of 5 runs on this PR. The file is byte-identical to main and runs in ~3s locally; Coverage Gate fails closed off the frontend result. I re-ran the failed jobs. This is worth a timeout bump on main — it will keep flapping other PRs — but it is not something this PR should carry.

Verified on 61070c7b: frontend 876 files / 11807 tests, 0 failures (this file 22; all 10 pre-existing pass unmodified); tsc -b clean; eslint 0 errors; i18n:check OK. One commit.

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Aug 10, 2026
@CrysisDeu
CrysisDeu merged commit 8e6b13e into main Aug 10, 2026
59 of 60 checks passed
@CrysisDeu
CrysisDeu deleted the fix/stt-drain-final branch August 10, 2026 17:44
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-screenshots PR has no visual delta; screenshot gate exempt

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant