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"> - + - - ) -} - // "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 ( - + - - ) -} 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: '查找中…',