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 9a7db494ee..eb8307b234 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 @@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useI18n } from '@/i18n' import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages' import { triggerHaptic } from '@/lib/haptics' -import { markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply' +import { adoptSpokenReplySession, markAssistantIdSpoken, resolveSpokenReply } from '@/lib/spoken-reply' import { CONVERSATION_LEASE, READ_ALOUD_LEASE, syncTtsLease } from '@/lib/tts-lease' import { clearWakeIndicator, syncWakeIndicatorWithVoice } from '@/lib/wake-indicator' import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer' @@ -65,8 +65,15 @@ export function useComposerVoice({ const { $messages } = useComposerScope() const [voiceConversationActive, setVoiceConversationActive] = useState(false) const ownsWakeIndicatorRef = useRef(false) + const previousSessionIdRef = useRef(sessionId) const voiceStartRequest = useStore($voiceConversationStartRequest) + // eslint-disable-next-line no-restricted-syntax -- session-id adopt token, not an atom mirror + useEffect(() => { + adoptSpokenReplySession(previousSessionIdRef.current, sessionId) + previousSessionIdRef.current = sessionId + }, [sessionId]) + const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({ focusInput, maxRecordingSeconds, diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts index 087247ff12..306b03dd38 100644 --- a/apps/desktop/src/lib/chat-messages.test.ts +++ b/apps/desktop/src/lib/chat-messages.test.ts @@ -1342,6 +1342,19 @@ describe('collectUnspokenTurnSpeech', () => { expect(collectUnspokenTurnSpeech([assistant('a1', 'Done.')], 'a1')).toBeNull() expect(collectUnspokenTurnSpeech([user('u1', 'hello'), assistant('a1', '')], null)).toBeNull() }) + + it('does not replay earlier turns when the spoken id is missing or stale', () => { + const messages = [ + user('u1', 'old question'), + assistant('a1', 'Previous output from last turn.'), + user('u2', 'new question'), + assistant('a2', 'Live reply only.') + ] + + expect(collectUnspokenTurnSpeech(messages, null)?.text).toBe('Live reply only.') + expect(collectUnspokenTurnSpeech(messages, 'vanished-stream-id')?.text).toBe('Live reply only.') + expect(collectUnspokenTurnSpeech(messages, 'a1')?.text).toBe('Live reply only.') + }) }) describe('stripPendingClarifyProjectionForCache', () => { diff --git a/apps/desktop/src/lib/chat-messages/parts.ts b/apps/desktop/src/lib/chat-messages/parts.ts index 30fd5ab5b8..64ba0e013b 100644 --- a/apps/desktop/src/lib/chat-messages/parts.ts +++ b/apps/desktop/src/lib/chat-messages/parts.ts @@ -68,12 +68,26 @@ export interface UnspokenTurnSpeech { * selecting only one bubble silently drops everything after it. The blank-line * join is a sentence boundary for the server's cutter, so a sealed bubble's * tail is flushed as soon as the next bubble starts. + * + * If `lastSpokenId` is missing or stale (session id assigned mid-turn, + * live-tail rewrite missed), do **not** fall back to index -1 — that replays + * every earlier assistant turn as one speech string. Bound to the current + * turn (assistant bubbles after the last user message) instead. A slice with + * no user row (mid-turn interims only) still collects those assistants. */ export function collectUnspokenTurnSpeech( messages: ChatMessage[], lastSpokenId: string | null ): UnspokenTurnSpeech | null { - const spokenIndex = lastSpokenId ? messages.findLastIndex(m => m.id === lastSpokenId) : -1 + let spokenIndex = lastSpokenId ? messages.findLastIndex(m => m.id === lastSpokenId) : -1 + + if (spokenIndex < 0) { + const lastUser = messages.findLastIndex(m => m.role === 'user' && !m.hidden) + + if (lastUser >= 0) { + spokenIndex = lastUser + } + } let id: string | null = null let pending = false diff --git a/apps/desktop/src/lib/spoken-reply.test.ts b/apps/desktop/src/lib/spoken-reply.test.ts index 2f5270f8f4..bc8f1bead5 100644 --- a/apps/desktop/src/lib/spoken-reply.test.ts +++ b/apps/desktop/src/lib/spoken-reply.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { absorbSpokenReplyRewrite, + adoptSpokenReplySession, assistantReplyOrdinal, clearSpokenRepliesForTests, isLiveTailReplyId, @@ -90,3 +91,20 @@ describe('resolveSpokenReply', () => { expect(spoken?.ordinal).toBe(0) }) }) + +describe('adoptSpokenReplySession', () => { + it('moves the null-session anchor onto the created session id', () => { + markAssistantIdSpoken(null, [assistant('a1')], 'a1') + adoptSpokenReplySession(null, 'session-created') + + expect(spokenReplyOf('session-created')?.id).toBe('a1') + }) + + it('does not leak a spoken anchor from one real session into another', () => { + markAssistantIdSpoken('session-a', [assistant('a1')], 'a1') + adoptSpokenReplySession('session-a', 'session-b') + + expect(spokenReplyOf('session-b')).toBeNull() + expect(spokenReplyOf('session-a')?.id).toBe('a1') + }) +}) diff --git a/apps/desktop/src/lib/spoken-reply.ts b/apps/desktop/src/lib/spoken-reply.ts index fe275824d2..57ca91108e 100644 --- a/apps/desktop/src/lib/spoken-reply.ts +++ b/apps/desktop/src/lib/spoken-reply.ts @@ -111,6 +111,31 @@ export function markAssistantIdSpoken( markSpokenReply(sessionId, { id, ordinal }) } +/** + * Carry the spoken anchor when a chat gets a real session id (null → created) + * mid voice-conversation. Do not copy across two real sessions — that would + * leak "already spoken" into a different transcript. + */ +export function adoptSpokenReplySession( + fromSessionId: string | null | undefined, + toSessionId: string | null | undefined +): void { + const fromKey = sessionKey(fromSessionId) + const toKey = sessionKey(toSessionId) + + if (fromKey === toKey || fromKey !== NO_SESSION) { + return + } + + const from = lastSpokenBySession.get(fromKey) + + if (!from || lastSpokenBySession.has(toKey)) { + return + } + + lastSpokenBySession.set(toKey, from) +} + /** Current spoken anchor, migrated in place when the live row was rewritten. */ export function resolveSpokenReply( sessionId: string | null | undefined,