ui : group the model selector components

Move the four selector surfaces into models/ModelsSelector with their own
barrel, extract the shared reasoning panel and download row, group the option
list helpers under navigation/utils, and add the models selector hook wiring
(in-flight downloads and reasoning menu included).

Assisted-by: pi:GLM-5.3-Flash
This commit is contained in:
Aleksander Grygier
2026-09-07 22:10:10 +02:00
parent 6ec02a4c81
commit d8702eb3d2
17 changed files with 671 additions and 302 deletions
@@ -1,75 +0,0 @@
<script lang="ts">
import { Check, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
const reasoning = useReasoningMenu();
</script>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
{#if reasoning.isReasoningActive}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
{:else if reasoning.isOff}
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{:else}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{/if}
<span
class="text-sm inline-flex gap-2 {!reasoning.isReasoningActive
? 'text-muted-foreground'
: ''}"
>
Reasoning
<span class="capitalize text-muted-foreground">
{reasoning.currentEffort}
</span>
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
>
{#each reasoning.levels as level (level.value)}
{@const tokenLabel = reasoning.tokenLabel(level)}
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
level
)
? 'bg-accent'
: ''}"
onclick={() => reasoning.select(level)}
>
{#if reasoning.isSelected(level)}
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
{:else}
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
{/if}
<span class="flex-1">{level.label}</span>
{#if tokenLabel}
<span class="text-[11px] text-muted-foreground opacity-60">
{tokenLabel}
</span>
{/if}
{#if level.hasInfo}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p>Maximum reasoning effort with extended context usage</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
@@ -220,14 +220,6 @@ export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/Chat
*/
export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte';
/** Dropdown submenu for selecting reasoning effort level.
*
* Shows a "Reasoning" sub-menu item with a lightbulb icon indicating
* thinking status, and a nested list of effort levels.
* Only visible when the current model supports thinking.
*/
export { default as ChatFormActionAddReasoningSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddReasoningSubmenu.svelte';
/**
* Compact context-usage gauge with per-turn and cumulative breakdown in the tooltip.
*/
@@ -0,0 +1,125 @@
<script lang="ts">
import ModelsDiscoverAvatar from '../discover/ModelsDiscoverAvatar.svelte';
import ModelsDiscoverDownloadProgressBar from '../discover/ModelsDiscoverDownloadProgressBar.svelte';
import { Loader2, Pause, Play, X } from '@lucide/svelte';
import { ModelId } from '$lib/components/app';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { ModelDownloadProgress } from '$lib/types';
interface Props {
/** One entry from the status feed: an in-flight or paused download. */
entry: { isPaused: boolean; progress: ModelDownloadProgress | null; repoWithTag: string };
/**
* Ask the list to confirm cancelling this download. The row owns no dialog;
* the list renders a single shared confirmation.
*/
onRequestCancel?: (repoWithTag: string) => void;
}
let { entry, onRequestCancel }: Props = $props();
let percent = $derived(
entry.progress && entry.progress.totalBytes > 0
? Math.round((entry.progress.downloadedBytes / entry.progress.totalBytes) * 100)
: null
);
let actionText = $derived(entry.isPaused ? 'Resume downloading' : 'Pause downloading');
// Avatar: the repo org with the quantizer org corner badge, as in the model
// option rows; the base model org resolves lazily via HF when unknown
let orgName = $derived(ModelsService.parseModelId(entry.repoWithTag).orgName);
let fetchedBaseModelOrg = $state<string | null>(null);
$effect(() => {
const repoWithTag = entry.repoWithTag;
fetchedBaseModelOrg = null;
let cancelled = false;
void HuggingFaceService.getBaseModel(repoWithTag)
.then((base) => {
if (!cancelled && base?.org) fetchedBaseModelOrg = base.org;
})
// best-effort lookup: offline or unknown repos keep the repo org
.catch(() => {});
return () => {
cancelled = true;
};
});
</script>
<!-- One in-flight download; same actions as the discover quant chips: the row
itself pauses / resumes, the trailing X cancels (stops and discards the
partial files). Both affordances fade in on hover, the slots are reserved
so the list never reflows. -->
<div
class="group relative flex items-center gap-2 rounded-sm p-2 text-left text-sm transition-colors hover:bg-accent/50"
>
<button
aria-label={actionText}
class="flex min-w-0 flex-1 cursor-pointer items-center gap-2 text-left"
onclick={() => {
if (entry.isPaused) void modelsStore.status.downloadModel(entry.repoWithTag).catch(() => {});
else void modelsStore.status.pauseDownload(entry.repoWithTag);
}}
type="button"
>
{#if orgName}
<ModelsDiscoverAvatar
class="mt-0"
org={fetchedBaseModelOrg ?? orgName}
quantOrg={orgName}
quantPositionClass="-bottom-1 -right-1"
quantSize="h-3 w-3"
size="h-6 w-6"
/>
{/if}
<ModelId class="flex-1" hideOrgName modelId={entry.repoWithTag} showRawTooltip />
{#if percent !== null}
<span class="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">{percent}%</span>
{:else if entry.isPaused}
<span class="shrink-0 text-xs text-muted-foreground">Paused</span>
{/if}
<!-- status action: spinner -> pause on hover while in flight, play on hover
when paused; opacity only for the spinner, the spin owns the transform -->
<span class="relative inline-flex h-4 w-4 shrink-0 items-center justify-center">
{#if entry.isPaused}
<Play
class="absolute h-4 w-4 scale-75 opacity-0 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
/>
{:else}
<Loader2
class="absolute h-4 w-4 animate-spin text-muted-foreground transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-0 [@media(pointer:coarse)]:hidden"
/>
<Pause
class="absolute h-4 w-4 opacity-0 transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover:opacity-100 [@media(pointer:coarse)]:opacity-100"
/>
{/if}
</span>
</button>
<button
aria-label="Cancel downloading"
class="inline-flex h-4 w-4 shrink-0 scale-75 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/70 opacity-0 transition-[opacity,transform,color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:text-destructive group-hover:scale-100 group-hover:opacity-100 [@media(pointer:coarse)]:scale-100 [@media(pointer:coarse)]:opacity-100"
onclick={() => onRequestCancel?.(entry.repoWithTag)}
type="button"
>
<X class="h-4 w-4" />
</button>
{#if entry.progress && entry.progress.totalBytes > 0}
<ModelsDiscoverDownloadProgressBar
downloadedBytes={entry.progress.downloadedBytes}
overlay
totalBytes={entry.progress.totalBytes}
/>
{/if}
</div>
@@ -1,18 +1,22 @@
<script lang="ts">
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import type { ModelItem } from './utils';
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
import { ChevronDown, Lightbulb, Loader2 } from '@lucide/svelte';
import {
ChatFormActionAddReasoningSubmenu,
DialogModelInformation,
DropdownMenuSearchable,
ModelId,
ModelsSelectorList,
ModelsSelectorOption
ModelsSelectorOption,
ModelsSelectorReasoningPanel
} from '$lib/components/app';
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_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants';
import {
DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR,
MODEL_SELECTOR_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';
@@ -39,9 +43,6 @@
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);
const ms = useModelsSelector({
currentModel: () => currentModel,
@@ -51,14 +52,16 @@
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;
// Defer focus so the content is mounted; bits-ui auto-focuses the
// opened content by default which can yank the page scroll, so we
// prevent that on the Content and refocus the search here.
requestAnimationFrame(() => {
if (!isOpen) return;
document
.querySelector<HTMLElement>(DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR)
?.focus({ preventScroll: true });
});
} else {
modelSubOpen = false;
}
},
useGlobalSelection: () => useGlobalSelection
@@ -66,6 +69,9 @@
const reasoning = useReasoningMenu();
const selectedOption = $derived(ms.getDisplayOption());
const triggerModel = $derived(selectedOption?.model ?? null);
const showOrgNameInTrigger = $derived(
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false
);
@@ -75,27 +81,12 @@
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.
$effect(() => {
if (!isOpen || !modelSubOpen) return;
requestAnimationFrame(() => {
const search = document.querySelector<HTMLElement>(
'[data-slot="dropdown-menu-sub-content"] input'
);
search?.focus({ preventScroll: true });
});
});
// 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.groupedFilteredOptions.loaded) order.push(item.option.id);
for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
for (const group of ms.groupedFilteredOptions.available) {
for (const item of group.items) order.push(item.option.id);
}
@@ -125,6 +116,13 @@
highlightedId = visualOrder[index];
}
// Pointer/focus interaction with the sticky actions footer (reasoning submenu,
// discover models) leaves the option list, so drop the row highlight the same
// way we do when the dropdown first opens. Keyboard arrows set it again.
function clearHighlight() {
highlightedId = null;
}
// Alt+Enter only unloads and keeps the dropdown open.
async function handleModelKeyAction(modelId: string, unload: boolean) {
if (!unload) {
@@ -180,7 +178,7 @@
'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" />
</span>
@@ -188,8 +186,6 @@
<p class="text-xs text-muted-foreground">No models available.</p>
{/if}
{:else}
{@const selectedOption = ms.getDisplayOption()}
{@const triggerModel = selectedOption?.model}
{@const triggerStatus = triggerModel
? modelsStore.routerModels.find((m) => m.id === triggerModel)?.status?.value
: undefined}
@@ -264,94 +260,90 @@
<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:max-w-[26rem] max-w-[calc(100vw-2rem)] p-0!"
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" />
<DropdownMenuSearchable
emptyMessage="No models found."
isEmpty={ms.filteredOptions.length === 0 && 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" hideQuantization modelId={currentModel} />
{#if selectedOption}
<ModelId
class="min-w-0 flex-1 overflow-hidden"
hideOrgName={!showOrgNameInTrigger}
hideQuantization
modelId={selectedOption.model}
/>
{:else}
<span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
{/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}
{#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}
showBaseModelAvatar
/>
{/snippet}
<ModelsSelectorList
activeId={ms.activeId}
{currentModel}
groups={ms.groupedFilteredOptions}
onInfoClick={ms.handleInfoClick}
onSelect={ms.handleSelect}
renderOption={modelOption}
sectionHeaderClass="[&:not(:first-child)]:mt-3 mb-1 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
/>
</div>
{#snippet footer()}
<!-- Sticky actions footer: reasoning effort panel.
Sticks to the bottom of the content scrollport. -->
<div
class="px-1.5"
onfocusin={clearHighlight}
onmouseenter={clearHighlight}
role="none"
>
<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"
/>
</div>
</DropdownMenuSearchable>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<ChatFormActionAddReasoningSubmenu />
<ModelsSelectorReasoningPanel inMenu />
</div>
{/snippet}
</DropdownMenuSearchable>
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
@@ -364,7 +356,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
@@ -1,6 +1,9 @@
<script lang="ts">
import type { GroupedModelOptions, ModelItem } from './utils';
import ModelsSelectorDownloadItem from './ModelsSelectorDownloadItem.svelte';
import { ModelsSelectorOption } from '$lib/components/app';
import { DialogConfirmDownload } from '$lib/components/app/dialogs';
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/navigation/utils';
import { ModelDownloadConfirmAction } from '$lib/enums';
import { modelsStore } from '$lib/stores';
interface Props {
@@ -8,7 +11,6 @@
currentModel: string | null;
activeId: string | null;
sectionHeaderClass?: string;
orgHeaderClass?: string;
onSelect: (modelId: string) => void;
onInfoClick: (modelName: string) => void;
renderOption?: import('svelte').Snippet<[ModelItem, boolean]>;
@@ -20,20 +22,33 @@
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'
sectionHeaderClass = 'm-0 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none'
}: Props = $props();
let render = $derived(renderOption ?? defaultOption);
/** 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 defaultOption(item: ModelItem, hideOrgName: boolean)}
{#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}
hideOrgName
{isFav}
isHighlighted={false}
{isSelected}
@@ -42,17 +57,10 @@
onMouseEnter={() => {}}
{onSelect}
{option}
showBaseModelAvatar
/>
{/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>
@@ -61,16 +69,35 @@
{/each}
{/if}
{#if getDownloadEntries.length > 0}
<p class={sectionHeaderClass}>Download in progress</p>
{#each getDownloadEntries as entry (entry.repoWithTag)}
<ModelsSelectorDownloadItem {entry} onRequestCancel={requestCancel} />
{/each}
{/if}
{#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.available.length > 0}
<p class={sectionHeaderClass}>Available models</p>
<h2 class={sectionHeaderClass}>Downloaded models</h2>
{#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}
<DialogConfirmDownload
action={ModelDownloadConfirmAction.CANCEL}
onClose={() => (cancelOpen = false)}
open={cancelOpen}
repoWithTag={pendingCancel}
/>
@@ -1,5 +1,6 @@
<script lang="ts">
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import ModelsDiscoverAvatar from '../discover/ModelsDiscoverAvatar.svelte';
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
import {
CircleAlert,
Heart,
@@ -11,11 +12,12 @@
RotateCw
} from '@lucide/svelte';
import { ActionIcon, ModelId } from '$lib/components/app';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { HF_BASE_MODEL_TAG_REGEX, ICON_CLASS_DEFAULT } from '$lib/constants';
import { ModelCapability, ServerModelStatus } from '$lib/enums';
import { HuggingFaceService, ModelsService } from '$lib/services';
import { modelsStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { modelLoadFraction, modelLoadProgressText } from '$lib/utils';
import { modelLoadFraction, modelLoadProgressText, orgOf } from '$lib/utils';
interface Props {
option: ModelOption;
@@ -27,6 +29,8 @@
onMouseEnter: () => void;
onKeyDown: (e: KeyboardEvent) => void;
onInfoClick?: (modelName: string) => 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;
}
let {
@@ -38,7 +42,8 @@
onKeyDown,
onMouseEnter,
onSelect,
option
option,
showBaseModelAvatar = false
}: Props = $props();
let currentRouterModels = $derived(modelsStore.routerModels);
@@ -59,6 +64,39 @@
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
let loadTitle = $derived(modelLoadProgressText(loadProgress));
let modalities = $derived(option.modalities);
// 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.
let parsedId = $derived(ModelsService.parseModelId(option.model));
let orgName = $derived(parsedId.orgName);
let tagBaseModel = $derived(
(option.tags ?? [])
.find((t) => HF_BASE_MODEL_TAG_REGEX.test(t))
?.match(HF_BASE_MODEL_TAG_REGEX)?.[1] ?? null
);
let fetchedBaseModelOrg = $state<string | null>(null);
let baseModelOrg = $derived(orgOf(tagBaseModel) || fetchedBaseModelOrg);
$effect(() => {
fetchedBaseModelOrg = null;
if (!showBaseModelAvatar || !orgName || tagBaseModel) return;
let cancelled = false;
void HuggingFaceService.getBaseModel(option.model)
.then((base) => {
if (!cancelled && base?.org) fetchedBaseModelOrg = base.org;
})
// best-effort lookup: offline or unknown repos keep the repo org
.catch(() => {});
return () => {
cancelled = true;
};
});
let capabilities = $derived.by(() => ({
reasoning: modelsStore.props.checkModelSupportsThinking(option.model),
tools: option.capabilities.includes(ModelCapability.TOOL_USE)
@@ -84,6 +122,17 @@
tabindex="0"
title={loadTitle}
>
{#if orgName}
<ModelsDiscoverAvatar
class="mt-0"
org={baseModelOrg ?? orgName}
quantOrg={showBaseModelAvatar ? orgName : undefined}
quantPositionClass="-bottom-1 -right-1"
quantSize="h-3 w-3"
size="size-5"
/>
{/if}
<ModelId
aliases={option.aliases}
class="flex-1"
@@ -0,0 +1,188 @@
<script lang="ts">
import { Check, ChevronDown, ChevronUp, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import type { ReasoningEffortLevel } from '$lib/types';
interface Props {
/** Render rows as DropdownMenu items (desktop dropdown); plain buttons in menus without one (sheet). */
inMenu?: boolean;
}
let { inMenu = false }: Props = $props();
const reasoning = useReasoningMenu();
let expanded = $state(false);
// rows stay mounted through the collapse transition so it can play; after
// it, menus drop them - mounted rows are menu items and would pollute the
// arrow-key navigation
// measured px transition: two plain lengths interpolate in every browser,
// no calc-size() support needed. The rows mount at height 0, get measured,
// then the region grows to the measured height.
const EXPAND_TRANSITION_MS = 200;
let rowsMounted = $state(false);
let regionEl = $state<HTMLDivElement | null>(null);
let heightPx = $state(0);
$effect(() => {
if (!inMenu) {
rowsMounted = true;
}
if (expanded) {
// in menus the rows mount with the flip; measure once they are laid out
if (inMenu) rowsMounted = true;
requestAnimationFrame(() => {
if (regionEl) heightPx = regionEl.scrollHeight;
});
} else {
heightPx = 0;
// menus drop the rows after the collapse: mounted rows are menu
// items and would pollute the arrow-key navigation
if (inMenu) {
const timer = setTimeout(() => (rowsMounted = false), EXPAND_TRANSITION_MS);
return () => clearTimeout(timer);
}
}
});
const pickLevel = (level: ReasoningEffortLevel) => {
reasoning.select(level);
// collapse so the footer returns to its resting single-row look
expanded = false;
};
</script>
<!-- Reasoning effort picker for the models selector footer; expands in place
(a flyout submenu would cover the list it belongs to). Rows are rendered
as dropdown menu items inside the dropdown, plain buttons in the sheet,
which has no menu context. -->
{#snippet triggerContent()}
{#if reasoning.isReasoningActive}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
{:else if reasoning.isOff}
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{:else}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{/if}
<span class="flex min-w-0 flex-1 items-center gap-2">
<span class="truncate">Reasoning</span>
<span class="shrink-0 capitalize text-muted-foreground">
{reasoning.currentEffort}
</span>
</span>
{#if expanded}
<ChevronUp class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{:else}
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{/if}
{/snippet}
{#if inMenu}
<!-- A menu item (for keyboard nav) wrapping the trigger via `child`;
closeOnSelect keeps the menu open while picking. -->
<DropdownMenu.Item
class="w-full min-w-0 cursor-pointer items-center gap-2 rounded-md text-left text-sm"
closeOnSelect={false}
>
{#snippet child({ props })}
<!-- No `class` here: a static attribute would override the spread props.class. -->
<button
{...props}
aria-expanded={expanded}
onclick={() => (expanded = !expanded)}
type="button"
>
{@render triggerContent()}
</button>
{/snippet}
</DropdownMenu.Item>
{:else}
<button
aria-expanded={expanded}
class="flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent focus-visible:bg-accent"
onclick={() => (expanded = !expanded)}
type="button"
>
{@render triggerContent()}
</button>
{/if}
<!-- Custom expand region instead of bits-ui Collapsible (whose conditional
rendering kills the transition): the height animates through
calc-size(auto, size) and visibility keeps collapsed rows out of the tab
order. In menus the rows unmount after the collapse transition, so the
arrow-key navigation never sees hidden items. -->
<div
bind:this={regionEl}
data-expanded={expanded}
class="overflow-hidden"
style={`height: ${heightPx}px; visibility: ${
expanded ? 'visible' : 'hidden'
}; transition: height ${EXPAND_TRANSITION_MS}ms cubic-bezier(0.23, 1, 0.32, 1), visibility ${EXPAND_TRANSITION_MS}ms;`}
>
{#if rowsMounted}
<!-- Plain items (not a RadioGroup): selection lives in the store. -->
<div class="mt-0.5 flex flex-col gap-0.5 pl-4">
{#each reasoning.levels as level (level.value)}
{@const tokenLabel = reasoning.tokenLabel(level)}
{#if inMenu}
<DropdownMenu.Item
class="flex w-full cursor-pointer gap-3 px-2 py-1.5"
closeOnSelect={false}
onSelect={() => pickLevel(level)}
>
{@render levelContent(level, tokenLabel)}
</DropdownMenu.Item>
{:else}
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent focus-visible:bg-accent"
onclick={() => pickLevel(level)}
type="button"
>
{@render levelContent(level, tokenLabel)}
</button>
{/if}
{/each}
</div>
{/if}
</div>
{#snippet levelContent(level: ReasoningEffortLevel, tokenLabel: string | null)}
{#if reasoning.isSelected(level)}
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
{:else}
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
{/if}
<span class="min-w-0 flex-1 truncate">{level.label}</span>
{#if tokenLabel}
<span class="shrink-0 text-[11px] text-muted-foreground opacity-60">
{tokenLabel}
</span>
{/if}
{#if level.hasInfo}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p>Maximum reasoning effort with extended context usage</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/snippet}
@@ -1,15 +1,17 @@
<script lang="ts">
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
import ModelLoadHighlight from '../ModelLoadHighlight.svelte';
import { ChevronDown, Lightbulb, Loader2, Package } from '@lucide/svelte';
import {
DialogModelInformation,
ModelId,
ModelsSelectorList,
ModelsSelectorReasoningPanel,
SearchInput
} from '$lib/components/app';
import * as Sheet from '$lib/components/ui/sheet';
import { ServerModelStatus } from '$lib/enums';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { modelsStore } from '$lib/stores';
import { modelLoadFraction } from '$lib/utils';
@@ -44,6 +46,8 @@
useGlobalSelection: () => useGlobalSelection
});
const reasoning = useReasoningMenu();
export function open() {
ms.handleOpenChange(true);
}
@@ -109,6 +113,10 @@
/>
{/if}
{#if reasoning.isReasoningActive}
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
{/if}
{#if ms.updating || ms.isLoadingModel}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{:else}
@@ -166,10 +174,13 @@
groups={ms.groupedFilteredOptions}
onInfoClick={ms.handleInfoClick}
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"
/>
</div>
<div class="px-2 pb-1">
<ModelsSelectorReasoningPanel />
</div>
</div>
</Sheet.Content>
</Sheet.Root>
@@ -0,0 +1,95 @@
/**
*
* 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';
/**
* **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';
/**
* **ModelsSelectorReasoningPanel** - Inline reasoning effort picker
*
* 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.
*/
export { default as ModelsSelectorReasoningPanel } from './ModelsSelectorReasoningPanel.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';
@@ -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.
@@ -118,3 +57,5 @@ export { default as ModelId } from './ModelId.svelte';
* styling stay consistent across every model-id surface.
*/
export { default as ModelCapabilityIcons } from './ModelCapabilityIcons.svelte';
export * from './ModelsSelector';
@@ -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,32 +9,45 @@
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();
</script>
<div class="sticky top-0 z-10 mb-2 bg-popover p-1 pt-2">
<div class="sticky top-0 z-20 p-1.5">
<SearchInput
bind:value={searchValue}
class={searchClass}
onInput={onSearchChange}
onKeyDown={onSearchKeyDown}
{placeholder}
/>
</div>
<div class="overflow-y-auto">
<div class={contentClass}>
{@render children()}
{#if isEmpty}
@@ -44,7 +56,13 @@
</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}
@@ -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"
@@ -23,6 +23,12 @@ export const DIALOG_SUBMENU_CONTENT = 'w-60';
export const CHAT_INPUT_FOCUS_SELECTOR =
'[data-slot="input-area"] textarea, [data-slot="input-area"] [contenteditable="true"]';
/**
* Selects the search input inside an open dropdown-menu's content, to focus it
* after the menu mounts (bits-ui's default auto-focus can yank page scroll).
*/
export const DROPDOWN_MENU_CONTENT_SEARCH_SELECTOR = '[data-slot="dropdown-menu-content"] input';
/** Default Tailwind size class for inline icon components (lucide, etc.). */
export const ICON_CLASS_DEFAULT = 'h-4 w-4';
@@ -1,4 +1,4 @@
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
import { filterModelOptions, groupModelOptions } from '$lib/components/app/navigation/utils';
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
import { modelsStore, serverStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
@@ -20,8 +20,8 @@ export interface UseReasoningMenuReturn {
/**
* Shared reactive state and helpers for the reasoning effort menu.
*
* Used by both the desktop dropdown (`ChatFormActionAddReasoningSubmenu`)
* and the mobile sheet (`ChatFormActionAddSheet`) to avoid duplicating the
* Used by the models dropdown footer (`ModelsSelectorReasoningPanel`) and the
* mobile sheet (`ChatFormActionAddSheet`) to avoid duplicating the
* thinking-support derivation and the effort selection logic.
*/
export function useReasoningMenu(): UseReasoningMenuReturn {
@@ -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';