]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
Add a search field on model selector / improve mobile display (#17765)
authorPascal <redacted>
Thu, 11 Dec 2025 17:21:21 +0000 (18:21 +0100)
committerGitHub <redacted>
Thu, 11 Dec 2025 17:21:21 +0000 (18:21 +0100)
* 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 <redacted>
16 files changed:
tools/server/public/index.html.gz
tools/server/webui/package-lock.json
tools/server/webui/package.json
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte
tools/server/webui/src/lib/components/app/index.ts
tools/server/webui/src/lib/components/app/misc/SearchInput.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte
tools/server/webui/src/lib/components/ui/popover/index.ts [new file with mode: 0644]
tools/server/webui/src/lib/components/ui/popover/popover-close.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/ui/popover/popover-content.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/ui/popover/popover-portal.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/ui/popover/popover-trigger.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/ui/popover/popover.svelte [new file with mode: 0644]
tools/server/webui/src/lib/constants/floating-ui-constraints.ts
tools/server/webui/src/lib/stores/models.svelte.ts

index dda053498a73d759f8695743cf9e0db816ba7255..4cff76429e17e3278492ced5821f896eaf94decb 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index 9c1c2499cfdd17f81d660a717ee67cb55f3c8f78..4f37b308b1348d51abb7e788ed0c9c112b4e079f 100644 (file)
@@ -41,7 +41,7 @@
                                "@tailwindcss/vite": "^4.0.0",
                                "@types/node": "^22",
                                "@vitest/browser": "^3.2.3",
-                               "bits-ui": "^2.8.11",
+                               "bits-ui": "^2.14.4",
                                "clsx": "^2.1.1",
                                "dexie": "^4.0.11",
                                "eslint": "^9.18.0",
                        }
                },
                "node_modules/bits-ui": {
-                       "version": "2.8.11",
-                       "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.8.11.tgz",
-                       "integrity": "sha512-lKN9rAk69my6j7H1D4B87r8LrHuEtfEsf1xCixBj9yViql2BdI3f04HyyyT7T1GOCpgb9+8b0B+nm3LN81Konw==",
+                       "version": "2.14.4",
+                       "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.14.4.tgz",
+                       "integrity": "sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg==",
                        "dev": true,
                        "license": "MIT",
                        "dependencies": {
                                "@floating-ui/core": "^1.7.1",
                                "@floating-ui/dom": "^1.7.1",
                                "esm-env": "^1.1.2",
-                               "runed": "^0.29.1",
-                               "svelte-toolbelt": "^0.9.3",
+                               "runed": "^0.35.1",
+                               "svelte-toolbelt": "^0.10.6",
                                "tabbable": "^6.2.0"
                        },
                        "engines": {
                        }
                },
                "node_modules/bits-ui/node_modules/runed": {
-                       "version": "0.29.2",
-                       "resolved": "https://registry.npmjs.org/runed/-/runed-0.29.2.tgz",
-                       "integrity": "sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==",
+                       "version": "0.35.1",
+                       "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz",
+                       "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==",
                        "dev": true,
                        "funding": [
                                "https://github.com/sponsors/huntabyte",
                        ],
                        "license": "MIT",
                        "dependencies": {
-                               "esm-env": "^1.0.0"
+                               "dequal": "^2.0.3",
+                               "esm-env": "^1.0.0",
+                               "lz-string": "^1.5.0"
                        },
                        "peerDependencies": {
+                               "@sveltejs/kit": "^2.21.0",
                                "svelte": "^5.7.0"
+                       },
+                       "peerDependenciesMeta": {
+                               "@sveltejs/kit": {
+                                       "optional": true
+                               }
                        }
                },
                "node_modules/bits-ui/node_modules/svelte-toolbelt": {
-                       "version": "0.9.3",
-                       "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.9.3.tgz",
-                       "integrity": "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw==",
+                       "version": "0.10.6",
+                       "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
+                       "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==",
                        "dev": true,
                        "funding": [
                                "https://github.com/sponsors/huntabyte"
                        ],
                        "dependencies": {
                                "clsx": "^2.1.1",
-                               "runed": "^0.29.0",
+                               "runed": "^0.35.1",
                                "style-to-object": "^1.0.8"
                        },
                        "engines": {
index 987a7239ed45804ca8dfab4220fad09c4a6cc7cf..c20ab3cfde0e914c38bb4fcf83b5ea42dc19224f 100644 (file)
@@ -43,7 +43,7 @@
                "@tailwindcss/vite": "^4.0.0",
                "@types/node": "^22",
                "@vitest/browser": "^3.2.3",
-               "bits-ui": "^2.8.11",
+               "bits-ui": "^2.14.4",
                "clsx": "^2.1.1",
                "dexie": "^4.0.11",
                "eslint": "^9.18.0",
index 7f8e38286d268362d9f3461a3e9eba415882e8a9..78cc1c47daa5e6b9690b6c767e22536955003865 100644 (file)
        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'
                : ''} {className}"
+       data-slot="chat-form"
 >
        <ChatAttachmentsList
                bind:uploadedFiles
index c9e6c6616a2aab0f6cb7b1fdef6f9cf153b32bc1..afc98470283af85e11c88e4f57fa8a30e574c23e 100644 (file)
@@ -1,6 +1,5 @@
 <script lang="ts">
-       import { Input } from '$lib/components/ui/input';
-       import { Search } from '@lucide/svelte';
+       import { SearchInput } from '$lib/components/app';
 
        interface Props {
                value?: string;
                onInput,
                class: className
        }: Props = $props();
-
-       function handleInput(event: Event) {
-               const target = event.target as HTMLInputElement;
-
-               value = target.value;
-               onInput?.(target.value);
-       }
 </script>
 
-<div class="relative 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>
+<SearchInput bind:value {placeholder} {onInput} class="mb-4 {className}" />
index 87b24598b722f168e134115453aebdf95bd0aa1d..8631d4fb3bd4c3040fc93bac457bf3c8a98cb87f 100644 (file)
@@ -64,6 +64,7 @@ export { default as CopyToClipboardIcon } from './misc/CopyToClipboardIcon.svelt
 export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
 export { default as MarkdownContent } from './misc/MarkdownContent.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 ModelsSelector } from './models/ModelsSelector.svelte';
 
diff --git a/tools/server/webui/src/lib/components/app/misc/SearchInput.svelte b/tools/server/webui/src/lib/components/app/misc/SearchInput.svelte
new file mode 100644 (file)
index 0000000..15cd6ab
--- /dev/null
@@ -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>
index c4331e92f1369c0792c0f099c9c557dfa3ac8bf3..ac0937696d449c777d21c2ae333bacc885783821 100644 (file)
@@ -2,8 +2,8 @@
        import { onMount, tick } from 'svelte';
        import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/svelte';
        import * as Tooltip from '$lib/components/ui/tooltip';
+       import * as Popover from '$lib/components/ui/popover';
        import { cn } from '$lib/components/ui/utils';
-       import { portalToBody } from '$lib/utils';
        import {
                modelsStore,
                modelOptions,
        import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte';
        import { ServerModelStatus } from '$lib/enums';
        import { isRouterMode } from '$lib/stores/server.svelte';
-       import { DialogModelInformation } from '$lib/components/app';
-       import {
-               MENU_MAX_WIDTH,
-               MENU_OFFSET,
-               VIEWPORT_GUTTER
-       } from '$lib/constants/floating-ui-constraints';
+       import { DialogModelInformation, SearchInput } from '$lib/components/app';
+       import type { ModelOption } from '$lib/types/models';
 
        interface Props {
                class?: string;
                return options.some((option) => option.model === currentModel);
        });
 
+       let searchTerm = $state('');
+       let searchInputRef = $state<HTMLInputElement | null>(null);
+       let highlightedIndex = $state<number>(-1);
+
+       let filteredOptions: ModelOption[] = $derived(
+               (() => {
+                       const term = searchTerm.trim().toLowerCase();
+                       if (!term) return options;
+
+                       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;
+       });
+
        let isOpen = $state(false);
        let showModelDialog = $state(false);
-       let container: HTMLDivElement | null = null;
-       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 () => {
-               try {
-                       await modelsStore.fetch();
-               } catch (error) {
+
+       onMount(() => {
+               modelsStore.fetch().catch((error) => {
                        console.error('Unable to load models:', error);
-               }
+               });
        });
 
-       function toggleOpen() {
+       function handleOpenChange(open: boolean) {
                if (loading || updating) return;
 
-               if (isRouter) {
-                       // Router mode: show dropdown
-                       if (isOpen) {
-                               closeMenu();
-                       } else {
-                               openMenu();
+               if (open) {
+                       isOpen = true;
+                       searchTerm = '';
+                       highlightedIndex = -1;
+
+                       // Focus search input after popover opens
+                       tick().then(() => {
+                               requestAnimationFrame(() => searchInputRef?.focus());
+                       });
+
+                       if (isRouter) {
+                               modelsStore.fetchRouterModels().then(() => {
+                                       modelsStore.fetchModalitiesForLoadedModels();
+                               });
                        }
                } else {
-                       // Single model mode: show dialog
-                       showModelDialog = true;
+                       isOpen = false;
+                       searchTerm = '';
+                       highlightedIndex = -1;
                }
        }
 
-       async function openMenu() {
+       function handleTriggerClick() {
                if (loading || updating) return;
 
-               isOpen = true;
-               await tick();
-               updateMenuPosition();
-               requestAnimationFrame(() => updateMenuPosition());
-
-               if (isRouter) {
-                       modelsStore.fetchRouterModels().then(() => {
-                               modelsStore.fetchModalitiesForLoadedModels();
-                       });
+               if (!isRouter) {
+                       // Single model mode: show dialog instead of popover
+                       showModelDialog = true;
                }
+               // For router mode, the Popover handles open/close
        }
 
        export function open() {
                if (isRouter) {
-                       openMenu();
+                       handleOpenChange(true);
                } else {
                        showModelDialog = true;
                }
        }
 
        function closeMenu() {
-               if (!isOpen) return;
-
-               isOpen = false;
-               menuPosition = null;
+               handleOpenChange(false);
        }
 
-       function handlePointerDown(event: PointerEvent) {
-               if (!container) return;
+       function handleSearchKeyDown(event: KeyboardEvent) {
+               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))) {
-                       closeMenu();
-               }
-       }
-
-       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 {
-                                       top = Math.min(Math.max(rawTop, minTop), maxTop);
-                               }
+                       const currentPos = compatibleIndices.indexOf(highlightedIndex);
+                       if (currentPos === -1 || currentPos === compatibleIndices.length - 1) {
+                               highlightedIndex = compatibleIndices[0];
                        } else {
-                               const rawTop = triggerRect.top - MENU_OFFSET - height;
-                               const minTop = VIEWPORT_GUTTER;
-                               const maxTop = viewportHeight - VIEWPORT_GUTTER - height;
-                               if (maxTop < minTop) {
-                                       top = minTop;
-                               } else {
-                                       top = Math.max(Math.min(rawTop, maxTop), minTop);
-                               }
+                               highlightedIndex = compatibleIndices[currentPos + 1];
                        }
+               } else if (event.key === 'ArrowUp') {
+                       event.preventDefault();
+                       if (compatibleIndices.length === 0) return;
 
-                       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;
+                       const currentPos = compatibleIndices.indexOf(highlightedIndex);
+                       if (currentPos === -1 || currentPos === 0) {
+                               highlightedIndex = compatibleIndices[compatibleIndices.length - 1];
+                       } else {
+                               highlightedIndex = compatibleIndices[currentPos - 1];
                        }
-                       if (left < VIEWPORT_GUTTER) {
-                               left = VIEWPORT_GUTTER;
+               } else if (event.key === 'Enter') {
+                       event.preventDefault();
+                       if (highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
+                               const option = filteredOptions[highlightedIndex];
+                               if (isModelCompatible(option)) {
+                                       handleSelect(option.id);
+                               }
+                       } else if (compatibleIndices.length > 0) {
+                               // No selection - highlight first compatible option
+                               highlightedIndex = compatibleIndices[0];
                        }
                }
-
-               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) {
 
                if (shouldCloseMenu) {
                        closeMenu();
+
+                       // Focus the chat textarea after model selection
+                       requestAnimationFrame(() => {
+                               const textarea = document.querySelector<HTMLTextAreaElement>(
+                                       '[data-slot="chat-form"] textarea'
+                               );
+                               textarea?.focus();
+                       });
                }
        }
 
        }
 </script>
 
-<svelte:window onresize={handleResize} />
-<svelte:document onpointerdown={handlePointerDown} onkeydown={handleKeydown} />
-
-<div class={cn('relative inline-flex flex-col items-end gap-1', className)} bind:this={container}>
+<div class={cn('relative inline-flex flex-col items-end gap-1', className)}>
        {#if loading && options.length === 0 && isRouter}
                <div class="flex items-center gap-2 text-xs text-muted-foreground">
                        <Loader2 class="h-3.5 w-3.5 animate-spin" />
        {:else}
                {@const selectedOption = getDisplayOption()}
 
-               <div class="relative">
-                       <button
-                               type="button"
+               <Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
+                       <Popover.Trigger
                                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`,
                                        !isCurrentModelInCache()
                                                        : isHighlightedCurrentModelActive
                                                                ? 'text-foreground'
                                                                : 'text-muted-foreground',
-                                       isOpen ? 'text-foreground' : '',
-                                       className
+                                       isOpen ? 'text-foreground' : ''
                                )}
                                style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
-                               aria-haspopup={isRouter ? 'listbox' : undefined}
-                               aria-expanded={isRouter ? isOpen : undefined}
-                               onclick={toggleOpen}
-                               bind:this={triggerButton}
-                               disabled={disabled || updating}
+                               onclick={handleTriggerClick}
+                               disabled={disabled || updating || !isRouter}
                        >
                                <Package class="h-3.5 w-3.5" />
 
                                {:else if isRouter}
                                        <ChevronDown class="h-3 w-3.5" />
                                {/if}
-                       </button>
-
-                       {#if isOpen && isRouter}
-                               <div
-                                       bind:this={menuRef}
-                                       use:portalToBody
-                                       class={cn(
-                                               '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'}
-                               >
+                       </Popover.Trigger>
+
+                       <Popover.Content
+                               class="group/popover-content w-96 max-w-[calc(100vw-2rem)] p-0"
+                               align="end"
+                               sideOffset={8}
+                               collisionPadding={16}
+                       >
+                               <div class="flex max-h-[50dvh] flex-col overflow-hidden">
+                                       <div
+                                               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"
+                                       >
+                                               <SearchInput
+                                                       id="model-search"
+                                                       placeholder="Search models..."
+                                                       bind:value={searchTerm}
+                                                       bind:ref={searchInputRef}
+                                                       onClose={closeMenu}
+                                                       onKeyDown={handleSearchKeyDown}
+                                               />
+                                       </div>
                                        <div
-                                               class="overflow-y-auto py-1"
-                                               style:max-height={menuPosition && menuPosition.maxHeight > 0
-                                                       ? `${menuPosition.maxHeight}px`
-                                                       : undefined}
+                                               class="models-list order-2 min-h-0 flex-1 overflow-y-auto group-data-[side=top]/popover-content:order-1"
                                        >
                                                {#if !isCurrentModelInCache() && currentModel}
                                                        <!-- Show unavailable model as first option (disabled) -->
                                                        <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"
                                                                aria-selected="true"
                                                                aria-disabled="true"
                                                        </button>
                                                        <div class="my-1 h-px bg-border"></div>
                                                {/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 isLoaded = status === ServerModelStatus.LOADED}
                                                        {@const isLoading = status === ServerModelStatus.LOADING}
                                                        {@const isSelected = currentModel === option.model || activeId === option.id}
                                                        {@const isCompatible = isModelCompatible(option)}
+                                                       {@const isHighlighted = index === highlightedIndex}
                                                        {@const missingModalities = getMissingModalities(option)}
+
                                                        <div
                                                                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
                                                                                ? 'cursor-pointer hover:bg-muted focus:bg-muted'
                                                                                : 'cursor-not-allowed opacity-50',
-                                                                       isSelected
+                                                                       isSelected || isHighlighted
                                                                                ? 'bg-accent text-accent-foreground'
                                                                                : isCompatible
                                                                                        ? 'hover:bg-accent hover:text-accent-foreground'
                                                                        isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
                                                                )}
                                                                role="option"
-                                                               aria-selected={isSelected}
+                                                               aria-selected={isSelected || isHighlighted}
                                                                aria-disabled={!isCompatible}
                                                                tabindex={isCompatible ? 0 : -1}
                                                                onclick={() => isCompatible && handleSelect(option.id)}
+                                                               onmouseenter={() => (highlightedIndex = index)}
                                                                onkeydown={(e) => {
                                                                        if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
                                                                                e.preventDefault();
                                                {/each}
                                        </div>
                                </div>
-                       {/if}
-               </div>
+                       </Popover.Content>
+               </Popover.Root>
        {/if}
 </div>
 
diff --git a/tools/server/webui/src/lib/components/ui/popover/index.ts b/tools/server/webui/src/lib/components/ui/popover/index.ts
new file mode 100644 (file)
index 0000000..c5937fb
--- /dev/null
@@ -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
+};
diff --git a/tools/server/webui/src/lib/components/ui/popover/popover-close.svelte b/tools/server/webui/src/lib/components/ui/popover/popover-close.svelte
new file mode 100644 (file)
index 0000000..dc4dec4
--- /dev/null
@@ -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} />
diff --git a/tools/server/webui/src/lib/components/ui/popover/popover-content.svelte b/tools/server/webui/src/lib/components/ui/popover/popover-content.svelte
new file mode 100644 (file)
index 0000000..2d3513d
--- /dev/null
@@ -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>
diff --git a/tools/server/webui/src/lib/components/ui/popover/popover-portal.svelte b/tools/server/webui/src/lib/components/ui/popover/popover-portal.svelte
new file mode 100644 (file)
index 0000000..25efb87
--- /dev/null
@@ -0,0 +1,7 @@
+<script lang="ts">
+       import { Popover as PopoverPrimitive } from 'bits-ui';
+
+       let { ...restProps }: PopoverPrimitive.PortalProps = $props();
+</script>
+
+<PopoverPrimitive.Portal {...restProps} />
diff --git a/tools/server/webui/src/lib/components/ui/popover/popover-trigger.svelte b/tools/server/webui/src/lib/components/ui/popover/popover-trigger.svelte
new file mode 100644 (file)
index 0000000..5ef3d0e
--- /dev/null
@@ -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}
+/>
diff --git a/tools/server/webui/src/lib/components/ui/popover/popover.svelte b/tools/server/webui/src/lib/components/ui/popover/popover.svelte
new file mode 100644 (file)
index 0000000..f39b867
--- /dev/null
@@ -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} />
index c95d3f184179e8281be48b3045981943edb7346a..003fc77acb08eac8b373bcf1243139f8cf911117 100644 (file)
@@ -1,3 +1,2 @@
 export const VIEWPORT_GUTTER = 8;
 export const MENU_OFFSET = 6;
-export const MENU_MAX_WIDTH = 320;
index 29416c2fe5becac803c786292a5af5b5d18f7e18..34b26403e4e6f7856752b1e725d0cbe7d9bc5e23 100644 (file)
@@ -295,14 +295,21 @@ class ModelsStore {
         * Fetch props for a specific model from /props endpoint
         * 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
-        * @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> {
                // Return cached props if available
                const cached = this.modelPropsCache.get(modelId);
                if (cached) return cached;
 
+               if (serverStore.isRouterMode && !this.isModelLoaded(modelId)) {
+                       return null;
+               }
+
                // Avoid duplicate fetches
                if (this.modelPropsFetching.has(modelId)) return null;