"use client"; import { ArrowDownIcon, ArrowUpIcon, PencilIcon, PlusIcon, StarIcon, XIcon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { ProviderDriverKind, type ProviderInstanceId, type ServerProviderModel, } from "@t3tools/contracts"; import { type CustomModelDefinition, normalizeCustomModelSlug } from "@t3tools/shared/model"; import { cn } from "../../lib/utils"; import { sortModelsForProviderInstance } from "../../modelOrdering"; import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { CustomModelEditor } from "./CustomModelEditor"; /** * Placeholder text for the "add a custom model" input, keyed by driver * kind. Mirrors the prior hardcoded switch in `SettingsPanels.tsx` so the * UX is unchanged — only the owning component has moved. */ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial> = { [ProviderDriverKind.make("codex")]: "gpt-6.7-codex-ultra-preview", [ProviderDriverKind.make("claudeAgent")]: "claude-sonnet-5", [ProviderDriverKind.make("cursor")]: "claude-sonnet-4-6", [ProviderDriverKind.make("opencode")]: "openai/gpt-5", }; /** Above this many models the list gets a filter input. */ const FILTER_THRESHOLD = 8; /** * Short capability words shown after a model's slug. Claude and Cursor report * fast mode as a boolean `fastMode` option; Codex reports it as a * `serviceTier` select whose fast tier is labelled "Fast" (catalog id * `priority`, or `fast` from the speed-tier fallback), matching the composer. */ function describeModelCapabilities(model: ServerProviderModel): string[] { const descriptors = model.capabilities?.optionDescriptors ?? []; const labels: string[] = []; const hasFastMode = descriptors.some( (descriptor) => descriptor.id === "fastMode" || (descriptor.id === "serviceTier" && descriptor.type === "select" && descriptor.options.some((option) => option.id === "fast" || option.label === "Fast")), ); if (hasFastMode) labels.push("Fast mode"); if (descriptors.some((descriptor) => descriptor.id === "thinking")) labels.push("Thinking"); if ( descriptors.some( (descriptor) => descriptor.type === "select" && (descriptor.id === "reasoningEffort" || descriptor.id === "effort" || descriptor.id === "reasoning" || descriptor.id === "variant"), ) ) { labels.push("Reasoning"); } return labels; } /** * Display order for the models list: favorites first (in user order), then * visible models, then hidden ones. Hidden models sink so the list reads * top-down as "what the picker shows"; moves only swap rows within the same * group, and the resulting display order is what gets persisted as * `modelOrder`. */ export function groupModelsForDisplay< T extends { readonly slug: string; readonly isCustom: boolean }, >( models: ReadonlyArray, options: { readonly favoriteModels: ReadonlySet; readonly hiddenModels: ReadonlySet; readonly modelOrder: ReadonlyArray; }, ): T[] { const ordered = sortModelsForProviderInstance(models, { favoriteModels: options.favoriteModels, groupFavorites: true, modelOrder: options.modelOrder, }); const isHidden = (model: T) => !model.isCustom && options.hiddenModels.has(model.slug); return [ ...ordered.filter((model) => options.favoriteModels.has(model.slug)), ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && !isHidden(model)), ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && isHidden(model)), ]; } export function nextHiddenModelsForBulkToggle( models: ReadonlyArray>, hiddenModels: ReadonlyArray, ): string[] { const builtInSlugs = models.filter((model) => !model.isCustom).map((model) => model.slug); const builtInSlugSet = new Set(builtInSlugs); const allBuiltInModelsHidden = builtInSlugs.every((slug) => hiddenModels.includes(slug)); if (allBuiltInModelsHidden) { return hiddenModels.filter((slug) => !builtInSlugSet.has(slug)); } return [...new Set([...hiddenModels, ...builtInSlugs])]; } interface ProviderModelsSectionProps { /** Identifier used to namespace input ids within the DOM. */ readonly instanceId: ProviderInstanceId; /** * Driver kind for slug normalization + input placeholder. `null` when * the section is rendered without enough provider metadata. */ readonly driverKind: ProviderDriverKind | null; /** * The live model list to display. Includes both built-in (probe-reported) * and custom entries, distinguished by `isCustom`. */ readonly models: ReadonlyArray; /** * The persisted custom-model list for this instance, resolved. Drives * dedup, and is the list we hand back (with an entry appended / replaced / * removed) via `onChange`. */ readonly customModels: ReadonlyArray; /** Server-returned model slugs hidden from the model picker. */ readonly hiddenModels: ReadonlyArray; /** Model slugs favorited for this provider instance. */ readonly favoriteModels: ReadonlyArray; /** Explicit user-authored model ordering for this provider instance. */ readonly modelOrder: ReadonlyArray; /** * Commit the new custom-model list. Caller is responsible for routing the * write to the correct storage (legacy `settings.providers[kind]` vs. * `providerInstances[id].config`). */ readonly onChange: (next: ReadonlyArray) => void; readonly onHiddenModelsChange: (next: ReadonlyArray) => void; readonly onFavoriteModelsChange: (next: ReadonlyArray) => void; readonly onModelOrderChange: (next: ReadonlyArray) => void; } /** * Shared "Models" section rendered on both the built-in default and custom * provider-instance cards. Owns its own input + error local state so two * cards on screen don't fight over the input value. * * Validation mirrors the pre-consolidation logic in `SettingsPanels`: * - empty / whitespace → "Enter a model slug." * - duplicate of a non-custom (probe-reported) slug → "already built in" * - exceeds `MAX_CUSTOM_MODEL_LENGTH` → length error * - duplicate of an already-saved custom slug → already-saved error */ export function ProviderModelsSection({ instanceId, driverKind, models, customModels, hiddenModels, favoriteModels, modelOrder, onChange, onHiddenModelsChange, onFavoriteModelsChange, onModelOrderChange, }: ProviderModelsSectionProps) { const [input, setInput] = useState(""); const [isAdding, setIsAdding] = useState(false); const [filter, setFilter] = useState(""); const [error, setError] = useState(null); // Slug of the custom model whose inline editor is open, if any. const [editingSlug, setEditingSlug] = useState(null); const listRef = useRef(null); // Slug of a just-added custom model, scrolled into view once its row exists. const scrollToSlugRef = useRef(null); const hiddenModelSet = useMemo(() => new Set(hiddenModels), [hiddenModels]); const favoriteModelSet = useMemo(() => new Set(favoriteModels), [favoriteModels]); const displayModels = useMemo( () => groupModelsForDisplay(models, { favoriteModels: favoriteModelSet, hiddenModels: hiddenModelSet, modelOrder, }), [favoriteModelSet, hiddenModelSet, modelOrder, models], ); const favoriteCount = displayModels.filter((model) => favoriteModelSet.has(model.slug)).length; const hiddenCount = displayModels.filter( (model) => !model.isCustom && hiddenModelSet.has(model.slug), ).length; const builtInModels = useMemo(() => models.filter((model) => !model.isCustom), [models]); const allBuiltInModelsHidden = builtInModels.length > 0 && builtInModels.every((model) => hiddenModelSet.has(model.slug)); const showFilter = models.length > FILTER_THRESHOLD; const normalizedFilter = filter.trim().toLowerCase(); const isFiltering = showFilter && normalizedFilter.length > 0; const visibleModels = isFiltering ? displayModels.filter( (model) => model.name.toLowerCase().includes(normalizedFilter) || model.slug.toLowerCase().includes(normalizedFilter), ) : displayModels; // The parent commits the new custom model and hands back an updated // `models` list, so the row can only be scrolled to after that render. useEffect(() => { const slug = scrollToSlugRef.current; if (slug === null) return; const row = listRef.current?.querySelector( `[data-model-slug="${CSS.escape(slug)}"]`, ); if (!row) return; scrollToSlugRef.current = null; row.scrollIntoView({ block: "nearest" }); }, [displayModels]); const handleAdd = () => { if (driverKind === "antigravity") return; const normalized = normalizeCustomModelSlug(input); if (!normalized) { setError("Enter a model slug."); return; } if (models.some((model) => !model.isCustom && model.slug === normalized)) { setError("That model is already built in."); return; } if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) { setError(`Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`); return; } if (customModels.some((entry) => entry.slug === normalized)) { setError("That custom model is already saved."); return; } // Clear the filter so the new row renders even when it does not match, // which is also what lets the pending scroll target resolve and clear. scrollToSlugRef.current = normalized; setFilter(""); onChange([...customModels, { slug: normalized, name: normalized, capabilities: null }]); setInput(""); setError(null); setIsAdding(false); }; const cancelAdd = () => { setInput(""); setError(null); setIsAdding(false); }; const handleRemove = (slug: string) => { if (editingSlug === slug) setEditingSlug(null); onChange(customModels.filter((entry) => entry.slug !== slug)); onModelOrderChange(modelOrder.filter((model) => model !== slug)); onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug)); setError(null); }; const handleSaveEdit = (next: CustomModelDefinition) => { onChange(customModels.map((entry) => (entry.slug === next.slug ? next : entry))); setEditingSlug(null); }; const setHidden = (slug: string, hidden: boolean) => { if (hidden === hiddenModelSet.has(slug)) return; onHiddenModelsChange( hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug), ); }; const handleToggleFavorite = (slug: string) => { if (favoriteModelSet.has(slug)) { onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug)); return; } onFavoriteModelsChange([...favoriteModels, slug]); }; // Rows only trade places with a neighbour in the same group (favorites, // visible, hidden), and the display order is persisted as the new order. const groupOf = (model: (typeof displayModels)[number]) => favoriteModelSet.has(model.slug) ? "favorite" : !model.isCustom && hiddenModelSet.has(model.slug) ? "hidden" : "visible"; const handleMove = (slug: string, direction: -1 | 1) => { const index = displayModels.findIndex((model) => model.slug === slug); const nextIndex = index + direction; if (index < 0 || nextIndex < 0 || nextIndex >= displayModels.length) return; if (groupOf(displayModels[index]!) !== groupOf(displayModels[nextIndex]!)) return; const next = displayModels.map((model) => model.slug); [next[index], next[nextIndex]] = [next[nextIndex]!, next[index]!]; onModelOrderChange(next); }; type DisplayModel = (typeof displayModels)[number]; const starButton = (model: DisplayModel, isFavorite: boolean) => ( handleToggleFavorite(model.slug)} aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${ isFavorite ? "from" : "to" } favorites`} /> } > {isFavorite ? "Remove from favorites" : "Add to favorites"} ); // Reorder and remove stay in the row at all times (dimmed when unavailable) // so ordering is discoverable without hovering. const rowActions = ( model: DisplayModel, options: { readonly isHidden: boolean; readonly canMoveUp: boolean; readonly canMoveDown: boolean; }, ) => ( {!options.isHidden && !isFiltering ? ( <> handleMove(model.slug, -1)} aria-label={`Move ${model.name} up`} /> } > Move up handleMove(model.slug, 1)} aria-label={`Move ${model.name} down`} /> } > Move down ) : null} {model.isCustom ? ( <> setEditingSlug((current) => (current === model.slug ? null : model.slug)) } /> } > Edit name and options handleRemove(model.slug)} /> } > Remove custom model ) : null} ); const pickerTooltip = (model: DisplayModel, isHidden: boolean) => model.isCustom ? "Custom models are always shown in the picker" : isHidden ? "Hidden from picker" : "Shown in picker"; // The trigger is a wrapper span: a disabled switch gets no pointer events, // so it could not open the tooltip itself. const pickerSwitch = (model: DisplayModel, isHidden: boolean) => ( }> setHidden(model.slug, !checked)} aria-label={`Show ${model.name} in the model picker`} /> {pickerTooltip(model, isHidden)} ); const renderRow = (model: DisplayModel) => { const capLabels = describeModelCapabilities(model); const group = groupOf(model); // Hidden is read from the preference itself: a favorited model can still be // hidden, and its switch must say so even though it sits in the favorites group. const isHidden = !model.isCustom && hiddenModelSet.has(model.slug); const isFavorite = group === "favorite"; const index = displayModels.indexOf(model); const previousModel = displayModels[index - 1]; const nextModel = displayModels[index + 1]; // Reordering a filtered view would be ambiguous, so arrows only show on // the full list. const canMoveUp = !isFiltering && previousModel !== undefined && groupOf(previousModel) === group; const canMoveDown = !isFiltering && nextModel !== undefined && groupOf(nextModel) === group; const nameClassName = cn("text-xs", isHidden ? "text-muted-foreground" : "text-foreground/90"); return (
{starButton(model, isFavorite)} {model.name} {model.name !== model.slug ? ( {model.slug} ) : null} {model.isCustom ? ( custom ) : null} {/* Always a grid item so the columns line up across rows; the text itself drops out on phone widths where it would starve the name. */} {capLabels.length > 0 ? ( {capLabels.join(" · ")} ) : null} {rowActions(model, { isHidden, canMoveUp, canMoveDown })} {pickerSwitch(model, isHidden)}
); }; const groupLabel = (label: string, isFirst: boolean) => (
{label}
); return (
{showFilter ? ( setFilter(event.target.value)} placeholder="Filter models" size="sm" className="w-56" spellCheck={false} aria-label="Filter models" /> ) : null}
{builtInModels.length > 0 ? ( ) : null} {models.length} model{models.length === 1 ? "" : "s"} {favoriteCount > 0 ? ` · ${favoriteCount} favorite${favoriteCount === 1 ? "" : "s"}` : ""} {hiddenCount > 0 ? ` · ${hiddenCount} hidden` : ""}
{visibleModels.length === 0 ? (

{isFiltering ? "No models match." : "No models reported for this provider yet."}

) : null} {visibleModels.map((model, index) => { const group = groupOf(model); const previous = visibleModels[index - 1]; const startsGroup = previous === undefined || groupOf(previous) !== group; const editingEntry = model.isCustom && editingSlug === model.slug ? customModels.find((entry) => entry.slug === model.slug) : undefined; return (
{startsGroup && favoriteCount > 0 && group === "favorite" ? groupLabel("Favorites", index === 0) : null} {startsGroup && favoriteCount > 0 && group === "visible" ? groupLabel("All", index === 0) : null} {startsGroup && group === "hidden" ? groupLabel("Hidden from picker", index === 0) : null} {renderRow(model)} {editingEntry ? ( setEditingSlug(null)} /> ) : null}
); })}
{driverKind === "antigravity" ? null : isAdding ? (
{ setInput(event.target.value); if (error) setError(null); }} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); cancelAdd(); return; } if (event.key !== "Enter") return; event.preventDefault(); handleAdd(); }} placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"} spellCheck={false} />
) : ( )} {driverKind !== "antigravity" && error ? (

{error}

) : null}
); }