ui : put the discover view in the models manager

The manager dialog gains its second view: the Discover Models call to
action fades the table out, slides the title with its compass mark, and
fades the discover surface in; the arrow button returns to the table.

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash

# Conflicts:
#	tools/ui/src/lib/components/ui/button/button.svelte
This commit is contained in:
Aleksander Grygier
2026-10-09 11:09:38 +02:00
committed by GitHub
parent 6a34bdba84
commit be7b06505e
11 changed files with 241 additions and 56 deletions
@@ -1,7 +1,12 @@
<script lang="ts">
import { ArrowLeft, Compass } from '@lucide/svelte';
import ModelsDiscover from '$lib/components/app/models/discover/ModelsDiscover.svelte';
import ModelsManager from '$lib/components/app/models/ModelsManager/ModelsManager.svelte';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { MODEL_ICON } from '$lib/constants';
import { uiStore } from '$lib/stores';
import { untrack } from 'svelte';
interface Props {
open?: boolean;
@@ -10,6 +15,54 @@
let { onOpenChange, open = $bindable(false) }: Props = $props();
type View = 'discover' | 'manage';
/** How long a view takes to fade out before the next one fades in. */
const VIEW_FADE_MS = 150;
// what the user asked for, and what is actually rendered: a view change fades the
// current one out, swaps, then fades the next one in
let view = $state<View>('manage');
let shownView = $state<View>('manage');
let isSwapping = $state(false);
$effect(() => {
const next = view;
if (next === shownView) return;
// nothing to fade while the dialog is closed, so the view is set before it opens
if (!open) {
untrack(() => (shownView = next));
return;
}
untrack(() => (isSwapping = true));
const timer = setTimeout(() => {
untrack(() => {
shownView = next;
isSwapping = false;
});
}, VIEW_FADE_MS);
return () => clearTimeout(timer);
});
let title = $derived(view === 'discover' ? 'Discover' : 'Models');
// the sidebar's Discover entry opens this dialog on its Discover view
$effect(() => {
if (!uiStore.discoverModelsOpen) return;
untrack(() => {
uiStore.discoverModelsOpen = false;
view = 'discover';
handleOpenChange(true);
});
});
function handleOpenChange(value: boolean) {
open = value;
onOpenChange?.(value);
@@ -21,11 +74,49 @@
class="max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! max-md:rounded-none! max-md:border-0! md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-380! flex flex-col p-4 pb-0"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<Dialog.Header class="flex flex-row items-center pr-8">
<Dialog.Title class="flex min-h-7 items-center gap-2">
<MODEL_ICON class="h-5 w-5 shrink-0" />
<Dialog.Header class="relative flex flex-row items-center justify-between p-2 pr-8">
<!--
The arrow is out of the flow and the title slides, so entering a sub-view
moves the title with a transform instead of reflowing the header. min-h
keeps the header's height fixed either way.
-->
<Button
aria-label="Back to models"
class="absolute top-1/2 left-1 h-7 w-7 -translate-y-1/2 transition-[opacity,visibility] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] {view ===
'manage'
? 'invisible opacity-0'
: 'visible opacity-100'}"
onclick={() => (view = 'manage')}
size="icon"
variant="ghost"
>
<ArrowLeft class="h-4 w-4" />
</Button>
<span>Models</span>
<Dialog.Title
class="flex min-h-7 items-center gap-2 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] {view ===
'manage'
? 'translate-x-0'
: 'translate-x-8'}"
>
<!-- the marks cross-fade in one grid cell, so the title never shifts -->
<span class="grid h-5 w-5 shrink-0 place-items-center">
<MODEL_ICON
class="col-start-1 row-start-1 h-5 w-5 transition-opacity duration-150 {view ===
'manage'
? 'opacity-100'
: 'opacity-0'}"
/>
<Compass
class="col-start-1 row-start-1 h-5 w-5 transition-opacity duration-150 {view ===
'discover'
? 'opacity-100'
: 'opacity-0'}"
/>
</span>
<span>{title}</span>
</Dialog.Title>
<Dialog.Description class="sr-only">
@@ -33,8 +124,45 @@
</Dialog.Description>
</Dialog.Header>
<div class="min-h-0 flex-1">
<ModelsManager class="h-full" />
<div class="dialog-view min-h-0 flex-1" data-visible={!isSwapping}>
{#if shownView === 'manage'}
<ModelsManager class="h-full">
{#snippet toolbarEnd()}
<Button
class="gap-1.5"
onclick={() => (view = 'discover')}
size="sm"
variant="tertiary"
>
<Compass class="h-3.5 w-3.5" />
Discover Models
</Button>
{/snippet}
</ModelsManager>
{:else}
<div class="grid h-full overflow-hidden" style="grid-template-columns: auto 1fr;">
<ModelsDiscover />
</div>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
<style>
/* a view change reads as one surface swapping, not as content teleporting */
.dialog-view {
opacity: 0;
transition: opacity 150ms cubic-bezier(0.23, 1, 0.32, 1);
}
.dialog-view[data-visible='true'] {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.dialog-view {
transition-duration: 100ms;
}
}
</style>
@@ -18,14 +18,14 @@
{#if overlay}
<div class="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 overflow-hidden rounded-b-sm">
<div
class="h-full bg-primary transition-[width] duration-200 ease-out"
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
style="width: {percent}%"
></div>
</div>
{:else}
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-primary transition-[width] duration-200 ease-out"
class="h-full animate-pulse bg-primary transition-[width] duration-200 ease-out"
style="width: {percent}%"
></div>
</div>
@@ -11,8 +11,9 @@
import { DropdownMenuActions } from '$lib/components/app';
import { MODEL_ROW_GRID_CLASS, MODEL_ROW_TRAILING_CELL_CLASS } from '$lib/constants';
import { ModelRowDownloadState } from '$lib/enums';
import { deviceStore, modelsStore } from '$lib/stores';
import { deviceStore, modelsStore, uiStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { repoOf } from '$lib/utils';
interface Props {
isFavorite: (option: ModelOption) => boolean;
@@ -38,6 +39,18 @@
? ModelRowDownloadState.DOWNLOADING
: null
);
/** Repo of the row's model, which is what the Discover details are keyed by. */
function openInDiscover(): void {
uiStore.openModelsDiscover(repoOf(option.model));
}
// a tracked download has nothing to configure yet, so its row opens the Discover
// details, where the sizes, variants and download options live
function activate(): void {
if (download) openInDiscover();
else onSelect(option);
}
</script>
<div
@@ -51,9 +64,9 @@
<!-- the row holds a load control and an actions menu, so only the model itself is
the button: a button nested in a role="button" row is invalid -->
<button
aria-pressed={selected}
aria-pressed={download ? undefined : selected}
class="flex min-w-0 cursor-pointer items-center gap-3 text-left max-md:gap-2 max-md:pr-9"
onclick={() => onSelect(option)}
onclick={activate}
style="padding-left: {indent}px"
type="button"
>
@@ -1,8 +1,9 @@
import { Eye, EyeOff, Heart, HeartOff, Trash2 } from '@lucide/svelte';
import { Eye, EyeOff, Heart, HeartOff, Search, Trash2 } from '@lucide/svelte';
import { MODEL_DOWNLOAD_ICONS } from '$lib/constants';
import { ModelRowDownloadState } from '$lib/enums';
import { modelsStore } from '$lib/stores';
import { modelsStore, uiStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { repoOf } from '$lib/utils';
/** Row actions follow the app's dropdown pattern: icon, label, separators, variants. */
export function modelRowActions(
@@ -13,6 +14,14 @@ export function modelRowActions(
/** Download state, when the row stands for a tracked download. */
download?: ModelRowDownloadState | null
) {
const viewInDiscover = {
icon: Search,
label: 'View in Discover',
// the details pane is keyed by repo, not by `<repo>:<quant>`
onclick: () => uiStore.openModelsDiscover(repoOf(option.model)),
separator: true
};
// 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
@@ -34,7 +43,8 @@ export function modelRowActions(
onclick: () => onDelete(option),
separator: true,
variant: 'destructive' as const
}
},
viewInDiscover
];
}
@@ -56,6 +66,7 @@ export function modelRowActions(
label: isHidden ? 'Unhide model' : 'Hide model',
onclick: () => modelsStore.toggleHidden(option.id),
separator: true
}
},
viewInDiscover
];
}
@@ -6,7 +6,7 @@
} from '$lib/components/app/models/discover';
import { Button } from '$lib/components/ui/button';
import { HuggingFaceService } from '$lib/services';
import { modelsDiscoverStore } from '$lib/stores';
import { modelsDiscoverStore, uiStore } from '$lib/stores';
import type { HfModelDetailInfo, HfModelSibling } from '$lib/types';
let selectedId = $state<string | null>(null);
@@ -35,6 +35,16 @@
}
});
// a caller can ask for one repo to be opened, the manager's rows do
$effect(() => {
const focus = uiStore.discoverModelFocus;
if (!focus) return;
selectedId = focus;
uiStore.discoverModelFocus = null;
});
function handleSearchInput(value: string) {
searchQuery = value;
@@ -1,5 +1,5 @@
<script lang="ts">
import ModelsDiscoverDownloadProgressBar from '../../ModelsDiscoverDownloadProgressBar.svelte';
import ModelDownloadProgressBar from '../../../ModelDownloadProgressBar.svelte';
import { labelFor } from './download-options.utils';
import { Check, Download, Loader2, Pause, Play, RotateCw, X } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -232,7 +232,7 @@
{@render tooltipTrigger('Cancel downloading', cancelChip)}
{#if percent !== null}
<ModelsDiscoverDownloadProgressBar
<ModelDownloadProgressBar
downloadedBytes={entry.progress?.downloadedBytes ?? 0}
overlay
totalBytes={entry.progress?.totalBytes ?? 0}
@@ -1,6 +1,6 @@
<script lang="ts">
import ModelCapabilityIcons from '../../ModelCapabilityIcons.svelte';
import ModelsDiscoverAvatar from '../ModelsDiscoverAvatar.svelte';
import ModelOrgAvatar from '../../ModelOrgAvatar.svelte';
import ModelsDiscoverDetailsHfHubStats from './ModelsDiscoverDetailsHfHubStats.svelte';
import ModelsDiscoverDetailsMetadata from './ModelsDiscoverDetailsMetadata.svelte';
import { ExternalLink } from '@lucide/svelte';
@@ -35,7 +35,7 @@
<header class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<ModelsDiscoverAvatar
<ModelOrgAvatar
org={avatarOrg}
{quantOrg}
quantPositionClass="-bottom-1.5 -right-1.5"
@@ -1,6 +1,6 @@
<script lang="ts">
import ModelId from '../../ModelId.svelte';
import ModelsDiscoverAvatar from '../ModelsDiscoverAvatar.svelte';
import ModelOrgAvatar from '../../ModelOrgAvatar.svelte';
import { HF_MMPROJ_FILENAME_TOKEN, HF_MODALITY_PIPELINE_TAGS } from '$lib/constants';
import { ModelDraftSidecar } from '$lib/enums';
import { HuggingFaceService } from '$lib/services';
@@ -101,11 +101,7 @@
onclick={() => onSelect?.(model.id)}
type="button"
>
<ModelsDiscoverAvatar
class="mt-1"
org={avatarOrg}
quantOrg={showBaseModelAvatar ? org : undefined}
/>
<ModelOrgAvatar class="mt-1" org={avatarOrg} quantOrg={showBaseModelAvatar ? org : undefined} />
<span class="min-w-0 flex-1">
<ModelId
@@ -18,22 +18,5 @@
*/
export { default as ModelsDiscover } from './ModelsDiscover.svelte';
/**
* **ModelsDiscoverAvatar** - Org avatar for a model row
*
* Shows the org's avatar image, falling back to a monogram on a stable hue
* derived from the org name when the image fails to load. Shared by the list,
* the detail header and the model selector rows.
*/
export { default as ModelsDiscoverAvatar } from './ModelsDiscoverAvatar.svelte';
/**
* **ModelsDiscoverDownloadProgressBar** - Thin download progress bar
*
* Normalizes bytes to a 0..100% bar; can pin to the bottom edge as an overlay.
* Shared by the quant chips and the model selector's download rows.
*/
export { default as ModelsDiscoverDownloadProgressBar } from './ModelsDiscoverDownloadProgressBar.svelte';
export * from './ModelsDiscoverList';
export * from './ModelsDiscoverDetails';
@@ -144,6 +144,12 @@ export class HuggingFaceService {
Q8_0: 8
};
// File trees already fetched, keyed by repo id: the discover view asks for the same
// repo from several places.
private static treeCache = new Map<string, HfModelSibling[]>();
private static treePending = new Map<string, Promise<HfModelSibling[]>>();
/** Details already fetched, for a caller that cannot await (a table sorting its rows). */
static cachedDetails(modelId: string): HfModelDetailInfo | null | undefined {
// llama.cpp model ids carry the quant tag after a colon
@@ -581,21 +587,31 @@ export class HuggingFaceService {
* repos that keep quants in per-quant subdirectories (e.g. `UD-Q4_K_XL/`)
* are included; follows cursor pagination for repos over one page.
*/
static async getTree(modelId: string): Promise<HfModelSibling[]> {
if (!HuggingFaceService.isEnabled()) return [];
const files: HfModelSibling[] = [];
const firstUrl =
`${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}${PATH_SEPARATOR}${HF_TREE_PATH}` +
`${PATH_SEPARATOR}${HF_MAIN_BRANCH}?${HF_RECURSIVE_TREE_PARAM}`;
static getTree(modelId: string): Promise<HfModelSibling[]> {
if (!HuggingFaceService.isEnabled()) return Promise.resolve([]);
let url: string | null = firstUrl;
const cached = HuggingFaceService.treeCache.get(modelId);
if (cached) return Promise.resolve(cached);
const pending = HuggingFaceService.treePending.get(modelId);
if (pending) return pending;
const promise = (async () => {
const files: HfModelSibling[] = [];
const firstUrl =
`${HF_API_MODELS_URL}${PATH_SEPARATOR}${modelId}${PATH_SEPARATOR}${HF_TREE_PATH}` +
`${PATH_SEPARATOR}${HF_MAIN_BRANCH}?${HF_RECURSIVE_TREE_PARAM}`;
let url: string | null = firstUrl;
try {
for (let page = 0; url && page < HF_TREE_MAX_PAGES; page++) {
const response: Response = await fetch(url);
if (!response.ok) return files;
if (!response.ok)
throw new Error(`Failed to fetch tree for ${modelId}: ${response.status}`);
const data = (await response.json()) as HfModelSibling[];
@@ -603,11 +619,24 @@ export class HuggingFaceService {
url = HuggingFaceService.parseNextPageUrl(response.headers.get(HF_LINK_HEADER));
}
} catch {
// Return whatever was fetched before the failure.
}
return files;
HuggingFaceService.treeCache.set(modelId, files);
return files;
})()
.catch((error: unknown) => {
// not cached: a rate limited or failed fetch should retry on the
// next mount; an empty tree makes the store fall back to the
// catalog's advertised sizes
console.error(`Error fetching tree for ${modelId}:`, error);
return [] as HfModelSibling[];
})
.finally(() => HuggingFaceService.treePending.delete(modelId));
HuggingFaceService.treePending.set(modelId, promise);
return promise;
}
static async getTrending(limit: number = HF_DEFAULT_LIMIT): Promise<HfModelInfo[]> {
+15
View File
@@ -12,6 +12,10 @@ class UiStore {
/** Set when a flow outside the chat wants the composer focused once it can take it. */
composerFocusRequested = $state(false);
/** Repo the Discover view selects when it opens, set by a model row. */
discoverModelFocus = $state<string | null>(null);
discoverModelsOpen = $state(false);
/** Whether the desktop sidebar is expanded (open). */
isSidebarExpanded = $state(false);
/** Model the manager reveals when it opens, a qualified id or a raw model name. */
@@ -21,11 +25,22 @@ class UiStore {
/** Model the information dialog shows; a phone opens it instead of the manager. */
modelInformation = $state<ModelOption | null>(null);
openDiscoverModels(): void {
this.discoverModelFocus = null;
this.discoverModelsOpen = true;
}
/** Open the model information dialog, a phone's stand-in for the manager pane. */
openModelInformation(option: ModelOption): void {
this.modelInformation = option;
}
/** Open the models dialog on Discover, focused on one repo. */
openModelsDiscover(focus?: string): void {
this.discoverModelFocus = focus ?? null;
this.discoverModelsOpen = true;
}
/** Open the models manager, optionally focused on one model. */
openModelsManager(focus?: string): void {
this.manageModelFocus = focus ?? null;