From 46faa4f63929e65ff781ed0da2095566b3c98cdf Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:45:47 -0700 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20keep=20the=20wake-word=20ear=20?= =?UTF-8?q?mounted=20everywhere=20=E2=80=94=20paused=20only=20during=20voi?= =?UTF-8?q?ce=20chat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/app/chat/composer/controls.test.tsx | 49 +++++++++++++++++++ .../src/app/chat/composer/controls.tsx | 36 +++++++++----- apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/store/wake-word.ts | 12 ++++- 8 files changed, 87 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/controls.test.tsx b/apps/desktop/src/app/chat/composer/controls.test.tsx index 90d38d274954e..b35c280c732d6 100644 --- a/apps/desktop/src/app/chat/composer/controls.test.tsx +++ b/apps/desktop/src/app/chat/composer/controls.test.tsx @@ -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) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 647a7647ba5f6..6e2ec91f49c12 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -185,6 +185,9 @@ function ConversationPill({ return (
+ {/* Keep the ear visible during voice chat — shown paused, since the + conversation holds the mic (the one time wake must not listen). */} + ) diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index cb20d06c7bc7b..076bad01acb2b 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 4f70f97e691a9..632435d21a817 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1819,6 +1819,7 @@ export const ja = defineLocale({ stopSpeakingReplies: '返信の読み上げを停止', wakeWordListening: phrase => `ウェイクワード:「${phrase}」— 待機中`, wakeWordOff: phrase => `ウェイクワード:「${phrase}」— オフ`, + wakeWordPausedVoice: phrase => `ウェイクワード:「${phrase}」— 音声チャット中は一時停止`, lookupLoading: '検索中…', lookupNoMatches: '一致なし。', lookupTry: '試す', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index a1042ce7d8808..35124612f7823 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index c45f1cbeceb13..4259890e8985e 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1762,6 +1762,7 @@ export const zhHant = defineLocale({ stopSpeakingReplies: '停止朗讀回覆', wakeWordListening: phrase => `喚醒詞:「${phrase}」— 正在聆聽`, wakeWordOff: phrase => `喚醒詞:「${phrase}」— 已關閉`, + wakeWordPausedVoice: phrase => `喚醒詞:「${phrase}」— 語音對話期間暫停`, lookupLoading: '查詢中…', lookupNoMatches: '沒有相符項目。', lookupTry: '試試', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index a5b48ed368ab8..a2ff10eeba5d6 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2153,6 +2153,7 @@ export const zh: Translations = { stopSpeakingReplies: '停止朗读回复', wakeWordListening: phrase => `唤醒词:"${phrase}" — 正在监听`, wakeWordOff: phrase => `唤醒词:"${phrase}" — 已关闭`, + wakeWordPausedVoice: phrase => `唤醒词:"${phrase}" — 语音对话期间暂停`, lookupLoading: '查找中…', lookupNoMatches: '没有匹配项。', lookupTry: '试试', diff --git a/apps/desktop/src/store/wake-word.ts b/apps/desktop/src/store/wake-word.ts index 3e64285a70338..fd120c3cb6a92 100644 --- a/apps/desktop/src/store/wake-word.ts +++ b/apps/desktop/src/store/wake-word.ts @@ -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