ui : refactor model selector dropdown with inline reasoning panel

Replace the submenu-based dropdown with a flat scrollable list that has

a sticky search header and actions footer. Add avatars and parameter

badges to model options. Introduce ModelsSelectorReasoningPanel for

in-place reasoning effort selection. Adjust dropdown sizing and input

blur styling.

Assisted-by: llama-ui:Qwen3.8-Flash-Next
This commit is contained in:
Aleksander Grygier
2026-09-01 18:23:38 +02:00
parent 5809350d41
commit 3628213ea7
13 changed files with 358 additions and 152 deletions
@@ -2,13 +2,13 @@
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import { ChevronDown, Lightbulb, Loader2, PackageSearch } from '@lucide/svelte';
import {
ChatFormActionAddReasoningSubmenu,
DialogModelInformation,
DialogModelsDiscover,
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';
@@ -41,9 +41,6 @@
let isOpen = $state(false);
let highlightedId = $state<string | null>(null);
let modelsHubOpen = $state(false);
// 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,
@@ -53,14 +50,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>('[data-slot="dropdown-menu-content"] input')
?.focus({ preventScroll: true });
});
} else {
modelSubOpen = false;
}
},
useGlobalSelection: () => useGlobalSelection
@@ -77,21 +76,6 @@
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[] = [];
@@ -127,6 +111,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) {
@@ -182,7 +173,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>
@@ -266,105 +257,94 @@
<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)] !py-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" />
{#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>
{/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"
/>
</div>
</DropdownMenuSearchable>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<ChatFormActionAddReasoningSubmenu />
<DropdownMenu.Separator />
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent"
onclick={() => (modelsHubOpen = true)}
<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}
>
<PackageSearch class="h-4 w-4 shrink-0 text-muted-foreground" />
<!-- Option list; the search header sticks to the top and the actions
footer to the bottom of the content scrollport. -->
<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>Discover models</span>
</DropdownMenu.Item>
<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}
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 and discover
models. Sticks to the bottom of the content scrollport. -->
<div onfocusin={clearHighlight} onmouseenter={clearHighlight} role="none">
<ModelsSelectorReasoningPanel />
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent"
onclick={() => (modelsHubOpen = true)}
>
<PackageSearch class="h-4 w-4 shrink-0 text-muted-foreground" />
<span>Discover models</span>
</DropdownMenu.Item>
</div>
{/snippet}
</DropdownMenuSearchable>
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
@@ -8,7 +8,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 +19,19 @@
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);
</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,11 +40,12 @@
onMouseEnter={() => {}}
{onSelect}
{option}
showBaseModelAvatar
/>
{/snippet}
{#if groups.loaded.length > 0}
<p class={sectionHeaderClass}>Loaded models</p>
<p class="{sectionHeaderClass} mt-0">Loaded models</p>
{#each groups.loaded as item (`loaded-${item.option.id}`)}
{@render render(item, false)}
@@ -62,13 +61,9 @@
{/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}
@@ -1,4 +1,5 @@
<script lang="ts">
import ModelsDiscoverAvatar from './discover/ModelsDiscoverAvatar.svelte';
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import {
CircleAlert,
@@ -11,11 +12,22 @@
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,
MODEL_ID,
PATH_SEPARATOR
} 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 {
formatParameters,
modelLoadFraction,
modelLoadProgressText,
normalizeModelName
} from '$lib/utils';
interface Props {
option: ModelOption;
@@ -27,6 +39,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 +52,8 @@
onKeyDown,
onMouseEnter,
onSelect,
option
option,
showBaseModelAvatar = false
}: Props = $props();
let currentRouterModels = $derived(modelsStore.routerModels);
@@ -59,10 +74,82 @@
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 models hub
// 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(tagBaseModel?.split(PATH_SEPARATOR)[0] ?? 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;
});
return () => {
cancelled = true;
};
});
let capabilities = $derived.by(() => ({
reasoning: modelsStore.props.checkModelSupportsThinking(option.model),
tools: option.capabilities.includes(ModelCapability.TOOL_USE)
}));
// Params badge fallback: the id usually carries the count (`Qwen3-8B`), but
// ids like `Kimi-K3` do not. Prefer the loaded model's meta `n_params`, then
// the HF GGUF total (`gguf.total`), fetched lazily like the models hub list
// does, only when neither the id nor the meta has it.
let parsedParams = $derived(parsedId.params);
let metaParams = $derived.by(() => {
const value = option.meta?.n_params;
return typeof value === 'number' && value > 0 ? value : null;
});
// HF repo id: the `org/model` part without the `:revision/quant` suffix;
// local paths and bare names are not HF repos.
let hfRepoId = $derived.by(() => {
const [repo] = option.model.split(MODEL_ID.QUANTIZATION_SEPARATOR);
const normalized = normalizeModelName(repo ?? '');
return normalized.includes(MODEL_ID.ORG_SEPARATOR) ? normalized : null;
});
let fetchedParams = $state<number | null>(null);
$effect(() => {
fetchedParams = null;
if (parsedParams || metaParams || !hfRepoId) return;
let cancelled = false;
void HuggingFaceService.getDetails(hfRepoId).then((info) => {
if (!cancelled && info?.gguf?.total) fetchedParams = info.gguf.total;
});
return () => {
cancelled = true;
};
});
let paramsFallback = $derived(
!parsedParams && (metaParams ?? fetchedParams)
? formatParameters(metaParams ?? fetchedParams)
: undefined
);
</script>
<div
@@ -84,12 +171,24 @@
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="h-6 w-6"
/>
{/if}
<ModelId
aliases={option.aliases}
class="flex-1"
{hideOrgName}
{modalities}
modelId={option.model}
params={paramsFallback}
showRawTooltip
supportsThinking={capabilities.reasoning}
supportsToolUse={capabilities.tools}
@@ -0,0 +1,100 @@
<script lang="ts">
import { Check, ChevronDown, ChevronRight, Info, Lightbulb, LightbulbOff } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
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();
let expanded = $state(false);
</script>
<!-- Reasoning effort picker for the models dropdown footer. Unlike
ChatFormActionAddReasoningSubmenu, which opens a flyout submenu, this one
expands in place: pinned to the bottom of a scrolling menu, a flyout would
float over the very list it belongs to. -->
<Collapsible.Root class="min-w-0" onOpenChange={(open) => (expanded = open)} open={expanded}>
<!-- Rendered as a menu item so arrow keys, typeahead and Enter reach it, but
through the `child` snippet to keep Collapsible.Trigger's native button.
closeOnSelect keeps the menu open while the effort list is being used. -->
<DropdownMenu.Item
class="w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1.75 text-left text-sm"
closeOnSelect={false}
>
{#snippet child({ props })}
<!-- No `class` here on purpose: a static attribute would override the
spread props.class, and the button is the menu item itself (the `child`
snippet replaces Item's own div), so all styling must come from above. -->
<Collapsible.Trigger {...props}>
{#if expanded}
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{:else}
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{/if}
{#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-1 truncate">Reasoning</span>
<span class="shrink-0 text-xs capitalize text-muted-foreground">
{reasoning.currentEffort}
</span>
</Collapsible.Trigger>
{/snippet}
</DropdownMenu.Item>
<Collapsible.Content>
<!-- Collapsible.Content only toggles the `hidden` attribute, so gate on
`expanded`: collapsed, the effort rows leave both the menu item order
(bits-ui collects items by attribute) and the tab order. -->
{#if expanded}
<!-- Plain items, not a RadioGroup: the selected effort lives in the store,
so the check mark derives from it and must not be duplicated as group
state. -->
<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)}
<DropdownMenu.Item
class="flex w-full cursor-pointer gap-3 px-2 py-1.5"
closeOnSelect={false}
onSelect={() => 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="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}
</DropdownMenu.Item>
{/each}
</div>
{/if}
</Collapsible.Content>
</Collapsible.Root>
@@ -166,7 +166,6 @@
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>
@@ -4,7 +4,7 @@
import { HuggingFaceService } from '$lib/services';
interface Props {
class?: string;
class?: string;
/** Org whose avatar is shown (may differ from the repo's org for base models). */
org: string;
/** Repo's own org, shown as a small corner badge when provided. */
@@ -23,7 +23,7 @@
let {
baseImageClass = '',
class: className = '',
class: className = '',
org,
quantImageClass = 'h-full w-full',
quantOrg,
@@ -135,7 +135,11 @@
onclick={() => onSelect?.(model.id)}
type="button"
>
<ModelsDiscoverAvatar class="mt-1" org={avatarOrg} quantOrg={showBaseModelAvatar ? org : undefined} />
<ModelsDiscoverAvatar
class="mt-1"
org={avatarOrg}
quantOrg={showBaseModelAvatar ? org : undefined}
/>
<span class="min-w-0 flex-1">
<ModelId
@@ -65,6 +65,15 @@ export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
*/
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
*
@@ -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,46 @@
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">
<!-- Sticks to the top of the dropdown content's scrollport. -->
<div class="sticky top-0 z-20 px-1 pb-2 pt-2">
<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 +57,10 @@
</div>
{#if footer}
<DropdownMenu.Separator />
<!-- Sticks to the bottom of the dropdown content's scrollport. -->
<div class="sticky bottom-0 z-20 bg-popover px-1 pb-2">
<div class="-mx-1 mb-1 h-px bg-border/20"></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"
@@ -45,7 +45,7 @@
className
)}
data-slot="input"
style="backdrop-filter: blur(0.5rem);"
style="backdrop-filter: blur(1rem);"
{type}
{...restProps}
/>
@@ -20,9 +20,10 @@ 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
* thinking-support derivation and the effort selection logic.
* Used by the models dropdown footer (`ModelsSelectorReasoningPanel`), the chat
* add menu (`ChatFormActionAddReasoningSubmenu`) and the mobile sheet
* (`ChatFormActionAddSheet`) to avoid duplicating the thinking-support
* derivation and the effort selection logic.
*/
export function useReasoningMenu(): UseReasoningMenuReturn {
const conversationModel = $derived(
+4 -1
View File
@@ -28,6 +28,9 @@ export function formatFileSize(bytes: number | unknown): string {
/**
* Format parameter count to human-readable format (B, M, K)
*
* Billions render as whole numbers (`176.94e9` -> `177B`): the decimals are
* noise at badge sizes and id-parsed counts are whole anyway (`Qwen3-8B`).
*
* @param params - Parameter count
* @returns Human-readable parameter count
*/
@@ -35,7 +38,7 @@ export function formatParameters(params: number | unknown): string {
if (typeof params !== 'number') return 'Unknown';
if (params >= 1e9) {
return `${(params / 1e9).toFixed(2)}B`;
return `${Math.round(params / 1e9)}B`;
}
if (params >= 1e6) {