import { AlertCircle, RotateCw, Search, Trash2 } from "lucide-react-native"; import { useCallback, useEffect, useMemo, useReducer, useState } from "react"; 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 { isWeb } from "@/constants/platform"; import { Fonts } from "@/constants/theme"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { SettingsSection } from "@/screens/settings/settings-section"; import { settingsStyles } from "@/styles/settings"; import { resolveProviderLabel } from "@/utils/provider-definitions"; import { formatTimeAgo } from "@/utils/time"; import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; import type { ProviderProfileModel } from "@server/server/agent/provider-launch-config"; interface ProviderDiagnosticSheetProps { provider: string; visible: boolean; onClose: () => void; serverId: string; } function ModelRow({ model }: { model: AgentModelDefinition }) { return ( {model.label} {model.id} ); } function CustomModelRow(props: { model: ProviderProfileModel; deleting: boolean; onDelete: (modelId: string) => void; }) { const { theme } = useUnistyles(); const { model, deleting, onDelete } = props; 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 CustomModelsSection(props: { provider: string; serverId: string; refresh: (providers?: AgentProvider[]) => Promise; }) { const { provider, serverId, refresh } = props; const { theme } = useUnistyles(); const { config, patchConfig } = useDaemonConfig(serverId); const [input, setInput] = useState(""); const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const [deletingModelId, setDeletingModelId] = useState(null); const providerConfig = config?.providers?.[provider]; const additionalModels = useMemo( () => providerConfig?.additionalModels ?? [], [providerConfig?.additionalModels], ); const trimmedInput = input.trim(); const canAdd = trimmedInput.length > 0 && !additionalModels.some((model) => model.id === trimmedInput); const patchAdditionalModels = useCallback( async (nextModels: ProviderProfileModel[]) => { await patchConfig({ providers: { [provider]: { additionalModels: nextModels, }, }, }); await refresh([provider]); }, [patchConfig, provider, refresh], ); const handleAdd = useCallback(() => { if (!canAdd) { return; } setError(null); setSaving(true); void patchAdditionalModels([ ...additionalModels, { id: trimmedInput, label: trimmedInput, }, ]) .then(() => { setInput(""); bumpInputResetKey(); return undefined; }) .catch((err) => { setError(err instanceof Error ? err.message : "Failed to save model"); }) .finally(() => setSaving(false)); }, [additionalModels, canAdd, patchAdditionalModels, trimmedInput]); const handleDelete = useCallback( (modelId: string) => { setError(null); setDeletingModelId(modelId); void patchAdditionalModels(additionalModels.filter((model) => model.id !== modelId)) .catch((err) => { setError(err instanceof Error ? err.message : "Failed to delete model"); }) .finally(() => { setDeletingModelId((current) => (current === modelId ? null : current)); }); }, [additionalModels, patchAdditionalModels], ); return ( {additionalModels.map((model) => ( ))} {error ? {error} : null} ); } function DiagnosticCodeBlock(props: { loading: boolean; diagnostic: string | null; foregroundMutedColor: string; }) { if (props.loading && !props.diagnostic) { return ( Running diagnostic… ); } if (props.diagnostic) { return ( {props.diagnostic} ); } return ( No diagnostic available ); } export function ProviderDiagnosticSheet({ provider, visible, onClose, serverId, }: ProviderDiagnosticSheetProps) { const { theme } = useUnistyles(); const client = useHostRuntimeClient(serverId); const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId); const [diagnostic, setDiagnostic] = useState(null); const [loading, setLoading] = useState(false); const [query, setQuery] = useState(""); const [queryResetKey, bumpQueryResetKey] = useReducer((key: number) => key + 1, 0); const providerLabel = resolveProviderLabel(provider, snapshotEntries); const providerEntry = useMemo( () => snapshotEntries?.find((entry) => entry.provider === provider), [snapshotEntries, provider], ); const models = providerEntry?.models ?? []; const providerSnapshotRefreshing = providerEntry?.status === "loading"; const providerErrorMessage = providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null; const refreshInFlight = isRefreshing || providerSnapshotRefreshing || loading; const [clockTick, setClockTick] = useState(0); useEffect(() => { if (!visible) return; const id = setInterval(() => setClockTick((t) => t + 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]); const q = query.trim().toLowerCase(); const filteredModels = q ? models.filter((m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q)) : models; const fetchDiagnostic = useCallback( async (options?: { keepCurrent?: boolean }) => { if (!client || !provider) return; setLoading(true); if (!options?.keepCurrent) { setDiagnostic(null); } try { const result = await client.getProviderDiagnostic(provider); setDiagnostic(result.diagnostic); } catch (err) { setDiagnostic(err instanceof Error ? err.message : "Failed to fetch diagnostic"); } finally { setLoading(false); } }, [client, provider], ); const refreshButtonStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ sheetStyles.iconButton, (Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered, refreshInFlight ? sheetStyles.disabled : null, ], [refreshInFlight], ); const handleRefresh = useCallback(() => { if (!provider) { return; } void Promise.all([refresh([provider]), fetchDiagnostic()]).catch((err) => { setDiagnostic(err instanceof Error ? err.message : "Failed to refresh provider"); }); }, [fetchDiagnostic, provider, refresh]); const headerActions = useMemo( () => ( {refreshInFlight ? ( ) : ( )} ), [ handleRefresh, refreshInFlight, refreshButtonStyle, providerLabel, theme.iconSize.sm, theme.colors.foregroundMuted, ], ); useEffect(() => { if (visible) { fetchDiagnostic(); } else { setDiagnostic(null); setQuery(""); bumpQueryResetKey(); } }, [visible, fetchDiagnostic]); const modelsTrailing = useMemo(() => { if (models.length === 0 && !fetchedAtLabel) return undefined; return ( {models.length > 0 ? ( {models.length} ) : null} {models.length > 0 && fetchedAtLabel ? ( · ) : null} {fetchedAtLabel ? ( Updated {fetchedAtLabel} ) : null} ); }, [models.length, fetchedAtLabel]); function renderModelsBody() { if (models.length === 0 && providerSnapshotRefreshing) { return ( Loading models… ); } if (models.length === 0 && providerErrorMessage) { return ( {providerErrorMessage} ); } if (models.length === 0) { return ( No models detected ); } if (filteredModels.length === 0) { return ( No models match your search ); } return filteredModels.map((model: AgentModelDefinition) => ( )); } const sheetHeader = useMemo( () => ({ title: providerLabel, actions: headerActions }), [providerLabel, headerActions], ); return ( Models {modelsTrailing} {renderModelsBody()} ); } const sheetStyles = StyleSheet.create((theme) => ({ mutedText: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, monoHint: { fontFamily: Fonts.mono, fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, marginTop: theme.spacing[1], }, errorText: { fontSize: theme.fontSize.xs, color: theme.colors.destructive, marginTop: theme.spacing[2], marginLeft: theme.spacing[1], }, iconButton: { width: 30, height: 30, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, iconButtonHovered: { backgroundColor: theme.colors.surface2, }, disabled: { opacity: 0.5, }, inlineRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, inlineInput: { flex: 1, minWidth: 0, paddingVertical: 0, color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, codeScroll: { maxHeight: 200, }, codeContent: { paddingVertical: theme.spacing[3], paddingHorizontal: theme.spacing[4], }, codeText: { fontFamily: Fonts.mono, fontSize: theme.fontSize.xs, color: theme.colors.foreground, lineHeight: 18, }, codeBlockLoading: { paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[4], flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, modelsHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], marginBottom: theme.spacing[3], marginLeft: theme.spacing[1], }, modelsTrailing: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], }, modelsScroll: { maxHeight: 360, }, modelsScrollContent: { paddingBottom: 0, }, emptyRow: { paddingVertical: theme.spacing[6], paddingHorizontal: theme.spacing[4], flexDirection: "row", alignItems: "center", justifyContent: "center", gap: theme.spacing[2], borderTopWidth: 1, borderTopColor: theme.colors.border, }, })); const DIAGNOSTIC_SHEET_SNAP_POINTS = ["50%", "85%"]; const DIAGNOSTIC_SEARCH_INPUT_STYLE = [sheetStyles.inlineInput, isWeb && { outlineStyle: "none" }]; const DIAGNOSTIC_INLINE_INPUT_STYLE = [sheetStyles.inlineInput, isWeb && { outlineStyle: "none" }]; const MODEL_ROW_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; const INLINE_ROW_STYLE = [settingsStyles.row, sheetStyles.inlineRow];