fix(desktop): keep the wake-word ear mounted everywhere — paused only during voice chat

The ear vanished whenever a voice conversation ran (the ConversationPill
replaces the whole controls row) and whenever a transient start refusal
marked the feature unavailable — so a persistent, config-backed setting
silently disappeared mid-session. The wake word is passive by design:
it should be visibly listening no matter what the GUI is doing, with
exactly one pause state — an active voice chat holding the mic.

- ConversationPill now renders the ear in paused form (disabled, EarOff,
  'paused during voice chat' tooltip) so voice chat shows the listener
  yielding the mic instead of the toggle vanishing.
- WakeWordButton hides only when the feature can't run AND isn't enabled
  in config; $wakeWord gains 'enabled' (config truth from wake.status /
  start/stop responses) so transient 'unavailable' refusals no longer
  unmount the button.
- Busy agent turns never touched the listener (it keeps listening
  through agent loops; wake.detected already opens a fresh session),
  and now they can't hide the toggle either.
- New i18n key wakeWordPausedVoice across en/ja/zh/zh-hant.

Tests: ear mounted during busy turn, mounted through refusal when
config-enabled, hidden when unavailable+disabled, paused ear disabled
inside the pill. 29 vitest green across controls + wake-word store.
This commit is contained in:
Teknium 2026-07-27 18:45:47 -07:00
parent f03bb2b4ef
commit 46faa4f639
No known key found for this signature in database
8 changed files with 87 additions and 15 deletions

View File

@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ChatBarState } from '@/app/chat/composer/types'
import { I18nProvider } from '@/i18n'
import { applyWakeStartResult, applyWakeStatus, resetWakeWordState } from '@/store/wake-word'
import { ComposerControls } from './controls'
@ -77,3 +78,51 @@ describe('ComposerControls shortcut tooltips', () => {
await expectShortcutTooltip('Queue message', 'Ctrl+↵')
})
})
describe('wake-word ear visibility', () => {
afterEach(() => {
resetWakeWordState()
})
it('stays mounted during a busy agent turn', () => {
applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' })
renderControls({ busy: true, busyAction: 'stop' })
expect(screen.getByLabelText('Wake word: "hey hermes" — listening')).toBeTruthy()
})
it('stays mounted (enabled in config) even when a start was refused', () => {
applyWakeStatus({ available: true, enabled: true, listening: false, phrase: 'hey hermes' })
// Transient refusal marks available false but enabled keeps it mounted.
applyWakeStartResult({ hint: 'mic busy', reason: 'unavailable', started: false })
renderControls()
expect(screen.getByLabelText('Wake word: "hey hermes" — off')).toBeTruthy()
})
it('hides only when unavailable AND not enabled in config', () => {
applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' })
renderControls()
expect(screen.queryByLabelText(/Wake word/)).toBeNull()
})
it('shows a disabled paused ear inside the voice-conversation pill', () => {
applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' })
renderControls({
conversation: {
active: true,
level: 0,
muted: false,
onEnd: vi.fn(),
onStart: vi.fn(),
onStopTurn: vi.fn(),
onToggleMute: vi.fn(),
status: 'listening'
}
})
const ear = screen.getByLabelText('Wake word: "hey hermes" — paused during voice chat')
expect((ear as HTMLButtonElement).disabled).toBe(true)
})
})

View File

@ -185,6 +185,9 @@ function ConversationPill({
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
{/* Keep the ear visible during voice chat shown paused, since the
conversation holds the mic (the one time wake must not listen). */}
<WakeWordButton disabled={disabled} pausedForVoice />
<Tip label={muted ? c.unmuteMic : c.muteMic}>
<Button
aria-label={muted ? c.unmuteMic : c.muteMic}
@ -298,35 +301,42 @@ function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disa
)
}
// "Hey Hermes" wake-word toggle. Three states: listening (accent-highlighted,
// like the auto-speak toggle above), off (muted ear-off), and unavailable —
// when the backend reports the wake word can't run, the button hides entirely
// so the row stays clean. Backend refusals ({started:false, reason}) keep the
// toggle off and surface the reason/hint in the tooltip.
function WakeWordButton({ disabled }: { disabled: boolean }) {
// "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.
function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolean; pausedForVoice?: boolean }) {
const { t } = useI18n()
const c = t.composer
const wake = useStore($wakeWord)
if (!wake.available) {
if (!wake.available && !wake.enabled) {
return null
}
const phrase = wake.phrase || 'hey hermes'
const label = wake.listening ? c.wakeWordListening(phrase) : c.wakeWordOff(phrase)
const tooltip = wake.notice ? `${label}${wake.notice}` : label
const label = pausedForVoice
? c.wakeWordPausedVoice(phrase)
: wake.listening
? c.wakeWordListening(phrase)
: c.wakeWordOff(phrase)
const tooltip = !pausedForVoice && wake.notice ? `${label}${wake.notice}` : label
return (
<Tip label={tooltip}>
<Button
aria-label={label}
aria-pressed={wake.listening}
aria-pressed={wake.listening && !pausedForVoice}
className={cn(
GHOST_ICON_BTN,
'p-0',
wake.listening && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
wake.listening && !pausedForVoice && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
)}
disabled={disabled || wake.pending}
disabled={disabled || pausedForVoice || wake.pending}
onClick={() => {
triggerHaptic(wake.listening ? 'close' : 'open')
void toggleWakeWord()
@ -335,7 +345,7 @@ function WakeWordButton({ disabled }: { disabled: boolean }) {
type="button"
variant="ghost"
>
{wake.listening ? <Ear className={iconSize.sm} /> : <EarOff className={iconSize.sm} />}
{wake.listening && !pausedForVoice ? <Ear className={iconSize.sm} /> : <EarOff className={iconSize.sm} />}
</Button>
</Tip>
)

View File

@ -1960,6 +1960,7 @@ export const en: Translations = {
stopSpeakingReplies: 'Stop reading replies aloud',
wakeWordListening: phrase => `Wake word: "${phrase}" — listening`,
wakeWordOff: phrase => `Wake word: "${phrase}" — off`,
wakeWordPausedVoice: phrase => `Wake word: "${phrase}" — paused during voice chat`,
lookupLoading: 'Looking up…',
lookupNoMatches: 'No matches.',
lookupTry: 'Try',

View File

@ -1819,6 +1819,7 @@ export const ja = defineLocale({
stopSpeakingReplies: '返信の読み上げを停止',
wakeWordListening: phrase => `ウェイクワード:「${phrase}」— 待機中`,
wakeWordOff: phrase => `ウェイクワード:「${phrase}」— オフ`,
wakeWordPausedVoice: phrase => `ウェイクワード:「${phrase}」— 音声チャット中は一時停止`,
lookupLoading: '検索中…',
lookupNoMatches: '一致なし。',
lookupTry: '試す',

View File

@ -1646,6 +1646,7 @@ export interface Translations {
stopSpeakingReplies: string
wakeWordListening: (phrase: string) => string
wakeWordOff: (phrase: string) => string
wakeWordPausedVoice: (phrase: string) => string
lookupLoading: string
lookupNoMatches: string
lookupTry: string

View File

@ -1762,6 +1762,7 @@ export const zhHant = defineLocale({
stopSpeakingReplies: '停止朗讀回覆',
wakeWordListening: phrase => `喚醒詞:「${phrase}」— 正在聆聽`,
wakeWordOff: phrase => `喚醒詞:「${phrase}」— 已關閉`,
wakeWordPausedVoice: phrase => `喚醒詞:「${phrase}」— 語音對話期間暫停`,
lookupLoading: '查詢中…',
lookupNoMatches: '沒有相符項目。',
lookupTry: '試試',

View File

@ -2153,6 +2153,7 @@ export const zh: Translations = {
stopSpeakingReplies: '停止朗读回复',
wakeWordListening: phrase => `唤醒词:"${phrase}" — 正在监听`,
wakeWordOff: phrase => `唤醒词:"${phrase}" — 已关闭`,
wakeWordPausedVoice: phrase => `唤醒词:"${phrase}" — 语音对话期间暂停`,
lookupLoading: '查找中…',
lookupNoMatches: '没有匹配项。',
lookupTry: '试试',

View File

@ -8,8 +8,10 @@ import { $gateway } from '@/store/gateway'
// cache of that truth, refreshed from every wake.* RPC response we see.
export interface WakeWordState {
/** Wake word can run at all (deps + mic + key). False hides the toggle. */
/** Wake word can run at all (deps + mic + key). With `enabled` false too, hides the toggle. */
available: boolean
/** Config truth (wake_word.enabled) — keeps the ear mounted through transient refusals. */
enabled: boolean
/** The listener is armed and owned by this surface. */
listening: boolean
/** Last failure reason/hint (start refused, unavailable, …) for the tooltip. */
@ -22,6 +24,7 @@ export interface WakeWordState {
const INITIAL_WAKE_WORD_STATE: WakeWordState = {
available: false,
enabled: false,
listening: false,
notice: '',
pending: false,
@ -115,6 +118,7 @@ export function applyWakeStatus(status: WakeStatusResponse | null | undefined):
$wakeWord.set({
...current,
available: Boolean(status?.available),
enabled: Boolean(status?.enabled),
listening,
notice: listening && !silent ? '' : noticeFrom(status),
phrase: status?.phrase?.trim() || current.phrase
@ -130,6 +134,7 @@ export function applyWakeStartResult(result: WakeStartResponse | null | undefine
$wakeWord.set({
...current,
available: true,
enabled: true,
listening: true,
notice: '',
pending: false,
@ -142,7 +147,9 @@ export function applyWakeStartResult(result: WakeStartResponse | null | undefine
$wakeWord.set({
...current,
// The backend probes requirements on start; an explicit "unavailable"
// refusal means the feature can't run here, so hide the toggle.
// refusal means the feature can't run here right now. Keep `enabled`
// (config truth) as-is so the button stays mounted through transient
// refusals instead of vanishing mid-session.
available: result?.reason === 'unavailable' ? false : current.available,
listening: false,
notice: noticeFrom(result),
@ -157,6 +164,7 @@ export function applyWakeStopResult(result: WakeStopResponse | null | undefined)
$wakeWord.set({
...current,
enabled: result?.disabled_persisted ? false : current.enabled,
listening: false,
notice: result?.stopped ? '' : noticeFrom(result),
pending: false