Rework provider selector and settings UX

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.
This commit is contained in:
Mohamed Boudra
2026-05-27 22:00:34 +07:00
parent 5696cdb455
commit a4cb7431d8
18 changed files with 1018 additions and 622 deletions
+2
View File
@@ -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({
<WorktreeSetupCalloutSource />
<CommandCenter />
<ProjectPickerModal />
<ProviderSettingsHost />
<WorkspaceShortcutTargetsSubscriber
enabled={keyboardShortcutsEnabled}
serverId={activeServerId}
@@ -28,9 +28,7 @@ import { isNative, isWeb } from "@/constants/platform";
export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
export interface SheetHeaderSearch {
value: string;
onChange: (value: string) => 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 (
<View>
@@ -398,6 +405,7 @@ export function InlineHeaderView({ header }: { header: SheetHeader }) {
<Text style={styles.inlineTitle} numberOfLines={1}>
{header.title}
</Text>
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
</View>
) : 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({
) : (
<View style={styles.bottomSheetStaticContent}>{children}</View>
)}
{footer ? <View style={styles.footer}>{footer}</View> : null}
</IsolatedBottomSheetModal>
);
}
@@ -518,13 +528,13 @@ export function AdaptiveModalSheet({
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
) : (
<View style={styles.desktopStaticContent}>{children}</View>
)}
{footer ? <View style={styles.footer}>{footer}</View> : null}
</>
);
@@ -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 = (
<Text style={styles.drillDownCount}>{`${count} ${count === 1 ? "model" : "models"}`}</Text>
);
} else if (selection.kind === "loading") {
stateNode = (
<View style={styles.rowStateInline}>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
style={styles.rowSpinner}
/>
<Text style={styles.drillDownCount}>Loading</Text>
</View>
);
} else {
stateNode = (
<View style={styles.rowStateInline}>
<AlertTriangle size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownCount}>Error</Text>
</View>
);
}
return (
<Pressable onPress={handlePress} style={drillDownRowStyle}>
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{providerLabel}</Text>
<Text style={styles.drillDownText}>{provider.label}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>
{defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
</Text>
{defaultLabel ? null : (
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
{stateNode}
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
);
@@ -312,64 +341,22 @@ function GroupProviderButton({
function GroupedProviderRows({
providers,
onDrillDown,
onSelectDefault,
}: {
providers: ProviderSelectorProvider[];
onDrillDown: (providerId: string, providerLabel: string) => void;
onSelectDefault: (providerId: string) => void;
}) {
return (
<View>
{providers.map((provider, index) => {
const rows = getProviderModelRows(provider);
const defaultLabel = getProviderDefaultLabel(provider);
return (
<View key={provider.id}>
{index > 0 ? <View style={styles.separator} /> : null}
<GroupProviderButton
providerId={provider.id}
providerLabel={provider.label}
rowCount={rows.length}
defaultLabel={defaultLabel}
onDrillDown={onDrillDown}
onSelectDefault={onSelectDefault}
/>
</View>
);
})}
{providers.map((provider, index) => (
<View key={provider.id}>
{index > 0 ? <View style={styles.separator} /> : null}
<GroupProviderButton provider={provider} onDrillDown={onDrillDown} />
</View>
))}
</View>
);
}
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 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
);
return (
<ComboboxItem
label="Default"
selected={isSelected}
onPress={handlePress}
leadingSlot={leadingSlot}
/>
);
}
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 (
<View style={styles.emptyState}>
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.emptyStateText}>{message}</Text>
<Button variant="default" size="sm" onPress={handleRetry} disabled={isRefreshing}>
{isRefreshing ? "Retrying…" : "Retry"}
</Button>
</View>
);
}
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 = (
<View style={styles.emptyState}>
@@ -479,17 +486,28 @@ function SelectorContent({
if (!selectedViewProvider) {
return emptyState;
}
if (getProviderDefaultLabel(selectedViewProvider) && !normalizedQuery) {
const drillSelection = selectedViewProvider.modelSelection;
if (drillSelection.kind === "loading") {
return (
<DefaultProviderRow
<View style={styles.emptyState}>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
style={styles.rowSpinner}
/>
<Text style={styles.emptyStateText}>Loading</Text>
</View>
);
}
if (drillSelection.kind === "error") {
return (
<ProviderErrorEmptyState
serverId={serverId}
providerId={view.providerId}
isSelected={view.providerId === selectedProvider && !selectedModel}
onSelect={onSelect}
message={drillSelection.message}
/>
);
}
if (visibleRows.length === 0) {
return emptyState;
}
@@ -519,11 +537,7 @@ function SelectorContent({
/>
{providers.length > 0 ? (
<GroupedProviderRows
providers={providers}
onDrillDown={onDrillDown}
onSelectDefault={handleSelectDefaultProvider}
/>
<GroupedProviderRows providers={providers} onDrillDown={onDrillDown} />
) : null}
{!hasResults ? emptyState : null}
@@ -543,6 +557,7 @@ export function CombinedModelSelector({
onOpen,
onClose,
disabled = false,
serverId = null,
}: CombinedModelSelectorProps) {
const { theme } = useUnistyles();
const anchorRef = useRef<View>(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<SheetHeader>(() => {
if (view.kind === "all") {
return { title: "Select provider" };
}
const ProviderIconForView = getProviderIcon(view.providerId);
const headerActions = (
<Pressable
onPress={openHeaderProviderSettings}
disabled={!serverId}
hitSlop={8}
style={iconButtonStyle}
accessibilityRole="button"
accessibilityLabel={`Open ${view.providerLabel} settings`}
testID={`selector-header-settings-${view.providerId}`}
>
<Settings
size={theme.iconSize.sm}
color={!serverId ? theme.colors.border : theme.colors.foregroundMuted}
/>
</Pressable>
);
return {
title: view.providerLabel,
leading: ProviderIconForView ? (
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
) : 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}
/>
) : (
<View style={styles.sheetLoadingState}>
@@ -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",
File diff suppressed because it is too large Load Diff
@@ -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 (
<ProviderDiagnosticSheet
provider={provider}
serverId={serverId}
visible
onClose={handleClose}
/>
);
}
+1 -7
View File
@@ -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({
/>
) : (
<TextInput
key={resetKey}
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
@@ -1041,7 +1037,6 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
value={props.searchQuery}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
@@ -1150,7 +1145,6 @@ function DesktopComboboxOptionsBody(props: {
{props.header || !props.searchable ? null : (
<SearchInput
placeholder={props.searchPlaceholder}
value={props.searchQuery}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus
@@ -93,6 +93,7 @@ interface ControlledAgentControlsProps {
onModelSelectorOpen?: () => 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}
/>
) : (
<SheetAgentControlsContent
@@ -641,6 +645,7 @@ function ControlledAgentControls({
handleSelectThinkingAndClose={handleSelectThinkingAndClose}
handleOpenChange={handleOpenChange}
renderThinkingOption={renderThinkingOption}
modelSelectorServerId={modelSelectorServerId}
/>
)}
</View>
@@ -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}
/>
</View>
</TooltipTrigger>
@@ -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<View | null>(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 ? (
<ControlledAgentControls
@@ -1650,6 +1664,7 @@ export function DraftAgentControls({
onSetFeature={onSetFeature}
onModelSelectorOpen={onModelSelectorOpen}
disabled={disabled}
modelSelectorServerId={modelSelectorServerId}
/>
);
}
@@ -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;
@@ -46,6 +46,7 @@ export function buildDraftAgentControls(input: {
onSetFeature,
onDropdownClose,
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
modelSelectorServerId: formState.selectedServerId,
};
}
@@ -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],
);
@@ -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", () => {
@@ -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);
@@ -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<HTMLElement>(
'[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();
});
});
@@ -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<typeof buildProviderDefinitions>[number];
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["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<string | null>(null);
const openProviderSettings = useProviderSettingsStore((state) => state.open);
const [isAddProviderOpen, setIsAddProviderOpen] = useState(false);
const [pendingProviderId, setPendingProviderId] = useState<string | null>(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) {
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={settingsStyles.sectionHeaderLinkText}>Add provider</Text>
</Pressable>
<Pressable
onPress={handleRefresh}
disabled={providerRefreshInFlight}
hitSlop={8}
style={settingsStyles.sectionHeaderLink}
accessibilityRole="button"
accessibilityLabel={
providerRefreshInFlight ? "Refreshing providers" : "Refresh providers"
}
>
{providerRefreshInFlight ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</Pressable>
</View>
) : 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}
</SettingsSection>
{diagnosticProvider ? (
<ProviderDiagnosticSheet
provider={diagnosticProvider}
visible
onClose={handleCloseDiagnostic}
serverId={serverId}
/>
) : null}
{hasServer && isConnected && isAddProviderOpen ? (
<AddProviderModal serverId={serverId} visible onClose={handleCloseAddProvider} />
) : null}
@@ -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<ProviderSettingsStoreState>()((set) => ({
serverId: null,
provider: null,
open: ({ serverId, provider }) => {
set({ serverId, provider });
},
close: () => {
set({ serverId: null, provider: null });
},
}));
@@ -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(
@@ -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<string, ProviderClientFactory> = {
runtimeSettings,
}),
mock: (logger) => new MockLoadTestAgentClient(logger),
"mock-slow": () => new MockSlowProviderClient(),
};
function getCursorACPCommand(
@@ -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<T>(): Promise<T> {
return new Promise<T>(() => {});
}
export class MockSlowProviderClient implements AgentClient {
readonly provider: AgentProvider = MOCK_SLOW_PROVIDER_ID;
readonly capabilities = CAPABILITIES;
async isAvailable(): Promise<boolean> {
return true;
}
listModels(_options: ListModelsOptions): Promise<AgentModelDefinition[]> {
return neverResolves<AgentModelDefinition[]>();
}
listModes(_options: ListModesOptions): Promise<AgentMode[]> {
return neverResolves<AgentMode[]>();
}
async listPersistedAgents(
_options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
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<AgentSession> {
throw new Error("Mock slow provider is dev-only; sessions are not supported.");
}
resumeSession(
_handle: AgentPersistenceHandle,
_overrides?: Partial<AgentSessionConfig>,
_launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
throw new Error("Mock slow provider is dev-only; sessions are not supported.");
}
}