Skip to content

feat(web): add direct send button during active voice session - #1419

Closed
swear01 wants to merge 14 commits into
tiann:mainfrom
swear01:voice-direct-send
Closed

swear01 wants to merge 14 commits into
tiann:mainfrom
swear01:voice-direct-send

Conversation

@swear01

@swear01 swear01 commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

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 stopAndSend implementation 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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 test and pr-review checks 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')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 finally after transcription errors, and a second tap returns immediately while transcribingRef is 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.onSend remains the pre-transcription handleSend closure where canSend was 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 test failed on an unrelated timeout in cli/src/claude/claudeRemote.test.ts:72 before 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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) return

Derive scratchlist eligibility from the live snapshot at invocation time, and add an integration test starting with canSend=false.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] Direct-send remains enabled in full voice-assistant sessions — isVoiceActive is 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 false for 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 handleSend closure, where canSend was 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 test was 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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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) return

Use this same snapshot for onParkScratchlist.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Blocker] The PR fails TypeScript validation — changing the standard dictation toggle to return Promise<boolean> makes result.current.toggle() infer as Promise<boolean> | Promise<void>, which is not a valid direct return from React Testing Library's act. The current test check fails with TS2769 at web/src/hooks/useDictation.test.ts:55, :57, :102, :164, and :169; :102 is 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, while useRealtimeDictation.toggle() always resolves void. During the visible connecting state, a click can abort startup with no session and then submit the existing draft; a realtime stop error likewise calls fail() but still resolves void and submits. Evidence web/src/components/AssistantChat/ComposerButtons.tsx:519, related context web/src/hooks/useRealtimeDictation.ts:204.
    Suggested fix:

    const committed = await props.onVoiceToggle()
    if (committed === true) {
        props.onSend('default')
    }

    Make realtime stop/toggle return Promise<boolean>: false when no session exists or an error/cancel occurs, and true only after onFinalTranscript inserts 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 test fails in bun typecheck with TS2769.
  • Add realtime direct-send coverage for connecting cancellation, stop failure, empty final transcript, and successful finalization.

HAPI Bot

Comment thread web/src/hooks/useDictation.test.ts Outdated
onTextChange
}))

await act(() => result.current.toggle())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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." Evidence web/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 !== false treats the realtime toggle's undefined result as success. A Send click while connecting aborts with no session, and a stop error calls fail(); both then invoke onSend. Evidence web/src/components/AssistantChat/ComposerButtons.tsx:520, related context web/src/hooks/useRealtimeDictation.ts:204.
    Suggested fix:

    const committed = await props.onVoiceToggle()
    if (committed === true) {
        props.onSend('default')
    }

    Make realtime stop/toggle return Promise<boolean>: false for no-session cancellation/errors, and true only after a non-empty final transcript is committed.

  • [Major] Empty standard transcripts are marked successful - the hub can return { text: '' }, but this path sets success = true after appendTranscript leaves the composer unchanged. Direct Send can therefore submit the user's pre-existing draft even though no speech was transcribed. Evidence web/src/hooks/useDictation.ts:129, related context hub/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 test check was still in progress at review time.
  • Missing coverage: rerender UnifiedButton across 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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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

@swear01
swear01 force-pushed the voice-direct-send branch from c7fd432 to 934509b Compare August 9, 2026 05:56

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings

  • [Major] ElevenLabs disconnects can still trigger direct Send — the error path calls finish(partial), which now populates lastFinishedTranscriptRef; stop() then treats any non-empty value as a successful commit. If ElevenLabs disconnects while the Send action awaits finalization, the hook returns true and UnifiedButton submits incomplete partial text even though the hook entered error. Evidence web/src/hooks/useRealtimeDictation.ts:223, related context web/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 test and pr-review checks were pending when inspected.
  • Missing coverage: realtime committed non-empty success versus ElevenLabs disconnect/timeout/empty-final failure; standard empty-transcript failure.

HAPI Bot

Comment thread web/src/hooks/useRealtimeDictation.ts Outdated
lastFinishedTranscriptRef.current = null
try {
await session.stop()
const finishedText = lastFinishedTranscriptRef.current as string | null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.current

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. HappyComposer then 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. Evidence web/src/components/AssistantChat/ComposerButtons.tsx:548, related context web/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 returns false or returns early because stopping is already in flight. Stop followed by Send therefore silently preserves the transcript without submitting it. Evidence web/src/components/AssistantChat/ComposerButtons.tsx:567, related context web/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 test check passed.
  • Missing coverage: a HappyComposer integration 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')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 ? (

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

swear01 added a commit to swear01/hapi that referenced this pull request Aug 10, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@heavygee heavygee added area:web Web PWA / React client community-pr PR from non-collaborator contributor enhancement New feature or request labels Sep 4, 2026
@swear01

swear01 commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:web Web PWA / React client community-pr PR from non-collaborator contributor enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants