OpenMAIC-React / src /components /generation /generation-toolbar.tsx
muthuk1's picture
Convert OpenMAIC from Next.js to React (Vite)
f56a29b verified
import { useState, useRef, useMemo } from 'react';
import { Bot, Brain, Check, Paperclip, FileText, X, Globe2, Search } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import { PDF_PROVIDERS } from '@/lib/pdf/constants';
import type { PDFProviderId } from '@/lib/pdf/types';
import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants';
import type { WebSearchProviderId } from '@/lib/web-search/types';
import type { ProviderId } from '@/lib/ai/providers';
import type {
ModelInfo,
ThinkingConfig,
ThinkingEffort,
ThinkingLevel,
} from '@/lib/types/provider';
import {
getDefaultThinkingConfig,
getThinkingDisplayValue,
getThinkingConfigKey,
normalizeThinkingConfig,
supportsConfigurableThinking,
} from '@/lib/ai/thinking-config';
import type { SettingsSection } from '@/lib/types/settings';
import { MediaPopover } from '@/components/generation/media-popover';
// ─── Constants ───────────────────────────────────────────────
const MAX_PDF_SIZE_MB = 50;
const MAX_PDF_SIZE_BYTES = MAX_PDF_SIZE_MB * 1024 * 1024;
// ─── Types ───────────────────────────────────────────────────
export interface GenerationToolbarProps {
webSearch: boolean;
onWebSearchChange: (v: boolean) => void;
onSettingsOpen: (section?: SettingsSection) => void;
// PDF
pdfFile: File | null;
onPdfFileChange: (file: File | null) => void;
onPdfError: (error: string | null) => void;
}
// ─── Component ───────────────────────────────────────────────
export function GenerationToolbar({
webSearch,
onWebSearchChange,
onSettingsOpen,
pdfFile,
onPdfFileChange,
onPdfError,
}: GenerationToolbarProps) {
const { t } = useI18n();
const currentProviderId = useSettingsStore((s) => s.providerId);
const currentModelId = useSettingsStore((s) => s.modelId);
const providersConfig = useSettingsStore((s) => s.providersConfig);
const setModel = useSettingsStore((s) => s.setModel);
const thinkingConfigs = useSettingsStore((s) => s.thinkingConfigs);
const setThinkingConfig = useSettingsStore((s) => s.setThinkingConfig);
const pdfProviderId = useSettingsStore((s) => s.pdfProviderId);
const pdfProvidersConfig = useSettingsStore((s) => s.pdfProvidersConfig);
const setPDFProvider = useSettingsStore((s) => s.setPDFProvider);
const webSearchProviderId = useSettingsStore((s) => s.webSearchProviderId);
const webSearchProvidersConfig = useSettingsStore((s) => s.webSearchProvidersConfig);
const setWebSearchProvider = useSettingsStore((s) => s.setWebSearchProvider);
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
// Check if the selected web search provider has a valid config (API key or server-configured)
const webSearchProvider = WEB_SEARCH_PROVIDERS[webSearchProviderId];
const webSearchConfig = webSearchProvidersConfig[webSearchProviderId];
const webSearchAvailable = webSearchProvider
? !webSearchProvider.requiresApiKey ||
!!webSearchConfig?.apiKey ||
!!webSearchConfig?.isServerConfigured
: false;
// Configured LLM providers (only those with valid credentials + models + endpoint)
const configuredProviders = providersConfig
? Object.entries(providersConfig)
.filter(
([, config]) =>
(!config.requiresApiKey || config.apiKey || config.isServerConfigured) &&
config.models.length >= 1 &&
(config.baseUrl || config.defaultBaseUrl || config.serverBaseUrl),
)
.map(([id, config]) => ({
id: id as ProviderId,
name: config.name,
icon: config.icon,
isServerConfigured: config.isServerConfigured,
models:
config.isServerConfigured && !config.apiKey && config.serverModels?.length
? config.models.filter((m) => new Set(config.serverModels).has(m.id))
: config.models,
}))
: [];
const currentProviderConfig = providersConfig?.[currentProviderId];
const currentModel = currentProviderConfig?.models.find((model) => model.id === currentModelId);
const currentThinkingConfig =
thinkingConfigs[getThinkingConfigKey(currentProviderId, currentModelId)];
// PDF handler
const handleFileSelect = (file: File) => {
if (file.type !== 'application/pdf') return;
if (file.size > MAX_PDF_SIZE_BYTES) {
onPdfError(t('upload.fileTooLarge'));
return;
}
onPdfError(null);
onPdfFileChange(file);
};
// ─── Pill button helper ─────────────────────────────
const pillCls =
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all cursor-pointer select-none whitespace-nowrap border';
const pillMuted = `${pillCls} border-border/50 text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`;
const pillActive = `${pillCls} border-violet-200/60 dark:border-violet-700/50 bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300`;
return (
<div className="flex items-center gap-1 flex-wrap">
{/* ── Model selector ── */}
{configuredProviders.length > 0 ? (
<ModelSettingsPopover
configuredProviders={configuredProviders}
currentProviderId={currentProviderId}
currentModelId={currentModelId}
currentProviderConfig={currentProviderConfig}
currentModel={currentModel}
setModel={setModel}
thinkingConfig={currentThinkingConfig}
onThinkingChange={(config) =>
setThinkingConfig(currentProviderId, currentModelId, config)
}
t={t}
/>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onSettingsOpen('providers')}
className={cn(
pillCls,
'text-amber-600 dark:text-amber-400 animate-pulse',
'bg-amber-50 dark:bg-amber-950/30 hover:bg-amber-100 dark:hover:bg-amber-950/50',
)}
>
<Bot className="size-3.5" />
<span>{t('toolbar.configureProvider')}</span>
</button>
</TooltipTrigger>
<TooltipContent>{t('toolbar.configureProviderHint')}</TooltipContent>
</Tooltip>
)}
<div className="flex min-w-0 items-center gap-1">
{/* ── Separator ── */}
<div className="w-px h-4 bg-border/60 mx-1" />
{/* ── PDF (parser + upload) combined Popover ── */}
<Popover>
<PopoverTrigger asChild>
{pdfFile ? (
<button className={pillActive}>
<Paperclip className="size-3.5" />
<span className="max-w-[100px] truncate">{pdfFile.name}</span>
<span
role="button"
className="size-4 rounded-full inline-flex items-center justify-center hover:bg-violet-200 dark:hover:bg-violet-800 transition-colors"
onClick={(e) => {
e.stopPropagation();
onPdfFileChange(null);
}}
>
<X className="size-2.5" />
</span>
</button>
) : (
<button className={pillMuted}>
<Paperclip className="size-3.5" />
</button>
)}
</PopoverTrigger>
<PopoverContent align="start" className="w-72 p-0">
{/* Parser selector */}
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
<span className="text-xs font-medium text-muted-foreground shrink-0">
{t('toolbar.pdfParser')}
</span>
<Select
value={pdfProviderId}
onValueChange={(v) => setPDFProvider(v as PDFProviderId)}
>
<SelectTrigger className="h-7 text-xs flex-1 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(PDF_PROVIDERS).map((provider) => {
const cfg = pdfProvidersConfig[provider.id];
const available =
!provider.requiresApiKey || !!cfg?.apiKey || !!cfg?.isServerConfigured;
return (
<SelectItem key={provider.id} value={provider.id} disabled={!available}>
<div
className={cn('flex items-center gap-1.5', !available && 'opacity-50')}
>
{provider.icon && (
<img src={provider.icon} alt={provider.name} className="w-3.5 h-3.5" />
)}
{provider.name}
{cfg?.isServerConfigured && (
<span className="text-[9px] px-1 py-0 rounded border text-muted-foreground">
{t('settings.serverConfigured')}
</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{/* Upload area / file info */}
<div className="px-3 pb-3">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept=".pdf"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFileSelect(f);
e.target.value = '';
}}
/>
{pdfFile ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="size-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center shrink-0">
<FileText className="size-4 text-violet-600 dark:text-violet-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{pdfFile.name}</p>
<p className="text-xs text-muted-foreground">
{(pdfFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<button
onClick={() => onPdfFileChange(null)}
className="w-full text-xs text-destructive hover:underline text-left"
>
{t('toolbar.removePdf')}
</button>
</div>
) : (
<div
className={cn(
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors cursor-pointer',
isDragging
? 'border-violet-400 bg-violet-50 dark:bg-violet-950/20'
: 'border-muted-foreground/20 hover:border-violet-300',
)}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
const f = e.dataTransfer.files?.[0];
if (f) handleFileSelect(f);
}}
>
<Paperclip className="size-5 text-muted-foreground/50 mb-1.5" />
<p className="text-xs font-medium">{t('toolbar.pdfUpload')}</p>
<p className="text-[10px] text-muted-foreground/60 mt-0.5">
{t('upload.pdfSizeLimit')}
</p>
</div>
)}
</div>
</PopoverContent>
</Popover>
{/* ── Web Search ── */}
{webSearchAvailable ? (
<Popover>
<PopoverTrigger asChild>
<button className={webSearch ? pillActive : pillMuted}>
<Globe2 className={cn('size-3.5', webSearch && 'animate-pulse')} />
{webSearch && (
<span>{WEB_SEARCH_PROVIDERS[webSearchProviderId]?.name || 'Search'}</span>
)}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-3 space-y-3">
{/* Toggle */}
<button
onClick={() => onWebSearchChange(!webSearch)}
className={cn(
'w-full flex items-center gap-2.5 rounded-lg border px-3 py-2.5 text-left transition-all',
webSearch
? 'bg-violet-50 dark:bg-violet-950/20 border-violet-200 dark:border-violet-800'
: 'border-border hover:bg-muted/50',
)}
>
<Globe2
className={cn(
'size-4 shrink-0',
webSearch ? 'text-violet-600 dark:text-violet-400' : 'text-muted-foreground',
)}
/>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium">
{webSearch ? t('toolbar.webSearchOn') : t('toolbar.webSearchOff')}
</p>
<p className="text-[10px] text-muted-foreground/70 mt-0.5">
{t('toolbar.webSearchDesc')}
</p>
</div>
</button>
{/* Provider selector */}
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-muted-foreground shrink-0">
{t('toolbar.webSearchProvider')}
</span>
<Select
value={webSearchProviderId}
onValueChange={(v) => setWebSearchProvider(v as WebSearchProviderId)}
>
<SelectTrigger className="h-7 text-xs flex-1 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(WEB_SEARCH_PROVIDERS).map((provider) => {
const cfg = webSearchProvidersConfig[provider.id];
const available =
!provider.requiresApiKey || !!cfg?.apiKey || !!cfg?.isServerConfigured;
return (
<SelectItem key={provider.id} value={provider.id} disabled={!available}>
<div
className={cn('flex items-center gap-1.5', !available && 'opacity-50')}
>
{provider.name}
{cfg?.isServerConfigured && (
<span className="text-[9px] px-1 py-0 rounded border text-muted-foreground">
{t('settings.serverConfigured')}
</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</PopoverContent>
</Popover>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
className={cn(pillCls, 'text-muted-foreground/40 cursor-not-allowed')}
disabled
>
<Globe2 className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>{t('toolbar.webSearchNoProvider')}</TooltipContent>
</Tooltip>
)}
{/* ── Separator ── */}
<div className="w-px h-4 bg-border/60 mx-1" />
{/* ── Media popover ── */}
<MediaPopover onSettingsOpen={onSettingsOpen} />
</div>
</div>
);
}
function formatThinkingValue(value?: string, t?: (key: string) => string) {
if (!value) return '';
if (value === 'none') return t ? t('toolbar.off') : 'off';
if (t && (value === 'dynamic' || value === 'on' || value === 'off' || value === 'auto')) {
return t(`toolbar.${value}`);
}
return value === 'xhigh' ? 'x-high' : value;
}
function formatCompactThinkingValue(value?: string, t?: (key: string) => string) {
if (!value) return '';
const numericValue = Number(value);
if (Number.isFinite(numericValue) && value.trim() !== '') {
return numericValue >= 10000 ? `${Math.round(numericValue / 1000)}k` : `${numericValue}`;
}
return formatThinkingValue(value, t);
}
function InlineThinkingControl({
model,
config,
onChange,
t,
}: {
model?: ModelInfo;
config?: ThinkingConfig;
onChange: (config: ThinkingConfig | undefined) => void;
t: (key: string) => string;
}) {
const thinking = model?.capabilities?.thinking;
if (!supportsConfigurableThinking(thinking)) return null;
const effective = normalizeThinkingConfig(thinking, config) ?? getDefaultThinkingConfig(thinking);
const applyConfig = (next: ThinkingConfig) => {
onChange(normalizeThinkingConfig(thinking, next));
};
const applyBudget = (value: number | undefined) => {
applyConfig({ ...effective, mode: effective?.mode ?? 'enabled', budgetTokens: value });
};
const defaultEnabledBudget =
typeof thinking.defaultBudgetTokens === 'number' && thinking.defaultBudgetTokens > 0
? thinking.defaultBudgetTokens
: (thinking.budgetRange?.step ?? thinking.budgetRange?.min);
const applyAutoBudget = () => {
applyConfig({ ...effective, mode: 'auto', enabled: undefined, budgetTokens: -1 });
};
const applyBudgetMode = (mode: 'disabled' | 'enabled' | 'auto') => {
if (mode === 'auto') {
applyAutoBudget();
return;
}
applyConfig({
...effective,
mode,
enabled: mode === 'enabled',
budgetTokens:
mode === 'enabled' && effective?.budgetTokens === -1
? defaultEnabledBudget
: effective?.budgetTokens,
});
};
const applySimpleMode = (mode: 'disabled' | 'enabled' | 'auto') => {
applyConfig({
...effective,
mode,
enabled: mode === 'enabled' ? true : mode === 'disabled' ? false : undefined,
});
};
const selectTriggerCls =
'h-6 min-w-[84px] rounded-full border-0 bg-violet-100 px-2 py-0 !text-[10px] font-medium leading-none text-violet-700 shadow-none focus-visible:ring-0 data-[size=sm]:h-6 dark:bg-violet-900/40 dark:text-violet-200 [&_svg]:size-3';
const selectItemCls = 'py-1 text-xs';
const hasAutoBudget =
(thinking.control === 'toggle-budget' || thinking.control === 'budget-only') &&
!!thinking.budgetRange?.allowDynamic;
const autoBudgetMode =
effective?.budgetTokens === -1 && thinking.budgetRange?.allowDynamic
? 'auto'
: effective?.mode === 'disabled'
? 'disabled'
: 'enabled';
const simpleMode =
thinking.control === 'mode' && effective?.mode === 'auto'
? 'auto'
: effective?.mode === 'disabled'
? 'disabled'
: 'enabled';
return (
<div
className="flex min-w-0 shrink-0 items-center gap-1"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<Brain className="size-3.5 shrink-0 text-violet-500" />
<div className="flex min-w-0 items-center gap-0.5 rounded-full border border-violet-200/70 bg-white/65 p-0.5 dark:border-violet-800/70 dark:bg-violet-950/25">
{hasAutoBudget && (
<Select
value={autoBudgetMode}
onValueChange={(mode) => applyBudgetMode(mode as 'disabled' | 'enabled' | 'auto')}
>
<SelectTrigger
size="sm"
className="h-6 min-w-[76px] rounded-full border-0 bg-violet-100 px-2 py-0 !text-[10px] font-medium leading-none text-violet-700 shadow-none focus-visible:ring-0 data-[size=sm]:h-6 dark:bg-violet-900/40 dark:text-violet-200 [&_svg]:size-3"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end" className="min-w-[96px]">
{thinking.control === 'toggle-budget' && (
<SelectItem value="disabled" className={selectItemCls}>
{t('toolbar.off')}
</SelectItem>
)}
<SelectItem value="enabled" className={selectItemCls}>
{t('toolbar.on')}
</SelectItem>
<SelectItem value="auto" className={selectItemCls}>
{t('toolbar.auto')}
</SelectItem>
</SelectContent>
</Select>
)}
{(thinking.control === 'toggle' ||
(thinking.control === 'toggle-budget' && !hasAutoBudget) ||
thinking.control === 'mode') && (
<Select
value={simpleMode}
onValueChange={(mode) => applySimpleMode(mode as 'disabled' | 'enabled' | 'auto')}
>
<SelectTrigger
size="sm"
className="h-6 min-w-[76px] rounded-full border-0 bg-violet-100 px-2 py-0 !text-[10px] font-medium leading-none text-violet-700 shadow-none focus-visible:ring-0 data-[size=sm]:h-6 dark:bg-violet-900/40 dark:text-violet-200 [&_svg]:size-3"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end" className="min-w-[96px]">
{thinking.control === 'mode' && (
<SelectItem value="auto" className={selectItemCls}>
{t('toolbar.auto')}
</SelectItem>
)}
<SelectItem value="disabled" className={selectItemCls}>
{t('toolbar.off')}
</SelectItem>
<SelectItem value="enabled" className={selectItemCls}>
{t('toolbar.on')}
</SelectItem>
</SelectContent>
</Select>
)}
{thinking.control === 'level' && !!thinking.levelValues?.length && (
<Select
value={effective?.level ?? thinking.defaultLevel ?? thinking.levelValues[0]}
onValueChange={(level) =>
applyConfig({ ...effective, mode: 'enabled', level: level as ThinkingLevel })
}
>
<SelectTrigger size="sm" className={selectTriggerCls}>
<SelectValue />
</SelectTrigger>
<SelectContent align="end" className="min-w-[96px]">
{thinking.levelValues.map((level: ThinkingLevel) => (
<SelectItem key={level} value={level} className={selectItemCls}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{thinking.control === 'effort' && !!thinking.effortValues?.length && (
<Select
value={effective?.effort ?? thinking.defaultEffort ?? thinking.effortValues[0]}
onValueChange={(effort) =>
applyConfig({
...effective,
mode: effort === 'none' ? 'disabled' : 'enabled',
effort: effort as ThinkingEffort,
})
}
>
<SelectTrigger size="sm" className={selectTriggerCls}>
<SelectValue />
</SelectTrigger>
<SelectContent align="end" className="min-w-[104px]">
{thinking.effortValues.map((effort: ThinkingEffort) => (
<SelectItem key={effort} value={effort} className={selectItemCls}>
{formatThinkingValue(effort, t)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{(thinking.control === 'toggle-budget' || thinking.control === 'budget-only') &&
thinking.budgetRange &&
(!hasAutoBudget || autoBudgetMode === 'enabled') && (
<label className="ml-0.5 grid h-6 shrink-0 grid-cols-[auto_60px] items-stretch overflow-hidden rounded-full border border-violet-200/70 bg-background dark:border-violet-800/70">
<span className="grid h-[22px] shrink-0 place-items-center border-r border-violet-200/70 bg-muted/30 px-2 font-sans text-[11px] font-medium leading-[22px] text-muted-foreground dark:border-violet-800/70">
{t('toolbar.thinkingBudget')}
</span>
<input
type="text"
inputMode="numeric"
aria-label={t('toolbar.thinkingBudget')}
disabled={effective?.mode === 'disabled'}
value={
typeof effective?.budgetTokens === 'number' && effective.budgetTokens !== -1
? effective.budgetTokens
: ''
}
placeholder={`${thinking.budgetRange.min}-${thinking.budgetRange.max}`}
title={`${thinking.budgetRange.min}-${thinking.budgetRange.max} tokens`}
onChange={(event) => {
const rawValue = event.target.value.trim();
if (!/^\d*$/.test(rawValue)) return;
const value = rawValue ? Number(rawValue) : undefined;
applyBudget(value);
}}
className="block h-[22px] w-[60px] border-0 bg-transparent px-1 py-0 text-center font-sans text-[11px] font-medium leading-[22px] tabular-nums outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
/>
</label>
)}
</div>
</div>
);
}
// ─── ModelSettingsPopover (provider + model picker) ─────
interface ConfiguredProvider {
id: ProviderId;
name: string;
icon?: string;
isServerConfigured?: boolean;
models: ModelInfo[];
}
function ModelSettingsPopover({
configuredProviders,
currentProviderId,
currentModelId,
currentProviderConfig,
currentModel,
setModel,
thinkingConfig,
onThinkingChange,
t,
}: {
configuredProviders: ConfiguredProvider[];
currentProviderId: ProviderId;
currentModelId: string;
currentProviderConfig: { name: string; icon?: string } | undefined;
currentModel?: ModelInfo;
setModel: (providerId: ProviderId, modelId: string) => void;
thinkingConfig?: ThinkingConfig;
onThinkingChange: (config: ThinkingConfig | undefined) => void;
t: (key: string) => string;
}) {
const [popoverOpen, setPopoverOpen] = useState(false);
const [activeProviderId, setActiveProviderId] = useState<ProviderId>(currentProviderId);
const [searchQuery, setSearchQuery] = useState('');
const currentProvider = configuredProviders.find((provider) => provider.id === currentProviderId);
const searchTerm = searchQuery.trim().toLowerCase();
const isSearching = searchTerm.length > 0;
const providerEntries = useMemo(() => {
const matchesSearch = (model: ModelInfo) =>
!searchTerm ||
model.name.toLowerCase().includes(searchTerm) ||
model.id.toLowerCase().includes(searchTerm);
return configuredProviders
.map((provider) => ({
provider,
matchingModels: provider.models.filter(matchesSearch),
}))
.filter((entry) => !isSearching || entry.matchingModels.length > 0);
}, [configuredProviders, isSearching, searchTerm]);
const activeProviderVisible = providerEntries.some(
(entry) => entry.provider.id === activeProviderId,
);
const firstVisibleProviderId = providerEntries[0]?.provider.id;
const resolvedActiveProviderId =
isSearching && !activeProviderVisible ? firstVisibleProviderId : activeProviderId;
const activeProviderEntry =
providerEntries.find((entry) => entry.provider.id === resolvedActiveProviderId) ??
providerEntries[0];
const activeProvider = activeProviderEntry?.provider;
const visibleModelEntries = useMemo(() => {
if (!activeProviderEntry) return [];
const { provider, matchingModels } = activeProviderEntry;
return matchingModels.map((model) => ({ provider, model }));
}, [activeProviderEntry]);
const currentProviderName =
currentProvider?.name ?? currentProviderConfig?.name ?? currentProviderId;
const currentProviderIcon = currentProvider?.icon ?? currentProviderConfig?.icon;
const currentModelLabel = currentModel?.name || currentModelId || t('settings.selectModel');
const currentThinkingValue = getThinkingDisplayValue(
currentModel?.capabilities?.thinking,
thinkingConfig,
);
const currentThinkingLabel = formatCompactThinkingValue(currentThinkingValue, t);
return (
<Popover
open={popoverOpen}
onOpenChange={(nextOpen) => {
setPopoverOpen(nextOpen);
if (nextOpen) {
setActiveProviderId(currentProviderId);
setSearchQuery('');
}
}}
>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
aria-label={`${currentProviderName} / ${currentModelLabel}`}
className={cn(
'inline-flex h-8 min-w-0 items-center gap-1.5 rounded-full border px-2 text-xs font-medium transition-all',
'border-violet-200/70 bg-violet-50 text-violet-700 hover:bg-violet-100 dark:border-violet-800/70 dark:bg-violet-950/30 dark:text-violet-300',
currentModelId &&
'shadow-[0_0_0_1px_rgba(124,58,237,0.12)] dark:shadow-[0_0_0_1px_rgba(167,139,250,0.16)]',
)}
>
{currentProviderIcon ? (
<img
src={currentProviderIcon}
alt={currentProviderName}
className="size-4 shrink-0 rounded-sm"
/>
) : (
<Bot className="size-3.5 shrink-0" />
)}
{currentThinkingLabel && (
<span className="shrink-0 rounded-full bg-white/80 px-1.5 py-0.5 text-[10px] font-semibold leading-none text-violet-700 ring-1 ring-violet-200/70 dark:bg-violet-950/50 dark:text-violet-200 dark:ring-violet-800/70">
{currentThinkingLabel}
</span>
)}
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
{currentModelId
? `${currentProviderConfig?.name || currentProviderId} / ${currentModelId}`
: t('settings.selectModel')}
</TooltipContent>
</Tooltip>
<PopoverContent
align="start"
side="top"
sideOffset={8}
collisionPadding={12}
className="w-[640px] max-w-[calc(100vw-2rem)] overflow-hidden p-0"
>
<div className="grid h-[430px] grid-cols-[128px_minmax(0,1fr)] sm:grid-cols-[160px_minmax(0,1fr)]">
<div className="min-h-0 border-r bg-muted/20">
<div className="px-3 py-2 text-[10px] font-semibold uppercase text-muted-foreground">
{t('toolbar.selectProvider')}
</div>
<div className="h-[calc(100%-28px)] overflow-y-auto px-2 pb-2 pt-1">
{providerEntries.length === 0 ? (
<div className="px-2 py-4 text-[11px] text-muted-foreground">
{t('settings.noModelsFound')}
</div>
) : (
providerEntries.map(({ provider, matchingModels }) => {
const isActive = activeProvider?.id === provider.id;
const isCurrent = currentProviderId === provider.id;
return (
<button
key={provider.id}
onClick={() => setActiveProviderId(provider.id)}
className={cn(
'mb-1 flex h-10 w-full items-center gap-2 rounded-md px-2 text-left transition-colors',
isActive
? 'bg-background text-foreground shadow-sm ring-1 ring-border/70'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
)}
>
{provider.icon ? (
<img
src={provider.icon}
alt={provider.name}
className="size-4 shrink-0 rounded-sm"
/>
) : (
<Bot className="size-4 shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{provider.name}</div>
<div className="text-[10px] text-muted-foreground">
{isSearching ? `${matchingModels.length}/` : ''}
{provider.models.length}
</div>
</div>
{isCurrent && <span className="size-1.5 rounded-full bg-violet-500" />}
</button>
);
})
)}
</div>
</div>
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden">
<div className="border-b p-3">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(event) => {
const nextSearch = event.target.value;
setSearchQuery(nextSearch);
if (!nextSearch.trim()) setActiveProviderId(currentProviderId);
}}
placeholder={t('settings.searchModels')}
className="h-8 pl-8 text-xs"
/>
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{visibleModelEntries.length === 0 ? (
<div className="px-3 py-8 text-center text-xs text-muted-foreground">
{searchQuery ? t('settings.noModelsFound') : t('settings.noModelsAvailable')}
</div>
) : (
visibleModelEntries.map(({ provider, model }) => {
const isSelected =
currentProviderId === provider.id && currentModelId === model.id;
const selectModel = () => {
setActiveProviderId(provider.id);
setModel(provider.id, model.id);
};
return (
<div
key={`${provider.id}:${model.id}`}
role="button"
tabIndex={0}
onClick={selectModel}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
selectModel();
}}
className={cn(
'mb-1 flex min-h-11 w-full items-center gap-2 rounded-md px-2.5 py-2 text-left transition-colors',
isSelected
? 'bg-violet-50 text-violet-700 ring-1 ring-violet-200 dark:bg-violet-950/25 dark:text-violet-300 dark:ring-violet-800'
: 'hover:bg-muted/60',
)}
>
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-xs font-medium">{model.name}</div>
{model.id !== model.name && (
<div className="truncate font-mono text-[10px] text-muted-foreground">
{model.id}
</div>
)}
</div>
{isSelected && currentModel && (
<InlineThinkingControl
model={currentModel}
config={thinkingConfig}
onChange={onThinkingChange}
t={t}
/>
)}
{isSelected && (
<Check className="size-3.5 shrink-0 text-violet-600 dark:text-violet-400" />
)}
</div>
);
})
)}
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}