fix(desktop): stream voice replies from live message deltas

This commit is contained in:
Daniel Muzology
2026-08-08 15:15:11 -04:00
committed by kshitij
parent 82f23d4c34
commit 93dc760d8a
2 changed files with 113 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
import { act, renderHook } from '@testing-library/react'
import { beforeEach, expect, test, vi } from 'vitest'
const mocks = vi.hoisted(() => {
let value: unknown[] = []
const listeners = new Set<(next: unknown[], old: unknown[]) => void>()
const messages = {
get: () => value,
listen: (listener: (next: unknown[], old: unknown[]) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
set: (next: unknown[]) => {
const old = value
value = next
listeners.forEach(listener => listener(next, old))
},
subscribe: (listener: (next: unknown[], old: unknown[]) => void) => {
listeners.add(listener)
listener(value, value)
return () => listeners.delete(listener)
}
}
const useVoiceConversation = vi.fn(() => ({
end: vi.fn(async () => undefined),
level: 0,
muted: false,
start: vi.fn(async () => undefined),
status: 'idle' as const,
stopTurn: vi.fn(),
toggleMute: vi.fn()
}))
return { messages, useVoiceConversation }
})
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
assistant: { thread: { readAloudFailed: 'read aloud failed' } },
notifications: { voice: { sayStopToEnd: (phrase: string) => phrase } },
settings: { config: { autosaveFailed: 'autosave failed' } }
}
})
}))
vi.mock('../scope', () => ({
useComposerScope: () => ({ $messages: mocks.messages }),
useComposerSurfaceId: () => null
}))
vi.mock('./use-voice-conversation', () => ({ useVoiceConversation: mocks.useVoiceConversation }))
vi.mock('./use-voice-recorder', () => ({
useVoiceRecorder: () => ({ dictate: vi.fn(), voiceActivityState: null, voiceStatus: 'idle' })
}))
vi.mock('./use-auto-speak-replies', () => ({ useAutoSpeakReplies: vi.fn() }))
vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() }))
vi.mock('@/lib/wake-indicator', () => ({
clearWakeIndicator: vi.fn(),
syncWakeIndicatorWithVoice: vi.fn(() => false)
}))
vi.mock('@/store/ambient', () => ({ ownsAmbientCue: vi.fn(async () => true) }))
vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() }))
vi.mock('@/store/wake-word', () => ({
resumeWakeAfterVoice: vi.fn(async () => undefined),
stopClientCapture: vi.fn(async () => undefined)
}))
import { useComposerVoice } from './use-composer-voice'
beforeEach(() => {
mocks.messages.set([])
mocks.useVoiceConversation.mockClear()
})
test('re-renders the voice loop when an in-progress assistant delta lands', () => {
renderHook(() =>
useComposerVoice({
busy: true,
clearDraft: vi.fn(),
disabled: false,
focusInput: vi.fn(),
insertText: vi.fn(),
maxRecordingSeconds: 60,
onSubmit: vi.fn(async () => true),
onTranscribeAudio: vi.fn(async () => ''),
sessionId: 'voice-session',
target: 'main'
})
)
const callsBeforeDelta = mocks.useVoiceConversation.mock.calls.length
act(() => {
mocks.messages.set([
{
id: 'assistant-stream',
pending: true,
role: 'assistant',
parts: [{ type: 'text', text: 'Natural selection is' }]
}
])
})
expect(mocks.useVoiceConversation.mock.calls.length).toBeGreaterThan(callsBeforeDelta)
})

View File

@@ -67,6 +67,11 @@ export function useComposerVoice({
const { t } = useI18n()
// A tile's composer speaks ITS transcript, not the primary chat's.
const { $messages } = useComposerScope()
// Voice conversation speech is driven by in-progress assistant deltas. Keep
// this hook subscribed so its speech effect runs before busy flips false;
// pendingTurnResponse still reads $messages.get() so the 150 ms feeder sees
// every later delta instead of a stale render-time snapshot.
useStore($messages)
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
// Engine selection is latched at conversation START (a Settings change
// applies to the next conversation, never mid-call).