ui: loading bar below the model picker (#24931)

* ui: show model load progress on the selector trigger

Mirror the in-dropdown stage progress as a thin bar on the selector
trigger, so the active model's load percent stays visible when the menu
is closed. Same status gating and composite fraction as the dropdown
row, so both bars track the selected model in sync.

Suggested-by: Julien Chaumond <@julien-c>

* ui: show model load progress bar on the in-conversation model selector

* ui: tune model load indicator to a pulsing highlight (suggested by @ngxson)

Also wire the indicator onto the mobile sheet trigger, which was missing
it since mobile uses the sheet instead of the dropdown.

* ui: thin (@allozaur) pulsating (@ngxson) model load bar
This commit is contained in:
Pascal
2026-06-24 10:50:44 +02:00
committed by GitHub
parent ef9c13d4c2
commit 00139b660b
5 changed files with 69 additions and 18 deletions
@@ -180,6 +180,9 @@
let displayedModel = $derived(message.model ?? null); let displayedModel = $derived(message.model ?? null);
// model being switched to while it loads, so the selector bar tracks it
let pendingModel = $state<string | null>(null);
let isCurrentlyLoading = $derived(isLoading()); let isCurrentlyLoading = $derived(isLoading());
let isStreaming = $derived(isChatStreaming()); let isStreaming = $derived(isChatStreaming());
let hasNoContent = $derived(!message?.content?.trim()); let hasNoContent = $derived(!message?.content?.trim());
@@ -318,13 +321,19 @@
> >
{#if isRouter} {#if isRouter}
<ModelsSelectorDropdown <ModelsSelectorDropdown
currentModel={displayedModel} currentModel={pendingModel ?? displayedModel}
disabled={isLoading()} disabled={isLoading()}
onModelChange={async (modelId: string, modelName: string) => { onModelChange={async (modelId: string, modelName: string) => {
const status = modelsStore.getModelStatus(modelId); const status = modelsStore.getModelStatus(modelId);
if (status !== ServerModelStatus.LOADED) { if (status !== ServerModelStatus.LOADED) {
pendingModel = modelId;
try {
await modelsStore.loadModel(modelId); await modelsStore.loadModel(modelId);
} finally {
pendingModel = null;
}
} }
onRegenerate(modelName); onRegenerate(modelName);
@@ -0,0 +1,11 @@
<script lang="ts">
let { percent }: { percent: number } = $props();
</script>
<!-- thin determinate load bar pinned to the bottom edge, pulsing while it fills -->
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm">
<div
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
style="width: {percent}%"
></div>
</div>
@@ -2,8 +2,10 @@
import { ChevronDown, Loader2, Package } from '@lucide/svelte'; import { ChevronDown, Loader2, Package } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { KeyboardKey } from '$lib/enums'; import { KeyboardKey, ServerModelStatus } from '$lib/enums';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte'; import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import { modelsStore, routerModels } from '$lib/stores/models.svelte';
import { modelLoadFraction } from '$lib/utils';
import { import {
DialogModelInformation, DialogModelInformation,
DropdownMenuSearchable, DropdownMenuSearchable,
@@ -11,6 +13,7 @@
ModelsSelectorList, ModelsSelectorList,
ModelsSelectorOption ModelsSelectorOption
} from '$lib/components/app'; } from '$lib/components/app';
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import type { ModelItem } from './utils'; import type { ModelItem } from './utils';
interface Props { interface Props {
@@ -113,6 +116,17 @@
{/if} {/if}
{:else} {:else}
{@const selectedOption = ms.getDisplayOption()} {@const selectedOption = ms.getDisplayOption()}
{@const triggerModel = selectedOption?.model}
{@const triggerStatus = triggerModel
? routerModels().find((m) => m.id === triggerModel)?.status?.value
: undefined}
{@const triggerLoading =
!!triggerModel &&
(triggerStatus === ServerModelStatus.LOADING ||
modelsStore.isModelOperationInProgress(triggerModel))}
{@const triggerLoadPercent = triggerLoading
? Math.round(modelLoadFraction(modelsStore.getLoadProgress(triggerModel)) * 100)
: 0}
{#if ms.isRouter} {#if ms.isRouter}
<DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}> <DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
@@ -123,7 +137,7 @@
<DropdownMenu.Trigger <DropdownMenu.Trigger
{...props} {...props}
class={[ class={[
`inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] 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`, `relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] 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 !ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400' ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText : forceForegroundText
@@ -154,6 +168,10 @@
{:else} {:else}
<ChevronDown class="h-3 w-3.5 shrink-0" /> <ChevronDown class="h-3 w-3.5 shrink-0" />
{/if} {/if}
{#if triggerLoading}
<ModelLoadHighlight percent={triggerLoadPercent} />
{/if}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
{/snippet} {/snippet}
</Tooltip.Trigger> </Tooltip.Trigger>
@@ -10,6 +10,7 @@
RotateCw RotateCw
} from '@lucide/svelte'; } from '@lucide/svelte';
import { ActionIcon, ModelId } from '$lib/components/app'; import { ActionIcon, ModelId } from '$lib/components/app';
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import type { ModelOption } from '$lib/types/models'; import type { ModelOption } from '$lib/types/models';
import { ServerModelStatus } from '$lib/enums'; import { ServerModelStatus } from '$lib/enums';
import { modelsStore, routerModels } from '$lib/stores/models.svelte'; import { modelsStore, routerModels } from '$lib/stores/models.svelte';
@@ -119,11 +120,11 @@
</div> </div>
{#if isLoading} {#if isLoading}
<div class="flex w-4 [@media(pointer:coarse)]:w-5 items-center justify-center"> <div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-5">
<Loader2 class="h-4 w-4 animate-spin text-muted-foreground" /> <Loader2 class="h-4 w-4 animate-spin text-muted-foreground" />
</div> </div>
{:else if isFailed} {:else if isFailed}
<div class="flex w-4 [@media(pointer:coarse)]:w-auto items-center justify-center"> <div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
<CircleAlert <CircleAlert
class="h-3.5 w-3.5 text-red-500 group-hover:hidden [@media(pointer:coarse)]:hidden" class="h-3.5 w-3.5 text-red-500 group-hover:hidden [@media(pointer:coarse)]:hidden"
/> />
@@ -140,7 +141,7 @@
</div> </div>
</div> </div>
{:else if isSleeping} {:else if isSleeping}
<div class="flex w-4 [@media(pointer:coarse)]:w-auto items-center justify-center"> <div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
<span <span
class="h-2 w-2 rounded-full bg-orange-400 group-hover:hidden [@media(pointer:coarse)]:hidden" class="h-2 w-2 rounded-full bg-orange-400 group-hover:hidden [@media(pointer:coarse)]:hidden"
></span> ></span>
@@ -159,7 +160,7 @@
</div> </div>
</div> </div>
{:else if isLoaded} {:else if isLoaded}
<div class="flex w-4 [@media(pointer:coarse)]:w-auto items-center justify-center"> <div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
<span <span
class="h-2 w-2 rounded-full bg-green-500 group-hover:hidden [@media(pointer:coarse)]:hidden" class="h-2 w-2 rounded-full bg-green-500 group-hover:hidden [@media(pointer:coarse)]:hidden"
></span> ></span>
@@ -176,7 +177,7 @@
</div> </div>
</div> </div>
{:else} {:else}
<div class="flex w-4 [@media(pointer:coarse)]:w-auto items-center justify-center"> <div class="flex w-4 items-center justify-center [@media(pointer:coarse)]:w-auto">
<span <span
class="h-2 w-2 rounded-full bg-muted-foreground/50 group-hover:hidden [@media(pointer:coarse)]:hidden" class="h-2 w-2 rounded-full bg-muted-foreground/50 group-hover:hidden [@media(pointer:coarse)]:hidden"
></span> ></span>
@@ -196,13 +197,6 @@
</div> </div>
{#if isLoading} {#if isLoading}
<div <ModelLoadHighlight percent={loadPercent} />
class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm bg-muted"
>
<div
class="h-full bg-primary transition-[width] duration-200 ease-out"
style="width: {loadPercent}%"
></div>
</div>
{/if} {/if}
</div> </div>
@@ -8,6 +8,10 @@
ModelsSelectorList, ModelsSelectorList,
SearchInput SearchInput
} from '$lib/components/app'; } from '$lib/components/app';
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore, routerModels } from '$lib/stores/models.svelte';
import { modelLoadFraction } from '$lib/utils';
interface Props { interface Props {
class?: string; class?: string;
@@ -61,12 +65,23 @@
<p class="text-xs text-muted-foreground">No models available.</p> <p class="text-xs text-muted-foreground">No models available.</p>
{:else} {:else}
{@const selectedOption = ms.getDisplayOption()} {@const selectedOption = ms.getDisplayOption()}
{@const triggerModel = selectedOption?.model}
{@const triggerStatus = triggerModel
? routerModels().find((m) => m.id === triggerModel)?.status?.value
: undefined}
{@const triggerLoading =
!!triggerModel &&
(triggerStatus === ServerModelStatus.LOADING ||
modelsStore.isModelOperationInProgress(triggerModel))}
{@const triggerLoadPercent = triggerLoading
? Math.round(modelLoadFraction(modelsStore.getLoadProgress(triggerModel)) * 100)
: 0}
{#if ms.isRouter} {#if ms.isRouter}
<button <button
type="button" type="button"
class={[ class={[
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 max-sm:px-3 max-sm:py-2 text-xs max-sm:text-sm 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`, `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 !ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400' ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText : forceForegroundText
@@ -99,6 +114,10 @@
{:else} {:else}
<ChevronDown class="h-3 w-3.5 shrink-0" /> <ChevronDown class="h-3 w-3.5 shrink-0" />
{/if} {/if}
{#if triggerLoading}
<ModelLoadHighlight percent={triggerLoadPercent} />
{/if}
</button> </button>
<Sheet.Root bind:open={sheetOpen} onOpenChange={handleSheetOpenChange}> <Sheet.Root bind:open={sheetOpen} onOpenChange={handleSheetOpenChange}>