diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 7bef1e82767..364ee6d3b83 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/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({ ) : ( )} + {showVoicePrimary ? ( + + ) +} + function DictationButton({ disabled, state, diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 379b6732a88..796e773153e 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/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 = (