Auto-TTS toggle for reading responses aloud
Cluster 43767 · apps/desktop. Data as of
Nothing open on GitHub. Fix merged in #55154 on 29 Jun 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #55154. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items all propose an automatic text-to-speech feature in the desktop composer that reads AI responses aloud, intended for hands-free use. A toggle in the composer UI is a common element across the proposals.
Open issues0
Open PRs0
Fixes on record
-
#55154 feat(desktop): read replies aloud (auto-TTS) composer toggle
files:
apps/desktop/src/app/chat/composer/controls.tsx,apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts,apps/desktop/src/app/chat/composer/index.tsx,apps/desktop/src/app/session/hooks/use-hermes-config.ts,apps/desktop/src/i18n/en.ts,apps/desktop/src/i18n/ja.ts,apps/desktop/src/i18n/types.ts,apps/desktop/src/i18n/zh-hant.ts,apps/desktop/src/i18n/zh.ts,apps/desktop/src/store/voice-prefs.ts,apps/desktop/src/types/hermes.tsShow the diff
-
apps/desktop/src/app/chat/composer/controls.tsx@@ -4,7 +4,7 @@ import { KbdCombo } from '@/components/ui/kbd' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons' +import { AudioLines, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons' import { formatCombo } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' @@ -39,6 +39,7 @@ interface ConversationProps { } export function ComposerControls({ + autoSpeak, busy, busyAction, canSteer, @@ -50,8 +51,10 @@ export function ComposerControls({ state, voiceStatus, onDictate, - onSteer + onSteer, + onToggleAutoSpeak }: { + autoSpeak: boolean busy: boolean busyAction: 'queue' | 'stop' canSteer: boolean @@ -64,6 +67,7 @@ export function ComposerControls({ voiceStatus: VoiceStatus onDictate: () => void onSteer: () => void + onToggleAutoSpeak: () => void }) { const { t } = useI18n() const c = t.composer @@ -105,6 +109,7 @@ export function ComposerControls({ ) : ( <DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} /> )} + <AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} /> {showVoicePrimary ? ( <Tip label={c.startVoice}> <Button @@ -254,6 +259,47 @@ function ConversationIndicator({ ) } +// Pure-TTS toggle: type normally, but have every assistant reply read aloud — +// no dictation, no full conversation loop. Filled/accent when on, mirroring the +// muted-mic pressed state above. Driven by (and persisted to) `voice.auto_tts`. +function AutoSpeakButton({ + active, + disabled, + onToggle +}: { + active: boolean + disabled: boolean + onToggle: () => void +}) { + const { t } = useI18n() + const c = t.composer + const label = active ? c.stopSpeakingReplies : c.speakReplies + + return ( + <Tip label={label}> + <Button + aria-label={label} + aria-pressed={active} + className={cn( + GHOST_ICON_BTN, + 'p-0', + active && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary' + )} + disabled={disabled} + onClick={() => { + triggerHaptic(active ? 'close' : 'open') + onToggle() + }} + size="icon" + type="button" + variant="ghost" + > + {active ? <Volume2 size={14} /> : <VolumeX size={14} />} + </Button> + </Tip> + ) +} + function DictationButton({ disabled, state, -
apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts@@ -0,0 +1,79 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useRef } from 'react' + +import { playSpeechText } from '@/lib/voice-playback' +import { notifyError } from '@/store/notifications' +import { $messages } from '@/store/session' +import { $voicePlayback } from '@/store/voice-playback' +import { $autoSpeakReplies } from '@/store/voice-prefs' + +interface AutoSpeakReply { + id: string + pending: boolean + text: string +} + +interface UseAutoSpeakReplies { + conversationActive: boolean + failureLabel: string + /** Mark the current last reply spoken — shared dedupe with the conversation consumer. */ + markSpoken: () => void + /** Latest completed assistant reply, or null; `pending` true while still streaming. */ + pendingReply: () => AutoSpeakReply | null + /** Re-arm on session switch so opening a chat never reads its existing last reply. */ + sessionId: string | null | undefined +} + +/** + * Pure-TTS auto-speak: when `voice.auto_tts` is on, read each completed assistant + * turn aloud — no dictation, no conversation loop. Stays off while a full voice + * conversation runs (it speaks replies itself) and never overlaps clips: a reply + * landing mid-playback is held and spoken on the playback-idle edge. Always reads + * the latest reply, so a backlog collapses to the newest. + */ +export function useAutoSpeakReplies({ + conversationActive, + failureLabel, + markSpoken, + pendingReply, + sessionId +}: UseAutoSpeakReplies) { + const enabled = useStore($autoSpeakReplies) + const latest = useRef({ conversationActive, failureLabel, markSpoken, pendingReply }) + latest.current = { conversationActive, failureLabel, markSpoken, pendingReply } + + useEffect(() => { + if (!enabled) { + return undefined + } + + // Don't read whatever reply already sits at the bottom when the toggle flips + // on (or a chat opens) — consume it so only later replies are spoken. + latest.current.markSpoken() + + const speakLatest = () => { + const { conversationActive, failureLabel, markSpoken, pendingReply } = latest.current + + if (conversationActive || $voicePlayback.get().status !== 'idle') { + return + } + + const reply = pendingReply() + + if (!reply || reply.pending) { + return + } + + markSpoken() + void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error => + notifyError(error, failureLabel) + ) + } + + // Re-check on a reply completing ($messages) and on the prior clip ending + // ($voicePlayback → idle), which frees us to read the next held reply. + const stops = [$messages.subscribe(speakLatest), $voicePlayback.listen(speakLatest)] + + return () => stops.forEach(f => f()) + }, [enabled, sessionId]) +} -
apps/desktop/src/app/chat/composer/index.tsx@@ -60,13 +60,14 @@ import { updateQueuedPrompt } from '@/store/composer-queue' import { $statusItemsBySession } from '@/store/composer-status' -import { notify } from '@/store/notifications' +import { notify, notifyError } from '@/store/notifications' import { $previewStatusBySession } from '@/store/preview-status' import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects' import { $activeSessionAwaitingInput } from '@/store/prompts' import { toggleReview } from '@/store/review' import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session' import { $threadScrolledUp } from '@/store/thread-scroll' +import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' import { isSecondaryWindow } from '@/store/windows' import { useTheme } from '@/themes' @@ -88,6 +89,7 @@ import { } from './focus' import { HelpHint } from './help-hint' import { useAtCompletions } from './hooks/use-at-completions' +import { useAutoSpeakReplies } from './hooks/use-auto-speak-replies' import { useComposerPopoutGestures } from './hooks/use-popout-drag' import { useSlashCompletions } from './hooks/use-slash-completions' import { useVoiceConversation } from './hooks/use-voice-conversation' @@ -230,6 +232,7 @@ export function ChatBar({ const statusItemsBySession = useStore($statusItemsBySession) const previewStatusBySession = useStore($previewStatusBySession) const scrolledUp = useStore($threadScrolledUp) + const autoSpeak = useStore($autoSpeakReplies) // The turn is parked on the user (clarify / approval / sudo / secret). Esc must // not interrupt it — there's nothing actively running to stop, and stopping // would discard a question the user may want to come back to. The blocking @@ -2021,6 +2024,20 @@ export function ChatBar({ useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation]) + const handleToggleAutoSpeak = useCallback(() => { + void setAutoSpeakReplies(!$autoSpeakReplies.get()).catch(error => + notifyError(error, t.settings.config.autosaveFailed) + ) + }, [t]) + + useAutoSpeakReplies({ + conversationActive: voiceConversationActive, + failureLabel: t.assistant.thread.readAloudFailed, + markSpoken: consumePendingResponse, + pendingReply: pendingResponse, + sessionId + }) + const contextMenu = ( <ContextMenu onInsertText={insertText} @@ -2038,6 +2055,7 @@ export function ChatBar({ const controls = ( <ComposerControls + autoSpeak={autoSpeak} busy={busy} busyAction={busyAction} canSteer={canSteer} @@ -2060,6 +2078,7 @@ export function ChatBar({ hasComposerPayload={hasComposerPayload} onDictate={dictate} onSteer={steerDraft} + onToggleAutoSpeak={handleToggleAutoSpeak} state={state} voiceStatus={voiceStatus} /> -
apps/desktop/src/app/session/hooks/use-hermes-config.ts@@ -12,6 +12,7 @@ import { setCurrentServiceTier, setIntroPersonality } from '@/store/session' +import { applyAutoSpeakFromConfig } from '@/store/voice-prefs' const DEFAULT_VOICE_SECONDS = 120 const FAST_TIERS = new Set(['fast', 'priority', 'on']) @@ -65,6 +66,7 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds)) setSttEnabled(config.stt?.enabled !== false) + applyAutoSpeakFromConfig(config) } catch { // Config is nice-to-have; chat still works without it. } -
apps/desktop/src/i18n/en.ts@@ -1434,6 +1434,8 @@ export const en: Translations = { stopDictation: 'Stop dictation', transcribingDictation: 'Transcribing dictation', voiceDictation: 'Voice dictation', + speakReplies: 'Read replies aloud', + stopSpeakingReplies: 'Stop reading replies aloud', lookupLoading: 'Looking up…', lookupNoMatches: 'No matches.', lookupTry: 'Try', -
apps/desktop/src/i18n/ja.ts@@ -1551,6 +1551,8 @@ export const ja = defineLocale({ stopDictation: '口述を停止', transcribingDictation: '口述を文字起こし中', voiceDictation: '音声口述', + speakReplies: '返信を読み上げる', + stopSpeakingReplies: '返信の読み上げを停止', lookupLoading: '検索中…', lookupNoMatches: '一致なし。', lookupTry: '試す', -
apps/desktop/src/i18n/types.ts@@ -1162,6 +1162,8 @@ export interface Translations { stopDictation: string transcribingDictation: string voiceDictation: string + speakReplies: string + stopSpeakingReplies: string lookupLoading: string lookupNoMatches: string lookupTry: string -
apps/desktop/src/i18n/zh-hant.ts@@ -1502,6 +1502,8 @@ export const zhHant = defineLocale({ stopDictation: '停止聽寫', transcribingDictation: '正在轉寫聽寫', voiceDictation: '語音聽寫', + speakReplies: '朗讀回覆', + stopSpeakingReplies: '停止朗讀回覆', lookupLoading: '查詢中…', lookupNoMatches: '沒有相符項目。', lookupTry: '試試', -
apps/desktop/src/i18n/zh.ts@@ -1613,6 +1613,8 @@ export const zh: Translations = { stopDictation: '停止听写', transcribingDictation: '正在转写听写', voiceDictation: '语音听写', + speakReplies: '朗读回复', + stopSpeakingReplies: '停止朗读回复', lookupLoading: '查找中…', lookupNoMatches: '没有匹配项。', lookupTry: '试试', -
apps/desktop/src/store/voice-prefs.ts@@ -0,0 +1,38 @@ +import { atom } from 'nanostores' + +import { getHermesConfigRecord, saveHermesConfig } from '@/hermes' + +// "Read replies aloud" — mirrors the canonical `voice.auto_tts` config key (also +// in Settings → Voice, honored by the messaging gateway) so the composer toggle +// and the Settings switch are one source of truth, not two that can disagree. +export const $autoSpeakReplies = atom<boolean>(false) + +/** Seed the atom from a loaded config payload (mount / refresh). */ +export function applyAutoSpeakFromConfig(config: { voice?: { auto_tts?: unknown } | null } | null | undefined) { + $autoSpeakReplies.set(Boolean(config?.voice?.auto_tts)) +} + +/** + * Flip the preference and persist it. Optimistic — the atom updates instantly and + * reverts if the config write fails. Read-modify-writes the whole record (the + * same path the Settings page uses; there's no partial-update endpoint). + */ +export async function setAutoSpeakReplies(enabled: boolean): Promise<void> { + const previous = $autoSpeakReplies.get() + + if (previous === enabled) { + return + } + + $autoSpeakReplies.set(enabled) + + try { + const record = await getHermesConfigRecord() + const voice = record.voice && typeof record.voice === 'object' ? (record.voice as Record<string, unknown>) : {} + + await saveHermesConfig({ ...record, voice: { ...voice, auto_tts: enabled } }) + } catch (error) { + $autoSpeakReplies.set(previous) + throw error + } +} -
apps/desktop/src/types/hermes.ts@@ -224,6 +224,7 @@ export interface HermesConfig { } voice?: { max_recording_seconds?: number + auto_tts?: boolean } }
-
1 PR closed without merging
From the close comments: done elsewhere 1
-
#43845 feat(desktop): auto-TTS toggle in composer for hands-free response readback · done elsewhere →
596b813,#55154
Closed issues · 1
- Closed#43767feat(desktop): auto-TTS / read-aloud on every response (keyboard input + spoken output)
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- apps/desktop
- Merged PRs
- #55154
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 29 Jun 2026