Mayo commited on
fix: improve llm dropdown list
Browse files
ui/components/ui/llm-model-select.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
import { useVirtualizer } from '@tanstack/react-virtual'
|
| 4 |
-
import { CheckIcon, ChevronDownIcon } from 'lucide-react'
|
| 5 |
import { useCallback, useMemo, useRef, useState } from 'react'
|
| 6 |
|
| 7 |
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
@@ -33,9 +33,7 @@ type LlmModelSelectProps = {
|
|
| 33 |
}
|
| 34 |
|
| 35 |
/**
|
| 36 |
-
* Model picker
|
| 37 |
-
* and a virtualized list. Designed for medium-sized model catalogs (tens
|
| 38 |
-
* to a few hundred entries) where a native `<select>` becomes unwieldy.
|
| 39 |
*/
|
| 40 |
export function LlmModelSelect({
|
| 41 |
value,
|
|
@@ -101,31 +99,36 @@ export function LlmModelSelect({
|
|
| 101 |
disabled={disabled}
|
| 102 |
data-testid={props['data-testid']}
|
| 103 |
className={cn(
|
| 104 |
-
"flex h-7 w-full items-center justify-between gap-1.5 rounded-md border border-input bg-transparent px-2 py-1 text-xs whitespace-nowrap shadow-xs transition-
|
| 105 |
triggerClassName,
|
| 106 |
)}
|
| 107 |
>
|
| 108 |
<TriggerLabel selected={selected} placeholder={placeholder} />
|
| 109 |
-
<ChevronDownIcon className='size-3.5 shrink-0 opacity-
|
| 110 |
</PopoverTrigger>
|
| 111 |
<PopoverContent
|
| 112 |
-
//
|
| 113 |
-
//
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
| 116 |
align='start'
|
| 117 |
onOpenAutoFocus={(e) => {
|
| 118 |
e.preventDefault()
|
| 119 |
inputRef.current?.focus()
|
| 120 |
}}
|
| 121 |
>
|
| 122 |
-
<
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
| 129 |
<ScrollArea className='relative' style={{ height: listHeight }} viewportRef={viewportRef}>
|
| 130 |
<div
|
| 131 |
style={{
|
|
@@ -156,7 +159,7 @@ export function LlmModelSelect({
|
|
| 156 |
{filtered.length === 0 && (
|
| 157 |
<div
|
| 158 |
data-testid='llm-model-empty'
|
| 159 |
-
className='px-2 py-
|
| 160 |
>
|
| 161 |
No models found
|
| 162 |
</div>
|
|
@@ -166,6 +169,18 @@ export function LlmModelSelect({
|
|
| 166 |
)
|
| 167 |
}
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
function TriggerLabel({
|
| 170 |
selected,
|
| 171 |
placeholder,
|
|
@@ -180,9 +195,9 @@ function TriggerLabel({
|
|
| 180 |
}
|
| 181 |
const { model, provider } = selected
|
| 182 |
return (
|
| 183 |
-
<span className='flex min-w-0 items-center gap-1.5'>
|
| 184 |
-
{provider && <ProviderBadge
|
| 185 |
-
<span className='truncate'>{model.name}</span>
|
| 186 |
</span>
|
| 187 |
)
|
| 188 |
}
|
|
@@ -202,26 +217,29 @@ function ModelRow({
|
|
| 202 |
return (
|
| 203 |
<button
|
| 204 |
type='button'
|
|
|
|
| 205 |
className={cn(
|
| 206 |
-
'absolute left-0 flex w-full cursor-default items-center gap-1.5
|
| 207 |
-
selected
|
|
|
|
|
|
|
| 208 |
)}
|
| 209 |
style={style}
|
| 210 |
onClick={onClick}
|
| 211 |
>
|
| 212 |
-
<
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
</button>
|
| 218 |
)
|
| 219 |
}
|
| 220 |
|
| 221 |
-
function ProviderBadge({
|
| 222 |
return (
|
| 223 |
-
<span className='shrink-0 rounded bg-primary/10 px-1 py-0.5 text-[9px] leading-none font-semibold text-primary uppercase'>
|
| 224 |
-
{
|
| 225 |
</span>
|
| 226 |
)
|
| 227 |
}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
import { useVirtualizer } from '@tanstack/react-virtual'
|
| 4 |
+
import { CheckIcon, ChevronDownIcon, SearchIcon } from 'lucide-react'
|
| 5 |
import { useCallback, useMemo, useRef, useState } from 'react'
|
| 6 |
|
| 7 |
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
|
|
| 33 |
}
|
| 34 |
|
| 35 |
/**
|
| 36 |
+
* Model picker with a search input and a virtualized list.
|
|
|
|
|
|
|
| 37 |
*/
|
| 38 |
export function LlmModelSelect({
|
| 39 |
value,
|
|
|
|
| 99 |
disabled={disabled}
|
| 100 |
data-testid={props['data-testid']}
|
| 101 |
className={cn(
|
| 102 |
+
"flex h-7 w-full items-center justify-between gap-1.5 rounded-md border border-input bg-transparent px-2 py-1 text-xs whitespace-nowrap shadow-xs transition-colors outline-none hover:border-primary/40 hover:bg-primary/[0.03] focus-visible:border-primary/60 focus-visible:ring-[3px] focus-visible:ring-primary/25 disabled:cursor-not-allowed disabled:opacity-50 data-[state=open]:border-primary/60 data-[state=open]:ring-[3px] data-[state=open]:ring-primary/25 dark:bg-input/30 dark:hover:bg-input/50 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
| 103 |
triggerClassName,
|
| 104 |
)}
|
| 105 |
>
|
| 106 |
<TriggerLabel selected={selected} placeholder={placeholder} />
|
| 107 |
+
<ChevronDownIcon className='size-3.5 shrink-0 opacity-60' />
|
| 108 |
</PopoverTrigger>
|
| 109 |
<PopoverContent
|
| 110 |
+
// Matches the enclosing LLM popover (w-64) — keep compact and
|
| 111 |
+
// rely on the badge + short-name rules to stay legible.
|
| 112 |
+
className={cn(
|
| 113 |
+
'w-64 min-w-(--radix-popover-trigger-width) overflow-hidden border-primary/15 p-0 shadow-lg',
|
| 114 |
+
className,
|
| 115 |
+
)}
|
| 116 |
align='start'
|
| 117 |
onOpenAutoFocus={(e) => {
|
| 118 |
e.preventDefault()
|
| 119 |
inputRef.current?.focus()
|
| 120 |
}}
|
| 121 |
>
|
| 122 |
+
<div className='relative border-b border-primary/10 bg-gradient-to-b from-primary/[0.04] to-transparent'>
|
| 123 |
+
<SearchIcon className='pointer-events-none absolute top-1/2 left-2 size-3 -translate-y-1/2 text-muted-foreground' />
|
| 124 |
+
<input
|
| 125 |
+
ref={inputRef}
|
| 126 |
+
value={search}
|
| 127 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 128 |
+
placeholder='Search models…'
|
| 129 |
+
className='w-full bg-transparent py-1.5 pr-2 pl-7 text-xs outline-none placeholder:text-muted-foreground/70'
|
| 130 |
+
/>
|
| 131 |
+
</div>
|
| 132 |
<ScrollArea className='relative' style={{ height: listHeight }} viewportRef={viewportRef}>
|
| 133 |
<div
|
| 134 |
style={{
|
|
|
|
| 159 |
{filtered.length === 0 && (
|
| 160 |
<div
|
| 161 |
data-testid='llm-model-empty'
|
| 162 |
+
className='px-2 py-6 text-center text-xs text-muted-foreground'
|
| 163 |
>
|
| 164 |
No models found
|
| 165 |
</div>
|
|
|
|
| 169 |
)
|
| 170 |
}
|
| 171 |
|
| 172 |
+
/** Last path segment — strips vendor prefixes like `anthropic/claude-…`. */
|
| 173 |
+
function shortModelName(name: string): string {
|
| 174 |
+
const idx = name.lastIndexOf('/')
|
| 175 |
+
return idx >= 0 && idx < name.length - 1 ? name.slice(idx + 1) : name
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
/** Provider badge label. Collapse `openai-compatible` to a short `compat`. */
|
| 179 |
+
function providerBadgeLabel(provider: LlmProviderCatalog): string {
|
| 180 |
+
if (provider.id === 'openai-compatible') return 'compat'
|
| 181 |
+
return provider.name
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
function TriggerLabel({
|
| 185 |
selected,
|
| 186 |
placeholder,
|
|
|
|
| 195 |
}
|
| 196 |
const { model, provider } = selected
|
| 197 |
return (
|
| 198 |
+
<span className='flex min-w-0 items-center gap-1.5' title={model.name}>
|
| 199 |
+
{provider && <ProviderBadge label={providerBadgeLabel(provider)} />}
|
| 200 |
+
<span className='truncate'>{shortModelName(model.name)}</span>
|
| 201 |
</span>
|
| 202 |
)
|
| 203 |
}
|
|
|
|
| 217 |
return (
|
| 218 |
<button
|
| 219 |
type='button'
|
| 220 |
+
title={model.name}
|
| 221 |
className={cn(
|
| 222 |
+
'absolute left-0 flex w-full cursor-default items-center gap-1.5 px-2 pr-7 text-left text-xs transition-colors select-none',
|
| 223 |
+
selected
|
| 224 |
+
? 'bg-accent text-accent-foreground ring-1 ring-primary/30 ring-inset'
|
| 225 |
+
: 'hover:bg-accent/60 hover:text-accent-foreground',
|
| 226 |
)}
|
| 227 |
style={style}
|
| 228 |
onClick={onClick}
|
| 229 |
>
|
| 230 |
+
{provider && <ProviderBadge label={providerBadgeLabel(provider)} />}
|
| 231 |
+
<span className='truncate'>{shortModelName(model.name)}</span>
|
| 232 |
+
{selected && (
|
| 233 |
+
<CheckIcon className='absolute top-1/2 right-2 size-3 -translate-y-1/2 text-primary' />
|
| 234 |
+
)}
|
| 235 |
</button>
|
| 236 |
)
|
| 237 |
}
|
| 238 |
|
| 239 |
+
function ProviderBadge({ label }: { label: string }) {
|
| 240 |
return (
|
| 241 |
+
<span className='shrink-0 rounded-sm border border-primary/20 bg-primary/10 px-1 py-0.5 text-[9px] leading-none font-semibold tracking-wide text-primary uppercase'>
|
| 242 |
+
{label}
|
| 243 |
</span>
|
| 244 |
)
|
| 245 |
}
|