import React from 'react';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import { useModelLists } from '@/hooks/useModelLists';
import { useI18n } from '@/lib/i18n';
import { ModelPickerList, type ModelPickerEntry, type ModelPickerProvider } from '@/components/model-picker/ModelPickerList';
/** 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;
variant?: string;
instanceId: string;
}
/** Model selection without instanceId (for external use) */
export interface ModelSelection {
providerID: string;
modelID: string;
displayName?: string;
variant?: string;
}
// 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 (
);
};
export interface ModelMultiSelectProps {
selectedModels: ModelSelectionWithId[];
onAdd: (model: ModelSelectionWithId) => void;
onRemove: (index: number) => void;
onUpdate?: (index: number, model: ModelSelectionWithId) => void;
/** Minimum models required (shows validation hint) */
minModels?: number;
/** Label for the add button */
addButtonLabel?: string;
/** Whether to show the selected chips */
showChips?: boolean;
/** Maximum models allowed */
maxModels?: number;
/** Optional className for add model trigger button */
addButtonClassName?: string;
/** 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;
}
/**
* Model selector for multi-run (allows selecting same model multiple times).
*/
export const ModelMultiSelect: React.FC = ({
selectedModels,
onAdd,
onRemove,
onUpdate,
minModels,
addButtonLabel,
showChips = true,
maxModels,
addButtonClassName,
dropdownSide = 'top',
dropdownClassName,
containerClassName,
triggerIcon,
}) => {
const { t } = useI18n();
const providers = useConfigStore((state) => state.providers) as ModelPickerProvider[];
const modelsMetadata = useConfigStore((state) => state.modelsMetadata);
const toggleFavoriteModel = useUIStore((state) => state.toggleFavoriteModel);
const isFavoriteModel = useUIStore((state) => state.isFavoriteModel);
const { favoriteModelsList, recentModelsList } = useModelLists();
const [isOpen, setIsOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
const [availableHeight, setAvailableHeight] = React.useState(null);
const dropdownRef = React.useRef(null);
const triggerRef = React.useRef(null);
const isSingleSelect = maxModels === 1;
const canAddModel = maxModels === undefined || selectedModels.length < maxModels || isSingleSelect;
// Count occurrences of each model for display purposes
const modelCounts = React.useMemo(() => {
const counts = new Map();
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]);
// Calculate available height: multi-run opens upward inside a scroller; fusion opens downward and may extend past the dialog.
React.useEffect(() => {
if (!isOpen || !triggerRef.current) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
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;
}
// 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;
}
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)));
}, [dropdownSide, isOpen]);
React.useEffect(() => {
if (!canAddModel && isOpen) {
setIsOpen(false);
setSearchQuery('');
}
}, [canAddModel, isOpen]);
// 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]);
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]);
return (
{/* Add model button (dropdown trigger) */}
{isOpen ? (
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('');
}}
/>
) : null}
{/* Selected models */}
{showChips && selectedModels.length > 0 && (
{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);
const providerModel = provider?.models?.find((m: Record
) => (m as { id?: string }).id === model.modelID) as
| { variants?: Record }
| 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 (
onRemove(index)}
/>
{hasVariants && (
)}
);
})}
)}
{/* Validation hint */}
{minModels !== undefined && selectedModels.length < minModels && (
{maxModels !== undefined
? t('multirun.modelMultiSelect.validation.minToMax', { min: minModels, max: maxModels })
: t('multirun.modelMultiSelect.validation.minOnly', { min: minModels })}
)}
);
};