ui : add a favorites tab to the model selector

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-16 21:45:50 +02:00
parent 7470533951
commit 71768246e8
8 changed files with 155 additions and 64 deletions
@@ -1,28 +1,47 @@
<script lang="ts">
import { Loader2, Plus } from '@lucide/svelte';
import { Loader2, Plus, Star } from '@lucide/svelte';
import { backendsModelsStore } from '$lib/stores';
import type { Backend } from '$lib/types';
interface Props {
activeId: string;
backends: Backend[];
favoritesActive?: boolean;
onAdd: () => void;
onSelect: (backendId: string) => void;
onSelectFavorites?: () => void;
}
let { activeId, backends, onAdd, onSelect }: Props = $props();
let {
activeId,
backends,
favoritesActive = false,
onAdd,
onSelect,
onSelectFavorites
}: Props = $props();
const tabClass = (active: boolean) => [
'inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap transition',
active
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
];
</script>
<div class="flex items-center gap-1 overflow-x-auto border-b border-border/50 px-2 py-2">
{#if onSelectFavorites}
<button class={tabClass(favoritesActive)} onclick={onSelectFavorites} type="button">
<Star class="h-3 w-3" />
Favorites
</button>
{/if}
{#each backends as backend (backend.id)}
{@const state = backendsModelsStore.get(backend.id)}
<button
class={[
'inline-flex shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap transition',
activeId === backend.id
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
]}
class={tabClass(activeId === backend.id)}
onclick={() => onSelect(backend.id)}
type="button"
>
@@ -89,12 +89,12 @@
let visualOrder = $derived.by(() => {
const order: string[] = [];
for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
for (const item of ms.favoriteItems) order.push(item.option.id);
for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
for (const item of ms.groupedFilteredOptions.external) order.push(item.option.id);
for (const group of ms.groupedFilteredOptions.available) {
for (const item of group.items) order.push(item.option.id);
}
for (const item of ms.groupedFilteredOptions.external) order.push(item.option.id);
return order;
});
@@ -277,20 +277,22 @@
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuSearchable
emptyMessage="No models found."
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
emptyMessage={ms.isFavoritesView ? 'No favorite models yet.' : 'No models found.'}
isEmpty={ms.isEmpty && ms.isCurrentModelInCache}
onSearchChange={(v) => ms.setSearchTerm(v)}
onSearchKeyDown={handleSearchKeyDown}
placeholder="Search models..."
searchClass="bg-transparent"
searchValue={ms.searchTerm}
>
<!-- Provider tabs sit under the search input, above the option list. -->
<!-- Provider tabs (favorites first), above the option list. -->
<ModelsSelectorBackendSwitcher
activeId={ms.activeBackendId}
activeId={ms.viewId}
backends={ms.backends}
favoritesActive={ms.isFavoritesView}
onAdd={handleAddBackend}
onSelect={(backendId) => void ms.handleBackendChange(backendId)}
onSelectFavorites={ms.showFavorites}
/>
<!-- Option list; the search header sticks to the top and the actions
@@ -312,8 +314,10 @@
</button>
{/if}
{#if ms.filteredOptions.length === 0}
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
{#if ms.isEmpty}
<p class="px-4 py-3 text-sm text-muted-foreground">
{ms.isFavoritesView ? 'No favorite models yet.' : 'No models found.'}
</p>
{/if}
{#snippet modelOption(item: ModelItem, _hideOrgName: boolean)}
@@ -344,6 +348,7 @@
<ModelsSelectorList
activeId={ms.activeId}
{currentModel}
favorites={ms.isFavoritesView ? ms.favoriteItems : []}
groups={ms.groupedFilteredOptions}
onInfoClick={ms.handleInfoClick}
onSelect={ms.handleSelect}
@@ -14,11 +14,14 @@
onSelect: (modelId: string) => void;
onInfoClick: (modelName: string) => void;
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
/** Favorite models of every backend; shown on the favorites tab. */
favorites?: ModelItem[];
}
let {
activeId,
currentModel,
favorites = [],
groups,
onInfoClick,
onSelect,
@@ -61,13 +64,10 @@
/>
{/snippet}
{#if groups.favorites.length > 0}
<p class={sectionHeaderClass}>Favorite models</p>
{#each groups.favorites as item (`fav-${item.option.id}`)}
{@render render(item, true)}
{/each}
{/if}
<!-- Favorites tab: rows only, the tab already names the view. -->
{#each favorites as item (`fav-${item.option.id}`)}
{@render render(item, true)}
{/each}
{#if getDownloadEntries.length > 0}
<p class={sectionHeaderClass}>Download in progress</p>
@@ -157,12 +157,14 @@
/>
</div>
<!-- Provider tabs sit under the search input, above the option list. -->
<!-- Provider tabs (favorites first), above the option list. -->
<ModelsSelectorBackendSwitcher
activeId={ms.activeBackendId}
activeId={ms.viewId}
backends={ms.backends}
favoritesActive={ms.isFavoritesView}
onAdd={handleAddBackend}
onSelect={(backendId) => void ms.handleBackendChange(backendId)}
onSelectFavorites={ms.showFavorites}
/>
<div class="max-h-[60vh] overflow-y-auto px-2">
@@ -182,13 +184,16 @@
<div class="my-1 h-px bg-border"></div>
{/if}
{#if ms.filteredOptions.length === 0}
<p class="px-3 py-3 text-center text-sm text-muted-foreground">No models found.</p>
{#if ms.isEmpty}
<p class="px-3 py-3 text-center text-sm text-muted-foreground">
{ms.isFavoritesView ? 'No favorite models yet.' : 'No models found.'}
</p>
{/if}
<ModelsSelectorList
activeId={ms.activeId}
{currentModel}
favorites={ms.isFavoritesView ? ms.favoriteItems : []}
groups={ms.groupedFilteredOptions}
onInfoClick={ms.handleInfoClick}
onSelect={ms.handleSelect}
@@ -14,7 +14,6 @@ export interface OrgGroup {
export interface GroupedModelOptions {
loaded: ModelItem[];
favorites: ModelItem[];
available: OrgGroup[];
/** Models served by other backends; rendered flat, without categories. */
external: ModelItem[];
@@ -52,9 +51,27 @@ export function filterModelOptions(options: ModelOption[], searchTerm: string):
);
}
/**
* Favorite models across every backend, in list order. The favorites tab spans
* all backends, so they are collected from the full option list.
*/
export function groupFavoriteOptions(
options: ModelOption[],
favoriteIds: Set<string>
): 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<string>,
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 };
}
@@ -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;
@@ -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<typeof groupModelOptions>;
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<string>(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<string | null>(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;
}
};
}
@@ -104,7 +104,6 @@
}
],
external: [],
favorites: favoriteModels,
loaded: loadedModels
};
@@ -125,6 +124,7 @@
<ModelsSelectorList
{activeId}
currentModel={selectedModel}
favorites={favoriteModels}
groups={groupedOptions}
onInfoClick={(modelName) => 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 @@
<ModelsSelectorList
activeId={null}
currentModel={null}
favorites={favoriteModels}
groups={{
available: [],
external: [],
favorites: favoriteModels,
loaded: []
}}
onInfoClick={(modelName) => console.log('Info clicked:', modelName)}