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];