From cc955efe4bdd43e5d24a6c91ab5094adab77ef60 Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 4 Dec 2025 19:05:19 +0100 Subject: [PATCH 1/8] webui: add search field to model selector and fixes mobile viewport overflow --- .../app/models/ModelsSelector.svelte | 81 ++++++++++++++----- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte b/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte index c4331e92f13..ce2ea83627f 100644 --- a/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte +++ b/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte @@ -23,6 +23,7 @@ MENU_OFFSET, VIEWPORT_GUTTER } from '$lib/constants/floating-ui-constraints'; + import type { ModelOption } from '$lib/types/models'; interface Props { class?: string; @@ -145,10 +146,26 @@ return options.some((option) => option.model === currentModel); }); + let searchTerm = $state(''); + let searchInputRef = $state(null); + + 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) + ); + })() + ); + let isOpen = $state(false); let showModelDialog = $state(false); let container: HTMLDivElement | null = null; let menuRef = $state(null); + let menuWidth = $state(null); let triggerButton = $state(null); let menuPosition = $state<{ top: number; @@ -186,9 +203,12 @@ if (loading || updating) return; isOpen = true; + searchTerm = ''; + menuWidth = null; await tick(); updateMenuPosition(); requestAnimationFrame(() => updateMenuPosition()); + requestAnimationFrame(() => searchInputRef?.focus()); if (isRouter) { modelsStore.fetchRouterModels().then(() => { @@ -210,6 +230,8 @@ isOpen = false; menuPosition = null; + menuWidth = null; + searchTerm = ''; } function handlePointerDown(event: PointerEvent) { @@ -243,19 +265,28 @@ 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 safeMaxWidth = availableWidth > 0 ? availableWidth : MENU_MAX_WIDTH; const desiredMinWidth = Math.min(160, safeMaxWidth || 160); - let width = Math.min( - Math.max(triggerRect.width, scrollWidth, desiredMinWidth), - safeMaxWidth || 320 - ); + if (menuWidth === null) { + menuRef.style.width = ''; + menuRef.style.maxWidth = ''; + + const idealWidth = Math.max( + triggerRect.width, + Math.min(menuRef.scrollWidth, safeMaxWidth), + 400 + ); + + menuWidth = Math.min(Math.max(idealWidth, desiredMinWidth), safeMaxWidth); + } else if (safeMaxWidth && menuWidth > safeMaxWidth) { + menuWidth = safeMaxWidth; + } + + const width = menuWidth ?? desiredMinWidth; const availableBelow = Math.max( 0, @@ -304,18 +335,11 @@ metrics = aboveMetrics; } - let left = triggerRect.right - width; + const availableRight = viewportWidth - VIEWPORT_GUTTER; + const rightAligned = Math.min(triggerRect.right, availableRight); + let left = rightAligned - 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; - } - } + left = Math.min(Math.max(left, VIEWPORT_GUTTER), Math.max(maxLeft, VIEWPORT_GUTTER)); menuPosition = { top: Math.round(metrics.top), @@ -467,6 +491,20 @@ style:width={menuPosition ? `${menuPosition.width}px` : undefined} data-placement={menuPosition?.placement ?? 'bottom'} > +
+ + +
+
0 @@ -488,7 +526,10 @@
{/if} - {#each options as option (option.id)} + {#if filteredOptions.length === 0} +

No models found.

+ {/if} + {#each filteredOptions as option (option.id)} {@const status = getModelStatus(option.model)} {@const isLoaded = status === ServerModelStatus.LOADED} {@const isLoading = status === ServerModelStatus.LOADING} From a26045c496f8310cc04da55c563ba9209f73944d Mon Sep 17 00:00:00 2001 From: Pascal Date: Thu, 4 Dec 2025 19:39:29 +0100 Subject: [PATCH 2/8] webui: simplify model search style and code --- .../app/models/ModelsSelector.svelte | 35 ++++++------------- .../lib/constants/floating-ui-constraints.ts | 1 - 2 files changed, 10 insertions(+), 26 deletions(-) diff --git a/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte b/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte index ce2ea83627f..85a7e55dd90 100644 --- a/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte +++ b/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte @@ -18,11 +18,7 @@ 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 { MENU_OFFSET, VIEWPORT_GUTTER } from '$lib/constants/floating-ui-constraints'; import type { ModelOption } from '$lib/types/models'; interface Props { @@ -256,7 +252,7 @@ } } - function updateMenuPosition() { + async function updateMenuPosition() { if (!isOpen || !triggerButton || !menuRef) return; const triggerRect = triggerButton.getBoundingClientRect(); @@ -268,26 +264,16 @@ const scrollHeight = menuRef.scrollHeight; const availableWidth = Math.max(0, viewportWidth - VIEWPORT_GUTTER * 2); - const safeMaxWidth = availableWidth > 0 ? availableWidth : MENU_MAX_WIDTH; - const desiredMinWidth = Math.min(160, safeMaxWidth || 160); + const safeMaxWidth = availableWidth || viewportWidth; if (menuWidth === null) { menuRef.style.width = ''; menuRef.style.maxWidth = ''; - const idealWidth = Math.max( - triggerRect.width, - Math.min(menuRef.scrollWidth, safeMaxWidth), - 400 - ); - - menuWidth = Math.min(Math.max(idealWidth, desiredMinWidth), safeMaxWidth); - } else if (safeMaxWidth && menuWidth > safeMaxWidth) { - menuWidth = safeMaxWidth; + const idealWidth = Math.max(triggerRect.width, Math.min(menuRef.scrollWidth, safeMaxWidth)); + menuWidth = Math.min(idealWidth, safeMaxWidth); } - const width = menuWidth ?? desiredMinWidth; - const availableBelow = Math.max( 0, viewportHeight - VIEWPORT_GUTTER - triggerRect.bottom - MENU_OFFSET @@ -337,14 +323,14 @@ const availableRight = viewportWidth - VIEWPORT_GUTTER; const rightAligned = Math.min(triggerRect.right, availableRight); - let left = rightAligned - width; - const maxLeft = viewportWidth - VIEWPORT_GUTTER - width; + let left = rightAligned - menuWidth; + const maxLeft = viewportWidth - VIEWPORT_GUTTER - menuWidth; left = Math.min(Math.max(left, VIEWPORT_GUTTER), Math.max(maxLeft, VIEWPORT_GUTTER)); menuPosition = { top: Math.round(metrics.top), left: Math.round(left), - width: Math.round(width), + width: Math.round(menuWidth), placement: metrics.placement, maxHeight: Math.round(metrics.maxHeight) }; @@ -491,11 +477,11 @@ style:width={menuPosition ? `${menuPosition.width}px` : undefined} data-placement={menuPosition?.placement ?? 'bottom'} > -
+
-
0 diff --git a/tools/server/webui/src/lib/constants/floating-ui-constraints.ts b/tools/server/webui/src/lib/constants/floating-ui-constraints.ts index c95d3f18417..003fc77acb0 100644 --- a/tools/server/webui/src/lib/constants/floating-ui-constraints.ts +++ b/tools/server/webui/src/lib/constants/floating-ui-constraints.ts @@ -1,3 +1,2 @@ export const VIEWPORT_GUTTER = 8; export const MENU_OFFSET = 6; -export const MENU_MAX_WIDTH = 320; From e68a6da2127f3457f288df7de7bba6e508a420cf Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Fri, 5 Dec 2025 12:41:52 +0100 Subject: [PATCH 3/8] refacor: Search Input component & consistent UI for Models Selector search --- .../chat/ChatSidebar/ChatSidebarSearch.svelte | 18 +-------- .../webui/src/lib/components/app/index.ts | 1 + .../components/app/misc/SearchInput.svelte | 37 +++++++++++++++++++ .../app/models/ModelsSelector.svelte | 22 ++++------- 4 files changed, 48 insertions(+), 30 deletions(-) create mode 100644 tools/server/webui/src/lib/components/app/misc/SearchInput.svelte diff --git a/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte b/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte index c9e6c6616a2..afc98470283 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarSearch.svelte @@ -1,6 +1,5 @@ -
- - - -
+ diff --git a/tools/server/webui/src/lib/components/app/index.ts b/tools/server/webui/src/lib/components/app/index.ts index 87b24598b72..8631d4fb3bd 100644 --- a/tools/server/webui/src/lib/components/app/index.ts +++ b/tools/server/webui/src/lib/components/app/index.ts @@ -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 index 00000000000..baf5c7a24db --- /dev/null +++ b/tools/server/webui/src/lib/components/app/misc/SearchInput.svelte @@ -0,0 +1,37 @@ + + +
+ + + +
diff --git a/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte b/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte index 85a7e55dd90..5ee5bda4974 100644 --- a/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte +++ b/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte @@ -17,7 +17,7 @@ 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 { DialogModelInformation, SearchInput } from '$lib/components/app'; import { MENU_OFFSET, VIEWPORT_GUTTER } from '$lib/constants/floating-ui-constraints'; import type { ModelOption } from '$lib/types/models'; @@ -477,19 +477,13 @@ style:width={menuPosition ? `${menuPosition.width}px` : undefined} data-placement={menuPosition?.placement ?? 'bottom'} > -
- - -
+
0 From 9c921fa351c3ad9d1af403fd0e3a5f3ecaea40ae Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Fri, 5 Dec 2025 13:07:50 +0100 Subject: [PATCH 4/8] feat: Use Popover component + improve interactions --- tools/server/webui/package-lock.json | 36 +- tools/server/webui/package.json | 2 +- .../app/chat/ChatForm/ChatForm.svelte | 1 + .../components/app/misc/SearchInput.svelte | 40 +- .../app/models/ModelsSelector.svelte | 507 ++++++++---------- .../src/lib/components/ui/popover/index.ts | 19 + .../ui/popover/popover-close.svelte | 7 + .../ui/popover/popover-content.svelte | 31 ++ .../ui/popover/popover-portal.svelte | 7 + .../ui/popover/popover-trigger.svelte | 17 + .../lib/components/ui/popover/popover.svelte | 7 + 11 files changed, 364 insertions(+), 310 deletions(-) create mode 100644 tools/server/webui/src/lib/components/ui/popover/index.ts create mode 100644 tools/server/webui/src/lib/components/ui/popover/popover-close.svelte create mode 100644 tools/server/webui/src/lib/components/ui/popover/popover-content.svelte create mode 100644 tools/server/webui/src/lib/components/ui/popover/popover-portal.svelte create mode 100644 tools/server/webui/src/lib/components/ui/popover/popover-trigger.svelte create mode 100644 tools/server/webui/src/lib/components/ui/popover/popover.svelte diff --git a/tools/server/webui/package-lock.json b/tools/server/webui/package-lock.json index 9c1c2499cfd..4f37b308b13 100644 --- a/tools/server/webui/package-lock.json +++ b/tools/server/webui/package-lock.json @@ -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", @@ -3343,17 +3343,17 @@ } }, "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": { @@ -3368,9 +3368,9 @@ } }, "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", @@ -3378,23 +3378,31 @@ ], "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": { diff --git a/tools/server/webui/package.json b/tools/server/webui/package.json index 987a7239ed4..c20ab3cfde0 100644 --- a/tools/server/webui/package.json +++ b/tools/server/webui/package.json @@ -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", diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte index 7f8e38286d2..78cc1c47daa 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -331,6 +331,7 @@ 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" > import { Input } from '$lib/components/ui/input'; - import { Search } from '@lucide/svelte'; + 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; @@ -15,17 +17,31 @@ 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?.(); + } + }
@@ -33,5 +49,25 @@ class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-muted-foreground" /> - + + + {#if showClearButton} + + {/if}
diff --git a/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte b/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte index 5ee5bda4974..3a078c9ef97 100644 --- a/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte +++ b/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte @@ -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, @@ -18,7 +18,6 @@ import { ServerModelStatus } from '$lib/enums'; import { isRouterMode } from '$lib/stores/server.svelte'; import { DialogModelInformation, SearchInput } from '$lib/components/app'; - import { MENU_OFFSET, VIEWPORT_GUTTER } from '$lib/constants/floating-ui-constraints'; import type { ModelOption } from '$lib/types/models'; interface Props { @@ -144,6 +143,7 @@ let searchTerm = $state(''); let searchInputRef = $state(null); + let highlightedIndex = $state(-1); let filteredOptions: ModelOption[] = $derived( (() => { @@ -157,19 +157,21 @@ })() ); + // 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(null); - let menuWidth = $state(null); - let triggerButton = $state(null); - let menuPosition = $state<{ - top: number; - left: number; - width: number; - placement: 'top' | 'bottom'; - maxHeight: number; - } | null>(null); onMount(async () => { try { @@ -179,161 +181,88 @@ } }); - 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; - searchTerm = ''; - menuWidth = null; - await tick(); - updateMenuPosition(); - requestAnimationFrame(() => updateMenuPosition()); - requestAnimationFrame(() => searchInputRef?.focus()); - - 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; - menuWidth = null; - searchTerm = ''; + handleOpenChange(false); } - function handlePointerDown(event: PointerEvent) { - if (!container) return; - - const target = event.target as Node | null; + function handleSearchKeyDown(event: KeyboardEvent) { + if (event.isComposing) return; - if (target && !container.contains(target) && !(menuRef && menuRef.contains(target))) { - closeMenu(); - } - } + if (event.key === 'ArrowDown') { + event.preventDefault(); + if (compatibleIndices.length === 0) return; - function handleKeydown(event: KeyboardEvent) { - if (event.key === 'Escape') { - closeMenu(); - } - } - - function handleResize() { - if (isOpen) { - updateMenuPosition(); - } - } - - async 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 scrollHeight = menuRef.scrollHeight; - - const availableWidth = Math.max(0, viewportWidth - VIEWPORT_GUTTER * 2); - const safeMaxWidth = availableWidth || viewportWidth; - - if (menuWidth === null) { - menuRef.style.width = ''; - menuRef.style.maxWidth = ''; - - const idealWidth = Math.max(triggerRect.width, Math.min(menuRef.scrollWidth, safeMaxWidth)); - menuWidth = Math.min(idealWidth, safeMaxWidth); - } + const currentPos = compatibleIndices.indexOf(highlightedIndex); + if (currentPos === -1 || currentPos === compatibleIndices.length - 1) { + highlightedIndex = compatibleIndices[0]; + } else { + highlightedIndex = compatibleIndices[currentPos + 1]; + } + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + if (compatibleIndices.length === 0) return; - 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 === 0) { + highlightedIndex = compatibleIndices[compatibleIndices.length - 1]; } 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 === '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]; } - - 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; - } - - const availableRight = viewportWidth - VIEWPORT_GUTTER; - const rightAligned = Math.min(triggerRect.right, availableRight); - let left = rightAligned - menuWidth; - const maxLeft = viewportWidth - VIEWPORT_GUTTER - menuWidth; - left = Math.min(Math.max(left, VIEWPORT_GUTTER), Math.max(maxLeft, VIEWPORT_GUTTER)); - - menuPosition = { - top: Math.round(metrics.top), - left: Math.round(left), - width: Math.round(menuWidth), - placement: metrics.placement, - maxHeight: Math.round(metrics.maxHeight) - }; } async function handleSelect(modelId: string) { @@ -366,6 +295,14 @@ if (shouldCloseMenu) { closeMenu(); + + // Focus the chat textarea after model selection + requestAnimationFrame(() => { + const textarea = document.querySelector( + '[data-slot="chat-form"] textarea' + ); + textarea?.focus(); + }); } } @@ -414,10 +351,7 @@ } - - - -
+
{#if loading && options.length === 0 && isRouter}
@@ -428,9 +362,8 @@ {:else} {@const selectedOption = getDisplayOption()} -
- - - {#if isOpen && isRouter} -
+ + + +
-
0 - ? `${menuPosition.maxHeight}px` - : undefined} - > - {#if !isCurrentModelInCache() && currentModel} - - -
- {/if} - {#if filteredOptions.length === 0} -

No models found.

- {/if} - {#each filteredOptions as option (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 missingModalities = getMissingModalities(option)} -
isCompatible && handleSelect(option.id)} - onkeydown={(e) => { - if (isCompatible && (e.key === 'Enter' || e.key === ' ')) { - e.preventDefault(); - handleSelect(option.id); - } - }} - > - {option.model} - - {#if missingModalities} - - {#if missingModalities.vision} - - - - - -

No vision support

-
-
- {/if} - {#if missingModalities.audio} - - - - - -

No audio support

-
-
- {/if} -
- {/if} - - {#if isLoading} - - - - - -

Loading model...

-
-
- {:else if isLoaded} - - - - - -

Unload model

-
-
- {:else} - - {/if} -
- {/each} -
- {/if} -
+
+ {#if !isCurrentModelInCache() && currentModel} + + +
+ {/if} + {#if filteredOptions.length === 0} +

No models found.

+ {/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)} + +
isCompatible && handleSelect(option.id)} + onmouseenter={() => (highlightedIndex = index)} + onkeydown={(e) => { + if (isCompatible && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + handleSelect(option.id); + } + }} + > + {option.model} + + {#if missingModalities} + + {#if missingModalities.vision} + + + + + +

No vision support

+
+
+ {/if} + {#if missingModalities.audio} + + + + + +

No audio support

+
+
+ {/if} +
+ {/if} + + {#if isLoading} + + + + + +

Loading model...

+
+
+ {:else if isLoaded} + + + + + +

Unload model

+
+
+ {:else} + + {/if} +
+ {/each} +
+ + {/if}
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 index 00000000000..c5937fb3a04 --- /dev/null +++ b/tools/server/webui/src/lib/components/ui/popover/index.ts @@ -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 index 00000000000..dc4dec4b339 --- /dev/null +++ b/tools/server/webui/src/lib/components/ui/popover/popover-close.svelte @@ -0,0 +1,7 @@ + + + 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 index 00000000000..e59caa2757e --- /dev/null +++ b/tools/server/webui/src/lib/components/ui/popover/popover-content.svelte @@ -0,0 +1,31 @@ + + + + + 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 index 00000000000..25efb877b73 --- /dev/null +++ b/tools/server/webui/src/lib/components/ui/popover/popover-portal.svelte @@ -0,0 +1,7 @@ + + + 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 index 00000000000..5ef3d0e9324 --- /dev/null +++ b/tools/server/webui/src/lib/components/ui/popover/popover-trigger.svelte @@ -0,0 +1,17 @@ + + + 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 index 00000000000..f39b867a694 --- /dev/null +++ b/tools/server/webui/src/lib/components/ui/popover/popover.svelte @@ -0,0 +1,7 @@ + + + From 4bd64c7a2990dbbbc37da0c9a9dc3e066fac3646 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Fri, 5 Dec 2025 13:35:26 +0100 Subject: [PATCH 5/8] fix: Fetching props for only loaded models in ROUTER mode --- tools/server/webui/src/lib/stores/models.svelte.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tools/server/webui/src/lib/stores/models.svelte.ts b/tools/server/webui/src/lib/stores/models.svelte.ts index 29416c2fe5b..34b26403e4e 100644 --- a/tools/server/webui/src/lib/stores/models.svelte.ts +++ b/tools/server/webui/src/lib/stores/models.svelte.ts @@ -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 { // 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; From 6ab708fe6d7796be170088225a44d464cf4fb1e8 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 6 Dec 2025 12:43:14 +0100 Subject: [PATCH 6/8] 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 --- .../app/models/ModelsSelector.svelte | 268 +++++++++--------- .../ui/popover/popover-content.svelte | 6 + 2 files changed, 140 insertions(+), 134 deletions(-) diff --git a/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte b/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte index 3a078c9ef97..96739fe21ac 100644 --- a/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte +++ b/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte @@ -173,12 +173,10 @@ let isOpen = $state(false); let showModelDialog = $state(false); - onMount(async () => { - try { - await modelsStore.fetch(); - } catch (error) { + onMount(() => { + modelsStore.fetch().catch((error) => { console.error('Unable to load models:', error); - } + }); }); function handleOpenChange(open: boolean) { @@ -394,138 +392,140 @@ -
- -
-
- {#if !isCurrentModelInCache() && currentModel} - - -
- {/if} - {#if filteredOptions.length === 0} -

No models found.

- {/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)} - -
isCompatible && handleSelect(option.id)} - onmouseenter={() => (highlightedIndex = index)} - onkeydown={(e) => { - if (isCompatible && (e.key === 'Enter' || e.key === ' ')) { - e.preventDefault(); - handleSelect(option.id); - } - }} - > - {option.model} - - {#if missingModalities} - - {#if missingModalities.vision} - - - - - -

No vision support

-
-
- {/if} - {#if missingModalities.audio} - - - - - -

No audio support

-
-
- {/if} -
- {/if} - - {#if isLoading} - - - - - -

Loading model...

-
-
- {:else if isLoaded} - - - - - -

Unload model

-
-
- {:else} - - {/if} -
- {/each} +
+
+ +
+
+ {#if !isCurrentModelInCache() && currentModel} + + +
+ {/if} + {#if filteredOptions.length === 0} +

No models found.

+ {/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)} + +
isCompatible && handleSelect(option.id)} + onmouseenter={() => (highlightedIndex = index)} + onkeydown={(e) => { + if (isCompatible && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + handleSelect(option.id); + } + }} + > + {option.model} + + {#if missingModalities} + + {#if missingModalities.vision} + + + + + +

No vision support

+
+
+ {/if} + {#if missingModalities.audio} + + + + + +

No audio support

+
+
+ {/if} +
+ {/if} + + {#if isLoading} + + + + + +

Loading model...

+
+
+ {:else if isLoaded} + + + + + +

Unload model

+
+
+ {:else} + + {/if} +
+ {/each} +
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 index e59caa2757e..2d3513d347f 100644 --- a/tools/server/webui/src/lib/components/ui/popover/popover-content.svelte +++ b/tools/server/webui/src/lib/components/ui/popover/popover-content.svelte @@ -8,7 +8,10 @@ ref = $bindable(null), class: className, sideOffset = 4, + side, align = 'center', + collisionPadding = 8, + avoidCollisions = true, portalProps, ...restProps }: PopoverPrimitive.ContentProps & { @@ -21,7 +24,10 @@ 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 From 13b69cf16ffb5c133d6e6ab89389c3bf8683fb35 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 6 Dec 2025 14:12:20 +0100 Subject: [PATCH 7/8] 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 --- .../lib/components/app/models/ModelsSelector.svelte | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte b/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte index 96739fe21ac..ac0937696d4 100644 --- a/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte +++ b/tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte @@ -391,13 +391,15 @@
-
+
-
+
{#if !isCurrentModelInCache() && currentModel}