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