import { useCallback, useMemo, useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { Alert, Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { settingsStyles } from "@/styles/settings"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { buildAcpProviderConfigPatch, type AcpProviderCatalogItem, } from "@/hooks/use-acp-provider-catalog"; import { ProviderCatalogList } from "@/components/provider-catalog-list"; import { getProviderIcon } from "@/components/provider-icons"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Switch } from "@/components/ui/switch"; import { SettingsSection } from "@/screens/settings/settings-section"; import { useProviderSettingsStore } from "@/stores/provider-settings-store"; import { ChevronRight } from "lucide-react-native"; type ProviderDefinition = ReturnType[number]; type ProviderEntry = NonNullable["entries"]>[number]; type StatusTone = "success" | "warning" | "danger" | "muted" | "loading"; interface ProviderStatus { tone: StatusTone; label: string; modelCount: number | null; } function getProviderStatus( status: string, enabled: boolean, modelCount: number, t: TFunction, ): ProviderStatus { if (!enabled) return { tone: "muted", label: t("settings.providers.statuses.disabled"), modelCount: null }; if (status === "loading") { return { tone: "loading", label: t("settings.providers.statuses.loading"), modelCount: null }; } if (status === "error") { return { tone: "danger", label: t("settings.providers.statuses.error"), modelCount: null }; } if (status === "ready") { return { tone: "success", label: t("settings.providers.statuses.available"), modelCount: modelCount > 0 ? modelCount : null, }; } return { tone: "warning", label: t("settings.providers.statuses.notInstalled"), modelCount: null, }; } interface ProviderRowProps { def: ProviderDefinition; entry: ProviderEntry; enabled: boolean; isToggling: boolean; isFirst: boolean; onPress: (providerId: string) => void; onToggleEnabled: (providerId: string, enabled: boolean) => void; } function ProviderRow({ def, entry, enabled, isToggling, isFirst, onPress, onToggleEnabled, }: ProviderRowProps) { const { t } = useTranslation(); const { theme } = useUnistyles(); const ProviderIcon = getProviderIcon(def.id); const providerError = enabled && entry.status === "error" && typeof entry.error === "string" && entry.error.trim().length > 0 ? entry.error.trim() : null; const modelCount = entry.models?.length ?? 0; const providerStatus = getProviderStatus(entry.status, enabled, modelCount, t); const handlePress = useCallback(() => { onPress(def.id); }, [def.id, onPress]); const handleToggleValueChange = useCallback( (value: boolean) => { onToggleEnabled(def.id, value); }, [def.id, onToggleEnabled], ); const rowStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ settingsStyles.row, !isFirst && settingsStyles.rowBorder, styles.row, hovered && styles.rowHovered, pressed && styles.rowPressed, ], [isFirst], ); return ( {({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => ( <> {def.label} · {providerError ? ( {providerError} ) : null} )} ); } function getDotColor(tone: StatusTone, theme: ReturnType["theme"]): string { switch (tone) { case "success": return theme.colors.statusSuccess; case "warning": return theme.colors.statusWarning; case "danger": return theme.colors.statusDanger; default: return theme.colors.foregroundMuted; } } function StatusIndicator({ status }: { status: ProviderStatus }) { const { t } = useTranslation(); const { theme } = useUnistyles(); const dotStyle = useMemo( () => [styles.statusDot, { backgroundColor: getDotColor(status.tone, theme) }], [status.tone, theme], ); return ( {status.tone === "loading" ? ( ) : ( )} {status.label} {status.modelCount !== null ? ( <> · {status.modelCount === 1 ? t("settings.providers.models.one") : t("settings.providers.models.many", { count: status.modelCount })} ) : null} ); } export interface ProvidersSectionProps { serverId: string; } export function ProvidersSection({ serverId }: ProvidersSectionProps) { const { t } = useTranslation(); const isConnected = useHostRuntimeIsConnected(serverId); const { entries, isLoading, refresh } = useProvidersSnapshot(serverId); const { patchConfig } = useDaemonConfig(serverId); const openProviderSettings = useProviderSettingsStore((state) => state.open); const [pendingProviderId, setPendingProviderId] = useState(null); const [installingProviderId, setInstallingProviderId] = useState(null); const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]); const hasServer = serverId.length > 0; const handleOpenProviderSettings = useCallback( (providerId: string) => { openProviderSettings({ serverId, provider: providerId }); }, [openProviderSettings, serverId], ); const handleToggleEnabled = useCallback( async (providerId: string, enabled: boolean) => { setPendingProviderId(providerId); try { await patchConfig({ providers: { [providerId]: { enabled } } }); } catch (error) { Alert.alert( t("settings.providers.updateErrorTitle"), error instanceof Error ? error.message : String(error), ); } finally { setPendingProviderId((current) => (current === providerId ? null : current)); } }, [patchConfig, t], ); const handleInstall = useCallback( async (entry: AcpProviderCatalogItem) => { if (installingProviderId) return; setInstallingProviderId(entry.id); try { await patchConfig(buildAcpProviderConfigPatch(entry)); await refresh([entry.id]); } catch (error) { Alert.alert( t("settings.providers.addErrorTitle"), error instanceof Error ? error.message : String(error), ); } finally { setInstallingProviderId((current) => (current === entry.id ? null : current)); } }, [installingProviderId, patchConfig, refresh, t], ); return ( <> {!hasServer || !isConnected ? ( {t("settings.providers.unavailable")} ) : null} {hasServer && isConnected && isLoading ? ( {t("settings.providers.loading")} ) : null} {hasServer && isConnected && !isLoading && providerDefinitions.length > 0 ? ( {providerDefinitions.map((def, index) => { const entry = entries?.find((candidate) => candidate.provider === def.id); if (!entry) return null; return ( ); })} ) : null} {hasServer && isConnected ? ( ) : null} ); } const styles = StyleSheet.create((theme) => ({ sectionSpacing: { marginBottom: theme.spacing[4], }, addProviderSection: { marginTop: theme.spacing[4], }, emptyCard: { padding: theme.spacing[4], alignItems: "center", }, emptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, row: { gap: theme.spacing[3], minHeight: 56, }, rowHovered: { backgroundColor: theme.colors.surface2, }, rowPressed: { backgroundColor: theme.colors.surface3, }, rowContent: { flex: 1, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, textColumn: { flex: 1, minWidth: 0, }, titleRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, statusRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1.5], }, statusDot: { width: 8, height: 8, borderRadius: 4, }, statusLabel: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, separator: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, errorText: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs, marginTop: theme.spacing[1], }, })); const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard];