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
This commit is contained in:
Aleksander Grygier
2026-09-28 12:10:13 +02:00
parent 2569306b71
commit 330ac0807e
6 changed files with 65 additions and 40 deletions
@@ -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<string>();
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<string, ModelQuantGroup[]>();
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;
@@ -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 @@
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} class="gap-1.5" size="sm" variant="outline">
<button
{...props}
class="inline-flex items-center whitespace-nowrap {FILTER_TRIGGER_CLASS}"
type="button"
>
<Server class="h-3.5 w-3.5" />
{providerLabel}
<ChevronDown class="h-3.5 w-3.5 opacity-60" />
</Button>
</button>
{/snippet}
</DropdownMenu.Trigger>
@@ -113,7 +116,7 @@
type="single"
value={String(contextLimit)}
>
<Select.Trigger class="gap-1.5 shadow-sm" size="sm">
<Select.Trigger class={FILTER_TRIGGER_CLASS} size="sm">
<span class="text-muted-foreground">Context:</span>
{contextLabel}
@@ -126,9 +129,19 @@
</Select.Content>
</Select.Root>
<ToggleGroup.Root bind:value={modalities} type="multiple" variant="outline">
<ToggleGroup.Root
bind:value={modalities}
class="bg-muted/60 dark:bg-muted/75"
type="multiple"
variant="outline"
>
{#each MODALITIES as modality (modality.key)}
<ToggleGroup.Item aria-label={modality.label} title={modality.label} value={modality.key}>
<ToggleGroup.Item
aria-label={modality.label}
class="bg-transparent! border-border/30! dark:border-border/20!"
title={modality.label}
value={modality.key}
>
<modality.icon class="h-3.5 w-3.5" />
</ToggleGroup.Item>
{/each}
@@ -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,
@@ -88,7 +88,7 @@
<SelectPrimitive.Content
bind:ref
class={cn(
'relative z-[var(--layer-popover,1000000)] max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:-translate-x-1 data-[side=left]:slide-in-from-right-2 data-[side=right]:translate-x-1 data-[side=right]:slide-in-from-left-2 data-[side=top]:-translate-y-1 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'relative z-[var(--layer-popover,1000000)] max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover dark:border-border/20 text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:-translate-x-1 data-[side=left]:slide-in-from-right-2 data-[side=right]:translate-x-1 data-[side=right]:slide-in-from-left-2 data-[side=top]:-translate-y-1 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
data-slot="select-content"
@@ -99,7 +99,7 @@
<SelectPrimitive.Viewport
class={cn(
'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1'
'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1.5'
)}
>
{@render children?.()}
@@ -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'
);
</script>
@@ -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';