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
@@ -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);