import { useLayoutEffect, useRef, useState } from 'react'; import { cn } from '@renderer/lib/utils'; import { getTeamModelBadgeLabel, getVisibleTeamProviderModels, } from '@renderer/utils/teamModelCatalog'; import { ChevronDown, ChevronUp } from 'lucide-react'; import type { CliProviderId, CliProviderModelAvailability, CliProviderModelAvailabilityStatus, CliProviderStatus, } from '@shared/types'; function formatModelBadgeLabel(providerId: CliProviderId, model: string): string { return getTeamModelBadgeLabel(providerId, model) ?? model; } function getAvailabilityStatus( model: string, modelAvailability: CliProviderModelAvailability[] | undefined ): CliProviderModelAvailabilityStatus | null { return modelAvailability?.find((item) => item.modelId === model)?.status ?? null; } function getAvailabilityReason( model: string, modelAvailability: CliProviderModelAvailability[] | undefined ): string | null { return modelAvailability?.find((item) => item.modelId === model)?.reason ?? null; } function getAvailabilityChip(status: CliProviderModelAvailabilityStatus | null): string | null { switch (status) { case 'checking': return 'Checking'; case 'unavailable': return 'Unavailable'; case 'unknown': return 'Check failed'; case 'available': default: return null; } } function getCatalogBadgeLabel( model: string, providerStatus: Pick | null | undefined ): string | null { const catalogItem = providerStatus?.modelCatalog?.models.find( (item) => item.launchModel === model || item.id === model ); const badgeLabel = catalogItem?.badgeLabel?.trim(); if (badgeLabel) { return badgeLabel; } return catalogItem?.metadata?.free === true ? 'Free' : null; } function normalizeBadgeText(value: string): string { return value.trim().replace(/\s+/g, ' ').toLowerCase(); } function shouldRenderCatalogBadge(modelLabel: string, catalogBadgeLabel: string | null): boolean { if (!catalogBadgeLabel) { return false; } return normalizeBadgeText(modelLabel) !== normalizeBadgeText(catalogBadgeLabel); } function hasChildAfterRowLimit(container: HTMLElement, rowLimit: number): boolean { const rowTops: number[] = []; const children = Array.from(container.children) as HTMLElement[]; for (const child of children) { const top = child.offsetTop; let rowIndex = rowTops.findIndex((rowTop) => Math.abs(rowTop - top) <= 1); if (rowIndex < 0) { rowTops.push(top); rowIndex = rowTops.length - 1; } if (rowIndex >= rowLimit) { return true; } } return false; } export const ProviderModelBadges = ({ providerId, models, modelAvailability, providerStatus, collapseAfter, maxCollapsedRows, }: { readonly providerId: CliProviderId; readonly models: string[]; readonly modelAvailability?: CliProviderModelAvailability[]; readonly providerStatus?: Pick< CliProviderStatus, 'providerId' | 'authMethod' | 'backend' | 'modelCatalog' > | null; readonly collapseAfter?: number; readonly maxCollapsedRows?: number; }): React.JSX.Element => { const [expanded, setExpanded] = useState(false); const [collapsedModelLimit, setCollapsedModelLimit] = useState(null); const [measureTick, setMeasureTick] = useState(0); const listRef = useRef(null); const visibleModels = getVisibleTeamProviderModels(providerId, models, providerStatus); const displayModelAvailability = providerId === 'opencode' ? undefined : modelAvailability; const shouldCollapse = typeof collapseAfter === 'number' && collapseAfter > 0 && visibleModels.length > collapseAfter; const collapsedBaseLimit = shouldCollapse ? collapseAfter : visibleModels.length; const collapsedLimit = shouldCollapse && !expanded ? Math.max(0, Math.min(collapsedModelLimit ?? collapsedBaseLimit, collapsedBaseLimit)) : visibleModels.length; const displayedModels = shouldCollapse && !expanded ? visibleModels.slice(0, collapsedLimit) : visibleModels; const hiddenCount = shouldCollapse ? visibleModels.length - displayedModels.length : 0; useLayoutEffect(() => { setCollapsedModelLimit(null); }, [collapseAfter, maxCollapsedRows, models, providerStatus]); useLayoutEffect(() => { if (!shouldCollapse || expanded || !maxCollapsedRows || maxCollapsedRows < 1) { return; } const container = listRef.current; if (!container) { return; } if (!hasChildAfterRowLimit(container, maxCollapsedRows)) { return; } const nextLimit = Math.max(0, collapsedLimit - 1); if (nextLimit !== collapsedLimit) { setCollapsedModelLimit(nextLimit); } }, [collapsedLimit, expanded, maxCollapsedRows, measureTick, shouldCollapse]); useLayoutEffect(() => { if (!shouldCollapse || expanded || !maxCollapsedRows || typeof ResizeObserver === 'undefined') { return; } const container = listRef.current; if (!container) { return; } let lastWidth = container.clientWidth; const observer = new ResizeObserver((entries) => { const width = Math.round(entries[0]?.contentRect.width ?? container.clientWidth); if (width === lastWidth) { return; } lastWidth = width; setCollapsedModelLimit(null); setMeasureTick((value) => value + 1); }); observer.observe(container); return () => observer.disconnect(); }, [expanded, maxCollapsedRows, shouldCollapse]); const badgeClassName = 'inline-flex items-center gap-1 rounded-md border px-1.5 py-px font-mono text-[10px] leading-4'; const badgeStyle = { borderColor: 'var(--color-border-subtle)', backgroundColor: 'rgba(255, 255, 255, 0.03)', color: 'var(--color-text-secondary)', }; const buttonClassName = 'inline-flex items-center gap-1 rounded-full border border-[rgba(59,130,246,0.35)] bg-[rgba(59,130,246,0.12)] px-2 py-px text-[10px] font-medium leading-4 text-[rgb(147,197,253)] transition-colors hover:border-[rgba(59,130,246,0.55)] hover:bg-[rgba(59,130,246,0.18)] hover:text-[rgb(191,219,254)]'; const listClassName = cn('flex flex-wrap gap-1.5'); const renderModelBadge = (model: string, index: number): React.JSX.Element => { const availabilityStatus = getAvailabilityStatus(model, displayModelAvailability); const availabilityReason = getAvailabilityReason(model, displayModelAvailability); const availabilityChip = getAvailabilityChip(availabilityStatus); const modelLabel = formatModelBadgeLabel(providerId, model); const catalogBadgeLabel = getCatalogBadgeLabel(model, providerStatus); const showCatalogBadge = shouldRenderCatalogBadge(modelLabel, catalogBadgeLabel); const title = [ availabilityReason ?? availabilityChip, showCatalogBadge && catalogBadgeLabel === 'Free' ? 'Reported by OpenCode metadata. Availability and limits may change.' : null, ] .filter(Boolean) .join(' - '); return ( {modelLabel} {showCatalogBadge ? ( {catalogBadgeLabel} ) : null} {availabilityChip ? ( {availabilityChip} ) : null} ); }; if (!shouldCollapse) { return
{displayedModels.map(renderModelBadge)}
; } return (
{displayedModels.map(renderModelBadge)} {shouldCollapse && !expanded ? ( ) : null}
{shouldCollapse && expanded ? ( ) : null}
); };