fix(ui): name OmniVoice correctly and cycle active engine labels

This commit is contained in:
Palash Debnath
2026-09-05 20:06:24 +05:30
parent 13eae6ff02
commit 5574cbbc16
7 changed files with 90 additions and 11 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ the frozen-backend fallback mirror it for their toolchains.
### Changed
- The title-bar engine button shows the selected engine name, with its full description in the tooltip (#1823)
- The title-bar label cycles through active speech, transcription, and LLM engines; bundled model labels correctly say OmniVoice (#1823)
- The top-bar Engines panel groups Speech, Transcription, and LLM choices into tabs, with compact memory controls and no duplicate pickers (#1823)
### Added
@@ -7,6 +7,7 @@ import { useEngines, useSelectEngine } from '../api/hooks';
import { notifyEngineSelected } from '../utils/engineSelectToast';
import { useAppStore } from '../store';
import { MENU_SURFACE } from './computeTarget';
import { engineDisplayName } from '../utils/engineDisplayName';
/**
* A compact TTS/ASR/LLM picker for chrome that needs to expose the active
@@ -150,7 +151,8 @@ export default function EngineQuickSwitch({
{available.map((engine) => {
const isActive = engine.id === familyData.active;
const warm = residentIds.has(engine.id);
const parts = engine.display_name.match(/^(.+?)\s*\((.*)\)$/);
const displayName = engineDisplayName(engine.display_name);
const parts = displayName.match(/^(.+?)\s*\((.*)\)$/);
return (
<button
key={engine.id}
@@ -177,7 +179,7 @@ export default function EngineQuickSwitch({
</span>
</>
) : (
engine.display_name
displayName
)}
</span>
<span className="shrink-0 text-[10px] text-[color:var(--chrome-fg-muted)]">
+33 -7
View File
@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import GpuTarget from './GpuTarget';
import EngineQuickSwitch from './EngineQuickSwitch';
import { engineDisplayName } from '../utils/engineDisplayName';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
import { createPortal } from 'react-dom';
import {
@@ -166,11 +167,21 @@ export default function Header({
const [flushOpen, setFlushOpen] = useState(false);
const [engineFamily, setEngineFamily] = useState('tts');
const { data: engines } = useEngines();
const activeFamily = engines?.[engineFamily];
const activeEngineName = activeFamily?.backends?.find(
(engine) => engine.id === activeFamily.active,
)?.display_name;
const [engineLabelIndex, setEngineLabelIndex] = useState(0);
const [engineLabelPaused, setEngineLabelPaused] = useState(false);
const activeEngines = ['tts', 'asr', 'llm'].flatMap((family) => {
const inventory = engines?.[family];
const active = inventory?.backends?.find((engine) => engine.id === inventory.active);
return active ? [{ family, name: engineDisplayName(active.display_name) }] : [];
});
const displayedEngine = activeEngines[engineLabelIndex % (activeEngines.length || 1)];
const activeEngineName = displayedEngine?.name;
const activeEngineShortName = activeEngineName?.split(' (')[0];
useEffect(() => {
if (flushOpen || engineLabelPaused || activeEngines.length < 2) return;
const timer = setInterval(() => setEngineLabelIndex((index) => index + 1), 4000);
return () => clearInterval(timer);
}, [flushOpen, engineLabelPaused, activeEngines.length]);
const [loadedModels, setLoadedModels] = useState([]);
const [unloading, setUnloading] = useState(null);
const flushRef = useRef(null);
@@ -419,10 +430,25 @@ export default function Header({
loading={flushing}
leading={!flushing && <Zap size={8} />}
trailing={<ChevronDown size={8} />}
onClick={() => setFlushOpen((o) => !o)}
onMouseEnter={() => setEngineLabelPaused(true)}
onMouseLeave={() => setEngineLabelPaused(false)}
onFocus={() => setEngineLabelPaused(true)}
onBlur={() => setEngineLabelPaused(false)}
onClick={() => {
if (!flushOpen && displayedEngine) setEngineFamily(displayedEngine.family);
setFlushOpen((o) => !o);
}}
className="ml-[2px]"
>
<span className="max-w-[160px] truncate max-[600px]:max-w-[100px]">
<span
key={displayedEngine?.family}
className="engine-title-label w-[190px] text-left truncate max-[600px]:w-[140px]"
>
{displayedEngine && (
<span className="text-[var(--chrome-fg-dim)]">
{t(`models.role_${displayedEngine.family}`)} ·{' '}
</span>
)}
{activeEngineShortName || t('settings.engines')}
</span>
</Button>
@@ -505,7 +531,7 @@ export default function Header({
>
<div className="flex flex-col gap-[1px] min-w-0">
<span className="text-[12px] text-[var(--color-fg)] font-medium">
{m.name}
{engineDisplayName(m.name)}
{/* Resident-but-not-routed engine (e.g. VoiceStudio still in
VRAM after switching to another backend) — say so. */}
{m.is_active_engine === false && (
+9
View File
@@ -2470,6 +2470,15 @@ input[type="file"]::file-selector-button:hover {
}
/* from MultiLangPicker (components/MultiLangPicker.css) */
@keyframes engine-title-enter {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.engine-title-label { animation: engine-title-enter 180ms ease-out; }
@media (prefers-reduced-motion: reduce) {
.engine-title-label { animation: none; }
}
/* Dropdown */
.multi-lang__drop {
position: fixed;
+27 -1
View File
@@ -8,7 +8,7 @@
* navigation at all (the rail isn't rendered either).
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
@@ -25,6 +25,7 @@ vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => windowActions
afterEach(() => {
delete window.__TAURI_INTERNALS__;
vi.clearAllMocks();
vi.useRealTimers();
});
function renderHeader(props, engines) {
@@ -39,6 +40,31 @@ function renderHeader(props, engines) {
}
describe('Header — rail mode (default)', () => {
it('cycles all three active engines and pauses while the panel is open', () => {
vi.useFakeTimers();
renderHeader(
{ onFlushMemory: vi.fn() },
{
tts: {
active: 'omni',
backends: [
{ id: 'omni', display_name: 'VoiceStudio (k2-fsa/OmniVoice, 600+ languages)' },
],
},
asr: { active: 'whisper', backends: [{ id: 'whisper', display_name: 'Whisper' }] },
llm: { active: 'off', backends: [{ id: 'off', display_name: 'Off (no LLM)' }] },
},
);
const trigger = screen.getByRole('button', { name: 'Engines' });
expect(trigger).toHaveTextContent('TTS · OmniVoice');
act(() => vi.advanceTimersByTime(4000));
expect(trigger).toHaveTextContent('ASR · Whisper');
act(() => vi.advanceTimersByTime(4000));
expect(trigger).toHaveTextContent('LLM · Off');
fireEvent.click(trigger);
act(() => vi.advanceTimersByTime(4000));
expect(trigger).toHaveTextContent('LLM · Off');
});
it('shows the selected engine name on the title-bar trigger', () => {
renderHeader(
{ onFlushMemory: vi.fn() },
+4
View File
@@ -0,0 +1,4 @@
// Older backends used the application name for the bundled OmniVoice model.
export function engineDisplayName(name = '') {
return name.replace(/^VoiceStudio(?= \(k2-fsa\/OmniVoice| TTS$|$)/, 'OmniVoice');
}
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { engineDisplayName } from './engineDisplayName';
describe('engineDisplayName', () => {
it('uses the model name for legacy engine and resident labels', () => {
expect(engineDisplayName('VoiceStudio (k2-fsa/OmniVoice, 600+ languages)')).toBe(
'OmniVoice (k2-fsa/OmniVoice, 600+ languages)',
);
expect(engineDisplayName('VoiceStudio TTS')).toBe('OmniVoice TTS');
expect(engineDisplayName('KittenTTS (English)')).toBe('KittenTTS (English)');
});
});