Skip to content

Commit ab4fe52

Browse files
webui: add search field to model selector and fixes mobile viewport overflow
1 parent 93d4c74 commit ab4fe52

File tree

1 file changed

+61
-25
lines changed

1 file changed

+61
-25
lines changed

tools/server/webui/src/lib/components/app/models/ModelsSelector.svelte

Lines changed: 61 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
MENU_OFFSET,
2424
VIEWPORT_GUTTER
2525
} from '$lib/constants/floating-ui-constraints';
26+
import type { ModelOption } from '$lib/types/models';
2627
2728
interface Props {
2829
class?: string;
@@ -145,10 +146,26 @@
145146
return options.some((option) => option.model === currentModel);
146147
});
147148
149+
let searchTerm = $state('');
150+
let searchInputRef = $state<HTMLInputElement | null>(null);
151+
152+
let filteredOptions: ModelOption[] = $derived(
153+
(() => {
154+
const term = searchTerm.trim().toLowerCase();
155+
if (!term) return options;
156+
157+
return options.filter(
158+
(option) =>
159+
option.model.toLowerCase().includes(term) || option.name?.toLowerCase().includes(term)
160+
);
161+
})()
162+
);
163+
148164
let isOpen = $state(false);
149165
let showModelDialog = $state(false);
150166
let container: HTMLDivElement | null = null;
151167
let menuRef = $state<HTMLDivElement | null>(null);
168+
let menuWidth = $state<number | null>(null);
152169
let triggerButton = $state<HTMLButtonElement | null>(null);
153170
let menuPosition = $state<{
154171
top: number;
@@ -186,9 +203,12 @@
186203
if (loading || updating) return;
187204
188205
isOpen = true;
206+
searchTerm = '';
207+
menuWidth = null;
189208
await tick();
190209
updateMenuPosition();
191210
requestAnimationFrame(() => updateMenuPosition());
211+
requestAnimationFrame(() => searchInputRef?.focus());
192212
193213
if (isRouter) {
194214
modelsStore.fetchRouterModels().then(() => {
@@ -210,6 +230,8 @@
210230
211231
isOpen = false;
212232
menuPosition = null;
233+
menuWidth = null;
234+
searchTerm = '';
213235
}
214236
215237
function handlePointerDown(event: PointerEvent) {
@@ -243,24 +265,28 @@
243265
244266
if (viewportWidth === 0 || viewportHeight === 0) return;
245267
246-
// Reset widths before measuring to avoid carrying over constrained sizes
247-
// from previous openings (which could progressively shrink the menu).
248-
menuRef.style.width = '';
249-
menuRef.style.maxWidth = '';
250-
251-
const scrollWidth = menuRef.scrollWidth;
252268
const scrollHeight = menuRef.scrollHeight;
253269
254270
const availableWidth = Math.max(0, viewportWidth - VIEWPORT_GUTTER * 2);
255-
const constrainedMaxWidth = Math.min(MENU_MAX_WIDTH, availableWidth || MENU_MAX_WIDTH);
256-
const safeMaxWidth =
257-
constrainedMaxWidth > 0 ? constrainedMaxWidth : Math.min(MENU_MAX_WIDTH, viewportWidth);
271+
const safeMaxWidth = availableWidth > 0 ? availableWidth : MENU_MAX_WIDTH;
258272
const desiredMinWidth = Math.min(160, safeMaxWidth || 160);
259273
260-
let width = Math.min(
261-
Math.max(triggerRect.width, scrollWidth, desiredMinWidth),
262-
safeMaxWidth || 320
263-
);
274+
if (menuWidth === null) {
275+
menuRef.style.width = '';
276+
menuRef.style.maxWidth = '';
277+
278+
const idealWidth = Math.max(
279+
triggerRect.width,
280+
Math.min(menuRef.scrollWidth, safeMaxWidth),
281+
400
282+
);
283+
284+
menuWidth = Math.min(Math.max(idealWidth, desiredMinWidth), safeMaxWidth);
285+
} else if (safeMaxWidth && menuWidth > safeMaxWidth) {
286+
menuWidth = safeMaxWidth;
287+
}
288+
289+
const width = menuWidth ?? desiredMinWidth;
264290
265291
const availableBelow = Math.max(
266292
0,
@@ -309,18 +335,11 @@
309335
metrics = aboveMetrics;
310336
}
311337
312-
let left = triggerRect.right - width;
338+
const availableRight = viewportWidth - VIEWPORT_GUTTER;
339+
const rightAligned = Math.min(triggerRect.right, availableRight);
340+
let left = rightAligned - width;
313341
const maxLeft = viewportWidth - VIEWPORT_GUTTER - width;
314-
if (maxLeft < VIEWPORT_GUTTER) {
315-
left = VIEWPORT_GUTTER;
316-
} else {
317-
if (left > maxLeft) {
318-
left = maxLeft;
319-
}
320-
if (left < VIEWPORT_GUTTER) {
321-
left = VIEWPORT_GUTTER;
322-
}
323-
}
342+
left = Math.min(Math.max(left, VIEWPORT_GUTTER), Math.max(maxLeft, VIEWPORT_GUTTER));
324343
325344
menuPosition = {
326345
top: Math.round(metrics.top),
@@ -472,6 +491,20 @@
472491
style:width={menuPosition ? `${menuPosition.width}px` : undefined}
473492
data-placement={menuPosition?.placement ?? 'bottom'}
474493
>
494+
<div class="border-b bg-popover px-3 py-2">
495+
<label class="sr-only" for="model-search">Search models</label>
496+
<input
497+
id="model-search"
498+
class="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm transition focus:border-ring focus:outline-none"
499+
placeholder="Search models"
500+
bind:value={searchTerm}
501+
bind:this={searchInputRef}
502+
aria-label="Search models"
503+
autocomplete="off"
504+
type="search"
505+
/>
506+
</div>
507+
475508
<div
476509
class="overflow-y-auto py-1"
477510
style:max-height={menuPosition && menuPosition.maxHeight > 0
@@ -493,7 +526,10 @@
493526
</button>
494527
<div class="my-1 h-px bg-border"></div>
495528
{/if}
496-
{#each options as option (option.id)}
529+
{#if filteredOptions.length === 0}
530+
<p class="px-3 py-2 text-sm text-muted-foreground">No models found.</p>
531+
{/if}
532+
{#each filteredOptions as option (option.id)}
497533
{@const status = getModelStatus(option.model)}
498534
{@const isLoaded = status === ServerModelStatus.LOADED}
499535
{@const isLoading = status === ServerModelStatus.LOADING}

0 commit comments

Comments
 (0)