ui : list downloads as their own manager section

The models table lists in-flight and paused downloads in a Downloading
section: flat rows like Loaded models, right after them, with a full
width progress bar along the bottom. The status column shows the percent
and swaps in pause or resume on hover; pause, resume and delete live in
the row's action menu. The selector lists running downloads only.

Assisted-by: pi
This commit is contained in:
Aleksander Grygier
2026-10-02 11:59:24 +02:00
parent 7851efc3b2
commit 7bc505dab7
11 changed files with 209 additions and 65 deletions
@@ -2,6 +2,7 @@
import ModelsManagerModelConfiguration from './ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte';
import ModelsManagerModelsTable from './ModelsManagerModelsTable.svelte';
import {
downloadGroups,
groupModelQuants,
modelContextLength,
type ModelQuantGroup,
@@ -139,14 +140,22 @@
rank.size === 0 ? list : [...list].sort((a, b) => rankOf(a) - rankOf(b));
// one entry per repo, so a model with several quants takes a single table row
let entries = $derived(byRecency(groupModelQuants(matching)));
// tracked downloads get their own section, listed like any other model
let downloads = $derived(
downloadGroups(modelsStore.status.getDownloadEntries(), modelsStore.models)
);
let groups = $derived.by(() => {
// a loaded quant is a model of its own: its repo keeps the quants left behind
const isLoaded = (option: ModelOption) => modelsStore.isModelLoaded(option.model);
// a tracked download stands in its own section, so it is not listed twice
const isDownload = (option: ModelOption) =>
modelsStore.status.isDownloadInProgress(option.model) ||
modelsStore.status.isDownloadPaused(option.model);
const loaded: ModelQuantGroup[] = [];
const rest: ModelQuantGroup[] = [];
for (const entry of entries) {
const remaining = entry.quants.filter((quant) => !isLoaded(quant));
const remaining = entry.quants.filter((quant) => !isLoaded(quant) && !isDownload(quant));
for (const quant of entry.quants) {
if (!isLoaded(quant)) continue;
@@ -183,6 +192,7 @@
};
pushSection(ModelsTableGroupKind.LOADED, loaded);
pushSection(ModelsTableGroupKind.DOWNLOADING, downloads);
pushSection(ModelsTableGroupKind.FAVORITES, favorites);
pushSection(ModelsTableGroupKind.LOCAL, local);
pushSection(ModelsTableGroupKind.HIDDEN, hidden);
@@ -0,0 +1,49 @@
<script lang="ts">
import { downloadProgressFor } from './utils';
import { ActionIcon } from '$lib/components/app';
import { MODEL_DOWNLOAD_ICONS, ModelRowDownloadState } from '$lib/constants';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
interface Props {
class?: string;
option: ModelOption;
/** Download state the row stands for. */
state: ModelRowDownloadState;
}
let { class: className = '', option, state }: Props = $props();
let isPaused = $derived(state === ModelRowDownloadState.PAUSED);
let progress = $derived(downloadProgressFor(option.model));
let percent = $derived(
progress && progress.totalBytes > 0
? Math.round((progress.downloadedBytes / progress.totalBytes) * 100)
: null
);
</script>
<!-- the percent is what the row shows at rest; the action takes its place on hover,
the way the load control does in the other sections -->
<div class={['flex items-center justify-center', className]}>
<span
class="text-xs text-muted-foreground tabular-nums group-hover:hidden [@media(pointer:coarse)]:hidden"
>
{percent !== null ? `${percent}%` : isPaused ? 'Paused' : ''}
</span>
<div class="hidden group-hover:flex [@media(pointer:coarse)]:flex">
<ActionIcon
class="h-5 w-5 hover:text-foreground"
icon={isPaused ? MODEL_DOWNLOAD_ICONS.resume : MODEL_DOWNLOAD_ICONS.pause}
iconSize="h-4 w-4"
onclick={() =>
void (isPaused
? modelsStore.status.downloadModel(option.model)
: modelsStore.status.pauseDownload(option.model))}
stopPropagationOnClick
tooltip={isPaused ? 'Resume downloading' : 'Pause downloading'}
tooltipAsTitle
/>
</div>
</div>
@@ -2,13 +2,14 @@
import ModelAvatar from '../ModelAvatar.svelte';
import ModelCapabilities from '../ModelCapabilities.svelte';
import ModelContext from '../ModelContext.svelte';
import ModelDownloadProgressBar from '../ModelDownloadProgressBar.svelte';
import ModelId from '../ModelId.svelte';
import ModelsManagerStatusCell from './ModelsManagerStatusCell.svelte';
import { modelRowActions } from './row-actions';
import { configuredContext } from './utils';
import { configuredContext, downloadProgressFor } from './utils';
import { MoreHorizontal } from '@lucide/svelte';
import { DropdownMenuActions } from '$lib/components/app';
import { MODEL_ROW_GRID_CLASS } from '$lib/constants';
import { MODEL_ROW_GRID_CLASS, ModelRowDownloadState } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { modelsStore, settingsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
@@ -27,6 +28,16 @@
let favorite = $derived(isFavorite(option));
let isHidden = $derived(modelsStore.isHidden(option.id));
// live while the download runs, frozen while it is paused
let downloadProgress = $derived(downloadProgressFor(option.model));
// a tracked download takes over the status column while it runs
let download = $derived(
modelsStore.status.isDownloadPaused(option.model)
? ModelRowDownloadState.PAUSED
: modelsStore.status.isDownloadInProgress(option.model)
? ModelRowDownloadState.DOWNLOADING
: null
);
function handleKeydown(event: KeyboardEvent): void {
if (event.key === KeyboardKey.SPACE) event.preventDefault();
@@ -38,7 +49,7 @@
<div
class={[
MODEL_ROW_GRID_CLASS,
'group cursor-pointer rounded-md px-2 py-3 transition',
'group relative cursor-pointer rounded-md px-2 py-3 transition',
isHidden && 'opacity-60',
selected ? 'bg-accent text-accent-foreground' : 'hover:bg-muted/40'
]}
@@ -74,11 +85,19 @@
<ModelContext class="justify-self-end" configured={configuredContext(option)} {option} />
<ModelsManagerStatusCell {option} />
<ModelsManagerStatusCell {download} {option} />
{#if download}
<ModelDownloadProgressBar
downloadedBytes={downloadProgress?.downloadedBytes ?? 0}
overlay
totalBytes={downloadProgress?.totalBytes ?? 0}
/>
{/if}
<div class="flex items-center justify-center justify-self-center">
<DropdownMenuActions
actions={modelRowActions(option, favorite, isHidden, onDelete)}
actions={modelRowActions(option, favorite, isHidden, onDelete, download)}
align="end"
triggerIcon={MoreHorizontal}
triggerTooltip="Model actions"
@@ -29,7 +29,6 @@
ModelsSection
} from '$lib/components/app';
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
import ModelsSelectorDownloadItem from '$lib/components/app/models/ModelsSelector/ModelsSelectorDownloadItem.svelte';
import {
FAMILY_ROW_WINDOW,
type ModalityKey,
@@ -39,7 +38,7 @@
ModelsTableSortKey
} from '$lib/constants';
import { KeyboardKey, ModelCapability, ModelDownloadConfirmAction } from '$lib/enums';
import { modelsStore, settingsStore } from '$lib/stores';
import { settingsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { groupModelFamilies, type ModelFamilyGroup } from '$lib/utils/model-families';
import type { Snippet } from 'svelte';
@@ -76,22 +75,18 @@
let isEmpty = $derived(groups.every((group) => group.items.length === 0));
let hasFilters = $derived(contextLimit > 0 || modalities.length > 0 || capabilities.length > 0);
/** In-flight and paused downloads, tracked by the status feed. */
let downloadEntries = $derived(modelsStore.status.getDownloadEntries());
/** Noun the show-more row counts in, per unit of the grouped list. */
const SHOW_MORE_NOUNS: Record<GroupedListUnit, string> = {
[GroupedListUnit.ENTRIES]: 'models',
[GroupedListUnit.GROUPS]: 'families'
};
let pendingCancel = $state('');
let cancelOpen = $state(false);
let pendingDelete = $state('');
let deleteOpen = $state(false);
/** Repos whose quants are folded away; the rest show them. */
const collapsedQuants = new SvelteSet<string>();
/** Sections that list their models straight, without folding them into families. */
const FLAT_SECTIONS = new Set<ModelsTableGroupKind>([
ModelsTableGroupKind.DOWNLOADING,
ModelsTableGroupKind.FAVORITES,
ModelsTableGroupKind.LOADED
]);
@@ -99,7 +94,9 @@
groups.map((group) => {
// a flat section lists its models straight, families or not
const flat = FLAT_SECTIONS.has(group.kind) || !settingsStore.config.groupModelsByFamily;
const items = sortEntries(group.items);
// downloads keep the feed's order: their progress, not their name, moves
const items =
group.kind === ModelsTableGroupKind.DOWNLOADING ? group.items : sortEntries(group.items);
return {
...group,
@@ -121,11 +118,6 @@
}
// cancel is confirmed once for the whole list, so one dialog serves every row
function requestCancel(repoWithTag: string): void {
pendingCancel = repoWithTag;
cancelOpen = true;
}
function requestDelete(option: ModelOption): void {
pendingDelete = option.model;
deleteOpen = true;
@@ -325,13 +317,6 @@
</div>
{/snippet}
<DialogConfirmDownload
action={ModelDownloadConfirmAction.CANCEL}
onClose={() => (cancelOpen = false)}
open={cancelOpen}
repoWithTag={pendingCancel}
/>
<DialogConfirmDownload
action={ModelDownloadConfirmAction.DELETE}
onClose={() => (deleteOpen = false)}
@@ -365,22 +350,12 @@
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
{#if downloadEntries.length > 0}
<ModelsSection count={downloadEntries.length} label="Download in progress" sticky>
{#snippet icon()}
<Download class="h-3.5 w-3.5 shrink-0" />
{/snippet}
{#each downloadEntries as entry (entry.repoWithTag)}
<ModelsSelectorDownloadItem {entry} onRequestCancel={requestCancel} />
{/each}
</ModelsSection>
{/if}
{#each sections as group (group.key)}
{#if group.items.length > 0}
{#snippet groupIcon()}
{#if group.kind === ModelsTableGroupKind.FAVORITES}
{#if group.kind === ModelsTableGroupKind.DOWNLOADING}
<Download class="h-3.5 w-3.5 shrink-0" />
{:else if group.kind === ModelsTableGroupKind.FAVORITES}
<Heart class="h-3.5 w-3.5 shrink-0" />
{:else if group.kind === ModelsTableGroupKind.LOADED}
<Power class="h-3.5 w-3.5 shrink-0" />
@@ -1,15 +1,19 @@
<script lang="ts">
import ModelLoadControl from '../ModelLoadControl.svelte';
import ModelsManagerDownloadControl from './ModelsManagerDownloadControl.svelte';
import type { ModelRowDownloadState } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
interface Props {
class?: string;
/** Download state, when the row stands for a tracked download. */
download?: ModelRowDownloadState | null;
option: ModelOption;
}
let { class: className = '', option }: Props = $props();
let { class: className = '', download = null, option }: Props = $props();
let status = $derived(modelsStore.getModelStatus(option.model));
let isOperationInProgress = $derived(modelsStore.status.isOperationInProgress(option.model));
@@ -19,11 +23,15 @@
);
</script>
<ModelLoadControl
class="justify-self-center {className}"
isFailed={status === ServerModelStatus.FAILED}
{isLoaded}
isLoading={status === ServerModelStatus.LOADING || isOperationInProgress}
isSleeping={status === ServerModelStatus.SLEEPING}
{option}
/>
{#if download}
<ModelsManagerDownloadControl class="justify-self-center {className}" {option} state={download} />
{:else}
<ModelLoadControl
class="justify-self-center {className}"
isFailed={status === ServerModelStatus.FAILED}
{isLoaded}
isLoading={status === ServerModelStatus.LOADING || isOperationInProgress}
isSleeping={status === ServerModelStatus.SLEEPING}
{option}
/>
{/if}
@@ -1,4 +1,5 @@
import { Eye, EyeOff, Heart, HeartOff, Trash2 } from '@lucide/svelte';
import { MODEL_DOWNLOAD_ICONS, ModelRowDownloadState } from '$lib/constants';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
@@ -7,8 +8,35 @@ export function modelRowActions(
option: ModelOption,
favorite: boolean,
isHidden: boolean,
onDelete: (option: ModelOption) => void
onDelete: (option: ModelOption) => void,
/** Download state, when the row stands for a tracked download. */
download?: ModelRowDownloadState | null
) {
// a tracked download is paused, resumed or dropped: it is not loaded, hidden or
// drafted. Deleting it stops the download and removes what is on disk, which is
// what the same wording offers on a cached model
if (download) {
const isPaused = download === ModelRowDownloadState.PAUSED;
return [
{
icon: isPaused ? MODEL_DOWNLOAD_ICONS.resume : MODEL_DOWNLOAD_ICONS.pause,
label: isPaused ? 'Resume downloading' : 'Pause downloading',
onclick: () =>
void (isPaused
? modelsStore.status.downloadModel(option.model)
: modelsStore.status.pauseDownload(option.model))
},
{
icon: Trash2,
label: 'Delete from disk',
onclick: () => onDelete(option),
separator: true,
variant: 'destructive' as const
}
];
}
return [
{
icon: favorite ? HeartOff : Heart,
@@ -1,8 +1,8 @@
import { ModelGroupKind, ModelsTableGroupKind } from '$lib/constants';
import { LOCAL_BACKEND_ID, ModelGroupKind, ModelsTableGroupKind } from '$lib/constants';
import { ModelCapability, ServerModelStatus } from '$lib/enums';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import type { ModelDownloadProgress, ModelOption } from '$lib/types/models';
import { detectThinkingSupport, detectToolUseSupport } from '$lib/utils';
/** One repo of the table, with the rows it ships as. */
@@ -13,6 +13,45 @@ export interface ModelQuantGroup {
quants: ModelOption[];
}
/** Byte counts of a tracked download: live while it runs, frozen while paused. */
export function downloadProgressFor(repoWithTag: string): ModelDownloadProgress | null {
return (
modelsStore.status.getDownloadProgress(repoWithTag) ??
modelsStore.status.getPausedDownloadProgress(repoWithTag)
);
}
/** One tracked download of the status feed. */
export interface DownloadEntry {
isPaused: boolean;
progress: ModelDownloadProgress | null;
repoWithTag: string;
}
/**
* One table row per tracked download, so the table lists them the way it lists
* any other model. A paused download is already in the router's listing, so its
* own option is reused; a fresh one stands for its tag alone.
*/
export function downloadGroups(entries: DownloadEntry[], models: ModelOption[]): ModelQuantGroup[] {
return entries.map((entry) => {
const option = models.find((candidate) => candidate.model === entry.repoWithTag) ?? {
backendId: LOCAL_BACKEND_ID,
capabilities: [],
id: entry.repoWithTag,
model: entry.repoWithTag,
name: entry.repoWithTag
};
return {
base: option,
key: `download::${entry.repoWithTag}`,
kind: ModelGroupKind.QUANTS,
quants: [option]
};
});
}
/** One collapsible block of the manager's table. */
export interface ModelsTableGroup {
/** One entry per repo, its quants hanging off it. */
@@ -1,10 +1,10 @@
<script lang="ts">
import ModelDownloadProgressBar from '../ModelDownloadProgressBar.svelte';
import ModelOrgAvatar from '../ModelOrgAvatar.svelte';
import { Info, Loader2, Pause, Play, X } from '@lucide/svelte';
import { Loader2, Pause, Play, X } from '@lucide/svelte';
import { ActionIcon, ModelId } from '$lib/components/app';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore, uiStore } from '$lib/stores';
import { modelsStore } from '$lib/stores';
import type { ModelDownloadProgress } from '$lib/types';
interface Props {
@@ -89,7 +89,7 @@
<!-- status action: spinner -> pause on hover while in flight, play on hover
when paused; opacity only for the spinner, the spin owns the transform -->
<span class="relative inline-flex h-4 w-4 shrink-0 items-center justify-center">
<span class="relative inline-flex h-5 w-5 shrink-0 items-center justify-center">
{#if entry.isPaused}
<Play
class="absolute h-4 w-4 scale-75 opacity-0 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
@@ -106,17 +106,10 @@
</span>
</button>
<ActionIcon
ariaLabel="Manage model"
class="h-4 w-4 shrink-0 scale-75 cursor-pointer rounded-sm text-muted-foreground/70 opacity-0 transition-[opacity,transform,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-foreground group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
icon={Info}
iconSize="h-4 w-4"
onclick={() => uiStore.openModelsManager(entry.repoWithTag)}
/>
<!-- the row actions keep the sizing and reveal of the other selector rows -->
<ActionIcon
ariaLabel="Cancel downloading"
class="h-4 w-4 shrink-0 scale-75 cursor-pointer rounded-sm text-muted-foreground/70 opacity-0 transition-[opacity,transform,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-destructive group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
class="pointer-events-none h-5 w-5 shrink-0 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 hover:text-destructive [@media(pointer:coarse)]:pointer-events-auto [@media(pointer:coarse)]:opacity-100"
icon={X}
iconSize="h-4 w-4"
onclick={() => onRequestCancel?.(entry.repoWithTag)}
@@ -80,7 +80,11 @@
const headerStyle = 'top: var(--dropdown-sticky-height, 0px)';
/** In-flight / paused downloads, tracked by the status feed. */
let getDownloadEntries = $derived(modelsStore.status.getDownloadEntries());
// only a running download belongs in the selector; a paused one waits in the
// manager table, where its partial files are resumed or dropped
let getDownloadEntries = $derived(
modelsStore.status.getDownloadEntries().filter((entry) => !entry.isPaused)
);
// cancel is confirmed once for the whole list, so one dialog serves every row
// The target is kept while the dialog closes so its copy stays rendered.
@@ -186,7 +190,7 @@
{/if}
{#if getDownloadEntries.length > 0}
<p class={headerClass} style={headerStyle}>Download in progress</p>
<p class={headerClass} style={headerStyle}>Downloading</p>
{#each getDownloadEntries as entry (entry.repoWithTag)}
<ModelsSelectorDownloadItem {entry} onRequestCancel={requestCancel} {showOrgName} />
@@ -15,6 +15,8 @@ import {
Loader2 as LoadingIcon,
Mic as AudioIcon,
Package as ModelIcon,
Pause as PauseDownloadIcon,
Play as ResumeDownloadIcon,
Power as LoadIcon,
RotateCw as RetryIcon,
Sparkles as PromptCommandIcon,
@@ -104,6 +106,12 @@ export const CHAT_FORM_COMMAND_ICONS: Record<ChatFormCommandAction, Component> =
};
/** Icons the model load control shows for its states and actions. */
/** Icons of the download control a manager row shows while it downloads. */
export const MODEL_DOWNLOAD_ICONS = {
pause: PauseDownloadIcon,
resume: ResumeDownloadIcon
} as const;
export const MODEL_LOAD_ICONS = {
failed: LoadFailedIcon,
load: LoadIcon,
@@ -10,6 +10,7 @@ export type ModelGroupKind = (typeof ModelGroupKind)[keyof typeof ModelGroupKind
/** Kind of one collapsible block of the table. */
export const ModelsTableGroupKind = {
DOWNLOADING: 'downloading',
FAVORITES: 'favorites',
HIDDEN: 'hidden',
LOADED: 'loaded',
@@ -20,12 +21,22 @@ export type ModelsTableGroupKind = (typeof ModelsTableGroupKind)[keyof typeof Mo
/** Header label of each manager section. */
export const MODELS_TABLE_GROUP_LABELS: Record<ModelsTableGroupKind, string> = {
[ModelsTableGroupKind.DOWNLOADING]: 'Downloading',
[ModelsTableGroupKind.FAVORITES]: 'Favorites',
[ModelsTableGroupKind.HIDDEN]: 'Hidden models',
[ModelsTableGroupKind.LOADED]: 'Loaded models',
[ModelsTableGroupKind.LOCAL]: 'Local models'
};
/** Download state a table row reports in place of its load state. */
export const ModelRowDownloadState = {
DOWNLOADING: 'downloading',
PAUSED: 'paused'
} as const;
export type ModelRowDownloadState =
(typeof ModelRowDownloadState)[keyof typeof ModelRowDownloadState];
/** Column the manager's table can be ordered by. */
export const ModelsTableSortKey = {
CONTEXT: 'context',