Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 55 additions & 24 deletions app/src/components/settings/panels/VoicePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -518,54 +518,85 @@ const VoicePanel = ({ embedded = false }: VoicePanelProps = {}) => {
</button>
</div>

{/* Whisper — coming soon */}
{/* Whisper — local STT, no API key required. Chip opens the
install/enable modal (which calls voice_install_whisper and
then voice_update_provider_settings on Enable). Toggling
off routes STT back to the managed cloud provider. */}
{(() => {
const tone = LOCAL_VOICE_PROVIDER_TONE.whisper;
const enabled = sttProvider === 'whisper';
return (
<div
className={`inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-medium ring-1 opacity-60 ${tone}`}>
<span>
{t('voice.providers.chip.whisper')}
<span className="ml-1 text-[10px] opacity-70">
({t('voice.providers.chip.comingSoon')})
</span>
</span>
className={`inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-medium ring-1 transition-colors ${tone}`}>
<span>{t('voice.providers.chip.whisper')}</span>
<button
type="button"
role="switch"
aria-checked={false}
disabled
className="relative inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-stone-300 dark:bg-neutral-600 disabled:cursor-not-allowed">
aria-checked={enabled}
data-testid="voice-provider-chip-whisper"
aria-label={
enabled
? `${t('voice.providers.chip.disableProvider')} ${t('voice.providers.chip.whisper')}`
: `${t('voice.providers.chip.enableProvider')} ${t('voice.providers.chip.whisper')}`
}
// Stay disabled for the full install window: the
// local RPC kickoff (`isInstallingWhisper`) ends as
// soon as the start call returns, but the install
// itself continues until `voice_install_status`
// reports `installed` / `error`. Combining both
// signals prevents routing edits mid-install.
disabled={isInstallingWhisper || whisperInstall?.state === 'installing'}
onClick={() => {
if (enabled) {
onSttProviderChange('cloud');
} else {
setPendingKeySlug('whisper');
setPendingKeyValue('');
}
}}
className={`relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${enabled ? 'bg-primary-500' : 'bg-stone-300 dark:bg-neutral-600'}`}>
<span
aria-hidden
className="inline-block h-3 w-3 transform rounded-full bg-white shadow translate-x-0.5"
className={`inline-block h-3 w-3 transform rounded-full bg-white shadow transition-transform ${enabled ? 'translate-x-3.5' : 'translate-x-0.5'}`}
/>
</button>
</div>
);
})()}

{/* Piper — coming soon */}
{/* Piper — local TTS, no API key required. Same chip flow as
Whisper above; targets the TTS routing slot. */}
{(() => {
const tone = LOCAL_VOICE_PROVIDER_TONE.piper;
const enabled = ttsProvider === 'piper';
return (
<div
className={`inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-medium ring-1 opacity-60 ${tone}`}>
<span>
{t('voice.providers.chip.piper')}
<span className="ml-1 text-[10px] opacity-70">
({t('voice.providers.chip.comingSoon')})
</span>
</span>
className={`inline-flex items-center gap-2 rounded-full px-2.5 py-1 text-xs font-medium ring-1 transition-colors ${tone}`}>
<span>{t('voice.providers.chip.piper')}</span>
<button
type="button"
role="switch"
aria-checked={false}
disabled
className="relative inline-flex h-4 w-7 shrink-0 items-center rounded-full bg-stone-300 dark:bg-neutral-600 disabled:cursor-not-allowed">
aria-checked={enabled}
data-testid="voice-provider-chip-piper"
aria-label={
enabled
? `${t('voice.providers.chip.disableProvider')} ${t('voice.providers.chip.piper')}`
: `${t('voice.providers.chip.enableProvider')} ${t('voice.providers.chip.piper')}`
}
// Same install-window guard as the Whisper chip.
disabled={isInstallingPiper || piperInstall?.state === 'installing'}
onClick={() => {
if (enabled) {
onTtsProviderChange('cloud');
} else {
setPendingKeySlug('piper');
setPendingKeyValue('');
}
}}
className={`relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${enabled ? 'bg-primary-500' : 'bg-stone-300 dark:bg-neutral-600'}`}>
<span
aria-hidden
className="inline-block h-3 w-3 transform rounded-full bg-white shadow translate-x-0.5"
className={`inline-block h-3 w-3 transform rounded-full bg-white shadow transition-transform ${enabled ? 'translate-x-3.5' : 'translate-x-0.5'}`}
/>
</button>
</div>
Expand Down
72 changes: 60 additions & 12 deletions app/src/components/settings/panels/__tests__/VoicePanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -354,21 +354,69 @@ describe('VoicePanel', () => {
expect(cloudSwitch).toBeDisabled();
});

it('renders Whisper and Piper chips as coming-soon and disabled', async () => {
it('renders Whisper and Piper chips as enabled and clickable (regression #2788)', async () => {
renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });

await screen.findByTestId('voice-providers-section');
// Both local provider chips are disabled (coming soon).
const switches = screen.getAllByRole('switch');
// Cloud, Whisper, Piper, plus any external provider chips.
const whisperSwitch = switches.find(s =>
s.closest('div')?.textContent?.toLowerCase().includes('whisper')
);
const piperSwitch = switches.find(s =>
s.closest('div')?.textContent?.toLowerCase().includes('piper')
);
expect(whisperSwitch).toBeDisabled();
expect(piperSwitch).toBeDisabled();
// The Whisper / Piper chips must be reachable so users can install and
// route to the local STT/TTS engines without editing config.toml by
// hand. The chip is "off" until the engine is selected as the active
// STT (whisper) / TTS (piper) routing target.
const whisperChip = await screen.findByTestId('voice-provider-chip-whisper');
const piperChip = await screen.findByTestId('voice-provider-chip-piper');
expect(whisperChip).not.toBeDisabled();
expect(piperChip).not.toBeDisabled();
expect(whisperChip).toHaveAttribute('aria-checked', 'false');
expect(piperChip).toHaveAttribute('aria-checked', 'false');
});

it('opens the install modal when the Whisper chip is clicked', async () => {
renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });

await screen.findByTestId('voice-providers-section');
const whisperChip = await screen.findByTestId('voice-provider-chip-whisper');
fireEvent.click(whisperChip);

// The existing local-provider modal opens with the whisper slug — it
// contains the install button and Whisper model selector that route
// through `voice_install_whisper` + `voice_update_provider_settings`.
expect(await screen.findByTestId('voice-provider-key-modal')).toBeInTheDocument();
});

it('opens the install modal when the Piper chip is clicked', async () => {
renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });

await screen.findByTestId('voice-providers-section');
const piperChip = await screen.findByTestId('voice-provider-chip-piper');
fireEvent.click(piperChip);

expect(await screen.findByTestId('voice-provider-key-modal')).toBeInTheDocument();
});

it('renders the Whisper chip as on when STT routing is set to whisper', async () => {
runtime.voiceSettings = makeVoiceSettings({
sttProvider: { kind: 'local', engine: 'whisper', model: 'medium' },
ttsProvider: { kind: 'cloud' },
});

renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });

await screen.findByTestId('voice-providers-section');
const whisperChip = await screen.findByTestId('voice-provider-chip-whisper');
await waitFor(() => expect(whisperChip).toHaveAttribute('aria-checked', 'true'));
});

it('renders the Piper chip as on when TTS routing is set to piper', async () => {
runtime.voiceSettings = makeVoiceSettings({
sttProvider: { kind: 'cloud' },
ttsProvider: { kind: 'local', engine: 'piper', model: '' },
});

renderWithProviders(<VoicePanel />, { initialEntries: ['/settings/voice'] });

await screen.findByTestId('voice-providers-section');
const piperChip = await screen.findByTestId('voice-provider-chip-piper');
await waitFor(() => expect(piperChip).toHaveAttribute('aria-checked', 'true'));
});

it('renders the ElevenLabs chip as off when no provider is registered', async () => {
Expand Down
Loading