ui : move model list into a submenu within the model selector

This commit is contained in:
Aleksander Grygier
2026-08-15 22:53:56 +02:00
parent 4d6874bbda
commit 2fab2f41b2
@@ -1,8 +1,9 @@
<script lang="ts">
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import type { ModelItem } from './utils';
import { ChevronDown, Loader2 } from '@lucide/svelte';
import { ChevronDown, Lightbulb, Loader2 } from '@lucide/svelte';
import {
ChatFormActionAddReasoningSubmenu,
DialogModelInformation,
DropdownMenuSearchable,
ModelId,
@@ -11,10 +12,11 @@
} from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { MODEL_SELECTOR_ICON } from '$lib/constants';
import { MODEL_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants';
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import { modelsStore } from '$lib/stores';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { modelsStore, settingsStore } from '$lib/stores';
import { modelLoadFraction } from '$lib/utils';
interface Props {
@@ -37,6 +39,9 @@
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,
@@ -44,24 +49,41 @@
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 showOrgNameInTrigger = $derived(
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false
);
$effect(() => {
void ms.searchTerm;
highlightedId = null;
});
// Focus the dropdown's search box without scrolling the page. bits-ui
// 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) return;
if (!isOpen || !modelSubOpen) return;
requestAnimationFrame(() => {
const search = document.querySelector<HTMLElement>(
'[data-slot="dropdown-menu-content"] input'
'[data-slot="dropdown-menu-sub-content"] input'
);
search?.focus({ preventScroll: true });
@@ -188,7 +210,7 @@
<DropdownMenu.Trigger
{...props}
class={[
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] 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`,
`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
@@ -203,16 +225,22 @@
>
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
{#if selectedOption}
<ModelId
class="min-w-0 overflow-hidden"
hideOrgName={false}
hideQuantization
modelId={selectedOption.model}
/>
{:else}
<span class="min-w-0 font-medium">Select model</span>
{/if}
<span class="flex min-w-0 items-center gap-1">
{#if selectedOption}
<ModelId
class="min-w-0 overflow-hidden"
hideOrgName={!showOrgNameInTrigger}
hideQuantization
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}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
@@ -236,73 +264,94 @@
<DropdownMenu.Content
align="end"
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<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} />
<DropdownMenu.Sub bind:open={modelSubOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<MODEL_SELECTOR_ICON class="h-4 w-4" />
<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}
{#if selectedOption}
<ModelId
class="min-w-0 flex-1 overflow-hidden"
hideOrgName={!showOrgNameInTrigger}
hideQuantization
modelId={selectedOption.model}
/>
{/snippet}
{:else}
<span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
{/if}
</DropdownMenu.SubTrigger>
<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 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"
/>
</div>
</DropdownMenuSearchable>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<ChatFormActionAddReasoningSubmenu />
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
@@ -332,12 +381,16 @@
{#if selectedOption}
<ModelId
class="min-w-0 overflow-hidden"
hideOrgName={false}
hideOrgName={!showOrgNameInTrigger}
hideQuantization
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}