feat: improve provider settings and model selection

This commit is contained in:
Mohamed Boudra
2026-04-24 21:42:47 +07:00
parent d3f9655e33
commit 4b7199f53b
24 changed files with 1384 additions and 514 deletions
@@ -1,4 +1,4 @@
import { AlertCircle, RotateCw, Search } from "lucide-react-native";
import { AlertCircle, Plus, RotateCw, Search, Trash2 } from "lucide-react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
@@ -13,11 +13,13 @@ import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-mod
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 { 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;
@@ -43,6 +45,182 @@ function ModelRow({ model, isFirst }: { model: AgentModelDefinition; isFirst: bo
);
}
function AdditionalModelRow(props: {
model: ProviderProfileModel;
isFirst: boolean;
deleting: boolean;
onDelete: (modelId: string) => void;
}) {
const { theme } = useUnistyles();
const { model, isFirst, deleting, onDelete } = props;
const rowStyle = useMemo(
() => [sheetStyles.additionalModelRow, !isFirst && sheetStyles.modelRowBorder],
[isFirst],
);
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 (
<View style={rowStyle}>
<View style={sheetStyles.additionalModelText}>
<Text style={sheetStyles.modelLabel} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.modelId} numberOfLines={1} selectable>
{model.id}
</Text>
</View>
<Pressable
onPress={handleDelete}
disabled={deleting}
hitSlop={8}
style={deleteButtonStyle}
accessibilityRole="button"
accessibilityLabel={`Remove ${model.id}`}
>
<Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />
</Pressable>
</View>
);
}
function AdditionalModelsEditor(props: {
provider: string;
serverId: string;
refresh: (providers?: AgentProvider[]) => Promise<void>;
}) {
const { provider, serverId, refresh } = props;
const { theme } = useUnistyles();
const { config, patchConfig } = useDaemonConfig(serverId);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deletingModelId, setDeletingModelId] = useState<string | null>(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 as AgentProvider]);
},
[patchConfig, provider, refresh],
);
const handleAdd = useCallback(() => {
if (!canAdd) {
return;
}
setError(null);
setSaving(true);
void patchAdditionalModels([
...additionalModels,
{
id: trimmedInput,
label: trimmedInput,
},
])
.then(() => setInput(""))
.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],
);
const addButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
sheetStyles.addModelButton,
(Boolean(hovered) || pressed) && sheetStyles.addModelButtonHovered,
!canAdd || saving ? sheetStyles.disabled : null,
],
[canAdd, saving],
);
return (
<View style={sheetStyles.section}>
<Text style={sheetStyles.sectionTitle}>Additional Models</Text>
<View style={sheetStyles.addModelRow}>
<View style={sheetStyles.addModelInputContainer}>
<AdaptiveTextInput
value={input}
onChangeText={setInput}
onSubmitEditing={handleAdd}
placeholder="Model ID"
placeholderTextColor={theme.colors.foregroundMuted}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
// @ts-expect-error - outlineStyle is web-only
style={DIAGNOSTIC_ADD_MODEL_INPUT_STYLE}
/>
</View>
<Pressable
onPress={handleAdd}
disabled={!canAdd || saving}
style={addButtonStyle}
accessibilityRole="button"
accessibilityLabel="Add model"
>
{saving ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.accentForeground} />
) : (
<Plus size={theme.iconSize.sm} color={theme.colors.accentForeground} />
)}
</Pressable>
</View>
{error ? <Text style={sheetStyles.errorText}>{error}</Text> : null}
{additionalModels.length > 0 ? (
<View style={sheetStyles.additionalModelsList}>
{additionalModels.map((model, index) => (
<AdditionalModelRow
key={model.id}
model={model}
isFirst={index === 0}
deleting={deletingModelId === model.id}
onDelete={handleDelete}
/>
))}
</View>
) : null}
</View>
);
}
function DiagnosticCodeBlock(props: {
loading: boolean;
diagnostic: string | null;
@@ -255,6 +433,8 @@ export function ProviderDiagnosticSheet({
</View>
<View style={sheetStyles.modelsSection}>
<AdditionalModelsEditor provider={provider} serverId={serverId} refresh={refresh} />
<View style={sheetStyles.modelsHeader}>
<Text style={sheetStyles.sectionTitle}>Models</Text>
<View style={sheetStyles.modelsHeaderMeta}>
@@ -308,6 +488,10 @@ const sheetStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
errorText: {
fontSize: theme.fontSize.xs,
color: theme.colors.destructive,
},
iconButton: {
width: 30,
height: 30,
@@ -380,6 +564,50 @@ const sheetStyles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.md,
paddingHorizontal: theme.spacing[3],
},
addModelRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
addModelInputContainer: {
flex: 1,
minWidth: 0,
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.md,
paddingHorizontal: theme.spacing[3],
},
addModelInput: {
paddingVertical: theme.spacing[2],
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
addModelButton: {
width: 34,
height: 34,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.colors.accent,
},
addModelButtonHovered: {
backgroundColor: theme.colors.accentBright,
},
additionalModelsList: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
additionalModelRow: {
minHeight: 48,
paddingVertical: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
additionalModelText: {
flex: 1,
minWidth: 0,
},
searchInput: {
flex: 1,
paddingVertical: theme.spacing[2],
@@ -419,3 +647,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
const DIAGNOSTIC_SHEET_SNAP_POINTS = ["50%", "85%"];
const DIAGNOSTIC_SEARCH_INPUT_STYLE = [sheetStyles.searchInput, isWeb && { outlineStyle: "none" }];
const DIAGNOSTIC_ADD_MODEL_INPUT_STYLE = [
sheetStyles.addModelInput,
isWeb && { outlineStyle: "none" },
];