diff --git a/apps/desktop/src/app/chat/composer/controls.test.tsx b/apps/desktop/src/app/chat/composer/controls.test.tsx index b35c280c732d..8a84be8ea477 100644 --- a/apps/desktop/src/app/chat/composer/controls.test.tsx +++ b/apps/desktop/src/app/chat/composer/controls.test.tsx @@ -100,11 +100,22 @@ describe('wake-word ear visibility', () => { expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy() }) - it('hides only when unavailable AND not enabled in config', () => { + it('stays visible (never hides) even when unavailable and not enabled', () => { applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' }) renderControls() - expect(screen.queryByLabelText(/Wake word/)).toBeNull() + // The ear ALWAYS shows so the user can click to enable; a failed start + // surfaces its reason in the tooltip rather than hiding the control. + expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy() + }) + + it('surfaces the backend refusal reason in the tooltip, still visible', () => { + applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' }) + applyWakeStartResult({ hint: 'run `hermes tools` (Voice section)', reason: 'unavailable', started: false }) + renderControls() + + const ear = screen.getByLabelText('Wake word: "hey hermes" — off') + expect(ear).toBeTruthy() }) it('shows a disabled paused ear inside the voice-conversation pill', () => { diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 6e2ec91f49c1..7c1a90df4de2 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -301,23 +301,20 @@ function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disa ) } -// "Hey Hermes" wake-word toggle. States: listening (accent-highlighted, like -// the auto-speak toggle above), off (muted ear-off), paused-for-voice (shown -// disabled while a voice conversation holds the mic — the one legitimate -// pause), and hidden — only when the feature can't run AND isn't enabled in -// config. `enabled` keeps the ear mounted through transient refusals and busy -// agent turns, so a persistent setting never silently vanishes mid-session. -// Backend refusals ({started:false, reason}) keep the toggle off and surface -// the reason/hint in the tooltip. +// "Hey Hermes" wake-word toggle. ALWAYS rendered — the ear never hides. A +// user must always be able to click it to turn passive listening on; if the +// backend can't start (missing STT/TTS, deps still installing, no mic +// permission, etc.) the click surfaces the reason in the tooltip and the +// toggle stays off. States: listening (accent-highlighted), off (muted +// ear-off), and paused-for-voice (disabled while a voice conversation holds +// the mic — the one time wake genuinely must not listen). Backend refusals +// ({started:false, reason}) keep the toggle off and put the reason/hint in +// the tooltip. function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolean; pausedForVoice?: boolean }) { const { t } = useI18n() const c = t.composer const wake = useStore($wakeWord) - if (!wake.available && !wake.enabled) { - return null - } - const phrase = wake.phrase || 'hey hermes' const label = pausedForVoice ? c.wakeWordPausedVoice(phrase) diff --git a/apps/desktop/src/store/wake-word.test.ts b/apps/desktop/src/store/wake-word.test.ts index 2234d468a7df..f69321e3f5b3 100644 --- a/apps/desktop/src/store/wake-word.test.ts +++ b/apps/desktop/src/store/wake-word.test.ts @@ -39,7 +39,7 @@ describe('applyWakeStatus', () => { }) }) - it('keeps the button hidden and carries the hint when unavailable', () => { + it('tracks unavailability and carries the hint for the tooltip', () => { applyWakeStatus({ available: false, hint: 'pip install openwakeword', listening: false, phrase: 'hey hermes' }) const state = $wakeWord.get()