ui : cut the work the model selector does on open

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 1fdb437047
commit ece4b1389c
4 changed files with 112 additions and 4 deletions
@@ -6,7 +6,12 @@
import { BackendIcon } from '$lib/components/app/backends';
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
import Logo from '$lib/components/app/misc/Logo.svelte';
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/navigation/utils';
import {
type GroupedModelOptions,
type ModelItem,
windowLocalGroups
} from '$lib/components/app/navigation/utils';
import { MODEL_ROW_WINDOW } from '$lib/constants';
import { ModelDownloadConfirmAction } from '$lib/enums';
import { modelsStore } from '$lib/stores';
import { getBackend } from '$lib/utils/api-base';
@@ -43,6 +48,38 @@
showOrgName = true
}: Props = $props();
let render = $derived(renderOption ?? defaultOption);
// A large local catalog is mounted a window at a time; the sentinel at the end
// of the list grows the window when it scrolls into view.
let visibleCount = $state(MODEL_ROW_WINDOW);
let sentinelEl = $state<HTMLElement | null>(null);
const localGroups = $derived(windowLocalGroups(groups, visibleCount));
const localRowCount = $derived(
groups.loaded.length + groups.available.reduce((count, group) => count + group.items.length, 0)
);
const hasMoreLocal = $derived(localGroups.shown < localRowCount);
$effect(() => {
const sentinel = sentinelEl;
if (!sentinel || !hasMoreLocal) return;
// scroll does not bubble, so listen in the capture phase and ask the
// sentinel where it is instead of guessing which ancestor scrolls
const onScroll = () => {
const rect = sentinel.getBoundingClientRect();
// grow only when the end of the list is actually on screen, otherwise
// every scroll event of the page would mount the whole catalog
if (rect.top > window.innerHeight || rect.bottom < 0) return;
visibleCount += MODEL_ROW_WINDOW;
};
document.addEventListener('scroll', onScroll, { capture: true, passive: true });
onScroll();
return () => document.removeEventListener('scroll', onScroll, { capture: true });
});
// a local-only install is one list: favorites first, then the rest, no headings
let localOnly = $derived(groups.providers.length === 0);
// section headers stick right below the search/tabs block of the dropdown
@@ -120,15 +157,19 @@
{#snippet localRows()}
<!-- the loaded models first -->
{#each groups.loaded as item (`loaded-${item.option.id}`)}
{#each localGroups.loaded as item (`loaded-${item.option.id}`)}
{@render render(item, !showOrgName)}
{/each}
{#each groups.available as group (group.orgName)}
{#each localGroups.available as group (group.orgName)}
{#each group.items as item (item.option.id)}
{@render render(item, !showOrgName)}
{/each}
{/each}
{#if hasMoreLocal}
<div bind:this={sentinelEl} aria-hidden="true" class="h-px"></div>
{/if}
{/snippet}
{#if groups.loaded.length > 0 || groups.available.length > 0}
@@ -85,10 +85,40 @@
let fetchedBaseModelOrg = $state<string | null>(null);
let baseModelOrg = $derived(orgOf(tagBaseModel) || fetchedBaseModelOrg);
// Long local lists mount hundreds of rows at once; resolving every base model
// up front means one Hugging Face request per row, so wait until a row is
// actually near the viewport.
let rowEl = $state<HTMLElement | null>(null);
let isNearViewport = $state(false);
$effect(() => {
if (isNearViewport || !rowEl) return;
if (typeof IntersectionObserver === 'undefined') {
isNearViewport = true;
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
isNearViewport = true;
observer.disconnect();
}
},
{ rootMargin: '200px' }
);
observer.observe(rowEl);
return () => observer.disconnect();
});
$effect(() => {
fetchedBaseModelOrg = null;
if (!showBaseModelAvatar || !orgName || tagBaseModel) return;
if (!isNearViewport || !showBaseModelAvatar || !orgName || tagBaseModel) return;
// external provider ids (`~openai/gpt-...`, `deepseek/deepseek-chat`) are
// not HF repos; their org is already the provider slug, so the base model
@@ -117,6 +147,7 @@
</script>
<div
bind:this={rowEl}
aria-selected={isSelected || isHighlighted}
class={[
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
@@ -84,6 +84,36 @@ export function groupFavoriteOptions(
return favorites;
}
/**
* Cut the local groups down to a window of rows. Loaded models come first, then
* the org groups, so the caller can grow the window as the list is scrolled.
*/
export function windowLocalGroups(
groups: GroupedModelOptions,
limit: number
): { available: GroupedModelOptions['available']; loaded: ModelItem[]; shown: number } {
const loaded = groups.loaded.slice(0, Math.max(0, limit));
let budget = limit - loaded.length;
const available: GroupedModelOptions['available'] = [];
let shown = loaded.length;
for (const group of groups.available) {
if (budget <= 0) break;
const items = group.items.slice(0, budget);
budget -= items.length;
shown += items.length;
if (items.length > 0) available.push({ ...group, items });
}
return { available, loaded, shown };
}
export function groupModelOptions(
filteredOptions: ModelOption[],
isModelLoaded: (model: string) => boolean
@@ -53,6 +53,12 @@ export const MODEL_SELECTOR_ICON = Package;
/** Models listed per remote provider before the "+ X more" line; search covers the rest. */
export const REMOTE_PROVIDER_MODEL_LIMIT = 12;
/**
* Model rows mounted before the list is scrolled. A local catalog can hold
* hundreds of models and mounting them all costs seconds on open.
*/
export const MODEL_ROW_WINDOW = 50;
/** Recently used models kept per browser, most recent first. */
export const RECENT_MODEL_LIMIT = 20;