"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { AlertCircle, Bot, Check, ChevronDown } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useLingerExpand } from "@/hooks/use-linger-expand"; import ProviderIcon from "@/components/common/ProviderIcon"; import type { LLMSelection } from "@/lib/unified-ws"; import { llmSelectionKey, sameLLMSelection, type LLMOption, } from "@/lib/llm-options"; function formatContextWindow(value?: number) { if (!value) return ""; if (value >= 1_000_000) return `${Math.round(value / 1_000_000)}M ctx`; if (value >= 1_000) return `${Math.round(value / 1_000)}k ctx`; return `${value} ctx`; } function providerLabel(option: LLMOption) { return ( option.provider_label || option.provider || option.profile_name || "LLM" ); } function ModelOptionRow({ option, selected, onSelect, }: { option: LLMOption; selected: boolean; onSelect: () => void; }) { const { t } = useTranslation(); // Official model ID as the primary label (what gets sent to the API), // per design. The user-given nickname and profile live in the tooltip. const modelLabel = option.model || option.model_name; const contextWindow = formatContextWindow(option.context_window); // Long model ids ("google/gemini-3-flash-preview") get ellipsized by the // inline layout; hovering the row reveals the full id as an overlay. The // scrollWidth check at mouseenter time keeps the overlay away from rows // that aren't actually truncated. const nameRef = useRef(null); const [revealFull, setRevealFull] = useState(false); return ( ); } export default function ModelSelector({ options, activeDefault, value, loading, error, allowSystemDefault = false, systemDefaultLabel, systemDefaultDetail, helperText, placement = "top", onChange, }: { options: LLMOption[]; activeDefault: LLMSelection | null; value: LLMSelection | null; loading: boolean; error: boolean; allowSystemDefault?: boolean; systemDefaultLabel?: string; systemDefaultDetail?: string; helperText?: string; placement?: "top" | "bottom"; onChange: (selection: LLMSelection | null) => void; }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const rootRef = useRef(null); const { expanded, linger, triggerProps: lingerProps } = useLingerExpand(open); const selectedSelection = allowSystemDefault ? value : (value ?? activeDefault); const selectedKey = llmSelectionKey(selectedSelection); const selectedOption = useMemo( () => options.find((option) => sameLLMSelection(option, selectedSelection)) ?? null, [options, selectedSelection], ); useEffect(() => { if (!open) return; const handler = (event: MouseEvent) => { const target = event.target as Node; if (rootRef.current && !rootRef.current.contains(target)) { setOpen(false); linger(); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [open, linger]); const defaultLabel = systemDefaultLabel || t("System default"); const defaultDetail = systemDefaultDetail || t("Use the active default model from Settings"); const disabled = loading || error || (options.length === 0 && !allowSystemDefault); const label = loading ? t("Loading models") : error ? t("Models unavailable") : allowSystemDefault && !selectedSelection ? defaultLabel : // Official model ID, consistent with the dropdown rows. selectedOption?.model || selectedOption?.model_name || t("Select model"); const menuPlacementClass = placement === "bottom" ? "top-full mt-1.5" : "bottom-full mb-1.5"; return (
{/* Same resting/expanded treatment as PersonaSelector: the brand icon is the whole control at rest; hovering (or opening) slides the model name out with a max-width animation and lingers ~1.2s after leave/selection before collapsing. */} {open && !disabled && (
{helperText ? (
{helperText}
) : null}
{allowSystemDefault && ( )} {options.map((option) => { const optionSelection = { profile_id: option.profile_id, model_id: option.model_id, }; const optionKey = llmSelectionKey(optionSelection); return ( { onChange(optionSelection); setOpen(false); linger(); }} /> ); })}
)}
); }