diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index e7e8f9d66..37fb673e5 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -28,6 +28,7 @@ import { QuittingOverlay } from "@/components/quitting-overlay";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { LeftSidebar } from "@/components/left-sidebar";
import { ProjectPickerModal } from "@/components/project-picker-modal";
+import { ProviderSettingsHost } from "@/components/provider-settings-host";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
@@ -466,6 +467,7 @@ function AppContainer({
+
void;
- initialValue?: string;
resetKey?: string | number;
placeholder?: string;
autoFocus?: boolean;
@@ -167,6 +165,7 @@ const styles = StyleSheet.create((theme) => ({
borderBottomColor: theme.colors.border,
},
inlineTitle: {
+ flex: 1,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
@@ -202,6 +201,16 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
},
+ footer: {
+ paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
+ paddingVertical: theme.spacing[3],
+ borderTopWidth: 1,
+ borderTopColor: theme.colors.surface2,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: theme.spacing[2],
+ },
adaptiveInputOutline: {
outlineColor: theme.colors.accent,
},
@@ -352,9 +361,7 @@ export function SheetHeaderView({
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={search.placeholder ?? "Search"}
- initialValue={search.initialValue}
resetKey={search.resetKey}
- value={search.value}
onChangeText={handleSearchChange}
autoCapitalize="none"
autoCorrect={false}
@@ -371,7 +378,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
const { theme } = useUnistyles();
const back = header.back;
const handleBackPress = back?.onPress;
- const hasInlineRow = Boolean(handleBackPress || header.leading);
+ const hasInlineRow = Boolean(handleBackPress || header.leading || header.actions);
if (!hasInlineRow && !header.search) return null;
return (
@@ -398,6 +405,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
{header.title}
+ {header.actions ? {header.actions} : null}
) : null}
{header.search ? (
@@ -407,9 +415,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={header.search.placeholder ?? "Search"}
- initialValue={header.search.initialValue}
resetKey={header.search.resetKey}
- value={header.search.value}
onChangeText={header.search.onChange}
autoCapitalize="none"
autoCorrect={false}
@@ -427,6 +433,8 @@ export interface AdaptiveModalSheetProps {
visible: boolean;
onClose: () => void;
children: ReactNode;
+ /** Sticky footer rendered below the scrollable content. */
+ footer?: ReactNode;
snapPoints?: string[];
testID?: string;
/** Override the max width of the desktop card. */
@@ -441,6 +449,7 @@ export function AdaptiveModalSheet({
visible,
onClose,
children,
+ footer,
snapPoints,
testID,
desktopMaxWidth,
@@ -506,6 +515,7 @@ export function AdaptiveModalSheet({
) : (
{children}
)}
+ {footer ? {footer} : null}
);
}
@@ -518,13 +528,13 @@ export function AdaptiveModalSheet({
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
- showsVerticalScrollIndicator={false}
>
{children}
) : (
{children}
)}
+ {footer ? {footer} : null}
>
);
diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx
index 920a1f6d6..2f7b3c2f5 100644
--- a/packages/app/src/components/combined-model-selector.tsx
+++ b/packages/app/src/components/combined-model-selector.tsx
@@ -11,9 +11,19 @@ import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
-import { ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
+import {
+ AlertTriangle,
+ ChevronDown,
+ ChevronRight,
+ Search,
+ Settings,
+ Star,
+} from "lucide-react-native";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
+import { useProviderSettingsStore } from "@/stores/provider-settings-store";
+import { Button } from "@/components/ui/button";
+import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
@@ -48,7 +58,6 @@ import {
buildSelectedTriggerLabel,
filterAndRankModelRows,
getAllProviderModelRows,
- getProviderDefaultLabel,
getProviderModelRows,
resolveSelectedModelLabel,
type ProviderSelectionModelRow,
@@ -79,6 +88,7 @@ interface CombinedModelSelectorProps {
onOpen?: () => void;
onClose?: () => void;
disabled?: boolean;
+ serverId?: string | null;
}
interface SelectorContentProps {
@@ -91,6 +101,7 @@ interface SelectorContentProps {
onSelect: (provider: string, modelId: string) => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void;
+ serverId: string | null;
}
function normalizeSearchQuery(value: string): string {
@@ -268,42 +279,60 @@ function FavoritesSection({
}
interface GroupProviderButtonProps {
- providerId: string;
- providerLabel: string;
- rowCount: number;
- defaultLabel: string | null;
+ provider: ProviderSelectorProvider;
onDrillDown: (providerId: string, providerLabel: string) => void;
- onSelectDefault: (providerId: string) => void;
}
-function GroupProviderButton({
- providerId,
- providerLabel,
- rowCount,
- defaultLabel,
- onDrillDown,
- onSelectDefault,
-}: GroupProviderButtonProps) {
+function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
+ return [
+ styles.rowIconButton,
+ Boolean(hovered) && styles.rowIconButtonHovered,
+ pressed && styles.rowIconButtonPressed,
+ ];
+}
+
+function GroupProviderButton({ provider, onDrillDown }: GroupProviderButtonProps) {
const { theme } = useUnistyles();
- const ProvIcon = getProviderIcon(providerId);
+ const ProvIcon = getProviderIcon(provider.id);
+ const selection = provider.modelSelection;
+
const handlePress = useCallback(() => {
- if (defaultLabel) {
- onSelectDefault(providerId);
- return;
- }
- onDrillDown(providerId, providerLabel);
- }, [defaultLabel, onDrillDown, onSelectDefault, providerId, providerLabel]);
+ onDrillDown(provider.id, provider.label);
+ }, [onDrillDown, provider.id, provider.label]);
+
+ let stateNode: React.ReactNode;
+ if (selection.kind === "models") {
+ const count = selection.rows.length;
+ stateNode = (
+ {`${count} ${count === 1 ? "model" : "models"}`}
+ );
+ } else if (selection.kind === "loading") {
+ stateNode = (
+
+
+ Loading
+
+ );
+ } else {
+ stateNode = (
+
+
+ Error
+
+ );
+ }
+
return (
- {providerLabel}
+ {provider.label}
-
- {defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
-
- {defaultLabel ? null : (
-
- )}
+ {stateNode}
+
);
@@ -312,64 +341,22 @@ function GroupProviderButton({
function GroupedProviderRows({
providers,
onDrillDown,
- onSelectDefault,
}: {
providers: ProviderSelectorProvider[];
onDrillDown: (providerId: string, providerLabel: string) => void;
- onSelectDefault: (providerId: string) => void;
}) {
return (
- {providers.map((provider, index) => {
- const rows = getProviderModelRows(provider);
- const defaultLabel = getProviderDefaultLabel(provider);
- return (
-
- {index > 0 ? : null}
-
-
- );
- })}
+ {providers.map((provider, index) => (
+
+ {index > 0 ? : null}
+
+
+ ))}
);
}
-function DefaultProviderRow({
- providerId,
- isSelected,
- onSelect,
-}: {
- providerId: string;
- isSelected: boolean;
- onSelect: (provider: string, modelId: string) => void;
-}) {
- const { theme } = useUnistyles();
- const ProviderIcon = getProviderIcon(providerId);
- const handlePress = useCallback(() => {
- onSelect(providerId, "");
- }, [onSelect, providerId]);
- const leadingSlot = useMemo(
- () => ,
- [ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
- );
-
- return (
-
- );
-}
-
function ProviderModelRows({
rows,
selectedProvider,
@@ -430,6 +417,31 @@ function ProviderModelRows({
);
}
+function ProviderErrorEmptyState({
+ serverId,
+ providerId,
+ message,
+}: {
+ serverId: string | null;
+ providerId: string;
+ message: string;
+}) {
+ const { theme } = useUnistyles();
+ const { refresh, isRefreshing } = useProvidersSnapshot(serverId);
+ const handleRetry = useCallback(() => {
+ void refresh([providerId]);
+ }, [refresh, providerId]);
+ return (
+
+
+ {message}
+
+
+ );
+}
+
function SelectorContent({
view,
providers,
@@ -440,6 +452,7 @@ function SelectorContent({
onSelect,
onToggleFavorite,
onDrillDown,
+ serverId,
}: SelectorContentProps) {
const { theme } = useUnistyles();
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
@@ -461,12 +474,6 @@ function SelectorContent({
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
[favoriteKeys, providers],
);
- const handleSelectDefaultProvider = useCallback(
- (providerId: string) => {
- onSelect(providerId, "");
- },
- [onSelect],
- );
const hasResults = favoriteRows.length > 0 || providers.length > 0;
const emptyState = (
@@ -479,17 +486,28 @@ function SelectorContent({
if (!selectedViewProvider) {
return emptyState;
}
-
- if (getProviderDefaultLabel(selectedViewProvider) && !normalizedQuery) {
+ const drillSelection = selectedViewProvider.modelSelection;
+ if (drillSelection.kind === "loading") {
return (
-
+
+ Loading
+
+ );
+ }
+ if (drillSelection.kind === "error") {
+ return (
+
);
}
-
if (visibleRows.length === 0) {
return emptyState;
}
@@ -519,11 +537,7 @@ function SelectorContent({
/>
{providers.length > 0 ? (
-
+
) : null}
{!hasResults ? emptyState : null}
@@ -543,6 +557,7 @@ export function CombinedModelSelector({
onOpen,
onClose,
disabled = false,
+ serverId = null,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const anchorRef = useRef(null);
@@ -648,6 +663,10 @@ export function CombinedModelSelector({
handleOpenChange(!isOpen);
}, [handleOpenChange, isOpen]);
+ const handleClose = useCallback(() => {
+ handleOpenChange(false);
+ }, [handleOpenChange]);
+
const triggerStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
@@ -673,21 +692,42 @@ export function CombinedModelSelector({
setSearchQuery(value);
}, []);
+ const openHeaderProviderSettings = useCallback(() => {
+ if (!serverId || view.kind !== "provider") return;
+ useProviderSettingsStore.getState().open({ serverId, provider: view.providerId });
+ handleClose();
+ }, [serverId, view, handleClose]);
+
const sheetHeader = useMemo(() => {
if (view.kind === "all") {
return { title: "Select provider" };
}
const ProviderIconForView = getProviderIcon(view.providerId);
+ const headerActions = (
+
+
+
+ );
return {
title: view.providerLabel,
leading: ProviderIconForView ? (
) : undefined,
back: singleProviderView ? undefined : { onPress: handleBackToAll },
+ actions: headerActions,
search: {
- value: searchQuery,
onChange: handleSearchQueryChange,
- initialValue: searchQuery,
resetKey: `${view.providerId}:${searchResetKey}`,
placeholder: "Search models...",
autoFocus: platformIsWeb,
@@ -697,11 +737,15 @@ export function CombinedModelSelector({
}, [
view,
singleProviderView,
+ serverId,
+ openHeaderProviderSettings,
+ theme.colors.border,
+ theme.colors.foregroundMuted,
handleBackToAll,
handleSearchQueryChange,
- searchQuery,
searchResetKey,
theme.iconSize.md,
+ theme.iconSize.sm,
theme.colors.foreground,
]);
@@ -760,6 +804,7 @@ export function CombinedModelSelector({
onSelect={handleSelect}
onToggleFavorite={onToggleFavorite}
onDrillDown={handleDrillDown}
+ serverId={serverId}
/>
) : (
@@ -857,6 +902,34 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
+ rowStateInline: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[1],
+ flexShrink: 1,
+ minWidth: 0,
+ },
+ rowErrorText: {
+ fontSize: theme.fontSize.xs,
+ color: theme.colors.foregroundMuted,
+ maxWidth: 140,
+ },
+ rowIconButton: {
+ width: 24,
+ height: 24,
+ borderRadius: theme.borderRadius.full,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ rowSpinner: {
+ transform: [{ scale: 0.7 }],
+ },
+ rowIconButtonHovered: {
+ backgroundColor: theme.colors.surface2,
+ },
+ rowIconButtonPressed: {
+ backgroundColor: theme.colors.surface1,
+ },
emptyState: {
paddingVertical: theme.spacing[4],
alignItems: "center",
diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx
index 9e01b4902..e8094ea25 100644
--- a/packages/app/src/components/provider-diagnostic-sheet.tsx
+++ b/packages/app/src/components/provider-diagnostic-sheet.tsx
@@ -1,5 +1,5 @@
-import { AlertCircle, RotateCw, Search, Trash2 } from "lucide-react-native";
-import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
+import { AlertTriangle, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
Pressable,
@@ -21,10 +21,10 @@ 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 { compareMatchScores, scoreTextFields } from "@/utils/score-match";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { ProviderProfileModel } from "@server/server/agent/provider-launch-config";
@@ -35,28 +35,45 @@ interface ProviderDiagnosticSheetProps {
serverId: string;
}
-function ModelRow({ model }: { model: AgentModelDefinition }) {
+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.label}
+
+
+ {model.id}
+
+ {model.description ? (
+
+ {model.description}
-
- {model.id}
-
-
+ ) : null}
);
}
-function CustomModelRow(props: {
+function CustomModelRow({
+ model,
+ deleting,
+ onDelete,
+}: {
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 }) => [
@@ -68,15 +85,14 @@ function CustomModelRow(props: {
);
return (
-
-
-
- {model.label}
-
-
- {model.id}
-
-
+
+
+ {model.label}
+
+
+ {model.id}
+
+
+ {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 { 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 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;
- }
-
+ if (!canAdd) return;
setError(null);
setSaving(true);
- void patchAdditionalModels([
- ...additionalModels,
- {
- id: trimmedInput,
- label: trimmedInput,
+ void patchConfig({
+ providers: {
+ [provider]: {
+ additionalModels: [...additionalModels, { id: trimmed, label: trimmed }],
+ },
},
- ])
- .then(() => {
- setInput("");
- bumpInputResetKey();
- return undefined;
- })
+ })
+ .then(() => refresh([provider]))
+ .then(() => onClose())
.catch((err) => {
setError(err instanceof Error ? err.message : "Failed to save model");
})
.finally(() => setSaving(false));
- }, [additionalModels, canAdd, patchAdditionalModels, trimmedInput]);
+ }, [additionalModels, canAdd, onClose, patchConfig, provider, refresh, trimmed]);
- 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 header = useMemo(() => ({ title: "Add custom model" }), []);
return (
-
-
-
-
-
+
);
}
-function DiagnosticCodeBlock(props: {
- loading: boolean;
- diagnostic: string | null;
- foregroundMutedColor: string;
+function DiagnosticSubSheet({
+ provider,
+ serverId,
+ visible,
+ onClose,
+}: {
+ provider: string;
+ serverId: string;
+ visible: boolean;
+ onClose: () => void;
}) {
- if (props.loading && !props.diagnostic) {
- return (
+ 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 : "Failed to fetch diagnostic");
+ } finally {
+ setLoading(false);
+ }
+ }, [client, provider]);
+
+ 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: "Diagnostic",
+ actions: (
+
+ {loading ? (
+
+ ) : (
+
+ )}
+
+ ),
+ }),
+ [
+ handleRefreshPress,
+ loading,
+ refreshButtonStyle,
+ theme.colors.foregroundMuted,
+ theme.iconSize.sm,
+ ],
+ );
+
+ let body: React.ReactNode;
+ if (loading && !diagnostic) {
+ body = (
-
+
Running diagnostic…
);
- }
- if (props.diagnostic) {
- return (
-
-
+ } else if (diagnostic) {
+ body = (
+
+
- {props.diagnostic}
+ {diagnostic}
);
+ } else {
+ body = (
+
+ No diagnostic available
+
+ );
+ }
+
+ 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 } };
+}
+
+function ProviderModalBody(props: ProviderModalBodyProps) {
+ const {
+ discoveredCount,
+ additionalCount,
+ providerSnapshotRefreshing,
+ providerErrorMessage,
+ modelsRefreshing,
+ searchActive,
+ filteredDiscovered,
+ filteredCustom,
+ deletingModelId,
+ onRefresh,
+ onDeleteCustom,
+ theme,
+ } = props;
+
+ if (discoveredCount === 0 && additionalCount === 0 && providerSnapshotRefreshing) {
+ return (
+
+
+ Loading models…
+
+ );
+ }
+ if (discoveredCount === 0 && additionalCount === 0 && providerErrorMessage) {
+ return (
+
+
+ {providerErrorMessage}
+
+ {modelsRefreshing ? "Retrying…" : "Retry"}
+
+
+ );
+ }
+ if (filteredDiscovered.length === 0 && filteredCustom.length === 0 && searchActive) {
+ return (
+
+ No models match your search
+
+ );
+ }
+ if (discoveredCount === 0 && additionalCount === 0) {
+ return (
+
+ No models detected
+
+ );
}
return (
-
- No diagnostic available
-
+ <>
+ {filteredDiscovered.length > 0 ? (
+
+
+
+ {filteredDiscovered.map((model) => (
+
+ ))}
+
+
+ ) : null}
+ {filteredCustom.length > 0 ? (
+
+
+
+ {filteredCustom.map((model) => (
+
+ ))}
+
+
+ ) : null}
+ >
);
}
@@ -251,23 +436,35 @@ export function ProviderDiagnosticSheet({
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 { config, patchConfig } = useDaemonConfig(serverId);
const [query, setQuery] = useState("");
- const [queryResetKey, bumpQueryResetKey] = useReducer((key: number) => key + 1, 0);
+ 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 models = providerEntry?.models ?? [];
+ const additionalModels = useMemo(
+ () => config?.providers?.[provider]?.additionalModels ?? [],
+ [config?.providers, provider],
+ );
const providerSnapshotRefreshing = providerEntry?.status === "loading";
const providerErrorMessage =
providerEntry?.status === "error" ? (providerEntry.error ?? "Unknown error") : null;
- const refreshInFlight = isRefreshing || providerSnapshotRefreshing || loading;
+ 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(() => {
@@ -281,199 +478,126 @@ export function ProviderDiagnosticSheet({
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);
+ if (!visible) {
setQuery("");
- bumpQueryResetKey();
+ setAddSheetOpen(false);
+ setDiagSheetOpen(false);
}
- }, [visible, fetchDiagnostic]);
+ }, [visible]);
- 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]);
+ 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],
+ );
- 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 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, actions: headerActions }),
- [providerLabel, headerActions],
+ () => ({
+ title: providerLabel,
+ search: {
+ onChange: setQuery,
+ placeholder: "Search models",
+ testID: "provider-settings-search",
+ },
+ }),
+ [providerLabel],
+ );
+
+ const footer = (
+ <>
+
+ {fetchedAtLabel ? `Updated ${fetchedAtLabel}` : ""}
+
+
+
+ Add model
+
+
+ Diagnostic
+
+
+ {modelsRefreshing ? "Refreshing…" : "Refresh"}
+
+
+ >
);
return (
-
-
-
-
-
-
-
-
-
-
-
- Models
- {modelsTrailing}
-
-
-
-
-
-
-
- {renderModelsBody()}
-
-
-
-
+ <>
+
+
+
+
+
+ >
);
}
@@ -486,17 +610,30 @@ const sheetStyles = StyleSheet.create((theme) => ({
fontFamily: Fonts.mono,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
- marginTop: theme.spacing[1],
+ flexShrink: 0,
+ },
+ descriptionInline: {
+ flex: 1,
+ fontSize: theme.fontSize.xs,
+ color: theme.colors.foregroundMuted,
},
errorText: {
fontSize: theme.fontSize.xs,
color: theme.colors.destructive,
- marginTop: theme.spacing[2],
- marginLeft: theme.spacing[1],
+ },
+ 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: 30,
- height: 30,
+ width: 28,
+ height: 28,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
@@ -507,20 +644,72 @@ const sheetStyles = StyleSheet.create((theme) => ({
disabled: {
opacity: 0.5,
},
- inlineRow: {
+ 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],
+ },
+ footerMeta: {
+ flex: 1,
+ fontSize: theme.fontSize.xs,
+ color: theme.colors.foregroundMuted,
+ },
+ footerActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
- inlineInput: {
- flex: 1,
- minWidth: 0,
- paddingVertical: 0,
- color: theme.colors.foreground,
+ 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: 200,
+ maxHeight: 480,
},
codeContent: {
paddingVertical: theme.spacing[3],
@@ -539,39 +728,11 @@ const sheetStyles = StyleSheet.create((theme) => ({
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];
+const FORM_INPUT_STYLE = [sheetStyles.formInput, isWeb && { outlineStyle: "none" }];
+
+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];
diff --git a/packages/app/src/components/provider-settings-host.tsx b/packages/app/src/components/provider-settings-host.tsx
new file mode 100644
index 000000000..30f5e2d07
--- /dev/null
+++ b/packages/app/src/components/provider-settings-host.tsx
@@ -0,0 +1,26 @@
+import { useCallback } from "react";
+import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
+import { useProviderSettingsStore } from "@/stores/provider-settings-store";
+
+export function ProviderSettingsHost() {
+ const serverId = useProviderSettingsStore((state) => state.serverId);
+ const provider = useProviderSettingsStore((state) => state.provider);
+ const close = useProviderSettingsStore((state) => state.close);
+
+ const handleClose = useCallback(() => {
+ close();
+ }, [close]);
+
+ if (!serverId || !provider) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx
index 62fa10521..65b4bb74d 100644
--- a/packages/app/src/components/ui/combobox.tsx
+++ b/packages/app/src/components/ui/combobox.tsx
@@ -156,7 +156,6 @@ function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
export interface SearchInputProps {
placeholder: string;
- value: string;
onChangeText: (text: string) => void;
onSubmitEditing?: () => void;
autoFocus?: boolean;
@@ -166,7 +165,6 @@ export interface SearchInputProps {
export function SearchInput({
placeholder,
- value,
onChangeText,
onSubmitEditing,
autoFocus = false,
@@ -194,9 +192,7 @@ export function SearchInput({
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={placeholder}
- initialValue={value}
resetKey={resetKey}
- value={value}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
@@ -204,12 +200,12 @@ export function SearchInput({
/>
) : (
void;
/** Extra elements rendered inline with the agent controls (desktop only). */
desktopExtras?: ReactNode;
+ modelSelectorServerId?: string | null;
}
export interface DraftAgentControlsProps {
@@ -117,6 +118,7 @@ export interface DraftAgentControlsProps {
onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
disabled?: boolean;
+ modelSelectorServerId?: string | null;
}
interface AgentControlsProps {
@@ -410,6 +412,7 @@ function ControlledAgentControls({
onDropdownClose,
onModelSelectorOpen,
desktopExtras,
+ modelSelectorServerId = null,
}: ControlledAgentControlsProps) {
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
@@ -613,6 +616,7 @@ function ControlledAgentControls({
handleOpenChange={handleOpenChange}
renderThinkingOption={renderThinkingOption}
extras={desktopExtras}
+ modelSelectorServerId={modelSelectorServerId}
/>
) : (
)}
@@ -693,6 +698,7 @@ interface DesktopAgentControlsContentProps {
onPress: () => void;
}) => ReactElement;
extras?: ReactNode;
+ modelSelectorServerId: string | null;
}
const DESKTOP_SEARCH_THRESHOLD = 6;
@@ -739,6 +745,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
handleOpenChange,
renderThinkingOption,
extras,
+ modelSelectorServerId,
} = props;
return (
@@ -791,6 +798,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
disabled={modelDisabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
+ serverId={modelSelectorServerId}
/>
@@ -884,6 +892,7 @@ interface SheetAgentControlsContentProps {
active: boolean;
onPress: () => void;
}) => ReactElement;
+ modelSelectorServerId: string | null;
}
function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
@@ -914,6 +923,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
handleSelectThinkingAndClose,
handleOpenChange,
renderThinkingOption,
+ modelSelectorServerId,
} = props;
const thinkingAnchorRef = useRef(null);
@@ -983,6 +993,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
renderTrigger={renderModelTrigger}
+ serverId={modelSelectorServerId}
/>
) : null}
@@ -1521,6 +1532,7 @@ export const AgentControls = memo(function AgentControls({
onDropdownClose={onDropdownClose}
disabled={!client}
desktopExtras={modeChip}
+ modelSelectorServerId={serverId}
/>
);
});
@@ -1547,6 +1559,7 @@ export function DraftAgentControls({
onDropdownClose,
onModelSelectorOpen,
disabled = false,
+ modelSelectorServerId = null,
}: DraftAgentControlsProps) {
const { preferences, updatePreferences } = useFormPreferences();
const isCompact = useIsCompactFormFactor();
@@ -1614,6 +1627,7 @@ export function DraftAgentControls({
disabled={disabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
+ serverId={modelSelectorServerId}
/>
{selectedProvider ? (
);
}
diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx
index 870fae7ce..6cd246d31 100644
--- a/packages/app/src/composer/agent-controls/mode-control.tsx
+++ b/packages/app/src/composer/agent-controls/mode-control.tsx
@@ -187,13 +187,12 @@ function AgentModeControlView({
() => ({
title: "Mode",
search: {
- value: searchQuery,
onChange: setSearchQuery,
placeholder: "Search modes...",
testID: "mode-search-input",
},
}),
- [searchQuery],
+ [],
);
if (!selectedMode) return null;
diff --git a/packages/app/src/composer/draft/input-draft-core.ts b/packages/app/src/composer/draft/input-draft-core.ts
index ac3b1087f..cbcf7b77b 100644
--- a/packages/app/src/composer/draft/input-draft-core.ts
+++ b/packages/app/src/composer/draft/input-draft-core.ts
@@ -46,6 +46,7 @@ export function buildDraftAgentControls(input: {
onSetFeature,
onDropdownClose,
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
+ modelSelectorServerId: formState.selectedServerId,
};
}
diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts
index 7be26a7c5..67eef830d 100644
--- a/packages/app/src/hooks/use-providers-snapshot.ts
+++ b/packages/app/src/hooks/use-providers-snapshot.ts
@@ -94,11 +94,11 @@ export function useProvidersSnapshot(
return;
}
await refreshSnapshot(providers);
- if (isProvidersSnapshotHomeScope(cwd)) {
- queryClient.removeQueries({ queryKey: queryRoot, exact: false });
- }
const snapshot = await client.getProvidersSnapshot(requestOptions);
queryClient.setQueryData(queryKey, snapshot);
+ if (isProvidersSnapshotHomeScope(cwd)) {
+ void queryClient.invalidateQueries({ queryKey: queryRoot, exact: false });
+ }
},
[client, cwd, queryClient, queryKey, queryRoot, refreshSnapshot, requestOptions],
);
diff --git a/packages/app/src/provider-selection/provider-selection.test.ts b/packages/app/src/provider-selection/provider-selection.test.ts
index 8d79f7d3b..a20901ace 100644
--- a/packages/app/src/provider-selection/provider-selection.test.ts
+++ b/packages/app/src/provider-selection/provider-selection.test.ts
@@ -99,14 +99,39 @@ describe("combined model selector data", () => {
).toEqual([]);
});
- it("excludes providers that are not ready", () => {
+ it("surfaces non-ready providers with their state-specific selection", () => {
expect(
buildSelectableProviderSelectorProviders([
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
- snapshotEntry({ provider: "error-provider", status: "error", models: [] }),
- snapshotEntry({ provider: "unavailable-provider", status: "unavailable", models: [] }),
+ snapshotEntry({
+ provider: "error-provider",
+ status: "error",
+ error: "boom",
+ models: [],
+ }),
+ snapshotEntry({
+ provider: "unavailable-provider",
+ status: "unavailable",
+ models: [],
+ }),
]),
- ).toEqual([]);
+ ).toEqual([
+ {
+ id: "loading-provider",
+ label: "loading-provider",
+ modelSelection: { kind: "loading" },
+ },
+ {
+ id: "error-provider",
+ label: "error-provider",
+ modelSelection: { kind: "error", message: "boom" },
+ },
+ {
+ id: "unavailable-provider",
+ label: "unavailable-provider",
+ modelSelection: { kind: "error", message: "Unavailable" },
+ },
+ ]);
});
it("builds selector providers from an already-curated provider list", () => {
diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts
index 871694045..a862862af 100644
--- a/packages/app/src/provider-selection/provider-selection.ts
+++ b/packages/app/src/provider-selection/provider-selection.ts
@@ -13,9 +13,8 @@ export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean
export type ProviderModelSelection =
| { kind: "models"; rows: ProviderSelectionModelRow[] }
- | { kind: "providerDefault"; label: string }
| { kind: "loading" }
- | { kind: "unavailable"; reason: string };
+ | { kind: "error"; message: string };
export interface ProviderSelectorProvider {
id: string;
@@ -53,6 +52,21 @@ function buildModelRows(
}));
}
+function buildSyntheticDefaultRow(
+ provider: string,
+ providerLabel: string,
+): ProviderSelectionModelRow {
+ return {
+ favoriteKey: buildFavoriteModelKey({ provider, modelId: "" }),
+ provider,
+ providerLabel,
+ modelId: "",
+ modelLabel: "Default",
+ description: undefined,
+ isDefault: true,
+ };
+}
+
function buildModelSelection(
provider: string,
providerLabel: string,
@@ -62,13 +76,28 @@ function buildModelSelection(
return { kind: "loading" };
}
if (models.length === 0) {
- return { kind: "providerDefault", label: "Default" };
+ return { kind: "models", rows: [buildSyntheticDefaultRow(provider, providerLabel)] };
}
return { kind: "models", rows: buildModelRows(provider, providerLabel, models) };
}
-function isSelectableProvider(entry: ProviderSnapshotEntry): boolean {
- return entry.enabled && entry.status === "ready";
+function buildEntryModelSelection(
+ entry: ProviderSnapshotEntry,
+ label: string,
+): ProviderModelSelection {
+ if ((entry.models?.length ?? 0) > 0) {
+ return buildModelSelection(entry.provider, label, entry.models ?? null);
+ }
+ if (entry.status === "ready") {
+ return buildModelSelection(entry.provider, label, entry.models ?? null);
+ }
+ if (entry.status === "loading") {
+ return { kind: "loading" };
+ }
+ return {
+ kind: "error",
+ message: entry.error ?? (entry.status === "unavailable" ? "Unavailable" : "Unknown error"),
+ };
}
export function buildProviderSelectorProviders(input: {
@@ -91,14 +120,16 @@ export function buildProviderSelectorProviders(input: {
export function buildSelectableProviderSelectorProviders(
entries: ProviderSnapshotEntry[] | undefined,
): ProviderSelectorProvider[] {
- return (entries ?? []).filter(isSelectableProvider).map((entry) => {
- const label = entry.label ?? entry.provider;
- return {
- id: entry.provider,
- label,
- modelSelection: buildModelSelection(entry.provider, label, entry.models ?? []),
- };
- });
+ return (entries ?? [])
+ .filter((entry) => entry.enabled)
+ .map((entry) => {
+ const label = entry.label ?? entry.provider;
+ return {
+ id: entry.provider,
+ label,
+ modelSelection: buildEntryModelSelection(entry, label),
+ };
+ });
}
export function getProviderModelRows(
@@ -113,10 +144,6 @@ export function getAllProviderModelRows(
return providers.flatMap(getProviderModelRows);
}
-export function getProviderDefaultLabel(provider: ProviderSelectorProvider): string | null {
- return provider.modelSelection.kind === "providerDefault" ? provider.modelSelection.label : null;
-}
-
export function resolveSelectedModelLabel(input: {
providers: ProviderSelectorProvider[];
selectedProvider: string;
@@ -129,23 +156,14 @@ export function resolveSelectedModelLabel(input: {
}
const provider = input.providers.find((entry) => entry.id === selectedProvider);
- if (!input.selectedModel) {
- if (provider?.modelSelection.kind === "providerDefault") {
- return provider.modelSelection.label;
- }
- if (provider?.modelSelection.kind === "loading") {
- return "Loading...";
- }
- return input.isLoading ? "Loading..." : "Select model";
- }
-
if (!provider) {
return input.isLoading ? "Loading..." : "Select model";
}
+ if (provider.modelSelection.kind === "loading") {
+ return "Loading...";
+ }
if (provider.modelSelection.kind !== "models") {
- return provider.modelSelection.kind === "providerDefault"
- ? provider.modelSelection.label
- : "Select model";
+ return "Select model";
}
const model = provider.modelSelection.rows.find((entry) => entry.modelId === input.selectedModel);
diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx
index 560df9f70..92e970f14 100644
--- a/packages/app/src/screens/settings/providers-section.test.tsx
+++ b/packages/app/src/screens/settings/providers-section.test.tsx
@@ -7,39 +7,41 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import type { MutableDaemonConfig } from "@server/shared/messages";
-const { theme, snapshotState, configState, patchConfigMock, refreshMock } = vi.hoisted(() => ({
- theme: {
- spacing: { 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24 },
- iconSize: { sm: 14, md: 20 },
- fontSize: { xs: 11, sm: 13, base: 15 },
- fontWeight: { normal: "400" },
- borderRadius: { lg: 8 },
- opacity: { 50: 0.5 },
- colors: {
- surface1: "#111",
- surface2: "#222",
- surface3: "#333",
- foreground: "#fff",
- foregroundMuted: "#aaa",
- border: "#555",
- accent: "#0a84ff",
- statusSuccess: "#00ff00",
- statusWarning: "#ff9500",
- statusDanger: "#ff0000",
- palette: { red: { 300: "#ff6b6b" }, white: "#fff" },
+const { theme, snapshotState, configState, patchConfigMock, openProviderSettingsMock } = vi.hoisted(
+ () => ({
+ theme: {
+ spacing: { 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24 },
+ iconSize: { sm: 14, md: 20 },
+ fontSize: { xs: 11, sm: 13, base: 15 },
+ fontWeight: { normal: "400" },
+ borderRadius: { lg: 8 },
+ opacity: { 50: 0.5 },
+ colors: {
+ surface1: "#111",
+ surface2: "#222",
+ surface3: "#333",
+ foreground: "#fff",
+ foregroundMuted: "#aaa",
+ border: "#555",
+ accent: "#0a84ff",
+ statusSuccess: "#00ff00",
+ statusWarning: "#ff9500",
+ statusDanger: "#ff0000",
+ palette: { red: { 300: "#ff6b6b" }, white: "#fff" },
+ },
},
- },
- snapshotState: {
- entries: undefined as ProviderSnapshotEntry[] | undefined,
- isLoading: false,
- isRefreshing: false,
- },
- configState: {
- config: null as MutableDaemonConfig | null,
- },
- patchConfigMock: vi.fn(async () => undefined),
- refreshMock: vi.fn(async () => {}),
-}));
+ snapshotState: {
+ entries: undefined as ProviderSnapshotEntry[] | undefined,
+ isLoading: false,
+ isRefreshing: false,
+ },
+ configState: {
+ config: null as MutableDaemonConfig | null,
+ },
+ patchConfigMock: vi.fn(async () => undefined),
+ openProviderSettingsMock: vi.fn(),
+ }),
+);
vi.mock("react-native", () => ({
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
@@ -96,7 +98,6 @@ vi.mock("lucide-react-native", () => {
return {
ChevronRight: icon("ChevronRight"),
Plus: icon("Plus"),
- RotateCw: icon("RotateCw"),
};
});
@@ -137,12 +138,9 @@ vi.mock("@/components/provider-icons", () => ({
React.createElement("span", { "data-icon": `provider-${provider}` }),
}));
-vi.mock("@/components/provider-diagnostic-sheet", () => ({
- ProviderDiagnosticSheet: ({ provider }: { provider: string }) =>
- React.createElement("div", {
- "data-testid": "provider-diagnostic-sheet",
- "data-provider": provider,
- }),
+vi.mock("@/stores/provider-settings-store", () => ({
+ useProviderSettingsStore: (selector: (state: unknown) => unknown) =>
+ selector({ open: openProviderSettingsMock }),
}));
vi.mock("@/components/add-provider-modal", () => ({
@@ -157,7 +155,7 @@ vi.mock("@/hooks/use-providers-snapshot", () => ({
isRefreshing: snapshotState.isRefreshing,
error: null,
supportsSnapshot: true,
- refresh: refreshMock,
+ refresh: vi.fn(async () => {}),
refetchIfStale: vi.fn(),
}),
}));
@@ -240,8 +238,7 @@ describe("ProvidersSection", () => {
configState.config = null;
patchConfigMock.mockReset();
patchConfigMock.mockResolvedValue(undefined);
- refreshMock.mockReset();
- refreshMock.mockResolvedValue(undefined);
+ openProviderSettingsMock.mockReset();
});
afterEach(() => {
@@ -321,16 +318,18 @@ describe("ProvidersSection", () => {
render();
- expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull();
+ expect(openProviderSettingsMock).not.toHaveBeenCalled();
const row = findRow("Codex provider details");
act(() => {
row.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
- const sheet = container?.querySelector('[data-testid="provider-diagnostic-sheet"]');
- expect(sheet).not.toBeNull();
- expect(sheet?.getAttribute("data-provider")).toBe("codex");
+ expect(openProviderSettingsMock).toHaveBeenCalledTimes(1);
+ expect(openProviderSettingsMock).toHaveBeenCalledWith({
+ serverId: "server-1",
+ provider: "codex",
+ });
});
it("toggles the provider enabled flag through patchConfig when the switch is pressed", async () => {
@@ -352,26 +351,5 @@ describe("ProvidersSection", () => {
expect(patchConfigMock).toHaveBeenCalledWith({
providers: { claude: { enabled: false } },
});
- expect(refreshMock).not.toHaveBeenCalled();
- expect(container?.querySelector('[data-testid="provider-diagnostic-sheet"]')).toBeNull();
- });
-
- it("forces a provider snapshot refresh from the settings refresh action", async () => {
- snapshotState.entries = [claudeEntry];
- configState.config = makeConfig();
-
- render();
-
- const refreshButton = container?.querySelector(
- '[role="button"][aria-label="Refresh providers"]',
- );
- expect(refreshButton).not.toBeNull();
-
- await act(async () => {
- refreshButton?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
- });
-
- expect(refreshMock).toHaveBeenCalledTimes(1);
- expect(patchConfigMock).not.toHaveBeenCalled();
});
});
diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx
index bdf2f4551..d4f0c3c7f 100644
--- a/packages/app/src/screens/settings/providers-section.tsx
+++ b/packages/app/src/screens/settings/providers-section.tsx
@@ -8,11 +8,11 @@ import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import { AddProviderModal } from "@/components/add-provider-modal";
import { getProviderIcon } from "@/components/provider-icons";
-import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/screens/settings/settings-section";
-import { ChevronRight, Plus, RotateCw } from "lucide-react-native";
+import { useProviderSettingsStore } from "@/stores/provider-settings-store";
+import { ChevronRight, Plus } from "lucide-react-native";
type ProviderDefinition = ReturnType[number];
type ProviderEntry = NonNullable["entries"]>[number];
@@ -179,22 +179,21 @@ export interface ProvidersSectionProps {
export function ProvidersSection({ serverId }: ProvidersSectionProps) {
const { theme } = useUnistyles();
const isConnected = useHostRuntimeIsConnected(serverId);
- const { entries, isLoading, isRefreshing, refresh } = useProvidersSnapshot(serverId);
+ const { entries, isLoading } = useProvidersSnapshot(serverId);
const { patchConfig } = useDaemonConfig(serverId);
- const [diagnosticProvider, setDiagnosticProvider] = useState(null);
+ const openProviderSettings = useProviderSettingsStore((state) => state.open);
const [isAddProviderOpen, setIsAddProviderOpen] = useState(false);
const [pendingProviderId, setPendingProviderId] = useState(null);
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
- const providerRefreshInFlight =
- isRefreshing || (entries?.some((entry) => entry.status === "loading") ?? false);
const hasServer = serverId.length > 0;
- const handleRefresh = useCallback(() => {
- void refresh();
- }, [refresh]);
-
- const handleCloseDiagnostic = useCallback(() => setDiagnosticProvider(null), []);
+ const handleOpenProviderSettings = useCallback(
+ (providerId: string) => {
+ openProviderSettings({ serverId, provider: providerId });
+ },
+ [openProviderSettings, serverId],
+ );
const handleOpenAddProvider = useCallback(() => setIsAddProviderOpen(true), []);
const handleCloseAddProvider = useCallback(() => setIsAddProviderOpen(false), []);
const handleToggleEnabled = useCallback(
@@ -229,30 +228,12 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
Add provider
-
- {providerRefreshInFlight ? (
-
- ) : (
-
- )}
-
) : undefined,
[
hasServer,
isConnected,
handleOpenAddProvider,
- handleRefresh,
- providerRefreshInFlight,
theme.iconSize.sm,
theme.colors.foregroundMuted,
],
@@ -289,7 +270,7 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
enabled={entry.enabled ?? true}
isToggling={pendingProviderId === def.id}
isFirst={index === 0}
- onPress={setDiagnosticProvider}
+ onPress={handleOpenProviderSettings}
onToggleEnabled={handleToggleEnabled}
/>
);
@@ -298,14 +279,6 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) {
) : null}
- {diagnosticProvider ? (
-
- ) : null}
{hasServer && isConnected && isAddProviderOpen ? (
) : null}
diff --git a/packages/app/src/stores/provider-settings-store.ts b/packages/app/src/stores/provider-settings-store.ts
new file mode 100644
index 000000000..cbe18a676
--- /dev/null
+++ b/packages/app/src/stores/provider-settings-store.ts
@@ -0,0 +1,24 @@
+import { create } from "zustand";
+
+interface ProviderSettingsTarget {
+ serverId: string;
+ provider: string;
+}
+
+interface ProviderSettingsStoreState {
+ serverId: string | null;
+ provider: string | null;
+ open: (target: ProviderSettingsTarget) => void;
+ close: () => void;
+}
+
+export const useProviderSettingsStore = create()((set) => ({
+ serverId: null,
+ provider: null,
+ open: ({ serverId, provider }) => {
+ set({ serverId, provider });
+ },
+ close: () => {
+ set({ serverId: null, provider: null });
+ },
+}));
diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts
index 2b94745ff..861fd5eb1 100644
--- a/packages/server/src/server/agent/provider-manifest.ts
+++ b/packages/server/src/server/agent/provider-manifest.ts
@@ -151,6 +151,16 @@ const MOCK_LOAD_TEST_MODES: AgentProviderModeDefinition[] = [
},
];
+const MOCK_SLOW_MODES: AgentProviderModeDefinition[] = [
+ {
+ id: "default",
+ label: "Default",
+ description: "Dev-only mode for the mock slow provider",
+ icon: "ShieldOff",
+ colorTier: "dangerous",
+ },
+];
+
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
{
id: "claude",
@@ -212,6 +222,13 @@ export const DEV_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
defaultModeId: "load-test",
modes: MOCK_LOAD_TEST_MODES,
},
+ {
+ id: "mock-slow",
+ label: "Mock Slow Provider",
+ description: "Dev-only: hangs during model discovery to test loading and timeout UI",
+ defaultModeId: "default",
+ modes: MOCK_SLOW_MODES,
+ },
];
export function getAgentProviderDefinition(
diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts
index 5bdb5c1c5..677e74024 100644
--- a/packages/server/src/server/agent/provider-registry.ts
+++ b/packages/server/src/server/agent/provider-registry.ts
@@ -31,6 +31,7 @@ import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
import { OpenCodeServerManager } from "./providers/opencode/server-manager.js";
import { PiRpcAgentClient } from "./providers/pi/agent.js";
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
+import { MockSlowProviderClient } from "./providers/mock-slow-provider.js";
import {
AGENT_PROVIDER_DEFINITIONS,
BUILTIN_PROVIDER_IDS,
@@ -126,6 +127,7 @@ const PROVIDER_CLIENT_FACTORIES: Record = {
runtimeSettings,
}),
mock: (logger) => new MockLoadTestAgentClient(logger),
+ "mock-slow": () => new MockSlowProviderClient(),
};
function getCursorACPCommand(
diff --git a/packages/server/src/server/agent/providers/mock-slow-provider.ts b/packages/server/src/server/agent/providers/mock-slow-provider.ts
new file mode 100644
index 000000000..e2b7e56a5
--- /dev/null
+++ b/packages/server/src/server/agent/providers/mock-slow-provider.ts
@@ -0,0 +1,78 @@
+import type {
+ AgentCapabilityFlags,
+ AgentClient,
+ AgentLaunchContext,
+ AgentMode,
+ AgentModelDefinition,
+ AgentPersistenceHandle,
+ AgentProvider,
+ AgentSession,
+ AgentSessionConfig,
+ ListModelsOptions,
+ ListModesOptions,
+ ListPersistedAgentsOptions,
+ PersistedAgentDescriptor,
+} from "../agent-sdk-types.js";
+
+export const MOCK_SLOW_PROVIDER_ID = "mock-slow";
+
+const CAPABILITIES: AgentCapabilityFlags = {
+ supportsStreaming: false,
+ supportsSessionPersistence: false,
+ supportsDynamicModes: false,
+ supportsMcpServers: false,
+ supportsReasoningStream: false,
+ supportsToolInvocations: false,
+ supportsRewindConversation: false,
+ supportsRewindFiles: false,
+ supportsRewindBoth: false,
+};
+
+function neverResolves(): Promise {
+ return new Promise(() => {});
+}
+
+export class MockSlowProviderClient implements AgentClient {
+ readonly provider: AgentProvider = MOCK_SLOW_PROVIDER_ID;
+ readonly capabilities = CAPABILITIES;
+
+ async isAvailable(): Promise {
+ return true;
+ }
+
+ listModels(_options: ListModelsOptions): Promise {
+ return neverResolves();
+ }
+
+ listModes(_options: ListModesOptions): Promise {
+ return neverResolves();
+ }
+
+ async listPersistedAgents(
+ _options?: ListPersistedAgentsOptions,
+ ): Promise {
+ return [];
+ }
+
+ async getDiagnostic(): Promise<{ diagnostic: string }> {
+ return {
+ diagnostic:
+ "Mock slow provider: dev-only. listModels() never resolves so the snapshot manager will time out.",
+ };
+ }
+
+ createSession(
+ _config: AgentSessionConfig,
+ _launchContext?: AgentLaunchContext,
+ ): Promise {
+ throw new Error("Mock slow provider is dev-only; sessions are not supported.");
+ }
+
+ resumeSession(
+ _handle: AgentPersistenceHandle,
+ _overrides?: Partial,
+ _launchContext?: AgentLaunchContext,
+ ): Promise {
+ throw new Error("Mock slow provider is dev-only; sessions are not supported.");
+ }
+}