ui : share one collapsible section for model lists

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-28 12:10:11 +02:00
parent b29897a922
commit 5cd229fcff
4 changed files with 119 additions and 76 deletions
@@ -2,8 +2,8 @@
import type { ModelsListFilter, ModelsListGroup } from '../utils';
import ModelsManagerModelsListControls from './ModelsManagerModelsListControls.svelte';
import ModelsManagerModelsListItem from './ModelsManagerModelsListItem.svelte';
import { Cloud, Heart, Server } from '@lucide/svelte';
import { CollapsibleSection } from '$lib/components/app';
import { Heart } from '@lucide/svelte';
import { Logo, ModelsSection } from '$lib/components/app';
import type { ModelOption } from '$lib/types/models';
interface Props {
@@ -27,8 +27,6 @@
selectedId,
view = $bindable<ModelsListFilter>('all')
}: Props = $props();
const headerClass = 'flex w-full cursor-pointer items-center gap-1.5 px-2 py-1.5 text-left';
</script>
<div class="flex h-full min-h-0 flex-col gap-3">
@@ -36,13 +34,9 @@
<div class="min-h-0 flex-1 space-y-1 overflow-y-auto">
{#if favorites.length > 0}
<CollapsibleSection triggerClass={headerClass}>
{#snippet trigger()}
<Heart class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="text-xs font-semibold text-muted-foreground">Favorites</span>
<span class="text-xs text-muted-foreground/70">{favorites.length}</span>
<ModelsSection count={favorites.length} label="Favorites" sticky>
{#snippet icon()}
<Heart class="h-3.5 w-3.5 shrink-0" />
{/snippet}
{#each favorites as option (option.id)}
@@ -54,21 +48,20 @@
{option}
/>
{/each}
</CollapsibleSection>
</ModelsSection>
{/if}
{#each groups as group (group.backendId)}
<CollapsibleSection triggerClass={headerClass}>
{#snippet trigger()}
<ModelsSection
backendId={group.isLocal ? undefined : group.backendId}
count={group.items.length}
label={group.label}
sticky
>
{#snippet icon()}
{#if group.isLocal}
<Server class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
{:else}
<Cloud class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<Logo class="shrink-0" style="--size: 0.875rem" />
{/if}
<span class="text-xs font-semibold text-muted-foreground">{group.label}</span>
<span class="text-xs text-muted-foreground/70">{group.items.length}</span>
{/snippet}
{#each group.items as option (option.id)}
@@ -80,7 +73,7 @@
{option}
/>
{/each}
</CollapsibleSection>
</ModelsSection>
{/each}
{#if groups.length === 0 && favorites.length === 0}
@@ -0,0 +1,87 @@
<script lang="ts">
import { ChevronLeft, CircleAlert, Loader2 } from '@lucide/svelte';
import { CollapsibleSection } from '$lib/components/app';
import { BackendIcon } from '$lib/components/app/backends';
import { getBackend } from '$lib/utils/api-base';
import type { Snippet } from 'svelte';
interface Props {
/** Section rows. */
children: Snippet;
/** Renders the backend's logo in the header. */
backendId?: string;
/** Number shown next to the label, omitted when undefined. */
count?: number;
error?: boolean;
/** Overrides the backend logo, used for sections without a backend. */
icon?: Snippet;
label: string;
loading?: boolean;
/** Renders the back control, for a drilled-in provider. */
onBack?: () => void;
revealChevronOnHover?: boolean;
sectionHeaderClass?: string;
/** Sticks the header to the top of the scrollport. */
sticky?: boolean;
}
let {
backendId,
children,
count,
error = false,
icon,
label,
loading = false,
onBack,
revealChevronOnHover = false,
sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none',
sticky = false
}: Props = $props();
let triggerClass = $derived(
`${sectionHeaderClass} flex w-full cursor-pointer items-center gap-1.5 text-left${sticky ? ' sticky z-10 bg-popover' : ''}`
);
// the dropdown publishes its search block height, surfaces without one fall back to 0
let triggerStyle = $derived(sticky ? 'top: var(--dropdown-sticky-height, 0px)' : '');
</script>
<CollapsibleSection {revealChevronOnHover} {triggerClass} {triggerStyle}>
{#snippet trigger()}
{#if onBack}
<button
aria-label="Back to all providers"
class="-ml-1 inline-flex shrink-0 cursor-pointer items-center rounded-sm p-0.5 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
onclick={(event) => {
// the surrounding trigger toggles the section, the back control
// must not collapse the list it is leaving
event.stopPropagation();
onBack?.();
}}
type="button"
>
<ChevronLeft class="h-3.5 w-3.5" />
</button>
{/if}
{#if icon}
{@render icon()}
{:else if backendId}
<BackendIcon backend={getBackend(backendId)} class="h-3.5 w-3.5" />
{/if}
<span class="truncate">{label}</span>
{#if loading}
<Loader2 class="h-3 w-3 shrink-0 animate-spin" />
{:else if error}
<CircleAlert class="h-3 w-3 shrink-0 text-destructive" />
{/if}
{#if count !== undefined}
<span class="shrink-0 text-xs text-muted-foreground/70">{count}</span>
{/if}
{/snippet}
{@render children()}
</CollapsibleSection>
@@ -1,9 +1,8 @@
<script lang="ts">
import ModelsSelectorDownloadItem from './ModelsSelectorDownloadItem.svelte';
import { ChevronLeft, CircleAlert, Heart, Loader2 } from '@lucide/svelte';
import { Heart } from '@lucide/svelte';
import { ModelsSelectorOption } from '$lib/components/app';
import { CollapsibleSection } from '$lib/components/app';
import { BackendIcon } from '$lib/components/app/backends';
import { ModelsSection } from '$lib/components/app';
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
import Logo from '$lib/components/app/misc/Logo.svelte';
import {
@@ -14,7 +13,6 @@
import { MODEL_ROW_WINDOW } from '$lib/constants';
import { ModelDownloadConfirmAction } from '$lib/enums';
import { modelsStore } from '$lib/stores';
import { getBackend } from '$lib/utils/api-base';
interface Props {
groups: GroupedModelOptions;
@@ -129,21 +127,15 @@
{/each}
{:else}
<!-- Favorites come first; the sections below skip them -->
<CollapsibleSection
revealChevronOnHover
triggerClass="{headerClass} flex w-full cursor-pointer items-center gap-1.5 text-left"
triggerStyle={headerStyle}
>
{#snippet trigger()}
<ModelsSection label="Favorites" revealChevronOnHover sticky>
{#snippet icon()}
<Heart class="h-3.5 w-3.5 shrink-0" />
Favorites
{/snippet}
{#each favorites as item (`fav-${item.option.id}`)}
{@render render(item, !showOrgName)}
{/each}
</CollapsibleSection>
</ModelsSection>
{/if}
{/if}
@@ -176,57 +168,27 @@
{#if localOnly}
{@render localRows()}
{:else}
<CollapsibleSection
revealChevronOnHover
triggerClass="{headerClass} flex w-full cursor-pointer items-center gap-1.5 text-left"
triggerStyle={headerStyle}
>
{#snippet trigger()}
<ModelsSection label="Local models" revealChevronOnHover sticky>
{#snippet icon()}
<Logo class="shrink-0" style="--size: 0.875rem" />
Local models
{/snippet}
{@render localRows()}
</CollapsibleSection>
</ModelsSection>
{/if}
{/if}
<!-- One section per remote provider. -->
{#each groups.providers as provider (provider.backendId)}
<CollapsibleSection
<ModelsSection
backendId={provider.backendId}
error={Boolean(provider.error)}
label={provider.name}
loading={provider.loading}
onBack={onProviderBack}
revealChevronOnHover
triggerClass="{headerClass} flex w-full cursor-pointer items-center gap-1.5 text-left"
triggerStyle={headerStyle}
sticky
>
{#snippet trigger()}
{#if onProviderBack}
<button
aria-label="Back to all providers"
class="-ml-1 inline-flex shrink-0 cursor-pointer items-center rounded-sm p-0.5 text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
onclick={(event) => {
// the surrounding trigger toggles the section, the back control
// must not collapse the list it is leaving
event.stopPropagation();
onProviderBack?.();
}}
type="button"
>
<ChevronLeft class="h-3.5 w-3.5" />
</button>
{/if}
<BackendIcon backend={getBackend(provider.backendId)} class="h-3.5 w-3.5" />
{provider.name}
{#if provider.loading}
<Loader2 class="h-3 w-3 animate-spin" />
{:else if provider.error}
<CircleAlert class="h-3 w-3 text-destructive" />
{/if}
{/snippet}
{#if provider.items.length > 0}
{#each provider.items as item (`${provider.backendId}-${item.option.id}`)}
{@render render(item, !showOrgName)}
@@ -247,7 +209,7 @@
{provider.error ?? (provider.loading ? 'Loading models...' : 'No models')}
</p>
{/if}
</CollapsibleSection>
</ModelsSection>
{/each}
<DialogConfirmDownload
@@ -48,6 +48,7 @@ export { default as ModelBadge } from './ModelBadge.svelte';
* Respects the user's `showRawModelNames` setting.
*/
export { default as ModelId } from './ModelId.svelte';
export { default as ModelsSection } from './ModelsSection.svelte';
/**
* **ModelCapabilityIcons** - Capability and modality icon row