Mayo commited on
Commit
c91534c
·
unverified ·
1 Parent(s): 1889d5b

fix: improve llm dropdown list

Browse files
Files changed (1) hide show
  1. ui/components/ui/llm-model-select.tsx +49 -31
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 styled like `FontSelect` — a popover with a search input
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-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 dark:hover:bg-input/50 [&_svg:not([class*='text-'])]:text-muted-foreground",
105
  triggerClassName,
106
  )}
107
  >
108
  <TriggerLabel selected={selected} placeholder={placeholder} />
109
- <ChevronDownIcon className='size-3.5 shrink-0 opacity-50' />
110
  </PopoverTrigger>
111
  <PopoverContent
112
- // Wider than the trigger (which is squeezed next to the
113
- // Load/Unload button) but narrower than the enclosing LLM
114
- // popover (w-[280px]).
115
- className={cn('w-64 min-w-(--radix-popover-trigger-width) p-0', className)}
 
 
116
  align='start'
117
  onOpenAutoFocus={(e) => {
118
  e.preventDefault()
119
  inputRef.current?.focus()
120
  }}
121
  >
122
- <input
123
- ref={inputRef}
124
- value={search}
125
- onChange={(e) => setSearch(e.target.value)}
126
- placeholder='Search models…'
127
- className='w-full border-b bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground'
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-4 text-center text-xs text-muted-foreground'
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 name={provider.name} />}
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 rounded-sm px-2 text-xs select-none hover:bg-accent hover:text-accent-foreground',
207
- selected && 'bg-accent',
 
 
208
  )}
209
  style={style}
210
  onClick={onClick}
211
  >
212
- <span className='flex size-3 shrink-0 items-center justify-center'>
213
- {selected && <CheckIcon className='size-3' />}
214
- </span>
215
- {provider && <ProviderBadge name={provider.name} />}
216
- <span className='truncate'>{model.name}</span>
217
  </button>
218
  )
219
  }
220
 
221
- function ProviderBadge({ name }: { name: string }) {
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
- {name}
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
  }