From 330ac0807e7369f954a14f16cc529964ad63fde6 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Sat, 26 Sep 2026 11:32:55 +0200 Subject: [PATCH] ui : give the model filters one box, and the loaded section its own rows The filter controls filled differently between themes: the search box and the toggle group drew their own border, and the select filled only in dark. They now share the app's box, the select trigger wears the same chevron as its neighbours, and the select popover matches the dropdown menu around it. The loaded section also lists loaded quants only, since a repo was claimed whole when a single quant of it was loaded, and the rest of the repo now stays with its provider. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash --- .../models/ModelsManager/ModelsManager.svelte | 40 ++++++++++++------- .../ModelsManager/ModelsManagerFilters.svelte | 27 +++++++++---- .../ModelsManagerModelsTable.svelte | 15 +------ .../ui/select/select-content.svelte | 4 +- .../ui/select/select-trigger.svelte | 6 +-- .../lib/constants/css-classes.constants.ts | 13 ++++++ 6 files changed, 65 insertions(+), 40 deletions(-) diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte index e5c09fcbb6..7e8f5714cb 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManager.svelte @@ -79,29 +79,41 @@ // and an entry lands in the first group that claims it let entries = $derived(byRecency(groupModelQuants(visible))); let groups = $derived.by(() => { - // only llama-compat servers report a load state - const isLlamaCompat = (entry: ModelQuantGroup) => - getBackendCapabilities(getBackend(entry.base.backendId)).loadUnload; - const loaded = entries.filter( - (entry) => - isLlamaCompat(entry) && entry.quants.some((q) => modelsStore.isModelLoaded(q.model)) - ); - const claimed = new SvelteSet(loaded.map((entry) => entry.key)); - const favorites = entries.filter( - (entry) => - !claimed.has(entry.key) && - entry.quants.some((q) => modelsStore.favoriteModelIds.has(q.model)) + // A loaded quant is a model of its own: it moves to the loaded section, and + // the quants of its repo that are not loaded stay behind as that repo. Only + // llama-compat servers report a load state. + const isLoaded = (option: ModelOption) => + getBackendCapabilities(getBackend(option.backendId)).loadUnload && + modelsStore.isModelLoaded(option.model); + const loaded: ModelQuantGroup[] = []; + const rest: ModelQuantGroup[] = []; + + for (const entry of entries) { + const remaining = entry.quants.filter((quant) => !isLoaded(quant)); + + for (const quant of entry.quants) { + if (!isLoaded(quant)) continue; + + loaded.push({ ...entry, base: quant, key: quant.id, quants: [quant] }); + } + + if (remaining.length > 0) rest.push({ ...entry, base: remaining[0], quants: remaining }); + } + + const claimed = new SvelteSet(); + const favorites = rest.filter((entry) => + entry.quants.some((q) => modelsStore.favoriteModelIds.has(q.model)) ); for (const entry of favorites) claimed.add(entry.key); - const hidden = entries.filter( + const hidden = rest.filter( (entry) => !claimed.has(entry.key) && entry.quants.some((q) => modelsStore.isHidden(q.id)) ); const hiddenKeys = new SvelteSet(hidden.map((entry) => entry.key)); const byBackend = new SvelteMap(); - for (const entry of entries) { + for (const entry of rest) { if (claimed.has(entry.key) || hiddenKeys.has(entry.key)) continue; const backendId = entry.base.backendId ?? LOCAL_BACKEND_ID; diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte index 7be2523089..956acf20af 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerFilters.svelte @@ -3,11 +3,10 @@ import { ChevronDown, Image, Mic, Server, Video } from '@lucide/svelte'; import { Logo } from '$lib/components/app'; import { BackendIcon } from '$lib/components/app/backends'; - import { Button } from '$lib/components/ui/button'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as Select from '$lib/components/ui/select'; import * as ToggleGroup from '$lib/components/ui/toggle-group'; - import { LOCAL_BACKEND_ID } from '$lib/constants'; + import { FILTER_TRIGGER_CLASS, LOCAL_BACKEND_ID } from '$lib/constants'; import type { Backend } from '$lib/types/backend'; interface Props { @@ -74,13 +73,17 @@ {#snippet child({ props })} - + {/snippet} @@ -113,7 +116,7 @@ type="single" value={String(contextLimit)} > - + Context: {contextLabel} @@ -126,9 +129,19 @@ - + {#each MODALITIES as modality (modality.key)} - + {/each} diff --git a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte index 26536bd5a7..e2ee57c580 100644 --- a/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte @@ -127,20 +127,7 @@ groups.map((group) => { // a flat section lists its models straight, families or not const flat = FLAT_SECTIONS.has(group.kind) || !settingsStore.config.groupModelsByFamily; - // a loaded section lists the quants themselves: the repo a quant came from - // is not what is loaded, so folding them under it says nothing - const entries = - group.kind === 'loaded' - ? group.items.flatMap((entry) => - entry.quants.map((quant) => ({ - ...entry, - base: quant, - key: quant.id, - quants: [quant] - })) - ) - : group.items; - const items = sortEntries(entries); + const items = sortEntries(group.items); return { ...group, diff --git a/tools/ui/src/lib/components/ui/select/select-content.svelte b/tools/ui/src/lib/components/ui/select/select-content.svelte index f8792f8a9d..a8263819a1 100644 --- a/tools/ui/src/lib/components/ui/select/select-content.svelte +++ b/tools/ui/src/lib/components/ui/select/select-content.svelte @@ -88,7 +88,7 @@ {@render children?.()} diff --git a/tools/ui/src/lib/components/ui/select/select-trigger.svelte b/tools/ui/src/lib/components/ui/select/select-trigger.svelte index 483db7b74f..de3abcdf21 100644 --- a/tools/ui/src/lib/components/ui/select/select-trigger.svelte +++ b/tools/ui/src/lib/components/ui/select/select-trigger.svelte @@ -17,20 +17,20 @@ // Super small trigger: fits its selected value, for dense inline use. const xsClasses = - "group flex h-6 w-fit items-center justify-between gap-1 rounded-md border border-input bg-transparent px-2 py-0 text-xs whitespace-nowrap outline-none select-none transition-colors focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='text-'])]:text-muted-foreground"; + "group flex h-6 w-fit items-center justify-between gap-1 rounded-md border border-border/30 bg-muted/60 px-2 py-0 text-xs whitespace-nowrap outline-none select-none transition-colors hover:bg-muted/80 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground dark:border-border/20 dark:bg-muted/75 dark:hover:bg-muted [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='text-'])]:text-muted-foreground"; const baseClasses = $derived( variant === 'plain' ? "group inline-flex w-full items-center justify-end gap-2 whitespace-nowrap px-0 py-0 text-sm font-medium text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3 [&_svg:not([class*='text-'])]:text-muted-foreground" : size === 'xs' ? xsClasses - : "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground" + : "flex w-fit items-center justify-between gap-2 rounded-md border border-border/30 bg-muted/60 px-3 py-2 text-sm whitespace-nowrap shadow-sm transition-colors outline-none select-none hover:bg-muted/80 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:border-border/20 dark:bg-muted/75 dark:hover:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 [&_svg:not([class*='text-'])]:text-muted-foreground" ); const chevronClasses = $derived( variant === 'plain' || size === 'xs' ? 'size-3 opacity-60 transition-transform group-data-[state=open]:-rotate-180' - : 'size-4 opacity-50' + : 'size-3.5 opacity-60 transition-transform group-data-[state=open]:-rotate-180' ); diff --git a/tools/ui/src/lib/constants/css-classes.constants.ts b/tools/ui/src/lib/constants/css-classes.constants.ts index 43315d7de7..c7009e8c06 100644 --- a/tools/ui/src/lib/constants/css-classes.constants.ts +++ b/tools/ui/src/lib/constants/css-classes.constants.ts @@ -29,6 +29,19 @@ export const CHAT_INPUT_FOCUS_SELECTOR = */ export const DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR = '[data-slot="dropdown-menu-content"] input'; +/** Filter controls above the model table: one box, one height, one fill. */ +export const FILTER_TRIGGER_CLASS = ` + h-8 + gap-1.5 + rounded-md + px-3 + text-sm + font-medium + transition-colors + hover:bg-muted/80 dark:hover:bg-muted + ${INPUT_CLASSES} +`; + /** Neutral model badge: params, quantization, tags. */ export const MODEL_BADGE_CLASS = 'inline-flex w-fit shrink-0 items-center justify-center whitespace-nowrap rounded-md border border-border/50 px-1 py-0 text-[10px] font-mono bg-foreground/15 dark:bg-foreground/10 text-foreground [a&]:hover:bg-foreground/25';