From 71768246e8b53639632ce93ed93d0f95a45a27c3 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Wed, 16 Sep 2026 21:45:50 +0200 Subject: [PATCH] ui : add a favorites tab to the model selector Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash --- .../ModelsSelectorBackendSwitcher.svelte | 35 ++++++-- .../ModelsSelectorDropdown.svelte | 21 +++-- .../ModelsSelector/ModelsSelectorList.svelte | 14 ++-- .../ModelsSelector/ModelsSelectorSheet.svelte | 13 ++- .../lib/components/app/navigation/utils.ts | 45 +++++----- tools/ui/src/lib/constants/ui.constants.ts | 3 + .../lib/hooks/use-models-selector.svelte.ts | 83 +++++++++++++++---- .../stories/ModelsSelector.stories.svelte | 5 +- 8 files changed, 155 insertions(+), 64 deletions(-) diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorBackendSwitcher.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorBackendSwitcher.svelte index 72fa3873aa..6152de8ec0 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorBackendSwitcher.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorBackendSwitcher.svelte @@ -1,28 +1,47 @@
+ {#if onSelectFavorites} + + {/if} + {#each backends as backend (backend.id)} {@const state = backendsModelsStore.get(backend.id)}
- + void ms.handleBackendChange(backendId)} + onSelectFavorites={ms.showFavorites} />
@@ -182,13 +184,16 @@
{/if} - {#if ms.filteredOptions.length === 0} -

No models found.

+ {#if ms.isEmpty} +

+ {ms.isFavoritesView ? 'No favorite models yet.' : 'No models found.'} +

{/if} +): ModelItem[] { + const favorites: ModelItem[] = []; + + for (let i = 0; i < options.length; i++) { + if (favoriteIds.has(options[i].model)) { + favorites.push({ flatIndex: i, option: options[i] }); + } + } + + return favorites; +} + export function groupModelOptions( filteredOptions: ModelOption[], - favoriteIds: Set, isModelLoaded: (model: string) => boolean, isLocalModel: (option: ModelOption) => boolean = () => true ): GroupedModelOptions { @@ -62,25 +79,15 @@ export function groupModelOptions( const loaded: ModelItem[] = []; for (let i = 0; i < filteredOptions.length; i++) { - if (isModelLoaded(filteredOptions[i].model)) { - loaded.push({ flatIndex: i, option: filteredOptions[i] }); + const option = filteredOptions[i]; + + if (isModelLoaded(option.model)) { + loaded.push({ flatIndex: i, option }); } } - // Favorites (excluding loaded) const loadedModelIds = new Set(loaded.map((item) => item.option.model)); - const favorites: ModelItem[] = []; - - for (let i = 0; i < filteredOptions.length; i++) { - if ( - favoriteIds.has(filteredOptions[i].model) && - !loadedModelIds.has(filteredOptions[i].model) - ) { - favorites.push({ flatIndex: i, option: filteredOptions[i] }); - } - } - - // Available models grouped by org (excluding loaded and favorites) + // Available models grouped by org (excluding loaded) const available: OrgGroup[] = []; // models from other backends have no load state, so they stay flat const external: ModelItem[] = []; @@ -89,7 +96,7 @@ export function groupModelOptions( for (let i = 0; i < filteredOptions.length; i++) { const option = filteredOptions[i]; - if (loadedModelIds.has(option.model) || favoriteIds.has(option.model)) continue; + if (loadedModelIds.has(option.model)) continue; if (!isLocalModel(option)) { external.push({ flatIndex: i, option }); @@ -108,5 +115,5 @@ export function groupModelOptions( available.push({ items, orgName: orgName || null }); } - return { available, external, favorites, loaded }; + return { available, external, loaded }; } diff --git a/tools/ui/src/lib/constants/ui.constants.ts b/tools/ui/src/lib/constants/ui.constants.ts index 4140abfb52..b4beb9c242 100644 --- a/tools/ui/src/lib/constants/ui.constants.ts +++ b/tools/ui/src/lib/constants/ui.constants.ts @@ -50,6 +50,9 @@ export const DARK_INVERT_AVATAR_ORGS = ['openai']; /** Icon used for the model selector and the `/model` slash command. */ export const MODEL_SELECTOR_ICON = Package; +/** Pseudo backend tab listing the favorites of every backend. */ +export const FAVORITES_TAB_ID = 'favorites'; + export const ICON_STRIP_TRANSITION_DURATION = 150; export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50; diff --git a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts index bb4d81bcb9..cb45ded481 100644 --- a/tools/ui/src/lib/hooks/use-models-selector.svelte.ts +++ b/tools/ui/src/lib/hooks/use-models-selector.svelte.ts @@ -1,11 +1,19 @@ -import { filterModelOptions, groupModelOptions } from '$lib/components/app/navigation/utils'; -import { CHAT_INPUT_FOCUS_SELECTOR, LOCAL_BACKEND_ID } from '$lib/constants'; +import type { ModelItem } from '$lib/components/app/navigation/utils'; +import { + filterModelOptions, + groupFavoriteOptions, + groupModelOptions +} from '$lib/components/app/navigation/utils'; +import { CHAT_INPUT_FOCUS_SELECTOR, FAVORITES_TAB_ID, LOCAL_BACKEND_ID } from '$lib/constants'; import { backendsModelsStore, backendsStore, modelsStore, serverStore } from '$lib/stores'; import type { Backend } from '$lib/types'; import type { ModelOption } from '$lib/types/models'; import { rawModelId } from '$lib/utils/model-option-id'; import { onMount } from 'svelte'; +/** Groups of the favorites tab, which lists favorites only. */ +const EMPTY_GROUPS = { available: [], external: [], loaded: [] }; + export interface UseModelsSelectorOptions { currentModel: () => string | null; useGlobalSelection?: () => boolean; @@ -27,13 +35,18 @@ export interface UseModelsSelectorReturn { readonly serverModel: string | null; readonly isHighlightedCurrentModelActive: boolean; readonly isCurrentModelInCache: boolean; + readonly favoriteItems: ModelItem[]; readonly filteredOptions: ModelOption[]; + readonly isFavoritesView: boolean; + readonly isEmpty: boolean; readonly groupedFilteredOptions: ReturnType; readonly isLoadingModel: boolean; readonly switchingBackends: boolean; readonly searchTerm: string; readonly showModelDialog: boolean; readonly infoModelId: string | null; + readonly viewId: string; + showFavorites(): void; setSearchTerm(value: string): void; setShowModelDialog(value: boolean): void; handleInfoClick(modelName: string): void; @@ -52,6 +65,9 @@ export interface UseModelsSelectorReturn { * duplicating store derivations, selection handling, and model loading. */ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSelectorReturn { + /** Tab the selector shows: a backend id, or the favorites pseudo tab. */ + let viewId = $state(backendsStore.active.id); + const activeBackendId = $derived(backendsStore.active.id); // every enabled backend's models stay selectable and resolvable, so a // model never turns unavailable just because another tab is open @@ -62,8 +78,12 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele return modelProps?.ui !== false; }) ); - // the switcher tabs scope the rendered list to one backend's models - const options = $derived(allOptions.filter((option) => option.backendId === activeBackendId)); + // the switcher tabs scope the rendered list to one backend's models; the + // favorites tab is not a backend and lists every backend's favorites + const isFavoritesView = $derived(viewId === FAVORITES_TAB_ID); + const options = $derived( + isFavoritesView ? allOptions : allOptions.filter((option) => option.backendId === viewId) + ); const loading = $derived(modelsStore.loading); const updating = $derived(modelsStore.updating); const activeId = $derived(modelsStore.selectedModelId); @@ -98,13 +118,21 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele let infoModelId = $state(null); const filteredOptions = $derived(filterModelOptions(options, searchTerm)); + // favorites span every backend, so they come from the full option list + const favoriteItems = $derived( + groupFavoriteOptions(filterModelOptions(allOptions, searchTerm), modelsStore.favoriteModelIds) + ); const groupedFilteredOptions = $derived( - groupModelOptions( - filteredOptions, - modelsStore.favoriteModelIds, - (m) => modelsStore.isModelLoaded(m), - (option) => option.backendId === LOCAL_BACKEND_ID - ) + isFavoritesView + ? EMPTY_GROUPS + : groupModelOptions( + filteredOptions, + (m) => modelsStore.isModelLoaded(m), + (option) => option.backendId === LOCAL_BACKEND_ID + ) + ); + const isEmpty = $derived( + isFavoritesView ? favoriteItems.length === 0 : filteredOptions.length === 0 ); function handleInfoClick(modelName: string) { @@ -139,14 +167,16 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele } async function handleBackendChange(backendId: string) { + viewId = backendId; + searchTerm = ''; + if (backendId === backendsStore.active.id) return; backendsStore.setActive(backendId); - searchTerm = ''; - // keep the multi-model selector mounted while the new backend's props and - // models load; role flips (external MODEL mode -> local ROUTER mode) would - // otherwise unmount and remount the open dropdown mid switch + // keep the multi-model selector mounted across the swap; a role flip + // (external MODEL mode -> local ROUTER mode) would otherwise unmount and + // remount the open dropdown switchingBackends = true; try { @@ -160,7 +190,9 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele } async function handleSelect(modelId: string) { - const option = options.find((opt) => opt.id === modelId); + // favorites live above the tabs and may belong to another backend, so the + // lookup spans every enabled backend + const option = allOptions.find((opt) => opt.id === modelId); if (!option) return; @@ -257,6 +289,10 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele return backends; }, + get favoriteItems() { + return favoriteItems; + }, + get filteredOptions() { return filteredOptions; }, @@ -283,10 +319,18 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele return isCurrentModelInCache; }, + get isEmpty() { + return isEmpty; + }, + isFavorite(model: string) { return modelsStore.favoriteModelIds.has(model); }, + get isFavoritesView() { + return isFavoritesView; + }, + get isHighlightedCurrentModelActive() { return isHighlightedCurrentModelActive; }, @@ -327,6 +371,11 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele showModelDialog = value; }, + showFavorites() { + viewId = FAVORITES_TAB_ID; + searchTerm = ''; + }, + get showModelDialog() { return showModelDialog; }, @@ -337,6 +386,10 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele get updating() { return updating; + }, + + get viewId() { + return viewId; } }; } diff --git a/tools/ui/tests/stories/ModelsSelector.stories.svelte b/tools/ui/tests/stories/ModelsSelector.stories.svelte index bf7c947f98..0a22806ae0 100644 --- a/tools/ui/tests/stories/ModelsSelector.stories.svelte +++ b/tools/ui/tests/stories/ModelsSelector.stories.svelte @@ -104,7 +104,6 @@ } ], external: [], - favorites: favoriteModels, loaded: loadedModels }; @@ -125,6 +124,7 @@ console.log('Info clicked:', modelName)} onSelect={handleSelect} @@ -140,7 +140,6 @@ groups={{ available: [], external: [], - favorites: [], loaded: [loadedModels[0]] }} onInfoClick={(modelName) => console.log('Info clicked:', modelName)} @@ -154,10 +153,10 @@ console.log('Info clicked:', modelName)}