Repository navigation
Conversation
There was a problem hiding this comment.
Findings
- [Major] Direct send races transcript finalization - the added handler can submit stale or empty composer text before dictation finishes; see the inline comment at
web/src/components/AssistantChat/ComposerButtons.tsx:514.
Summary
Review mode: initial
One Major issue found. The new direct voice-send path does not wait for the final standard/realtime dictation transcript, so the spoken result can remain unsent.
Testing
- Not run (automation; PR code was not executed).
- GitHub
testandpr-reviewchecks were pending at review time. - Added component coverage uses synchronous callback spies; async transcript-finalization coverage is missing for standard and realtime dictation.
HAPI Bot
|
|
||
| const handleVoiceSendClick = () => { | ||
| props.onVoiceToggle() | ||
| props.onSend('default') |
There was a problem hiding this comment.
[MAJOR] Submit only after the final transcript reaches the composer
onVoiceToggle is fire-and-forget here, but the real dictation toggle is async. Realtime stopping waits for provider finalization (web/src/hooks/useRealtimeDictation.ts:219), while standard mode transcribes inside async MediaRecorder.onstop and its current stop() returns immediately after recorder.stop() (web/src/hooks/useDictation.ts:95, web/src/hooks/useDictation.ts:137). Calling onSend now therefore sends the pre-dictation/empty composer state; the final transcript is inserted later and remains unsent. The new test's synchronous spies cannot catch this.
Suggested fix:
onVoiceToggle: () => void | Promise<void>
const handleVoiceSendClick = async () => {
await props.onVoiceToggle()
props.onSend('default')
}Also make standard useDictation.stop() resolve only after onstop has completed transcription and called onTextChange, then cover both standard and realtime finalization ordering.
There was a problem hiding this comment.
Findings
-
[Major] Direct-send UI is also enabled for full voice-assistant sessions — assistant speech is relayed directly to the coding agent and is not inserted into the composer, so this button stops the assistant and submits unrelated draft text or nothing. Evidence:
web/src/components/AssistantChat/ComposerButtons.tsx:503,web/src/components/AssistantChat/HappyComposer.tsx:481,docs/guide/voice-assistant.md:5.
Suggested fix:if (isVoiceActive && props.dictationEnabled) { return <VoiceStopAndSendButtons /> } if (isVoiceActive) { return <VoiceStopButton /> }
-
[Major] Failed or repeated stop attempts still call send — standard dictation resolves its new completion promise from
finallyafter transcription errors, and a second tap returns immediately whiletranscribingRefis true. Both paths fulfill the await and submit stale/partial composer state. Evidence:web/src/components/AssistantChat/ComposerButtons.tsx:513,web/src/hooks/useDictation.ts:132,web/src/hooks/useDictation.ts:150.
Suggested fix:if (voiceSendPendingRef.current) return voiceSendPendingRef.current = true try { const completed = await props.onVoiceToggle() if (completed) props.onSend('default') } finally { voiceSendPendingRef.current = false }
-
[Major] Empty-composer dictation still cannot send to the scratchlist — after transcription updates the assistant store,
props.onSendremains the pre-transcriptionhandleSendclosure wherecanSendwas false; the scratchlist path returns before reading its live snapshot. Evidence:web/src/components/AssistantChat/ComposerButtons.tsx:514,web/src/components/AssistantChat/HappyComposer.tsx:1132.
Suggested fix:const snapshot = api.composer().getState() const liveCanPark = ( snapshot.text.trim().length > 0 || snapshot.attachments.length > 0 ) && areAttachmentsReady(snapshot.attachments) && !controlsDisabled if (!liveCanPark || parkInFlightRef.current) return
Summary
Review mode: follow-up after new commits
Three Major correctness issues remain in the direct voice-send path: unsupported assistant-mode behavior, stale sends after unsuccessful/reentrant stops, and failed scratchlist routing from an initially empty composer.
Testing
- Not run (automation; PR code was not executed).
- GitHub
testfailed on an unrelated timeout incli/src/claude/claudeRemote.test.ts:72before the web suite ran. - Add integration coverage for assistant mode, transcription rejection/double-tap, and empty-composer dictation routed to scratchlist.
HAPI Bot
| const isVoiceActive = isConnecting || isConnected | ||
| const hasText = props.canSend | ||
| const routesToScratchlist = props.routesToScratchlist ?? false | ||
|
|
There was a problem hiding this comment.
[MAJOR] Restrict the direct-send pair to dictation mode
isVoiceActive covers both dictation and the full voice assistant (HappyComposer.tsx:481-485). The assistant path relays spoken requests directly to the coding agent and never inserts a user transcript into the composer (docs/guide/voice-assistant.md:5,35-41). In that mode this new button therefore stops the assistant and calls composer send with unrelated draft text—or nothing.
Suggested fix:
if (isVoiceActive && props.dictationEnabled) {
return <VoiceStopAndSendButtons />
}
if (isVoiceActive) {
return <VoiceStopButton />
}Pass an explicit dictation/direct-send capability into UnifiedButton; keep the existing stop-only behavior for assistant sessions.
| : 'bg-black text-white' | ||
|
|
||
| const handleVoiceSendClick = async () => { | ||
| await props.onVoiceToggle() |
There was a problem hiding this comment.
[MAJOR] Send only after one successful stop
This await still fulfills on two unsuccessful paths. Standard dictation catches transcription failure and resolves stopResolverRef from finally (useDictation.ts:128-136), and a second tap while transcription is pending returns immediately at useDictation.ts:150. The handler then submits the pre-transcription draft; two quick taps can also create an early send followed by the eventual transcript send.
Suggested fix:
if (voiceSendPendingRef.current) return
voiceSendPendingRef.current = true
try {
const completed = await props.onVoiceToggle()
if (completed) props.onSend('default')
} finally {
voiceSendPendingRef.current = false
}Make dictation stop/toggle return false for empty audio, transcription failure, and an already-running stop, and disable/guard the send button until completion.
|
|
||
| const handleVoiceSendClick = async () => { | ||
| await props.onVoiceToggle() | ||
| props.onSend('default') |
There was a problem hiding this comment.
[MAJOR] Read live composer state before scratchlist routing
When recording starts from an empty composer, this callback captures the current HappyComposer.handleSend, whose canSend value is false. Awaiting transcription updates assistant-ui, but it does not replace this captured callback. In scratchlist mode, handleSend therefore returns at HappyComposer.tsx:1132 before its live snapshot at line 1136, leaving the transcript in the composer instead of parking it. The amber routesToScratchlist state explicitly exposes this path.
Suggested fix:
const snapshot = api.composer().getState()
const liveCanPark = (
snapshot.text.trim().length > 0 || snapshot.attachments.length > 0
) && areAttachmentsReady(snapshot.attachments) && !controlsDisabled
if (!liveCanPark || parkInFlightRef.current) returnDerive scratchlist eligibility from the live snapshot at invocation time, and add an integration test starting with canSend=false.
There was a problem hiding this comment.
Findings
-
[Major] Direct-send remains enabled in full voice-assistant sessions —
isVoiceActiveis fed by either dictation or assistant status, but assistant speech is relayed directly to the coding agent and does not populate this composer. The new Send button can therefore stop the assistant and submit unrelated draft text or nothing. Evidence:web/src/components/AssistantChat/ComposerButtons.tsx:504,web/src/components/AssistantChat/HappyComposer.tsx:481,docs/guide/voice-assistant.md:41.
Suggested fix:// Pass dictationEnabled through to UnifiedButton; always keep Stop. {props.dictationEnabled ? ( <button type="button" onClick={handleVoiceSendClick}> <SendIcon /> </button> ) : null}
-
[Major] A fulfilled toggle is treated as a successful transcription and can send twice — standard dictation also fulfills after transcription failure, while a second tap returns immediately when transcription is already pending. The second handler can send stale text early and the first can send again after transcription. Evidence:
web/src/components/AssistantChat/ComposerButtons.tsx:513,web/src/hooks/useDictation.ts:128,web/src/hooks/useDictation.ts:149.
Suggested fix:if (voiceSendPendingRef.current) return voiceSendPendingRef.current = true try { const committed = await props.onVoiceToggle() if (committed) props.onSend('default') } finally { voiceSendPendingRef.current = false }
Make the dictation stop/toggle return
falsefor no audio, transcription failure, a connecting abort, and an already-running stop. -
[Major] Empty-composer dictation still cannot route directly to the scratchlist — after transcription updates assistant-ui, this event handler invokes the pre-transcription
handleSendclosure, wherecanSendwas false. The scratchlist branch returns before reading its live composer snapshot, leaving the transcript in the composer. Evidence:web/src/components/AssistantChat/ComposerButtons.tsx:514,web/src/components/AssistantChat/HappyComposer.tsx:1127.
Suggested fix:const snapshot = api.composer().getState() const liveAttachmentsReady = snapshot.attachments.every((attachment) => { if (attachment.status.type === 'complete') return true if (attachment.status.type !== 'requires-action') return false return Boolean((attachment as { path?: string }).path) }) const liveCanSend = ( snapshot.text.trim().length > 0 || snapshot.attachments.length > 0 ) && liveAttachmentsReady && !controlsDisabled if (!liveCanSend || parkInFlightRef.current) return
Summary
Review mode: follow-up after new commits
Three Major correctness issues remain at the latest head. The follow-up commit only increases an unrelated CLI test timeout and does not change the affected voice-send paths.
Testing
- Not run (automation; PR code was not executed).
- GitHub
testwas pending when inspected. - Added tests cover successful stop/transcription ordering only; add coverage for assistant mode, rejection/double-tap/connecting-stop paths, and empty-composer dictation routed to scratchlist.
HAPI Bot
| const hasText = props.canSend | ||
| const routesToScratchlist = props.routesToScratchlist ?? false | ||
|
|
||
| if (isVoiceActive) { |
There was a problem hiding this comment.
[MAJOR] Keep direct send out of assistant mode
isVoiceActive covers both dictation and the full voice assistant because HappyComposer.tsx:481-485 selects either source as effectiveVoiceStatus. Assistant speech is relayed directly to the coding agent and does not insert a transcript into this composer (docs/guide/voice-assistant.md:35-41), so this branch exposes a Send button that stops the assistant and submits unrelated draft text or an empty composer.
Suggested fix:
// Pass dictationEnabled through to UnifiedButton; always keep Stop.
{props.dictationEnabled ? (
<button type="button" onClick={handleVoiceSendClick}>
<SendIcon />
</button>
) : null}| : 'bg-black text-white' | ||
|
|
||
| const handleVoiceSendClick = async () => { | ||
| await props.onVoiceToggle() |
There was a problem hiding this comment.
[MAJOR] Send only after one successful stop
This await only proves that the toggle fulfilled. Standard dictation resolves its stop promise from finally after transcription rejection (useDictation.ts:128-136), and a second tap returns immediately while transcribingRef.current is true (useDictation.ts:149-160). The second handler can therefore send stale text early, followed by another send when the first stop completes. The connecting/no-recorder path also fulfills without producing a transcript.
Suggested fix:
if (voiceSendPendingRef.current) return
voiceSendPendingRef.current = true
try {
const committed = await props.onVoiceToggle()
if (committed) props.onSend('default')
} finally {
voiceSendPendingRef.current = false
}Return false from dictation stop/toggle for no audio, transcription failure, a connecting abort, and an already-running stop.
|
|
||
| const handleVoiceSendClick = async () => { | ||
| await props.onVoiceToggle() | ||
| props.onSend('default') |
There was a problem hiding this comment.
[MAJOR] Recompute scratchlist eligibility after transcription
When recording begins from an empty composer, this callback retains the pre-transcription HappyComposer.handleSend, whose captured canSend is false. Although transcription updates assistant-ui before this call, the scratchlist path checks that stale value at HappyComposer.tsx:1132 before reading the live snapshot, so the amber direct-send path leaves the transcript unparked.
Suggested fix:
const snapshot = api.composer().getState()
const liveAttachmentsReady = snapshot.attachments.every((attachment) => {
if (attachment.status.type === 'complete') return true
if (attachment.status.type !== 'requires-action') return false
return Boolean((attachment as { path?: string }).path)
})
const liveCanSend = (
snapshot.text.trim().length > 0 || snapshot.attachments.length > 0
) && liveAttachmentsReady && !controlsDisabled
if (!liveCanSend || parkInFlightRef.current) returnUse this same snapshot for onParkScratchlist.
There was a problem hiding this comment.
Findings
-
[Blocker] The PR fails TypeScript validation — changing the standard dictation toggle to return
Promise<boolean>makesresult.current.toggle()infer asPromise<boolean> | Promise<void>, which is not a valid direct return from React Testing Library'sact. The currenttestcheck fails with TS2769 atweb/src/hooks/useDictation.test.ts:55,:57,:102,:164, and:169;:102is added by this PR.
Suggested fix:await act(async () => { await result.current.toggle() })
Apply this wrapper form to all five direct
act(() => result.current.toggle())calls. -
[Major] Realtime dictation still treats cancellation and failure as a committed transcript — the new handler sends for every result except literal
false, whileuseRealtimeDictation.toggle()always resolvesvoid. During the visibleconnectingstate, a click can abort startup with no session and then submit the existing draft; a realtime stop error likewise callsfail()but still resolvesvoidand submits. Evidenceweb/src/components/AssistantChat/ComposerButtons.tsx:519, related contextweb/src/hooks/useRealtimeDictation.ts:204.
Suggested fix:const committed = await props.onVoiceToggle() if (committed === true) { props.onSend('default') }
Make realtime
stop/togglereturnPromise<boolean>:falsewhen no session exists or an error/cancel occurs, andtrueonly afteronFinalTranscriptinserts a non-empty final transcript.
Summary
Review mode: follow-up after new commits
One Blocker and one Major remain at the latest head. The current head cannot pass typecheck, and realtime direct-send can submit without a successful transcription.
Testing
- Not run (automation; PR code was not executed).
- GitHub
testfails inbun typecheckwith TS2769. - Add realtime direct-send coverage for connecting cancellation, stop failure, empty final transcript, and successful finalization.
HAPI Bot
| onTextChange | ||
| })) | ||
|
|
||
| await act(() => result.current.toggle()) |
There was a problem hiding this comment.
[BLOCKER] Fix the new toggle return type at every act call
The standard toggle now returns Promise<boolean>, while realtime returns Promise<void>, so result.current.toggle() is inferred as Promise<boolean> | Promise<void>. React Testing Library's act callback only accepts void | Promise<void>; the current CI typecheck fails here and at lines 55, 57, 164, and 169 with TS2769.
Suggested fix:
await act(async () => {
await result.current.toggle()
})Apply the same wrapper to all five direct toggle calls.
| voiceSendPendingRef.current = true | ||
| try { | ||
| const committed = await props.onVoiceToggle() | ||
| if (committed !== false) { |
There was a problem hiding this comment.
[MAJOR] Require an explicit successful realtime commit
This condition still treats undefined as success. In realtime dictation, useRealtimeDictation.toggle() always resolves void: if Send is clicked while status is connecting, stop() can abort with no session and return; stop errors also call fail() and return. Both paths therefore submit the existing draft despite producing no successful transcript.
Suggested fix:
const committed = await props.onVoiceToggle()
if (committed === true) {
props.onSend('default')
}Also make realtime stop/toggle return Promise<boolean>: false for no-session cancellation/error and true only after a non-empty final transcript is inserted.
There was a problem hiding this comment.
Findings
-
[Blocker] Voice-state transitions change the hook count - the new early return runs before
useLongPress, so a disconnected -> connecting/connected render skips hooks that were called previously and React can throw "Rendered fewer hooks than expected." Evidenceweb/src/components/AssistantChat/ComposerButtons.tsx:507.
Suggested fix:const canQueueGesture = Boolean( props.allowQueueGesture && hasText && !isVoiceActive && !routesToScratchlist && !props.controlsDisabled ) const sendButtonHandlers = useLongPress(existingOptions) if (isVoiceActive) { return voiceButtons }
-
[Major] Realtime cancellation and stop failures still submit the draft -
committed !== falsetreats the realtime toggle'sundefinedresult as success. A Send click while connecting aborts with no session, and a stop error callsfail(); both then invokeonSend. Evidenceweb/src/components/AssistantChat/ComposerButtons.tsx:520, related contextweb/src/hooks/useRealtimeDictation.ts:204.
Suggested fix:const committed = await props.onVoiceToggle() if (committed === true) { props.onSend('default') }
Make realtime
stop/togglereturnPromise<boolean>:falsefor no-session cancellation/errors, andtrueonly after a non-empty final transcript is committed. -
[Major] Empty standard transcripts are marked successful - the hub can return
{ text: '' }, but this path setssuccess = trueafterappendTranscriptleaves the composer unchanged. Direct Send can therefore submit the user's pre-existing draft even though no speech was transcribed. Evidenceweb/src/hooks/useDictation.ts:129, related contexthub/src/web/routes/voice.ts:169.
Suggested fix:const transcript = result.text.trim() if (!transcript) { setError('No speech was transcribed') setStatus('error') return } config.onTextChange(appendTranscript(config.getCurrentText(), transcript)) setStatus('disconnected') success = true
Summary
Review mode: follow-up after new commits
The prior TypeScript act failure is addressed at this head. One Blocker and two Major correctness issues remain.
Testing
- Not run (automation; PR code was not executed).
- GitHub
testcheck was still in progress at review time. - Missing coverage: rerender
UnifiedButtonacross disconnected/connecting/connected states; realtime connecting cancellation, stop failure, empty final, and successful finalization; standard empty transcript.
HAPI Bot
| const routesToScratchlist = props.routesToScratchlist ?? false | ||
| const isDictation = props.dictationEnabled ?? false | ||
|
|
||
| if (isVoiceActive) { |
There was a problem hiding this comment.
[BLOCKER] Keep useLongPress unconditional
This new early return runs before useLongPress at line 574. When voiceStatus transitions from disconnected to connecting/connected, this render calls fewer hooks than the previous render, so React can throw "Rendered fewer hooks than expected" and break the voice flow. The added tests mount a fixed status and do not exercise the transition.
Suggested fix:
const canQueueGesture = Boolean(
props.allowQueueGesture
&& hasText
&& !isVoiceActive
&& !routesToScratchlist
&& !props.controlsDisabled
)
const sendButtonHandlers = useLongPress(existingOptions)
if (isVoiceActive) {
return voiceButtons
}Move the existing voice-active branch below the unconditional hook call and restore the !isVoiceActive queue guard.
| voiceSendPendingRef.current = true | ||
| try { | ||
| const committed = await props.onVoiceToggle() | ||
| if (committed !== false) { |
There was a problem hiding this comment.
[MAJOR] Require an explicit successful realtime commit
useRealtimeDictation.toggle() still resolves void. If Send is clicked while realtime dictation is connecting, stop() aborts startup with no session and returns undefined; if session.stop() fails, it calls fail() and also returns undefined. This condition treats both as success and submits the existing composer draft.
Suggested fix:
const committed = await props.onVoiceToggle()
if (committed === true) {
props.onSend('default')
}Also make realtime stop/toggle return Promise<boolean>: false for no-session cancellation/errors, and true only after a non-empty final transcript is committed.
| if (!mountedRef.current) return | ||
| config.onTextChange(appendTranscript(config.getCurrentText(), result.text)) | ||
| setStatus('disconnected') | ||
| success = true |
There was a problem hiding this comment.
[MAJOR] Do not commit an empty standard transcript
The hub intentionally normalizes a missing upstream transcript to text: '' (hub/src/web/routes/voice.ts:169). appendTranscript then leaves the composer unchanged, but this line reports success, so direct Send can submit a pre-existing draft even though no speech was recognized.
Suggested fix:
const transcript = result.text.trim()
if (!transcript) {
setError('No speech was transcribed')
setStatus('error')
return
}
config.onTextChange(appendTranscript(config.getCurrentText(), transcript))
setStatus('disconnected')
success = true…rancy, and fix empty composer scratchlist parking
…ted boolean for voice send, check empty standard transcript, and handle realtime commit state
c7fd432 to
934509b
Compare
There was a problem hiding this comment.
Findings
- [Major] ElevenLabs disconnects can still trigger direct Send — the error path calls
finish(partial), which now populateslastFinishedTranscriptRef;stop()then treats any non-empty value as a successful commit. If ElevenLabs disconnects while the Send action awaits finalization, the hook returnstrueandUnifiedButtonsubmits incomplete partial text even though the hook enterederror. Evidenceweb/src/hooks/useRealtimeDictation.ts:223, related contextweb/src/hooks/useRealtimeDictation.ts:103.
Suggested fix:const finish = useCallback((text: string, committed = true) => { if (!mountedRef.current) return lastCommitSucceededRef.current = committed && text.trim().length > 0 updatePartial('') onFinalTranscriptRef.current(text) setStatus('disconnected') }, [updatePartial]) // Disconnect and unconfirmed timeout fallbacks preserve text, but must not send it. finish(partial, false) // ... return lastCommitSucceededRef.current
Questions
- None.
Summary
Review mode: follow-up after new commits
One Major correctness issue remains in realtime direct-send success classification. No additional findings met the 80% confidence threshold.
Testing
- Not run (automation; PR code was not executed).
- GitHub
testandpr-reviewchecks were pending when inspected. - Missing coverage: realtime committed non-empty success versus ElevenLabs disconnect/timeout/empty-final failure; standard empty-transcript failure.
HAPI Bot
| lastFinishedTranscriptRef.current = null | ||
| try { | ||
| await session.stop() | ||
| const finishedText = lastFinishedTranscriptRef.current as string | null |
There was a problem hiding this comment.
[MAJOR] Do not classify disconnect-preserved partial text as committed
finish() now writes every final/fallback string to lastFinishedTranscriptRef. The ElevenLabs onDisconnect error path calls finish(partial) before setting status='error', so a disconnect while direct Send awaits session.stop() leaves a non-empty value here. This returns true, and UnifiedButton immediately calls onSend with incomplete partial text despite the transcription error.
Suggested fix:
const finish = useCallback((text: string, committed = true) => {
if (!mountedRef.current) return
lastCommitSucceededRef.current = committed && text.trim().length > 0
updatePartial('')
onFinalTranscriptRef.current(text)
setStatus('disconnected')
}, [updatePartial])
// Preserve fallback text without authorizing direct Send.
finish(partial, false) // onDisconnect / unconfirmed timeout
// ...
return lastCommitSucceededRef.currentThere was a problem hiding this comment.
Findings
-
[Major] Direct Send can overwrite the finalized transcript with the pre-transcription rich-editor DOM - the new call invokes Send in the same async continuation that resolves after dictation writes composer state.
HappyComposerthen flushes the rich DOM before sending, but that DOM only receives programmatic composer changes in a later layout effect. Rich composer is enabled by default, so an empty draft can send nothing, and an existing draft can send without the dictated suffix. Evidenceweb/src/components/AssistantChat/ComposerButtons.tsx:548, related contextweb/src/hooks/useDictation.ts:133,web/src/components/AssistantChat/HappyComposer.tsx:1120,web/src/components/AssistantChat/RichComposerInput.tsx:742.
Suggested fix:const [voiceSendRequested, setVoiceSendRequested] = useState(false) useEffect(() => { if (!voiceSendRequested) return setVoiceSendRequested(false) props.onSend('default') // latest callback, after composer/rich DOM commit }, [voiceSendRequested, props.onSend]) // handleVoiceSendClick if (committed === true) setVoiceSendRequested(true)
-
[Minor] The direct Send affordance remains active while status is
connecting- after Stop starts transcription/finalization, the toolbar still renders Send, but a second toggle returnsfalseor returns early because stopping is already in flight. Stop followed by Send therefore silently preserves the transcript without submitting it. Evidenceweb/src/components/AssistantChat/ComposerButtons.tsx:567, related contextweb/src/hooks/useDictation.ts:158,web/src/hooks/useRealtimeDictation.ts:219.
Suggested fix:{isDictation && isConnected ? ( <button type="button" onClick={handleVoiceSendClick}> <SendIcon /> </button> ) : null}
Questions
- None.
Summary
Review mode: follow-up after new commits
Two issues remain in the direct-send lifecycle: a Major default rich-composer synchronization race and a Minor connecting-state no-op. The prior ElevenLabs disconnect success-classification issue is no longer present in the latest head.
Testing
- Not run (automation; PR code was not executed).
- GitHub
testcheck passed. - Missing coverage: a
HappyComposerintegration test with rich mentions enabled that verifies the exact sent text after dictation; a Stop-then-Send/connecting-state interaction test.
HAPI Bot
| try { | ||
| const committed = await props.onVoiceToggle() | ||
| if (committed === true) { | ||
| props.onSend('default') |
There was a problem hiding this comment.
[MAJOR] Wait for the composer commit before invoking Send
toggle() resolves immediately after dictation writes the finalized text into composer state (useDictation.ts:133-144; realtime follows the same pattern through finish()). This call then invokes the old onSend callback in the same continuation.
HappyComposer.handleSend unconditionally calls richInputRef.current.flushSerializedText() at HappyComposer.tsx:1120, while RichComposerInput only paints programmatic value changes into its DOM from the layout effect at RichComposerInput.tsx:742. Because rich composer is the default, the flush can serialize the old DOM back over the new transcript before send(): an empty draft sends nothing, or an existing draft sends without the dictated suffix.
Queue the send for a post-commit effect so it uses the latest callback after the rich DOM has synchronized:
const [voiceSendRequested, setVoiceSendRequested] = useState(false)
useEffect(() => {
if (!voiceSendRequested) return
setVoiceSendRequested(false)
props.onSend('default')
}, [voiceSendRequested, props.onSend])
// handleVoiceSendClick
if (committed === true) setVoiceSendRequested(true)| > | ||
| {stopIcon} | ||
| </button> | ||
| {isDictation ? ( |
There was a problem hiding this comment.
[MINOR] Hide direct Send once stopping/finalization has begun
This condition renders Send for both connected and connecting. After the user clicks Stop, standard dictation sets status='connecting' and transcribingRef=true; a subsequent Send calls toggle(), which returns false at useDictation.ts:158. Realtime sessions similarly have a stop already in flight. The visible Send button therefore silently does not submit after Stop.
Render it only while recording is still connected (or return the shared in-flight stop promise):
{isDictation && isConnected ? (
<button
type="button"
onClick={handleVoiceSendClick}
disabled={props.controlsDisabled}
>
<SendIcon />
</button>
) : null}There was a problem hiding this comment.
Findings
- None.
Questions
- None.
Summary
Review mode: follow-up after new commits
No issues found in the latest full diff. The prior rich-composer synchronization race and connecting-state no-op are resolved. Residual risk: the complete provider-stop → composer synchronization → send path is not covered by an integration test.
Testing
- Not run (automation; PR code was not executed).
- GitHub test check: IN_PROGRESS at review time.
- Missing coverage: a HappyComposer integration test asserting the exact submitted text after standard and realtime dictation, plus direct-send routing while scratchlist mode is active.
HAPI Bot
There was a problem hiding this comment.
Findings
- None.
Questions
- None.
Summary
Review mode: follow-up after new commits
No issues found in the latest full diff. Residual risk: the end-to-end HappyComposer → provider stop/finalization → composer synchronization → submission path is still covered only through isolated mocks, and realtime provider commit success/failure is not directly asserted.
Testing
- Not run (automation; PR code was not executed).
- Missing coverage: a HappyComposer integration test asserting the exact submitted text for standard and realtime dictation, including scratchlist routing; provider-level tests asserting the realtime toggle boolean after commit, timeout, and disconnect.
HAPI Bot
|
Closing this superseded implementation during the author’s PR maintenance pass. The normal-chat direct-send button and transcript finalization are implemented by #1436, which remains open. The excluded scratchlist, attachment and scheduling contexts are outside that replacement’s scope and have no confirmed requirement here. Keeping one session-bound voice-finalization implementation avoids maintaining competing pipelines. |
Scope decision (2026-09-14)
Do not merge this old implementation independently of #1436. Its normal-chat direct-send button and transcript-finalization path are superseded by the session-bound
stopAndSendimplementation already present in #1436 (not yet in upstream main). Integrating both as-is conflicts in ComposerButtons, standard dictation, realtime dictation, and dictation tests.The remaining distinction is direct sending with scratchlist/attachments/scheduling, which #1436 intentionally excludes. This follow-up does not add those unconfirmed behaviors or reintroduce a second voice-finalization pipeline. Keep #1436 as the implementation to land; once it lands, reassess closing this PR unless a separate concrete need for the excluded contexts is confirmed. This PR has not been closed or merged.
Add an upward-arrow send button alongside the stop button during active voice input sessions. Clicking the send button stops the voice session and immediately submits the transcribed result as a user prompt.