feat(desktop): add read-replies-aloud toggle and wire auto-speak

This commit is contained in:
Brooklyn Nicholson 2026-06-29 15:22:37 -05:00
parent fcdc05c891
commit 596b813c9b
2 changed files with 68 additions and 3 deletions

View file

@ -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,

View file

@ -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}
/>