ui : keep the collapsed model sections and families in local storage

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-28 12:10:12 +02:00
parent 6d9cce6734
commit 0a6e34d8d3
7 changed files with 98 additions and 20 deletions
@@ -17,6 +17,8 @@
defaultOpen?: boolean;
/** Controlled open state, for a caller that binds it. */
open?: boolean;
/** Called on every toggle, for a caller that persists the state. */
ontoggle?: ((open: boolean) => void) | null;
/** Extra classes for the chevron, to line it up with a row's own control. */
chevronClass?: string;
/** Hide the chevron while expanded, until the trigger is hovered. */
@@ -32,6 +34,7 @@
children,
defaultOpen = true,
inMenu = false,
ontoggle = null,
open = $bindable(defaultOpen),
revealChevronOnHover = false,
trigger,
@@ -39,6 +42,11 @@
triggerPosition = 'top',
triggerStyle = ''
}: Props = $props();
function toggle(): void {
open = !open;
ontoggle?.(open);
}
</script>
{#snippet chevron()}
@@ -65,13 +73,7 @@
>
{#snippet child({ props })}
<!-- No `class` here: a static attribute would override the spread props.class. -->
<button
{...props}
aria-expanded={open}
onclick={() => (open = !open)}
style={triggerStyle}
type="button"
>
<button {...props} aria-expanded={open} onclick={toggle} style={triggerStyle} type="button">
{@render trigger()}
{@render chevron()}
@@ -82,7 +84,7 @@
<button
aria-expanded={open}
class="group {triggerClass}"
onclick={() => (open = !open)}
onclick={toggle}
style={triggerStyle}
type="button"
>
@@ -9,6 +9,7 @@
<script generics="G, E" lang="ts">
import CollapsibleRegion from './CollapsibleRegion.svelte';
import { modelsStore } from '$lib/stores';
import type { Snippet } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
@@ -21,6 +22,8 @@
groups?: GroupedListGroup<G, E>[] | null;
/** Entries of one group shown before its show more row. 0 shows every entry. */
groupWindow?: number;
/** Namespace of the persisted open state, e.g. a section key. Omit to keep it in memory. */
groupStateKey?: string;
/** One row. Depth is 1 under a group, 0 in a flat list. */
item: Snippet<[{ depth: number; entry: E }]>;
/** Identity of a row, used for the keyed each. */
@@ -42,6 +45,7 @@
let {
group,
groups = null,
groupStateKey,
groupWindow = 0,
item,
items = [],
@@ -53,8 +57,13 @@
weightOf
}: Props = $props();
// groups start open; this tracks the ones the user folded away
const collapsed = new SvelteSet<string>();
// groups start open; this tracks the ones the user folded away, and a list with
// a state key opens with the ones the user had folded away before the reload
// the initial namespace only
// svelte-ignore state_referenced_locally
const collapsed = new SvelteSet<string>(
groupStateKey ? modelsStore.collapsedGroupsUnder(groupStateKey) : []
);
// windows grow one step at a time, per group and per list
const groupSteps = new SvelteMap<string, number>();
let sectionSteps = $state(0);
@@ -62,11 +71,11 @@
function toggleGroup(key: string): void {
if (collapsed.has(key)) {
collapsed.delete(key);
return;
} else {
collapsed.add(key);
}
collapsed.add(key);
if (groupStateKey) modelsStore.setGroupOpen(`${groupStateKey}-${key}`, !collapsed.has(key));
}
function growGroup(key: string): void {
@@ -556,12 +556,14 @@
defaultOpen={group.kind !== 'hidden'}
icon={group.kind === 'provider' ? undefined : groupIcon}
label={group.label}
persistKey={group.key}
revealChevronOnHover
sticky
stickyClass="sticky z-10 bg-muted/90 backdrop-blur-lg"
>
<GroupedList
group={familyRow}
groupStateKey={group.key}
groupWindow={FAMILY_ROW_WINDOW}
groups={group.flat ? null : familyGroups(group)}
item={listItem}
@@ -2,6 +2,7 @@
import { ChevronLeft, CircleAlert, Loader2 } from '@lucide/svelte';
import { CollapsibleSection } from '$lib/components/app';
import { BackendIcon } from '$lib/components/app/backends';
import { modelsStore } from '$lib/stores';
import { getBackend } from '$lib/utils/api-base';
import type { Snippet } from 'svelte';
@@ -21,6 +22,8 @@
loading?: boolean;
/** Renders the back control, for a drilled-in provider. */
onBack?: () => void;
/** Persists the open state under this id, e.g. a section key. */
persistKey?: string;
/** Start expanded; the manager collapses its hidden block. */
defaultOpen?: boolean;
revealChevronOnHover?: boolean;
@@ -42,6 +45,7 @@
label,
loading = false,
onBack,
persistKey,
revealChevronOnHover = false,
sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground select-none',
sticky = false,
@@ -57,7 +61,8 @@
<CollapsibleSection
{chevronClass}
{defaultOpen}
defaultOpen={persistKey ? modelsStore.isGroupOpen(persistKey, defaultOpen) : defaultOpen}
ontoggle={persistKey ? (open: boolean) => modelsStore.setGroupOpen(persistKey, open) : null}
{revealChevronOnHover}
{triggerClass}
{triggerStyle}
@@ -10,7 +10,7 @@
type ModelItem,
windowLocalGroups
} from '$lib/components/app/navigation/utils';
import { MODEL_ROW_WINDOW } from '$lib/constants';
import { LOCAL_BACKEND_ID, MODEL_ROW_WINDOW } from '$lib/constants';
import { ModelDownloadConfirmAction } from '$lib/enums';
import { ModelGroupingMode } from '$lib/enums/settings.enums';
import { modelsStore, settingsStore } from '$lib/stores';
@@ -154,6 +154,7 @@
{#if settingsStore.config.groupModelsByFamily}
<GroupedList
group={familyHeading}
groupStateKey={prefix}
groups={groupModelFamilies(items, (row) => row.option.model).map((family) => ({
entries: family.entries,
group: family,
@@ -188,7 +189,13 @@
{/snippet}
{#if loaded.length > 0}
<ModelsSection count={loaded.length} label="Loaded models" revealChevronOnHover sticky>
<ModelsSection
count={loaded.length}
label="Loaded models"
persistKey="loaded"
revealChevronOnHover
sticky
>
{#snippet icon()}
<Power class="h-3.5 w-3.5 shrink-0" />
{/snippet}
@@ -201,7 +208,7 @@
{#if favorites.length > 0}
<!-- Favorites come first; the sections below skip them -->
<ModelsSection label="Favorites" revealChevronOnHover sticky>
<ModelsSection label="Favorites" persistKey="favorites" revealChevronOnHover sticky>
{#snippet icon()}
<Heart class="h-3.5 w-3.5 shrink-0" />
{/snippet}
@@ -243,7 +250,7 @@
iconKind: 'boxes' | 'logo',
key: string
)}
<ModelsSection {label} revealChevronOnHover sticky>
<ModelsSection {label} persistKey={key} revealChevronOnHover sticky>
{#snippet icon()}
{#if iconKind === 'logo'}
<Logo class="shrink-0" style="--size: 0.875rem" />
@@ -261,7 +268,7 @@
{/each}
{:else}
{#if groups.loaded.length > 0 || groups.available.length > 0}
<ModelsSection label="Local models" revealChevronOnHover sticky>
<ModelsSection label="Local models" persistKey={LOCAL_BACKEND_ID} revealChevronOnHover sticky>
{#snippet icon()}
<Logo class="shrink-0" style="--size: 0.875rem" />
{/snippet}
@@ -278,6 +285,7 @@
label={provider.name}
loading={provider.loading}
onBack={onProviderBack}
persistKey={provider.backendId}
revealChevronOnHover
sticky
>
@@ -31,6 +31,9 @@ export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabled
export const DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolCategories`;
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
/** Open state the user set for a model list section or one of its families, by id. */
export const MODEL_GROUP_OPEN_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.modelGroupOpen`;
/** Model families pinned to the top of their provider section. */
export const FAVORITE_MODEL_FAMILIES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModelFamilies`;
+50 -1
View File
@@ -12,6 +12,7 @@ import {
FAVORITE_MODEL_FAMILIES_LOCALSTORAGE_KEY,
FAVORITE_MODELS_LOCALSTORAGE_KEY,
HIDDEN_MODELS_LOCALSTORAGE_KEY,
MODEL_GROUP_OPEN_LOCALSTORAGE_KEY,
MODEL_ROW_WINDOW,
RECENT_MODEL_LIMIT,
RECENT_MODEL_USAGE_LOCALSTORAGE_KEY,
@@ -33,7 +34,7 @@ import { settingsStore } from '$lib/stores/settings/index.svelte';
import { getBackendCapabilities, readModelContextLength } from '$lib/utils/backend';
import { getConversationModel } from '$lib/utils/conversation-utils';
import { backendIdFromModelId, qualifyModelId, rawModelId } from '$lib/utils/model-option-id';
import { SvelteSet } from 'svelte/reactivity';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
/** Selection kept from the last session, so a reload does not drop the picked model. */
@@ -100,6 +101,24 @@ function loadFavoriteFamilies(): Set<string> {
}
}
/** Open state the user set for a section or family of the model lists. */
function loadGroupOpenState(): SvelteMap<string, boolean> {
if (!browser) return new SvelteMap<string, boolean>();
try {
const raw = localStorage.getItem(MODEL_GROUP_OPEN_LOCALSTORAGE_KEY);
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
const entries =
parsed && typeof parsed === 'object' ? Object.entries(parsed as Record<string, unknown>) : [];
return new SvelteMap(
entries.filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean')
);
} catch {
return new SvelteMap<string, boolean>();
}
}
/** Recently used backend-qualified ids, most recent first. */
function loadRecentModels(): string[] {
if (!browser) return [];
@@ -124,6 +143,7 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
error = $state<string | null>(null);
favoriteFamilyIds = $state<Set<string>>(loadFavoriteFamilies());
favoriteModelIds = $state<Set<string>>(this.loadFavoritesFromStorage());
groupOpenState = $state<SvelteMap<string, boolean>>(loadGroupOpenState());
hiddenModelIds = $state<Set<string>>(loadHiddenModels());
loading = $state(false);
/**
@@ -280,6 +300,15 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
this.persistSelection();
}
/** Family keys folded away under one section, for a list that restores them. */
collapsedGroupsUnder(prefix: string): string[] {
const head = `${prefix}-`;
return [...this.groupOpenState]
.filter(([id, open]) => !open && id.startsWith(head))
.map(([id]) => id.slice(head.length));
}
/**
* Auto-selects the first available model if none is selected.
* Prioritizes:
@@ -448,6 +477,10 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
return this.favoriteFamilyIds.has(key);
}
isGroupOpen(id: string, fallbackOpen: boolean): boolean {
return this.groupOpenState.get(id) ?? fallbackOpen;
}
isHidden(modelId: string): boolean {
return this.hiddenModelIds.has(modelId);
}
@@ -563,6 +596,22 @@ class ModelsStore implements ModelPropsHost, ModelStatusHost {
}
}
setGroupOpen(id: string, open: boolean): void {
const next = new SvelteMap(this.groupOpenState);
next.set(id, open);
this.groupOpenState = next;
try {
localStorage.setItem(
MODEL_GROUP_OPEN_LOCALSTORAGE_KEY,
JSON.stringify(Object.fromEntries(next))
);
} catch {
toast.error('Failed to save the model list state to local storage');
}
}
/**
* Activate a backend for the selector tabs. Everything comes from memory:
* the model list and router rows are prefetched at startup and the local