import { AlertTriangle, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native"; import type { TFunction } from "i18next"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, Pressable, type PressableStateCallbackType, ScrollView, Text, View, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader, } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { settingsStyles } from "@/styles/settings"; import { resolveProviderLabel } from "@/utils/provider-definitions"; import { formatTimeAgo } from "@/utils/time"; import { compareMatchScores, scoreTextFields } from "@/utils/score-match"; import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types"; import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config"; interface ProviderDiagnosticSheetProps { provider: string; visible: boolean; onClose: () => void; serverId: string; } function rankModels(items: T[], query: string, fields: (item: T) => string[]): T[] { if (!query.trim()) return items; const scored = items .map((item) => ({ item, score: scoreTextFields(query, fields(item)) })) .filter( (entry): entry is { item: T; score: NonNullable } => entry.score !== null, ); scored.sort((a, b) => compareMatchScores(a.score, b.score)); return scored.map((entry) => entry.item); } function DiscoveredModelRow({ model }: { model: AgentModelDefinition }) { return ( {model.label} {model.id} {model.description ? ( {model.description} ) : null} ); } function CustomModelRow({ model, deleting, onDelete, }: { model: ProviderProfileModel; deleting: boolean; onDelete: (modelId: string) => void; }) { const { t } = useTranslation(); const { theme } = useUnistyles(); const handleDelete = useCallback(() => onDelete(model.id), [model.id, onDelete]); const deleteButtonStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ sheetStyles.iconButton, (Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered, deleting ? sheetStyles.disabled : null, ], [deleting], ); return ( {model.label} {model.id} ); } function SectionHeader({ title, count, hint }: { title: string; count?: number; hint?: string }) { return ( {title} {count !== undefined ? ( {count} ) : null} {count !== undefined && hint ? ( ยท ) : null} {hint ? {hint} : null} ); } function AddCustomModelSubSheet({ provider, serverId, visible, onClose, refresh, }: { provider: string; serverId: string; visible: boolean; onClose: () => void; refresh: (providers?: AgentProvider[]) => Promise; }) { const { t } = useTranslation(); const { theme } = useUnistyles(); const { config, patchConfig } = useDaemonConfig(serverId); const [input, setInput] = useState(""); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const additionalModels = useMemo( () => config?.providers?.[provider]?.additionalModels ?? [], [config?.providers, provider], ); const trimmed = input.trim(); const canAdd = trimmed.length > 0 && !additionalModels.some((model) => model.id === trimmed); useEffect(() => { if (!visible) { setInput(""); setError(null); } }, [visible]); const handleAdd = useCallback(() => { if (!canAdd) return; setError(null); setSaving(true); void patchConfig({ providers: { [provider]: { additionalModels: [...additionalModels, { id: trimmed, label: trimmed }], }, }, }) .then(() => refresh([provider])) .then(() => onClose()) .catch((err) => { setError(err instanceof Error ? err.message : t("settings.providers.models.failedToSave")); }) .finally(() => setSaving(false)); }, [additionalModels, canAdd, onClose, patchConfig, provider, refresh, t, trimmed]); const header = useMemo( () => ({ title: t("settings.providers.models.addCustomTitle") }), [t], ); return ( {t("settings.providers.models.modelId")} {error ? {error} : null} ); } function DiagnosticSubSheet({ provider, serverId, visible, onClose, }: { provider: string; serverId: string; visible: boolean; onClose: () => void; }) { const { t } = useTranslation(); const { theme } = useUnistyles(); const client = useHostRuntimeClient(serverId); const [diagnostic, setDiagnostic] = useState(null); const [loading, setLoading] = useState(false); const fetchDiagnostic = useCallback(async () => { if (!client) return; setLoading(true); try { const result = await client.getProviderDiagnostic(provider); setDiagnostic(result.diagnostic); } catch (err) { setDiagnostic( err instanceof Error ? err.message : t("settings.providers.diagnostic.failedToFetch"), ); } finally { setLoading(false); } }, [client, provider, t]); useEffect(() => { if (visible) { void fetchDiagnostic(); } else { setDiagnostic(null); } }, [visible, fetchDiagnostic]); const refreshButtonStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ sheetStyles.iconButton, (Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered, loading ? sheetStyles.disabled : null, ], [loading], ); const handleRefreshPress = useCallback(() => { void fetchDiagnostic(); }, [fetchDiagnostic]); const header = useMemo( () => ({ title: t("settings.providers.diagnostic.title"), actions: ( {loading ? ( ) : ( )} ), }), [ handleRefreshPress, loading, refreshButtonStyle, t, theme.colors.foregroundMuted, theme.iconSize.sm, ], ); let body: React.ReactNode; if (loading && !diagnostic) { body = ( {t("settings.providers.diagnostic.running")} ); } else if (diagnostic) { body = ( {diagnostic} ); } else { body = ( {t("settings.providers.diagnostic.none")} ); } return ( {body} ); } interface ProviderModalBodyProps { discoveredCount: number; additionalCount: number; providerSnapshotRefreshing: boolean; providerErrorMessage: string | null; modelsRefreshing: boolean; searchActive: boolean; filteredDiscovered: AgentModelDefinition[]; filteredCustom: ProviderProfileModel[]; deletingModelId: string | null; onRefresh: () => void; onDeleteCustom: (modelId: string) => void; theme: { iconSize: { md: number }; colors: { foregroundMuted: string } }; } interface ProviderSheetFooterInput { fetchedAtLabel: string | null; isCompact: boolean; modelsRefreshing: boolean; t: TFunction; onOpenAddSheet: () => void; onOpenDiagSheet: () => void; onRefreshModels: () => void; } function renderProviderSheetFooter({ fetchedAtLabel, isCompact, modelsRefreshing, t, onOpenAddSheet, onOpenDiagSheet, onRefreshModels, }: ProviderSheetFooterInput) { const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent; const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions; const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null; const metaStyle = isCompact ? COMPACT_FOOTER_META_STYLE : sheetStyles.footerMeta; return ( {fetchedAtLabel || !isCompact ? ( {fetchedAtLabel ? t("settings.providers.models.updated", { time: fetchedAtLabel }) : ""} ) : null} ); } function ProviderModalBody(props: ProviderModalBodyProps) { const { t } = useTranslation(); const { discoveredCount, additionalCount, providerSnapshotRefreshing, providerErrorMessage, modelsRefreshing, searchActive, filteredDiscovered, filteredCustom, deletingModelId, onRefresh, onDeleteCustom, theme, } = props; if (discoveredCount === 0 && additionalCount === 0 && providerSnapshotRefreshing) { return ( {t("settings.providers.models.loading")} ); } if (discoveredCount === 0 && additionalCount === 0 && providerErrorMessage) { return ( {providerErrorMessage} ); } if (filteredDiscovered.length === 0 && filteredCustom.length === 0 && searchActive) { return ( {t("settings.providers.models.noSearchMatches")} ); } if (discoveredCount === 0 && additionalCount === 0) { return ( {t("settings.providers.models.noneDetected")} ); } return ( <> {filteredDiscovered.length > 0 ? ( {filteredDiscovered.map((model) => ( ))} ) : null} {filteredCustom.length > 0 ? ( {filteredCustom.map((model) => ( ))} ) : null} ); } export function ProviderDiagnosticSheet({ provider, visible, onClose, serverId, }: ProviderDiagnosticSheetProps) { const { t } = useTranslation(); const { theme } = useUnistyles(); const isCompact = useIsCompactFormFactor(); const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId); const { config, patchConfig } = useDaemonConfig(serverId); const [query, setQuery] = useState(""); const [addSheetOpen, setAddSheetOpen] = useState(false); const [diagSheetOpen, setDiagSheetOpen] = useState(false); const [deletingModelId, setDeletingModelId] = useState(null); const providerLabel = resolveProviderLabel(provider, snapshotEntries); const providerEntry = useMemo( () => snapshotEntries?.find((entry) => entry.provider === provider), [snapshotEntries, provider], ); const additionalModels = useMemo( () => config?.providers?.[provider]?.additionalModels ?? [], [config?.providers, provider], ); const providerSnapshotRefreshing = providerEntry?.status === "loading"; const providerErrorMessage = providerEntry?.status === "error" ? (providerEntry.error ?? t("settings.providers.diagnostic.unknownError")) : null; const modelsRefreshing = isRefreshing || providerSnapshotRefreshing; const stableDiscoveredRef = useRef([]); if (providerEntry?.models && providerEntry.models.length > 0) { stableDiscoveredRef.current = providerEntry.models; } const discoveredModels = providerEntry?.models && providerEntry.models.length > 0 ? providerEntry.models : stableDiscoveredRef.current; const [clockTick, setClockTick] = useState(0); useEffect(() => { if (!visible) return; const id = setInterval(() => setClockTick((tick) => tick + 1), 10_000); return () => clearInterval(id); }, [visible]); const fetchedAtLabel = useMemo(() => { if (!providerEntry?.fetchedAt) return null; void clockTick; return formatTimeAgo(new Date(providerEntry.fetchedAt)); }, [providerEntry?.fetchedAt, clockTick]); useEffect(() => { if (!visible) { setQuery(""); setAddSheetOpen(false); setDiagSheetOpen(false); } }, [visible]); const q = query.trim(); const filteredDiscovered = useMemo( () => rankModels(discoveredModels, q, (m) => [m.label, m.id, m.description ?? ""]), [discoveredModels, q], ); const filteredCustom = useMemo( () => rankModels(additionalModels, q, (m) => [m.label, m.id]), [additionalModels, q], ); const handleRefreshModels = useCallback(() => { void refresh([provider]); }, [provider, refresh]); const handleOpenAddSheet = useCallback(() => setAddSheetOpen(true), []); const handleCloseAddSheet = useCallback(() => setAddSheetOpen(false), []); const handleOpenDiagSheet = useCallback(() => setDiagSheetOpen(true), []); const handleCloseDiagSheet = useCallback(() => setDiagSheetOpen(false), []); const handleDeleteCustom = useCallback( (modelId: string) => { setDeletingModelId(modelId); void patchConfig({ providers: { [provider]: { additionalModels: additionalModels.filter((model) => model.id !== modelId), }, }, }) .then(() => refresh([provider])) .finally(() => { setDeletingModelId((current) => (current === modelId ? null : current)); }); }, [additionalModels, patchConfig, provider, refresh], ); const sheetHeader = useMemo( () => ({ title: providerLabel, search: { onChange: setQuery, placeholder: t("settings.providers.models.searchPlaceholder"), testID: "provider-settings-search", }, }), [providerLabel, t], ); return ( <> ); } const sheetStyles = StyleSheet.create((theme) => ({ mutedText: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, monoHint: { fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, color: theme.colors.foregroundMuted, flexShrink: 0, }, descriptionInline: { flex: 1, fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, errorText: { fontSize: theme.fontSize.xs, color: theme.colors.destructive, }, formInput: { backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius.lg, paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], color: theme.colors.foreground, borderWidth: 1, borderColor: theme.colors.border, fontSize: theme.fontSize.sm, }, iconButton: { width: 28, height: 28, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, iconButtonHovered: { backgroundColor: theme.colors.surface2, }, disabled: { opacity: 0.5, }, section: { marginBottom: theme.spacing[4], }, sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], marginBottom: theme.spacing[2], marginLeft: theme.spacing[1], }, sectionHeaderMeta: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], }, modelRow: { flexDirection: "row", alignItems: "center", paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[4], gap: theme.spacing[3], borderTopWidth: 1, borderTopColor: theme.colors.border, }, modelTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, flexShrink: 0, }, modelRowFiller: { flex: 1, }, emptyState: { paddingVertical: theme.spacing[8], alignItems: "center", gap: theme.spacing[3], }, footerContent: { flex: 1, flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], }, compactFooterContent: { flex: 1, gap: theme.spacing[2], }, footerMeta: { flex: 1, fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, compactFooterMeta: { flex: 0, }, footerActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, compactFooterActions: { gap: theme.spacing[2], }, compactFooterButton: { alignSelf: "stretch", }, formGroup: { gap: theme.spacing[3], }, formLabel: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, color: theme.colors.foreground, }, formActions: { flexDirection: "row", justifyContent: "flex-end", gap: theme.spacing[2], }, diagnosticCard: { overflow: "hidden", }, codeScroll: { maxHeight: 480, }, codeContent: { paddingVertical: theme.spacing[3], paddingHorizontal: theme.spacing[4], }, codeText: { fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, color: theme.colors.foreground, lineHeight: 18, }, codeBlockLoading: { paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[4], flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, })); const FORM_INPUT_STYLE = [sheetStyles.formInput, isWeb && { outlineStyle: "none" }]; const COMPACT_FOOTER_META_STYLE = [sheetStyles.footerMeta, sheetStyles.compactFooterMeta]; const MAIN_SNAP_POINTS = ["65%", "92%"]; const ADD_SNAP_POINTS = ["40%"]; const DIAGNOSTIC_SNAP_POINTS = ["50%", "85%"]; const DIAGNOSTIC_CARD_STYLE = [settingsStyles.card, sheetStyles.diagnosticCard];