mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
dc4c2414f9
commit
c597b4c47b
4 changed files with 36 additions and 21 deletions
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue