From 2f524018e47b6e65c2d6d1c00a3e618c889e6770 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Mon, 28 Sep 2026 15:58:56 +0200 Subject: [PATCH] ui : group the model selector components The selector surfaces move under models/ModelsSelector with their own barrel: the dropdown and sheet relocate, the list, option and trigger icon split out, and the shared list helpers join the navigation utils. The searchable dropdown gains a sticky footer and per-surface class hooks. Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash --- tools/ui/src/lib/components/app/misc/index.ts | 1 + .../app/models/ModelCapabilities.svelte | 3 +- .../components/app/models/ModelContext.svelte | 3 +- .../ModelsSelectorDropdown.svelte | 353 ++++++++++-------- .../ModelsSelector/ModelsSelectorList.svelte | 268 +++++++++++++ .../ModelsSelectorOption.svelte | 136 +++++++ .../ModelsSelectorSheet.svelte | 124 ++++-- .../ModelsSelectorTriggerIcon.svelte | 23 ++ .../app/models/ModelsSelector/index.ts | 109 ++++++ .../app/models/ModelsSelectorList.svelte | 76 ---- .../ui/src/lib/components/app/models/index.ts | 75 +--- .../ui/src/lib/components/app/models/utils.ts | 101 ----- .../navigation/DropdownMenuSearchable.svelte | 43 ++- .../lib/components/app/navigation/utils.ts | 197 ++++++++++ .../dropdown-menu-content.svelte | 2 +- .../stories/ModelsSelector.stories.svelte | 23 +- 16 files changed, 1075 insertions(+), 462 deletions(-) rename tools/ui/src/lib/components/app/models/{ => ModelsSelector}/ModelsSelectorDropdown.svelte (52%) create mode 100644 tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte create mode 100644 tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte rename tools/ui/src/lib/components/app/models/{ => ModelsSelector}/ModelsSelectorSheet.svelte (62%) create mode 100644 tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorTriggerIcon.svelte create mode 100644 tools/ui/src/lib/components/app/models/ModelsSelector/index.ts delete mode 100644 tools/ui/src/lib/components/app/models/ModelsSelectorList.svelte delete mode 100644 tools/ui/src/lib/components/app/models/utils.ts create mode 100644 tools/ui/src/lib/components/app/navigation/utils.ts diff --git a/tools/ui/src/lib/components/app/misc/index.ts b/tools/ui/src/lib/components/app/misc/index.ts index e695f242e4..9c48eadc3d 100644 --- a/tools/ui/src/lib/components/app/misc/index.ts +++ b/tools/ui/src/lib/components/app/misc/index.ts @@ -59,6 +59,7 @@ export { default as CodeBlockActions } from './CodeBlockActions.svelte'; * `class` and `style` props and inherits color via `currentColor`. */ export { default as Logo } from './Logo.svelte'; + /** * **CollapsibleSection** - Section with a trigger that expands in place * diff --git a/tools/ui/src/lib/components/app/models/ModelCapabilities.svelte b/tools/ui/src/lib/components/app/models/ModelCapabilities.svelte index 67cccade4c..295947da3c 100644 --- a/tools/ui/src/lib/components/app/models/ModelCapabilities.svelte +++ b/tools/ui/src/lib/components/app/models/ModelCapabilities.svelte @@ -1,5 +1,6 @@
- {#if ms.loading && ms.options.length === 0 && ms.isRouter} + {#if ms.loading && ms.options.length === 0 && ms.isMultiModel}
Loading models…
- {:else if ms.options.length === 0 && ms.isRouter} + {:else if ms.options.length === 0 && ms.isMultiModel} {#if currentModel} - + {:else} -

No models available.

+ {/if} {:else} - {@const selectedOption = ms.getDisplayOption()} - {@const triggerModel = selectedOption?.model} {@const triggerStatus = triggerModel ? modelsStore.routerModels.find((m) => m.id === triggerModel)?.status?.value : undefined} @@ -224,7 +252,7 @@ ? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100) : 0} - {#if ms.isRouter} + {#if ms.isMultiModel} @@ -234,35 +262,36 @@ {...props} class={[ `relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`, - !ms.isCurrentModelInCache - ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400' - : forceForegroundText - ? 'text-foreground' - : ms.isHighlightedCurrentModelActive + error + ? 'border-destructive/40 bg-destructive/10 !text-destructive hover:bg-destructive/20' + : !ms.isCurrentModelInCache + ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400' + : forceForegroundText ? 'text-foreground' - : 'text-foreground', + : ms.isHighlightedCurrentModelActive + ? 'text-foreground' + : 'text-foreground', isOpen && 'text-foreground', 'max-w-[min(calc(100vw-4rem) md:max-w-[min(calc(100cqw-9rem),25rem)]' ]} disabled={disabled || ms.updating} > - + - + {#if selectedOption} {:else} Select model {/if} - - {#if reasoning.isReasoningActive} - - {/if} {#if ms.updating || ms.isLoadingModel} @@ -287,94 +316,98 @@ 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} - - ms.setSearchTerm(v)} + onSearchKeyDown={handleSearchKeyDown} + placeholder="Search models..." + searchClass="bg-transparent" + searchValue={ms.searchTerm} + > + +
+ {#if !ms.isCurrentModelInCache && currentModel} + +
- - - - + (not available) + + {/if} + + {#if ms.isEmpty} +

{ms.emptyMessage}

+ {/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={!settingsStore.config.groupModelsByFamily} + showRepoOrgAvatar={settingsStore.config.groupModelsByFamily} + /> + {/snippet} + + +
+ + {#snippet footer()} + + + + + + Manage models + + + {/snippet} +
{:else} @@ -387,7 +420,7 @@ class={[ `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`, !ms.isCurrentModelInCache - ? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400' + ? 'bg-red-400/10 text-red-400! hover:bg-red-400/20 hover:text-red-400' : forceForegroundText ? 'text-foreground' : ms.isHighlightedCurrentModelActive @@ -399,21 +432,20 @@ onclick={() => ms.handleOpenChange(true)} style="max-width: min(calc(100cqw - 6.5rem), 32rem)" > - + {#if selectedOption} {/if} - {#if reasoning.isReasoningActive} - - {/if} - {#if ms.updating} {/if} @@ -423,18 +455,15 @@ {#if selectedOption} -

{triggerTooltipLabel(selectedOption)}

-
+

{triggerTooltipLabel(selectedOption)}

+ {/if} {/if} {/if}
-{#if ms.showModelDialog} - ms.setShowModelDialog(v)} - open={ms.showModelDialog} - /> -{/if} + void ms.showBackendModels(backend.id)} +/> diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte new file mode 100644 index 0000000000..4d58b89b7e --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte @@ -0,0 +1,268 @@ + + +{#snippet familyHeading({ group: family }: { group: ModelFamilyGroup })} +
+ + + + {family.label} + + + + {family.entries.length} model{family.entries.length === 1 ? '' : 's'} + +
+{/snippet} + +{#snippet listItem({ depth, entry }: { depth: number; entry: ModelItem })} +
{@render render(entry, !showOrgName)}
+{/snippet} + +{#snippet listRows(items: ModelItem[], prefix: string)} + {#if settingsStore.config.groupModelsByFamily} + row.option.model).map((family) => ({ + entries: family.entries, + group: family, + key: `${prefix}-${family.key}` + }))} + item={listItem} + keyOf={(row) => `${prefix}-${row.option.id}`} + stickyStyle="top: calc(var(--dropdown-sticky-height, 0px) + 2.25rem - 1px)" + /> + {:else} + `${prefix}-${row.option.id}`} /> + {/if} +{/snippet} + +{#snippet defaultOption(item: ModelItem, hideOrgName: boolean)} + {@const { option } = item} + {@const isSelected = currentModel === option.model || activeId === option.id} + {@const isFav = modelsStore.favoriteModelIds.has(option.model)} + + {}} + onMouseEnter={() => {}} + {onSelect} + {option} + showBaseModelAvatar={!settingsStore.config.groupModelsByFamily} + showRepoOrgAvatar={settingsStore.config.groupModelsByFamily} + /> +{/snippet} + +{#if loaded.length > 0} + + {#snippet icon()} + + {/snippet} + + {#each loaded as item (item.option.id)} + {@render render(item, !showOrgName)} + {/each} + +{/if} + +{#if favorites.length > 0} + + + {#snippet icon()} + + {/snippet} + + {#each favorites as item (`fav-${item.option.id}`)} + {@render render(item, !showOrgName)} + {/each} + +{/if} + +{#if getDownloadEntries.length > 0} +

Download in progress

+ + {#each getDownloadEntries as entry (entry.repoWithTag)} + + {/each} +{/if} + +{#snippet localRows()} + + {#each localGroups.loaded as item (`loaded-${item.option.id}`)} + {@render render(item, !showOrgName)} + {/each} + + {@render listRows( + localGroups.available.flatMap((group) => group.items), + 'local' + )} + + {#if hasMoreLocal} + + {/if} +{/snippet} + +{#if groups.loaded.length > 0 || groups.available.length > 0} + + {#snippet icon()} + + {/snippet} + + {@render localRows()} + +{/if} + + +{#each groups.providers as provider (provider.backendId)} + + {#if provider.items.length > 0} + {@render listRows(provider.items, provider.backendId)} + + {#if onProviderOpen && provider.matched > provider.items.length} + + + {/if} + {:else if provider.catalog === 0} +

+ {provider.error ?? (provider.loading ? 'Loading models...' : 'No models')} +

+ {/if} +
+{/each} + + (cancelOpen = false)} + open={cancelOpen} + repoWithTag={pendingCancel} +/> diff --git a/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte new file mode 100644 index 0000000000..071dc145ae --- /dev/null +++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte @@ -0,0 +1,136 @@ + + +
onSelect(option.id)} + onkeydown={onKeyDown} + onmouseenter={onMouseEnter} + role="option" + tabindex="0" + title={loadTitle} +> + + + + + {#if showCapabilities} + + {/if} + +
+ + + +
+ + {#if isLoading} + + {/if} +
diff --git a/tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorSheet.svelte similarity index 62% rename from tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte rename to tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorSheet.svelte index c89ca186e3..74a8c42f50 100644 --- a/tools/ui/src/lib/components/app/models/ModelsSelectorSheet.svelte +++ b/tools/ui/src/lib/components/app/models/ModelsSelector/ModelsSelectorSheet.svelte @@ -1,24 +1,32 @@
- {#if ms.loading && ms.options.length === 0 && ms.isRouter} + {#if ms.loading && ms.options.length === 0 && ms.isMultiModel}
Loading models…
- {:else if ms.options.length === 0 && ms.isRouter} -

No models available.

+ {:else if ms.options.length === 0 && ms.isMultiModel} + {:else} {@const selectedOption = ms.getDisplayOption()} {@const triggerModel = selectedOption?.model} @@ -77,17 +110,19 @@ ? Math.round(modelLoadFraction(modelsStore.status.getLoadProgress(triggerModel)) * 100) : 0} - {#if ms.isRouter} + {#if ms.isMultiModel}
+ +
+ +
@@ -177,21 +227,28 @@