ui : group the model selector components

The selector surfaces move under models/ModelsSelector with their own
barrel: the dropdown and sheet relocate, the list, option and trigger icon
split out, and the shared list helpers join the navigation utils. The
searchable dropdown gains a sticky footer and per-surface class hooks.

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-30 15:31:11 +02:00
parent bf998c5d84
commit 2f524018e4
16 changed files with 1075 additions and 462 deletions
@@ -59,6 +59,7 @@ export { default as CodeBlockActions } from './CodeBlockActions.svelte';
* `class` and `style` props and inherits color via `currentColor`.
*/
export { default as Logo } from './Logo.svelte';
/**
* **CollapsibleSection** - Section with a trigger that expands in place
*
@@ -1,5 +1,6 @@
<script lang="ts">
import ModelCapabilityIcons from './ModelCapabilityIcons.svelte';
import { isLocalOption } from './ModelsManager/utils';
import { ModelCapability } from '$lib/enums';
import { HuggingFaceService } from '$lib/services';
import type { ModelOption } from '$lib/types/models';
@@ -20,7 +21,7 @@
$effect(() => {
template = '';
if (!isNearViewport) return;
if (!isNearViewport || !isLocalOption(option)) return;
// the listing says nothing about tools and reasoning; only the model's chat
// template does, and the Hub carries that template for a local GGUF whether
@@ -1,4 +1,5 @@
<script lang="ts">
import { isLocalOption } from './ModelsManager/utils';
import { HuggingFaceService } from '$lib/services';
import type { ModelOption } from '$lib/types/models';
import { nearViewport } from '$lib/utils';
@@ -21,7 +22,7 @@
$effect(() => {
fetched = null;
if (reported || !isNearViewport) return;
if (reported || !isNearViewport || !isLocalOption(option)) return;
// a local GGUF carries its trained context in the model metadata
const repo = option.model.split(':')[0] ?? '';
@@ -1,32 +1,43 @@
<script lang="ts">
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import type { ModelItem } from './utils';
import { ChevronDown, Lightbulb, Loader2 } from '@lucide/svelte';
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
import { ChevronDown, Loader2 } from '@lucide/svelte';
import {
ChatFormActionAddReasoningSubmenu,
DialogModelInformation,
DropdownMenuSearchable,
ModelId,
ModelsSelectorList,
ModelsSelectorOption
ModelsSelectorOption,
ModelsSelectorTriggerIcon
} from '$lib/components/app';
import { DialogBackendForm } from '$lib/components/app/backends';
import type { ModelItem } from '$lib/components/app/navigation/utils';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { MODEL_ID, MODEL_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants';
import {
DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR,
MODEL_ID,
MODEL_ICON,
SETTINGS_KEYS
} from '$lib/constants';
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { ModelsService } from '$lib/services/models.service';
import { modelsStore, settingsStore } from '$lib/stores';
import { modelsStore, settingsStore, uiStore } from '$lib/stores';
import type { ModelOption, ModelSidecarBadge } from '$lib/types/models';
import { modelLoadFraction } from '$lib/utils';
import { rawModelId } from '$lib/utils/model-option-id';
interface Props {
class?: string;
currentModel?: string | null;
disabled?: boolean;
/** The provider behind this selector is unreachable. */
error?: boolean;
forceForegroundText?: boolean;
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
onModelChange?: (
modelId: string,
modelName: string,
backendId?: string
) => Promise<boolean> | boolean | void;
useGlobalSelection?: boolean;
}
@@ -34,6 +45,7 @@
class: className = '',
currentModel = null,
disabled = false,
error = false,
forceForegroundText = false,
onModelChange,
useGlobalSelection = false
@@ -41,9 +53,7 @@
let isOpen = $state(false);
let highlightedId = $state<string | null>(null);
// The model submenu opens together with the menu so the list and its search
// box are immediately available, as before the submenu was introduced
let modelSubOpen = $state(false);
let showAddBackend = $state(false);
const ms = useModelsSelector({
currentModel: () => currentModel,
@@ -51,22 +61,15 @@
onOpenChange: (open) => {
isOpen = open;
highlightedId = null;
if (open) {
// Defer submenu open so the Sub component is mounted first;
// setting bind:open synchronously can be lost if the Sub hasn't
// rendered yet.
queueMicrotask(() => {
if (isOpen) modelSubOpen = true;
});
} else {
modelSubOpen = false;
}
},
useGlobalSelection: () => useGlobalSelection
});
const reasoning = useReasoningMenu();
const selectedOption = $derived(ms.getDisplayOption());
const triggerModel = $derived(selectedOption?.model ?? null);
// one setting for every model id in the selector: the trigger and the rows
const showOrgName = $derived(settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME] ?? true);
/** Draft sidecar as it reads in the trigger tooltip, with its own quant. */
function draftSidecarLabel(baseModel: string, badge: ModelSidecarBadge): string {
@@ -89,39 +92,45 @@
return [option.model, ...drafts].join(' + ');
}
const showOrgNameInTrigger = $derived(
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false
);
$effect(() => {
void ms.searchTerm;
highlightedId = null;
});
// Focus the model submenu's search box without scrolling the page. bits-ui
// auto-focuses the opened content by default, which can yank the page
// scroll; we prevent that on the Content and refocus the search here.
// The search input takes focus as soon as it is mounted. bits-ui auto-focuses
// the opened content by default, which can yank the page scroll, so the content
// prevents that and this waits for the input instead.
$effect(() => {
if (!isOpen || !modelSubOpen) return;
if (!isOpen) return;
requestAnimationFrame(() => {
const search = document.querySelector<HTMLElement>(
'[data-slot="dropdown-menu-sub-content"] input'
);
let frames = 0;
let handle = requestAnimationFrame(function focusSearch() {
const input = document.querySelector<HTMLElement>(DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR);
search?.focus({ preventScroll: true });
if (input) {
input.focus({ preventScroll: true });
return;
}
if (frames++ < 20) handle = requestAnimationFrame(focusSearch);
});
return () => cancelAnimationFrame(handle);
});
// Keyboard navigation follows the on-screen row order, not the flat option list order.
let visualOrder = $derived.by(() => {
const order: string[] = [];
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.favorites) 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 provider of ms.groupedFilteredOptions.providers) {
for (const item of provider.items) order.push(item.option.id);
}
return order;
});
@@ -148,6 +157,20 @@
highlightedId = visualOrder[index];
}
function handleManageModels() {
isOpen = false;
// let the menu finish closing before the dialog takes focus
setTimeout(() => uiStore.openModelsManager(), 0);
}
function handleAddBackend() {
isOpen = false;
// let the menu finish closing before the dialog takes focus
setTimeout(() => (showAddBackend = true), 0);
}
// Alt+Enter only unloads and keeps the dropdown open.
async function handleModelKeyAction(modelId: string, unload: boolean) {
if (!unload) {
@@ -156,12 +179,13 @@
return;
}
const model = modelsStore.routerModels.find((m) => m.id === modelId);
const rawId = rawModelId(modelId);
const model = modelsStore.routerModels.find((m) => m.id === rawId);
const status = model?.status?.value as ServerModelStatus | undefined;
if (status === ServerModelStatus.LOADING) return;
await modelsStore.status.unload(modelId);
await modelsStore.status.unload(rawId);
}
export function open() {
@@ -190,29 +214,33 @@
</script>
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
{#if ms.loading && ms.options.length === 0 && ms.isMultiModel}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
Loading models…
</div>
{:else if ms.options.length === 0 && ms.isRouter}
{:else if ms.options.length === 0 && ms.isMultiModel}
{#if currentModel}
<span
class={[
'inline-flex items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs text-muted-foreground',
className
]}
style="max-width: min(calc(100cqw - 10rem), 20rem)"
style="max-width: min(calc(100cqw - 10rem), 48rem)"
>
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
<MODEL_ICON class="h-3.5 w-3.5 shrink-0" />
</span>
{:else}
<p class="text-xs text-muted-foreground">No models available.</p>
<button
class="cursor-pointer text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onclick={handleAddBackend}
type="button"
>
No models yet. Add a backend to get started.
</button>
{/if}
{:else}
{@const selectedOption = ms.getDisplayOption()}
{@const triggerModel = selectedOption?.model}
{@const triggerStatus = triggerModel
? modelsStore.routerModels.find((m) => m.id === triggerModel)?.status?.value
: undefined}
@@ -224,7 +252,7 @@
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
: 0}
{#if ms.isRouter}
{#if ms.isMultiModel}
<DropdownMenu.Root bind:open={isOpen} onOpenChange={ms.handleOpenChange}>
<Tooltip.Root>
<Tooltip.Trigger>
@@ -234,35 +262,36 @@
{...props}
class={[
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
error
? 'border-destructive/40 bg-destructive/10 !text-destructive hover:bg-destructive/20'
: !ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: 'text-foreground',
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-foreground',
isOpen && 'text-foreground',
'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]'
]}
disabled={disabled || ms.updating}
>
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
<ModelsSelectorTriggerIcon
class="size-3.5 shrink-0 mr-0.375"
option={selectedOption}
/>
<span class="flex min-w-0 items-center gap-1">
<span class="flex min-w-0 items-center gap-0.5">
{#if selectedOption}
<ModelId
class="min-w-0 overflow-hidden"
draftSidecars={selectedOption.draftSidecars}
hideOrgName={!showOrgNameInTrigger}
hideOrgName={!showOrgName}
modelId={selectedOption.model}
/>
{:else}
<span class="min-w-0 font-medium">Select model</span>
{/if}
{#if reasoning.isReasoningActive}
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
{/if}
</span>
{#if ms.updating || ms.isLoadingModel}
@@ -287,94 +316,98 @@
<DropdownMenu.Content
align="end"
class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
class="w-full md:min-w-80 md:w-112 max-w-[calc(100vw-2rem)] p-0! max-h-[min(40rem,calc(var(--bits-dropdown-menu-content-available-height)-1rem))]"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenu.Sub bind:open={modelSubOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<MODEL_SELECTOR_ICON class="h-4 w-4" />
{#if selectedOption}
<ModelId
class="min-w-0 flex-1 overflow-hidden"
draftSidecars={selectedOption.draftSidecars}
hideOrgName={!showOrgNameInTrigger}
modelId={selectedOption.model}
/>
{:else}
<span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
{/if}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-100 max-w-[calc(100vw-2rem)] pt-0">
<DropdownMenuSearchable
emptyMessage="No models found."
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
onSearchChange={(v) => ms.setSearchTerm(v)}
onSearchKeyDown={handleSearchKeyDown}
placeholder="Search models..."
searchValue={ms.searchTerm}
>
<div class="models-list">
{#if !ms.isCurrentModelInCache && currentModel}
<!-- Show unavailable model as first option (disabled) -->
<button
aria-disabled="true"
aria-selected="true"
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
disabled
role="option"
type="button"
>
<ModelId class="flex-1" hideQuantization modelId={currentModel} />
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
{/if}
{#if ms.filteredOptions.length === 0}
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
{/if}
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
{@const isHighlighted = option.id === highlightedId}
{@const isFav = ms.isFavorite(option.model)}
<ModelsSelectorOption
{hideOrgName}
{isFav}
{isHighlighted}
{isSelected}
onInfoClick={ms.handleInfoClick}
onKeyDown={(event) => {
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
event.preventDefault();
void handleModelKeyAction(option.id, event.altKey);
}
}}
onMouseEnter={() => (highlightedId = option.id)}
onSelect={ms.handleSelect}
{option}
/>
{/snippet}
<ModelsSelectorList
activeId={ms.activeId}
{currentModel}
groups={ms.groupedFilteredOptions}
onInfoClick={ms.handleInfoClick}
onSelect={ms.handleSelect}
renderOption={modelOption}
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
<DropdownMenuSearchable
emptyMessage={ms.emptyMessage}
isEmpty={ms.isEmpty && ms.isCurrentModelInCache}
onSearchChange={(v) => ms.setSearchTerm(v)}
onSearchKeyDown={handleSearchKeyDown}
placeholder="Search models..."
searchClass="bg-transparent"
searchValue={ms.searchTerm}
>
<!-- Option list; the search header sticks to the top and the actions
footer to the bottom of the content scrollport. -->
<div class="models-list px-1.5">
{#if !ms.isCurrentModelInCache && currentModel}
<!-- Show unavailable model as first option (disabled) -->
<button
aria-disabled="true"
aria-selected="true"
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
disabled
role="option"
type="button"
>
<ModelId
class="flex-1"
hideOrgName={!showOrgName}
hideQuantization
modelId={currentModel}
/>
</div>
</DropdownMenuSearchable>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<ChatFormActionAddReasoningSubmenu />
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
{/if}
{#if ms.isEmpty}
<p class="px-4 py-3 text-sm text-muted-foreground">{ms.emptyMessage}</p>
{/if}
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
{@const isHighlighted = option.id === highlightedId}
{@const isFav = ms.isFavorite(option.model)}
<ModelsSelectorOption
{hideOrgName}
{isFav}
{isHighlighted}
{isSelected}
onKeyDown={(event) => {
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
event.preventDefault();
void handleModelKeyAction(option.id, event.altKey);
}
}}
onMouseEnter={() => (highlightedId = option.id)}
onSelect={ms.handleSelect}
{option}
showBaseModelAvatar={!settingsStore.config.groupModelsByFamily}
showRepoOrgAvatar={settingsStore.config.groupModelsByFamily}
/>
{/snippet}
<ModelsSelectorList
activeId={ms.activeId}
{currentModel}
favorites={ms.favoriteItems}
groups={ms.groupedFilteredOptions}
loaded={ms.loadedItems}
onProviderBack={ms.isProviderView ? ms.closeProvider : undefined}
onProviderOpen={ms.openProvider}
onSelect={ms.handleSelect}
renderOption={modelOption}
sectionHeaderClass="[&:not(:first-child)]:mt-1 mb-1 px-2 py-2 text-[13px] font-semibold text-foreground/80 select-none"
{showOrgName}
/>
</div>
{#snippet footer()}
<!-- Sticky action footer: it sticks to the bottom of the content
scrollport, past the option list. -->
<DropdownMenu.Group class="px-2">
<DropdownMenu.Item class="gap-2" onSelect={handleManageModels}>
<MODEL_ICON class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
Manage models
</DropdownMenu.Item>
</DropdownMenu.Group>
{/snippet}
</DropdownMenuSearchable>
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
@@ -387,7 +420,7 @@
class={[
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
? 'bg-red-400/10 text-red-400! hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
@@ -399,21 +432,20 @@
onclick={() => ms.handleOpenChange(true)}
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
>
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
<ModelsSelectorTriggerIcon
class="size-3.5 shrink-0 mr-0.375"
option={selectedOption}
/>
{#if selectedOption}
<ModelId
class="min-w-0 overflow-hidden"
draftSidecars={selectedOption.draftSidecars}
hideOrgName={!showOrgNameInTrigger}
hideOrgName={!showOrgName}
modelId={selectedOption.model}
/>
{/if}
{#if reasoning.isReasoningActive}
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
{/if}
{#if ms.updating}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{/if}
@@ -423,18 +455,15 @@
{#if selectedOption}
<Tooltip.Content>
<p class="font-mono">{triggerTooltipLabel(selectedOption)}</p>
</Tooltip.Content>
<p class="font-mono">{triggerTooltipLabel(selectedOption)}</p>
</Tooltip.Content>
{/if}
</Tooltip.Root>
{/if}
{/if}
</div>
{#if ms.showModelDialog}
<DialogModelInformation
modelId={ms.infoModelId}
onOpenChange={(v) => ms.setShowModelDialog(v)}
open={ms.showModelDialog}
/>
{/if}
<DialogBackendForm
bind:open={showAddBackend}
onSaved={(backend) => void ms.showBackendModels(backend.id)}
/>
@@ -0,0 +1,268 @@
<script lang="ts">
import ModelsSelectorDownloadItem from './ModelsSelectorDownloadItem.svelte';
import { Heart, Power } from '@lucide/svelte';
import { GroupedList, ModelAvatar, ModelsSelectorOption } from '$lib/components/app';
import { ModelsSection } from '$lib/components/app';
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
import Logo from '$lib/components/app/misc/Logo.svelte';
import {
type GroupedModelOptions,
type ModelItem,
windowLocalGroups
} from '$lib/components/app/navigation/utils';
import { LOCAL_BACKEND_ID, MODEL_ROW_WINDOW } from '$lib/constants';
import { ModelDownloadConfirmAction } from '$lib/enums';
import { modelsStore, settingsStore } from '$lib/stores';
import { groupModelFamilies, type ModelFamilyGroup } from '$lib/utils/model-families';
interface Props {
groups: GroupedModelOptions;
currentModel: string | null;
activeId: string | null;
sectionHeaderClass?: string;
onSelect: (modelId: string) => void;
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
/** Favorite models of every backend, listed in their own section. */
favorites?: ModelItem[];
/** Loaded models of every llama-compat backend, leading the list. */
loaded?: ModelItem[];
/** Show the organization name in every model id of the list. */
showOrgName?: boolean;
/** Open one provider's full list, offered when a section is cut short. */
onProviderOpen?: (backendId: string) => void;
/** Leave the drilled-in provider; enables the back affordance. */
onProviderBack?: () => void;
}
let {
activeId,
currentModel,
favorites = [],
groups,
loaded = [],
onProviderBack,
onProviderOpen,
onSelect,
renderOption,
sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none',
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 });
});
// section headers stick right below the search/tabs block of the dropdown
// scrollport; `--dropdown-sticky-height` is set by DropdownMenuSearchable
// and falls back to 0 in surfaces without one (the mobile sheet)
let headerClass = $derived(`${sectionHeaderClass} sticky z-10 bg-popover`);
const headerStyle = 'top: var(--dropdown-sticky-height, 0px)';
/** In-flight / paused downloads, tracked by the status feed. */
let getDownloadEntries = $derived(modelsStore.status.getDownloadEntries());
// Cancel is confirmed once for the whole list rather than per download row, so
// a single dialog instance is mounted however many downloads are in flight.
// The target is kept while the dialog closes so its copy stays rendered.
let pendingCancel = $state('');
let cancelOpen = $state(false);
function requestCancel(repoWithTag: string) {
pendingCancel = repoWithTag;
cancelOpen = true;
}
</script>
{#snippet familyHeading({ group: family }: { group: ModelFamilyGroup<ModelItem> })}
<div class="flex items-center gap-2 px-2 py-1.5 select-none">
<ModelAvatar
option={family.entries[0].option}
showBaseModelAvatar
showQuantBadge={false}
size="size-6"
/>
<span class="truncate text-[13px] font-semibold text-muted-foreground/70">
{family.label}
</span>
<span class="text-xs text-muted-foreground">
{family.entries.length} model{family.entries.length === 1 ? '' : 's'}
</span>
</div>
{/snippet}
{#snippet listItem({ depth, entry }: { depth: number; entry: ModelItem })}
<div style="padding-left: {depth * 16}px">{@render render(entry, !showOrgName)}</div>
{/snippet}
{#snippet listRows(items: ModelItem[], prefix: string)}
{#if settingsStore.config.groupModelsByFamily}
<GroupedList
group={familyHeading}
groupStateKey={prefix}
groups={groupModelFamilies(items, (row) => row.option.model).map((family) => ({
entries: family.entries,
group: family,
key: `${prefix}-${family.key}`
}))}
item={listItem}
keyOf={(row) => `${prefix}-${row.option.id}`}
stickyStyle="top: calc(var(--dropdown-sticky-height, 0px) + 2.25rem - 1px)"
/>
{:else}
<GroupedList item={listItem} {items} keyOf={(row) => `${prefix}-${row.option.id}`} />
{/if}
{/snippet}
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || activeId === option.id}
{@const isFav = modelsStore.favoriteModelIds.has(option.model)}
<ModelsSelectorOption
{hideOrgName}
{isFav}
isHighlighted={false}
{isSelected}
onKeyDown={() => {}}
onMouseEnter={() => {}}
{onSelect}
{option}
showBaseModelAvatar={!settingsStore.config.groupModelsByFamily}
showRepoOrgAvatar={settingsStore.config.groupModelsByFamily}
/>
{/snippet}
{#if loaded.length > 0}
<ModelsSection
count={loaded.length}
label="Loaded models"
persistKey="loaded"
revealChevronOnHover
sticky
>
{#snippet icon()}
<Power class="h-3.5 w-3.5 shrink-0" />
{/snippet}
{#each loaded as item (item.option.id)}
{@render render(item, !showOrgName)}
{/each}
</ModelsSection>
{/if}
{#if favorites.length > 0}
<!-- Favorites come first; the sections below skip them -->
<ModelsSection label="Favorites" persistKey="favorites" revealChevronOnHover sticky>
{#snippet icon()}
<Heart class="h-3.5 w-3.5 shrink-0" />
{/snippet}
{#each favorites as item (`fav-${item.option.id}`)}
{@render render(item, !showOrgName)}
{/each}
</ModelsSection>
{/if}
{#if getDownloadEntries.length > 0}
<p class={headerClass} style={headerStyle}>Download in progress</p>
{#each getDownloadEntries as entry (entry.repoWithTag)}
<ModelsSelectorDownloadItem {entry} onRequestCancel={requestCancel} {showOrgName} />
{/each}
{/if}
{#snippet localRows()}
<!-- the loaded models first -->
{#each localGroups.loaded as item (`loaded-${item.option.id}`)}
{@render render(item, !showOrgName)}
{/each}
{@render listRows(
localGroups.available.flatMap((group) => group.items),
'local'
)}
{#if hasMoreLocal}
<div bind:this={sentinelEl} aria-hidden="true" class="h-px"></div>
{/if}
{/snippet}
{#if groups.loaded.length > 0 || groups.available.length > 0}
<ModelsSection label="Local models" persistKey={LOCAL_BACKEND_ID} revealChevronOnHover sticky>
{#snippet icon()}
<Logo class="shrink-0" style="--size: 0.875rem" />
{/snippet}
{@render localRows()}
</ModelsSection>
{/if}
<!-- One section per remote provider. -->
{#each groups.providers as provider (provider.backendId)}
<ModelsSection
backendId={provider.backendId}
error={Boolean(provider.error)}
label={provider.name}
loading={provider.loading}
onBack={onProviderBack}
persistKey={provider.backendId}
revealChevronOnHover
sticky
>
{#if provider.items.length > 0}
{@render listRows(provider.items, provider.backendId)}
{#if onProviderOpen && provider.matched > provider.items.length}
<!-- same box as a model row, it opens the provider's full list -->
<button
class="flex w-full cursor-pointer items-center gap-2 rounded-sm p-2 text-left text-sm text-muted-foreground transition hover:bg-accent hover:text-foreground focus:outline-none"
onclick={() => onProviderOpen(provider.backendId)}
type="button"
>
+ {provider.matched - provider.items.length} more
</button>
{/if}
{:else if provider.catalog === 0}
<p class="px-4 pb-2 text-xs text-muted-foreground">
{provider.error ?? (provider.loading ? 'Loading models...' : 'No models')}
</p>
{/if}
</ModelsSection>
{/each}
<DialogConfirmDownload
action={ModelDownloadConfirmAction.CANCEL}
onClose={() => (cancelOpen = false)}
open={cancelOpen}
repoWithTag={pendingCancel}
/>
@@ -0,0 +1,136 @@
<script lang="ts">
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
import { modelDraftsFor } from '../ModelsManager/utils';
import {
ModelAvatar,
ModelCapabilities,
ModelId,
ModelLoadControl,
ModelRowActions
} from '$lib/components/app';
import { SETTINGS_KEYS } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore, settingsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
import { getBackend } from '$lib/utils/api-base';
import { getBackendCapabilities } from '$lib/utils/backend';
interface Props {
option: ModelOption;
isSelected: boolean;
isHighlighted: boolean;
isFav: boolean;
hideOrgName?: boolean;
onSelect: (modelId: string) => void;
onMouseEnter: () => void;
onKeyDown: (e: KeyboardEvent) => void;
/** Show the base model's org as the main avatar and the repo (quant) org as the corner badge; resolves the base org lazily via HF. */
showBaseModelAvatar?: boolean;
/** Show the repo's own org instead of the base model's. */
showRepoOrgAvatar?: boolean;
}
let {
hideOrgName = false,
isFav,
isHighlighted,
isSelected,
onKeyDown,
onMouseEnter,
onSelect,
option,
showBaseModelAvatar = false,
showRepoOrgAvatar = false
}: Props = $props();
// row actions follow the backend that serves the row, not the selected one
let rowBackend = $derived(getBackend(option.backendId));
let canLoad = $derived(rowBackend ? getBackendCapabilities(rowBackend).loadUnload : false);
let currentRouterModels = $derived(modelsStore.routerModels);
let serverStatus = $derived.by(() => {
const model = currentRouterModels.find((m) => m.id === option.model);
return (model?.status?.value as ServerModelStatus) ?? null;
});
let isOperationInProgress = $derived(modelsStore.status.isOperationInProgress(option.model));
let isFailed = $derived(serverStatus === ServerModelStatus.FAILED);
let isSleeping = $derived(serverStatus === ServerModelStatus.SLEEPING);
let isLoaded = $derived(
(serverStatus === ServerModelStatus.LOADED || isSleeping) && !isOperationInProgress
);
let isLoading = $derived(serverStatus === ServerModelStatus.LOADING || isOperationInProgress);
let loadProgress = $derived(isLoading ? modelsStore.status.getLoadProgress(option.model) : null);
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
let loadTitle = $derived(modelLoadProgressText(loadProgress));
let modalities = $derived(option.modalities);
let showCapabilities = $derived(
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_CAPABILITIES_IN_SELECTOR] ?? false
);
// Avatar: with showBaseModelAvatar the original base model's org is the main
// image and the repo (quantizer) org the corner badge, as in the discover
// list. Loaded models usually carry the `base_model` tag on the option; GGUF
// repos only known to HF are resolved lazily via the cached getBaseModel
// lookup.
</script>
<div
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',
'cursor-pointer',
// skip layout and paint for rows scrolled out of the long lists
'[content-visibility:auto] [contain-intrinsic-size:auto_2.25rem]',
isSelected && !isHighlighted && 'bg-accent/50',
isHighlighted && 'bg-accent',
(isSelected || isHighlighted) && 'text-accent-foreground',
'hover:bg-accent',
'focus:bg-accent',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
]}
onclick={() => onSelect(option.id)}
onkeydown={onKeyDown}
onmouseenter={onMouseEnter}
role="option"
tabindex="0"
title={loadTitle}
>
<ModelAvatar {option} {showBaseModelAvatar} {showRepoOrgAvatar} />
<ModelId
aliases={option.aliases}
class="min-w-0 flex-1"
draftSidecars={option.draftSidecars}
drafts={modelDraftsFor(option)}
hideCapabilities
hideModalities
{hideOrgName}
{modalities}
modelId={option.model}
tags={option.tags}
title={option.model}
/>
{#if showCapabilities}
<ModelCapabilities {option} />
{/if}
<div class="flex shrink-0 items-center gap-1">
<ModelRowActions {isFav} {option} />
<ModelLoadControl
{canLoad}
{isFailed}
{isLoaded}
{isLoading}
{isSleeping}
{option}
showBackendMark
/>
</div>
{#if isLoading}
<ModelLoadHighlight percent={loadPercent} />
{/if}
</div>
@@ -1,24 +1,32 @@
<script lang="ts">
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
import { ChevronDown, Loader2 } from '@lucide/svelte';
import {
DialogModelInformation,
ModelId,
ModelsSelectorList,
ModelsSelectorTriggerIcon,
SearchInput
} from '$lib/components/app';
import { DialogBackendForm } from '$lib/components/app/backends';
import * as Sheet from '$lib/components/ui/sheet';
import { MODEL_ICON, SETTINGS_KEYS } from '$lib/constants';
import { ServerModelStatus } from '$lib/enums';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import { modelsStore } from '$lib/stores';
import { modelsStore, settingsStore, uiStore } from '$lib/stores';
import { modelLoadFraction } from '$lib/utils';
interface Props {
class?: string;
currentModel?: string | null;
/** Callback when model changes. Return false to keep menu open (e.g., for validation failures) */
onModelChange?: (modelId: string, modelName: string) => Promise<boolean> | boolean | void;
onModelChange?: (
modelId: string,
modelName: string,
backendId?: string
) => Promise<boolean> | boolean | void;
disabled?: boolean;
/** The provider behind this selector is unreachable. */
error?: boolean;
forceForegroundText?: boolean;
/** When true, user's global selection takes priority over currentModel (for form selector) */
useGlobalSelection?: boolean;
@@ -28,12 +36,14 @@
class: className = '',
currentModel = null,
disabled = false,
error = false,
forceForegroundText = false,
onModelChange,
useGlobalSelection = false
}: Props = $props();
let sheetOpen = $state(false);
let showAddBackend = $state(false);
const ms = useModelsSelector({
currentModel: () => currentModel,
@@ -44,6 +54,9 @@
useGlobalSelection: () => useGlobalSelection
});
// one setting for every model id in the selector: the trigger and the rows
const showOrgName = $derived(settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME] ?? true);
export function open() {
ms.handleOpenChange(true);
}
@@ -53,16 +66,36 @@
ms.handleOpenChange(false);
}
}
function handleManageModels() {
sheetOpen = false;
// let the sheet finish closing before the dialog takes focus
setTimeout(() => uiStore.openModelsManager(), 0);
}
function handleAddBackend() {
sheetOpen = false;
// let the sheet finish closing before the dialog takes focus
setTimeout(() => (showAddBackend = true), 0);
}
</script>
<div class={['relative inline-flex flex-col items-end gap-1', className]}>
{#if ms.loading && ms.options.length === 0 && ms.isRouter}
{#if ms.loading && ms.options.length === 0 && ms.isMultiModel}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
Loading models…
</div>
{:else if ms.options.length === 0 && ms.isRouter}
<p class="text-xs text-muted-foreground">No models available.</p>
{:else if ms.options.length === 0 && ms.isMultiModel}
<button
class="cursor-pointer text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
onclick={handleAddBackend}
type="button"
>
No models yet. Add a backend to get started.
</button>
{:else}
{@const selectedOption = ms.getDisplayOption()}
{@const triggerModel = selectedOption?.model}
@@ -77,17 +110,19 @@
? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100)
: 0}
{#if ms.isRouter}
{#if ms.isMultiModel}
<button
class={[
`relative inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 max-sm:px-3 max-sm:py-2 max-sm:text-sm dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
error
? 'border-destructive/40 bg-destructive/10 !text-destructive hover:bg-destructive/20'
: !ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: 'text-foreground',
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-foreground',
sheetOpen && 'text-foreground'
]}
disabled={disabled || ms.updating}
@@ -95,7 +130,7 @@
style="max-width: min(calc(100cqw - 9rem), 20rem)"
type="button"
>
<Package class="h-3.5 w-3.5 shrink-0" />
<ModelsSelectorTriggerIcon class="h-3.5 w-3.5 shrink-0" option={selectedOption} />
{#if !selectedOption}
<span class="min-w-0 font-medium">Select model</span>
@@ -156,20 +191,35 @@
<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.emptyMessage}</p>
{/if}
<ModelsSelectorList
activeId={ms.activeId}
{currentModel}
favorites={ms.favoriteItems}
groups={ms.groupedFilteredOptions}
onInfoClick={ms.handleInfoClick}
loaded={ms.loadedItems}
onProviderBack={ms.isProviderView ? ms.closeProvider : undefined}
onProviderOpen={ms.openProvider}
onSelect={ms.handleSelect}
orgHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none [&:not(:first-child)]:mt-2"
sectionHeaderClass="px-2 py-2 text-xs font-semibold text-muted-foreground/60 select-none"
{showOrgName}
/>
</div>
<div class="px-2 pb-1">
<button
class="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent"
onclick={handleManageModels}
type="button"
>
<MODEL_ICON class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
Manage models
</button>
</div>
</div>
</Sheet.Content>
</Sheet.Root>
@@ -177,21 +227,28 @@
<button
class={[
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: ms.isHighlightedCurrentModelActive
error
? 'border-destructive/40 bg-destructive/10 !text-destructive hover:bg-destructive/20'
: !ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: 'text-foreground'
: ms.isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-foreground'
]}
disabled={disabled || ms.updating}
onclick={() => ms.handleOpenChange(true)}
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
>
<Package class="h-3.5 w-3.5 shrink-0" />
<ModelsSelectorTriggerIcon class="h-3.5 w-3.5 shrink-0" option={selectedOption} />
<ModelId class="font-medium" hideQuantization modelId={selectedOption?.model || ''} />
<ModelId
class="font-medium"
hideOrgName={!showOrgName}
hideQuantization
modelId={selectedOption?.model || ''}
/>
{#if ms.updating}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
@@ -201,10 +258,7 @@
{/if}
</div>
{#if ms.showModelDialog}
<DialogModelInformation
modelId={ms.infoModelId}
onOpenChange={(v) => ms.setShowModelDialog(v)}
open={ms.showModelDialog}
/>
{/if}
<DialogBackendForm
bind:open={showAddBackend}
onSaved={(backend) => void ms.showBackendModels(backend.id)}
/>
@@ -0,0 +1,23 @@
<script lang="ts">
import ModelAvatar from '../ModelAvatar.svelte';
import { MODEL_ICON } from '$lib/constants';
import type { ModelOption } from '$lib/types/models';
interface Props {
class?: string;
/** Selected model; its avatar decides the mark, the generic icon when absent. */
option?: ModelOption | null;
}
let { class: className = 'h-3.5 w-3.5', option }: Props = $props();
</script>
{#if option}
<ModelAvatar class={className} {option} showQuantBadge={false} showRepoOrgAvatar size={className}>
{#snippet fallback()}
<MODEL_ICON class={className} />
{/snippet}
</ModelAvatar>
{:else}
<MODEL_ICON class={className} />
{/if}
@@ -0,0 +1,109 @@
/**
*
* MODELS SELECTOR
*
* Model selection UI for the chat form: a desktop dropdown and a mobile sheet,
* each backed by a shared grouped options list, option rows, in-flight download
* rows and an inline reasoning-effort picker. Supports two server modes:
* - **Single model mode**: Server runs with one model, selector shows model info
* - **Router mode**: Server runs with multiple models, selector enables switching
*
* Integrates with modelsStore for model data and serverStore for mode detection.
*
*/
/**
* **ModelsSelectorDropdown** - Model selection dropdown (desktop)
*
* Dropdown for selecting AI models with status indicators,
* search, and model information display. Adapts UI based on server mode.
*
* **Architecture:**
* - Uses DropdownMenuSearchable for model list
* - Integrates with modelsStore for model options and selection
* - Detects router vs single mode from serverStore
* - Opens DialogModelInformation for model details
*
* **Features:**
* - Searchable model list with keyboard navigation
* - Model status indicators (loading/ready/error/updating)
* - Model capabilities badges (vision, tools, etc.)
* - Current/active model highlighting
* - Model information dialog on info button click
* - Router mode: shows all available models with status
* - Single mode: shows current model name only
* - Loading/updating skeleton states
* - Global selection support for form integration
*
* @example
* ```svelte
* <ModelsSelectorDropdown
* currentModel={conversation.modelId}
* onModelChange={(id, name) => updateModel(id)}
* useGlobalSelection
* />
* ```
*/
export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte';
/**
*
* Switches the rendered view: the favorites of every backend, the local server's
* models, or the remote backends'. Purely a display concern - the backend that
* serves requests follows the selected model.
*/
/**
* **ModelsSelectorList** - Grouped model options list
*
* Renders grouped model options (loaded, favorites, available) with section
* headers and org subgroups. Shared between ModelsSelectorDropdown and ModelsSelectorSheet
* to avoid template duplication.
*
* Accepts an optional `renderOption` snippet to customize how each option is
* rendered (e.g., to add keyboard navigation or highlighting).
*/
export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
/**
* **ModelsSelectorDownloadItem** - Single in-flight download row
*
* One "Download in progress" row for the models selector: live progress bar,
* pause / resume on click and a hover-revealed cancel, mirroring the discover
* quant chips.
*/
export { default as ModelsSelectorDownloadItem } from './ModelsSelectorDownloadItem.svelte';
/**
* **ModelsSelectorOption** - Single model option row
*
* Renders a single model option with selection state, favorite toggle,
* load/unload actions, status indicators, and an info button.
* Used inside ModelsSelectorList or directly in custom render snippets.
*/
export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
/**
*
* Collapsible row that expands in place to reveal the reasoning effort levels.
* Used in the sticky footer of ModelsSelectorDropdown, where a flyout submenu
* would float over the model list it belongs to.
*/
/**
* **ModelsSelectorSheet** - Mobile model selection sheet
*
* Bottom sheet variant of ModelsSelectorDropdown optimized for touch interaction
* on mobile devices. Same functionality as ModelsSelectorDropdown but uses Sheet UI
* instead of DropdownMenu.
*/
export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.svelte';
/**
* **ModelsSelectorTriggerIcon** - Icon for the selector trigger button
*
* Shows the selected model's own org avatar (the same mark the option rows
* use) and falls back to the generic model icon when the model has neither an
* org nor a provider mark.
*/
export { default as ModelsSelectorTriggerIcon } from './ModelsSelectorTriggerIcon.svelte';
@@ -1,76 +0,0 @@
<script lang="ts">
import type { GroupedModelOptions, ModelItem } from './utils';
import { ModelsSelectorOption } from '$lib/components/app';
import { modelsStore } from '$lib/stores';
interface Props {
groups: GroupedModelOptions;
currentModel: string | null;
activeId: string | null;
sectionHeaderClass?: string;
orgHeaderClass?: string;
onSelect: (modelId: string) => void;
onInfoClick: (modelName: string) => void;
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
}
let {
activeId,
currentModel,
groups,
onInfoClick,
onSelect,
orgHeaderClass = 'px-2 py-2 text-[11px] font-semibold text-muted-foreground/50 select-none [&:not(:first-child)]:mt-1',
renderOption,
sectionHeaderClass = 'my-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none'
}: Props = $props();
let render = $derived(renderOption ?? defaultOption);
</script>
{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || activeId === option.id}
{@const isFav = modelsStore.favoriteModelIds.has(option.model)}
<ModelsSelectorOption
{hideOrgName}
{isFav}
isHighlighted={false}
{isSelected}
{onInfoClick}
onKeyDown={() => {}}
onMouseEnter={() => {}}
{onSelect}
{option}
/>
{/snippet}
{#if groups.loaded.length > 0}
<p class={sectionHeaderClass}>Loaded models</p>
{#each groups.loaded as item (`loaded-${item.option.id}`)}
{@render render(item, false)}
{/each}
{/if}
{#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}
{#if groups.available.length > 0}
<p class={sectionHeaderClass}>Available models</p>
{#each groups.available as group (group.orgName)}
{#if group.orgName}
<p class={orgHeaderClass}>{group.orgName}</p>
{/if}
{#each group.items as item (item.option.id)}
{@render render(item, true)}
{/each}
{/each}
{/if}
+11 -64
View File
@@ -8,72 +8,11 @@
*
* Integrates with modelsStore for model data and serverStore for mode detection.
*
* The selection UI lives in the ModelsSelector subfolder; the shared model
* display primitives (id / badge rendering) stay here.
*
*/
/**
* **ModelsSelectorDropdown** - Model selection dropdown (desktop)
*
* Dropdown for selecting AI models with status indicators,
* search, and model information display. Adapts UI based on server mode.
*
* **Architecture:**
* - Uses DropdownMenuSearchable for model list
* - Integrates with modelsStore for model options and selection
* - Detects router vs single mode from serverStore
* - Opens DialogModelInformation for model details
*
* **Features:**
* - Searchable model list with keyboard navigation
* - Model status indicators (loading/ready/error/updating)
* - Model capabilities badges (vision, tools, etc.)
* - Current/active model highlighting
* - Model information dialog on info button click
* - Router mode: shows all available models with status
* - Single mode: shows current model name only
* - Loading/updating skeleton states
* - Global selection support for form integration
*
* @example
* ```svelte
* <ModelsSelectorDropdown
* currentModel={conversation.modelId}
* onModelChange={(id, name) => updateModel(id)}
* useGlobalSelection
* />
* ```
*/
export { default as ModelsSelectorDropdown } from './ModelsSelectorDropdown.svelte';
/**
* **ModelsSelectorList** - Grouped model options list
*
* Renders grouped model options (loaded, favorites, available) with section
* headers and org subgroups. Shared between ModelsSelectorDropdown and ModelsSelectorSheet
* to avoid template duplication.
*
* Accepts an optional `renderOption` snippet to customize how each option is
* rendered (e.g., to add keyboard navigation or highlighting).
*/
export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
/**
* **ModelsSelectorOption** - Single model option row
*
* Renders a single model option with selection state, favorite toggle,
* load/unload actions, status indicators, and an info button.
* Used inside ModelsSelectorList or directly in custom render snippets.
*/
export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
/**
* **ModelsSelectorSheet** - Mobile model selection sheet
*
* Bottom sheet variant of ModelsSelectorDropdown optimized for touch interaction
* on mobile devices. Same functionality as ModelsSelectorDropdown but uses Sheet UI
* instead of DropdownMenu.
*/
export { default as ModelsSelectorSheet } from './ModelsSelectorSheet.svelte';
/** * **ModelBadge** - Model name display badge
*
* Compact badge showing current model name with package icon.
@@ -108,7 +47,13 @@ export { default as ModelBadge } from './ModelBadge.svelte';
* aliases, and tags. Supports raw mode to show the unprocessed model name.
* Respects the user's `showRawModelNames` setting.
*/
export { default as ModelCapabilities } from './ModelCapabilities.svelte';
export { default as ModelContext } from './ModelContext.svelte';
export { default as ModelId } from './ModelId.svelte';
export { default as ModelAvatar } from './ModelAvatar.svelte';
export { default as ModelLoadControl } from './ModelLoadControl.svelte';
export { default as ModelRowActions } from './ModelRowActions.svelte';
export { default as ModelsSection } from './ModelsSection.svelte';
/**
* **ModelCapabilityIcons** - Capability and modality icon row
@@ -139,3 +84,5 @@ export { default as ModelAvatar } from './ModelAvatar.svelte';
export { default as ModelLoadControl } from './ModelLoadControl.svelte';
export { default as ModelRowActions } from './ModelRowActions.svelte';
export { default as ModelsSection } from './ModelsSection.svelte';
export * from './ModelsSelector';
@@ -1,101 +0,0 @@
import { ModelModality } from '$lib/enums';
import type { ModelOption } from '$lib/types/models';
import { SvelteMap } from 'svelte/reactivity';
export interface ModelItem {
option: ModelOption;
flatIndex: number;
}
export interface OrgGroup {
orgName: string | null;
items: ModelItem[];
}
export interface GroupedModelOptions {
loaded: ModelItem[];
favorites: ModelItem[];
available: OrgGroup[];
}
function matchesModality(option: ModelOption, term: string): boolean {
const modalities = option.modalities;
if (!modalities) return false;
switch (term) {
case ModelModality.VISION.toLowerCase():
return modalities.vision;
case ModelModality.AUDIO.toLowerCase():
return modalities.audio;
case ModelModality.VIDEO.toLowerCase():
return modalities.video;
default:
return false;
}
}
export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] {
const term = searchTerm.trim().toLowerCase();
if (!term) return options;
return options.filter(
(option) =>
option.model.toLowerCase().includes(term) ||
option.name?.toLowerCase().includes(term) ||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) ||
matchesModality(option, term)
);
}
export function groupModelOptions(
filteredOptions: ModelOption[],
favoriteIds: Set<string>,
isModelLoaded: (model: string) => boolean
): GroupedModelOptions {
// Loaded models
const loaded: ModelItem[] = [];
for (let i = 0; i < filteredOptions.length; i++) {
if (isModelLoaded(filteredOptions[i].model)) {
loaded.push({ flatIndex: i, option: filteredOptions[i] });
}
}
// 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)
const available: OrgGroup[] = [];
const orgGroups = new SvelteMap<string, ModelItem[]>();
for (let i = 0; i < filteredOptions.length; i++) {
const option = filteredOptions[i];
if (loadedModelIds.has(option.model) || favoriteIds.has(option.model)) continue;
const key = option.parsedId?.orgName ?? '';
if (!orgGroups.has(key)) orgGroups.set(key, []);
orgGroups.get(key)!.push({ flatIndex: i, option });
}
for (const [orgName, items] of orgGroups) {
available.push({ items, orgName: orgName || null });
}
return { available, favorites, loaded };
}
@@ -1,6 +1,5 @@
<script lang="ts">
import { SearchInput } from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import type { Snippet } from 'svelte';
interface Props {
@@ -10,41 +9,67 @@
onSearchKeyDown?: (event: KeyboardEvent) => void;
emptyMessage?: string;
isEmpty?: boolean;
/** Extra classes for the wrapper around the option list. */
contentClass?: string;
/** Extra classes for the search input. */
searchClass?: string;
children: Snippet;
/**
* Optional sticky footer. It sticks to the bottom of the dropdown content's
* own scrollport, so it stays visible while the option list scrolls. For this
* to work, DropdownMenu.Content must be the scroll container (keep its
* overflow-y-auto and a max-height) and must not be `overflow-hidden`.
*/
footer?: Snippet;
}
let {
children,
contentClass = '',
emptyMessage = 'No items found',
footer,
isEmpty = false,
onSearchChange,
onSearchKeyDown,
placeholder = 'Search...',
searchClass = '',
searchValue = $bindable('')
}: Props = $props();
// the search sticks to the top; its height is published so
// list section headers can stick right below it
let stickyHeaderHeight = $state(0);
</script>
<div class="sticky top-0 z-10 mb-2 bg-popover p-1 pt-2">
<div bind:clientHeight={stickyHeaderHeight} class="sticky top-0 z-20 bg-popover p-1.5">
<SearchInput
bind:value={searchValue}
class={searchClass}
onInput={onSearchChange}
onKeyDown={onSearchKeyDown}
{placeholder}
/>
</div>
<div class="overflow-y-auto">
{@render children()}
<div class={contentClass}>
<!-- wrapper carries the sticky offset: bits-ui owns the style of the scrollport -->
<div style="--dropdown-sticky-height: {stickyHeaderHeight}px">
{@render children()}
{#if isEmpty}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{/if}
{#if isEmpty}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{/if}
</div>
</div>
{#if footer}
<DropdownMenu.Separator />
<div class="sticky bottom-0 z-20 bg-popover py-1.5">
<div
aria-orientation="horizontal"
class="h-px bg-border/20 mb-1.5 mx-1.5"
role="separator"
></div>
{@render footer()}
{@render footer()}
</div>
{/if}
@@ -0,0 +1,197 @@
import { ModelModality } from '$lib/enums';
import type { ModelOption } from '$lib/types/models';
import { SvelteMap } from 'svelte/reactivity';
export interface ModelItem {
option: ModelOption;
flatIndex: number;
}
export interface OrgGroup {
orgName: string | null;
items: ModelItem[];
}
/** One remote backend's section on the remote view. */
export interface ProviderGroup {
backendId: string;
/** Models the provider lists without any search filtering. */
catalog: number;
error: string | null;
/** Rows to render, capped by the display limit. */
items: ModelItem[];
loading: boolean;
/** Rows left after the search filter, before the cap. */
matched: number;
name: string;
}
export interface GroupedModelOptions {
available: OrgGroup[];
loaded: ModelItem[];
/** Remote backends, one section each. */
providers: ProviderGroup[];
}
function matchesModality(option: ModelOption, term: string): boolean {
const modalities = option.modalities;
if (!modalities) return false;
switch (term) {
case ModelModality.VISION.toLowerCase():
return modalities.vision;
case ModelModality.AUDIO.toLowerCase():
return modalities.audio;
case ModelModality.VIDEO.toLowerCase():
return modalities.video;
default:
return false;
}
}
export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] {
const term = searchTerm.trim().toLowerCase();
if (!term) return options;
return options.filter(
(option) =>
option.model.toLowerCase().includes(term) ||
option.name?.toLowerCase().includes(term) ||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) ||
matchesModality(option, term)
);
}
/**
* 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;
}
/**
* 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
): GroupedModelOptions {
// Loaded models
const loaded: ModelItem[] = [];
for (let i = 0; i < filteredOptions.length; i++) {
const option = filteredOptions[i];
if (isModelLoaded(option.model)) {
loaded.push({ flatIndex: i, option });
}
}
const loadedModelIds = new Set(loaded.map((item) => item.option.model));
// Available models grouped by org (excluding loaded)
const available: OrgGroup[] = [];
const orgGroups = new SvelteMap<string, ModelItem[]>();
for (let i = 0; i < filteredOptions.length; i++) {
const option = filteredOptions[i];
if (loadedModelIds.has(option.model)) continue;
const key = option.parsedId?.orgName ?? '';
if (!orgGroups.has(key)) orgGroups.set(key, []);
orgGroups.get(key)!.push({ flatIndex: i, option });
}
for (const [orgName, items] of orgGroups) {
available.push({ items, orgName: orgName || null });
}
return { available, loaded, providers: [] };
}
/**
* Remote backends as sections, one per backend, in the given order. Each section
* keeps at most `limit` rows; `matched` carries the full count so the caller can
* offer the rest.
*/
export function groupProviderOptions(
options: ModelOption[],
providers: {
backendId: string;
catalog: number;
error: string | null;
loading: boolean;
name: string;
}[],
limit = Infinity,
recentIds: readonly string[] = []
): ProviderGroup[] {
const byBackend = new SvelteMap<string, ModelItem[]>();
const rank = new SvelteMap<string, number>();
recentIds.forEach((id, index) => rank.set(id, index));
const rankOf = (id: string) => rank.get(id) ?? Number.MAX_SAFE_INTEGER;
// recently used models lead their section, the rest keep the backend's order
const byRecency = (items: ModelItem[]) =>
rank.size === 0 ? items : [...items].sort((a, b) => rankOf(a.option.id) - rankOf(b.option.id));
for (let i = 0; i < options.length; i++) {
const option = options[i];
const backendId = option.backendId ?? '';
if (!byBackend.has(backendId)) byBackend.set(backendId, []);
byBackend.get(backendId)!.push({ flatIndex: i, option });
}
return providers.map((provider) => {
const items = byRecency(byBackend.get(provider.backendId) ?? []);
return { ...provider, items: items.slice(0, limit), matched: items.length };
});
}
@@ -17,7 +17,7 @@
<DropdownMenuPrimitive.Content
bind:ref
class={cn(
'z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
'z-50 max-h-[calc(var(--bits-dropdown-menu-content-available-height)-1rem)] min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
className
)}
data-slot="dropdown-menu-content"
@@ -1,8 +1,8 @@
<script lang="ts" module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import ModelsSelectorList from '$lib/components/app/models/ModelsSelectorList.svelte';
import ModelsSelectorOption from '$lib/components/app/models/ModelsSelectorOption.svelte';
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/models/utils';
import ModelsSelectorList from '$lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte';
import ModelsSelectorOption from '$lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte';
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/navigation/utils';
import { ServerModelStatus } from '$lib/enums';
import { modelsStore } from '$lib/stores/models/index.svelte';
@@ -103,8 +103,8 @@
orgName: 'intel'
}
],
favorites: favoriteModels,
loaded: loadedModels
loaded: loadedModels,
providers: []
};
function handleSelect(modelId: string) {
@@ -124,8 +124,8 @@
<ModelsSelectorList
{activeId}
currentModel={selectedModel}
favorites={favoriteModels}
groups={groupedOptions}
onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
onSelect={handleSelect}
/>
</div>
@@ -138,10 +138,9 @@
currentModel={null}
groups={{
available: [],
favorites: [],
loaded: [loadedModels[0]]
loaded: [loadedModels[0]],
providers: []
}}
onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
onSelect={handleSelect}
/>
</div>
@@ -152,12 +151,12 @@
<ModelsSelectorList
activeId={null}
currentModel={null}
favorites={favoriteModels}
groups={{
available: [],
favorites: favoriteModels,
loaded: []
loaded: [],
providers: []
}}
onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
onSelect={handleSelect}
/>
</div>