ui : keep the model selector usable when a provider is down

The selector was disabled whenever no backend was reachable, which locked
the one control that lets you leave the broken provider. It now stays
enabled and wears the error colours, the manager marks the unreachable
provider in its section header, and the banner only speaks when there is a
single provider and nowhere to switch to.

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-28 12:10:14 +02:00
parent 727d8b93d5
commit e4d69bf6cd
5 changed files with 61 additions and 26 deletions
@@ -1,7 +1,8 @@
<script lang="ts">
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
import { useBackendAvailability } from '$lib/hooks/use-backend-availability.svelte';
import { LOCAL_BACKEND_ID } from '$lib/constants';
import {
backendsModelsStore,
backendsStore,
conversationsStore,
deviceStore,
@@ -35,8 +36,14 @@
}: Props = $props();
let isRouter = $derived(serverStore.isRouterMode);
const availability = useBackendAvailability();
let isOffline = $derived(availability.isOffline);
// the provider this selector is pointed at, which is the one its colours report
let selectorError = $derived.by(() => {
const backendId = backendsStore.active.id;
return backendId === LOCAL_BACKEND_ID
? Boolean(serverStore.error)
: backendsModelsStore.get(backendId).error !== null;
});
let conversationModel = $derived(
getConversationModel(conversationsStore.activeMessages as DatabaseMessage[])
@@ -169,7 +176,8 @@
<ModelsSelectorSheet
bind:this={selectorModelRef}
currentModel={selectorModel}
disabled={disabled || isOffline}
{disabled}
error={selectorError}
{forceForegroundText}
{useGlobalSelection}
/>
@@ -177,7 +185,8 @@
<ModelsSelectorDropdown
bind:this={selectorModelRef}
currentModel={selectorModel}
disabled={disabled || isOffline}
{disabled}
error={selectorError}
{forceForegroundText}
{useGlobalSelection}
/>
@@ -3,12 +3,16 @@
import * as Alert from '$lib/components/ui/alert';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { useBackendAvailability } from '$lib/hooks/use-backend-availability.svelte';
import { serverStore } from '$lib/stores';
import { backendsStore, serverStore } from '$lib/stores';
const availability = useBackendAvailability();
let isLoadingModel = $derived(serverStore.status === 503);
let hasError = $derived(availability.isOffline);
// A single provider leaves nowhere to switch to, so the banner is the only
// place the failure can be reported. With another provider available the
// selector carries it instead, and stays usable.
let isOnlyProvider = $derived(backendsStore.enabled.length <= 1);
let hasError = $derived(availability.isOffline && isOnlyProvider);
</script>
{#if hasError || isLoadingModel}
@@ -49,7 +49,13 @@
import { Button } from '$lib/components/ui/button';
import { FAMILY_ROW_WINDOW, MODEL_ROW_WINDOW } from '$lib/constants';
import { ModelDownloadConfirmAction, ServerModelStatus } from '$lib/enums';
import { backendsStore, modelsStore, settingsStore, uiStore } from '$lib/stores';
import {
backendsModelsStore,
backendsStore,
modelsStore,
settingsStore,
uiStore
} from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { getBackend } from '$lib/utils/api-base';
import { getBackendCapabilities } from '$lib/utils/backend';
@@ -709,13 +715,17 @@
{/if}
{/snippet}
{@const backendState = group.backendId ? backendsModelsStore.get(group.backendId) : null}
<ModelsSection
backendId={group.kind === 'provider' ? (group.backendId ?? undefined) : undefined}
chevronClass="mr-7"
count={group.items.length}
defaultOpen={group.kind !== 'hidden'}
error={Boolean(backendState?.error)}
icon={group.kind === 'provider' ? undefined : groupIcon}
label={group.label}
loading={Boolean(backendState?.loading)}
persistKey={group.key}
revealChevronOnHover
sticky
@@ -29,6 +29,8 @@
class?: string;
currentModel?: string | null;
disabled?: boolean;
/** The provider behind this selector is unreachable. */
error?: boolean;
forceForegroundText?: boolean;
onModelChange?: (
modelId: string,
@@ -42,6 +44,7 @@
class: className = '',
currentModel = null,
disabled = false,
error = false,
forceForegroundText = false,
onModelChange,
useGlobalSelection = false
@@ -239,13 +242,15 @@
{...props}
class={[
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
error
? 'border-destructive/40 bg-destructive/10 !text-destructive hover:bg-destructive/20'
: !ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: 'text-foreground',
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-foreground',
isOpen && 'text-foreground',
'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]'
]}
@@ -27,6 +27,8 @@
backendId?: string
) => Promise<boolean> | boolean | void;
disabled?: boolean;
/** The provider behind this selector is unreachable. */
error?: boolean;
forceForegroundText?: boolean;
/** When true, user's global selection takes priority over currentModel (for form selector) */
useGlobalSelection?: boolean;
@@ -36,6 +38,7 @@
class: className = '',
currentModel = null,
disabled = false,
error = false,
forceForegroundText = false,
onModelChange,
useGlobalSelection = false
@@ -107,13 +110,15 @@
<button
class={[
`relative inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 max-sm:px-3 max-sm:py-2 max-sm:text-sm dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
error
? 'border-destructive/40 bg-destructive/10 !text-destructive hover:bg-destructive/20'
: !ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: 'text-foreground',
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-foreground',
sheetOpen && 'text-foreground'
]}
disabled={disabled || ms.updating}
@@ -214,13 +219,15 @@
<button
class={[
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
error
? 'border-destructive/40 bg-destructive/10 !text-destructive hover:bg-destructive/20'
: !ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: 'text-foreground'
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-foreground'
]}
disabled={disabled || ms.updating}
onclick={() => ms.handleOpenChange(true)}