|
8 | 8 | Server, |
9 | 9 | Settings2, |
10 | 10 | } from "lucide-react" |
11 | | -import { useMemo, useState } from "react" |
| 11 | +import { useEffect, useMemo, useRef, useState } from "react" |
12 | 12 | import { |
13 | 13 | ModelSelectorContent, |
14 | 14 | ModelSelectorEmpty, |
@@ -114,134 +114,189 @@ export function ModelSelector({ |
114 | 114 | ? `${selectedModel.modelId} ${dict.modelConfig.clickToChange}` |
115 | 115 | : `${dict.modelConfig.usingServerDefault} ${dict.modelConfig.clickToChange}` |
116 | 116 |
|
| 117 | + const wrapperRef = useRef<HTMLDivElement | null>(null) |
| 118 | + const [showLabel, setShowLabel] = useState(true) |
| 119 | + |
| 120 | + // Threshold (px) under which we hide the label (tweak as needed) |
| 121 | + const HIDE_THRESHOLD = 240 |
| 122 | + const SHOW_THRESHOLD = 260 |
| 123 | + useEffect(() => { |
| 124 | + const el = wrapperRef.current |
| 125 | + if (!el) return |
| 126 | + |
| 127 | + const target = el.parentElement ?? el |
| 128 | + |
| 129 | + const ro = new ResizeObserver((entries) => { |
| 130 | + for (const entry of entries) { |
| 131 | + const width = entry.contentRect.width |
| 132 | + setShowLabel((prev) => { |
| 133 | + // if currently showing and width dropped below hide threshold -> hide |
| 134 | + if (prev && width <= HIDE_THRESHOLD) return false |
| 135 | + // if currently hidden and width rose above show threshold -> show |
| 136 | + if (!prev && width >= SHOW_THRESHOLD) return true |
| 137 | + // otherwise keep previous state (hysteresis) |
| 138 | + return prev |
| 139 | + }) |
| 140 | + } |
| 141 | + }) |
| 142 | + |
| 143 | + ro.observe(target) |
| 144 | + |
| 145 | + const initialWidth = target.getBoundingClientRect().width |
| 146 | + setShowLabel(initialWidth >= SHOW_THRESHOLD) |
| 147 | + |
| 148 | + return () => ro.disconnect() |
| 149 | + }, []) |
| 150 | + |
117 | 151 | return ( |
118 | | - <ModelSelectorRoot open={open} onOpenChange={setOpen}> |
119 | | - <ModelSelectorTrigger asChild> |
120 | | - <ButtonWithTooltip |
121 | | - tooltipContent={tooltipContent} |
122 | | - variant="ghost" |
123 | | - size="sm" |
124 | | - disabled={disabled} |
125 | | - className="hover:bg-accent gap-1.5 h-8 max-w-[180px] px-2" |
126 | | - > |
127 | | - <Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" /> |
128 | | - <span className="text-xs truncate"> |
129 | | - {selectedModel |
130 | | - ? selectedModel.modelId |
131 | | - : dict.modelConfig.default} |
132 | | - </span> |
133 | | - <ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" /> |
134 | | - </ButtonWithTooltip> |
135 | | - </ModelSelectorTrigger> |
136 | | - <ModelSelectorContent title={dict.modelConfig.selectModel}> |
137 | | - <ModelSelectorInput |
138 | | - placeholder={dict.modelConfig.searchModels} |
139 | | - /> |
140 | | - <ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"> |
141 | | - <ModelSelectorEmpty> |
142 | | - {displayModels.length === 0 && models.length > 0 |
143 | | - ? dict.modelConfig.noVerifiedModels |
144 | | - : dict.modelConfig.noModelsFound} |
145 | | - </ModelSelectorEmpty> |
146 | | - |
147 | | - {/* Server Default Option */} |
148 | | - <ModelSelectorGroup heading={dict.modelConfig.default}> |
149 | | - <ModelSelectorItem |
150 | | - value="__server_default__" |
151 | | - onSelect={handleSelect} |
152 | | - className={cn( |
153 | | - "cursor-pointer", |
154 | | - !selectedModelId && "bg-accent", |
155 | | - )} |
156 | | - > |
157 | | - <Check |
| 152 | + <div ref={wrapperRef} className="inline-block"> |
| 153 | + <ModelSelectorRoot open={open} onOpenChange={setOpen}> |
| 154 | + <ModelSelectorTrigger asChild> |
| 155 | + <ButtonWithTooltip |
| 156 | + tooltipContent={tooltipContent} |
| 157 | + variant="ghost" |
| 158 | + size="sm" |
| 159 | + disabled={disabled} |
| 160 | + className={cn( |
| 161 | + "hover:bg-accent gap-1.5 h-8 px-2 transition-all duration-150 ease-in-out", |
| 162 | + !showLabel && "px-1.5 justify-center", |
| 163 | + )} |
| 164 | + // accessibility: expose label to screen readers |
| 165 | + aria-label={tooltipContent} |
| 166 | + > |
| 167 | + <Bot className="h-4 w-4 flex-shrink-0 text-muted-foreground" /> |
| 168 | + {/* show/hide visible label based on measured width */} |
| 169 | + {showLabel ? ( |
| 170 | + <span className="text-xs truncate"> |
| 171 | + {selectedModel |
| 172 | + ? selectedModel.modelId |
| 173 | + : dict.modelConfig.default} |
| 174 | + </span> |
| 175 | + ) : ( |
| 176 | + // Keep an sr-only label for screen readers when hidden |
| 177 | + <span className="sr-only"> |
| 178 | + {selectedModel |
| 179 | + ? selectedModel.modelId |
| 180 | + : dict.modelConfig.default} |
| 181 | + </span> |
| 182 | + )} |
| 183 | + <ChevronDown className="h-3 w-3 flex-shrink-0 text-muted-foreground" /> |
| 184 | + </ButtonWithTooltip> |
| 185 | + </ModelSelectorTrigger> |
| 186 | + |
| 187 | + <ModelSelectorContent title={dict.modelConfig.selectModel}> |
| 188 | + <ModelSelectorInput |
| 189 | + placeholder={dict.modelConfig.searchModels} |
| 190 | + /> |
| 191 | + <ModelSelectorList className="[&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]"> |
| 192 | + <ModelSelectorEmpty> |
| 193 | + {displayModels.length === 0 && models.length > 0 |
| 194 | + ? dict.modelConfig.noVerifiedModels |
| 195 | + : dict.modelConfig.noModelsFound} |
| 196 | + </ModelSelectorEmpty> |
| 197 | + |
| 198 | + {/* Server Default Option */} |
| 199 | + <ModelSelectorGroup heading={dict.modelConfig.default}> |
| 200 | + <ModelSelectorItem |
| 201 | + value="__server_default__" |
| 202 | + onSelect={handleSelect} |
158 | 203 | className={cn( |
159 | | - "mr-2 h-4 w-4", |
160 | | - !selectedModelId |
161 | | - ? "opacity-100" |
162 | | - : "opacity-0", |
| 204 | + "cursor-pointer", |
| 205 | + !selectedModelId && "bg-accent", |
163 | 206 | )} |
164 | | - /> |
165 | | - <Server className="mr-2 h-4 w-4 text-muted-foreground" /> |
166 | | - <ModelSelectorName> |
167 | | - {dict.modelConfig.serverDefault} |
168 | | - </ModelSelectorName> |
169 | | - </ModelSelectorItem> |
170 | | - </ModelSelectorGroup> |
171 | | - |
172 | | - {/* Configured Models by Provider */} |
173 | | - {Array.from(groupedModels.entries()).map( |
174 | | - ([ |
175 | | - providerLabel, |
176 | | - { provider, models: providerModels }, |
177 | | - ]) => ( |
178 | | - <ModelSelectorGroup |
179 | | - key={providerLabel} |
180 | | - heading={providerLabel} |
181 | 207 | > |
182 | | - {providerModels.map((model) => ( |
183 | | - <ModelSelectorItem |
184 | | - key={model.id} |
185 | | - value={model.modelId} |
186 | | - onSelect={() => handleSelect(model.id)} |
187 | | - className="cursor-pointer" |
188 | | - > |
189 | | - <Check |
190 | | - className={cn( |
191 | | - "mr-2 h-4 w-4", |
192 | | - selectedModelId === model.id |
193 | | - ? "opacity-100" |
194 | | - : "opacity-0", |
195 | | - )} |
196 | | - /> |
197 | | - <ModelSelectorLogo |
198 | | - provider={ |
199 | | - PROVIDER_LOGO_MAP[provider] || |
200 | | - provider |
| 208 | + <Check |
| 209 | + className={cn( |
| 210 | + "mr-2 h-4 w-4", |
| 211 | + !selectedModelId |
| 212 | + ? "opacity-100" |
| 213 | + : "opacity-0", |
| 214 | + )} |
| 215 | + /> |
| 216 | + <Server className="mr-2 h-4 w-4 text-muted-foreground" /> |
| 217 | + <ModelSelectorName> |
| 218 | + {dict.modelConfig.serverDefault} |
| 219 | + </ModelSelectorName> |
| 220 | + </ModelSelectorItem> |
| 221 | + </ModelSelectorGroup> |
| 222 | + |
| 223 | + {/* Configured Models by Provider */} |
| 224 | + {Array.from(groupedModels.entries()).map( |
| 225 | + ([ |
| 226 | + providerLabel, |
| 227 | + { provider, models: providerModels }, |
| 228 | + ]) => ( |
| 229 | + <ModelSelectorGroup |
| 230 | + key={providerLabel} |
| 231 | + heading={providerLabel} |
| 232 | + > |
| 233 | + {providerModels.map((model) => ( |
| 234 | + <ModelSelectorItem |
| 235 | + key={model.id} |
| 236 | + value={model.modelId} |
| 237 | + onSelect={() => |
| 238 | + handleSelect(model.id) |
201 | 239 | } |
202 | | - className="mr-2" |
203 | | - /> |
204 | | - <ModelSelectorName> |
205 | | - {model.modelId} |
206 | | - </ModelSelectorName> |
207 | | - {model.validated !== true && ( |
208 | | - <span |
209 | | - title={ |
210 | | - dict.modelConfig |
211 | | - .unvalidatedModelWarning |
| 240 | + className="cursor-pointer" |
| 241 | + > |
| 242 | + <Check |
| 243 | + className={cn( |
| 244 | + "mr-2 h-4 w-4", |
| 245 | + selectedModelId === model.id |
| 246 | + ? "opacity-100" |
| 247 | + : "opacity-0", |
| 248 | + )} |
| 249 | + /> |
| 250 | + <ModelSelectorLogo |
| 251 | + provider={ |
| 252 | + PROVIDER_LOGO_MAP[ |
| 253 | + provider |
| 254 | + ] || provider |
212 | 255 | } |
213 | | - > |
214 | | - <AlertTriangle className="ml-auto h-3 w-3 text-warning" /> |
215 | | - </span> |
216 | | - )} |
217 | | - </ModelSelectorItem> |
218 | | - ))} |
219 | | - </ModelSelectorGroup> |
220 | | - ), |
221 | | - )} |
222 | | - |
223 | | - {/* Configure Option */} |
224 | | - <ModelSelectorSeparator /> |
225 | | - <ModelSelectorGroup> |
226 | | - <ModelSelectorItem |
227 | | - value="__configure__" |
228 | | - onSelect={handleSelect} |
229 | | - className="cursor-pointer" |
230 | | - > |
231 | | - <Settings2 className="mr-2 h-4 w-4" /> |
232 | | - <ModelSelectorName> |
233 | | - {dict.modelConfig.configureModels} |
234 | | - </ModelSelectorName> |
235 | | - </ModelSelectorItem> |
236 | | - </ModelSelectorGroup> |
237 | | - {/* Info text */} |
238 | | - <div className="px-3 py-2 text-xs text-muted-foreground border-t"> |
239 | | - {showUnvalidatedModels |
240 | | - ? dict.modelConfig.allModelsShown |
241 | | - : dict.modelConfig.onlyVerifiedShown} |
242 | | - </div> |
243 | | - </ModelSelectorList> |
244 | | - </ModelSelectorContent> |
245 | | - </ModelSelectorRoot> |
| 256 | + className="mr-2" |
| 257 | + /> |
| 258 | + <ModelSelectorName> |
| 259 | + {model.modelId} |
| 260 | + </ModelSelectorName> |
| 261 | + {model.validated !== true && ( |
| 262 | + <span |
| 263 | + title={ |
| 264 | + dict.modelConfig |
| 265 | + .unvalidatedModelWarning |
| 266 | + } |
| 267 | + > |
| 268 | + <AlertTriangle className="ml-auto h-3 w-3 text-warning" /> |
| 269 | + </span> |
| 270 | + )} |
| 271 | + </ModelSelectorItem> |
| 272 | + ))} |
| 273 | + </ModelSelectorGroup> |
| 274 | + ), |
| 275 | + )} |
| 276 | + |
| 277 | + {/* Configure Option */} |
| 278 | + <ModelSelectorSeparator /> |
| 279 | + <ModelSelectorGroup> |
| 280 | + <ModelSelectorItem |
| 281 | + value="__configure__" |
| 282 | + onSelect={handleSelect} |
| 283 | + className="cursor-pointer" |
| 284 | + > |
| 285 | + <Settings2 className="mr-2 h-4 w-4" /> |
| 286 | + <ModelSelectorName> |
| 287 | + {dict.modelConfig.configureModels} |
| 288 | + </ModelSelectorName> |
| 289 | + </ModelSelectorItem> |
| 290 | + </ModelSelectorGroup> |
| 291 | + {/* Info text */} |
| 292 | + <div className="px-3 py-2 text-xs text-muted-foreground border-t"> |
| 293 | + {showUnvalidatedModels |
| 294 | + ? dict.modelConfig.allModelsShown |
| 295 | + : dict.modelConfig.onlyVerifiedShown} |
| 296 | + </div> |
| 297 | + </ModelSelectorList> |
| 298 | + </ModelSelectorContent> |
| 299 | + </ModelSelectorRoot> |
| 300 | + </div> |
246 | 301 | ) |
247 | 302 | } |
0 commit comments