From a4cb7431d8302f2baf4fa5f30101d5f63ede8c9d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 27 May 2026 22:00:10 +0700 Subject: [PATCH] Rework provider selector and settings UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface all enabled providers in the model selector with inline loading and error states instead of silently hiding non-ready ones. Tap any row to drill in; loading and error states show in the drill-down body, with a Retry button on error. Providers with no models render a synthetic "Default" row so every provider's drill-down has symmetric structure. Redesign the per-provider settings sheet: search lives in the header, discovered and custom models share one scrolling list, and the footer hosts action buttons. Diagnostic and "Add model" each open as their own sub-sheet, so neither expands inline or shifts the surrounding layout. Snapshot refresh now keeps cached data visible: setQueryData replaces the cache atomically and sibling-scope caches are invalidated (not removed), so refreshing a provider no longer blanks its row. Search inputs across the app are uncontrolled — onChangeText drives parent state, and resetKey remounts the field when an explicit reset is needed — to avoid RN's controlled-input flicker. Adds a dev-only mock-slow provider whose discovery never resolves, so the 30s snapshot timeout produces a real error and lets loading and timeout-error UI be exercised end to end. --- packages/app/src/app/_layout.tsx | 2 + .../src/components/adaptive-modal-sheet.tsx | 26 +- .../components/combined-model-selector.tsx | 267 ++++-- .../components/provider-diagnostic-sheet.tsx | 893 +++++++++++------- .../src/components/provider-settings-host.tsx | 26 + packages/app/src/components/ui/combobox.tsx | 8 +- .../app/src/composer/agent-controls/index.tsx | 15 + .../composer/agent-controls/mode-control.tsx | 3 +- .../src/composer/draft/input-draft-core.ts | 1 + .../app/src/hooks/use-providers-snapshot.ts | 6 +- .../provider-selection.test.ts | 33 +- .../provider-selection/provider-selection.ts | 78 +- .../settings/providers-section.test.tsx | 112 +-- .../screens/settings/providers-section.tsx | 49 +- .../app/src/stores/provider-settings-store.ts | 24 + .../src/server/agent/provider-manifest.ts | 17 + .../src/server/agent/provider-registry.ts | 2 + .../agent/providers/mock-slow-provider.ts | 78 ++ 18 files changed, 1018 insertions(+), 622 deletions(-) create mode 100644 packages/app/src/components/provider-settings-host.tsx create mode 100644 packages/app/src/stores/provider-settings-store.ts create mode 100644 packages/server/src/server/agent/providers/mock-slow-provider.ts 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 ( - - - - - + - {additionalModels.map((model) => ( - - ))} - {error ? {error} : null} - + ); } -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} + + + ); + } + 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}` : ""} + + + + + + + ); 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."); + } +}