fix(desktop): start voice on wake via a latched store, not a window event

Wake opened a fresh session but voice didn't start: the start intent was
a fire-once window CustomEvent, and the fresh-session remount tore down /
recreated the composer's subscription, so the deferred dispatch landed in
the gap and was lost.

Replace it with a latched nanostore ($voiceConversationStartRequest +
takeVoiceConversationStart): the controller sets it on wake.detected, and
the composer claims it once on (re)mount when the gateway is open, waiting
out any transient `disabled`. Drop the now-unused composer voice-start
window event.
This commit is contained in:
Brooklyn Nicholson 2026-06-27 00:38:34 -05:00 committed by Teknium
parent dc4c2414f9
commit c597b4c47b
No known key found for this signature in database
4 changed files with 36 additions and 21 deletions

View file

@ -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.
*

View file

@ -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<string | null>(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(() => {

View file

@ -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<typeof handleDesktopGatewayEvent>[0]) => {
if (event.type === 'wake.detected') {
startFreshSessionDraft()
requestVoiceStart()
requestVoiceConversationStart()
return
}
handleDesktopGatewayEvent(event)

View file

@ -21,6 +21,25 @@ export const $composerDraft = atom('')
export const $composerAttachments = atom<ComposerAttachment[]>([])
export const $composerTerminalSelections = atom<Record<string, string>>({})
// 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