import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { View, Text, TextInput, Pressable, Platform, ActivityIndicator, type GestureResponderEvent, } from "react-native"; import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { ArrowLeft, ChevronDown, ChevronRight, Search, Star, } from "lucide-react-native"; import type { AgentModelDefinition, AgentProvider, } from "@server/server/agent/agent-sdk-types"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; const IS_WEB = Platform.OS === "web"; import { Combobox, ComboboxItem } from "@/components/ui/combobox"; import { getProviderIcon } from "@/components/provider-icons"; import type { FavoriteModelRow } from "@/hooks/use-form-preferences"; import { buildModelRows, buildSelectedTriggerLabel, matchesSearch, resolveProviderLabel, type SelectorModelRow, } from "./combined-model-selector.utils"; // TODO: this should be configured per provider in the provider manifest const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]); type SelectorView = | { kind: "all" } | { kind: "provider"; providerId: string; providerLabel: string }; interface CombinedModelSelectorProps { providerDefinitions: AgentProviderDefinition[]; allProviderModels: Map; selectedProvider: string; selectedModel: string; onSelect: (provider: AgentProvider, modelId: string) => void; isLoading: boolean; canSelectProvider?: (provider: string) => boolean; favoriteKeys?: Set; onToggleFavorite?: (provider: string, modelId: string) => void; renderTrigger?: (input: { selectedModelLabel: string; onPress: () => void; disabled: boolean; isOpen: boolean; }) => React.ReactNode; onClose?: () => void; disabled?: boolean; } interface SelectorContentProps { view: SelectorView; providerDefinitions: AgentProviderDefinition[]; allProviderModels: Map; selectedProvider: string; selectedModel: string; searchQuery: string; onSearchChange: (query: string) => void; favoriteKeys: Set; onSelect: (provider: string, modelId: string) => void; canSelectProvider: (provider: string) => boolean; onToggleFavorite?: (provider: string, modelId: string) => void; onDrillDown: (providerId: string, providerLabel: string) => void; } function resolveDefaultModelLabel(models: AgentModelDefinition[] | undefined): string { if (!models || models.length === 0) { return "Select model"; } return (models.find((model) => model.isDefault) ?? models[0])?.label ?? "Select model"; } function normalizeSearchQuery(value: string): string { return value.trim().toLowerCase(); } function partitionRows( rows: SelectorModelRow[], favoriteKeys: Set, ): { favoriteRows: SelectorModelRow[]; regularRows: SelectorModelRow[] } { const favoriteRows: SelectorModelRow[] = []; const regularRows: SelectorModelRow[] = []; for (const row of rows) { if (favoriteKeys.has(row.favoriteKey)) { favoriteRows.push(row); continue; } regularRows.push(row); } return { favoriteRows, regularRows }; } function sortFavoritesFirst( rows: SelectorModelRow[], favoriteKeys: Set, ): SelectorModelRow[] { const favorites: SelectorModelRow[] = []; const rest: SelectorModelRow[] = []; for (const row of rows) { if (favoriteKeys.has(row.favoriteKey)) { favorites.push(row); } else { rest.push(row); } } return [...favorites, ...rest]; } function groupRowsByProvider( rows: SelectorModelRow[], ): Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }> { const grouped = new Map(); for (const row of rows) { const existing = grouped.get(row.provider); if (existing) { existing.rows.push(row); continue; } grouped.set(row.provider, { providerId: row.provider, providerLabel: row.providerLabel, rows: [row], }); } return Array.from(grouped.values()); } function ModelRow({ row, isSelected, isFavorite, disabled = false, elevated = false, onPress, onToggleFavorite, }: { row: SelectorModelRow; isSelected: boolean; isFavorite: boolean; disabled?: boolean; elevated?: boolean; onPress: () => void; onToggleFavorite?: (provider: string, modelId: string) => void; }) { const { theme } = useUnistyles(); const ProviderIcon = getProviderIcon(row.provider); const handleToggleFavorite = useCallback( (event: GestureResponderEvent) => { event.stopPropagation(); onToggleFavorite?.(row.provider, row.modelId); }, [onToggleFavorite, row.modelId, row.provider], ); const showDescription = row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider); return ( } trailingSlot={ onToggleFavorite && !disabled ? ( [ styles.favoriteButton, hovered && styles.favoriteButtonHovered, pressed && styles.favoriteButtonPressed, ]} accessibilityRole="button" accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"} testID={`favorite-model-${row.provider}-${row.modelId}`} > {({ hovered }) => ( )} ) : null } /> ); } function FavoritesSection({ favoriteRows, selectedProvider, selectedModel, favoriteKeys, onSelect, canSelectProvider, onToggleFavorite, }: { favoriteRows: SelectorModelRow[]; selectedProvider: string; selectedModel: string; favoriteKeys: Set; onSelect: (provider: string, modelId: string) => void; canSelectProvider: (provider: string) => boolean; onToggleFavorite?: (provider: string, modelId: string) => void; }) { const { theme } = useUnistyles(); if (favoriteRows.length === 0) { return null; } return ( Favorites {favoriteRows.map((row) => ( onSelect(row.provider, row.modelId)} onToggleFavorite={onToggleFavorite} /> ))} ); } function GroupedProviderRows({ providerDefinitions, groupedRows, selectedProvider, selectedModel, favoriteKeys, onSelect, canSelectProvider, onToggleFavorite, onDrillDown, viewKind, }: { providerDefinitions: AgentProviderDefinition[]; groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>; selectedProvider: string; selectedModel: string; favoriteKeys: Set; onSelect: (provider: string, modelId: string) => void; canSelectProvider: (provider: string) => boolean; onToggleFavorite?: (provider: string, modelId: string) => void; onDrillDown: (providerId: string, providerLabel: string) => void; viewKind: SelectorView["kind"]; }) { const { theme } = useUnistyles(); return ( {groupedRows.map((group, index) => { const providerDefinition = providerDefinitions.find((definition) => definition.id === group.providerId); const ProvIcon = getProviderIcon(group.providerId); const isInline = viewKind === "provider"; return ( {index > 0 ? : null} {isInline ? ( <> {sortFavoritesFirst(group.rows, favoriteKeys).map((row) => ( onSelect(row.provider, row.modelId)} onToggleFavorite={onToggleFavorite} /> ))} ) : ( onDrillDown(group.providerId, group.providerLabel)} style={({ pressed, hovered }) => [ styles.drillDownRow, hovered && styles.drillDownRowHovered, pressed && styles.drillDownRowPressed, ]} > {group.providerLabel} {group.rows.length} {group.rows.length === 1 ? "model" : "models"} )} ); })} ); } function ProviderSearchInput({ value, onChangeText, autoFocus = false, }: { value: string; onChangeText: (text: string) => void; autoFocus?: boolean; }) { const { theme } = useUnistyles(); const inputRef = useRef(null); const isMobile = useIsCompactFormFactor(); const InputComponent = isMobile ? BottomSheetTextInput : TextInput; useEffect(() => { if (autoFocus && Platform.OS === "web" && inputRef.current) { const timer = setTimeout(() => { inputRef.current?.focus(); }, 50); return () => clearTimeout(timer); } }, [autoFocus]); return ( ); } function SelectorContent({ view, providerDefinitions, allProviderModels, selectedProvider, selectedModel, searchQuery, onSearchChange, favoriteKeys, onSelect, canSelectProvider, onToggleFavorite, onDrillDown, }: SelectorContentProps) { const { theme } = useUnistyles(); const allRows = useMemo( () => buildModelRows(providerDefinitions, allProviderModels), [allProviderModels, providerDefinitions], ); const scopedRows = useMemo(() => { if (view.kind === "provider") { return allRows.filter((row) => row.provider === view.providerId); } return allRows; }, [allRows, view]); const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); const visibleRows = useMemo( () => scopedRows.filter((row) => matchesSearch(row, normalizedQuery)), [normalizedQuery, scopedRows], ); const { favoriteRows, regularRows } = useMemo( () => partitionRows(visibleRows, favoriteKeys), [favoriteKeys, visibleRows], ); // Group ALL visible rows by provider — favorites are a cross-cutting view, // not a partition. A model being favorited doesn't remove it from its provider. const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]); // When searching at Level 1, filter grouped rows to only providers whose name or models match const filteredGroupedRows = useMemo(() => { if (view.kind === "provider" || !normalizedQuery) { return allGroupedRows; } return allGroupedRows.filter( (group) => group.providerLabel.toLowerCase().includes(normalizedQuery) || group.rows.length > 0, ); }, [allGroupedRows, normalizedQuery, view.kind]); const hasResults = favoriteRows.length > 0 || filteredGroupedRows.length > 0; return ( {view.kind === "all" ? ( ) : null} {filteredGroupedRows.length > 0 ? ( ) : null} {!hasResults ? ( No models match your search ) : null} ); } function ProviderBackButton({ providerId, providerLabel, onBack, }: { providerId: string; providerLabel: string; onBack?: () => void; }) { const { theme } = useUnistyles(); const ProviderIcon = getProviderIcon(providerId); if (!onBack) { return null; } return ( [ styles.backButton, hovered && styles.backButtonHovered, pressed && styles.backButtonPressed, ]} > {providerLabel} ); } export function CombinedModelSelector({ providerDefinitions, allProviderModels, selectedProvider, selectedModel, onSelect, isLoading, canSelectProvider = () => true, favoriteKeys = new Set(), onToggleFavorite, renderTrigger, onClose, disabled = false, }: CombinedModelSelectorProps) { const { theme } = useUnistyles(); const isWeb = Platform.OS === "web"; const anchorRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const [isContentReady, setIsContentReady] = useState(isWeb); const [view, setView] = useState({ kind: "all" }); const [searchQuery, setSearchQuery] = useState(""); // Single-provider mode: only one provider with models → skip Level 1 entirely const singleProviderView = useMemo(() => { const providers = Array.from(allProviderModels.keys()); if (providers.length !== 1) return null; const providerId = providers[0]!; const label = resolveProviderLabel(providerDefinitions, providerId); return { kind: "provider", providerId, providerLabel: label }; }, [allProviderModels, providerDefinitions]); const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); setView(singleProviderView ?? { kind: "all" }); if (!open) { setSearchQuery(""); onClose?.(); } }, [onClose, singleProviderView], ); const handleSelect = useCallback( (provider: string, modelId: string) => { onSelect(provider as AgentProvider, modelId); setIsOpen(false); setView(singleProviderView ?? { kind: "all" }); setSearchQuery(""); }, [onSelect, singleProviderView], ); const ProviderIcon = getProviderIcon(selectedProvider); const selectedProviderLabel = useMemo( () => resolveProviderLabel(providerDefinitions, selectedProvider), [providerDefinitions, selectedProvider], ); const selectedModelLabel = useMemo(() => { const models = allProviderModels.get(selectedProvider); if (!models) { return isLoading ? "Loading..." : "Select model"; } const model = models.find((entry) => entry.id === selectedModel); return model?.label ?? resolveDefaultModelLabel(models); }, [allProviderModels, isLoading, selectedModel, selectedProvider]); const desktopFixedHeight = useMemo(() => { if (view.kind !== "provider") { return undefined; } const models = allProviderModels.get(view.providerId); const modelCount = models?.length ?? 0; return Math.min(80 + modelCount * 40, 400); }, [allProviderModels, view]); const triggerLabel = useMemo(() => { if (selectedModelLabel === "Loading..." || selectedModelLabel === "Select model") { return selectedModelLabel; } return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel); }, [selectedModelLabel, selectedProviderLabel]); useEffect(() => { if (isWeb) { return; } if (!isOpen) { setIsContentReady(false); return; } const frame = requestAnimationFrame(() => { setIsContentReady(true); }); return () => cancelAnimationFrame(frame); }, [isOpen, isWeb]); return ( <> handleOpenChange(!isOpen)} style={({ pressed, hovered }) => [ styles.trigger, hovered && styles.triggerHovered, (pressed || isOpen) && styles.triggerPressed, disabled && styles.triggerDisabled, renderTrigger ? styles.customTriggerWrapper : null, ]} accessibilityRole="button" accessibilityLabel={`Select model (${selectedModelLabel})`} testID="combined-model-selector" > {renderTrigger ? ( renderTrigger({ selectedModelLabel: triggerLabel, onPress: () => handleOpenChange(!isOpen), disabled, isOpen, }) ) : ( <> {triggerLabel} )} {}} open={isOpen} onOpenChange={handleOpenChange} stackBehavior="push" anchorRef={anchorRef} desktopPlacement="top-start" desktopMinWidth={360} desktopFixedHeight={desktopFixedHeight} title="Select model" stickyHeader={ view.kind === "provider" ? ( {!singleProviderView ? ( { setView({ kind: "all" }); setSearchQuery(""); }} /> ) : null} ) : undefined } > {isContentReady ? ( { setView({ kind: "provider", providerId, providerLabel }); }} /> ) : ( Loading model selector… )} ); } const styles = StyleSheet.create((theme) => ({ trigger: { height: 28, flexDirection: "row", alignItems: "center", backgroundColor: "transparent", gap: theme.spacing[1], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius["2xl"], }, triggerHovered: { backgroundColor: theme.colors.surface2, }, triggerPressed: { backgroundColor: theme.colors.surface0, }, triggerDisabled: { opacity: 0.5, }, triggerText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, }, customTriggerWrapper: { paddingHorizontal: 0, paddingVertical: 0, height: "auto", }, favoritesContainer: { backgroundColor: theme.colors.surface1, borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, separator: { height: 1, backgroundColor: theme.colors.border, }, sectionHeading: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingTop: theme.spacing[2], paddingBottom: theme.spacing[1], ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), }, sectionHeadingText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, drillDownRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], minHeight: 36, ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), }, drillDownRowHovered: { backgroundColor: theme.colors.surface1, }, drillDownRowPressed: { backgroundColor: theme.colors.surface2, }, drillDownText: { flex: 1, fontSize: theme.fontSize.sm, color: theme.colors.foreground, }, drillDownTrailing: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], }, drillDownCount: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, level2Header: { backgroundColor: theme.colors.surface1, borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, backButton: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderBottomWidth: 1, borderBottomColor: theme.colors.border, ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), }, backButtonHovered: { backgroundColor: theme.colors.surface2, }, backButtonPressed: { backgroundColor: theme.colors.surface2, }, backButtonText: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, emptyState: { paddingVertical: theme.spacing[4], alignItems: "center", gap: theme.spacing[2], }, emptyStateText: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, favoriteButton: { width: 24, height: 24, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, favoriteButtonHovered: { backgroundColor: theme.colors.surface2, }, favoriteButtonPressed: { backgroundColor: theme.colors.surface1, }, sheetLoadingState: { minHeight: 160, justifyContent: "center", alignItems: "center", gap: theme.spacing[2], }, sheetLoadingText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, providerSearchContainer: { flexDirection: "row", alignItems: "center", paddingHorizontal: theme.spacing[3], gap: theme.spacing[2], ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), }, providerSearchInput: { flex: 1, paddingVertical: theme.spacing[3], color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, }));