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:
parent
f03bb2b4ef
commit
46faa4f639
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -1819,6 +1819,7 @@ export const ja = defineLocale({
|
|||
stopSpeakingReplies: '返信の読み上げを停止',
|
||||
wakeWordListening: phrase => `ウェイクワード:「${phrase}」— 待機中`,
|
||||
wakeWordOff: phrase => `ウェイクワード:「${phrase}」— オフ`,
|
||||
wakeWordPausedVoice: phrase => `ウェイクワード:「${phrase}」— 音声チャット中は一時停止`,
|
||||
lookupLoading: '検索中…',
|
||||
lookupNoMatches: '一致なし。',
|
||||
lookupTry: '試す',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1762,6 +1762,7 @@ export const zhHant = defineLocale({
|
|||
stopSpeakingReplies: '停止朗讀回覆',
|
||||
wakeWordListening: phrase => `喚醒詞:「${phrase}」— 正在聆聽`,
|
||||
wakeWordOff: phrase => `喚醒詞:「${phrase}」— 已關閉`,
|
||||
wakeWordPausedVoice: phrase => `喚醒詞:「${phrase}」— 語音對話期間暫停`,
|
||||
lookupLoading: '查詢中…',
|
||||
lookupNoMatches: '沒有相符項目。',
|
||||
lookupTry: '試試',
|
||||
|
|
|
|||
|
|
@ -2153,6 +2153,7 @@ export const zh: Translations = {
|
|||
stopSpeakingReplies: '停止朗读回复',
|
||||
wakeWordListening: phrase => `唤醒词:"${phrase}" — 正在监听`,
|
||||
wakeWordOff: phrase => `唤醒词:"${phrase}" — 已关闭`,
|
||||
wakeWordPausedVoice: phrase => `唤醒词:"${phrase}" — 语音对话期间暂停`,
|
||||
lookupLoading: '查找中…',
|
||||
lookupNoMatches: '没有匹配项。',
|
||||
lookupTry: '试试',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue