import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
View,
Text,
Pressable,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
import { AlertTriangle, ChevronRight, Search, Settings, Star } from "lucide-react-native";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
import { Button } from "@/components/ui/button";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
Combobox,
ComboboxItem,
type ComboboxOption,
type ComboboxProps,
} from "@/components/ui/combobox";
import { getProviderIcon } from "@/components/provider-icons";
import {
buildSelectedTriggerLabel,
filterAndRankModelRows,
getAllProviderModelRows,
getProviderModelRows,
resolveSelectedModelLabel,
type ProviderSelectionModelRow,
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
const IS_WEB = platformIsWeb;
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
function noop() {}
function favoriteButtonStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [
styles.favoriteButton,
Boolean(hovered) && styles.favoriteButtonHovered,
pressed && styles.favoriteButtonPressed,
];
}
function drillDownRowStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [
styles.drillDownRow,
Boolean(hovered) && styles.drillDownRowHovered,
pressed && styles.drillDownRowPressed,
];
}
const DESKTOP_PROVIDER_VIEW_MIN_HEIGHT = 220;
const DESKTOP_PROVIDER_VIEW_MAX_HEIGHT = 400;
const DESKTOP_PROVIDER_VIEW_BASE_HEIGHT = 80;
const DESKTOP_MODEL_ROW_HEIGHT = 40;
const ThemedAlertTriangle = withUnistyles(AlertTriangle);
const ThemedChevronRight = withUnistyles(ChevronRight);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const ThemedSearch = withUnistyles(Search);
const ThemedSettings = withUnistyles(Settings);
const ThemedStar = withUnistyles(Star);
const foregroundMutedMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const headerSettingsMapping = (disabled: boolean) => (theme: Theme) => ({
color: disabled ? theme.colors.border : theme.colors.foregroundMuted,
});
const favoriteStarMapping =
(isFavorite: boolean, hovered: boolean) =>
(theme: Theme): { color: string; fill: string } => {
const favoriteColor = theme.colors.palette.amber[500];
if (isFavorite) {
return { color: favoriteColor, fill: favoriteColor };
}
return {
color: hovered ? theme.colors.foregroundMuted : theme.colors.border,
fill: "transparent",
};
};
type ProviderGlyphTone = "muted" | "foreground";
function ProviderGlyph({
provider,
size,
tone = "muted",
}: {
provider: string;
size: number;
tone?: ProviderGlyphTone;
}) {
const Icon = getProviderIcon(provider);
const color =
tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color;
return ;
}
function HeaderSettingsIcon({ disabled }: { disabled: boolean }) {
const uniProps = useMemo(() => headerSettingsMapping(disabled), [disabled]);
return ;
}
function FavoriteStar({ isFavorite, hovered }: { isFavorite: boolean; hovered: boolean }) {
const uniProps = useMemo(() => favoriteStarMapping(isFavorite, hovered), [hovered, isFavorite]);
return ;
}
type SelectorView =
| { kind: "all" }
| { kind: "provider"; providerId: string; providerLabel: string };
interface CombinedModelSelectorProps {
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
onSelect: (provider: AgentProvider, modelId: string) => void;
isLoading: boolean;
favoriteKeys?: Set;
onToggleFavorite?: (provider: string, modelId: string) => void;
renderTrigger?: (input: {
selectedModelLabel: string;
onPress: () => void;
disabled: boolean;
isOpen: boolean;
hovered: boolean;
pressed: boolean;
}) => React.ReactNode;
onOpen?: () => void;
onClose?: () => void;
onRetryProvider?: (provider: AgentProvider) => void;
isRetryingProvider?: boolean;
disabled?: boolean;
serverId?: string | null;
desktopPlacement?: ComboboxProps["desktopPlacement"];
desktopMinWidth?: number;
/**
* Render the custom trigger as a full-width form field: the outer Pressable
* becomes a transparent passthrough that stretches its child edge-to-edge and
* stops painting its own hover/pressed background and rounded corners. The
* trigger itself owns the field visuals and reads hovered/pressed to show its
* active state. Without this the trigger stays a content-width toolbar chip
* (the composer's layout).
*/
triggerFill?: boolean;
}
interface SelectorContentProps {
view: SelectorView;
providers: ProviderSelectorProvider[];
selectedProvider: string;
selectedModel: string;
searchQuery: string;
favoriteKeys: Set;
onSelect: (provider: string, modelId: string) => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void;
onRetryProvider?: (provider: AgentProvider) => void;
isRetryingProvider: boolean;
}
function normalizeSearchQuery(value: string): string {
return value.trim().toLowerCase();
}
function sortFavoritesFirst(
rows: ProviderSelectionModelRow[],
favoriteKeys: Set,
): ProviderSelectionModelRow[] {
const favorites: ProviderSelectionModelRow[] = [];
const rest: ProviderSelectionModelRow[] = [];
for (const row of rows) {
if (favoriteKeys.has(row.favoriteKey)) {
favorites.push(row);
} else {
rest.push(row);
}
}
return [...favorites, ...rest];
}
function ModelRow({
row,
isSelected,
isFavorite,
elevated = false,
onPress,
onToggleFavorite,
}: {
row: ProviderSelectionModelRow;
isSelected: boolean;
isFavorite: boolean;
elevated?: boolean;
onPress: () => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { t } = useTranslation();
const handleToggleFavorite = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
onToggleFavorite?.(row.provider, row.modelId);
},
[onToggleFavorite, row.modelId, row.provider],
);
const leadingSlot = useMemo(
() => ,
[row.provider],
);
const trailingSlot = useMemo(
() =>
onToggleFavorite ? (
{({ hovered }) => }
) : null,
[onToggleFavorite, handleToggleFavorite, isFavorite, row.provider, row.modelId, t],
);
return (
);
}
interface SelectableModelRowProps {
row: ProviderSelectionModelRow;
isSelected: boolean;
isFavorite: boolean;
elevated?: boolean;
onSelect: (provider: string, modelId: string) => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
}
function SelectableModelRow({
row,
isSelected,
isFavorite,
elevated,
onSelect,
onToggleFavorite,
}: SelectableModelRowProps) {
const handlePress = useCallback(() => {
onSelect(row.provider, row.modelId);
}, [onSelect, row.provider, row.modelId]);
return (
);
}
function FavoritesSection({
favoriteRows,
selectedProvider,
selectedModel,
favoriteKeys,
onSelect,
onToggleFavorite,
}: {
favoriteRows: ProviderSelectionModelRow[];
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set;
onSelect: (provider: string, modelId: string) => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { t } = useTranslation();
if (favoriteRows.length === 0) {
return null;
}
return (
{t("modelSelector.favorites")}
{favoriteRows.map((row) => (
))}
);
}
interface GroupProviderButtonProps {
provider: ProviderSelectorProvider;
onDrillDown: (providerId: string, providerLabel: string) => void;
}
function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
return [
styles.rowIconButton,
Boolean(hovered) && styles.rowIconButtonHovered,
pressed && styles.rowIconButtonPressed,
];
}
function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
const { t } = useTranslation();
const selection = provider.modelSelection;
const handlePress = useCallback(() => {
onDrillDown(provider.id, provider.label);
}, [onDrillDown, provider.id, provider.label]);
let stateNode: React.ReactNode;
if (selection.kind === "models") {
const count = selection.rows.length;
stateNode = (
{t(count === 1 ? "modelSelector.modelCount" : "modelSelector.modelCountPlural", {
count,
})}
);
} else if (selection.kind === "loading") {
stateNode = (
{t("modelSelector.loadingShort")}
);
} else {
stateNode = (
{t("modelSelector.error")}
);
}
return (
{provider.label}
{stateNode}
);
}
function GroupedProviderRows({
providers,
onDrillDown,
}: {
providers: ProviderSelectorProvider[];
onDrillDown: (providerId: string, providerLabel: string) => void;
}) {
return (
{providers.map((provider, index) => (
{index > 0 ? : null}
))}
);
}
function ProviderModelRows({
rows,
selectedProvider,
selectedModel,
favoriteKeys,
onSelect,
onToggleFavorite,
normalizedQuery,
}: {
rows: ProviderSelectionModelRow[];
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set;
onSelect: (provider: string, modelId: string) => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
normalizedQuery: string;
}) {
const isMobile = useIsCompactFormFactor();
const useVirtualizedList = isMobile && isNative;
const displayRows = useMemo(
() => (normalizedQuery ? rows : sortFavoritesFirst(rows, favoriteKeys)),
[favoriteKeys, normalizedQuery, rows],
);
const renderItem = useCallback(
({ item }: { item: ProviderSelectionModelRow }) => (
),
[favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
);
const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []);
if (useVirtualizedList) {
return (
);
}
return (
{displayRows.map((row) => (
{renderItem({ item: row })}
))}
);
}
function ProviderErrorEmptyState({
providerId,
message,
onRetryProvider,
isRetryingProvider,
}: {
providerId: string;
message: string;
onRetryProvider?: (provider: AgentProvider) => void;
isRetryingProvider: boolean;
}) {
const { t } = useTranslation();
const handleRetry = useCallback(() => {
onRetryProvider?.(providerId);
}, [onRetryProvider, providerId]);
return (
{message}
{onRetryProvider ? (
) : null}
);
}
function SelectorContent({
view,
providers,
selectedProvider,
selectedModel,
searchQuery,
favoriteKeys,
onSelect,
onToggleFavorite,
onDrillDown,
onRetryProvider,
isRetryingProvider,
}: SelectorContentProps) {
const { t } = useTranslation();
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
const selectedViewProvider = useMemo(
() =>
view.kind === "provider"
? providers.find((provider) => provider.id === view.providerId)
: null,
[providers, view],
);
const visibleRows = useMemo(
() =>
selectedViewProvider
? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery)
: [],
[normalizedQuery, selectedViewProvider],
);
const favoriteRows = useMemo(
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
[favoriteKeys, providers],
);
const hasResults = favoriteRows.length > 0 || providers.length > 0;
const emptyState = (
{t("modelSelector.noMatches")}
);
if (view.kind === "provider") {
if (!selectedViewProvider) {
return emptyState;
}
const drillSelection = selectedViewProvider.modelSelection;
if (drillSelection.kind === "loading") {
return (
{t("modelSelector.loadingShort")}
);
}
if (drillSelection.kind === "error") {
return (
);
}
if (visibleRows.length === 0) {
return emptyState;
}
return (
);
}
return (
{providers.length > 0 ? (
) : null}
{!hasResults ? emptyState : null}
);
}
export function CombinedModelSelector({
providers,
selectedProvider,
selectedModel,
onSelect,
isLoading,
favoriteKeys = new Set(),
onToggleFavorite,
renderTrigger,
onOpen,
onClose,
onRetryProvider,
isRetryingProvider = false,
disabled = false,
serverId = null,
desktopPlacement,
desktopMinWidth,
triggerFill = false,
}: CombinedModelSelectorProps) {
const { t } = useTranslation();
const anchorRef = useRef(null);
const [isOpen, setIsOpen] = useState(false);
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
const [view, setView] = useState({ kind: "all" });
const [searchQuery, setSearchQuery] = useState("");
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
// Single-provider mode: only one provider → skip Level 1 entirely
const singleProviderView = useMemo(() => {
if (providers.length !== 1) return null;
const provider = providers[0];
if (!provider) return null;
return { kind: "provider", providerId: provider.id, providerLabel: provider.label };
}, [providers]);
const computeInitialView = useCallback((): SelectorView => {
if (singleProviderView) return singleProviderView;
const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`;
if (selectedProvider && selectedModel && !favoriteKeys.has(selectedFavoriteKey)) {
const provider = providers.find((entry) => entry.id === selectedProvider);
if (provider)
return { kind: "provider", providerId: provider.id, providerLabel: provider.label };
}
return { kind: "all" };
}, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providers]);
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
setView(computeInitialView());
if (open) {
onOpen?.();
} else {
setSearchQuery("");
bumpSearchResetKey();
onClose?.();
}
},
[onOpen, onClose, computeInitialView],
);
const handleSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider, modelId);
setIsOpen(false);
setSearchQuery("");
bumpSearchResetKey();
},
[onSelect],
);
const hasSelectedProvider = selectedProvider.trim().length > 0;
const selectedModelLabel = useMemo(() => {
return resolveSelectedModelLabel({
providers,
selectedProvider,
selectedModel,
isLoading,
});
}, [isLoading, providers, selectedModel, selectedProvider]);
const desktopFixedHeight = useMemo(() => {
if (view.kind !== "provider") {
return undefined;
}
const provider = providers.find((entry) => entry.id === view.providerId);
if (!provider || provider.modelSelection.kind !== "models") {
return DESKTOP_PROVIDER_VIEW_MIN_HEIGHT;
}
const modelCount = getProviderModelRows(provider).length;
return Math.min(
Math.max(
DESKTOP_PROVIDER_VIEW_MIN_HEIGHT,
DESKTOP_PROVIDER_VIEW_BASE_HEIGHT + modelCount * DESKTOP_MODEL_ROW_HEIGHT,
),
DESKTOP_PROVIDER_VIEW_MAX_HEIGHT,
);
}, [providers, view]);
const triggerLabel = useMemo(() => {
if (
selectedModelLabel === t("modelSelector.loading") ||
selectedModelLabel === t("modelSelector.selectModel")
) {
return selectedModelLabel;
}
return buildSelectedTriggerLabel(selectedModelLabel);
}, [selectedModelLabel, t]);
useEffect(() => {
if (platformIsWeb) {
return () => {};
}
if (!isOpen) {
setIsContentReady(false);
return () => {};
}
const frame = requestAnimationFrame(() => {
setIsContentReady(true);
});
return () => cancelAnimationFrame(frame);
}, [isOpen]);
const handleTriggerPress = useCallback(() => {
handleOpenChange(!isOpen);
}, [handleOpenChange, isOpen]);
const triggerStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
// Fill mode: transparent full-width passthrough. The trigger paints its own
// hover/pressed state from the args, so the wrapper must not double-paint.
if (triggerFill) {
return [
styles.trigger,
styles.customTriggerWrapper,
styles.triggerFill,
disabled && styles.triggerDisabled,
];
}
return [
styles.trigger,
Boolean(hovered) && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
renderTrigger ? styles.customTriggerWrapper : null,
];
},
[disabled, isOpen, renderTrigger, triggerFill],
);
const handleBackToAll = useCallback(() => {
setView({ kind: "all" });
setSearchQuery("");
bumpSearchResetKey();
}, []);
const handleDrillDown = useCallback((providerId: string, providerLabel: string) => {
setView({ kind: "provider", providerId, providerLabel });
}, []);
const handleSearchQueryChange = useCallback((value: string) => {
setSearchQuery(value);
}, []);
const openProviderSettings = useCallback(() => {
if (!serverId || view.kind !== "provider") return;
useProviderSettingsStore.getState().open({ serverId, provider: view.providerId });
}, [serverId, view]);
const sheetHeader = useMemo(() => {
if (view.kind === "all") {
return { title: t("modelSelector.title") };
}
const headerActions = (
);
return {
title: view.providerLabel,
leading: ,
back: singleProviderView ? undefined : { onPress: handleBackToAll },
actions: headerActions,
search: {
onChange: handleSearchQueryChange,
resetKey: `${view.providerId}:${searchResetKey}`,
placeholder: t("modelSelector.searchPlaceholder"),
autoFocus: platformIsWeb,
testID: "model-search-input",
},
};
}, [
view,
singleProviderView,
serverId,
openProviderSettings,
handleBackToAll,
handleSearchQueryChange,
searchResetKey,
t,
]);
return (
<>
{renderTrigger ? (
{({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) =>
renderTrigger({
selectedModelLabel: triggerLabel,
onPress: handleTriggerPress,
disabled,
isOpen,
hovered: Boolean(hovered),
pressed,
})
}
) : (
{hasSelectedProvider ? (
) : null}
{triggerLabel}
)}
{isContentReady ? (
) : (
{t("modelSelector.loadingSelector")}
)}
>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
minWidth: 0,
flexShrink: 1,
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: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
customTriggerWrapper: {
paddingHorizontal: 0,
paddingVertical: 0,
height: "auto",
},
// Stretch the wrapper (and, via column + stretch, its single child) to the
// full width of the field, with no background or rounding of its own.
triggerFill: {
alignSelf: "stretch",
flexShrink: 0,
flexDirection: "column",
alignItems: "stretch",
backgroundColor: "transparent",
borderRadius: 0,
},
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,
},
rowStateInline: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 1,
minWidth: 0,
},
rowErrorText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
maxWidth: 140,
},
rowIconButton: {
width: 24,
height: 24,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
rowSpinner: {
transform: [{ scale: 0.7 }],
},
rowIconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
rowIconButtonPressed: {
backgroundColor: theme.colors.surface1,
},
emptyState: {
paddingVertical: theme.spacing[4],
alignItems: "center",
gap: theme.spacing[2],
},
emptyStateText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
virtualizedModelList: {
flex: 1,
},
virtualizedModelListContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[8],
},
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,
},
providerIconMuted: {
color: theme.colors.foregroundMuted,
},
providerIconForeground: {
color: theme.colors.foreground,
},
}));