From 3628213ea71a2632fced455c37f66f2798bb3343 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Tue, 1 Sep 2026 18:23:38 +0200 Subject: [PATCH] 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 --- .../app/models/ModelsSelectorDropdown.svelte | 226 ++++++++---------- .../app/models/ModelsSelectorList.svelte | 17 +- .../app/models/ModelsSelectorOption.svelte | 105 +++++++- .../ModelsSelectorReasoningPanel.svelte | 100 ++++++++ .../app/models/ModelsSelectorSheet.svelte | 1 - .../discover/ModelsDiscoverAvatar.svelte | 4 +- .../discover/ModelsDiscoverListItem.svelte | 6 +- .../ui/src/lib/components/app/models/index.ts | 9 + .../navigation/DropdownMenuSearchable.svelte | 26 +- .../dropdown-menu-content.svelte | 2 +- .../src/lib/components/ui/input/input.svelte | 2 +- .../lib/hooks/use-reasoning-menu.svelte.ts | 7 +- tools/ui/src/lib/utils/formatters.ts | 5 +- 13 files changed, 358 insertions(+), 152 deletions(-) create mode 100644 tools/ui/src/lib/components/app/models/ModelsSelectorReasoningPanel.svelte diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte index 415329c8c2..bc534230b9 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelectorDropdown.svelte @@ -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(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('[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( - '[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)" > @@ -266,105 +257,94 @@ event.preventDefault()} > - - - - - {#if selectedOption} - - {:else} - No model - {/if} - - - - ms.setSearchTerm(v)} - onSearchKeyDown={handleSearchKeyDown} - placeholder="Search models..." - searchValue={ms.searchTerm} - > -
- {#if !ms.isCurrentModelInCache && currentModel} - - - {/if} - - {#if ms.filteredOptions.length === 0} -

No models found.

- {/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)} - - { - 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} - - -
-
-
-
- - - - - - (modelsHubOpen = true)} + ms.setSearchTerm(v)} + onSearchKeyDown={handleSearchKeyDown} + placeholder="Search models..." + searchClass="bg-transparent" + searchValue={ms.searchTerm} > - + +
+ {#if !ms.isCurrentModelInCache && currentModel} + + + {/if} + + {#if ms.filteredOptions.length === 0} +

No models found.

+ {/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)} + + { + 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} + + +
+ + {#snippet footer()} + +
+ + + (modelsHubOpen = true)} + > + + + Discover models + +
+ {/snippet} +
{:else} diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte index d6fdad87cd..370474f4c4 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte @@ -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); -{#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)} {}} {onSelect} {option} + showBaseModelAvatar /> {/snippet} {#if groups.loaded.length > 0} -

Loaded models

+

Loaded models

{#each groups.loaded as item (`loaded-${item.option.id}`)} {@render render(item, false)} @@ -62,13 +61,9 @@ {/if} {#if groups.available.length > 0} -

Available models

+

Downloaded models

{#each groups.available as group (group.orgName)} - {#if group.orgName} -

{group.orgName}

- {/if} - {#each group.items as item (item.option.id)} {@render render(item, true)} {/each} diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte index cee7d0c3b5..113278737e 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelectorOption.svelte @@ -1,4 +1,5 @@
+ {#if orgName} + + {/if} + + 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); + + + + (expanded = open)} open={expanded}> + + + {#snippet child({ props })} + + + {#if expanded} + + {:else} + + {/if} + + {#if reasoning.isReasoningActive} + + {:else if reasoning.isOff} + + {:else} + + {/if} + + Reasoning + + + {reasoning.currentEffort} + + + {/snippet} + + + + + {#if expanded} + +
+ {#each reasoning.levels as level (level.value)} + {@const tokenLabel = reasoning.tokenLabel(level)} + reasoning.select(level)} + > + {#if reasoning.isSelected(level)} + + {:else} +
+ {/if} + + {level.label} + + {#if tokenLabel} + + {tokenLabel} + + {/if} + + {#if level.hasInfo} + + + + + + +

Maximum reasoning effort with extended context usage

+
+
+ {/if} +
+ {/each} +
+ {/if} +
+
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte b/tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte index c89ca186e3..5757537e24 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte @@ -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" />
diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverAvatar.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverAvatar.svelte index ec575d1e12..8321039d80 100644 --- a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverAvatar.svelte +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverAvatar.svelte @@ -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, diff --git a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverListItem.svelte b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverListItem.svelte index f6f12726bd..b83978f69e 100644 --- a/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverListItem.svelte +++ b/tools/ui/src/lib/components/app/models/discover/ModelsDiscoverListItem.svelte @@ -135,7 +135,11 @@ onclick={() => onSelect?.(model.id)} type="button" > - + 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(); -
+ +
-
+
{@render children()} {#if isEmpty} @@ -44,7 +57,10 @@
{#if footer} - + +
+
- {@render footer()} + {@render footer()} +
{/if} diff --git a/tools/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/tools/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte index 014e85b58b..83f54958f6 100644 --- a/tools/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte +++ b/tools/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -17,7 +17,7 @@ diff --git a/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts b/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts index 3c52d7ab27..245e765dbb 100644 --- a/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts +++ b/tools/ui/src/lib/hooks/use-reasoning-menu.svelte.ts @@ -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( diff --git a/tools/ui/src/lib/utils/formatters.ts b/tools/ui/src/lib/utils/formatters.ts index 27555a47be..f9f6477dcb 100644 --- a/tools/ui/src/lib/utils/formatters.ts +++ b/tools/ui/src/lib/utils/formatters.ts @@ -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) {