diff --git a/apps/desktop/src/app/chat/composer/controls.test.tsx b/apps/desktop/src/app/chat/composer/controls.test.tsx
index d5694b6144..5b8ff62b24 100644
--- a/apps/desktop/src/app/chat/composer/controls.test.tsx
+++ b/apps/desktop/src/app/chat/composer/controls.test.tsx
@@ -61,19 +61,28 @@ afterEach(() => {
$hudMode.set(false)
})
-// The HUD is a Spotlight bar a few hundred pixels wide: the four voice
-// controls fold into one menu there, and the way out of HUD mode joins the
-// row instead of floating above the bar in a reserved strip. The docked
-// composer keeps every control inline and shows no exit.
+// The HUD is a Spotlight bar a few hundred pixels wide: the voice controls
+// fold into one menu there, and the way out of HUD mode joins the row instead
+// of floating above the bar in a reserved strip. The docked composer keeps the
+// mic inline, with the other voice toggles fanned out of it on hover, and
+// shows no exit.
describe('HUD mode', () => {
- it('keeps the voice controls inline and offers no exit in the docked composer', () => {
+ it('keeps the mic inline, fans the toggles on hover, and offers no exit in the docked composer', async () => {
renderControls()
- expect(screen.getByLabelText('Voice dictation')).toBeTruthy()
- expect(screen.getByLabelText('Read replies aloud')).toBeTruthy()
+ const mic = screen.getByLabelText('Voice dictation')
+
+ expect(mic).toBeTruthy()
+ expect(screen.queryByLabelText('Read replies aloud')).toBeNull()
+
+ fireEvent.pointerEnter(mic.parentElement!)
+
+ expect(await screen.findByLabelText('Read replies aloud')).toBeTruthy()
+ expect(screen.getByLabelText('Wake word "hey hermes"')).toBeTruthy()
expect(screen.queryByLabelText('Exit HUD mode')).toBeNull()
expect(screen.queryByLabelText('Reset HUD size and position')).toBeNull()
- expect(screen.queryByLabelText('Voice')).toBeNull()
+ // No folded menu trigger — the fan's group shares the "Voice" name.
+ expect(screen.queryByRole('button', { name: 'Voice' })).toBeNull()
})
it('folds them into one menu and offers the way out in the HUD', () => {
@@ -162,38 +171,37 @@ describe('wake-word ear visibility', () => {
resetWakeWordState()
})
- it('stays mounted during a busy agent turn', () => {
+ // The ear lives in the mic's fan now: hover the mic to reach it.
+ const findEar = async () => {
+ fireEvent.pointerEnter(screen.getByLabelText('Voice dictation').parentElement!)
+
+ return screen.findByLabelText('Wake word "hey hermes"')
+ }
+
+ it('stays reachable during a busy agent turn', async () => {
applyWakeStatus({ available: true, enabled: true, listening: true, phrase: 'hey hermes' })
renderControls({ busy: true, busyAction: 'stop' })
- expect(screen.getByLabelText('Wake word: "hey hermes" — listening')).toBeTruthy()
+ expect((await findEar()).getAttribute('aria-pressed')).toBe('true')
})
- it('stays mounted (enabled in config) even when a start was refused', () => {
+ it('stays reachable (enabled in config) even when a start was refused', async () => {
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()
+ expect((await findEar()).getAttribute('aria-pressed')).toBe('false')
})
- 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', () => {
+ it('stays reachable (never hides) even when unavailable and not enabled', async () => {
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()
+ // The ear ALWAYS shows so the user can click to enable; a refused start
+ // never hides the control.
+ expect(((await findEar()) as HTMLButtonElement).disabled).toBe(false)
})
it('shows a disabled paused ear inside the voice-conversation pill', () => {
diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx
index 62e1a59234..09ecd91e50 100644
--- a/apps/desktop/src/app/chat/composer/controls.tsx
+++ b/apps/desktop/src/app/chat/composer/controls.tsx
@@ -5,7 +5,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
-import { Ear, EarOff, iconSize, Layers3, Loader2, Square, Volume2, VolumeX } from '@/lib/icons'
+import { Ear, EarOff, iconSize, Layers3, Loader2, Square } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $hudMode, closeHud, resetHudLayout } from '@/store/hud'
import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
@@ -16,6 +16,7 @@ import { ModelPill } from './model-pill'
import { ReasoningPill } from './reasoning-pill'
import { StartVoiceButton } from './start-voice-button'
import type { ChatBarState, VoiceStatus } from './types'
+import { VoiceFan } from './voice-fan'
import { VoiceMenu } from './voice-menu'
// Re-exported: `context-menu.tsx` and other row neighbours have always reached
@@ -100,11 +101,15 @@ export function ComposerControls({
voiceStatus={voiceStatus}
/>
) : (
- <>
-
-
-
- >
+ // One mic in the row; hovering it fans the other voice toggles out of it.
+
)
return (
@@ -121,7 +126,7 @@ export function ComposerControls({
>
)}
{showQueueButton ? (
- }>
+ } side="left">
)
}
+ side="left"
>
-
+
-
+
void }) {
- const { t } = useI18n()
- const c = t.composer
- const label = active ? c.stopSpeakingReplies : c.speakReplies
-
- return (
-
- {
- triggerHaptic(active ? 'close' : 'open')
- onToggle()
- }}
- size="icon"
- type="button"
- variant="ghost"
- >
- {active ? : }
-
-
- )
-}
-
// "Hey Hermes" wake-word toggle. ALWAYS rendered — the ear never hides. A
// user must always be able to click it to turn passive listening on; if the
// backend can't start (missing STT/TTS, deps still installing, no mic
@@ -370,7 +347,7 @@ function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolea
const tooltip = !pausedForVoice && wake.notice ? `${label} — ${wake.notice}` : label
return (
-
+
)
}
-
-function DictationButton({
- disabled,
- state,
- status,
- onToggle
-}: {
- disabled: boolean
- state: ChatBarState['voice']
- status: VoiceStatus
- onToggle: () => void
-}) {
- const { t } = useI18n()
- const c = t.composer
- const active = state.active || status !== 'idle'
-
- const aria =
- status === 'recording' ? c.stopDictation : status === 'transcribing' ? c.transcribingDictation : c.voiceDictation
-
- return (
-
- {
- triggerHaptic(active ? 'close' : 'open')
- onToggle()
- }}
- size="icon"
- type="button"
- variant="ghost"
- >
- {status === 'recording' ? (
-
- ) : status === 'transcribing' ? (
-
- ) : (
-
- )}
-
-
- )
-}
diff --git a/apps/desktop/src/app/chat/composer/voice-fan.tsx b/apps/desktop/src/app/chat/composer/voice-fan.tsx
new file mode 100644
index 0000000000..88ee3a8278
--- /dev/null
+++ b/apps/desktop/src/app/chat/composer/voice-fan.tsx
@@ -0,0 +1,110 @@
+import { useStore } from '@nanostores/react'
+import { useCallback, useMemo } from 'react'
+
+import { Codicon } from '@/components/ui/codicon'
+import { FanMenu, type FanMenuItem } from '@/components/ui/fan-menu'
+import { useI18n } from '@/i18n'
+import { triggerHaptic } from '@/lib/haptics'
+import { Ear, EarOff, iconSize, Loader2, Square, Volume2, VolumeX } from '@/lib/icons'
+import { cn } from '@/lib/utils'
+import { $wakeWord, toggleWakeWord } from '@/store/wake-word'
+
+import { ACTIVE_ICON_BTN, GHOST_ICON_BTN } from './control-classes'
+import type { ChatBarState, VoiceStatus } from './types'
+
+export interface VoiceFanProps {
+ autoSpeak: boolean
+ disabled: boolean
+ state: ChatBarState
+ voiceStatus: VoiceStatus
+ onDictate: () => void
+ onToggleAutoSpeak: () => void
+}
+
+/**
+ * The voice toggles behind one hub. The mic is the button in the row; hovering
+ * it fans the other two — spoken replies and the wake word — out of it.
+ * Starting a conversation stays on the primary button beside it.
+ *
+ * The hub is the mic and reports dictation only (recording, transcribing);
+ * each disc carries its own on-state.
+ *
+ * Items are memoized on the handful of state bits they read, so the fan only
+ * re-renders when a toggle actually flips — not on every composer keystroke.
+ */
+export function VoiceFan({ autoSpeak, disabled, state, voiceStatus, onDictate, onToggleAutoSpeak }: VoiceFanProps) {
+ const { t } = useI18n()
+ const c = t.composer
+ const wake = useStore($wakeWord)
+
+ const phrase = wake.phrase || 'hey hermes'
+ const dictating = state.voice.active || voiceStatus !== 'idle'
+ const wakeListening = wake.listening
+ const wakePending = wake.pending
+
+ const dictationLabel =
+ voiceStatus === 'recording'
+ ? c.stopDictation
+ : voiceStatus === 'transcribing'
+ ? c.transcribingDictation
+ : c.voiceDictation
+
+ const hubLabel = dictating ? dictationLabel : c.voiceDictation
+
+ const dictate = useCallback(() => {
+ triggerHaptic(dictating ? 'close' : 'open')
+ onDictate()
+ }, [dictating, onDictate])
+
+ // The hub is the mic and only dictation lights it. The wake word has its own
+ // disc with its own on-state; mirroring it here read as dictation being on.
+ const hub = useMemo(
+ () => ({
+ id: 'dictate',
+ active: dictating,
+ className: cn(GHOST_ICON_BTN, 'rounded-full p-0', dictating && ACTIVE_ICON_BTN),
+ disabled: disabled || !state.voice.enabled || voiceStatus === 'transcribing',
+ icon:
+ voiceStatus === 'recording' ? (
+
+ ) : voiceStatus === 'transcribing' ? (
+
+ ) : (
+
+ ),
+ label: hubLabel,
+ onSelect: dictate
+ }),
+ [dictate, dictating, disabled, hubLabel, state.voice.enabled, voiceStatus]
+ )
+
+ const items = useMemo(
+ () => [
+ {
+ id: 'speak',
+ active: autoSpeak,
+ disabled,
+ icon: autoSpeak ? : ,
+ label: autoSpeak ? c.stopSpeakingReplies : c.speakReplies,
+ onSelect: () => {
+ triggerHaptic(autoSpeak ? 'close' : 'open')
+ onToggleAutoSpeak()
+ }
+ },
+ {
+ id: 'wake',
+ active: wakeListening,
+ disabled: disabled || wakePending,
+ icon: wakeListening ? : ,
+ label: c.wakeWord(phrase),
+ onSelect: () => {
+ triggerHaptic(wakeListening ? 'close' : 'open')
+ void toggleWakeWord()
+ }
+ }
+ ],
+ [autoSpeak, c, disabled, onToggleAutoSpeak, phrase, wakeListening, wakePending]
+ )
+
+ return
+}
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 0ce996a314..1d2ce02324 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -2908,6 +2908,7 @@ export const en: Translations = {
voiceDictation: 'Voice dictation',
speakReplies: 'Read replies aloud',
stopSpeakingReplies: 'Stop reading replies aloud',
+ wakeWord: phrase => `Wake word "${phrase}"`,
wakeWordListening: phrase => `Wake word: "${phrase}" — listening`,
wakeWordOff: phrase => `Wake word: "${phrase}" — off`,
wakeWordPausedVoice: phrase => `Wake word: "${phrase}" — paused during voice chat`,
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 6993ff90d5..3d1b247308 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -2401,6 +2401,7 @@ export const ja = defineLocale({
speakReplies: '返信を読み上げる',
stopSpeakingReplies: '返信の読み上げを停止',
wakeWordListening: phrase => `ウェイクワード:「${phrase}」— 待機中`,
+ wakeWord: phrase => `ウェイクワード「${phrase}」`,
wakeWordOff: phrase => `ウェイクワード:「${phrase}」— オフ`,
wakeWordPausedVoice: phrase => `ウェイクワード:「${phrase}」— 音声チャット中は一時停止`,
lookupLoading: '検索中…',
diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts
index 803ad1e69e..df21d80553 100644
--- a/apps/desktop/src/i18n/ru.ts
+++ b/apps/desktop/src/i18n/ru.ts
@@ -2674,6 +2674,7 @@ export const ru = defineLocale({
voiceDictation: 'Голосовая диктовка',
speakReplies: 'Зачитывать ответы вслух',
stopSpeakingReplies: 'Перестать зачитывать ответы вслух',
+ wakeWord: phrase => `Слово-пробуждение «${phrase}»`,
wakeWordListening: phrase => `Слово-пробуждение: «${phrase}» — слушает`,
wakeWordOff: phrase => `Слово-пробуждение: «${phrase}» — выключено`,
wakeWordPausedVoice: phrase => `Слово-пробуждение: «${phrase}» — приостановлено во время голосового чата`,
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 62fdf475e0..536d29b029 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -2513,6 +2513,7 @@ export interface Translations {
voiceDictation: string
speakReplies: string
stopSpeakingReplies: string
+ wakeWord: (phrase: string) => string
wakeWordListening: (phrase: string) => string
wakeWordOff: (phrase: string) => string
wakeWordPausedVoice: (phrase: string) => string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index 6f5e79005f..c434702518 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -2383,6 +2383,7 @@ export const zhHant = defineLocale({
speakReplies: '朗讀回覆',
stopSpeakingReplies: '停止朗讀回覆',
wakeWordListening: phrase => `喚醒詞:「${phrase}」— 正在聆聽`,
+ wakeWord: phrase => `喚醒詞「${phrase}」`,
wakeWordOff: phrase => `喚醒詞:「${phrase}」— 已關閉`,
wakeWordPausedVoice: phrase => `喚醒詞:「${phrase}」— 語音對話期間暫停`,
lookupLoading: '查詢中…',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index b31f823cc2..1ab0cf0b68 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -3051,6 +3051,7 @@ export const zh = defineLocale({
speakReplies: '朗读回复',
stopSpeakingReplies: '停止朗读回复',
wakeWordListening: phrase => `唤醒词:"${phrase}" — 正在监听`,
+ wakeWord: phrase => `唤醒词"${phrase}"`,
wakeWordOff: phrase => `唤醒词:"${phrase}" — 已关闭`,
wakeWordPausedVoice: phrase => `唤醒词:"${phrase}" — 语音对话期间暂停`,
lookupLoading: '查找中…',