Skip to content

Commit f1a2ff2

Browse files
committed
carry: PR tiann#1419 voice direct send (resolved on f63d4bb)
1 parent 26adc12 commit f1a2ff2

6 files changed

Lines changed: 478 additions & 201 deletions

File tree

‎cli/src/claude/claudeRemote.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ describe('claudeRemote async message handling', () => {
107107
queryMock.mockReset();
108108
querySpy.mockRestore();
109109
}
110-
});
110+
}, 15000);
111111

112112
it('continues consuming assistant messages even when next user message is pending', async () => {
113113
const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query);

‎web/src/components/AssistantChat/ComposerButtons.test.tsx‎

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,3 +317,230 @@ describe('ComposerButtons responsive toolbar', () => {
317317
expect(getComposerToolbarJustifyContent('split')).toBe('flex-start')
318318
})
319319
})
320+
321+
describe('UnifiedButton — voice active state', () => {
322+
afterEach(() => {
323+
cleanup()
324+
})
325+
326+
it('preserves React hook order when re-rendering across disconnected, connecting, and connected states', () => {
327+
const onVoiceToggle = vi.fn()
328+
const onSend = vi.fn()
329+
const { rerender } = renderInProviders(
330+
<UnifiedButton
331+
canSend={false}
332+
voiceStatus="disconnected"
333+
voiceEnabled
334+
dictationEnabled
335+
controlsDisabled={false}
336+
onSend={onSend}
337+
onVoiceToggle={onVoiceToggle}
338+
/>,
339+
)
340+
341+
expect(() => {
342+
rerender(
343+
<I18nProvider>
344+
<UnifiedButton
345+
canSend={false}
346+
voiceStatus="connecting"
347+
voiceEnabled
348+
dictationEnabled
349+
controlsDisabled={false}
350+
onSend={onSend}
351+
onVoiceToggle={onVoiceToggle}
352+
/>
353+
</I18nProvider>,
354+
)
355+
rerender(
356+
<I18nProvider>
357+
<UnifiedButton
358+
canSend={false}
359+
voiceStatus="connected"
360+
voiceEnabled
361+
dictationEnabled
362+
controlsDisabled={false}
363+
onSend={onSend}
364+
onVoiceToggle={onVoiceToggle}
365+
/>
366+
</I18nProvider>,
367+
)
368+
rerender(
369+
<I18nProvider>
370+
<UnifiedButton
371+
canSend={false}
372+
voiceStatus="disconnected"
373+
voiceEnabled
374+
dictationEnabled
375+
controlsDisabled={false}
376+
onSend={onSend}
377+
onVoiceToggle={onVoiceToggle}
378+
/>
379+
</I18nProvider>,
380+
)
381+
}).not.toThrow()
382+
})
383+
384+
it('renders ONLY Stop button when in voice assistant mode (dictationEnabled is false)', () => {
385+
const onVoiceToggle = vi.fn()
386+
const onSend = vi.fn()
387+
renderInProviders(
388+
<UnifiedButton
389+
canSend={false}
390+
voiceStatus="connected"
391+
voiceEnabled
392+
dictationEnabled={false}
393+
controlsDisabled={false}
394+
onSend={onSend}
395+
onVoiceToggle={onVoiceToggle}
396+
/>,
397+
)
398+
399+
const stopBtn = screen.getByRole('button', { name: 'Stop' })
400+
const sendBtn = screen.queryByRole('button', { name: 'Send' })
401+
expect(stopBtn).toBeInTheDocument()
402+
expect(sendBtn).toBeNull()
403+
})
404+
405+
it('renders ONLY Stop button during connecting status even when dictationEnabled is true', () => {
406+
const onVoiceToggle = vi.fn()
407+
const onSend = vi.fn()
408+
renderInProviders(
409+
<UnifiedButton
410+
canSend={false}
411+
voiceStatus="connecting"
412+
voiceEnabled
413+
dictationEnabled
414+
controlsDisabled={false}
415+
onSend={onSend}
416+
onVoiceToggle={onVoiceToggle}
417+
/>,
418+
)
419+
420+
const stopBtn = screen.getByRole('button', { name: /connecting/i })
421+
const sendBtn = screen.queryByRole('button', { name: 'Send' })
422+
expect(stopBtn).toBeInTheDocument()
423+
expect(sendBtn).toBeNull()
424+
})
425+
426+
it('renders both Stop and Send buttons in dictation mode (dictationEnabled is true)', () => {
427+
const onVoiceToggle = vi.fn()
428+
const onSend = vi.fn()
429+
renderInProviders(
430+
<UnifiedButton
431+
canSend={false}
432+
voiceStatus="connected"
433+
voiceEnabled
434+
dictationEnabled
435+
controlsDisabled={false}
436+
onSend={onSend}
437+
onVoiceToggle={onVoiceToggle}
438+
/>,
439+
)
440+
441+
const stopBtn = screen.getByRole('button', { name: 'Stop' })
442+
const sendBtn = screen.getByRole('button', { name: 'Send' })
443+
expect(stopBtn).toBeInTheDocument()
444+
expect(sendBtn).toBeInTheDocument()
445+
})
446+
447+
it('clicking Stop button calls onVoiceToggle without calling onSend', () => {
448+
const onVoiceToggle = vi.fn()
449+
const onSend = vi.fn()
450+
renderInProviders(
451+
<UnifiedButton
452+
canSend={false}
453+
voiceStatus="connected"
454+
voiceEnabled
455+
dictationEnabled
456+
controlsDisabled={false}
457+
onSend={onSend}
458+
onVoiceToggle={onVoiceToggle}
459+
/>,
460+
)
461+
462+
const stopBtn = screen.getByRole('button', { name: 'Stop' })
463+
fireEvent.click(stopBtn)
464+
expect(onVoiceToggle).toHaveBeenCalledOnce()
465+
expect(onSend).not.toHaveBeenCalled()
466+
})
467+
468+
it('clicking Send button during active dictation calls onVoiceToggle and onSend', async () => {
469+
const onVoiceToggle = vi.fn(async () => true)
470+
const onSend = vi.fn()
471+
renderInProviders(
472+
<UnifiedButton
473+
canSend={false}
474+
voiceStatus="connected"
475+
voiceEnabled
476+
dictationEnabled
477+
controlsDisabled={false}
478+
onSend={onSend}
479+
onVoiceToggle={onVoiceToggle}
480+
/>,
481+
)
482+
483+
const sendBtn = screen.getByRole('button', { name: 'Send' })
484+
await act(async () => {
485+
fireEvent.click(sendBtn)
486+
})
487+
expect(onVoiceToggle).toHaveBeenCalledOnce()
488+
expect(onSend).toHaveBeenCalledOnce()
489+
expect(onSend).toHaveBeenCalledWith('default')
490+
})
491+
492+
it('does not call onSend if onVoiceToggle returns false (transcription failure / no audio)', async () => {
493+
const onVoiceToggle = vi.fn(async () => false)
494+
const onSend = vi.fn()
495+
renderInProviders(
496+
<UnifiedButton
497+
canSend={false}
498+
voiceStatus="connected"
499+
voiceEnabled
500+
dictationEnabled
501+
controlsDisabled={false}
502+
onSend={onSend}
503+
onVoiceToggle={onVoiceToggle}
504+
/>,
505+
)
506+
507+
const sendBtn = screen.getByRole('button', { name: 'Send' })
508+
await act(async () => {
509+
fireEvent.click(sendBtn)
510+
})
511+
expect(onVoiceToggle).toHaveBeenCalledOnce()
512+
expect(onSend).not.toHaveBeenCalled()
513+
})
514+
515+
it('prevents double-tap reentrancy while voice send is pending', async () => {
516+
let resolveVoiceToggle!: (res: boolean) => void
517+
const onVoiceToggle = vi.fn(() => new Promise<boolean>((resolve) => {
518+
resolveVoiceToggle = resolve
519+
}))
520+
const onSend = vi.fn()
521+
renderInProviders(
522+
<UnifiedButton
523+
canSend={false}
524+
voiceStatus="connected"
525+
voiceEnabled
526+
dictationEnabled
527+
controlsDisabled={false}
528+
onSend={onSend}
529+
onVoiceToggle={onVoiceToggle}
530+
/>,
531+
)
532+
533+
const sendBtn = screen.getByRole('button', { name: 'Send' })
534+
fireEvent.click(sendBtn)
535+
fireEvent.click(sendBtn) // second click while in flight
536+
expect(onVoiceToggle).toHaveBeenCalledOnce()
537+
expect(onSend).not.toHaveBeenCalled()
538+
539+
await act(async () => {
540+
resolveVoiceToggle(true)
541+
})
542+
expect(onSend).toHaveBeenCalledOnce()
543+
})
544+
})
545+
546+

‎web/src/components/AssistantChat/ComposerButtons.tsx‎

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { 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'
33
import type { CodexUsage } from '@hapi/protocol/types'
44
import type { ConversationStatus } from '@/realtime/types'
55
import { 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

Comments
 (0)