diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice-stream-subscription.test.tsx b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice-stream-subscription.test.tsx new file mode 100644 index 0000000000..2979f22377 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice-stream-subscription.test.tsx @@ -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) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts index 1798952e0f..875642dc4b 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-voice.ts @@ -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).