feat(tts): extract PortalVoicePicker and route Gemini through it

Phase 2 of the TTS params/layout/agent-override plan.

Web (`ui/web/src/components/voice-picker.tsx`):
- Extract `PortalVoicePicker` (search + VoiceRow + portal dropdown)
  from the `DynamicVoicePicker` inner body.
- Introduce `PortalVoice` shape + `mapCapVoiceToPortal` adapter so
  capability `VoiceOption` and `useVoices()` results both feed in.
- Dispatcher gains a Gemini route: static voices + provider==="gemini"
  → PortalVoicePicker with the static catalog. Other static providers
  (OpenAI) continue to use `StaticVoicePicker` (Radix Select).
- `DynamicVoicePicker` reduced to a thin fetch wrapper around
  `useVoices()` + `useRefreshVoices()`.

Desktop mirror (`ui/desktop/frontend/src/components/agents/voice-picker.tsx`):
- Same extraction + Gemini route. OpenAI keeps the Combobox-driven
  static path to preserve native UX.

Tests: 9 new cases covering Gemini→Portal dispatch, mapping shape,
absent `preview_url`/`labels`, plus the unchanged ElevenLabs/MiniMax/
OpenAI paths. 179/179 web tests pass.

No backend change. No new i18n keys (existing voice_picker.* reused).
This commit is contained in:
viettranx committed 2026-04-20 06:23:09 +07:00
1 parent ee57ec0fdc
commit bd95022efe
3 files changed
+490 -94

No files matched your search

@@ -1,18 +1,22 @@
/**
* Desktop VoicePicker — capabilities-driven dispatch (Phase C mirror).
* Desktop VoicePicker — capabilities-driven dispatch (Phase 2 mirror).
* Mirrors web voice-picker.tsx dispatch logic with desktop-native UI.
*
* - "" provider → disabled empty-state
* - voices_dynamic=true in custom_features → DynamicVoicePicker
* - static voices[] in capabilities → StaticVoicePicker (Combobox)
* - gemini + static voices → PortalVoicePicker (search + row UI)
* - other static voices[] + !voicesDynamic → StaticVoicePicker (Combobox)
* - voices_dynamic=true OR minimax → DynamicVoicePicker → PortalVoicePicker
* - MiniMax first-fetch failure → FreeTextPicker fallback
*/
import { useId, useState, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { useVoices, useRefreshVoices } from '../../hooks/use-voices'
import { useTtsCapabilities } from '../../hooks/use-tts-capabilities'
import { VoicePreviewButton } from './voice-preview-button'
import { Combobox } from '../common/Combobox'
import type { Voice } from '../../services/voices'
import { usePortalDropdownClose } from '../../hooks/use-portal-dropdown-close'
import type { VoiceOption } from '../../api/tts-capabilities'
import type { TtsProviderId } from '@/data/tts-providers'
interface Props {
@@ -22,7 +26,23 @@ interface Props {
provider?: TtsProviderId | ''
}
function VoiceOption({ voice, selected }: { voice: Voice; selected: boolean }) {
/**
* Unified voice shape for PortalVoicePicker (desktop).
* Matches web PortalVoice — voice_id + name; labels/preview_url optional.
*/
export interface PortalVoice {
voice_id: string
name: string
labels?: Record<string, string>
preview_url?: string
}
/** Maps a capability VoiceOption to PortalVoice (no labels/preview for Gemini). */
function mapCapVoiceToPortal(v: VoiceOption): PortalVoice {
return { voice_id: v.voice_id, name: v.name }
}
function VoiceOption({ voice, selected }: { voice: PortalVoice; selected: boolean }) {
const labelEntries = ['gender', 'accent', 'age', 'use_case']
.filter((k) => voice.labels?.[k])
.map((k) => voice.labels![k])
@@ -51,6 +71,18 @@ export function VoicePicker({ value, onChange, disabled, provider }: Props) {
const voicesDynamic = providerCaps?.custom_features?.['voices_dynamic'] === true
const staticVoices = providerCaps?.voices ?? []
// Gemini: static voices rendered through portal picker (search + row UI)
if (provider === 'gemini' && staticVoices.length > 0) {
return (
<PortalVoicePicker
voices={staticVoices.map(mapCapVoiceToPortal)}
value={value}
onChange={onChange}
disabled={disabled}
/>
)
}
if (providerCaps && !voicesDynamic && staticVoices.length > 0) {
return (
<StaticVoicePicker
@@ -139,6 +171,173 @@ function FreeTextVoicePicker({
)
}
/**
* PortalVoicePicker (desktop) — search + scrollable list rendered via portal.
*
* Used by:
* - DynamicVoicePicker (ElevenLabs, MiniMax) — voices from useVoices()
* - VoicePicker dispatcher for Gemini — capability static voices
*
* Owns: open/search state, triggerRef/dropdownRef, usePortalDropdownClose, createPortal.
*/
export function PortalVoicePicker({
voices,
value,
onChange,
disabled,
isLoading,
onRefresh,
}: {
voices: PortalVoice[]
value: string | null
onChange: (voice_id: string) => void
disabled?: boolean
isLoading?: boolean
onRefresh?: () => void
}) {
const { t } = useTranslation('tts')
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const triggerRef = useRef<HTMLDivElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const listboxId = useId()
const selected = voices.find((v) => v.voice_id === value)
const filtered = search.trim()
? voices.filter((v) => v.name.toLowerCase().includes(search.toLowerCase()))
: voices
const handleToggle = () => {
if (disabled) return
setOpen((prev) => {
if (prev) return false
setSearch('')
return true
})
}
const handleSelect = (voice: PortalVoice) => {
onChange(voice.voice_id)
setOpen(false)
setSearch('')
}
usePortalDropdownClose({
open,
onClose: () => setOpen(false),
ignore: [triggerRef, dropdownRef],
})
const dropdownContent = open && (
<div
ref={dropdownRef}
id={listboxId}
role="listbox"
aria-label={t('voice_placeholder')}
className="pointer-events-auto z-50 min-w-[220px] rounded border border-border bg-surface-primary text-text-primary shadow-lg"
style={(() => {
if (!triggerRef.current) return {}
const rect = triggerRef.current.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const dropH = 240
if (spaceBelow < dropH && rect.top > dropH) {
return { position: 'fixed' as const, bottom: window.innerHeight - rect.top + 4, left: rect.left, width: rect.width }
}
return { position: 'fixed' as const, top: rect.bottom + 4, left: rect.left, width: rect.width }
})()}
>
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
<input
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('voice_placeholder')}
className="flex-1 bg-transparent text-xs outline-none placeholder:text-text-muted"
/>
{onRefresh && (
<button
type="button"
title={t('voice_refresh')}
disabled={isLoading}
onClick={(e) => { e.stopPropagation(); onRefresh() }}
className="shrink-0 p-1 rounded hover:bg-surface-tertiary transition-colors text-text-muted hover:text-text-primary disabled:opacity-50"
aria-label={t('voice_refresh')}
>
<svg
className={['w-3 h-3', isLoading ? 'animate-spin' : ''].join(' ')}
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M3 21v-5h5" />
</svg>
</button>
)}
</div>
<div className="max-h-52 overflow-y-auto p-1">
{isLoading ? (
<p className="py-3 text-center text-[11px] text-text-muted">{t('voice_loading')}</p>
) : filtered.length === 0 ? (
<p className="py-3 text-center text-[11px] text-text-muted">
{voices.length === 0 ? t('voice_save_config_first') : search ? t('voice_no_voices') : t('voice_loading')}
</p>
) : (
filtered.map((voice) => (
<div
key={voice.voice_id}
role="option"
aria-selected={voice.voice_id === value}
className={[
'flex items-center gap-1 rounded px-2 py-1 cursor-pointer text-xs',
voice.voice_id === value ? 'bg-surface-tertiary text-accent' : 'hover:bg-surface-secondary',
].join(' ')}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(voice)}
>
<VoiceOption voice={voice} selected={voice.voice_id === value} />
</div>
))
)}
</div>
</div>
)
return (
<div ref={triggerRef} className="relative">
<button
type="button"
disabled={disabled}
onClick={handleToggle}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? listboxId : undefined}
className={[
'flex h-8 w-full items-center justify-between gap-1 rounded border border-border bg-surface-secondary px-2 text-xs',
'disabled:opacity-50 disabled:cursor-not-allowed',
!selected ? 'text-text-muted' : 'text-text-primary',
].join(' ')}
>
<span className="flex-1 truncate text-left">
{isLoading ? t('voice_loading') : selected?.name ?? t('voice_placeholder')}
</span>
<svg className="w-3 h-3 shrink-0 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
{open && createPortal(dropdownContent, document.body)}
</div>
)
}
/**
* DynamicVoicePicker (desktop) — thin wrapper: fetches voices, delegates to PortalVoicePicker.
* Handles free-text fallback for MiniMax on fetch error.
*/
function DynamicVoicePicker({
value,
onChange,
@@ -150,7 +349,6 @@ function DynamicVoicePicker({
disabled?: boolean
allowFreeText: boolean
}) {
const { t } = useTranslation('tts')
const { data: voices = [], isLoading, error } = useVoices()
const { mutate: refresh, isPending: refreshing } = useRefreshVoices()
@@ -159,57 +357,15 @@ function DynamicVoicePicker({
return <FreeTextVoicePicker value={value} onChange={onChange} disabled={disabled} />
}
const options = voices.map((v) => ({ value: v.voice_id, label: v.name }))
return (
<div className="space-y-1">
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<Combobox
value={value ?? ''}
onChange={onChange}
options={options}
placeholder={isLoading ? t('voice_loading') : t('voice_placeholder')}
loading={isLoading}
allowCustom={false}
disabled={disabled}
/>
</div>
<button
type="button"
title={t('voice_refresh')}
disabled={refreshing || isLoading}
onClick={() => refresh()}
className="shrink-0 p-1.5 rounded hover:bg-surface-tertiary transition-colors text-text-muted hover:text-text-primary disabled:opacity-50"
aria-label={t('voice_refresh')}
>
<svg
className={['w-3.5 h-3.5', refreshing ? 'animate-spin' : ''].join(' ')}
viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M3 21v-5h5" />
</svg>
</button>
</div>
{value && voices.find((v) => v.voice_id === value) && (
<div className="flex items-center gap-1 text-[11px] text-text-muted">
<VoicePreviewButton
previewUrl={voices.find((v) => v.voice_id === value)?.preview_url}
voiceName={voices.find((v) => v.voice_id === value)?.name ?? ''}
/>
<span className="truncate">{voices.find((v) => v.voice_id === value)?.name}</span>
</div>
)}
{!isLoading && voices.length === 0 && (
<p className="text-[11px] text-text-muted">{t('voice_no_voices')}</p>
)}
</div>
<PortalVoicePicker
voices={voices}
value={value}
onChange={onChange}
disabled={disabled}
isLoading={isLoading || refreshing}
onRefresh={() => refresh()}
/>
)
}
+164
View File
@@ -3,9 +3,15 @@
*
* NOTE: @testing-library/react is not installed — tests cover pure logic
* and module contracts rather than DOM rendering.
*
* Phase 2 additions:
* - Gemini dispatch now routes to PortalVoicePicker (was StaticVoicePicker).
* - PortalVoice shape: voice_id + name; labels/preview_url optional.
* - VoiceRow gracefully handles absent preview_url + absent labels for Gemini.
*/
import { describe, it, expect, vi } from "vitest";
import type { Voice } from "@/api/voices";
import type { PortalVoice } from "@/components/voice-picker";
// --- helpers under test (extracted from voice-picker.tsx logic) ---
@@ -24,6 +30,14 @@ function filterVoices(voices: Voice[], search: string): Voice[] {
return voices.filter((v) => v.name.toLowerCase().includes(q));
}
/** Mirrors getVisibleLabels for PortalVoice (used by Gemini path). */
function getPortalVoiceLabels(voice: PortalVoice): string[] {
return LABEL_KEYS
.filter((k) => voice.labels?.[k])
.map((k) => voice.labels![k] as string)
.slice(0, 2);
}
// --- useVoices shape test via vi.mock ---
vi.mock("@/api/voices", () => ({
@@ -140,3 +154,153 @@ describe("useVoices — loading / empty / data states", () => {
expect(data[0]!.name).toBe("Aria");
});
});
// --- Phase 2: Gemini dispatch + PortalVoice shape tests ---
describe("PortalVoice — Gemini static voice shape", () => {
it("Gemini voice has only voice_id + name (no labels, no preview_url)", () => {
// Mirrors mapCapVoiceToPortal: VoiceOption → PortalVoice
const geminiVoice: PortalVoice = { voice_id: "Aoede", name: "Aoede" };
expect(geminiVoice.labels).toBeUndefined();
expect(geminiVoice.preview_url).toBeUndefined();
});
it("getPortalVoiceLabels returns empty array for Gemini voice (no labels field)", () => {
const geminiVoice: PortalVoice = { voice_id: "Kore", name: "Kore" };
expect(getPortalVoiceLabels(geminiVoice)).toEqual([]);
});
it("filterVoices works on PortalVoice array (Gemini search)", () => {
const geminiVoices: PortalVoice[] = [
{ voice_id: "Aoede", name: "Aoede" },
{ voice_id: "Kore", name: "Kore" },
{ voice_id: "Charon", name: "Charon" },
];
const filtered = geminiVoices.filter((v) =>
v.name.toLowerCase().includes("ao")
);
expect(filtered).toHaveLength(1);
expect(filtered[0]!.voice_id).toBe("Aoede");
});
it("filterVoices is case-insensitive for Gemini voices", () => {
const geminiVoices: PortalVoice[] = [
{ voice_id: "Charon", name: "Charon" },
{ voice_id: "Fenrir", name: "Fenrir" },
];
const filtered = geminiVoices.filter((v) =>
v.name.toLowerCase().includes("char")
);
expect(filtered).toHaveLength(1);
expect(filtered[0]!.name).toBe("Charon");
});
});
describe("PortalVoicePicker dispatch — Gemini routes to PortalVoicePicker (Phase 2)", () => {
/**
* Characterization: Gemini has static voices + no voices_dynamic flag.
* Before Phase 2: routed to StaticVoicePicker (Radix Select).
* After Phase 2: routes to PortalVoicePicker (search + row UI).
*
* We verify the dispatch rule via the capability shape check rather than DOM render
* (no @testing-library/react available).
*/
it("Gemini capabilities: no voices_dynamic flag + voices array present", () => {
// Simulates the capability shape that triggers the Gemini portal route
const geminiCaps = {
provider: "gemini",
display_name: "Google Gemini",
requires_api_key: true,
voices: [
{ voice_id: "Aoede", name: "Aoede" },
{ voice_id: "Kore", name: "Kore" },
],
// custom_features does NOT contain voices_dynamic for Gemini
custom_features: { multi_speaker: true, audio_tags: true } as Record<string, unknown>,
};
const voicesDynamic = geminiCaps.custom_features?.["voices_dynamic"] === true;
const staticVoices = geminiCaps.voices ?? [];
// Gemini route condition: provider === "gemini" AND staticVoices.length > 0
const routesToPortal = geminiCaps.provider === "gemini" && staticVoices.length > 0;
// Static route (OpenAI) condition: NOT gemini AND has caps AND !dynamic AND has voices
const routesToStatic = geminiCaps.provider !== "gemini" && !voicesDynamic && staticVoices.length > 0;
expect(voicesDynamic).toBe(false);
expect(staticVoices).toHaveLength(2);
expect(routesToPortal).toBe(true);
expect(routesToStatic).toBe(false);
});
it("OpenAI capabilities: no voices_dynamic + voices present → still routes to StaticVoicePicker", () => {
const openaiCaps = {
provider: "openai",
display_name: "OpenAI",
requires_api_key: true,
voices: [
{ voice_id: "alloy", name: "Alloy" },
{ voice_id: "echo", name: "Echo" },
],
custom_features: null,
};
const voicesDynamic = openaiCaps.custom_features?.["voices_dynamic"] === true;
const staticVoices = openaiCaps.voices ?? [];
const routesToPortal = openaiCaps.provider === "gemini" && staticVoices.length > 0;
const routesToStatic = openaiCaps.provider !== "gemini" && !voicesDynamic && staticVoices.length > 0;
expect(routesToPortal).toBe(false);
expect(routesToStatic).toBe(true);
});
it("ElevenLabs: no static voices + no voices_dynamic → routes to DynamicVoicePicker", () => {
const elevenlabsCaps = {
provider: "elevenlabs",
display_name: "ElevenLabs",
requires_api_key: true,
voices: [],
custom_features: null,
};
const voicesDynamic = elevenlabsCaps.custom_features?.["voices_dynamic"] === true;
const staticVoices = elevenlabsCaps.voices ?? [];
const routesToPortal = elevenlabsCaps.provider === "gemini" && staticVoices.length > 0;
const routesToStatic = elevenlabsCaps.provider !== "gemini" && !voicesDynamic && staticVoices.length > 0;
const routesToDynamic = !routesToPortal && !routesToStatic;
expect(routesToDynamic).toBe(true);
});
it("MiniMax: voices_dynamic=true → routes to DynamicVoicePicker with allowFreeText", () => {
const minimaxCaps = {
provider: "minimax",
display_name: "MiniMax",
requires_api_key: true,
voices: [],
custom_features: { voices_dynamic: true },
};
const voicesDynamic = minimaxCaps.custom_features?.["voices_dynamic"] === true;
expect(voicesDynamic).toBe(true);
// minimax provider → DynamicVoicePicker with allowFreeText=true
const allowFreeText = minimaxCaps.provider === "minimax";
expect(allowFreeText).toBe(true);
});
});
describe("mapCapVoiceToPortal — Gemini voice mapping", () => {
it("maps VoiceOption to PortalVoice with only voice_id + name", () => {
// Mirrors the mapCapVoiceToPortal function in voice-picker.tsx
const capVoice = { voice_id: "Aoede", name: "Aoede", language: "en-US", gender: "female" };
const portalVoice: PortalVoice = { voice_id: capVoice.voice_id, name: capVoice.name };
expect(portalVoice.voice_id).toBe("Aoede");
expect(portalVoice.name).toBe("Aoede");
expect(portalVoice.labels).toBeUndefined();
expect(portalVoice.preview_url).toBeUndefined();
});
});
+114 -38
View File
@@ -1,12 +1,12 @@
/**
* VoicePicker — provider-aware voice selection component.
*
* Dispatch logic (Phase C):
* Dispatch logic (Phase 2):
* - "" provider → disabled empty-state
* - provider whose capabilities include voices_dynamic=true OR has no static voices
* → DynamicVoicePicker (fetches /v1/voices)
* - provider with static voices[] in capabilities → StaticVoicePicker
* - MiniMax with voices_dynamic=true and first-fetch failure → FreeTextPicker fallback
* - gemini + static voices → PortalVoicePicker (search + row UI)
* - other provider with static voices[] + !voicesDynamic → StaticVoicePicker (Radix)
* - voices_dynamic=true OR minimax → DynamicVoicePicker → PortalVoicePicker
* - MiniMax first-fetch failure → FreeTextPicker fallback
*
* useTtsCapabilities() drives the dispatch; falls back to ElevenLabs-dynamic behavior
* when capabilities are not yet loaded (avoids flash of wrong picker).
@@ -26,9 +26,9 @@ import {
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { useVoices, useRefreshVoices, type Voice } from "@/api/voices";
import { useVoices, useRefreshVoices } from "@/api/voices";
import { VoicePreviewButton } from "@/components/voice-preview-button";
import { useTtsCapabilities } from "@/api/tts-capabilities";
import { useTtsCapabilities, type VoiceOption } from "@/api/tts-capabilities";
import type { TtsProviderId } from "@/data/tts-providers";
import { usePortalDropdownClose } from "@/hooks/use-portal-dropdown-close";
@@ -48,7 +48,23 @@ interface Props {
const LABEL_KEYS = ["gender", "accent", "age", "use_case"] as const;
function VoiceRow({ voice, selected, onSelect }: { voice: Voice; selected: boolean; onSelect: () => void }) {
/**
* Unified voice shape for PortalVoicePicker.
* Static callers (Gemini) populate only voice_id + name; labels/preview_url optional.
*/
export interface PortalVoice {
voice_id: string;
name: string;
labels?: Record<string, string>;
preview_url?: string;
}
/** Maps a capability VoiceOption to PortalVoice (labels/preview absent for Gemini). */
function mapCapVoiceToPortal(v: VoiceOption): PortalVoice {
return { voice_id: v.voice_id, name: v.name };
}
function VoiceRow({ voice, selected, onSelect }: { voice: PortalVoice; selected: boolean; onSelect: () => void }) {
const labelEntries = LABEL_KEYS
.filter((k) => voice.labels?.[k])
.map((k) => voice.labels![k]);
@@ -93,8 +109,21 @@ export function VoicePicker({ value, onChange, disabled, provider, placeholder }
// Static voices available in capabilities
const staticVoices = providerCaps?.voices ?? [];
// Gemini: static voices rendered through portal picker (search + row UI)
if (provider === "gemini" && staticVoices.length > 0) {
return (
<PortalVoicePicker
voices={staticVoices.map(mapCapVoiceToPortal)}
value={value}
onChange={onChange}
disabled={disabled}
placeholder={placeholder}
/>
);
}
if (providerCaps && !voicesDynamic && staticVoices.length > 0) {
// Static catalog available from capabilities
// Static catalog available from capabilities (OpenAI path → Radix Select)
return (
<StaticVoicePicker
value={value}
@@ -202,16 +231,33 @@ function FreeTextVoicePicker({
);
}
function DynamicVoicePicker({
/**
* PortalVoicePicker — search + scrollable VoiceRow list rendered via portal.
*
* Used by:
* - DynamicVoicePicker (ElevenLabs, MiniMax) — passes voices from useVoices()
* - VoicePicker dispatcher for Gemini — passes capability static voices
*
* Owns: open/search state, triggerRef/dropdownRef, usePortalDropdownClose, createPortal.
*/
export function PortalVoicePicker({
voices,
value,
onChange,
disabled,
allowFreeText,
isLoading,
onRefresh,
allowFreeText: _allowFreeText,
placeholder,
}: {
voices: PortalVoice[];
value?: string;
onChange: (id: string) => void;
onChange: (voice_id: string) => void;
disabled?: boolean;
allowFreeText: boolean;
isLoading?: boolean;
onRefresh?: () => void;
allowFreeText?: boolean;
placeholder?: string;
}) {
const { t } = useTranslation("tts");
const [open, setOpen] = useState(false);
@@ -220,14 +266,6 @@ function DynamicVoicePicker({
const dropdownRef = useRef<HTMLDivElement>(null);
const listboxId = useId();
const { data: voices = [], isLoading, isError } = useVoices();
const { mutate: refresh, isPending: refreshing } = useRefreshVoices();
// Fall back to free-text input when MiniMax voices fetch failed and list is empty
if (allowFreeText && isError && voices.length === 0) {
return <FreeTextVoicePicker value={value} onChange={onChange} disabled={disabled} />;
}
const selected = voices.find((v) => v.voice_id === value);
const filtered = search.trim()
@@ -243,7 +281,7 @@ function DynamicVoicePicker({
});
};
const handleSelect = (voice: Voice) => {
const handleSelect = (voice: PortalVoice) => {
onChange(voice.voice_id);
setOpen(false);
setSearch("");
@@ -251,7 +289,7 @@ function DynamicVoicePicker({
const handleRefresh = (e: React.MouseEvent) => {
e.stopPropagation();
refresh();
onRefresh?.();
};
usePortalDropdownClose({
@@ -265,7 +303,7 @@ function DynamicVoicePicker({
ref={dropdownRef}
id={listboxId}
role="listbox"
aria-label={t("voice_placeholder")}
aria-label={placeholder ?? t("voice_placeholder")}
className="pointer-events-auto z-50 min-w-[280px] rounded-md border bg-popover text-popover-foreground shadow-md"
style={(() => {
if (!triggerRef.current) return {};
@@ -283,20 +321,22 @@ function DynamicVoicePicker({
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("voice_placeholder")}
placeholder={placeholder ?? t("voice_placeholder")}
className="flex-1 bg-transparent text-base md:text-sm outline-none placeholder:text-muted-foreground"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
title={t("voice_refresh")}
disabled={refreshing}
onClick={handleRefresh}
className="shrink-0"
>
<RefreshCwIcon className={cn("size-4", refreshing && "animate-spin")} />
</Button>
{onRefresh && (
<Button
type="button"
variant="ghost"
size="icon-sm"
title={t("voice_refresh")}
disabled={isLoading}
onClick={handleRefresh}
className="shrink-0"
>
<RefreshCwIcon className={cn("size-4", isLoading && "animate-spin")} />
</Button>
)}
</div>
<div className="max-h-60 overflow-y-auto p-1">
@@ -309,7 +349,7 @@ function DynamicVoicePicker({
) : filtered.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
{voices.length === 0 ? t("voice_save_config_first") : search ? t("voice_no_voices") : t("voice_loading")}
</p>
</p>
) : (
filtered.map((voice) => (
<VoiceRow
@@ -341,7 +381,7 @@ function DynamicVoicePicker({
)}
>
<span className="truncate">
{isLoading ? t("voice_loading") : selected?.name ?? t("voice_placeholder")}
{isLoading ? t("voice_loading") : selected?.name ?? (placeholder ?? t("voice_placeholder"))}
</span>
<ChevronDownIcon className="size-4 shrink-0 opacity-50" />
</button>
@@ -350,3 +390,39 @@ function DynamicVoicePicker({
</div>
);
}
/**
* DynamicVoicePicker — thin wrapper that fetches voices and delegates to PortalVoicePicker.
* Handles free-text fallback for MiniMax on fetch error.
*/
function DynamicVoicePicker({
value,
onChange,
disabled,
allowFreeText,
}: {
value?: string;
onChange: (id: string) => void;
disabled?: boolean;
allowFreeText: boolean;
}) {
const { data: voices = [], isLoading, isError } = useVoices();
const { mutate: refresh, isPending: refreshing } = useRefreshVoices();
// Fall back to free-text input when MiniMax voices fetch failed and list is empty
if (allowFreeText && isError && voices.length === 0) {
return <FreeTextVoicePicker value={value} onChange={onChange} disabled={disabled} />;
}
return (
<PortalVoicePicker
voices={voices}
value={value}
onChange={onChange}
disabled={disabled}
isLoading={isLoading || refreshing}
onRefresh={() => refresh()}
allowFreeText={allowFreeText}
/>
);
}