Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
c57d942
feat(web): persist and send voice input message across session naviga…
swear01 Aug 9, 2026
6965f91
fix(web): address bot review findings for voice send across session s…
swear01 Aug 9, 2026
7aab40c
fix(web): resolve teardown, inactive session, send error recovery and…
swear01 Aug 9, 2026
9a85226
fix(web): preserve draft initialText on error and handle async cross-…
swear01 Aug 9, 2026
1c4ea89
fix(web): preserve session draft on background failure and send via c…
swear01 Aug 9, 2026
9ef85e5
fix(web): send dictation directly via API and fix standard dictation …
swear01 Aug 9, 2026
222db06
trigger CI rerun for voice dictation session switch fixes (#1435)
swear01 Aug 9, 2026
c2eb773
fix(web): prevent failed voice sends from clobbering replacement comp…
swear01 Aug 9, 2026
b9f7a80
fix(web): prevent zero-byte recording failure from clobbering replace…
swear01 Aug 9, 2026
cba29e0
fix(web): prevent unmount voice transcription, clear draft on send su…
swear01 Aug 9, 2026
5071604
fix(web): stop button transcribes into composer without auto-sending;…
swear01 Aug 9, 2026
658bde8
ci: rerun CI for flaky claudeRemote test timeout (#1435)
swear01 Aug 9, 2026
860653f
ci: retry flaky claudeRemote test (#1435)
swear01 Aug 9, 2026
be0df61
fix(web): guard dictation send path against attachments/schedule/conn…
swear01 Aug 9, 2026
cf3c918
fix(web): preserve Pi steer delivery mode for background dictation se…
swear01 Aug 9, 2026
7a57fc1
fix(web): include active state in handleSend dependency array (#1435)
swear01 Aug 9, 2026
2cf9c56
fix(voice): surface MediaRecorder failures instead of transcribing pa…
swear01 Aug 11, 2026
d2f3f69
Merge remote-tracking branch 'upstream/main' into feat/web-persist-vo…
swear01 Aug 11, 2026
5e62021
Merge remote-tracking branch 'upstream/main' into feat/web-persist-vo…
swear01 Aug 12, 2026
9bfd016
fix(web): resume inactive sessions for voice dictation direct-sends
swear01 Aug 12, 2026
1288ae3
fix(web): satisfy HappyChatContextValue in markdown-a test helper
swear01 Aug 12, 2026
0b225bd
fix(web): expose a Send action during connected dictation
swear01 Aug 12, 2026
47f8d77
fix(web): hide dictation Send button when direct send is not eligible
swear01 Aug 12, 2026
51e70b2
Merge remote-tracking branch 'upstream/main' into feat/web-persist-vo…
swear01 Aug 12, 2026
70ddbc5
fix(web): drop superseded markdown-a test context fix
swear01 Aug 12, 2026
00d00de
fix(web): gate dictation direct-send on full attachment state and gua…
swear01 Aug 12, 2026
b8293c9
fix(web): read live route state when a background voice send completes
swear01 Aug 12, 2026
2b00f6c
fix(web): recover post-resume send failures under the resumed session id
swear01 Aug 12, 2026
05119cf
fix(web): snapshot resumed-session draft before the post-resume send
swear01 Aug 12, 2026
5e45dd2
fix(web): preserve both drafts when a post-resume send fails
swear01 Aug 12, 2026
155ec33
fix(web): merge live composer text into failed voice-send recovery
swear01 Aug 12, 2026
3ffcdf2
fix(web): preserve dictation audio constraints
swear01 Sep 3, 2026
fd536b0
Merge remote-tracking branch 'upstream/main' into feat/web-persist-vo…
swear01 Sep 3, 2026
acef2ee
fix(web): transfer follow-up drafts after voice resume
swear01 Sep 3, 2026
3740602
fix(web): merge source drafts on failed voice resume
swear01 Sep 3, 2026
1274fd2
Merge current main and preserve voice-send navigation guards
swear01 Sep 12, 2026
0230973
Preserve resumed voice drafts and replace recovered composer text
swear01 Sep 12, 2026
c3e2fff
Recover delayed voice failures into the current composer mount
swear01 Sep 12, 2026
22fbc4c
fix: preserve live composer drafts after background voice success
swear01 Sep 12, 2026
7787a1b
fix: retain resumed voice follow-ups and destination attachments
swear01 Sep 12, 2026
9379ea5
fix: preserve independent destination drafts during voice recovery
swear01 Sep 12, 2026
0a7bbc3
fix: preserve voice recovery drafts before composer hydration
swear01 Sep 12, 2026
66772e6
Merge remote-tracking branch 'upstream/main' into feat/web-persist-vo…
swear01 Sep 20, 2026
03316fe
Refresh PR #1436 against current upstream and preserve feature behavior
swear01 Oct 8, 2026
3cc695e
fix(web): retain ElevenLabs finalization across navigation
swear01 Oct 12, 2026
10b8919
merge: refresh PR #1436 against upstream main
swear01 Oct 14, 2026
1626bd3
fix(web): preserve new same-text drafts across pending voice sends
swear01 Oct 14, 2026
836c4b7
fix(web): refresh voice send callback after draft hydration
swear01 Oct 14, 2026
03a2bcc
fix(web): resolve voice destinations from current session activity
swear01 Oct 14, 2026
454f437
fix(web): give voice direct-sends a localId so agent echoes dedupe
swear01 Oct 18, 2026
45bf446
Merge upstream/main into feat/web-persist-voice-input-session-switch
swear01 Oct 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@assistant-ui/react": "^0.15.21",
"@assistant-ui/react-markdown": "^0.14.16",
"@assistant-ui/tap": "^0.9.18",
"@elevenlabs/client": "0.13.0",
"@elevenlabs/react": "^0.13.0",
"@hapi/protocol": "workspace:*",
"@lobehub/icons": "^5.14.0",
Expand Down
116 changes: 116 additions & 0 deletions web/src/components/AssistantChat/ComposerButtons.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,122 @@ describe('UnifiedButton — fill-height primary (voice ↔ send)', () => {
})
})

describe('UnifiedButton — active dictation send action', () => {
afterEach(cleanup)

it('exposes a Send button next to Stop while dictation is connected and routes it through onSend', () => {
const onSend = vi.fn()
const onVoiceToggle = vi.fn()
renderInProviders(
<UnifiedButton
canSend
voiceStatus="connected"
voiceEnabled
dictationEnabled
dictationCanDirectSend
controlsDisabled={false}
onSend={onSend}
onVoiceToggle={onVoiceToggle}
/>,
)

const sendButton = getButton('Send')
fireEvent.click(sendButton)
// onSend('default') lands in handleSend's dictation branch, which runs
// stopAndSend bound to the target session (survives navigation).
expect(onSend).toHaveBeenCalledWith('default')
expect(onVoiceToggle).not.toHaveBeenCalled()
})

it('retains the fill-height Stop control alongside direct dictation Send', () => {
const onSend = vi.fn()
const onVoiceToggle = vi.fn()
renderInProviders(
<UnifiedButton
canSend
voiceStatus="connected"
voiceEnabled
dictationEnabled
dictationCanDirectSend
fillHeight
controlsDisabled={false}
onSend={onSend}
onVoiceToggle={onVoiceToggle}
/>,
)

const stopButton = getButton('Stop')
expect(stopButton.getAttribute('data-size')).toBe('fill')
expect(stopButton.className).toContain('h-full')
fireEvent.click(getButton('Send'))
expect(onSend).toHaveBeenCalledWith('default')
expect(onVoiceToggle).not.toHaveBeenCalled()
fireEvent.click(stopButton)
expect(onVoiceToggle).toHaveBeenCalledOnce()
expect(onSend).toHaveBeenCalledOnce()
})

it('hides the dictation Send button when direct send is not eligible', () => {
const onSend = vi.fn()
const onVoiceToggle = vi.fn()
renderInProviders(
<UnifiedButton
canSend
voiceStatus="connected"
voiceEnabled
dictationEnabled
dictationCanDirectSend={false}
controlsDisabled={false}
onSend={onSend}
onVoiceToggle={onVoiceToggle}
/>,
)

// Attachments / pending schedule / scratchlist mode make handleSend
// fall back to dictation.toggle(); a "Send" label would be misleading.
expect(screen.queryByRole('button', { name: 'Send' })).toBeNull()
expect(getButton('Stop')).toBeDefined()
})

it('keeps Stop stop-only during connected dictation', () => {
const onSend = vi.fn()
const onVoiceToggle = vi.fn()
renderInProviders(
<UnifiedButton
canSend
voiceStatus="connected"
voiceEnabled
dictationEnabled
dictationCanDirectSend
controlsDisabled={false}
onSend={onSend}
onVoiceToggle={onVoiceToggle}
/>,
)

fireEvent.click(getButton('Stop'))
expect(onVoiceToggle).toHaveBeenCalledOnce()
expect(onSend).not.toHaveBeenCalled()
})

it('does not show the dictation Send button while connecting', () => {
renderInProviders(
<UnifiedButton
canSend
voiceStatus="connecting"
voiceEnabled
dictationEnabled
controlsDisabled={false}
onSend={() => {}}
onVoiceToggle={() => {}}
/>,
)

expect(screen.queryByRole('button', { name: 'Send' })).toBeNull()
expect(getButton(/Connecting/)).toBeDefined()
})
})

describe('DictationButton', () => {
afterEach(cleanup)

Expand Down
50 changes: 38 additions & 12 deletions web/src/components/AssistantChat/ComposerButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,9 @@ export function UnifiedButton(props: {
canSend: boolean
voiceStatus: ConversationStatus
voiceEnabled: boolean
dictationEnabled?: boolean
/** When false, the dictation Send button is hidden (direct send not eligible). */
dictationCanDirectSend?: boolean
controlsDisabled: boolean
onSend: (intent?: ComposerSendIntent) => void
onVoiceToggle: () => void
Expand Down Expand Up @@ -567,6 +570,7 @@ export function UnifiedButton(props: {
const isConnecting = props.voiceStatus === 'connecting'
const isConnected = props.voiceStatus === 'connected'
const isVoiceActive = isConnecting || isConnected
const isDictation = props.dictationEnabled ?? false
const hasText = props.canSend
const routesToScratchlist = props.routesToScratchlist ?? false
const fillHeight = props.fillHeight ?? true
Expand Down Expand Up @@ -627,18 +631,36 @@ export function UnifiedButton(props: {
)

return (
<button
type="button"
onClick={handleClick}
disabled={isDisabled}
aria-label={ariaLabel}
title={ariaLabel}
data-testid="composer-unified-button"
data-size={fillHeight ? 'fill' : 'compact'}
className={`flex ${unifiedButtonSizeClass(fillHeight)} items-center justify-center transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
>
{icon}
</button>
<>
<button
type="button"
onClick={handleClick}
disabled={isDisabled}
aria-label={ariaLabel}
title={ariaLabel}
data-testid="composer-unified-button"
data-size={fillHeight ? 'fill' : 'compact'}
className={`flex ${unifiedButtonSizeClass(fillHeight)} items-center justify-center transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
>
{icon}
</button>
{isDictation && isConnected && props.dictationCanDirectSend ? (
// Explicit send action while dictation is connected: mouse/touch
// users must be able to reach the session-bound stopAndSend path
// (handleSend's dictation branch) without first stopping the
// recording, or the transcript is only sendable via keyboard.
<button
type="button"
onClick={() => props.onSend('default')}
disabled={props.controlsDisabled}
aria-label={t('composer.send')}
title={t('composer.send')}
className="ml-1 flex self-center h-8 w-8 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50 bg-black text-white"
>
<SendIcon />
</button>
) : null}
</>
)
}

Expand Down Expand Up @@ -715,6 +737,8 @@ export function ComposerButtons(props: {
onSwitch: () => void
voiceEnabled: boolean
dictationEnabled?: boolean
/** When false, the dictation Send button is hidden (direct send not eligible). */
dictationCanDirectSend?: boolean
voiceStatus: ConversationStatus
voiceMicMuted?: boolean
onVoiceToggle: () => void
Expand Down Expand Up @@ -1011,6 +1035,8 @@ export function ComposerButtons(props: {
canSend={props.canSend}
voiceStatus={props.voiceStatus}
voiceEnabled={props.voiceEnabled}
dictationEnabled={props.dictationEnabled}
dictationCanDirectSend={props.dictationCanDirectSend}
controlsDisabled={props.controlsDisabled}
onSend={props.onSend}
onVoiceToggle={props.onVoiceToggle}
Expand Down
91 changes: 66 additions & 25 deletions web/src/components/AssistantChat/HappyComposer.sendError.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { ReactNode, TextareaHTMLAttributes } from 'react'
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/lib/i18n-context'
import type { PendingSchedule } from '@/components/AssistantChat/ScheduleTimePicker'
Expand Down Expand Up @@ -33,32 +33,37 @@ const runtime = vi.hoisted(() => ({
pendingSendIntentRef: null as null | { current: ComposerSendIntent },
sentIntents: [] as ComposerSendIntent[],
modelChanges: [] as Array<{ provider: string; modelId: string } | string | null>,
pendingHydration: null as Promise<void> | null,
dictationEnabled: false,
dictation: { status: 'connected', supported: true, partialTranscript: '', error: null, toggle: vi.fn(), stopAndSend: vi.fn() },
}))

vi.mock('@assistant-ui/react', async () => {
const React = await import('react')
return {
useAui: () => ({
composer: () => ({
setText: (text: string) => {
runtime.setSnapshot!((current) => ({
...current,
composer: { ...current.composer, text },
}))
},
send: () => {
const intent = runtime.pendingSendIntentRef?.current ?? 'default'
runtime.sentIntents.push(intent)
if (runtime.pendingSendIntentRef) runtime.pendingSendIntentRef.current = 'default'
runtime.setSnapshot!((current) => ({
...current,
composer: { text: '', attachments: [] },
}))
},
addAttachment: async () => {},
}),
thread: () => ({ cancelRun: () => {} }),
const api = {
composer: () => ({
getState: () => runtime.snapshot.composer,
setText: (text: string) => {
runtime.setSnapshot!((current) => ({
...current,
composer: { ...current.composer, text },
}))
},
send: () => {
const intent = runtime.pendingSendIntentRef?.current ?? 'default'
runtime.sentIntents.push(intent)
if (runtime.pendingSendIntentRef) runtime.pendingSendIntentRef.current = 'default'
runtime.setSnapshot!((current) => ({
...current,
composer: { text: '', attachments: [] },
}))
},
addAttachment: async () => {},
}),
thread: () => ({ cancelRun: () => {} }),
}
return {
useAui: () => api,
useAuiState: (selector: (state: typeof runtime.snapshot) => unknown) => selector(runtime.snapshot),
ComposerPrimitive: {
Root: ({ children, onSubmit }: { children: ReactNode; onSubmit?: () => void }) => (
Expand Down Expand Up @@ -98,7 +103,15 @@ vi.mock('@/lib/composerSegments', () => ({
showContinueHint ? 'misc.typeMessage' : 'misc.typeAMessage',
}))
vi.mock('@/hooks/useComposerDraft', () => ({
useComposerDraft: (sessionId: string | undefined) => ({ sessionId, complete: true, restoredAny: false, hasStoredAttachments: false }),
useComposerDraft: (sessionId: string | undefined) => {
const [complete, setComplete] = useState(runtime.pendingHydration === null)
useEffect(() => { void runtime.pendingHydration?.then(() => setComplete(true)) }, [])
return { sessionId, complete, restoredAny: false, hasStoredAttachments: false }
},
}))
vi.mock('@/hooks/useDictation', () => ({ useDictation: () => runtime.dictation }))
vi.mock('@/hooks/useVoiceInputPreferences', () => ({
useVoiceInputPreferences: () => ({ voiceMode: runtime.dictationEnabled ? 'dictation' : 'conversation', provider: 'openai', transcriptionMode: 'standard' }),
}))
vi.mock('@/hooks/useComposerEnterBehavior', () => ({ useComposerEnterBehavior: () => ({ composerEnterBehavior: 'send' }) }))
vi.mock('@/hooks/usePlatform', () => ({ usePlatform: () => ({ haptic: { impact: () => {}, notification: () => {} }, isTouch: false }) }))
Expand Down Expand Up @@ -136,8 +149,8 @@ vi.mock('@/components/AssistantChat/ComposerButtons', () => ({
</div>
),
// HappyComposer places UnifiedButton beside the card (omitPrimaryButton).
UnifiedButton: (props: { onSend: () => void }) => (
<button type="button" onClick={props.onSend}>send</button>
UnifiedButton: (props: { onSend: () => void; dictationCanDirectSend?: boolean }) => (
<button type="button" data-direct-send={props.dictationCanDirectSend} onClick={() => props.onSend()}>send</button>
),
DictationButton: () => null,
isComposerPrimaryFillHeight: () => true,
Expand Down Expand Up @@ -607,3 +620,31 @@ describe('HappyComposer send intent gestures', () => {
expect(runtime.pendingSendIntentRef?.current).toBe('default')
})
})

it('refreshes voice Send after empty attachment hydration completes', async () => {
let hydrate: (() => void) | undefined
runtime.pendingHydration = new Promise<void>(resolve => { hydrate = resolve })
runtime.dictationEnabled = true
runtime.dictation.stopAndSend.mockClear()
runtime.dictation.toggle.mockClear()
const resolveSessionIdForVoice = vi.fn(async () => ({ sessionId: 'voice-resumed', resumed: true }))
try {
runtime.snapshot = { composer: { text: '', attachments: [] }, thread: { isRunning: false, isDisabled: false } }
runtime.setSnapshot = vi.fn()
render(<I18nProvider><HappyComposer sessionId="voice-inactive" active={false} resolveSessionIdForVoice={resolveSessionIdForVoice} /></I18nProvider>)
expect(screen.getByRole('button', { name: 'send' })).toHaveAttribute('data-direct-send', 'false')
await act(async () => { hydrate?.() })
expect(screen.getByRole('button', { name: 'send' })).toHaveAttribute('data-direct-send', 'true')
send()
await waitFor(() => expect(runtime.dictation.stopAndSend).toHaveBeenCalledWith('voice-inactive', '', 'queue', {
resolveSessionId: resolveSessionIdForVoice,
onSessionResolved: undefined,
}))
expect(runtime.dictation.toggle).not.toHaveBeenCalled()
} finally {
cleanup()
runtime.pendingHydration = null
runtime.dictationEnabled = false
runtime.setSnapshot = null
}
})
Loading
Loading