feat(desktop): fan the composer's voice toggles out of the mic
The docked composer spent three icon buttons on toggles that are set once and rarely touched. The mic is now the one button in the row; hovering it fans spoken-replies and the wake word out above it. The mic reports dictation only — the wake word carries its own on-state on its own disc, so mirroring it onto the mic read as dictation being on. The wake disc's tip names the phrase and nothing else; the pressed state says on/off. The folded HUD/narrow-tile VoiceMenu is unchanged.
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
|
||||
<WakeWordButton disabled={disabled} />
|
||||
</>
|
||||
// One mic in the row; hovering it fans the other voice toggles out of it.
|
||||
<VoiceFan
|
||||
autoSpeak={autoSpeak}
|
||||
disabled={disabled}
|
||||
onDictate={onDictate}
|
||||
onToggleAutoSpeak={onToggleAutoSpeak}
|
||||
state={state}
|
||||
voiceStatus={voiceStatus}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -121,7 +126,7 @@ export function ComposerControls({
|
||||
</>
|
||||
)}
|
||||
{showQueueButton ? (
|
||||
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
|
||||
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />} side="left">
|
||||
<Button
|
||||
aria-label={c.queueMessage}
|
||||
className={GHOST_ICON_BTN}
|
||||
@@ -146,6 +151,7 @@ export function ComposerControls({
|
||||
<TipKeybindLabel actionId="composer.send" text={c.send} />
|
||||
)
|
||||
}
|
||||
side="left"
|
||||
>
|
||||
<Button
|
||||
aria-label={showStop ? c.stop : c.send}
|
||||
@@ -178,7 +184,7 @@ function HudWindowButtons() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tip label={t.titlebar.resetHudLayout}>
|
||||
<Tip label={t.titlebar.resetHudLayout} side="left">
|
||||
<Button
|
||||
aria-label={t.titlebar.resetHudLayout}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0')}
|
||||
@@ -190,7 +196,7 @@ function HudWindowButtons() {
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={t.titlebar.exitHud}>
|
||||
<Tip label={t.titlebar.exitHud} side="left">
|
||||
<Button
|
||||
aria-label={t.titlebar.exitHud}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0')}
|
||||
@@ -236,7 +242,7 @@ function ConversationPill({
|
||||
{/* Keep the ear visible during voice chat — shown paused, since the
|
||||
conversation holds the mic (the one time wake must not listen). */}
|
||||
<WakeWordButton disabled={disabled} pausedForVoice />
|
||||
<Tip label={muted ? c.unmuteMic : c.muteMic}>
|
||||
<Tip label={muted ? c.unmuteMic : c.muteMic} side="left">
|
||||
<Button
|
||||
aria-label={muted ? c.unmuteMic : c.muteMic}
|
||||
aria-pressed={muted}
|
||||
@@ -316,35 +322,6 @@ function ConversationIndicator({
|
||||
)
|
||||
}
|
||||
|
||||
// Pure-TTS toggle: type normally, but have every assistant reply read aloud —
|
||||
// no dictation, no full conversation loop. Filled/accent when on, mirroring the
|
||||
// muted-mic pressed state above. Persisted locally, independently of gateway TTS.
|
||||
function AutoSpeakButton({ active, disabled, onToggle }: { active: boolean; disabled: boolean; onToggle: () => void }) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const label = active ? c.stopSpeakingReplies : c.speakReplies
|
||||
|
||||
return (
|
||||
<Tip label={label}>
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
className={cn(GHOST_ICON_BTN, 'p-0', active && ACTIVE_ICON_BTN)}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
triggerHaptic(active ? 'close' : 'open')
|
||||
onToggle()
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{active ? <Volume2 className={iconSize.sm} /> : <VolumeX className={iconSize.sm} />}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
// "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 (
|
||||
<Tip label={tooltip}>
|
||||
<Tip label={tooltip} side="left">
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={wake.listening && !pausedForVoice}
|
||||
@@ -389,55 +366,3 @@ function WakeWordButton({ disabled, pausedForVoice = false }: { disabled: boolea
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tip label={aria}>
|
||||
<Button
|
||||
aria-label={aria}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'p-0',
|
||||
'data-[active=true]:bg-accent data-[active=true]:text-foreground',
|
||||
status === 'recording' && ACTIVE_ICON_BTN,
|
||||
status === 'transcribing' && 'bg-primary/10 text-primary'
|
||||
)}
|
||||
data-active={active}
|
||||
disabled={disabled || !state.enabled || status === 'transcribing'}
|
||||
onClick={() => {
|
||||
triggerHaptic(active ? 'close' : 'open')
|
||||
onToggle()
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{status === 'recording' ? (
|
||||
<Square className={cn('fill-current', iconSize.xs)} />
|
||||
) : status === 'transcribing' ? (
|
||||
<Loader2 className={cn('animate-spin', iconSize.sm)} />
|
||||
) : (
|
||||
<Codicon name="mic" size="0.875rem" />
|
||||
)}
|
||||
</Button>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' ? (
|
||||
<Square className={cn('fill-current', iconSize.xs)} />
|
||||
) : voiceStatus === 'transcribing' ? (
|
||||
<Loader2 className={cn('animate-spin', iconSize.sm)} />
|
||||
) : (
|
||||
<Codicon name="mic" size="0.875rem" />
|
||||
),
|
||||
label: hubLabel,
|
||||
onSelect: dictate
|
||||
}),
|
||||
[dictate, dictating, disabled, hubLabel, state.voice.enabled, voiceStatus]
|
||||
)
|
||||
|
||||
const items = useMemo<FanMenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'speak',
|
||||
active: autoSpeak,
|
||||
disabled,
|
||||
icon: autoSpeak ? <Volume2 className={iconSize.sm} /> : <VolumeX className={iconSize.sm} />,
|
||||
label: autoSpeak ? c.stopSpeakingReplies : c.speakReplies,
|
||||
onSelect: () => {
|
||||
triggerHaptic(autoSpeak ? 'close' : 'open')
|
||||
onToggleAutoSpeak()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'wake',
|
||||
active: wakeListening,
|
||||
disabled: disabled || wakePending,
|
||||
icon: wakeListening ? <Ear className={iconSize.sm} /> : <EarOff className={iconSize.sm} />,
|
||||
label: c.wakeWord(phrase),
|
||||
onSelect: () => {
|
||||
triggerHaptic(wakeListening ? 'close' : 'open')
|
||||
void toggleWakeWord()
|
||||
}
|
||||
}
|
||||
],
|
||||
[autoSpeak, c, disabled, onToggleAutoSpeak, phrase, wakeListening, wakePending]
|
||||
)
|
||||
|
||||
return <FanMenu direction="vertical" hub={hub} items={items} label={c.voiceControls} />
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -2401,6 +2401,7 @@ export const ja = defineLocale({
|
||||
speakReplies: '返信を読み上げる',
|
||||
stopSpeakingReplies: '返信の読み上げを停止',
|
||||
wakeWordListening: phrase => `ウェイクワード:「${phrase}」— 待機中`,
|
||||
wakeWord: phrase => `ウェイクワード「${phrase}」`,
|
||||
wakeWordOff: phrase => `ウェイクワード:「${phrase}」— オフ`,
|
||||
wakeWordPausedVoice: phrase => `ウェイクワード:「${phrase}」— 音声チャット中は一時停止`,
|
||||
lookupLoading: '検索中…',
|
||||
|
||||
@@ -2674,6 +2674,7 @@ export const ru = defineLocale({
|
||||
voiceDictation: 'Голосовая диктовка',
|
||||
speakReplies: 'Зачитывать ответы вслух',
|
||||
stopSpeakingReplies: 'Перестать зачитывать ответы вслух',
|
||||
wakeWord: phrase => `Слово-пробуждение «${phrase}»`,
|
||||
wakeWordListening: phrase => `Слово-пробуждение: «${phrase}» — слушает`,
|
||||
wakeWordOff: phrase => `Слово-пробуждение: «${phrase}» — выключено`,
|
||||
wakeWordPausedVoice: phrase => `Слово-пробуждение: «${phrase}» — приостановлено во время голосового чата`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2383,6 +2383,7 @@ export const zhHant = defineLocale({
|
||||
speakReplies: '朗讀回覆',
|
||||
stopSpeakingReplies: '停止朗讀回覆',
|
||||
wakeWordListening: phrase => `喚醒詞:「${phrase}」— 正在聆聽`,
|
||||
wakeWord: phrase => `喚醒詞「${phrase}」`,
|
||||
wakeWordOff: phrase => `喚醒詞:「${phrase}」— 已關閉`,
|
||||
wakeWordPausedVoice: phrase => `喚醒詞:「${phrase}」— 語音對話期間暫停`,
|
||||
lookupLoading: '查詢中…',
|
||||
|
||||
@@ -3051,6 +3051,7 @@ export const zh = defineLocale({
|
||||
speakReplies: '朗读回复',
|
||||
stopSpeakingReplies: '停止朗读回复',
|
||||
wakeWordListening: phrase => `唤醒词:"${phrase}" — 正在监听`,
|
||||
wakeWord: phrase => `唤醒词"${phrase}"`,
|
||||
wakeWordOff: phrase => `唤醒词:"${phrase}" — 已关闭`,
|
||||
wakeWordPausedVoice: phrase => `唤醒词:"${phrase}" — 语音对话期间暂停`,
|
||||
lookupLoading: '查找中…',
|
||||
|
||||
Reference in New Issue
Block a user