959 lines
27 KiB
TypeScript
959 lines
27 KiB
TypeScript
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
Pressable,
|
|
ActivityIndicator,
|
|
type GestureResponderEvent,
|
|
type PressableStateCallbackType,
|
|
} from "react-native";
|
|
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
|
import { 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";
|
|
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
|
const IS_WEB = platformIsWeb;
|
|
|
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
|
|
|
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,
|
|
];
|
|
}
|
|
import { getProviderIcon } from "@/components/provider-icons";
|
|
import {
|
|
buildModelRows,
|
|
buildProviderGroups,
|
|
buildSelectedTriggerLabel,
|
|
filterAndRankModelRows,
|
|
resolveProviderLabel,
|
|
type SelectorProviderGroup,
|
|
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<string, AgentModelDefinition[]>;
|
|
selectedProvider: string;
|
|
selectedModel: string;
|
|
onSelect: (provider: AgentProvider, modelId: string) => void;
|
|
isLoading: boolean;
|
|
canSelectProvider?: (provider: string) => boolean;
|
|
favoriteKeys?: Set<string>;
|
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
|
renderTrigger?: (input: {
|
|
selectedModelLabel: string;
|
|
onPress: () => void;
|
|
disabled: boolean;
|
|
isOpen: boolean;
|
|
}) => React.ReactNode;
|
|
onOpen?: () => void;
|
|
onClose?: () => void;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
interface SelectorContentProps {
|
|
view: SelectorView;
|
|
providerDefinitions: AgentProviderDefinition[];
|
|
allProviderModels: Map<string, AgentModelDefinition[]>;
|
|
selectedProvider: string;
|
|
selectedModel: string;
|
|
searchQuery: string;
|
|
favoriteKeys: Set<string>;
|
|
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 sortFavoritesFirst(
|
|
rows: SelectorModelRow[],
|
|
favoriteKeys: Set<string>,
|
|
): 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 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 leadingSlot = useMemo(
|
|
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
|
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
|
|
);
|
|
const trailingSlot = useMemo(
|
|
() =>
|
|
onToggleFavorite && !disabled ? (
|
|
<Pressable
|
|
onPress={handleToggleFavorite}
|
|
hitSlop={8}
|
|
style={favoriteButtonStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
|
|
testID={`favorite-model-${row.provider}-${row.modelId}`}
|
|
>
|
|
{({ hovered }) => {
|
|
let starColor: string;
|
|
if (isFavorite) starColor = theme.colors.palette.amber[500];
|
|
else if (hovered) starColor = theme.colors.foregroundMuted;
|
|
else starColor = theme.colors.border;
|
|
return (
|
|
<Star
|
|
size={16}
|
|
color={starColor}
|
|
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
|
|
/>
|
|
);
|
|
}}
|
|
</Pressable>
|
|
) : null,
|
|
[
|
|
onToggleFavorite,
|
|
disabled,
|
|
handleToggleFavorite,
|
|
isFavorite,
|
|
row.provider,
|
|
row.modelId,
|
|
theme.colors.palette.amber,
|
|
theme.colors.foregroundMuted,
|
|
theme.colors.border,
|
|
],
|
|
);
|
|
|
|
const showDescription = row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
|
|
|
|
return (
|
|
<ComboboxItem
|
|
label={row.modelLabel}
|
|
description={showDescription ? row.description : undefined}
|
|
selected={isSelected}
|
|
disabled={disabled}
|
|
elevated={elevated}
|
|
onPress={onPress}
|
|
leadingSlot={leadingSlot}
|
|
trailingSlot={trailingSlot}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface SelectableModelRowProps {
|
|
row: SelectorModelRow;
|
|
isSelected: boolean;
|
|
isFavorite: boolean;
|
|
disabled?: boolean;
|
|
elevated?: boolean;
|
|
onSelect: (provider: string, modelId: string) => void;
|
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
|
}
|
|
|
|
function SelectableModelRow({
|
|
row,
|
|
isSelected,
|
|
isFavorite,
|
|
disabled,
|
|
elevated,
|
|
onSelect,
|
|
onToggleFavorite,
|
|
}: SelectableModelRowProps) {
|
|
const handlePress = useCallback(() => {
|
|
onSelect(row.provider, row.modelId);
|
|
}, [onSelect, row.provider, row.modelId]);
|
|
return (
|
|
<ModelRow
|
|
row={row}
|
|
isSelected={isSelected}
|
|
isFavorite={isFavorite}
|
|
disabled={disabled}
|
|
elevated={elevated}
|
|
onPress={handlePress}
|
|
onToggleFavorite={onToggleFavorite}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function FavoritesSection({
|
|
favoriteRows,
|
|
selectedProvider,
|
|
selectedModel,
|
|
favoriteKeys,
|
|
onSelect,
|
|
canSelectProvider,
|
|
onToggleFavorite,
|
|
}: {
|
|
favoriteRows: SelectorModelRow[];
|
|
selectedProvider: string;
|
|
selectedModel: string;
|
|
favoriteKeys: Set<string>;
|
|
onSelect: (provider: string, modelId: string) => void;
|
|
canSelectProvider: (provider: string) => boolean;
|
|
onToggleFavorite?: (provider: string, modelId: string) => void;
|
|
}) {
|
|
const { theme: _theme } = useUnistyles();
|
|
|
|
if (favoriteRows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<View style={styles.favoritesContainer}>
|
|
<View style={styles.sectionHeading}>
|
|
<Text style={styles.sectionHeadingText}>Favorites</Text>
|
|
</View>
|
|
{favoriteRows.map((row) => (
|
|
<SelectableModelRow
|
|
key={row.favoriteKey}
|
|
row={row}
|
|
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
|
|
isFavorite={favoriteKeys.has(row.favoriteKey)}
|
|
disabled={!canSelectProvider(row.provider)}
|
|
elevated
|
|
onSelect={onSelect}
|
|
onToggleFavorite={onToggleFavorite}
|
|
/>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
interface GroupProviderButtonProps {
|
|
providerId: string;
|
|
providerLabel: string;
|
|
rowCount: number;
|
|
hasNoModels: boolean;
|
|
disabled?: boolean;
|
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
|
onSelectDefault: (providerId: string) => void;
|
|
}
|
|
|
|
function GroupProviderButton({
|
|
providerId,
|
|
providerLabel,
|
|
rowCount,
|
|
hasNoModels,
|
|
disabled,
|
|
onDrillDown,
|
|
onSelectDefault,
|
|
}: GroupProviderButtonProps) {
|
|
const { theme } = useUnistyles();
|
|
const ProvIcon = getProviderIcon(providerId);
|
|
const handlePress = useCallback(() => {
|
|
if (hasNoModels) {
|
|
onSelectDefault(providerId);
|
|
return;
|
|
}
|
|
onDrillDown(providerId, providerLabel);
|
|
}, [hasNoModels, onDrillDown, onSelectDefault, providerId, providerLabel]);
|
|
return (
|
|
<Pressable disabled={disabled} onPress={handlePress} style={drillDownRowStyle}>
|
|
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
<Text style={styles.drillDownText}>{providerLabel}</Text>
|
|
<View style={styles.drillDownTrailing}>
|
|
<Text style={styles.drillDownCount}>
|
|
{hasNoModels ? "Default" : `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
|
|
</Text>
|
|
{hasNoModels ? null : (
|
|
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
)}
|
|
</View>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function GroupedProviderRows({
|
|
groupedRows,
|
|
onDrillDown,
|
|
onSelectDefault,
|
|
canSelectProvider,
|
|
}: {
|
|
groupedRows: SelectorProviderGroup[];
|
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
|
onSelectDefault: (providerId: string) => void;
|
|
canSelectProvider: (provider: string) => boolean;
|
|
}) {
|
|
return (
|
|
<View>
|
|
{groupedRows.map((group, index) => {
|
|
return (
|
|
<View key={group.providerId}>
|
|
{index > 0 ? <View style={styles.separator} /> : null}
|
|
<GroupProviderButton
|
|
providerId={group.providerId}
|
|
providerLabel={group.providerLabel}
|
|
rowCount={group.rows.length}
|
|
hasNoModels={group.hasNoModels}
|
|
disabled={group.hasNoModels && !canSelectProvider(group.providerId)}
|
|
onDrillDown={onDrillDown}
|
|
onSelectDefault={onSelectDefault}
|
|
/>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function DefaultProviderRow({
|
|
providerId,
|
|
isSelected,
|
|
disabled,
|
|
onSelect,
|
|
}: {
|
|
providerId: string;
|
|
isSelected: boolean;
|
|
disabled?: boolean;
|
|
onSelect: (provider: string, modelId: string) => void;
|
|
}) {
|
|
const { theme } = useUnistyles();
|
|
const ProviderIcon = getProviderIcon(providerId);
|
|
const handlePress = useCallback(() => {
|
|
onSelect(providerId, "");
|
|
}, [onSelect, providerId]);
|
|
const leadingSlot = useMemo(
|
|
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
|
|
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
|
|
);
|
|
|
|
return (
|
|
<ComboboxItem
|
|
label="Default"
|
|
selected={isSelected}
|
|
disabled={disabled}
|
|
onPress={handlePress}
|
|
leadingSlot={leadingSlot}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ProviderModelRows({
|
|
rows,
|
|
selectedProvider,
|
|
selectedModel,
|
|
favoriteKeys,
|
|
onSelect,
|
|
canSelectProvider,
|
|
onToggleFavorite,
|
|
normalizedQuery,
|
|
}: {
|
|
rows: SelectorModelRow[];
|
|
selectedProvider: string;
|
|
selectedModel: string;
|
|
favoriteKeys: Set<string>;
|
|
onSelect: (provider: string, modelId: string) => void;
|
|
canSelectProvider: (provider: string) => boolean;
|
|
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: SelectorModelRow }) => (
|
|
<SelectableModelRow
|
|
row={item}
|
|
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
|
|
isFavorite={favoriteKeys.has(item.favoriteKey)}
|
|
disabled={!canSelectProvider(item.provider)}
|
|
onSelect={onSelect}
|
|
onToggleFavorite={onToggleFavorite}
|
|
/>
|
|
),
|
|
[canSelectProvider, favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
|
|
);
|
|
const keyExtractor = useCallback((row: SelectorModelRow) => row.favoriteKey, []);
|
|
|
|
if (useVirtualizedList) {
|
|
return (
|
|
<BottomSheetFlatList
|
|
data={displayRows}
|
|
renderItem={renderItem}
|
|
keyExtractor={keyExtractor}
|
|
style={styles.virtualizedModelList}
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
contentContainerStyle={styles.virtualizedModelListContent}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View>
|
|
{displayRows.map((row) => (
|
|
<View key={row.favoriteKey}>{renderItem({ item: row })}</View>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SelectorContent({
|
|
view,
|
|
providerDefinitions,
|
|
allProviderModels,
|
|
selectedProvider,
|
|
selectedModel,
|
|
searchQuery,
|
|
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(
|
|
() => filterAndRankModelRows(scopedRows, normalizedQuery),
|
|
[normalizedQuery, scopedRows],
|
|
);
|
|
|
|
const favoriteRows = useMemo(
|
|
() => visibleRows.filter((row) => favoriteKeys.has(row.favoriteKey)),
|
|
[favoriteKeys, visibleRows],
|
|
);
|
|
|
|
const allGroupedRows = useMemo(
|
|
() => buildProviderGroups(providerDefinitions, allProviderModels, visibleRows, normalizedQuery),
|
|
[allProviderModels, normalizedQuery, providerDefinitions, visibleRows],
|
|
);
|
|
const handleSelectDefaultProvider = useCallback(
|
|
(providerId: string) => {
|
|
onSelect(providerId, "");
|
|
},
|
|
[onSelect],
|
|
);
|
|
const hasResults = favoriteRows.length > 0 || allGroupedRows.length > 0;
|
|
const emptyState = (
|
|
<View style={styles.emptyState}>
|
|
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
|
<Text style={styles.emptyStateText}>No models match your search</Text>
|
|
</View>
|
|
);
|
|
|
|
if (view.kind === "provider") {
|
|
const providerModels = allProviderModels.get(view.providerId);
|
|
if (providerModels && providerModels.length === 0 && !normalizedQuery) {
|
|
return (
|
|
<DefaultProviderRow
|
|
providerId={view.providerId}
|
|
isSelected={view.providerId === selectedProvider && !selectedModel}
|
|
disabled={!canSelectProvider(view.providerId)}
|
|
onSelect={onSelect}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (visibleRows.length === 0) {
|
|
return emptyState;
|
|
}
|
|
|
|
return (
|
|
<ProviderModelRows
|
|
rows={visibleRows}
|
|
selectedProvider={selectedProvider}
|
|
selectedModel={selectedModel}
|
|
favoriteKeys={favoriteKeys}
|
|
onSelect={onSelect}
|
|
canSelectProvider={canSelectProvider}
|
|
onToggleFavorite={onToggleFavorite}
|
|
normalizedQuery={normalizedQuery}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View>
|
|
<FavoritesSection
|
|
favoriteRows={favoriteRows}
|
|
selectedProvider={selectedProvider}
|
|
selectedModel={selectedModel}
|
|
favoriteKeys={favoriteKeys}
|
|
onSelect={onSelect}
|
|
canSelectProvider={canSelectProvider}
|
|
onToggleFavorite={onToggleFavorite}
|
|
/>
|
|
|
|
{allGroupedRows.length > 0 ? (
|
|
<GroupedProviderRows
|
|
groupedRows={allGroupedRows}
|
|
onDrillDown={onDrillDown}
|
|
onSelectDefault={handleSelectDefaultProvider}
|
|
canSelectProvider={canSelectProvider}
|
|
/>
|
|
) : null}
|
|
|
|
{!hasResults ? emptyState : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function CombinedModelSelector({
|
|
providerDefinitions,
|
|
allProviderModels,
|
|
selectedProvider,
|
|
selectedModel,
|
|
onSelect,
|
|
isLoading,
|
|
canSelectProvider = () => true,
|
|
favoriteKeys = new Set<string>(),
|
|
onToggleFavorite,
|
|
renderTrigger,
|
|
onOpen,
|
|
onClose,
|
|
disabled = false,
|
|
}: CombinedModelSelectorProps) {
|
|
const { theme } = useUnistyles();
|
|
const anchorRef = useRef<View>(null);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isContentReady, setIsContentReady] = useState(platformIsWeb);
|
|
const [view, setView] = useState<SelectorView>({ kind: "all" });
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
|
|
|
|
// Single-provider mode: only one provider with models → skip Level 1 entirely
|
|
const singleProviderView = useMemo<SelectorView | null>(() => {
|
|
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 computeInitialView = useCallback((): SelectorView => {
|
|
if (singleProviderView) return singleProviderView;
|
|
|
|
const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`;
|
|
if (selectedProvider && selectedModel && !favoriteKeys.has(selectedFavoriteKey)) {
|
|
const label = resolveProviderLabel(providerDefinitions, selectedProvider);
|
|
return { kind: "provider", providerId: selectedProvider, providerLabel: label };
|
|
}
|
|
|
|
return { kind: "all" };
|
|
}, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providerDefinitions]);
|
|
|
|
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 ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
|
|
|
const selectedModelLabel = useMemo(() => {
|
|
if (!selectedModel) {
|
|
if (!hasSelectedProvider) {
|
|
return "Select model";
|
|
}
|
|
const models = allProviderModels.get(selectedProvider);
|
|
if (models && models.length === 0) {
|
|
return "Default";
|
|
}
|
|
return isLoading ? "Loading..." : "Select model";
|
|
}
|
|
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, hasSelectedProvider, 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(selectedModelLabel);
|
|
}, [selectedModelLabel]);
|
|
|
|
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 }) => [
|
|
styles.trigger,
|
|
Boolean(hovered) && styles.triggerHovered,
|
|
(pressed || isOpen) && styles.triggerPressed,
|
|
disabled && styles.triggerDisabled,
|
|
renderTrigger ? styles.customTriggerWrapper : null,
|
|
],
|
|
[disabled, isOpen, renderTrigger],
|
|
);
|
|
|
|
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 sheetHeader = useMemo<SheetHeader>(() => {
|
|
if (view.kind === "all") {
|
|
return { title: "Select provider" };
|
|
}
|
|
const ProviderIconForView = getProviderIcon(view.providerId);
|
|
return {
|
|
title: view.providerLabel,
|
|
leading: ProviderIconForView ? (
|
|
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
|
|
) : undefined,
|
|
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
|
search: {
|
|
value: searchQuery,
|
|
onChange: handleSearchQueryChange,
|
|
initialValue: searchQuery,
|
|
resetKey: `${view.providerId}:${searchResetKey}`,
|
|
placeholder: "Search models...",
|
|
autoFocus: platformIsWeb,
|
|
testID: "model-search-input",
|
|
},
|
|
};
|
|
}, [
|
|
view,
|
|
singleProviderView,
|
|
handleBackToAll,
|
|
handleSearchQueryChange,
|
|
searchQuery,
|
|
searchResetKey,
|
|
theme.iconSize.md,
|
|
theme.colors.foreground,
|
|
]);
|
|
|
|
return (
|
|
<>
|
|
<Pressable
|
|
ref={anchorRef}
|
|
collapsable={false}
|
|
disabled={disabled}
|
|
onPress={handleTriggerPress}
|
|
style={triggerStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={`Select model (${selectedModelLabel})`}
|
|
testID="combined-model-selector"
|
|
>
|
|
{renderTrigger ? (
|
|
renderTrigger({
|
|
selectedModelLabel: triggerLabel,
|
|
onPress: handleTriggerPress,
|
|
disabled,
|
|
isOpen,
|
|
})
|
|
) : (
|
|
<>
|
|
{ProviderIcon ? (
|
|
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
|
) : null}
|
|
<Text style={styles.triggerText} numberOfLines={1} ellipsizeMode="tail">
|
|
{triggerLabel}
|
|
</Text>
|
|
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
</>
|
|
)}
|
|
</Pressable>
|
|
<Combobox
|
|
options={EMPTY_COMBOBOX_OPTIONS}
|
|
value=""
|
|
onSelect={noop}
|
|
open={isOpen}
|
|
onOpenChange={handleOpenChange}
|
|
anchorRef={anchorRef}
|
|
desktopPlacement="top-start"
|
|
desktopMinWidth={360}
|
|
desktopFixedHeight={desktopFixedHeight}
|
|
header={sheetHeader}
|
|
mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative}
|
|
>
|
|
{isContentReady ? (
|
|
<SelectorContent
|
|
view={view}
|
|
providerDefinitions={providerDefinitions}
|
|
allProviderModels={allProviderModels}
|
|
selectedProvider={selectedProvider}
|
|
selectedModel={selectedModel}
|
|
searchQuery={searchQuery}
|
|
favoriteKeys={favoriteKeys}
|
|
onSelect={handleSelect}
|
|
canSelectProvider={canSelectProvider}
|
|
onToggleFavorite={onToggleFavorite}
|
|
onDrillDown={handleDrillDown}
|
|
/>
|
|
) : (
|
|
<View style={styles.sheetLoadingState}>
|
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
<Text style={styles.sheetLoadingText}>Loading model selector…</Text>
|
|
</View>
|
|
)}
|
|
</Combobox>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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",
|
|
},
|
|
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,
|
|
},
|
|
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,
|
|
},
|
|
}));
|