fix(desktop): keep auto-speak silent across the stream-id rewrite

Supersedes #75649, #86637, #87672, #88642.

Fixes #86601
Fixes #87652
Fixes #87823

Co-authored-by: Charmmy <lilShawtty@qq.com>
Co-authored-by: chelsealong <chelsealong@126.com>
Co-authored-by: Olympusbuildz <Olympus.roots@outlook.com>
Co-authored-by: Ricardo Mendes <ricardo.mendes@maiolabs.ai>
This commit is contained in:
brooklyn!
2026-08-19 02:56:55 -05:00
committed by GitHub
parent 4180c3f326
commit 63565fa26b
4 changed files with 238 additions and 5 deletions
@@ -4,6 +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 { clearWakeIndicator, syncWakeIndicatorWithVoice } from '@/lib/wake-indicator'
import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer'
import { resetBrowseState } from '@/store/composer-input-history'
@@ -62,7 +63,6 @@ export function useComposerVoice({
// A tile's composer speaks ITS transcript, not the primary chat's.
const { $messages } = useComposerScope()
const [voiceConversationActive, setVoiceConversationActive] = useState(false)
const lastSpokenIdRef = useRef<string | null>(null)
const ownsWakeIndicatorRef = useRef(false)
const voiceStartRequest = useStore($voiceConversationStartRequest)
@@ -77,8 +77,9 @@ export function useComposerVoice({
const pendingResponse = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
const spoken = resolveSpokenReply(sessionId, messages)
if (!last || last.id === lastSpokenIdRef.current) {
if (!last || last.id === spoken?.id) {
return null
}
@@ -100,14 +101,18 @@ export function useComposerVoice({
* in order — narration interims AND the final answer, not just whichever
* bubble happens to be last. See `collectUnspokenTurnSpeech`.
*/
const pendingTurnResponse = () => collectUnspokenTurnSpeech($messages.get(), lastSpokenIdRef.current)
const pendingTurnResponse = () => {
const messages = $messages.get()
return collectUnspokenTurnSpeech(messages, resolveSpokenReply(sessionId, messages)?.id ?? null)
}
const consumePendingResponse = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
if (last) {
lastSpokenIdRef.current = last.id
markAssistantIdSpoken(sessionId, messages, last.id)
}
}
@@ -9,6 +9,7 @@ import {
import { useStore } from '@nanostores/react'
import { type FC, useCallback, useMemo, useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { ChangedFilesCard } from '@/components/assistant-ui/thread/changed-files-card'
import {
contentHasVisibleText,
@@ -30,6 +31,7 @@ import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, GitForkIcon, Loader2Icon, RefreshCwIcon, SmilePlusIcon, VolumeXIcon, XIcon } from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets'
import { markAssistantIdSpoken } from '@/lib/spoken-reply'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
@@ -342,6 +344,8 @@ const ReadAloudButton: FC<{ getText: () => string; messageId: string }> = ({ get
const { t } = useI18n()
const copy = t.assistant.thread
const voicePlayback = useStore($voicePlayback)
const view = useSessionView()
const sessionId = useStore(view.$runtimeId)
const readAloudStatus =
voicePlayback.source === 'read-aloud' && voicePlayback.messageId === messageId ? voicePlayback.status : 'idle'
@@ -361,10 +365,11 @@ const ReadAloudButton: FC<{ getText: () => string; messageId: string }> = ({ get
try {
await playSpeechText(text, { messageId, source: 'read-aloud' })
markAssistantIdSpoken(sessionId, view.$messages.get(), messageId)
} catch (error) {
notifyError(error, copy.readAloudFailed)
}
}, [copy.readAloudFailed, getText, messageId])
}, [copy.readAloudFailed, getText, messageId, sessionId, view.$messages])
return (
<TooltipIconButton
+92
View File
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
absorbSpokenReplyRewrite,
assistantReplyOrdinal,
clearSpokenRepliesForTests,
isLiveTailReplyId,
markAssistantIdSpoken,
resolveSpokenReply,
spokenReplyOf
} from './spoken-reply'
const assistant = (id: string) => ({ id, role: 'assistant' as const })
const user = (id: string) => ({ id, role: 'user' as const })
const hidden = (id: string) => ({ hidden: true, id, role: 'assistant' as const })
afterEach(() => {
clearSpokenRepliesForTests()
})
describe('isLiveTailReplyId', () => {
it('matches renderer stream and inflight ids only', () => {
expect(isLiveTailReplyId('assistant-stream-s1')).toBe(true)
expect(isLiveTailReplyId('inflight-assistant-9')).toBe(true)
expect(isLiveTailReplyId('42')).toBe(false)
expect(isLiveTailReplyId('1770-3-assistant')).toBe(false)
})
})
describe('assistantReplyOrdinal', () => {
it('counts visible assistant bubbles and skips hidden ones', () => {
const messages = [user('u1'), assistant('a1'), hidden('skip'), assistant('a2')]
expect(assistantReplyOrdinal(messages, 'a1')).toBe(0)
expect(assistantReplyOrdinal(messages, 'a2')).toBe(1)
expect(assistantReplyOrdinal(messages, 'missing')).toBe(-1)
})
})
describe('absorbSpokenReplyRewrite', () => {
it('stays silent when the live-tail id is rewritten at the same ordinal', () => {
const spoken = { id: 'assistant-stream-s', ordinal: 1 }
const after = [user('u1'), assistant('a0'), assistant('42')]
expect(absorbSpokenReplyRewrite(spoken, after)).toEqual({ id: '42', ordinal: 1 })
})
it('does not treat a later same-slot-looking turn as the rewrite when ordinal moved', () => {
const spoken = { id: 'assistant-stream-s', ordinal: 0 }
const after = [user('u1'), assistant('durable-1'), user('u2'), assistant('assistant-stream-next')]
expect(absorbSpokenReplyRewrite(spoken, after)).toEqual(spoken)
})
it('does not migrate a durable id that simply vanished', () => {
const spoken = { id: 'durable-old', ordinal: 0 }
const after = [assistant('durable-new')]
expect(absorbSpokenReplyRewrite(spoken, after)).toEqual(spoken)
})
it('keeps the anchor when the spoken id is still in the list', () => {
const spoken = { id: 'assistant-stream-s', ordinal: 0 }
const messages = [assistant('assistant-stream-s')]
expect(absorbSpokenReplyRewrite(spoken, messages)).toBe(spoken)
})
})
describe('resolveSpokenReply', () => {
it('migrates per session and does not leak across sessions', () => {
const before = [assistant('assistant-stream-s')]
markAssistantIdSpoken('session-a', before, 'assistant-stream-s')
const after = [assistant('42')]
expect(resolveSpokenReply('session-a', after)?.id).toBe('42')
expect(spokenReplyOf('session-b')).toBeNull()
expect(resolveSpokenReply('session-b', after)).toBeNull()
})
it('lets a second turn at the next ordinal stay unspoken', () => {
markAssistantIdSpoken('s', [assistant('assistant-stream-1')], 'assistant-stream-1')
resolveSpokenReply('s', [assistant('durable-1')])
const nextTurn = [assistant('durable-1'), assistant('assistant-stream-2')]
const spoken = resolveSpokenReply('s', nextTurn)
expect(spoken?.id).toBe('durable-1')
expect(assistantReplyOrdinal(nextTurn, 'assistant-stream-2')).toBe(1)
expect(spoken?.ordinal).toBe(0)
})
})
+131
View File
@@ -0,0 +1,131 @@
/**
* Spoken-reply identity for Desktop auto-speak / Read Aloud.
*
* The live assistant row id (`assistant-stream-*`, `inflight-assistant-*`) is
* not stable: hydrate rewrites that row under its durable backend id. Keying
* "already spoken" on id alone then re-reads the same turn at the playback-idle
* edge. A content fingerprint would swallow a later distinct turn that happens
* to say the same thing ("Done.").
*
* Anchor on the assistant-role ordinal (nth visible assistant bubble). The
* rewrite keeps that slot; a new turn appends and the ordinal moves.
*/
export interface SpokenReplyAnchor {
id: string
ordinal: number
}
export interface SpokenReplyMessage {
hidden?: boolean
id: string
role: string
}
const NO_SESSION = '\0'
const lastSpokenBySession = new Map<string, SpokenReplyAnchor>()
export function isLiveTailReplyId(id: string): boolean {
return id.startsWith('assistant-stream-') || id.startsWith('inflight-assistant-')
}
function sessionKey(sessionId: string | null | undefined): string {
return sessionId ?? NO_SESSION
}
export function assistantReplyOrdinal(messages: readonly SpokenReplyMessage[], id: string): number {
let ordinal = -1
for (const message of messages) {
if (message.role !== 'assistant' || message.hidden) {
continue
}
ordinal += 1
if (message.id === id) {
return ordinal
}
}
return -1
}
function lastVisibleAssistant(messages: readonly SpokenReplyMessage[]): SpokenReplyMessage | undefined {
return messages.findLast(message => message.role === 'assistant' && !message.hidden)
}
/** If a spoken live-tail row vanished and the same assistant slot now has a
* durable id, migrate the anchor. Leave durable ids and later turns alone. */
export function absorbSpokenReplyRewrite(
spoken: SpokenReplyAnchor | null,
messages: readonly SpokenReplyMessage[]
): SpokenReplyAnchor | null {
if (!spoken) {
return null
}
if (assistantReplyOrdinal(messages, spoken.id) >= 0) {
return spoken
}
if (!isLiveTailReplyId(spoken.id)) {
return spoken
}
const last = lastVisibleAssistant(messages)
if (!last) {
return spoken
}
const ordinal = assistantReplyOrdinal(messages, last.id)
if (ordinal !== spoken.ordinal) {
return spoken
}
return { id: last.id, ordinal }
}
export function spokenReplyOf(sessionId: string | null | undefined): SpokenReplyAnchor | null {
return lastSpokenBySession.get(sessionKey(sessionId)) ?? null
}
function markSpokenReply(sessionId: string | null | undefined, anchor: SpokenReplyAnchor): void {
lastSpokenBySession.set(sessionKey(sessionId), anchor)
}
export function markAssistantIdSpoken(
sessionId: string | null | undefined,
messages: readonly SpokenReplyMessage[],
id: string
): void {
const ordinal = assistantReplyOrdinal(messages, id)
if (ordinal < 0) {
return
}
markSpokenReply(sessionId, { id, ordinal })
}
/** Current spoken anchor, migrated in place when the live row was rewritten. */
export function resolveSpokenReply(
sessionId: string | null | undefined,
messages: readonly SpokenReplyMessage[]
): SpokenReplyAnchor | null {
const current = spokenReplyOf(sessionId)
const next = absorbSpokenReplyRewrite(current, messages)
if (next && next.id !== current?.id) {
markSpokenReply(sessionId, next)
}
return next
}
export function clearSpokenRepliesForTests(): void {
lastSpokenBySession.clear()
}