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:
+11
-2
@@ -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) {
|
||||||
await modelsStore.loadModel(modelId);
|
pendingModel = modelId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user