Add a search field on model selector / improve mobile display (#17765)

* webui: add search field to model selector and fixes mobile viewport overflow

* webui: simplify model search style and code

* refacor: Search Input component & consistent UI for Models Selector search

* feat: Use Popover component + improve interactions

* fix: Fetching props for only loaded models in ROUTER mode

* webui: prevent models selector popover from overflowing viewport

Use Floating UI's auto-positioning with 50dvh height limit and proper
collision detection instead of forcing top positioning. Fixes overflow
on desktop and mobile keyboard issues

* webui: keep search field near trigger in models selector

Place search at the 'near end' (closest to trigger) by swapping layout
with CSS flexbox order based on popover direction. Prevents input from
moving during typing as list shrinks

* chore: update webui build output

---------

Co-authored-by: Aleksander Grygier <aleksander.grygier@gmail.com>
This commit is contained in:
Pascal 2025-12-11 18:21:21 +01:00 committed by GitHub
parent 53ecd4fdb9
commit a81a569577
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 342 additions and 228 deletions

Binary file not shown.

View File

@ -41,7 +41,7 @@
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/node": "^22", "@types/node": "^22",
"@vitest/browser": "^3.2.3", "@vitest/browser": "^3.2.3",
"bits-ui": "^2.8.11", "bits-ui": "^2.14.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.0.11", "dexie": "^4.0.11",
"eslint": "^9.18.0", "eslint": "^9.18.0",
@ -3343,17 +3343,17 @@
} }
}, },
"node_modules/bits-ui": { "node_modules/bits-ui": {
"version": "2.8.11", "version": "2.14.4",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.8.11.tgz", "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.14.4.tgz",
"integrity": "sha512-lKN9rAk69my6j7H1D4B87r8LrHuEtfEsf1xCixBj9yViql2BdI3f04HyyyT7T1GOCpgb9+8b0B+nm3LN81Konw==", "integrity": "sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.7.1", "@floating-ui/core": "^1.7.1",
"@floating-ui/dom": "^1.7.1", "@floating-ui/dom": "^1.7.1",
"esm-env": "^1.1.2", "esm-env": "^1.1.2",
"runed": "^0.29.1", "runed": "^0.35.1",
"svelte-toolbelt": "^0.9.3", "svelte-toolbelt": "^0.10.6",
"tabbable": "^6.2.0" "tabbable": "^6.2.0"
}, },
"engines": { "engines": {
@ -3368,9 +3368,9 @@
} }
}, },
"node_modules/bits-ui/node_modules/runed": { "node_modules/bits-ui/node_modules/runed": {
"version": "0.29.2", "version": "0.35.1",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.29.2.tgz", "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz",
"integrity": "sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==", "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
"https://github.com/sponsors/huntabyte", "https://github.com/sponsors/huntabyte",
@ -3378,23 +3378,31 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esm-env": "^1.0.0" "dequal": "^2.0.3",
"esm-env": "^1.0.0",
"lz-string": "^1.5.0"
}, },
"peerDependencies": { "peerDependencies": {
"@sveltejs/kit": "^2.21.0",
"svelte": "^5.7.0" "svelte": "^5.7.0"
},
"peerDependenciesMeta": {
"@sveltejs/kit": {
"optional": true
}
} }
}, },
"node_modules/bits-ui/node_modules/svelte-toolbelt": { "node_modules/bits-ui/node_modules/svelte-toolbelt": {
"version": "0.9.3", "version": "0.10.6",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.9.3.tgz", "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
"integrity": "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw==", "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
"https://github.com/sponsors/huntabyte" "https://github.com/sponsors/huntabyte"
], ],
"dependencies": { "dependencies": {
"clsx": "^2.1.1", "clsx": "^2.1.1",
"runed": "^0.29.0", "runed": "^0.35.1",
"style-to-object": "^1.0.8" "style-to-object": "^1.0.8"
}, },
"engines": { "engines": {

View File

@ -43,7 +43,7 @@
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/node": "^22", "@types/node": "^22",
"@vitest/browser": "^3.2.3", "@vitest/browser": "^3.2.3",
"bits-ui": "^2.8.11", "bits-ui": "^2.14.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dexie": "^4.0.11", "dexie": "^4.0.11",
"eslint": "^9.18.0", "eslint": "^9.18.0",

View File

@ -331,6 +331,7 @@
class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {disabled
? 'cursor-not-allowed opacity-60' ? 'cursor-not-allowed opacity-60'
: ''} {className}" : ''} {className}"
data-slot="chat-form"
> >
<ChatAttachmentsList <ChatAttachmentsList
bind:uploadedFiles bind:uploadedFiles

View File

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Input } from '$lib/components/ui/input'; import { SearchInput } from '$lib/components/app';
import { Search } from '@lucide/svelte';
interface Props { interface Props {
value?: string; value?: string;
@ -15,19 +14,6 @@
onInput, onInput,
class: className class: className
}: Props = $props(); }: Props = $props();
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
value = target.value;
onInput?.(target.value);
}
</script> </script>
<div class="relative mb-4 {className}"> <SearchInput bind:value {placeholder} {onInput} class="mb-4 {className}" />
<Search
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
/>
<Input bind:value class="pl-10" oninput={handleInput} {placeholder} type="search" />
</div>

View File

@ -64,6 +64,7 @@ export { default as CopyToClipboardIcon } from './misc/CopyToClipboardIcon.svelt
export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte'; export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
export { default as MarkdownContent } from './misc/MarkdownContent.svelte'; export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
export { default as RemoveButton } from './misc/RemoveButton.svelte'; export { default as RemoveButton } from './misc/RemoveButton.svelte';
export { default as SearchInput } from './misc/SearchInput.svelte';
export { default as SyntaxHighlightedCode } from './misc/SyntaxHighlightedCode.svelte'; export { default as SyntaxHighlightedCode } from './misc/SyntaxHighlightedCode.svelte';
export { default as ModelsSelector } from './models/ModelsSelector.svelte'; export { default as ModelsSelector } from './models/ModelsSelector.svelte';

View File

@ -0,0 +1,73 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { Search, X } from '@lucide/svelte';
interface Props {
value?: string;
placeholder?: string;
onInput?: (value: string) => void;
onClose?: () => void;
onKeyDown?: (event: KeyboardEvent) => void;
class?: string;
id?: string;
ref?: HTMLInputElement | null;
}
let {
value = $bindable(''),
placeholder = 'Search...',
onInput,
onClose,
onKeyDown,
class: className,
id,
ref = $bindable(null)
}: Props = $props();
let showClearButton = $derived(!!value || !!onClose);
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
value = target.value;
onInput?.(target.value);
}
function handleClear() {
if (value) {
value = '';
onInput?.('');
ref?.focus();
} else {
onClose?.();
}
}
</script>
<div class="relative {className}">
<Search
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
/>
<Input
{id}
bind:value
bind:ref
class="pl-9 {showClearButton ? 'pr-9' : ''}"
oninput={handleInput}
onkeydown={onKeyDown}
{placeholder}
type="search"
/>
{#if showClearButton}
<button
type="button"
class="absolute top-1/2 right-3 -translate-y-1/2 transform text-muted-foreground transition-colors hover:text-foreground"
onclick={handleClear}
aria-label={value ? 'Clear search' : 'Close'}
>
<X class="h-4 w-4" />
</button>
{/if}
</div>

View File

@ -2,8 +2,8 @@
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/svelte'; import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import * as Popover from '$lib/components/ui/popover';
import { cn } from '$lib/components/ui/utils'; import { cn } from '$lib/components/ui/utils';
import { portalToBody } from '$lib/utils';
import { import {
modelsStore, modelsStore,
modelOptions, modelOptions,
@ -17,12 +17,8 @@
import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte'; import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte';
import { ServerModelStatus } from '$lib/enums'; import { ServerModelStatus } from '$lib/enums';
import { isRouterMode } from '$lib/stores/server.svelte'; import { isRouterMode } from '$lib/stores/server.svelte';
import { DialogModelInformation } from '$lib/components/app'; import { DialogModelInformation, SearchInput } from '$lib/components/app';
import { import type { ModelOption } from '$lib/types/models';
MENU_MAX_WIDTH,
MENU_OFFSET,
VIEWPORT_GUTTER
} from '$lib/constants/floating-ui-constraints';
interface Props { interface Props {
class?: string; class?: string;
@ -145,185 +141,126 @@
return options.some((option) => option.model === currentModel); return options.some((option) => option.model === currentModel);
}); });
let isOpen = $state(false); let searchTerm = $state('');
let showModelDialog = $state(false); let searchInputRef = $state<HTMLInputElement | null>(null);
let container: HTMLDivElement | null = null; let highlightedIndex = $state<number>(-1);
let menuRef = $state<HTMLDivElement | null>(null);
let triggerButton = $state<HTMLButtonElement | null>(null);
let menuPosition = $state<{
top: number;
left: number;
width: number;
placement: 'top' | 'bottom';
maxHeight: number;
} | null>(null);
onMount(async () => { let filteredOptions: ModelOption[] = $derived(
try { (() => {
await modelsStore.fetch(); const term = searchTerm.trim().toLowerCase();
} catch (error) { if (!term) return options;
console.error('Unable to load models:', error);
} return options.filter(
(option) =>
option.model.toLowerCase().includes(term) || option.name?.toLowerCase().includes(term)
);
})()
);
// Get indices of compatible options for keyboard navigation
let compatibleIndices = $derived(
filteredOptions
.map((option, index) => (isModelCompatible(option) ? index : -1))
.filter((i) => i !== -1)
);
// Reset highlighted index when search term changes
$effect(() => {
void searchTerm;
highlightedIndex = -1;
}); });
function toggleOpen() { let isOpen = $state(false);
if (loading || updating) return; let showModelDialog = $state(false);
if (isRouter) { onMount(() => {
// Router mode: show dropdown modelsStore.fetch().catch((error) => {
if (isOpen) { console.error('Unable to load models:', error);
closeMenu(); });
} else { });
openMenu();
} function handleOpenChange(open: boolean) {
} else {
// Single model mode: show dialog
showModelDialog = true;
}
}
async function openMenu() {
if (loading || updating) return; if (loading || updating) return;
if (open) {
isOpen = true; isOpen = true;
await tick(); searchTerm = '';
updateMenuPosition(); highlightedIndex = -1;
requestAnimationFrame(() => updateMenuPosition());
// Focus search input after popover opens
tick().then(() => {
requestAnimationFrame(() => searchInputRef?.focus());
});
if (isRouter) { if (isRouter) {
modelsStore.fetchRouterModels().then(() => { modelsStore.fetchRouterModels().then(() => {
modelsStore.fetchModalitiesForLoadedModels(); modelsStore.fetchModalitiesForLoadedModels();
}); });
} }
} else {
isOpen = false;
searchTerm = '';
highlightedIndex = -1;
}
}
function handleTriggerClick() {
if (loading || updating) return;
if (!isRouter) {
// Single model mode: show dialog instead of popover
showModelDialog = true;
}
// For router mode, the Popover handles open/close
} }
export function open() { export function open() {
if (isRouter) { if (isRouter) {
openMenu(); handleOpenChange(true);
} else { } else {
showModelDialog = true; showModelDialog = true;
} }
} }
function closeMenu() { function closeMenu() {
if (!isOpen) return; handleOpenChange(false);
isOpen = false;
menuPosition = null;
} }
function handlePointerDown(event: PointerEvent) { function handleSearchKeyDown(event: KeyboardEvent) {
if (!container) return; if (event.isComposing) return;
const target = event.target as Node | null; if (event.key === 'ArrowDown') {
event.preventDefault();
if (compatibleIndices.length === 0) return;
if (target && !container.contains(target) && !(menuRef && menuRef.contains(target))) { const currentPos = compatibleIndices.indexOf(highlightedIndex);
closeMenu(); if (currentPos === -1 || currentPos === compatibleIndices.length - 1) {
} highlightedIndex = compatibleIndices[0];
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeMenu();
}
}
function handleResize() {
if (isOpen) {
updateMenuPosition();
}
}
function updateMenuPosition() {
if (!isOpen || !triggerButton || !menuRef) return;
const triggerRect = triggerButton.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (viewportWidth === 0 || viewportHeight === 0) return;
const scrollWidth = menuRef.scrollWidth;
const scrollHeight = menuRef.scrollHeight;
const availableWidth = Math.max(0, viewportWidth - VIEWPORT_GUTTER * 2);
const constrainedMaxWidth = Math.min(MENU_MAX_WIDTH, availableWidth || MENU_MAX_WIDTH);
const safeMaxWidth =
constrainedMaxWidth > 0 ? constrainedMaxWidth : Math.min(MENU_MAX_WIDTH, viewportWidth);
const desiredMinWidth = Math.min(160, safeMaxWidth || 160);
let width = Math.min(
Math.max(triggerRect.width, scrollWidth, desiredMinWidth),
safeMaxWidth || 320
);
const availableBelow = Math.max(
0,
viewportHeight - VIEWPORT_GUTTER - triggerRect.bottom - MENU_OFFSET
);
const availableAbove = Math.max(0, triggerRect.top - VIEWPORT_GUTTER - MENU_OFFSET);
const viewportAllowance = Math.max(0, viewportHeight - VIEWPORT_GUTTER * 2);
const fallbackAllowance = Math.max(1, viewportAllowance > 0 ? viewportAllowance : scrollHeight);
function computePlacement(placement: 'top' | 'bottom') {
const available = placement === 'bottom' ? availableBelow : availableAbove;
const allowedHeight =
available > 0 ? Math.min(available, fallbackAllowance) : fallbackAllowance;
const maxHeight = Math.min(scrollHeight, allowedHeight);
const height = Math.max(0, maxHeight);
let top: number;
if (placement === 'bottom') {
const rawTop = triggerRect.bottom + MENU_OFFSET;
const minTop = VIEWPORT_GUTTER;
const maxTop = viewportHeight - VIEWPORT_GUTTER - height;
if (maxTop < minTop) {
top = minTop;
} else { } else {
top = Math.min(Math.max(rawTop, minTop), maxTop); highlightedIndex = compatibleIndices[currentPos + 1];
} }
} else if (event.key === 'ArrowUp') {
event.preventDefault();
if (compatibleIndices.length === 0) return;
const currentPos = compatibleIndices.indexOf(highlightedIndex);
if (currentPos === -1 || currentPos === 0) {
highlightedIndex = compatibleIndices[compatibleIndices.length - 1];
} else { } else {
const rawTop = triggerRect.top - MENU_OFFSET - height; highlightedIndex = compatibleIndices[currentPos - 1];
const minTop = VIEWPORT_GUTTER; }
const maxTop = viewportHeight - VIEWPORT_GUTTER - height; } else if (event.key === 'Enter') {
if (maxTop < minTop) { event.preventDefault();
top = minTop; if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
} else { const option = filteredOptions[highlightedIndex];
top = Math.max(Math.min(rawTop, maxTop), minTop); if (isModelCompatible(option)) {
handleSelect(option.id);
}
} else if (compatibleIndices.length > 0) {
// No selection - highlight first compatible option
highlightedIndex = compatibleIndices[0];
} }
} }
return { placement, top, height, maxHeight };
}
const belowMetrics = computePlacement('bottom');
const aboveMetrics = computePlacement('top');
let metrics = belowMetrics;
if (scrollHeight > belowMetrics.maxHeight && aboveMetrics.maxHeight > belowMetrics.maxHeight) {
metrics = aboveMetrics;
}
let left = triggerRect.right - width;
const maxLeft = viewportWidth - VIEWPORT_GUTTER - width;
if (maxLeft < VIEWPORT_GUTTER) {
left = VIEWPORT_GUTTER;
} else {
if (left > maxLeft) {
left = maxLeft;
}
if (left < VIEWPORT_GUTTER) {
left = VIEWPORT_GUTTER;
}
}
menuPosition = {
top: Math.round(metrics.top),
left: Math.round(left),
width: Math.round(width),
placement: metrics.placement,
maxHeight: Math.round(metrics.maxHeight)
};
} }
async function handleSelect(modelId: string) { async function handleSelect(modelId: string) {
@ -356,6 +293,14 @@
if (shouldCloseMenu) { if (shouldCloseMenu) {
closeMenu(); closeMenu();
// Focus the chat textarea after model selection
requestAnimationFrame(() => {
const textarea = document.querySelector<HTMLTextAreaElement>(
'[data-slot="chat-form"] textarea'
);
textarea?.focus();
});
} }
} }
@ -404,10 +349,7 @@
} }
</script> </script>
<svelte:window onresize={handleResize} /> <div class={cn('relative inline-flex flex-col items-end gap-1', className)}>
<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
<div class={cn('relative inline-flex flex-col items-end gap-1', className)} bind:this={container}>
{#if loading && options.length === 0 && isRouter} {#if loading && options.length === 0 && isRouter}
<div class="flex items-center gap-2 text-xs text-muted-foreground"> <div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" /> <Loader2 class="h-3.5 w-3.5 animate-spin" />
@ -418,9 +360,8 @@
{:else} {:else}
{@const selectedOption = getDisplayOption()} {@const selectedOption = getDisplayOption()}
<div class="relative"> <Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
<button <Popover.Trigger
type="button"
class={cn( class={cn(
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`, `inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
!isCurrentModelInCache() !isCurrentModelInCache()
@ -430,15 +371,11 @@
: isHighlightedCurrentModelActive : isHighlightedCurrentModelActive
? 'text-foreground' ? 'text-foreground'
: 'text-muted-foreground', : 'text-muted-foreground',
isOpen ? 'text-foreground' : '', isOpen ? 'text-foreground' : ''
className
)} )}
style="max-width: min(calc(100cqw - 6.5rem), 32rem)" style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
aria-haspopup={isRouter ? 'listbox' : undefined} onclick={handleTriggerClick}
aria-expanded={isRouter ? isOpen : undefined} disabled={disabled || updating || !isRouter}
onclick={toggleOpen}
bind:this={triggerButton}
disabled={disabled || updating}
> >
<Package class="h-3.5 w-3.5" /> <Package class="h-3.5 w-3.5" />
@ -451,33 +388,35 @@
{:else if isRouter} {:else if isRouter}
<ChevronDown class="h-3 w-3.5" /> <ChevronDown class="h-3 w-3.5" />
{/if} {/if}
</button> </Popover.Trigger>
{#if isOpen && isRouter} <Popover.Content
<div class="group/popover-content w-96 max-w-[calc(100vw-2rem)] p-0"
bind:this={menuRef} align="end"
use:portalToBody sideOffset={8}
class={cn( collisionPadding={16}
'fixed z-[1000] overflow-hidden rounded-md border bg-popover shadow-lg transition-opacity',
menuPosition ? 'opacity-100' : 'pointer-events-none opacity-0'
)}
role="listbox"
style:top={menuPosition ? `${menuPosition.top}px` : undefined}
style:left={menuPosition ? `${menuPosition.left}px` : undefined}
style:width={menuPosition ? `${menuPosition.width}px` : undefined}
data-placement={menuPosition?.placement ?? 'bottom'}
> >
<div class="flex max-h-[50dvh] flex-col overflow-hidden">
<div <div
class="overflow-y-auto py-1" class="order-1 shrink-0 border-b p-4 group-data-[side=top]/popover-content:order-2 group-data-[side=top]/popover-content:border-t group-data-[side=top]/popover-content:border-b-0"
style:max-height={menuPosition && menuPosition.maxHeight > 0 >
? `${menuPosition.maxHeight}px` <SearchInput
: undefined} id="model-search"
placeholder="Search models..."
bind:value={searchTerm}
bind:ref={searchInputRef}
onClose={closeMenu}
onKeyDown={handleSearchKeyDown}
/>
</div>
<div
class="models-list order-2 min-h-0 flex-1 overflow-y-auto group-data-[side=top]/popover-content:order-1"
> >
{#if !isCurrentModelInCache() && currentModel} {#if !isCurrentModelInCache() && currentModel}
<!-- Show unavailable model as first option (disabled) --> <!-- Show unavailable model as first option (disabled) -->
<button <button
type="button" type="button"
class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-3 py-2 text-left text-sm text-red-400" class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-4 py-2 text-left text-sm text-red-400"
role="option" role="option"
aria-selected="true" aria-selected="true"
aria-disabled="true" aria-disabled="true"
@ -488,20 +427,25 @@
</button> </button>
<div class="my-1 h-px bg-border"></div> <div class="my-1 h-px bg-border"></div>
{/if} {/if}
{#each options as option (option.id)} {#if filteredOptions.length === 0}
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
{/if}
{#each filteredOptions as option, index (option.id)}
{@const status = getModelStatus(option.model)} {@const status = getModelStatus(option.model)}
{@const isLoaded = status === ServerModelStatus.LOADED} {@const isLoaded = status === ServerModelStatus.LOADED}
{@const isLoading = status === ServerModelStatus.LOADING} {@const isLoading = status === ServerModelStatus.LOADING}
{@const isSelected = currentModel === option.model || activeId === option.id} {@const isSelected = currentModel === option.model || activeId === option.id}
{@const isCompatible = isModelCompatible(option)} {@const isCompatible = isModelCompatible(option)}
{@const isHighlighted = index === highlightedIndex}
{@const missingModalities = getMissingModalities(option)} {@const missingModalities = getMissingModalities(option)}
<div <div
class={cn( class={cn(
'group flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition focus:outline-none', 'group flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition focus:outline-none',
isCompatible isCompatible
? 'cursor-pointer hover:bg-muted focus:bg-muted' ? 'cursor-pointer hover:bg-muted focus:bg-muted'
: 'cursor-not-allowed opacity-50', : 'cursor-not-allowed opacity-50',
isSelected isSelected || isHighlighted
? 'bg-accent text-accent-foreground' ? 'bg-accent text-accent-foreground'
: isCompatible : isCompatible
? 'hover:bg-accent hover:text-accent-foreground' ? 'hover:bg-accent hover:text-accent-foreground'
@ -509,10 +453,11 @@
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground' isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
)} )}
role="option" role="option"
aria-selected={isSelected} aria-selected={isSelected || isHighlighted}
aria-disabled={!isCompatible} aria-disabled={!isCompatible}
tabindex={isCompatible ? 0 : -1} tabindex={isCompatible ? 0 : -1}
onclick={() => isCompatible && handleSelect(option.id)} onclick={() => isCompatible && handleSelect(option.id)}
onmouseenter={() => (highlightedIndex = index)}
onkeydown={(e) => { onkeydown={(e) => {
if (isCompatible && (e.key === 'Enter' || e.key === ' ')) { if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault(); e.preventDefault();
@ -586,8 +531,8 @@
{/each} {/each}
</div> </div>
</div> </div>
{/if} </Popover.Content>
</div> </Popover.Root>
{/if} {/if}
</div> </div>

View File

@ -0,0 +1,19 @@
import Root from './popover.svelte';
import Close from './popover-close.svelte';
import Content from './popover-content.svelte';
import Trigger from './popover-trigger.svelte';
import Portal from './popover-portal.svelte';
export {
Root,
Content,
Trigger,
Close,
Portal,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
Portal as PopoverPortal
};

View File

@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script>
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />

View File

@ -0,0 +1,37 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
import PopoverPortal from './popover-portal.svelte';
import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
side,
align = 'center',
collisionPadding = 8,
avoidCollisions = true,
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
} = $props();
</script>
<PopoverPortal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{side}
{align}
{collisionPadding}
{avoidCollisions}
class={cn(
'z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
{...restProps}
/>
</PopoverPortal>

View File

@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />

View File

@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$lib/components/ui/utils.js';
import { Popover as PopoverPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn('', className)}
{...restProps}
/>

View File

@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script>
<PopoverPrimitive.Root bind:open {...restProps} />

View File

@ -1,3 +1,2 @@
export const VIEWPORT_GUTTER = 8; export const VIEWPORT_GUTTER = 8;
export const MENU_OFFSET = 6; export const MENU_OFFSET = 6;
export const MENU_MAX_WIDTH = 320;

View File

@ -295,14 +295,21 @@ class ModelsStore {
* Fetch props for a specific model from /props endpoint * Fetch props for a specific model from /props endpoint
* Uses caching to avoid redundant requests * Uses caching to avoid redundant requests
* *
* In ROUTER mode, this will only fetch props if the model is loaded,
* since unloaded models return 400 from /props endpoint.
*
* @param modelId - Model identifier to fetch props for * @param modelId - Model identifier to fetch props for
* @returns Props data or null if fetch failed * @returns Props data or null if fetch failed or model not loaded
*/ */
async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> { async fetchModelProps(modelId: string): Promise<ApiLlamaCppServerProps | null> {
// Return cached props if available // Return cached props if available
const cached = this.modelPropsCache.get(modelId); const cached = this.modelPropsCache.get(modelId);
if (cached) return cached; if (cached) return cached;
if (serverStore.isRouterMode && !this.isModelLoaded(modelId)) {
return null;
}
// Avoid duplicate fetches // Avoid duplicate fetches
if (this.modelPropsFetching.has(modelId)) return null; if (this.modelPropsFetching.has(modelId)) return null;