diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index 3ddc096156c..7120fde2b1a 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -42,7 +42,6 @@ const INSERT_EVENT = 'hermes:composer-insert' const INSERT_REFS_EVENT = 'hermes:composer-insert-refs' const SUBMIT_EVENT = 'hermes:composer-submit' const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle' -const VOICE_START_EVENT = 'hermes:composer-voice-start' interface SubmitDetail { target: ComposerTarget @@ -151,14 +150,6 @@ export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active') export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) => subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target)) -/** Explicitly START (never stop) the active composer's voice conversation — - * used by the "Hey Hermes" wake word so a fresh session begins back-and-forth - * voice without the toggle risking an immediate stop. */ -export const requestVoiceStart = () => dispatch<{ at: number }>(VOICE_START_EVENT, { at: Date.now() }) - -export const onComposerVoiceStartRequest = (handler: () => void) => - subscribe<{ at: number }>(VOICE_START_EVENT, () => handler()) - /** * Focus a composer input across React commit + browser focus restore. * 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 e2bc1b0382c..1223f1bbd57 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 @@ -1,15 +1,17 @@ +import { useStore } from '@nanostores/react' import { useCallback, useEffect, useRef, useState } from 'react' import { useI18n } from '@/i18n' import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages' import { triggerHaptic } from '@/lib/haptics' +import { $voiceConversationStartRequest, takeVoiceConversationStart } from '@/store/composer' import { resetBrowseState } from '@/store/composer-input-history' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs' import type { ComposerTarget } from '../focus' -import { onComposerVoiceStartRequest, onComposerVoiceToggleRequest } from '../focus' +import { onComposerVoiceToggleRequest } from '../focus' import { useComposerScope } from '../scope' import type { ChatBarProps } from '../types' @@ -55,6 +57,7 @@ export function useComposerVoice({ const { $messages } = useComposerScope() const [voiceConversationActive, setVoiceConversationActive] = useState(false) const lastSpokenIdRef = useRef(null) + const voiceStartRequest = useStore($voiceConversationStartRequest) const { dictate, voiceActivityState, voiceStatus } = useVoiceRecorder({ focusInput, @@ -143,15 +146,16 @@ export function useComposerVoice({ [target, toggleVoiceConversation] ) - useEffect( - () => - onComposerVoiceStartRequest(() => { - if (target === 'main' && !disabled && !voiceConversationActive) { - setVoiceConversationActive(true) - } - }), - [disabled, target, voiceConversationActive] - ) + useEffect(() => { + if ( + target === 'main' && + !disabled && + takeVoiceConversationStart(voiceStartRequest) && + !voiceConversationActive + ) { + setVoiceConversationActive(true) + } + }, [disabled, target, voiceConversationActive, voiceStartRequest]) const wakePausedRef = useRef(false) const resumeWakeIfPaused = useCallback(() => { diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index c6d9086608b..72cd6d10a4a 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -52,6 +52,7 @@ import { setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay' +import { requestVoiceConversationStart } from '../store/composer' import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview' import { $activeGatewayProfile, @@ -105,7 +106,7 @@ import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store import { isSecondaryWindow } from '../store/windows' import { ChatView } from './chat' -import { requestComposerFocus, requestComposerInsert, requestVoiceStart } from './chat/composer/focus' +import { requestComposerFocus, requestComposerInsert } from './chat/composer/focus' import { useComposerActions } from './chat/hooks/use-composer-actions' import { ChatPreviewRail, @@ -746,7 +747,7 @@ export function DesktopController() { (event: Parameters[0]) => { if (event.type === 'wake.detected') { startFreshSessionDraft() - requestVoiceStart() + requestVoiceConversationStart() return } handleDesktopGatewayEvent(event) diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index b984fe8bfc3..a9df29b6df7 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -21,6 +21,25 @@ export const $composerDraft = atom('') export const $composerAttachments = atom([]) export const $composerTerminalSelections = atom>({}) +// Latched because opening a fresh session may remount the main composer before +// it can start voice. Session-tile composers deliberately never consume this. +export const $voiceConversationStartRequest = atom(0) +let nextVoiceStartRequest = 0 +let handledVoiceStartRequest = 0 + +export const requestVoiceConversationStart = (): void => + $voiceConversationStartRequest.set(++nextVoiceStartRequest) + +export const takeVoiceConversationStart = (current: number): boolean => { + if (current <= handledVoiceStartRequest) { + return false + } + + handledVoiceStartRequest = current + + return true +} + // --------------------------------------------------------------------------- // Composer scopes — one live attachment set PER MOUNTED COMPOSER. The main // chat's scope wraps the module-level atom above (all existing readers keep