ui : lead the discover list with downloads

The discover sidebar lists running downloads above the suggested models,
and steps aside while a query is active. A paused download stays in the
manager table, where it is resumed or dropped.

Assisted-by: pi
This commit is contained in:
Aleksander Grygier
2026-10-02 11:59:25 +02:00
parent 1120c39205
commit 38c2c9cbc2
2 changed files with 204 additions and 16 deletions
@@ -1,16 +1,82 @@
<script lang="ts">
import ModelsDiscoverListItem from './ModelsDiscoverList/ModelsDiscoverListItem.svelte';
import { Download } from '@lucide/svelte';
import { ModelsSection } from '$lib/components/app';
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
import {
ModelsDiscoverDetails,
ModelsDiscoverList,
ModelsDiscoverListSearch
} from '$lib/components/app/models/discover';
import { Button } from '$lib/components/ui/button';
import { MODEL_ID } from '$lib/constants';
import { ModelDownloadConfirmAction } from '$lib/enums';
import { HuggingFaceService } from '$lib/services';
import { modelsDiscoverStore, uiStore } from '$lib/stores';
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types';
import { modelsDiscoverStore, modelsStore, uiStore } from '$lib/stores';
import type { HfModelDetailInfo, HfModelInfo, HfModelSibling } from '$lib/types';
let selectedId = $state<string | null>(null);
let searchQuery = $state('');
// running and paused downloads both lead the list; a search owns the pane, so the
// section steps aside while a query is active
let downloadEntries = $derived(modelsStore.status.getDownloadEntries());
let isSearching = $derived(searchQuery.trim().length > 0);
/** Repo id of a download tag, e.g. `org/repo:Q8_0-mtp` reports `org/repo`. */
function repoOf(repoWithTag: string): string {
return repoWithTag.split(MODEL_ID.QUANTIZATION_SEPARATOR)[0] ?? repoWithTag;
}
/** Hub record for a repo the list does not carry, so its row still renders. */
function hubRecord(id: string): HfModelInfo {
return {
_id: id,
downloads: 0,
id,
library_name: null,
likes: 0,
modelId: id,
pipeline_tag: null,
private: false,
tags: []
};
}
// Hub records of repos the list does not carry, e.g. a download started from a
// search hit: fetched once so the row shows the same badges and icons as a result.
let fetchedRecords = $state<Record<string, HfModelInfo>>({});
/** Record of a repo: what the list fetched, else its own details, else a bare one. */
function recordOf(repo: string): HfModelInfo {
return (
modelsDiscoverStore.models.find((model) => model.id === repo) ??
fetchedRecords[repo] ??
hubRecord(repo)
);
}
$effect(() => {
for (const entry of downloadEntries) {
const repo = repoOf(entry.repoWithTag);
if (fetchedRecords[repo] || modelsDiscoverStore.models.some((m) => m.id === repo)) continue;
void HuggingFaceService.getDetails(repo).then((details) => {
if (details) fetchedRecords = { ...fetchedRecords, [repo]: details };
});
}
});
// cancel is confirmed once, so a single dialog serves every download row
// The target is kept while the dialog closes so its copy stays rendered.
let pendingCancel = $state('');
let cancelOpen = $state(false);
function requestCancel(repoWithTag: string) {
pendingCancel = repoWithTag;
cancelOpen = true;
}
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
// Detail pane state, reloaded when the selection changes.
@@ -104,11 +170,54 @@
});
</script>
{#snippet catalog()}
<ModelsDiscoverList
activeId={selectedId}
loading={modelsDiscoverStore.loading || modelsDiscoverStore.searching}
models={modelsDiscoverStore.models}
onSelect={(id) => (selectedId = id)}
showBaseModelAvatar
/>
{/snippet}
<aside
class="w-md shrink-0 self-start border-r border-border/40 bg-background overflow-y-auto md:pr-4 h-full space-y-4"
>
<ModelsDiscoverListSearch bind:value={searchQuery} onSearch={handleSearchInput} />
{#if downloadEntries.length > 0 && !isSearching}
<ModelsSection
count={downloadEntries.length}
label="Downloading"
persistKey="discover-downloads"
sticky
stickyClass="sticky z-10 bg-background"
>
{#snippet icon()}
<Download class="h-3.5 w-3.5 shrink-0" />
{/snippet}
{#each downloadEntries as entry (entry.repoWithTag)}
<!-- the row is the list's own, so a download opens the same details pane a
result does; only the state line and the trailing control differ -->
{@const repo = repoOf(entry.repoWithTag)}
<ModelsDiscoverListItem
active={repo === selectedId}
download={{
isPaused: entry.isPaused,
onRequestCancel: requestCancel,
progress: entry.progress,
repoWithTag: entry.repoWithTag
}}
model={recordOf(repo)}
onSelect={(id) => (selectedId = id)}
showBaseModelAvatar
/>
{/each}
</ModelsSection>
{/if}
<!-- One list instance, so the rows keep their state across search round trips;
skeleton rows replace them while the initial catalog or a query loads. -->
<div>
@@ -123,13 +232,19 @@
{:else if !modelsDiscoverStore.loading && !modelsDiscoverStore.searching && modelsDiscoverStore.models.length === 0}
<p class="p-4 text-sm text-muted-foreground">No models found</p>
{:else}
<ModelsDiscoverList
activeId={selectedId}
loading={modelsDiscoverStore.loading || modelsDiscoverStore.searching}
models={modelsDiscoverStore.models}
onSelect={(id) => (selectedId = id)}
showBaseModelAvatar
/>
{#if isSearching}
{@render catalog()}
{:else}
<ModelsSection
count={modelsDiscoverStore.models.length}
label="Suggested models"
persistKey="discover-suggested"
sticky
stickyClass="sticky z-10 bg-background"
>
{@render catalog()}
</ModelsSection>
{/if}
{/if}
</div>
</aside>
@@ -146,3 +261,10 @@
/>
{/if}
</main>
<DialogConfirmDownload
action={ModelDownloadConfirmAction.CANCEL}
onClose={() => (cancelOpen = false)}
open={cancelOpen}
repoWithTag={pendingCancel}
/>
@@ -1,25 +1,43 @@
<script lang="ts">
import ModelDownloadProgressBar from '../../ModelDownloadProgressBar.svelte';
import ModelId from '../../ModelId.svelte';
import ModelOrgAvatar from '../../ModelOrgAvatar.svelte';
import { Pause, Play, X } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app';
import { HF_MMPROJ_FILENAME_TOKEN, HF_MODALITY_PIPELINE_TAGS } from '$lib/constants';
import { ModelDraftSidecar } from '$lib/enums';
import { HuggingFaceService } from '$lib/services';
import { modelsDiscoverStore } from '$lib/stores';
import { modelsDiscoverStore, modelsStore } from '$lib/stores';
import type { ModelsDiscoverSizeRange } from '$lib/stores/models-discover/index.svelte';
import type { HfModelInfo } from '$lib/types/huggingface';
import type { ModelModalities } from '$lib/types/models';
import type { ModelDownloadProgress, ModelModalities } from '$lib/types/models';
import { detectThinkingSupport, detectToolUseSupport, isDraftSidecar, orgOf } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
model: HfModelInfo;
active?: boolean;
/** State of a tracked download this row stands for, else the row is a plain result. */
download?: {
isPaused: boolean;
/** Live while the download runs, frozen while it is paused. */
progress: ModelDownloadProgress | null;
repoWithTag: string;
/** Ask the list to confirm cancelling; the list owns the single dialog. */
onRequestCancel?: (repoWithTag: string) => void;
};
/** Show the original (base) model's org avatar instead of the repo's org. */
showBaseModelAvatar?: boolean;
onSelect?: (modelId: string) => void;
}
let { active = false, model, onSelect, showBaseModelAvatar = false }: Props = $props();
let { active = false, download, model, onSelect, showBaseModelAvatar = false }: Props = $props();
let percent = $derived(
download?.progress && download.progress.totalBytes > 0
? Math.round((download.progress.downloadedBytes / download.progress.totalBytes) * 100)
: null
);
let org = $derived(orgOf(model.id));
@@ -92,12 +110,14 @@
});
</script>
<li>
<li
class="group relative flex items-center gap-0.5 overflow-hidden rounded-lg transition-colors {active
? 'bg-primary/10 hover:bg-primary/15'
: 'hover:bg-muted/60'}"
>
<button
aria-current={active ? 'page' : undefined}
class="flex w-full cursor-pointer items-start gap-2.5 rounded-lg p-2.5 text-left transition-colors {active
? 'bg-primary/10 hover:bg-primary/15'
: 'hover:bg-muted/60'}"
class="flex min-w-0 flex-1 cursor-pointer items-start gap-2.5 p-2.5 text-left"
onclick={() => onSelect?.(model.id)}
type="button"
>
@@ -119,4 +139,50 @@
/>
</span>
</button>
{#if download}
{@const state = download}
<!-- the percent sits on the row's centre line, and the download's controls take
its place on hover, the way the table's status column does -->
<span class="flex shrink-0 items-center gap-0.5 pr-2.5">
<span
class="text-xs text-muted-foreground tabular-nums group-hover:hidden [@media(pointer:coarse)]:hidden"
>
{percent !== null ? `${percent}%` : state.isPaused ? 'Paused' : 'Downloading'}
</span>
<span class="hidden items-center gap-0.5 group-hover:flex [@media(pointer:coarse)]:flex">
<ActionIcon
icon={state.isPaused ? Play : Pause}
iconSize="h-4 w-4"
onclick={() =>
void (state.isPaused
? modelsStore.status.downloadModel(state.repoWithTag)
: modelsStore.status.pauseDownload(state.repoWithTag))}
stopPropagationOnClick
tooltip={state.isPaused ? 'Resume downloading' : 'Pause downloading'}
tooltipAsTitle
/>
<ActionIcon
class="text-muted-foreground hover:text-destructive"
icon={X}
iconSize="h-4 w-4"
onclick={() => state.onRequestCancel?.(state.repoWithTag)}
stopPropagationOnClick
tooltip="Cancel downloading"
tooltipAsTitle
/>
</span>
</span>
{#if state.progress && state.progress.totalBytes > 0}
<ModelDownloadProgressBar
downloadedBytes={state.progress.downloadedBytes}
overlay
totalBytes={state.progress.totalBytes}
/>
{/if}
{/if}
</li>