Repository navigation
fix(web): keep dictation in the current thread - #137
Conversation
There was a problem hiding this comment.
The diagnosis is right and the fix is in the right file. The implementation is not the simple version this repo already picked.
useLayoutEffect plus a stable useCallback is a third freshness mechanism next to the render-time closures on start / cancel. Mobile's useVoiceInputController — the hook this file says it mirrors — and BranchToolbar already solved this compiler / memo() / useEffectEvent failure with ref.current = input during render. Use that one ref for the session, the keydown handler, start, and cancel. The layout-effect also leaves a render-to-commit window that still points at the previous thread. Drop the extra hooks.
Sent by Cursor Automation: Thermo nuke 4.6
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
NoahHendrickson
left a comment
There was a problem hiding this comment.
Review — max effort
The premise checks out, and the root cause is now pinned. Running this branch's guard against a copy of the pre-fix hook reproduces the bug exactly as described (all three threads' transcripts land in thread-a). The mechanism is in react-dom 19.2.6: commitBeforeMutationEffects applies updateQueue.events only for FunctionComponent fibers — case 11 (ForwardRef) and case 15 (SimpleMemoComponent) are explicit empty breaks. ChatComposer is memo(function ChatComposer …) → tag 15, and it carries no key, so it stays mounted across thread switches. The fix is sound.
Gates are green: vp fmt --check clean, vp lint --report-unused-disable-directives exits 0 on the changed files, all 451 fork guards pass, and the 11 focused dictation tests pass.
Inline comments below. Three things stand out.
1. One race is still open (inline at useForkDictationController.ts:200)
The ref sync is a layout effect, but controller.ownerChanged() is still passive. A transcript settling in that window takes the resolveTranscriptCommit stale path and paints "The draft changed while voice input was running" — plus steals focus — on the thread the user just opened. Not reproducible under act(), which drains passive effects synchronously, so this harness structurally cannot see it.
2. The same bug is live one file away — apps/web/src/custom/useComposerPendingUserInputCard.ts:75
Not in this diff, so no inline anchor. handleOptionSelection is a useEffectEvent whose only caller is memo(function ComposerPendingUserInputCard(…)) (overrides/components/chat/ComposerPendingUserInputPanel.tsx:69), keyed by activePrompt.requestId — so the card stays mounted while questionIndex advances.
On a prompt whose question 1 is single-select and question 2 is multi-select, clicking an option on question 2 (panel:156) or pressing its number key (hook:115) runs question 1's frozen closure: activeQuestion?.multiSelect reads false, so it takes the single-select branch, sets optimisticSingleSelect and arms the 200 ms auto-advance — shoving the user off a multi-select question after one pick — and writes through the mount-time onToggleOption. Note the hook already hand-rolls onAdvanceRef for onAdvance, so the latest-ref need was recognised there.
An audit of all 18 useEffectEvent files found these two are the only memo/forwardRef hosts; everything else is tag 0 and unaffected. Worth a follow-up rather than growing this PR.
3. The change silently drops the hook out of the React Compiler
Measured with the repo's own babel-plugin-react-compiler@1.0.0: custom compiles to const $ = _c(41); this branch emits no _c( at all, with CompileError: Cannot access refs during render. Details inline at line 206. Bounded today, but worth a deliberate decision rather than a silent one.
Ruled out, so nobody re-investigates: the .tsx extension is picked up correctly by vitest (vite.config.ts:82), .fork/lint-owned.mjs, and forkLintCleanliness.test.ts, with no baseline growth; the oxlint-disable is load-bearing, not an unused directive; the folded-scalar manifest edit parses through detect-drift.mjs; docs/user/composer.md:96 already says "Switching threads cancels an active recording", so no doc update is owed; StrictMode's double-invoked useState initializer leaks nothing (BrowserVoiceRecorder's constructor is field-initializers only, and acquireSession() is reached only from start()); and listing the test under verify: but not files: matches the existing manifest convention.
Two candidates were refuted and dropped: the no-dep useLayoutEffect is not a scheduling cost (~60 ns/commit vs useEffectEvent, which sets the same Update flag), and there is no retention regression — the old code pinned the first render's closure; the ref holds the latest.
One convention nit not worth an inline: this guard has no Fork guard — see .fork/customizations.yaml#<id> header. 55 of the 57 guard files carry one — but the other exception is forkLocalDictation.test.ts, the existing guard for this same customization, so the new file matches its sibling. Add the header to both or neither.
🤖 Generated with Claude Code
…verage The ref mirror landed in the layout phase but the owner-change cancel was still passive, so a transcript settling in that window took the stale path and painted an error banner (plus stole focus) on the thread just opened. Make the cancel a layout effect declared after the mirror, so both land in one uninterruptible commit. Also from review: route start/cancel through the mirror so the click and keyboard paths share one freshness model, drop the no-op useCallback and its dep, declare the React Compiler bailout with "use no memo" instead of leaving it silent, and rewrite the comment to name the real mechanism (react-dom applies useEffectEvent impls only for FunctionComponent fibers). Guard: reset the module graph per test so a leaked active-session token cannot cascade, guard the unmount so a throw does not leak stubs, pin that the composer is reconciled rather than remounted, assert phase and error on the late-transcript drop, and add cases for the Ctrl+Shift+Space path and for callbacks retained across a thread switch. All four fail on the pre-fix hook. Manifest intent now says what must stay true — a new recording targets the visible thread, an owner change cancels an in-flight one — rather than implying an in-flight session re-targets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-ups — pushed in 68fd135Addressed1. Layout-vs-passive race. Comment records the wrong mechanism. Rewritten to name it: react-dom 19.2.6 applies
The indirection was incomplete. 3. React Compiler bailout. Made deliberate with Guard: no reset. Guard: unmount can leak stubs. Guard: reconciliation invariant unasserted. Test 1 collects Guard: Guard: keyboard path untested. New test 3. Each thread gets its own composer element; after switching to B, a chord targeted at A's editor is ignored and one targeted at B's starts and stops a recording that lands in B's draft. Fails pre-fix with Guard header. Added to both this file and Manifest intent. Now "A new recording targets the thread the composer is currently showing, and an owner change cancels an in-flight one rather than re-targeting it." The React implementation fact moved into the code comment, and the orphaned Declined, with reasons@cursor — switch to a render-phase Worth noting the Third copy of the fake-DOM harness. Left in place. The other two copies are 2. Gates
Unrelated: Release Smoke is red
🤖 Generated with Claude Code |


What Changed
Keep dictation attached to the current thread by reading composer callbacks from a ref refreshed after each committed render. Add regression tests using a memoized composer and register them in the fork manifest.
Why
After opening thread A and switching to thread B, confirming dictation could insert the transcript into A while B stayed empty and no error appeared. The effect-event callback retained A’s draft handlers inside the memoized composer. Both new tests fail before this fix and pass afterward; they cover repeated thread switches and a late transcript arriving after navigation.
This changes the desktop dictation hook used by both the microphone button and keyboard shortcut. Audio processing, mobile, browser-only clients, and server contracts are unchanged.
Validation
cd apps/web && vp test run src/__fork_guards__).git diff --checkpass.Checklist
Model: GPT-6-Astra. Harness: Codex.