2026-01-03 22:11:16 +01:00
|
|
|
import React from 'react';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
2026-01-08 17:01:24 +02:00
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
2026-01-03 22:11:16 +01:00
|
|
|
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
2026-05-13 13:26:15 +03:00
|
|
|
import { Icon } from "@/components/icon/Icon";
|
2026-01-03 22:11:16 +01:00
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
2026-05-20 16:26:40 +03:00
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
2026-01-03 22:11:16 +01:00
|
|
|
import { useModelLists } from '@/hooks/useModelLists';
|
2026-04-26 14:03:39 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
2026-05-20 16:26:40 +03:00
|
|
|
import { ModelPickerList, type ModelPickerEntry, type ModelPickerProvider } from '@/components/model-picker/ModelPickerList';
|
2026-01-03 22:11:16 +01:00
|
|
|
|
|
|
|
|
/** Chip height class - shared between chips and add button */
|
|
|
|
|
const CHIP_HEIGHT_CLASS = 'h-7';
|
|
|
|
|
|
|
|
|
|
/** UI-only type with instanceId for React keys and duplicate tracking */
|
|
|
|
|
export interface ModelSelectionWithId {
|
|
|
|
|
providerID: string;
|
|
|
|
|
modelID: string;
|
|
|
|
|
displayName?: string;
|
2026-01-08 17:01:24 +02:00
|
|
|
variant?: string;
|
2026-01-03 22:11:16 +01:00
|
|
|
instanceId: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Model selection without instanceId (for external use) */
|
|
|
|
|
export interface ModelSelection {
|
|
|
|
|
providerID: string;
|
|
|
|
|
modelID: string;
|
|
|
|
|
displayName?: string;
|
2026-01-08 17:01:24 +02:00
|
|
|
variant?: string;
|
2026-01-03 22:11:16 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// eslint-disable-next-line react-refresh/only-export-components -- Utility is tightly coupled with ModelMultiSelect
|
|
|
|
|
export const generateInstanceId = (): string => {
|
|
|
|
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Model selection chip with remove button.
|
|
|
|
|
* Shows instance index (e.g., "(2)") when same model is selected multiple times.
|
|
|
|
|
*/
|
|
|
|
|
export const ModelChip: React.FC<{
|
|
|
|
|
model: ModelSelectionWithId;
|
|
|
|
|
instanceIndex: number;
|
|
|
|
|
totalSameModel: number;
|
|
|
|
|
onRemove: () => void;
|
|
|
|
|
}> = ({ model, instanceIndex, totalSameModel, onRemove }) => {
|
|
|
|
|
const displayName = model.displayName || `${model.providerID}/${model.modelID}`;
|
|
|
|
|
const label = totalSameModel > 1 ? `${displayName} (${instanceIndex})` : displayName;
|
|
|
|
|
|
|
|
|
|
return (
|
2026-02-01 18:29:34 +02:00
|
|
|
<div className={cn('flex items-center gap-1.5 px-2 rounded-md bg-interactive-selection/20 border border-border/30', CHIP_HEIGHT_CLASS)}>
|
2026-01-03 22:11:16 +01:00
|
|
|
<ProviderLogo providerId={model.providerID} className="h-3.5 w-3.5" />
|
|
|
|
|
<span className="typography-meta font-medium truncate max-w-[140px]">
|
|
|
|
|
{label}
|
|
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onRemove}
|
|
|
|
|
className="text-muted-foreground hover:text-foreground ml-0.5"
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="close" className="h-3.5 w-3.5" />
|
2026-01-03 22:11:16 +01:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export interface ModelMultiSelectProps {
|
|
|
|
|
selectedModels: ModelSelectionWithId[];
|
|
|
|
|
onAdd: (model: ModelSelectionWithId) => void;
|
|
|
|
|
onRemove: (index: number) => void;
|
2026-01-08 17:01:24 +02:00
|
|
|
onUpdate?: (index: number, model: ModelSelectionWithId) => void;
|
2026-01-03 22:11:16 +01:00
|
|
|
/** Minimum models required (shows validation hint) */
|
|
|
|
|
minModels?: number;
|
|
|
|
|
/** Label for the add button */
|
|
|
|
|
addButtonLabel?: string;
|
2026-01-08 17:01:24 +02:00
|
|
|
/** Whether to show the selected chips */
|
2026-01-03 22:11:16 +01:00
|
|
|
showChips?: boolean;
|
|
|
|
|
/** Maximum models allowed */
|
|
|
|
|
maxModels?: number;
|
2026-03-22 22:31:29 +02:00
|
|
|
/** Optional className for add model trigger button */
|
|
|
|
|
addButtonClassName?: string;
|
2026-05-15 13:26:46 +03:00
|
|
|
/** Direction for the model picker popup. Multi-run launcher opens upward near the footer. */
|
|
|
|
|
dropdownSide?: 'top' | 'bottom';
|
|
|
|
|
/** Optional className for the picker popup. */
|
|
|
|
|
dropdownClassName?: string;
|
|
|
|
|
/** Optional className for the trigger/dropdown positioning container. */
|
|
|
|
|
containerClassName?: string;
|
|
|
|
|
/** Optional trigger icon override. */
|
|
|
|
|
triggerIcon?: React.ReactNode;
|
2026-01-03 22:11:16 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Model selector for multi-run (allows selecting same model multiple times).
|
|
|
|
|
*/
|
|
|
|
|
export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
|
|
|
|
|
selectedModels,
|
|
|
|
|
onAdd,
|
|
|
|
|
onRemove,
|
2026-01-08 17:01:24 +02:00
|
|
|
onUpdate,
|
2026-01-03 22:11:16 +01:00
|
|
|
minModels,
|
2026-04-26 14:03:39 +03:00
|
|
|
addButtonLabel,
|
2026-01-03 22:11:16 +01:00
|
|
|
showChips = true,
|
|
|
|
|
maxModels,
|
2026-03-22 22:31:29 +02:00
|
|
|
addButtonClassName,
|
2026-05-15 13:26:46 +03:00
|
|
|
dropdownSide = 'top',
|
|
|
|
|
dropdownClassName,
|
|
|
|
|
containerClassName,
|
|
|
|
|
triggerIcon,
|
2026-01-03 22:11:16 +01:00
|
|
|
}) => {
|
2026-04-26 14:03:39 +03:00
|
|
|
const { t } = useI18n();
|
2026-05-20 16:26:40 +03:00
|
|
|
const providers = useConfigStore((state) => state.providers) as ModelPickerProvider[];
|
2026-04-04 02:19:55 +03:00
|
|
|
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
|
2026-05-20 16:26:40 +03:00
|
|
|
const toggleFavoriteModel = useUIStore((state) => state.toggleFavoriteModel);
|
|
|
|
|
const isFavoriteModel = useUIStore((state) => state.isFavoriteModel);
|
2026-01-03 22:11:16 +01:00
|
|
|
const { favoriteModelsList, recentModelsList } = useModelLists();
|
|
|
|
|
const [isOpen, setIsOpen] = React.useState(false);
|
|
|
|
|
const [searchQuery, setSearchQuery] = React.useState('');
|
2026-01-04 01:24:29 +02:00
|
|
|
const [availableHeight, setAvailableHeight] = React.useState<number | null>(null);
|
2026-01-03 22:11:16 +01:00
|
|
|
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
2026-01-04 01:24:29 +02:00
|
|
|
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
2026-05-15 13:26:46 +03:00
|
|
|
const isSingleSelect = maxModels === 1;
|
|
|
|
|
const canAddModel = maxModels === undefined || selectedModels.length < maxModels || isSingleSelect;
|
2026-01-03 22:11:16 +01:00
|
|
|
|
|
|
|
|
// Count occurrences of each model for display purposes
|
|
|
|
|
const modelCounts = React.useMemo(() => {
|
|
|
|
|
const counts = new Map<string, number>();
|
|
|
|
|
for (const m of selectedModels) {
|
|
|
|
|
const key = `${m.providerID}:${m.modelID}`;
|
|
|
|
|
counts.set(key, (counts.get(key) || 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
return counts;
|
|
|
|
|
}, [selectedModels]);
|
|
|
|
|
|
|
|
|
|
// Get instance index for a specific model selection
|
|
|
|
|
const getInstanceIndex = React.useCallback((model: ModelSelectionWithId): number => {
|
|
|
|
|
const sameModels = selectedModels.filter(
|
|
|
|
|
m => m.providerID === model.providerID && m.modelID === model.modelID
|
|
|
|
|
);
|
|
|
|
|
return sameModels.findIndex(m => m.instanceId === model.instanceId) + 1;
|
|
|
|
|
}, [selectedModels]);
|
|
|
|
|
|
2026-05-15 13:26:46 +03:00
|
|
|
// Calculate available height: multi-run opens upward inside a scroller; fusion opens downward and may extend past the dialog.
|
2026-01-04 01:24:29 +02:00
|
|
|
React.useEffect(() => {
|
2026-03-22 22:31:29 +02:00
|
|
|
if (!isOpen || !triggerRef.current) return;
|
|
|
|
|
|
|
|
|
|
const triggerRect = triggerRef.current.getBoundingClientRect();
|
|
|
|
|
|
2026-05-15 13:26:46 +03:00
|
|
|
if (dropdownSide === 'bottom') {
|
|
|
|
|
const viewportHeight = window.visualViewport?.height ?? document.documentElement.clientHeight ?? window.innerHeight;
|
|
|
|
|
const spaceBelow = viewportHeight - triggerRect.bottom - 16;
|
|
|
|
|
// availableHeight is only the scrollable model list; reserve room for search + keyboard hint chrome.
|
|
|
|
|
const listSpaceBelow = spaceBelow - 112;
|
|
|
|
|
setAvailableHeight(Math.max(160, Math.min(320, listSpaceBelow)));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 22:31:29 +02:00
|
|
|
// Find the nearest dialog or overflow ancestor to constrain within
|
|
|
|
|
let container: HTMLElement | null = triggerRef.current.parentElement;
|
|
|
|
|
while (container) {
|
|
|
|
|
if (container.getAttribute('role') === 'dialog' || container.hasAttribute('data-scroll-shadow')) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
const style = getComputedStyle(container);
|
|
|
|
|
if (style.overflow === 'auto' || style.overflow === 'hidden' || style.overflowY === 'auto' || style.overflowY === 'hidden') {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
container = container.parentElement;
|
2026-01-04 01:24:29 +02:00
|
|
|
}
|
2026-03-22 22:31:29 +02:00
|
|
|
|
|
|
|
|
const topBound = container ? container.getBoundingClientRect().top : 0;
|
|
|
|
|
const spaceAbove = triggerRect.top - topBound - 16;
|
|
|
|
|
// Cap: min 150, max 300
|
|
|
|
|
setAvailableHeight(Math.max(150, Math.min(300, spaceAbove)));
|
2026-05-15 13:26:46 +03:00
|
|
|
}, [dropdownSide, isOpen]);
|
2026-01-04 01:24:29 +02:00
|
|
|
|
2026-05-08 08:32:05 -04:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!canAddModel && isOpen) {
|
|
|
|
|
setIsOpen(false);
|
|
|
|
|
setSearchQuery('');
|
|
|
|
|
}
|
|
|
|
|
}, [canAddModel, isOpen]);
|
|
|
|
|
|
2026-01-03 22:11:16 +01:00
|
|
|
// Close dropdown when clicking outside
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!isOpen) return;
|
|
|
|
|
|
|
|
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
|
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
|
|
|
setIsOpen(false);
|
|
|
|
|
setSearchQuery('');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
|
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
|
|
|
}, [isOpen]);
|
|
|
|
|
|
2026-05-20 16:26:40 +03:00
|
|
|
const handleSelectModel = React.useCallback((entry: ModelPickerEntry) => {
|
|
|
|
|
const nextModel = {
|
|
|
|
|
providerID: entry.providerID,
|
|
|
|
|
modelID: entry.modelID,
|
|
|
|
|
displayName: (entry.model.name as string) || entry.modelID,
|
|
|
|
|
instanceId: generateInstanceId(),
|
|
|
|
|
};
|
|
|
|
|
if (isSingleSelect && selectedModels.length > 0 && onUpdate) {
|
|
|
|
|
onUpdate(0, nextModel);
|
|
|
|
|
} else {
|
|
|
|
|
onAdd(nextModel);
|
|
|
|
|
}
|
|
|
|
|
if (isSingleSelect) {
|
|
|
|
|
setIsOpen(false);
|
|
|
|
|
setSearchQuery('');
|
|
|
|
|
}
|
|
|
|
|
}, [isSingleSelect, onAdd, onUpdate, selectedModels.length]);
|
|
|
|
|
|
|
|
|
|
const labels = React.useMemo(() => ({
|
|
|
|
|
searchPlaceholder: t('multirun.modelMultiSelect.search.placeholder'),
|
|
|
|
|
noResults: t('multirun.modelMultiSelect.search.noResults'),
|
|
|
|
|
favorites: t('multirun.modelMultiSelect.sections.favorites'),
|
|
|
|
|
recent: t('multirun.modelMultiSelect.sections.recent'),
|
|
|
|
|
keyboardHint: t('multirun.modelMultiSelect.keyboard.hint'),
|
|
|
|
|
favorite: t('settings.agents.modelSelector.actions.favorite'),
|
|
|
|
|
unfavorite: t('settings.agents.modelSelector.actions.unfavorite'),
|
|
|
|
|
capabilities: t('chat.modelControls.capabilities'),
|
|
|
|
|
capabilityToolCalling: t('chat.modelControls.capability.toolCalling'),
|
|
|
|
|
capabilityReasoning: t('chat.modelControls.capability.reasoning'),
|
|
|
|
|
input: t('chat.modelControls.input'),
|
|
|
|
|
output: t('chat.modelControls.output'),
|
|
|
|
|
costPerMillion: t('chat.modelControls.costPerMillion'),
|
|
|
|
|
}), [t]);
|
2026-01-03 22:11:16 +01:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="flex flex-wrap gap-1.5 items-center">
|
|
|
|
|
{/* Add model button (dropdown trigger) */}
|
2026-05-15 13:26:46 +03:00
|
|
|
<div className={cn('relative', containerClassName)} ref={dropdownRef}>
|
2026-01-03 22:11:16 +01:00
|
|
|
<Button
|
2026-01-04 01:24:29 +02:00
|
|
|
ref={triggerRef}
|
2026-01-03 22:11:16 +01:00
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
2026-03-22 22:31:29 +02:00
|
|
|
className={cn(
|
|
|
|
|
CHIP_HEIGHT_CLASS,
|
2026-04-07 21:46:38 +03:00
|
|
|
'!border-border/80 !bg-[var(--surface-subtle)] hover:!bg-[var(--interactive-hover)]/70',
|
2026-03-22 22:31:29 +02:00
|
|
|
addButtonClassName,
|
|
|
|
|
)}
|
2026-05-08 08:32:05 -04:00
|
|
|
disabled={!canAddModel}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setIsOpen(!isOpen);
|
|
|
|
|
}}
|
2026-01-03 22:11:16 +01:00
|
|
|
>
|
2026-05-15 13:26:46 +03:00
|
|
|
{triggerIcon ?? <Icon name="add" className="h-3.5 w-3.5 mr-1" />}
|
2026-04-26 14:03:39 +03:00
|
|
|
{addButtonLabel ?? t('multirun.modelMultiSelect.actions.addModel')}
|
2026-01-03 22:11:16 +01:00
|
|
|
</Button>
|
|
|
|
|
|
2026-05-20 16:26:40 +03:00
|
|
|
{isOpen ? (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'absolute left-0 z-50 w-[min(420px,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] flex flex-col overflow-hidden rounded-xl border border-border/50 shadow-lg',
|
|
|
|
|
dropdownSide === 'top' ? 'bottom-full mb-1' : 'top-full mt-1',
|
|
|
|
|
dropdownClassName,
|
|
|
|
|
)}
|
|
|
|
|
style={{
|
|
|
|
|
background: 'linear-gradient(var(--surface-elevated),var(--surface-elevated)),linear-gradient(var(--surface-background),var(--surface-background))',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<ModelPickerList
|
|
|
|
|
providers={providers}
|
|
|
|
|
favoriteModels={favoriteModelsList}
|
|
|
|
|
recentModels={recentModelsList}
|
|
|
|
|
modelsMetadata={modelsMetadata}
|
|
|
|
|
searchQuery={searchQuery}
|
|
|
|
|
onSearchQueryChange={setSearchQuery}
|
|
|
|
|
onSelect={handleSelectModel}
|
|
|
|
|
labels={labels}
|
|
|
|
|
selectionCount={(entry) => modelCounts.get(`${entry.providerID}:${entry.modelID}`) || 0}
|
|
|
|
|
disabled={!canAddModel}
|
|
|
|
|
maxHeightClassName="flex-1"
|
|
|
|
|
maxHeightStyle={{ maxHeight: availableHeight ? `${availableHeight}px` : '300px' }}
|
|
|
|
|
stickyHeaders
|
|
|
|
|
tooltipsEnabled={isOpen}
|
|
|
|
|
isFavorite={(entry) => isFavoriteModel(entry.providerID, entry.modelID)}
|
|
|
|
|
onToggleFavorite={(entry) => toggleFavoriteModel(entry.providerID, entry.modelID)}
|
|
|
|
|
onEscape={() => {
|
|
|
|
|
setIsOpen(false);
|
|
|
|
|
setSearchQuery('');
|
2026-03-22 22:31:29 +02:00
|
|
|
}}
|
2026-05-20 16:26:40 +03:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-01-03 22:11:16 +01:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Selected models */}
|
2026-01-08 17:01:24 +02:00
|
|
|
{showChips && selectedModels.length > 0 && (
|
|
|
|
|
<div className="flex flex-col gap-2 w-full">
|
|
|
|
|
{selectedModels.map((model, index) => {
|
|
|
|
|
const key = `${model.providerID}:${model.modelID}`;
|
|
|
|
|
const totalSameModel = modelCounts.get(key) || 1;
|
|
|
|
|
const instanceIndex = getInstanceIndex(model);
|
|
|
|
|
|
|
|
|
|
const provider = providers.find((p) => p.id === model.providerID);
|
2026-05-20 16:26:40 +03:00
|
|
|
const providerModel = provider?.models?.find((m: Record<string, unknown>) => (m as { id?: string }).id === model.modelID) as
|
2026-01-08 17:01:24 +02:00
|
|
|
| { variants?: Record<string, unknown> }
|
|
|
|
|
| undefined;
|
|
|
|
|
const variantKeys = providerModel?.variants ? Object.keys(providerModel.variants) : [];
|
|
|
|
|
const hasVariants = variantKeys.length > 0;
|
|
|
|
|
|
|
|
|
|
const DEFAULT_VARIANT_VALUE = '__default__';
|
|
|
|
|
const variantValue = model.variant ?? DEFAULT_VARIANT_VALUE;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={model.instanceId} className="flex items-center gap-2 min-w-0">
|
|
|
|
|
<ModelChip
|
|
|
|
|
model={model}
|
|
|
|
|
instanceIndex={instanceIndex}
|
|
|
|
|
totalSameModel={totalSameModel}
|
|
|
|
|
onRemove={() => onRemove(index)}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{hasVariants && (
|
|
|
|
|
<Select
|
|
|
|
|
value={variantValue}
|
|
|
|
|
onValueChange={(value) => {
|
|
|
|
|
if (!onUpdate) return;
|
|
|
|
|
const nextVariant = value === DEFAULT_VARIANT_VALUE ? undefined : value;
|
|
|
|
|
onUpdate(index, { ...model, variant: nextVariant });
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-03-22 22:31:29 +02:00
|
|
|
<SelectTrigger
|
|
|
|
|
size="chip"
|
2026-04-07 21:46:38 +03:00
|
|
|
className="px-2 gap-1.5 rounded-md !border-border/80 !bg-[var(--surface-subtle)] hover:!bg-[var(--interactive-hover)]/70 typography-meta font-medium text-foreground"
|
2026-03-22 22:31:29 +02:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="brain-ai-3"
|
2026-01-08 17:01:24 +02:00
|
|
|
className={cn(
|
|
|
|
|
'h-3.5 w-3.5 flex-shrink-0',
|
|
|
|
|
variantValue === DEFAULT_VARIANT_VALUE ? 'text-muted-foreground' : 'text-[color:var(--status-info)]'
|
|
|
|
|
)}
|
|
|
|
|
/>
|
2026-05-14 23:33:04 +03:00
|
|
|
<SelectValue placeholder={t('multirun.modelMultiSelect.variant.placeholder')}>
|
|
|
|
|
{(value) => value === DEFAULT_VARIANT_VALUE
|
|
|
|
|
? t('multirun.modelMultiSelect.variant.default')
|
|
|
|
|
: value}
|
|
|
|
|
</SelectValue>
|
2026-01-08 17:01:24 +02:00
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent fitContent>
|
|
|
|
|
<SelectItem value={DEFAULT_VARIANT_VALUE} className="pr-2 [&>span:first-child]:hidden">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('multirun.modelMultiSelect.variant.default')}
|
2026-01-08 17:01:24 +02:00
|
|
|
</SelectItem>
|
|
|
|
|
{variantKeys.map((variant) => (
|
|
|
|
|
<SelectItem key={variant} value={variant} className="pr-2 [&>span:first-child]:hidden">
|
|
|
|
|
{variant}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-03 22:11:16 +01:00
|
|
|
</div>
|
2026-01-07 12:08:53 +02:00
|
|
|
|
2026-01-03 22:11:16 +01:00
|
|
|
{/* Validation hint */}
|
|
|
|
|
{minModels !== undefined && selectedModels.length < minModels && (
|
|
|
|
|
<p className="typography-micro text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{maxModels !== undefined
|
|
|
|
|
? t('multirun.modelMultiSelect.validation.minToMax', { min: minModels, max: maxModels })
|
|
|
|
|
: t('multirun.modelMultiSelect.validation.minOnly', { min: minModels })}
|
2026-01-03 22:11:16 +01:00
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|