11import { ComposerPrimitive } from '@assistant-ui/react'
2- import { Children , isValidElement , useCallback , useLayoutEffect , useRef , useState , type ReactElement , type ReactNode , type Ref } from 'react'
2+ import { Children , isValidElement , useCallback , useLayoutEffect , useEffect , useRef , useState , type ReactElement , type ReactNode , type Ref } from 'react'
33import type { CodexUsage } from '@hapi/protocol/types'
44import type { ConversationStatus } from '@/realtime/types'
55import { useTranslation } from '@/lib/use-translation'
@@ -477,9 +477,11 @@ export function UnifiedButton(props: {
477477 canSend : boolean
478478 voiceStatus : ConversationStatus
479479 voiceEnabled : boolean
480+ dictationEnabled ?: boolean
480481 controlsDisabled : boolean
481482 onSend : ( intent ?: ComposerSendIntent ) => void
482- onVoiceToggle : ( ) => void
483+ onVoiceToggle : ( ) => void | boolean | Promise < void | boolean >
484+ onVoiceSend ?: ( ) => void | Promise < void >
483485 voiceLabel ?: string
484486 /**
485487 * When true, the send button repaints amber and the aria-label
@@ -497,17 +499,24 @@ export function UnifiedButton(props: {
497499 allowQueueGesture ?: boolean
498500} ) {
499501 const { t } = useTranslation ( )
502+ const voiceSendPendingRef = useRef ( false )
503+ const [ voiceSendRequested , setVoiceSendRequested ] = useState ( false )
504+
505+ useEffect ( ( ) => {
506+ if ( ! voiceSendRequested ) return
507+ setVoiceSendRequested ( false )
508+ props . onSend ( 'default' )
509+ } , [ voiceSendRequested , props . onSend ] )
500510
501511 const isConnecting = props . voiceStatus === 'connecting'
502512 const isConnected = props . voiceStatus === 'connected'
503513 const isVoiceActive = isConnecting || isConnected
504514 const hasText = props . canSend
505515 const routesToScratchlist = props . routesToScratchlist ?? false
516+ const isDictation = props . dictationEnabled ?? false
506517
507518 const handleClick = ( ) => {
508- if ( isVoiceActive ) {
509- props . onVoiceToggle ( ) // Stop voice
510- } else if ( hasText ) {
519+ if ( hasText ) {
511520 props . onSend ( 'default' ) // Send message (or scratchlist add — wrapper decides)
512521 } else if ( props . voiceEnabled && ! routesToScratchlist ) {
513522 props . onVoiceToggle ( ) // Start voice (suppressed in scratchlist mode)
@@ -533,19 +542,64 @@ export function UnifiedButton(props: {
533542 disabled : props . controlsDisabled ,
534543 } )
535544
545+ if ( isVoiceActive ) {
546+ const stopIcon = isConnecting ? < LoadingIcon /> : < StopIcon />
547+ const stopAriaLabel = isConnecting ? t ( 'voice.connecting' ) : t ( 'composer.stop' )
548+ const sendAriaLabel = routesToScratchlist ? t ( 'scratchlist.sendToScratchlist' ) : t ( 'composer.send' )
549+ const sendClassName = routesToScratchlist
550+ ? 'bg-amber-500 text-white hover:bg-amber-600'
551+ : 'bg-black text-white'
552+
553+ const handleVoiceSendClick = async ( ) => {
554+ if ( voiceSendPendingRef . current ) return
555+ voiceSendPendingRef . current = true
556+ try {
557+ if ( props . onVoiceSend ) {
558+ await props . onVoiceSend ( )
559+ } else {
560+ const committed = await props . onVoiceToggle ( )
561+ if ( committed === true ) {
562+ setVoiceSendRequested ( true )
563+ }
564+ }
565+ } finally {
566+ voiceSendPendingRef . current = false
567+ }
568+ }
569+
570+ return (
571+ < div className = "flex items-center gap-1" >
572+ < button
573+ type = "button"
574+ onClick = { props . onVoiceToggle }
575+ disabled = { props . controlsDisabled }
576+ aria-label = { stopAriaLabel }
577+ title = { stopAriaLabel }
578+ className = "ml-1 flex h-8 w-8 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50 bg-black text-white"
579+ >
580+ { stopIcon }
581+ </ button >
582+ { isDictation && isConnected ? (
583+ < button
584+ type = "button"
585+ onClick = { handleVoiceSendClick }
586+ disabled = { props . controlsDisabled }
587+ aria-label = { sendAriaLabel }
588+ title = { sendAriaLabel }
589+ className = { `ml-1 flex h-8 w-8 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${ sendClassName } ` }
590+ >
591+ < SendIcon />
592+ </ button >
593+ ) : null }
594+ </ div >
595+ )
596+ }
597+
536598 let icon : React . ReactNode
537599 let className : string
538600 let ariaLabel : string
539601
540- if ( isConnecting ) {
541- icon = < LoadingIcon />
542- className = 'bg-black text-white'
543- ariaLabel = t ( 'voice.connecting' )
544- } else if ( isConnected ) {
545- icon = < StopIcon />
546- className = 'bg-black text-white'
547- ariaLabel = t ( 'composer.stop' )
548- } else if ( routesToScratchlist ) {
602+ if ( routesToScratchlist ) {
549603 // Amber send button - matches the scratchlist drawer accent.
550604 // Single visual signal carries the "this goes to the scratchlist"
551605 // contract; without it, the modal state is invisible to the user.
@@ -574,7 +628,7 @@ export function UnifiedButton(props: {
574628 const isDisabled = props . controlsDisabled || (
575629 routesToScratchlist
576630 ? ! hasText
577- : ! hasText && ! props . voiceEnabled && ! isVoiceActive
631+ : ! hasText && ! props . voiceEnabled
578632 )
579633
580634 return (
@@ -652,6 +706,7 @@ export function ComposerButtons(props: {
652706 voiceStatus : ConversationStatus
653707 voiceMicMuted ?: boolean
654708 onVoiceToggle : ( ) => void
709+ onVoiceSend ?: ( ) => void | Promise < void >
655710 onVoiceMicToggle ?: ( ) => void
656711 onSend : ( intent ?: ComposerSendIntent ) => void
657712 pendingSchedule ?: PendingSchedule | null
@@ -918,9 +973,11 @@ export function ComposerButtons(props: {
918973 canSend = { props . canSend }
919974 voiceStatus = { props . voiceStatus }
920975 voiceEnabled = { props . voiceEnabled }
976+ dictationEnabled = { props . dictationEnabled }
921977 controlsDisabled = { props . controlsDisabled }
922978 onSend = { props . onSend }
923979 onVoiceToggle = { props . onVoiceToggle }
980+ onVoiceSend = { props . onVoiceSend }
924981 voiceLabel = { props . dictationEnabled ? t ( 'composer.dictate' ) : undefined }
925982 /*
926983 * Derived, NOT raw scratchlistMode. Mirror SessionChat's
0 commit comments