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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user