diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts
index b5f9cc6a8be..a60ebb37621 100644
--- a/apps/desktop/electron/session-windows.test.ts
+++ b/apps/desktop/electron/session-windows.test.ts
@@ -208,3 +208,13 @@ test('chatWindowWebPreferences passes the preload path through and keeps the har
assert.equal(prefs.sandbox, true)
assert.equal(prefs.nodeIntegration, false)
})
+
+test('chatWindowWebPreferences allows autoplay so wake-started voice speaks its first reply', () => {
+ // Regression: Chromium's default autoplay policy suspends audio until a user
+ // gesture. A wake-word-started voice conversation has no preceding click, so
+ // the first reply's playback was rejected and only turn 2+ spoke. A native
+ // app the user launched should not gate audio on a gesture.
+ const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
+
+ assert.equal(prefs.autoplayPolicy, 'no-user-gesture-required')
+})
diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts
index 46871b5384e..312deb3d1e3 100644
--- a/apps/desktop/electron/session-windows.ts
+++ b/apps/desktop/electron/session-windows.ts
@@ -21,6 +21,16 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// occluded windows. A streaming chat app must keep painting in the
// background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname.
+//
+// `autoplayPolicy: 'no-user-gesture-required'` is load-bearing for voice:
+// Chromium's default autoplay policy suspends audio (HTMLAudioElement.play()
+// and AudioContext) until the user has interacted with the frame. A voice
+// conversation started by the "Hey Hermes" wake word has NO preceding click,
+// so the FIRST reply's audio playback was rejected (NotAllowedError, silently
+// swallowed) and only turn 2+ spoke — the very "first message in a new voice
+// session is silent" bug. Manual voice-start worked only because the button
+// click counted as the gesture. This is a native app the user deliberately
+// launched; there is no drive-by-autoplay concern to protect against.
function chatWindowWebPreferences(preloadPath: string) {
return {
preload: preloadPath,
@@ -29,7 +39,8 @@ function chatWindowWebPreferences(preloadPath: string) {
sandbox: true,
nodeIntegration: false,
devTools: true,
- backgroundThrottling: false
+ backgroundThrottling: false,
+ autoplayPolicy: 'no-user-gesture-required' as const
}
}
diff --git a/apps/desktop/src/app/chat/composer/controls.test.tsx b/apps/desktop/src/app/chat/composer/controls.test.tsx
index 90d38d27495..8a84be8ea47 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,62 @@ 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('stays visible (never hides) even when unavailable and not enabled', () => {
+ applyWakeStatus({ available: false, enabled: false, listening: false, phrase: 'hey hermes' })
+ renderControls()
+
+ // 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', () => {
+ 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 996b962f544..7c1a90df4de 100644
--- a/apps/desktop/src/app/chat/composer/controls.tsx
+++ b/apps/desktop/src/app/chat/composer/controls.tsx
@@ -1,10 +1,13 @@
+import { useStore } from '@nanostores/react'
+
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
-import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
+import { AudioLines, Ear, EarOff, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
import { cn } from '@/lib/utils'
+import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
import type { ConversationStatus } from './hooks/use-voice-conversation'
import { ModelPill } from './model-pill'
@@ -80,6 +83,7 @@ export function ComposerControls({
+
{busyAction === 'steer' ? (
}>