fix(desktop): the wake-word ear ALWAYS shows — never hide it

Teknium: the ear must always be visible so a user can click to enable
passive listening. If it can't start (missing STT/TTS, deps still
installing, no mic permission), the click surfaces the reason in the
tooltip and the toggle stays off — but the control never disappears.

Removes the 'if (!wake.available && !wake.enabled) return null' hide
branch that made the button vanish on machines where a requirements
probe returned false (the Windows report). The only non-idle state is
paused-for-voice (disabled, in the voice-chat pill), since an active
voice conversation genuinely holds the mic.

Tests updated: 'stays visible when unavailable and not enabled' and
'surfaces the refusal reason in the tooltip, still visible' replace the
old hide assertion. 30 vitest green, tsc + eslint clean.
This commit is contained in:
Teknium 2026-07-27 20:31:07 -07:00
parent 1398cc40cd
commit 74fae07d75
No known key found for this signature in database
3 changed files with 23 additions and 15 deletions

View file

@ -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', () => {

View file

@ -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)

View file

@ -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()