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
@@ -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}