| author | |
| committer | |
| log | 6effe0a2fab1bcd48315485b580419377451c757 |
| tree | 53e80892e89972ae0d43c7fa8c85ac6a8d43465d |
| parent | bc918e74ace5dbb4fe1ce73b59d06a9ca1be9ed3 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>7 files changed, 819 insertions(+), 562 deletions(-)
apps/web/src/components/settings/ProviderAccentColorPicker.tsx+53-32| ... | @@ -228,8 +228,17 @@ export function ProviderAccentColorPicker(props: { | ... | @@ -228,8 +228,17 @@ export function ProviderAccentColorPicker(props: { |
| 228 | readonly onCommit: (value: string) => void; | 228 | readonly onCommit: (value: string) => void; |
| 229 | readonly description?: string; | 229 | readonly description?: string; |
| 230 | readonly commitDelayMs?: number; | 230 | readonly commitDelayMs?: number; |
| 231 | /** `inline` renders only the swatch row, for callers that supply their own label. */ | ||
| 232 | readonly layout?: "stacked" | "inline"; | ||
| 231 | }) { | 233 | }) { |
| 232 | const { commitDelayMs = 0, description, displayName, onCommit, value } = props; | 234 | const { |
| 235 | commitDelayMs = 0, | ||
| 236 | description, | ||
| 237 | displayName, | ||
| 238 | layout = "stacked", | ||
| 239 | onCommit, | ||
| 240 | value, | ||
| 241 | } = props; | ||
| 233 | const [optimisticValue, setOptimisticValue] = useState(() => value ?? ""); | 242 | const [optimisticValue, setOptimisticValue] = useState(() => value ?? ""); |
| 234 | const commitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); | 243 | const commitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 235 | const pendingCommitRef = useRef<string | null>(null); | 244 | const pendingCommitRef = useRef<string | null>(null); |
| ... | @@ -295,40 +304,52 @@ export function ProviderAccentColorPicker(props: { | ... | @@ -295,40 +304,52 @@ export function ProviderAccentColorPicker(props: { |
| 295 | : ""; | 304 | : ""; |
| 296 | const customSelected = Boolean(normalized && selectedValue === ""); | 305 | const customSelected = Boolean(normalized && selectedValue === ""); |
| 297 | 306 | ||
| 307 | const swatchRow = ( | ||
| 308 | <div | ||
| 309 | role="group" | ||
| 310 | aria-label="Accent color" | ||
| 311 | className="flex min-w-0 flex-wrap items-center gap-2" | ||
| 312 | > | ||
| 313 | <ProviderCustomColorPicker | ||
| 314 | displayName={displayName} | ||
| 315 | value={normalized} | ||
| 316 | selected={customSelected} | ||
| 317 | onCommit={commitAccentColor} | ||
| 318 | /> | ||
| 319 | <ColorSelector | ||
| 320 | key={selectedValue} | ||
| 321 | colors={[...PROVIDER_ACCENT_SWATCHES]} | ||
| 322 | defaultValue={selectedValue} | ||
| 323 | size="lg" | ||
| 324 | onColorSelect={commitAccentColor} | ||
| 325 | className="flex-wrap gap-1.5" | ||
| 326 | /> | ||
| 327 | <Button | ||
| 328 | type="button" | ||
| 329 | size="icon" | ||
| 330 | variant="ghost" | ||
| 331 | className={cn( | ||
| 332 | "size-7 shrink-0 text-muted-foreground transition-opacity", | ||
| 333 | normalized ? "opacity-100" : "pointer-events-none opacity-0", | ||
| 334 | )} | ||
| 335 | onClick={() => commitAccentColor("")} | ||
| 336 | aria-label={`Clear accent color for ${displayName}`} | ||
| 337 | aria-hidden={!normalized} | ||
| 338 | tabIndex={normalized ? 0 : -1} | ||
| 339 | > | ||
| 340 | <XIcon className="size-3.5" aria-hidden /> | ||
| 341 | </Button> | ||
| 342 | </div> | ||
| 343 | ); | ||
| 344 | |||
| 345 | if (layout === "inline") { | ||
| 346 | return swatchRow; | ||
| 347 | } | ||
| 348 | |||
| 298 | return ( | 349 | return ( |
| 299 | <div className="grid gap-2"> | 350 | <div className="grid gap-2"> |
| 300 | <span className="text-xs font-medium text-foreground">Accent color</span> | 351 | <span className="text-xs font-medium text-foreground">Accent color</span> |
| 301 | <div className="flex min-w-0 flex-wrap items-center gap-2"> | 352 | {swatchRow} |
| 302 | <ProviderCustomColorPicker | ||
| 303 | displayName={displayName} | ||
| 304 | value={normalized} | ||
| 305 | selected={customSelected} | ||
| 306 | onCommit={commitAccentColor} | ||
| 307 | /> | ||
| 308 | <ColorSelector | ||
| 309 | key={selectedValue} | ||
| 310 | colors={[...PROVIDER_ACCENT_SWATCHES]} | ||
| 311 | defaultValue={selectedValue} | ||
| 312 | size="lg" | ||
| 313 | onColorSelect={commitAccentColor} | ||
| 314 | className="flex-wrap gap-1.5" | ||
| 315 | /> | ||
| 316 | <Button | ||
| 317 | type="button" | ||
| 318 | size="icon" | ||
| 319 | variant="ghost" | ||
| 320 | className={cn( | ||
| 321 | "size-7 shrink-0 text-muted-foreground transition-opacity", | ||
| 322 | normalized ? "opacity-100" : "pointer-events-none opacity-0", | ||
| 323 | )} | ||
| 324 | onClick={() => commitAccentColor("")} | ||
| 325 | aria-label={`Clear accent color for ${displayName}`} | ||
| 326 | aria-hidden={!normalized} | ||
| 327 | tabIndex={normalized ? 0 : -1} | ||
| 328 | > | ||
| 329 | <XIcon className="size-3.5" aria-hidden /> | ||
| 330 | </Button> | ||
| 331 | </div> | ||
| 332 | {description ? <span className="text-xs text-muted-foreground">{description}</span> : null} | 353 | {description ? <span className="text-xs text-muted-foreground">{description}</span> : null} |
| 333 | </div> | 354 | </div> |
| 334 | ); | 355 | ); |
apps/web/src/components/settings/ProviderInstanceCard.tsx+181-190| ... | @@ -5,6 +5,8 @@ import { | ... | @@ -5,6 +5,8 @@ import { |
| 5 | CopyIcon, | 5 | CopyIcon, |
| 6 | DownloadIcon, | 6 | DownloadIcon, |
| 7 | LoaderIcon, | 7 | LoaderIcon, |
| 8 | LockIcon, | ||
| 9 | LockOpenIcon, | ||
| 8 | PlusIcon, | 10 | PlusIcon, |
| 9 | Trash2Icon, | 11 | Trash2Icon, |
| 10 | XIcon, | 12 | XIcon, |
| ... | @@ -28,12 +30,10 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | ... | @@ -28,12 +30,10 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 28 | import { normalizeProviderAccentColor } from "../../providerInstances"; | 30 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 29 | import { Badge } from "../ui/badge"; | 31 | import { Badge } from "../ui/badge"; |
| 30 | import { Button } from "../ui/button"; | 32 | import { Button } from "../ui/button"; |
| 31 | import { Checkbox } from "../ui/checkbox"; | ||
| 32 | import { DraftInput } from "../ui/draft-input"; | 33 | import { DraftInput } from "../ui/draft-input"; |
| 33 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | 34 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 34 | import { ScrollArea } from "../ui/scroll-area"; | 35 | import { ScrollArea } from "../ui/scroll-area"; |
| 35 | import { Switch } from "../ui/switch"; | 36 | import { Switch } from "../ui/switch"; |
| 36 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"; | ||
| 37 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 37 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 38 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 38 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 39 | import type { DriverOption } from "./providerDriverMeta"; | 39 | import type { DriverOption } from "./providerDriverMeta"; |
| ... | @@ -53,6 +53,13 @@ import { | ... | @@ -53,6 +53,13 @@ import { |
| 53 | 53 | ||
| 54 | const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; | 54 | const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; |
| 55 | 55 | ||
| 56 | /** Label-left field grid for the Configuration tab: one row per field. */ | ||
| 57 | const PROVIDER_FIELD_GRID_CLASS_NAME = | ||
| 58 | "grid gap-x-4 gap-y-2.5 sm:grid-cols-[8rem_minmax(0,1fr)] sm:items-start"; | ||
| 59 | /** Full-width divider row that names the group of fields below it. */ | ||
| 60 | const PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME = | ||
| 61 | "col-span-full mt-1 border-t border-border/60 pt-2.5 text-[11px] text-muted-foreground"; | ||
| 62 | |||
| 56 | let environmentVariableDraftId = 0; | 63 | let environmentVariableDraftId = 0; |
| 57 | const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; | 64 | const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; |
| 58 | 65 | ||
| ... | @@ -235,118 +242,104 @@ function ProviderEnvironmentSection(props: { | ... | @@ -235,118 +242,104 @@ function ProviderEnvironmentSection(props: { |
| 235 | publishRows(nextRows); | 242 | publishRows(nextRows); |
| 236 | }; | 243 | }; |
| 237 | 244 | ||
| 245 | const addVariable = () => | ||
| 246 | setRows([ | ||
| 247 | ...rows, | ||
| 248 | { | ||
| 249 | id: nextEnvironmentVariableDraftId(), | ||
| 250 | name: "", | ||
| 251 | value: "", | ||
| 252 | sensitive: true, | ||
| 253 | }, | ||
| 254 | ]); | ||
| 255 | |||
| 238 | return ( | 256 | return ( |
| 239 | <div className="grid gap-2"> | 257 | <div className="min-w-0 space-y-1.5"> |
| 240 | <div className="flex items-center justify-between gap-3"> | 258 | {rows.map((variable, index) => ( |
| 241 | <span className="text-xs font-medium text-foreground">Environment variables</span> | 259 | <div key={variable.id} className="flex min-w-0 items-center gap-1.5"> |
| 242 | <Button | 260 | <DraftInput |
| 243 | type="button" | 261 | size="compact" |
| 244 | size="sm" | 262 | className="w-44 shrink-0 font-mono" |
| 245 | variant="outline" | 263 | value={variable.name} |
| 246 | className="h-7 gap-1.5 px-2 text-xs" | 264 | onCommit={(name) => updateVariable(variable.id, { name: name.trim() })} |
| 247 | onClick={() => | 265 | placeholder="VARIABLE_NAME" |
| 248 | setRows([ | 266 | spellCheck={false} |
| 249 | ...rows, | 267 | aria-label={`Environment variable name ${index + 1}`} |
| 250 | { | 268 | /> |
| 251 | id: nextEnvironmentVariableDraftId(), | 269 | <span className="text-xs text-muted-foreground" aria-hidden> |
| 252 | name: "", | 270 | = |
| 253 | value: "", | 271 | </span> |
| 254 | sensitive: true, | 272 | <DraftInput |
| 255 | }, | 273 | size="compact" |
| 256 | ]) | 274 | className="min-w-0 flex-1 font-mono" |
| 257 | } | 275 | value={variable.valueRedacted ? "" : variable.value} |
| 258 | > | 276 | onCommit={(value) => updateVariable(variable.id, { value })} |
| 277 | type={variable.sensitive ? "password" : undefined} | ||
| 278 | autoComplete="off" | ||
| 279 | placeholder={ | ||
| 280 | variable.valueRedacted ? "Stored secret, enter a new value to replace" : "value" | ||
| 281 | } | ||
| 282 | spellCheck={false} | ||
| 283 | aria-label={`Environment variable value ${index + 1}`} | ||
| 284 | /> | ||
| 285 | <Tooltip> | ||
| 286 | <TooltipTrigger | ||
| 287 | render={ | ||
| 288 | <Button | ||
| 289 | type="button" | ||
| 290 | size="icon-micro" | ||
| 291 | variant="ghost-muted" | ||
| 292 | className={cn( | ||
| 293 | "[--control-icon-color:currentColor]", | ||
| 294 | variable.sensitive && "text-foreground", | ||
| 295 | )} | ||
| 296 | onClick={() => { | ||
| 297 | const sensitive = !variable.sensitive; | ||
| 298 | updateVariable(variable.id, { | ||
| 299 | sensitive, | ||
| 300 | ...(sensitive && variable.valueRedacted === undefined | ||
| 301 | ? {} | ||
| 302 | : { valueRedacted: sensitive ? variable.valueRedacted : false }), | ||
| 303 | }); | ||
| 304 | }} | ||
| 305 | aria-pressed={variable.sensitive} | ||
| 306 | aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`} | ||
| 307 | > | ||
| 308 | {variable.sensitive ? ( | ||
| 309 | <LockIcon className="size-3" /> | ||
| 310 | ) : ( | ||
| 311 | <LockOpenIcon className="size-3" /> | ||
| 312 | )} | ||
| 313 | </Button> | ||
| 314 | } | ||
| 315 | /> | ||
| 316 | <TooltipPopup side="top"> | ||
| 317 | {variable.sensitive ? "Sensitive, stored separately" : "Plain text"} | ||
| 318 | </TooltipPopup> | ||
| 319 | </Tooltip> | ||
| 320 | <Button | ||
| 321 | type="button" | ||
| 322 | size="icon-micro" | ||
| 323 | variant="ghost-muted" | ||
| 324 | className="[--control-icon-color:currentColor] hover:text-destructive" | ||
| 325 | onClick={() => removeVariable(variable.id)} | ||
| 326 | aria-label={`Remove environment variable ${variable.name || index + 1}`} | ||
| 327 | > | ||
| 328 | <XIcon className="size-3" /> | ||
| 329 | </Button> | ||
| 330 | </div> | ||
| 331 | ))} | ||
| 332 | <div className="flex min-h-[1.875rem] flex-wrap items-center gap-x-3 gap-y-1"> | ||
| 333 | <Button type="button" size="xs" variant="ghost-muted" onClick={addVariable}> | ||
| 259 | <PlusIcon className="size-3" /> | 334 | <PlusIcon className="size-3" /> |
| 260 | Add | 335 | Add variable |
| 261 | </Button> | 336 | </Button> |
| 337 | <span className="text-xs text-muted-foreground"> | ||
| 338 | {rows.length === 0 | ||
| 339 | ? "API keys, base URLs, or other per-instance CLI settings." | ||
| 340 | : "Sensitive values are stored separately and never returned to the app."} | ||
| 341 | </span> | ||
| 262 | </div> | 342 | </div> |
| 263 | {rows.length === 0 ? ( | ||
| 264 | <p className="text-xs text-muted-foreground"> | ||
| 265 | Add variables to pass API keys, base URLs, or other per-instance CLI settings. | ||
| 266 | </p> | ||
| 267 | ) : ( | ||
| 268 | <div className="overflow-hidden rounded-md border border-border/70"> | ||
| 269 | <Table> | ||
| 270 | <TableHeader className="bg-muted/25 text-[11px] text-muted-foreground"> | ||
| 271 | <TableRow className="hover:bg-transparent"> | ||
| 272 | <TableHead>Variable</TableHead> | ||
| 273 | <TableHead>Value</TableHead> | ||
| 274 | <TableHead className="w-20">Sensitive</TableHead> | ||
| 275 | <TableHead className="w-12 text-right"> | ||
| 276 | <span className="sr-only">Options</span> | ||
| 277 | </TableHead> | ||
| 278 | </TableRow> | ||
| 279 | </TableHeader> | ||
| 280 | <TableBody> | ||
| 281 | {rows.map((variable, index) => ( | ||
| 282 | <TableRow | ||
| 283 | key={variable.id} | ||
| 284 | className="border-border/60 odd:bg-muted/20 even:bg-background/20" | ||
| 285 | > | ||
| 286 | <TableCell> | ||
| 287 | <DraftInput | ||
| 288 | value={variable.name} | ||
| 289 | onCommit={(name) => updateVariable(variable.id, { name: name.trim() })} | ||
| 290 | placeholder="VARIABLE_NAME" | ||
| 291 | spellCheck={false} | ||
| 292 | aria-label={`Environment variable name ${index + 1}`} | ||
| 293 | /> | ||
| 294 | </TableCell> | ||
| 295 | <TableCell> | ||
| 296 | <DraftInput | ||
| 297 | value={variable.valueRedacted ? "" : variable.value} | ||
| 298 | onCommit={(value) => updateVariable(variable.id, { value })} | ||
| 299 | type={variable.sensitive ? "password" : undefined} | ||
| 300 | autoComplete="off" | ||
| 301 | placeholder={ | ||
| 302 | variable.valueRedacted | ||
| 303 | ? "Stored secret - enter a new value to replace" | ||
| 304 | : "Value" | ||
| 305 | } | ||
| 306 | spellCheck={false} | ||
| 307 | aria-label={`Environment variable value ${index + 1}`} | ||
| 308 | /> | ||
| 309 | </TableCell> | ||
| 310 | <TableCell className="w-20"> | ||
| 311 | <div className="flex h-8 items-center justify-center"> | ||
| 312 | <Checkbox | ||
| 313 | checked={variable.sensitive} | ||
| 314 | onCheckedChange={(checked) => { | ||
| 315 | const sensitive = Boolean(checked); | ||
| 316 | updateVariable(variable.id, { | ||
| 317 | sensitive, | ||
| 318 | ...(sensitive && variable.valueRedacted === undefined | ||
| 319 | ? {} | ||
| 320 | : { valueRedacted: sensitive ? variable.valueRedacted : false }), | ||
| 321 | }); | ||
| 322 | }} | ||
| 323 | aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`} | ||
| 324 | /> | ||
| 325 | </div> | ||
| 326 | </TableCell> | ||
| 327 | <TableCell className="w-12"> | ||
| 328 | <div className="flex justify-end"> | ||
| 329 | <Button | ||
| 330 | type="button" | ||
| 331 | size="icon-sm" | ||
| 332 | variant="ghost" | ||
| 333 | className="size-8 text-muted-foreground hover:text-destructive" | ||
| 334 | onClick={() => removeVariable(variable.id)} | ||
| 335 | aria-label={`Remove environment variable ${variable.name || index + 1}`} | ||
| 336 | > | ||
| 337 | <XIcon className="size-3.5" /> | ||
| 338 | </Button> | ||
| 339 | </div> | ||
| 340 | </TableCell> | ||
| 341 | </TableRow> | ||
| 342 | ))} | ||
| 343 | </TableBody> | ||
| 344 | </Table> | ||
| 345 | </div> | ||
| 346 | )} | ||
| 347 | <span className="text-xs text-muted-foreground"> | ||
| 348 | Sensitive values are stored separately and are not returned to the app after saving. | ||
| 349 | </span> | ||
| 350 | </div> | 343 | </div> |
| 351 | ); | 344 | ); |
| 352 | } | 345 | } |
| ... | @@ -362,7 +355,7 @@ interface ProviderInstanceCardProps { | ... | @@ -362,7 +355,7 @@ interface ProviderInstanceCardProps { |
| 362 | readonly readOnly?: boolean | undefined; | 355 | readonly readOnly?: boolean | undefined; |
| 363 | readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void; | 356 | readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void; |
| 364 | /** | 357 | /** |
| 365 | * Pass `undefined` to hide the delete button entirely. Built-in default | 358 | * Pass `undefined` to hide the delete footer entirely. Built-in default |
| 366 | * instance slots use `undefined` — they can't be deleted without losing | 359 | * instance slots use `undefined` — they can't be deleted without losing |
| 367 | * the slot, and their "reset to defaults" affordance lives on an outer | 360 | * the slot, and their "reset to defaults" affordance lives on an outer |
| 368 | * reset button instead. Explicit `| undefined` in the type accommodates | 361 | * reset button instead. Explicit `| undefined` in the type accommodates |
| ... | @@ -489,6 +482,9 @@ export function ProviderInstanceCard({ | ... | @@ -489,6 +482,9 @@ export function ProviderInstanceCard({ |
| 489 | liveModels: liveProvider?.models, | 482 | liveModels: liveProvider?.models, |
| 490 | customModels, | 483 | customModels, |
| 491 | }); | 484 | }); |
| 485 | const hiddenModelCount = modelsForDisplay.filter( | ||
| 486 | (model) => !model.isCustom && hiddenModels.includes(model.slug), | ||
| 487 | ).length; | ||
| 492 | 488 | ||
| 493 | const updateDisplayName = (value: string) => { | 489 | const updateDisplayName = (value: string) => { |
| 494 | const trimmed = value.trim(); | 490 | const trimmed = value.trim(); |
| ... | @@ -581,35 +577,9 @@ export function ProviderInstanceCard({ | ... | @@ -581,35 +577,9 @@ export function ProviderInstanceCard({ |
| 581 | </> | 577 | </> |
| 582 | ); | 578 | ); |
| 583 | 579 | ||
| 584 | const titleTailNode = ( | 580 | const titleTailNode = headerAction ? ( |
| 585 | <> | 581 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">{headerAction}</span> |
| 586 | {headerAction ? ( | 582 | ) : null; |
| 587 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> | ||
| 588 | {headerAction} | ||
| 589 | </span> | ||
| 590 | ) : null} | ||
| 591 | {onDelete ? ( | ||
| 592 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center"> | ||
| 593 | <Tooltip> | ||
| 594 | <TooltipTrigger | ||
| 595 | render={ | ||
| 596 | <Button | ||
| 597 | size="icon-micro" | ||
| 598 | variant="ghost" | ||
| 599 | className="text-muted-foreground hover:text-destructive" | ||
| 600 | onClick={onDelete} | ||
| 601 | aria-label={`Delete provider instance ${instanceId}`} | ||
| 602 | > | ||
| 603 | <Trash2Icon className="size-3" /> | ||
| 604 | </Button> | ||
| 605 | } | ||
| 606 | /> | ||
| 607 | <TooltipPopup side="top">Delete instance</TooltipPopup> | ||
| 608 | </Tooltip> | ||
| 609 | </span> | ||
| 610 | ) : null} | ||
| 611 | </> | ||
| 612 | ); | ||
| 613 | 583 | ||
| 614 | const versionCodeNode = versionLabel ? ( | 584 | const versionCodeNode = versionLabel ? ( |
| 615 | <code className="text-xs text-muted-foreground">{versionLabel}</code> | 585 | <code className="text-xs text-muted-foreground">{versionLabel}</code> |
| ... | @@ -634,7 +604,7 @@ export function ProviderInstanceCard({ | ... | @@ -634,7 +604,7 @@ export function ProviderInstanceCard({ |
| 634 | className={cn( | 604 | className={cn( |
| 635 | // Sidebar-style selection with a fixed row height so the list stays | 605 | // Sidebar-style selection with a fixed row height so the list stays |
| 636 | // even; the status line clamps to two lines instead of growing. | 606 | // even; the status line clamps to two lines instead of growing. |
| 637 | "group flex h-19 items-start gap-3 rounded-md px-3 py-2 transition-colors", | 607 | "group flex min-h-19 items-start gap-3 rounded-md px-3 py-2 transition-colors", |
| 638 | // Foreground-alpha tint so the fill reads the same in light and dark themes. | 608 | // Foreground-alpha tint so the fill reads the same in light and dark themes. |
| 639 | selected ? "bg-foreground/8" : "hover:bg-foreground/4", | 609 | selected ? "bg-foreground/8" : "hover:bg-foreground/4", |
| 640 | )} | 610 | )} |
| ... | @@ -712,7 +682,7 @@ export function ProviderInstanceCard({ | ... | @@ -712,7 +682,7 @@ export function ProviderInstanceCard({ |
| 712 | size="icon-xs" | 682 | size="icon-xs" |
| 713 | variant="ghost" | 683 | variant="ghost" |
| 714 | className={cn( | 684 | className={cn( |
| 715 | "size-5 rounded-sm p-0", | 685 | "size-5 rounded-sm p-0 [--control-icon-color:currentColor]", |
| 716 | versionAdvisory.emphasis === "strong" | 686 | versionAdvisory.emphasis === "strong" |
| 717 | ? "text-warning hover:text-warning" | 687 | ? "text-warning hover:text-warning" |
| 718 | : "text-muted-foreground hover:text-foreground", | 688 | : "text-muted-foreground hover:text-foreground", |
| ... | @@ -820,6 +790,25 @@ export function ProviderInstanceCard({ | ... | @@ -820,6 +790,25 @@ export function ProviderInstanceCard({ |
| 820 | </p> | 790 | </p> |
| 821 | ) : null} | 791 | ) : null} |
| 822 | </div> | 792 | </div> |
| 793 | {onDelete ? ( | ||
| 794 | <span | ||
| 795 | inert={readOnly} | ||
| 796 | aria-disabled={readOnly || undefined} | ||
| 797 | className={cn("shrink-0", readOnly && "opacity-50")} | ||
| 798 | > | ||
| 799 | <Button | ||
| 800 | type="button" | ||
| 801 | size="xs" | ||
| 802 | variant="ghost" | ||
| 803 | className="[--control-icon-color:currentColor] text-muted-foreground hover:text-destructive" | ||
| 804 | onClick={onDelete} | ||
| 805 | aria-label={`Delete instance ${instanceId}`} | ||
| 806 | > | ||
| 807 | <Trash2Icon className="size-3" /> | ||
| 808 | Delete instance | ||
| 809 | </Button> | ||
| 810 | </span> | ||
| 811 | ) : null} | ||
| 823 | </div> | 812 | </div> |
| 824 | 813 | ||
| 825 | <div className="flex h-11 shrink-0 border-b border-border/70 px-1"> | 814 | <div className="flex h-11 shrink-0 border-b border-border/70 px-1"> |
| ... | @@ -835,10 +824,14 @@ export function ProviderInstanceCard({ | ... | @@ -835,10 +824,14 @@ export function ProviderInstanceCard({ |
| 835 | <button | 824 | <button |
| 836 | type="button" | 825 | type="button" |
| 837 | aria-pressed={visibleTab === "models"} | 826 | aria-pressed={visibleTab === "models"} |
| 838 | className={providerSettingsTabClassName(visibleTab === "models")} | 827 | className={cn(providerSettingsTabClassName(visibleTab === "models"), "gap-1.5")} |
| 839 | onClick={() => setActiveTab("models")} | 828 | onClick={() => setActiveTab("models")} |
| 840 | > | 829 | > |
| 841 | Models | 830 | Models |
| 831 | <span className="font-normal text-muted-foreground/70"> | ||
| 832 | {modelsForDisplay.length} | ||
| 833 | {hiddenModelCount > 0 ? ` · ${hiddenModelCount} hidden` : ""} | ||
| 834 | </span> | ||
| 842 | </button> | 835 | </button> |
| 843 | ) : null} | 836 | ) : null} |
| 844 | </div> | 837 | </div> |
| ... | @@ -853,62 +846,60 @@ export function ProviderInstanceCard({ | ... | @@ -853,62 +846,60 @@ export function ProviderInstanceCard({ |
| 853 | <div | 846 | <div |
| 854 | inert={readOnly} | 847 | inert={readOnly} |
| 855 | aria-disabled={readOnly || undefined} | 848 | aria-disabled={readOnly || undefined} |
| 856 | className={cn("space-y-5 px-4 py-5", readOnly && "opacity-50 select-none")} | 849 | className={cn("px-4 py-4", readOnly && "opacity-50 select-none")} |
| 857 | > | 850 | > |
| 858 | <div> | 851 | <div className={PROVIDER_FIELD_GRID_CLASS_NAME}> |
| 859 | <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block"> | 852 | <label |
| 860 | <span className="text-xs font-medium text-foreground">Display name</span> | 853 | htmlFor={`provider-instance-${instanceId}-display-name`} |
| 854 | className="pt-1.5 text-xs font-medium text-foreground" | ||
| 855 | > | ||
| 856 | Display name | ||
| 857 | </label> | ||
| 858 | <div className="flex min-w-0 flex-wrap items-center gap-3"> | ||
| 861 | <DraftInput | 859 | <DraftInput |
| 862 | id={`provider-instance-${instanceId}-display-name`} | 860 | id={`provider-instance-${instanceId}-display-name`} |
| 863 | className="mt-1.5" | 861 | className="w-44" |
| 864 | value={instance.displayName ?? ""} | 862 | value={instance.displayName ?? ""} |
| 865 | onCommit={updateDisplayName} | 863 | onCommit={updateDisplayName} |
| 866 | placeholder={driverOption?.label ?? "Instance label"} | 864 | placeholder={driverOption?.label ?? "Instance label"} |
| 867 | spellCheck={false} | 865 | spellCheck={false} |
| 868 | /> | 866 | /> |
| 869 | <span className="mt-1 block text-xs text-muted-foreground"> | 867 | <ProviderAccentColorPicker |
| 870 | Optional label shown in the provider list. | 868 | layout="inline" |
| 871 | </span> | 869 | displayName={displayName} |
| 872 | </label> | 870 | value={accentColor} |
| 873 | </div> | 871 | onCommit={updateAccentColor} |
| 874 | 872 | commitDelayMs={120} | |
| 875 | <div> | 873 | /> |
| 876 | <ProviderAccentColorPicker | ||
| 877 | displayName={displayName} | ||
| 878 | value={accentColor} | ||
| 879 | onCommit={updateAccentColor} | ||
| 880 | commitDelayMs={120} | ||
| 881 | description="Used to distinguish this instance in picker rails and model lists." | ||
| 882 | /> | ||
| 883 | </div> | ||
| 884 | |||
| 885 | <div> | ||
| 886 | <ProviderEnvironmentSection | ||
| 887 | environment={instance.environment ?? []} | ||
| 888 | onChange={updateEnvironment} | ||
| 889 | /> | ||
| 890 | </div> | ||
| 891 | |||
| 892 | {driverOption ? ( | ||
| 893 | <ProviderSettingsForm | ||
| 894 | definition={driverOption} | ||
| 895 | value={instance.config} | ||
| 896 | idPrefix={`provider-instance-${instanceId}`} | ||
| 897 | variant="card" | ||
| 898 | onChange={updateConfig} | ||
| 899 | /> | ||
| 900 | ) : null} | ||
| 901 | |||
| 902 | {driverOption === undefined ? ( | ||
| 903 | <div> | ||
| 904 | <p className="text-xs text-muted-foreground"> | ||
| 905 | This instance uses a driver ( | ||
| 906 | <code className="text-foreground">{String(instance.driver)}</code>) that is not | ||
| 907 | shipped with the current build. Configuration values are preserved but cannot be | ||
| 908 | edited from this surface. | ||
| 909 | </p> | ||
| 910 | </div> | 874 | </div> |
| 911 | ) : null} | 875 | <div className={PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME}>Runtime</div> |
| 876 | {driverOption ? ( | ||
| 877 | <ProviderSettingsForm | ||
| 878 | definition={driverOption} | ||
| 879 | value={instance.config} | ||
| 880 | idPrefix={`provider-instance-${instanceId}`} | ||
| 881 | variant="grid" | ||
| 882 | onChange={updateConfig} | ||
| 883 | /> | ||
| 884 | ) : ( | ||
| 885 | <> | ||
| 886 | <span className="pt-1.5 text-xs font-medium text-foreground">Driver</span> | ||
| 887 | <p className="text-xs text-muted-foreground"> | ||
| 888 | This instance uses a driver ( | ||
| 889 | <code className="text-foreground">{String(instance.driver)}</code>) that is not | ||
| 890 | shipped with the current build. Configuration values are preserved but cannot be | ||
| 891 | edited from this surface. | ||
| 892 | </p> | ||
| 893 | </> | ||
| 894 | )} | ||
| 895 | <div className={PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME}>Environment</div> | ||
| 896 | <div className="col-span-full"> | ||
| 897 | <ProviderEnvironmentSection | ||
| 898 | environment={instance.environment ?? []} | ||
| 899 | onChange={updateEnvironment} | ||
| 900 | /> | ||
| 901 | </div> | ||
| 902 | </div> | ||
| 912 | </div> | 903 | </div> |
| 913 | </ScrollArea> | 904 | </ScrollArea> |
| 914 | {driverOption !== undefined ? ( | 905 | {driverOption !== undefined ? ( |
apps/web/src/components/settings/ProviderModelsSection.test.ts created+23| ... | @@ -0,0 +1,23 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | import type { ServerProviderModel } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { groupModelsForDisplay } from "./ProviderModelsSection"; | ||
| 5 | |||
| 6 | function model(slug: string, isCustom = false): ServerProviderModel { | ||
| 7 | return { slug, name: slug, isCustom, capabilities: null }; | ||
| 8 | } | ||
| 9 | |||
| 10 | describe("groupModelsForDisplay", () => { | ||
| 11 | it("lists favorites first, then visible models in user order, then hidden ones", () => { | ||
| 12 | const models = [model("a"), model("b"), model("c"), model("d"), model("custom", true)]; | ||
| 13 | |||
| 14 | const display = groupModelsForDisplay(models, { | ||
| 15 | favoriteModels: new Set(["c"]), | ||
| 16 | hiddenModels: new Set(["a", "custom"]), | ||
| 17 | modelOrder: ["d", "b"], | ||
| 18 | }); | ||
| 19 | |||
| 20 | // A custom model is never hidden, even if its slug is in the hidden set. | ||
| 21 | expect(display.map((entry) => entry.slug)).toEqual(["c", "d", "b", "custom", "a"]); | ||
| 22 | }); | ||
| 23 | }); | ||
apps/web/src/components/settings/ProviderModelsSection.tsx+397-252| ... | @@ -1,16 +1,7 @@ | ... | @@ -1,16 +1,7 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { ArrowDownIcon, ArrowUpIcon, PlusIcon, StarIcon, XIcon } from "lucide-react"; |
| 4 | ArrowDownIcon, | 4 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 5 | ArrowUpIcon, | ||
| 6 | EyeIcon, | ||
| 7 | EyeOffIcon, | ||
| 8 | InfoIcon, | ||
| 9 | PlusIcon, | ||
| 10 | StarIcon, | ||
| 11 | XIcon, | ||
| 12 | } from "lucide-react"; | ||
| 13 | import { useMemo, useRef, useState } from "react"; | ||
| 14 | import { | 5 | import { |
| 15 | ProviderDriverKind, | 6 | ProviderDriverKind, |
| 16 | type ProviderInstanceId, | 7 | type ProviderInstanceId, |
| ... | @@ -23,7 +14,7 @@ import { sortModelsForProviderInstance } from "../../modelOrdering"; | ... | @@ -23,7 +14,7 @@ import { sortModelsForProviderInstance } from "../../modelOrdering"; |
| 23 | import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection"; | 14 | import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection"; |
| 24 | import { Button } from "../ui/button"; | 15 | import { Button } from "../ui/button"; |
| 25 | import { Input } from "../ui/input"; | 16 | import { Input } from "../ui/input"; |
| 26 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | 17 | import { Switch } from "../ui/switch"; |
| 27 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 18 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 28 | 19 | ||
| 29 | /** | 20 | /** |
| ... | @@ -38,6 +29,72 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial<Record<ProviderDriverKind, strin | ... | @@ -38,6 +29,72 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial<Record<ProviderDriverKind, strin |
| 38 | [ProviderDriverKind.make("opencode")]: "openai/gpt-5", | 29 | [ProviderDriverKind.make("opencode")]: "openai/gpt-5", |
| 39 | }; | 30 | }; |
| 40 | 31 | ||
| 32 | /** Above this many models the list gets a filter input. */ | ||
| 33 | const FILTER_THRESHOLD = 8; | ||
| 34 | |||
| 35 | /** | ||
| 36 | * Short capability words shown after a model's slug. Claude and Cursor report | ||
| 37 | * fast mode as a boolean `fastMode` option; Codex reports it as a | ||
| 38 | * `serviceTier` select whose fast tier is labelled "Fast" (catalog id | ||
| 39 | * `priority`, or `fast` from the speed-tier fallback), matching the composer. | ||
| 40 | */ | ||
| 41 | function describeModelCapabilities(model: ServerProviderModel): string[] { | ||
| 42 | const descriptors = model.capabilities?.optionDescriptors ?? []; | ||
| 43 | const labels: string[] = []; | ||
| 44 | const hasFastMode = descriptors.some( | ||
| 45 | (descriptor) => | ||
| 46 | descriptor.id === "fastMode" || | ||
| 47 | (descriptor.id === "serviceTier" && | ||
| 48 | descriptor.type === "select" && | ||
| 49 | descriptor.options.some((option) => option.id === "fast" || option.label === "Fast")), | ||
| 50 | ); | ||
| 51 | if (hasFastMode) labels.push("Fast mode"); | ||
| 52 | if (descriptors.some((descriptor) => descriptor.id === "thinking")) labels.push("Thinking"); | ||
| 53 | if ( | ||
| 54 | descriptors.some( | ||
| 55 | (descriptor) => | ||
| 56 | descriptor.type === "select" && | ||
| 57 | (descriptor.id === "reasoningEffort" || | ||
| 58 | descriptor.id === "effort" || | ||
| 59 | descriptor.id === "reasoning" || | ||
| 60 | descriptor.id === "variant"), | ||
| 61 | ) | ||
| 62 | ) { | ||
| 63 | labels.push("Reasoning"); | ||
| 64 | } | ||
| 65 | return labels; | ||
| 66 | } | ||
| 67 | |||
| 68 | /** | ||
| 69 | * Display order for the models list: favorites first (in user order), then | ||
| 70 | * visible models, then hidden ones. Hidden models sink so the list reads | ||
| 71 | * top-down as "what the picker shows"; moves only swap rows within the same | ||
| 72 | * group, and the resulting display order is what gets persisted as | ||
| 73 | * `modelOrder`. | ||
| 74 | */ | ||
| 75 | export function groupModelsForDisplay< | ||
| 76 | T extends { readonly slug: string; readonly isCustom: boolean }, | ||
| 77 | >( | ||
| 78 | models: ReadonlyArray<T>, | ||
| 79 | options: { | ||
| 80 | readonly favoriteModels: ReadonlySet<string>; | ||
| 81 | readonly hiddenModels: ReadonlySet<string>; | ||
| 82 | readonly modelOrder: ReadonlyArray<string>; | ||
| 83 | }, | ||
| 84 | ): T[] { | ||
| 85 | const ordered = sortModelsForProviderInstance(models, { | ||
| 86 | favoriteModels: options.favoriteModels, | ||
| 87 | groupFavorites: true, | ||
| 88 | modelOrder: options.modelOrder, | ||
| 89 | }); | ||
| 90 | const isHidden = (model: T) => !model.isCustom && options.hiddenModels.has(model.slug); | ||
| 91 | return [ | ||
| 92 | ...ordered.filter((model) => options.favoriteModels.has(model.slug)), | ||
| 93 | ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && !isHidden(model)), | ||
| 94 | ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && isHidden(model)), | ||
| 95 | ]; | ||
| 96 | } | ||
| 97 | |||
| 41 | interface ProviderModelsSectionProps { | 98 | interface ProviderModelsSectionProps { |
| 42 | /** Identifier used to namespace input ids within the DOM. */ | 99 | /** Identifier used to namespace input ids within the DOM. */ |
| 43 | readonly instanceId: ProviderInstanceId; | 100 | readonly instanceId: ProviderInstanceId; |
| ... | @@ -99,17 +156,50 @@ export function ProviderModelsSection({ | ... | @@ -99,17 +156,50 @@ export function ProviderModelsSection({ |
| 99 | onModelOrderChange, | 156 | onModelOrderChange, |
| 100 | }: ProviderModelsSectionProps) { | 157 | }: ProviderModelsSectionProps) { |
| 101 | const [input, setInput] = useState(""); | 158 | const [input, setInput] = useState(""); |
| 159 | const [isAdding, setIsAdding] = useState(false); | ||
| 160 | const [filter, setFilter] = useState(""); | ||
| 102 | const [error, setError] = useState<string | null>(null); | 161 | const [error, setError] = useState<string | null>(null); |
| 103 | const listRef = useRef<HTMLDivElement | null>(null); | 162 | const listRef = useRef<HTMLDivElement>(null); |
| 163 | // Slug of a just-added custom model, scrolled into view once its row exists. | ||
| 164 | const scrollToSlugRef = useRef<string | null>(null); | ||
| 104 | const hiddenModelSet = useMemo(() => new Set(hiddenModels), [hiddenModels]); | 165 | const hiddenModelSet = useMemo(() => new Set(hiddenModels), [hiddenModels]); |
| 105 | const favoriteModelSet = useMemo(() => new Set(favoriteModels), [favoriteModels]); | 166 | const favoriteModelSet = useMemo(() => new Set(favoriteModels), [favoriteModels]); |
| 106 | const orderedModels = useMemo(() => { | 167 | const displayModels = useMemo( |
| 107 | return sortModelsForProviderInstance(models, { | 168 | () => |
| 108 | favoriteModels: favoriteModelSet, | 169 | groupModelsForDisplay(models, { |
| 109 | groupFavorites: true, | 170 | favoriteModels: favoriteModelSet, |
| 110 | modelOrder, | 171 | hiddenModels: hiddenModelSet, |
| 111 | }); | 172 | modelOrder, |
| 112 | }, [favoriteModelSet, modelOrder, models]); | 173 | }), |
| 174 | [favoriteModelSet, hiddenModelSet, modelOrder, models], | ||
| 175 | ); | ||
| 176 | const favoriteCount = displayModels.filter((model) => favoriteModelSet.has(model.slug)).length; | ||
| 177 | const hiddenCount = displayModels.filter( | ||
| 178 | (model) => !model.isCustom && hiddenModelSet.has(model.slug), | ||
| 179 | ).length; | ||
| 180 | const showFilter = models.length > FILTER_THRESHOLD; | ||
| 181 | const normalizedFilter = filter.trim().toLowerCase(); | ||
| 182 | const isFiltering = showFilter && normalizedFilter.length > 0; | ||
| 183 | const visibleModels = isFiltering | ||
| 184 | ? displayModels.filter( | ||
| 185 | (model) => | ||
| 186 | model.name.toLowerCase().includes(normalizedFilter) || | ||
| 187 | model.slug.toLowerCase().includes(normalizedFilter), | ||
| 188 | ) | ||
| 189 | : displayModels; | ||
| 190 | |||
| 191 | // The parent commits the new custom model and hands back an updated | ||
| 192 | // `models` list, so the row can only be scrolled to after that render. | ||
| 193 | useEffect(() => { | ||
| 194 | const slug = scrollToSlugRef.current; | ||
| 195 | if (slug === null) return; | ||
| 196 | const row = listRef.current?.querySelector<HTMLElement>( | ||
| 197 | `[data-model-slug="${CSS.escape(slug)}"]`, | ||
| 198 | ); | ||
| 199 | if (!row) return; | ||
| 200 | scrollToSlugRef.current = null; | ||
| 201 | row.scrollIntoView({ block: "nearest" }); | ||
| 202 | }, [displayModels]); | ||
| 113 | 203 | ||
| 114 | const handleAdd = () => { | 204 | const handleAdd = () => { |
| 115 | const normalized = normalizeCustomModelSlug(input); | 205 | const normalized = normalizeCustomModelSlug(input); |
| ... | @@ -130,24 +220,20 @@ export function ProviderModelsSection({ | ... | @@ -130,24 +220,20 @@ export function ProviderModelsSection({ |
| 130 | return; | 220 | return; |
| 131 | } | 221 | } |
| 132 | 222 | ||
| 223 | // Clear the filter so the new row renders even when it does not match, | ||
| 224 | // which is also what lets the pending scroll target resolve and clear. | ||
| 225 | scrollToSlugRef.current = normalized; | ||
| 226 | setFilter(""); | ||
| 133 | onChange([...customModels, normalized]); | 227 | onChange([...customModels, normalized]); |
| 134 | setInput(""); | 228 | setInput(""); |
| 135 | setError(null); | 229 | setError(null); |
| 230 | setIsAdding(false); | ||
| 231 | }; | ||
| 136 | 232 | ||
| 137 | // Scroll the new row into view once the DOM reflects the commit. | 233 | const cancelAdd = () => { |
| 138 | // `MutationObserver` handles the one-frame gap between `onChange` and | 234 | setInput(""); |
| 139 | // the `models` prop update; the `requestAnimationFrame` covers the | 235 | setError(null); |
| 140 | // common case where the parent updates synchronously. | 236 | setIsAdding(false); |
| 141 | const el = listRef.current; | ||
| 142 | if (!el) return; | ||
| 143 | const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); | ||
| 144 | requestAnimationFrame(scrollToEnd); | ||
| 145 | const observer = new MutationObserver(() => { | ||
| 146 | scrollToEnd(); | ||
| 147 | observer.disconnect(); | ||
| 148 | }); | ||
| 149 | observer.observe(el, { childList: true, subtree: true }); | ||
| 150 | setTimeout(() => observer.disconnect(), 2_000); | ||
| 151 | }; | 237 | }; |
| 152 | 238 | ||
| 153 | const handleRemove = (slug: string) => { | 239 | const handleRemove = (slug: string) => { |
| ... | @@ -157,12 +243,11 @@ export function ProviderModelsSection({ | ... | @@ -157,12 +243,11 @@ export function ProviderModelsSection({ |
| 157 | setError(null); | 243 | setError(null); |
| 158 | }; | 244 | }; |
| 159 | 245 | ||
| 160 | const handleToggleHidden = (slug: string) => { | 246 | const setHidden = (slug: string, hidden: boolean) => { |
| 161 | if (hiddenModelSet.has(slug)) { | 247 | if (hidden === hiddenModelSet.has(slug)) return; |
| 162 | onHiddenModelsChange(hiddenModels.filter((model) => model !== slug)); | 248 | onHiddenModelsChange( |
| 163 | return; | 249 | hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug), |
| 164 | } | 250 | ); |
| 165 | onHiddenModelsChange([...hiddenModels, slug]); | ||
| 166 | }; | 251 | }; |
| 167 | 252 | ||
| 168 | const handleToggleFavorite = (slug: string) => { | 253 | const handleToggleFavorite = (slug: string) => { |
| ... | @@ -173,237 +258,297 @@ export function ProviderModelsSection({ | ... | @@ -173,237 +258,297 @@ export function ProviderModelsSection({ |
| 173 | onFavoriteModelsChange([...favoriteModels, slug]); | 258 | onFavoriteModelsChange([...favoriteModels, slug]); |
| 174 | }; | 259 | }; |
| 175 | 260 | ||
| 261 | // Rows only trade places with a neighbour in the same group (favorites, | ||
| 262 | // visible, hidden), and the display order is persisted as the new order. | ||
| 263 | const groupOf = (model: (typeof displayModels)[number]) => | ||
| 264 | favoriteModelSet.has(model.slug) | ||
| 265 | ? "favorite" | ||
| 266 | : !model.isCustom && hiddenModelSet.has(model.slug) | ||
| 267 | ? "hidden" | ||
| 268 | : "visible"; | ||
| 176 | const handleMove = (slug: string, direction: -1 | 1) => { | 269 | const handleMove = (slug: string, direction: -1 | 1) => { |
| 177 | const slugs = orderedModels.map((model) => model.slug); | 270 | const index = displayModels.findIndex((model) => model.slug === slug); |
| 178 | const index = slugs.indexOf(slug); | ||
| 179 | const nextIndex = index + direction; | 271 | const nextIndex = index + direction; |
| 180 | if (index < 0 || nextIndex < 0 || nextIndex >= slugs.length) { | 272 | if (index < 0 || nextIndex < 0 || nextIndex >= displayModels.length) return; |
| 181 | return; | 273 | if (groupOf(displayModels[index]!) !== groupOf(displayModels[nextIndex]!)) return; |
| 182 | } | 274 | const next = displayModels.map((model) => model.slug); |
| 183 | const next = [...slugs]; | ||
| 184 | [next[index], next[nextIndex]] = [next[nextIndex]!, next[index]!]; | 275 | [next[index], next[nextIndex]] = [next[nextIndex]!, next[index]!]; |
| 185 | onModelOrderChange(next); | 276 | onModelOrderChange(next); |
| 186 | }; | 277 | }; |
| 187 | 278 | ||
| 279 | type DisplayModel = (typeof displayModels)[number]; | ||
| 280 | |||
| 281 | const starButton = (model: DisplayModel, isFavorite: boolean) => ( | ||
| 282 | <Tooltip> | ||
| 283 | <TooltipTrigger | ||
| 284 | render={ | ||
| 285 | <Button | ||
| 286 | size="icon-micro" | ||
| 287 | variant="ghost" | ||
| 288 | className={cn( | ||
| 289 | "[--control-icon-color:currentColor]", | ||
| 290 | isFavorite | ||
| 291 | ? "text-yellow-500 hover:text-yellow-600" | ||
| 292 | : "text-muted-foreground/40 hover:text-muted-foreground", | ||
| 293 | )} | ||
| 294 | onClick={() => handleToggleFavorite(model.slug)} | ||
| 295 | aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${ | ||
| 296 | isFavorite ? "from" : "to" | ||
| 297 | } favorites`} | ||
| 298 | /> | ||
| 299 | } | ||
| 300 | > | ||
| 301 | <StarIcon className={cn("size-3", isFavorite && "fill-current")} /> | ||
| 302 | </TooltipTrigger> | ||
| 303 | <TooltipPopup side="top"> | ||
| 304 | {isFavorite ? "Remove from favorites" : "Add to favorites"} | ||
| 305 | </TooltipPopup> | ||
| 306 | </Tooltip> | ||
| 307 | ); | ||
| 308 | |||
| 309 | // Reorder and remove stay in the row at all times (dimmed when unavailable) | ||
| 310 | // so ordering is discoverable without hovering. | ||
| 311 | const rowActions = ( | ||
| 312 | model: DisplayModel, | ||
| 313 | options: { | ||
| 314 | readonly isHidden: boolean; | ||
| 315 | readonly canMoveUp: boolean; | ||
| 316 | readonly canMoveDown: boolean; | ||
| 317 | }, | ||
| 318 | ) => ( | ||
| 319 | <span className="flex shrink-0 items-center justify-end gap-0.5"> | ||
| 320 | {!options.isHidden && !isFiltering ? ( | ||
| 321 | <> | ||
| 322 | <Tooltip> | ||
| 323 | <TooltipTrigger | ||
| 324 | render={ | ||
| 325 | <Button | ||
| 326 | size="icon-micro" | ||
| 327 | variant="ghost-muted" | ||
| 328 | disabled={!options.canMoveUp} | ||
| 329 | onClick={() => handleMove(model.slug, -1)} | ||
| 330 | aria-label={`Move ${model.name} up`} | ||
| 331 | /> | ||
| 332 | } | ||
| 333 | > | ||
| 334 | <ArrowUpIcon className="size-3" /> | ||
| 335 | </TooltipTrigger> | ||
| 336 | <TooltipPopup side="top">Move up</TooltipPopup> | ||
| 337 | </Tooltip> | ||
| 338 | <Tooltip> | ||
| 339 | <TooltipTrigger | ||
| 340 | render={ | ||
| 341 | <Button | ||
| 342 | size="icon-micro" | ||
| 343 | variant="ghost-muted" | ||
| 344 | disabled={!options.canMoveDown} | ||
| 345 | onClick={() => handleMove(model.slug, 1)} | ||
| 346 | aria-label={`Move ${model.name} down`} | ||
| 347 | /> | ||
| 348 | } | ||
| 349 | > | ||
| 350 | <ArrowDownIcon className="size-3" /> | ||
| 351 | </TooltipTrigger> | ||
| 352 | <TooltipPopup side="top">Move down</TooltipPopup> | ||
| 353 | </Tooltip> | ||
| 354 | </> | ||
| 355 | ) : null} | ||
| 356 | {model.isCustom ? ( | ||
| 357 | <Tooltip> | ||
| 358 | <TooltipTrigger | ||
| 359 | render={ | ||
| 360 | <Button | ||
| 361 | size="icon-micro" | ||
| 362 | variant="ghost-muted" | ||
| 363 | aria-label={`Remove ${model.slug}`} | ||
| 364 | onClick={() => handleRemove(model.slug)} | ||
| 365 | /> | ||
| 366 | } | ||
| 367 | > | ||
| 368 | <XIcon className="size-3" /> | ||
| 369 | </TooltipTrigger> | ||
| 370 | <TooltipPopup side="top">Remove custom model</TooltipPopup> | ||
| 371 | </Tooltip> | ||
| 372 | ) : null} | ||
| 373 | </span> | ||
| 374 | ); | ||
| 375 | |||
| 376 | const pickerTooltip = (model: DisplayModel, isHidden: boolean) => | ||
| 377 | model.isCustom | ||
| 378 | ? "Custom models are always shown in the picker" | ||
| 379 | : isHidden | ||
| 380 | ? "Hidden from picker" | ||
| 381 | : "Shown in picker"; | ||
| 382 | |||
| 383 | // The trigger is a wrapper span: a disabled switch gets no pointer events, | ||
| 384 | // so it could not open the tooltip itself. | ||
| 385 | const pickerSwitch = (model: DisplayModel, isHidden: boolean) => ( | ||
| 386 | <Tooltip> | ||
| 387 | <TooltipTrigger render={<span className="flex shrink-0 items-center" />}> | ||
| 388 | <Switch | ||
| 389 | className="sm:[--thumb-size:--spacing(3.5)]" | ||
| 390 | checked={!isHidden} | ||
| 391 | disabled={model.isCustom} | ||
| 392 | onCheckedChange={(checked) => setHidden(model.slug, !checked)} | ||
| 393 | aria-label={`Show ${model.name} in the model picker`} | ||
| 394 | /> | ||
| 395 | </TooltipTrigger> | ||
| 396 | <TooltipPopup side="top">{pickerTooltip(model, isHidden)}</TooltipPopup> | ||
| 397 | </Tooltip> | ||
| 398 | ); | ||
| 399 | |||
| 400 | const renderRow = (model: DisplayModel) => { | ||
| 401 | const capLabels = describeModelCapabilities(model); | ||
| 402 | const group = groupOf(model); | ||
| 403 | // Hidden is read from the preference itself: a favorited model can still be | ||
| 404 | // hidden, and its switch must say so even though it sits in the favorites group. | ||
| 405 | const isHidden = !model.isCustom && hiddenModelSet.has(model.slug); | ||
| 406 | const isFavorite = group === "favorite"; | ||
| 407 | const index = displayModels.indexOf(model); | ||
| 408 | const previousModel = displayModels[index - 1]; | ||
| 409 | const nextModel = displayModels[index + 1]; | ||
| 410 | // Reordering a filtered view would be ambiguous, so arrows only show on | ||
| 411 | // the full list. | ||
| 412 | const canMoveUp = | ||
| 413 | !isFiltering && previousModel !== undefined && groupOf(previousModel) === group; | ||
| 414 | const canMoveDown = !isFiltering && nextModel !== undefined && groupOf(nextModel) === group; | ||
| 415 | const nameClassName = cn("text-xs", isHidden ? "text-muted-foreground" : "text-foreground/90"); | ||
| 416 | |||
| 417 | return ( | ||
| 418 | <div | ||
| 419 | key={`${instanceId}:${model.slug}`} | ||
| 420 | data-model-slug={model.slug} | ||
| 421 | className={cn( | ||
| 422 | "grid h-7 grid-cols-[1.5rem_minmax(0,1fr)_auto_4rem_auto] items-center gap-2 rounded-md px-2 transition-colors hover:bg-muted/30", | ||
| 423 | isHidden && "opacity-50", | ||
| 424 | )} | ||
| 425 | > | ||
| 426 | {starButton(model, isFavorite)} | ||
| 427 | <span className="flex min-w-0 items-baseline gap-2"> | ||
| 428 | <span className={cn(nameClassName, "truncate")}>{model.name}</span> | ||
| 429 | {model.isCustom ? ( | ||
| 430 | <span className="text-[11px] text-muted-foreground/70">custom</span> | ||
| 431 | ) : model.name !== model.slug ? ( | ||
| 432 | <code className="truncate font-mono text-[11px] text-muted-foreground/70"> | ||
| 433 | {model.slug} | ||
| 434 | </code> | ||
| 435 | ) : null} | ||
| 436 | </span> | ||
| 437 | {/* | ||
| 438 | Always a grid item so the columns line up across rows; the text | ||
| 439 | itself drops out on phone widths where it would starve the name. | ||
| 440 | */} | ||
| 441 | <span className="text-[11px] text-muted-foreground/70"> | ||
| 442 | {capLabels.length > 0 ? ( | ||
| 443 | <span className="hidden sm:inline">{capLabels.join(" · ")}</span> | ||
| 444 | ) : null} | ||
| 445 | </span> | ||
| 446 | {rowActions(model, { isHidden, canMoveUp, canMoveDown })} | ||
| 447 | {pickerSwitch(model, isHidden)} | ||
| 448 | </div> | ||
| 449 | ); | ||
| 450 | }; | ||
| 451 | |||
| 452 | const groupLabel = (label: string, isFirst: boolean) => ( | ||
| 453 | <div className={cn("px-2 pb-1.5 text-[11px] text-muted-foreground", isFirst ? "pt-1" : "pt-5")}> | ||
| 454 | {label} | ||
| 455 | </div> | ||
| 456 | ); | ||
| 457 | |||
| 188 | return ( | 458 | return ( |
| 189 | <div className="lg:flex lg:h-full lg:min-h-0 lg:flex-col"> | 459 | <div className="lg:flex lg:h-full lg:min-h-0 lg:flex-col"> |
| 190 | <div className="text-xs font-medium text-foreground">Models</div> | 460 | <div className="flex flex-wrap items-center justify-between gap-2"> |
| 191 | <div className="mt-1 text-xs text-muted-foreground"> | 461 | {showFilter ? ( |
| 192 | {models.length} model{models.length === 1 ? "" : "s"} available. | 462 | <Input |
| 463 | value={filter} | ||
| 464 | onChange={(event) => setFilter(event.target.value)} | ||
| 465 | placeholder="Filter models" | ||
| 466 | size="compact" | ||
| 467 | className="w-56" | ||
| 468 | spellCheck={false} | ||
| 469 | aria-label="Filter models" | ||
| 470 | /> | ||
| 471 | ) : null} | ||
| 472 | <span className="text-xs text-muted-foreground"> | ||
| 473 | {models.length} model{models.length === 1 ? "" : "s"} | ||
| 474 | {favoriteCount > 0 ? ` · ${favoriteCount} favorite${favoriteCount === 1 ? "" : "s"}` : ""} | ||
| 475 | {hiddenCount > 0 ? ` · ${hiddenCount} hidden` : ""} | ||
| 476 | </span> | ||
| 193 | </div> | 477 | </div> |
| 194 | <div | 478 | <div |
| 195 | ref={listRef} | 479 | ref={listRef} |
| 196 | className="mt-2 max-h-40 overflow-y-auto pb-1 lg:min-h-0 lg:max-h-none lg:flex-1" | 480 | className="mt-2 -mx-2 max-h-64 overflow-y-auto lg:max-h-none lg:min-h-0 lg:flex-1" |
| 197 | > | 481 | > |
| 198 | {orderedModels.map((model, index) => { | 482 | {visibleModels.length === 0 ? ( |
| 199 | const caps = model.capabilities; | 483 | <p className="px-2 py-2 text-xs text-muted-foreground"> |
| 200 | const capLabels: string[] = []; | 484 | {isFiltering ? "No models match." : "No models reported for this provider yet."} |
| 201 | const isHidden = !model.isCustom && hiddenModelSet.has(model.slug); | 485 | </p> |
| 202 | const isFavorite = favoriteModelSet.has(model.slug); | 486 | ) : null} |
| 203 | const previousModel = orderedModels[index - 1]; | 487 | {visibleModels.map((model, index) => { |
| 204 | const nextModel = orderedModels[index + 1]; | 488 | const group = groupOf(model); |
| 205 | const canMoveUp = | 489 | const previous = visibleModels[index - 1]; |
| 206 | previousModel !== undefined && favoriteModelSet.has(previousModel.slug) === isFavorite; | 490 | const startsGroup = previous === undefined || groupOf(previous) !== group; |
| 207 | const canMoveDown = | ||
| 208 | nextModel !== undefined && favoriteModelSet.has(nextModel.slug) === isFavorite; | ||
| 209 | const descriptors = caps?.optionDescriptors ?? []; | ||
| 210 | if (descriptors.some((descriptor) => descriptor.id === "fastMode")) { | ||
| 211 | capLabels.push("Fast mode"); | ||
| 212 | } | ||
| 213 | if (descriptors.some((descriptor) => descriptor.id === "thinking")) { | ||
| 214 | capLabels.push("Thinking"); | ||
| 215 | } | ||
| 216 | if ( | ||
| 217 | descriptors.some( | ||
| 218 | (descriptor) => | ||
| 219 | descriptor.type === "select" && | ||
| 220 | (descriptor.id === "reasoningEffort" || | ||
| 221 | descriptor.id === "effort" || | ||
| 222 | descriptor.id === "reasoning" || | ||
| 223 | descriptor.id === "variant"), | ||
| 224 | ) | ||
| 225 | ) { | ||
| 226 | capLabels.push("Reasoning"); | ||
| 227 | } | ||
| 228 | const hasDetails = capLabels.length > 0 || model.name !== model.slug; | ||
| 229 | |||
| 230 | return ( | 491 | return ( |
| 231 | <div | 492 | <div key={`${instanceId}:${model.slug}:group`}> |
| 232 | key={`${instanceId}:${model.slug}`} | 493 | {startsGroup && favoriteCount > 0 && group === "favorite" |
| 233 | className={cn( | 494 | ? groupLabel("Favorites", index === 0) |
| 234 | "grid min-h-7 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 py-1", | 495 | : null} |
| 235 | isHidden && "text-muted-foreground", | 496 | {startsGroup && favoriteCount > 0 && group === "visible" |
| 236 | )} | 497 | ? groupLabel("All", index === 0) |
| 237 | > | 498 | : null} |
| 238 | <div className="flex min-w-0 items-center gap-1"> | 499 | {startsGroup && group === "hidden" |
| 239 | <span | 500 | ? groupLabel("Hidden from picker", index === 0) |
| 240 | className={cn( | 501 | : null} |
| 241 | "min-w-0 truncate text-xs", | 502 | {renderRow(model)} |
| 242 | isHidden ? "text-muted-foreground line-through" : "text-foreground/90", | ||
| 243 | )} | ||
| 244 | > | ||
| 245 | {model.name} | ||
| 246 | </span> | ||
| 247 | {hasDetails ? ( | ||
| 248 | <Popover> | ||
| 249 | <PopoverTrigger | ||
| 250 | openOnHover | ||
| 251 | delay={250} | ||
| 252 | closeDelay={100} | ||
| 253 | render={ | ||
| 254 | <Button | ||
| 255 | size="icon-micro" | ||
| 256 | variant="ghost" | ||
| 257 | className="text-muted-foreground/60 hover:text-muted-foreground" | ||
| 258 | aria-label={`Details for ${model.name}`} | ||
| 259 | /> | ||
| 260 | } | ||
| 261 | > | ||
| 262 | <InfoIcon className="size-3" /> | ||
| 263 | </PopoverTrigger> | ||
| 264 | <PopoverPopup side="top" tooltipStyle className="max-w-56"> | ||
| 265 | <div className="space-y-1"> | ||
| 266 | <code className="block text-[11px] text-foreground">{model.slug}</code> | ||
| 267 | {capLabels.length > 0 ? ( | ||
| 268 | <div className="flex flex-wrap gap-x-2 gap-y-0.5"> | ||
| 269 | {capLabels.map((label) => ( | ||
| 270 | <span key={label} className="text-[10px] text-muted-foreground"> | ||
| 271 | {label} | ||
| 272 | </span> | ||
| 273 | ))} | ||
| 274 | </div> | ||
| 275 | ) : null} | ||
| 276 | </div> | ||
| 277 | </PopoverPopup> | ||
| 278 | </Popover> | ||
| 279 | ) : null} | ||
| 280 | {isHidden ? ( | ||
| 281 | <span className="text-[10px] text-muted-foreground">hidden</span> | ||
| 282 | ) : null} | ||
| 283 | {model.isCustom ? ( | ||
| 284 | <span className="text-[10px] text-muted-foreground">custom</span> | ||
| 285 | ) : null} | ||
| 286 | </div> | ||
| 287 | <div className="flex shrink-0 items-center gap-0.5"> | ||
| 288 | <Tooltip> | ||
| 289 | <TooltipTrigger | ||
| 290 | render={ | ||
| 291 | <Button | ||
| 292 | size="icon-micro" | ||
| 293 | variant="ghost-muted" | ||
| 294 | className={cn(isFavorite && "text-yellow-500 hover:text-yellow-600")} | ||
| 295 | onClick={() => handleToggleFavorite(model.slug)} | ||
| 296 | aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${ | ||
| 297 | isFavorite ? "from" : "to" | ||
| 298 | } favorites`} | ||
| 299 | /> | ||
| 300 | } | ||
| 301 | > | ||
| 302 | <StarIcon className={cn("size-3", isFavorite && "fill-current")} /> | ||
| 303 | </TooltipTrigger> | ||
| 304 | <TooltipPopup side="top"> | ||
| 305 | {isFavorite ? "Remove from favorites" : "Add to favorites"} | ||
| 306 | </TooltipPopup> | ||
| 307 | </Tooltip> | ||
| 308 | <Tooltip> | ||
| 309 | <TooltipTrigger | ||
| 310 | render={ | ||
| 311 | <Button | ||
| 312 | size="icon-micro" | ||
| 313 | variant="ghost-muted" | ||
| 314 | disabled={!canMoveUp} | ||
| 315 | onClick={() => handleMove(model.slug, -1)} | ||
| 316 | aria-label={`Move ${model.name} up`} | ||
| 317 | /> | ||
| 318 | } | ||
| 319 | > | ||
| 320 | <ArrowUpIcon className="size-3" /> | ||
| 321 | </TooltipTrigger> | ||
| 322 | <TooltipPopup side="top">Move up</TooltipPopup> | ||
| 323 | </Tooltip> | ||
| 324 | <Tooltip> | ||
| 325 | <TooltipTrigger | ||
| 326 | render={ | ||
| 327 | <Button | ||
| 328 | size="icon-micro" | ||
| 329 | variant="ghost-muted" | ||
| 330 | disabled={!canMoveDown} | ||
| 331 | onClick={() => handleMove(model.slug, 1)} | ||
| 332 | aria-label={`Move ${model.name} down`} | ||
| 333 | /> | ||
| 334 | } | ||
| 335 | > | ||
| 336 | <ArrowDownIcon className="size-3" /> | ||
| 337 | </TooltipTrigger> | ||
| 338 | <TooltipPopup side="top">Move down</TooltipPopup> | ||
| 339 | </Tooltip> | ||
| 340 | {!model.isCustom ? ( | ||
| 341 | <Tooltip> | ||
| 342 | <TooltipTrigger | ||
| 343 | render={ | ||
| 344 | <Button | ||
| 345 | size="icon-micro" | ||
| 346 | variant="ghost-muted" | ||
| 347 | onClick={() => handleToggleHidden(model.slug)} | ||
| 348 | aria-label={`${isHidden ? "Show" : "Hide"} ${model.name}`} | ||
| 349 | /> | ||
| 350 | } | ||
| 351 | > | ||
| 352 | {isHidden ? ( | ||
| 353 | <EyeIcon className="size-3" /> | ||
| 354 | ) : ( | ||
| 355 | <EyeOffIcon className="size-3" /> | ||
| 356 | )} | ||
| 357 | </TooltipTrigger> | ||
| 358 | <TooltipPopup side="top"> | ||
| 359 | {isHidden ? "Show in picker" : "Hide from picker"} | ||
| 360 | </TooltipPopup> | ||
| 361 | </Tooltip> | ||
| 362 | ) : null} | ||
| 363 | {model.isCustom ? ( | ||
| 364 | <Tooltip> | ||
| 365 | <TooltipTrigger | ||
| 366 | render={ | ||
| 367 | <Button | ||
| 368 | size="icon-micro" | ||
| 369 | variant="ghost-muted" | ||
| 370 | aria-label={`Remove ${model.slug}`} | ||
| 371 | onClick={() => handleRemove(model.slug)} | ||
| 372 | /> | ||
| 373 | } | ||
| 374 | > | ||
| 375 | <XIcon className="size-3" /> | ||
| 376 | </TooltipTrigger> | ||
| 377 | <TooltipPopup side="top">Remove custom model</TooltipPopup> | ||
| 378 | </Tooltip> | ||
| 379 | ) : null} | ||
| 380 | </div> | ||
| 381 | </div> | 503 | </div> |
| 382 | ); | 504 | ); |
| 383 | })} | 505 | })} |
| 384 | </div> | 506 | </div> |
| 385 | 507 | ||
| 386 | <div className="mt-3 flex flex-col gap-2 sm:flex-row"> | 508 | {isAdding ? ( |
| 387 | <Input | 509 | <div className="mt-3 flex flex-col gap-2 sm:flex-row"> |
| 388 | id={`provider-instance-${instanceId}-custom-model`} | 510 | <Input |
| 389 | value={input} | 511 | id={`provider-instance-${instanceId}-custom-model`} |
| 390 | onChange={(event) => { | 512 | autoFocus |
| 391 | setInput(event.target.value); | 513 | value={input} |
| 392 | if (error) setError(null); | 514 | onChange={(event) => { |
| 393 | }} | 515 | setInput(event.target.value); |
| 394 | onKeyDown={(event) => { | 516 | if (error) setError(null); |
| 395 | if (event.key !== "Enter") return; | 517 | }} |
| 396 | event.preventDefault(); | 518 | onKeyDown={(event) => { |
| 397 | handleAdd(); | 519 | if (event.key === "Escape") { |
| 398 | }} | 520 | event.preventDefault(); |
| 399 | placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"} | 521 | cancelAdd(); |
| 400 | spellCheck={false} | 522 | return; |
| 401 | /> | 523 | } |
| 402 | <Button className="shrink-0" variant="outline" onClick={handleAdd}> | 524 | if (event.key !== "Enter") return; |
| 403 | <PlusIcon className="size-3.5" /> | 525 | event.preventDefault(); |
| 404 | Add | 526 | handleAdd(); |
| 527 | }} | ||
| 528 | placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"} | ||
| 529 | spellCheck={false} | ||
| 530 | /> | ||
| 531 | <div className="flex shrink-0 gap-2"> | ||
| 532 | <Button variant="outline" onClick={handleAdd}> | ||
| 533 | Add | ||
| 534 | </Button> | ||
| 535 | <Button variant="ghost" onClick={cancelAdd}> | ||
| 536 | Cancel | ||
| 537 | </Button> | ||
| 538 | </div> | ||
| 539 | </div> | ||
| 540 | ) : ( | ||
| 541 | <Button | ||
| 542 | type="button" | ||
| 543 | size="xs" | ||
| 544 | variant="ghost-muted" | ||
| 545 | className="mt-2 -ml-2" | ||
| 546 | onClick={() => setIsAdding(true)} | ||
| 547 | > | ||
| 548 | <PlusIcon className="size-3" /> | ||
| 549 | Add custom model | ||
| 405 | </Button> | 550 | </Button> |
| 406 | </div> | 551 | )} |
| 407 | 552 | ||
| 408 | {error ? <p className="mt-2 text-xs text-destructive">{error}</p> : null} | 553 | {error ? <p className="mt-2 text-xs text-destructive">{error}</p> : null} |
| 409 | </div> | 554 | </div> |
apps/web/src/components/settings/ProviderSettingsForm.tsx+71-4| ... | @@ -158,7 +158,13 @@ interface ProviderSettingsFormProps { | ... | @@ -158,7 +158,13 @@ interface ProviderSettingsFormProps { |
| 158 | readonly definition: ProviderClientDefinition; | 158 | readonly definition: ProviderClientDefinition; |
| 159 | readonly value: unknown; | 159 | readonly value: unknown; |
| 160 | readonly idPrefix: string; | 160 | readonly idPrefix: string; |
| 161 | readonly variant: "card" | "dialog"; | 161 | /** |
| 162 | * `card` stacks label over control, `dialog` is the compact wizard layout, | ||
| 163 | * `grid` emits a label cell and a control cell per field for a parent | ||
| 164 | * two-column grid (label column left, control right), with the description | ||
| 165 | * beside a fixed-width control so each field stays on one line. | ||
| 166 | */ | ||
| 167 | readonly variant: "card" | "dialog" | "grid"; | ||
| 162 | readonly onChange: (nextConfig: Record<string, unknown> | undefined) => void; | 168 | readonly onChange: (nextConfig: Record<string, unknown> | undefined) => void; |
| 163 | } | 169 | } |
| 164 | 170 | ||
| ... | @@ -189,14 +195,75 @@ function ProviderSettingsFieldRow({ | ... | @@ -189,14 +195,75 @@ function ProviderSettingsFieldRow({ |
| 189 | }: ProviderSettingsFieldRowProps) { | 195 | }: ProviderSettingsFieldRowProps) { |
| 190 | const inputId = `${idPrefix}-${field.key}`; | 196 | const inputId = `${idPrefix}-${field.key}`; |
| 191 | const descriptionClassName = | 197 | const descriptionClassName = |
| 192 | variant === "card" | 198 | variant === "dialog" |
| 193 | ? "mt-1 block text-xs text-muted-foreground" | 199 | ? "text-[11px] text-muted-foreground" |
| 194 | : "text-[11px] text-muted-foreground"; | 200 | : "mt-1 block text-xs text-muted-foreground"; |
| 195 | const label = <span className="text-xs font-medium text-foreground">{field.label}</span>; | 201 | const label = <span className="text-xs font-medium text-foreground">{field.label}</span>; |
| 196 | const description = field.description ? ( | 202 | const description = field.description ? ( |
| 197 | <span className={descriptionClassName}>{field.description}</span> | 203 | <span className={descriptionClassName}>{field.description}</span> |
| 198 | ) : null; | 204 | ) : null; |
| 199 | 205 | ||
| 206 | if (variant === "grid") { | ||
| 207 | // Label cell, then a control cell where the description sits beside a | ||
| 208 | // fixed-width control and wraps under it when the pane is narrow. The | ||
| 209 | // description is outside the label, so the control points at it instead. | ||
| 210 | const descriptionId = field.description ? `${inputId}-description` : undefined; | ||
| 211 | return ( | ||
| 212 | <> | ||
| 213 | {field.control === "switch" ? ( | ||
| 214 | <span className="pt-1.5 text-xs font-medium text-foreground">{field.label}</span> | ||
| 215 | ) : ( | ||
| 216 | <label htmlFor={inputId} className="pt-1.5 text-xs font-medium text-foreground"> | ||
| 217 | {field.label} | ||
| 218 | </label> | ||
| 219 | )} | ||
| 220 | <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"> | ||
| 221 | {field.control === "switch" ? ( | ||
| 222 | <span className="flex h-8 items-center"> | ||
| 223 | <Switch | ||
| 224 | checked={readProviderConfigBoolean(value, field.key, field.defaultBooleanValue)} | ||
| 225 | onCheckedChange={(checked) => | ||
| 226 | onChange(nextProviderConfigWithFieldValue(value, field, Boolean(checked))) | ||
| 227 | } | ||
| 228 | aria-label={field.label} | ||
| 229 | aria-describedby={descriptionId} | ||
| 230 | /> | ||
| 231 | </span> | ||
| 232 | ) : field.control === "textarea" ? ( | ||
| 233 | <Textarea | ||
| 234 | id={inputId} | ||
| 235 | aria-describedby={descriptionId} | ||
| 236 | className="w-full max-w-sm" | ||
| 237 | value={readProviderConfigString(value, field.key)} | ||
| 238 | onChange={(event) => | ||
| 239 | onChange(nextProviderConfigWithFieldValue(value, field, event.target.value)) | ||
| 240 | } | ||
| 241 | placeholder={field.placeholder} | ||
| 242 | spellCheck={false} | ||
| 243 | /> | ||
| 244 | ) : ( | ||
| 245 | <DraftInput | ||
| 246 | id={inputId} | ||
| 247 | aria-describedby={descriptionId} | ||
| 248 | className="w-56" | ||
| 249 | type={field.control === "password" ? "password" : undefined} | ||
| 250 | autoComplete={field.control === "password" ? "off" : undefined} | ||
| 251 | value={readProviderConfigString(value, field.key)} | ||
| 252 | onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))} | ||
| 253 | placeholder={field.placeholder} | ||
| 254 | spellCheck={false} | ||
| 255 | /> | ||
| 256 | )} | ||
| 257 | {field.description ? ( | ||
| 258 | <span id={descriptionId} className="text-[11px] leading-snug text-muted-foreground"> | ||
| 259 | {field.description} | ||
| 260 | </span> | ||
| 261 | ) : null} | ||
| 262 | </div> | ||
| 263 | </> | ||
| 264 | ); | ||
| 265 | } | ||
| 266 | |||
| 200 | if (field.control === "switch") { | 267 | if (field.control === "switch") { |
| 201 | return ( | 268 | return ( |
| 202 | <FieldFrame variant={variant}> | 269 | <FieldFrame variant={variant}> |
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+18-10| ... | @@ -141,6 +141,21 @@ function renderPanel(options?: { | ... | @@ -141,6 +141,21 @@ function renderPanel(options?: { |
| 141 | }) as ReactElement<Record<string, unknown>>; | 141 | }) as ReactElement<Record<string, unknown>>; |
| 142 | } | 142 | } |
| 143 | 143 | ||
| 144 | function isRefreshButton(element: ReactElement<Record<string, unknown>>): boolean { | ||
| 145 | const children = element.props.children; | ||
| 146 | return ( | ||
| 147 | Array.isArray(children) && | ||
| 148 | children.some( | ||
| 149 | (child) => | ||
| 150 | typeof child === "object" && | ||
| 151 | child !== null && | ||
| 152 | (child as ReactElement<Record<string, unknown>>).props?.className === "sr-only" && | ||
| 153 | (child as ReactElement<Record<string, unknown>>).props?.children === | ||
| 154 | "Refresh provider status", | ||
| 155 | ) | ||
| 156 | ); | ||
| 157 | } | ||
| 158 | |||
| 144 | function isAddProviderButton(element: ReactElement<Record<string, unknown>>): boolean { | 159 | function isAddProviderButton(element: ReactElement<Record<string, unknown>>): boolean { |
| 145 | return element.props["aria-label"] === "Add provider"; | 160 | return element.props["aria-label"] === "Add provider"; |
| 146 | } | 161 | } |
| ... | @@ -184,10 +199,7 @@ describe("EnvironmentProviderSettings routing", () => { | ... | @@ -184,10 +199,7 @@ describe("EnvironmentProviderSettings routing", () => { |
| 184 | it("routes refresh and provider update commands to the selected environment", async () => { | 199 | it("routes refresh and provider update commands to the selected environment", async () => { |
| 185 | atoms.providers = [provider()]; | 200 | atoms.providers = [provider()]; |
| 186 | const panel = renderPanel(); | 201 | const panel = renderPanel(); |
| 187 | const refreshButton = visitElements( | 202 | const refreshButton = visitElements(panel, isRefreshButton); |
| 188 | panel, | ||
| 189 | (element) => element.props["aria-label"] === "Refresh provider status", | ||
| 190 | ); | ||
| 191 | expect(refreshButton).not.toBeNull(); | 203 | expect(refreshButton).not.toBeNull(); |
| 192 | (refreshButton?.props.onClick as (() => void) | undefined)?.(); | 204 | (refreshButton?.props.onClick as (() => void) | undefined)?.(); |
| 193 | await flushPromises(); | 205 | await flushPromises(); |
| ... | @@ -243,9 +255,7 @@ describe("EnvironmentProviderSettings routing", () => { | ... | @@ -243,9 +255,7 @@ describe("EnvironmentProviderSettings routing", () => { |
| 243 | const notice = visitElements(panel, (element) => element.props.title === "Limited permissions"); | 255 | const notice = visitElements(panel, (element) => element.props.title === "Limited permissions"); |
| 244 | expect(notice).not.toBeNull(); | 256 | expect(notice).not.toBeNull(); |
| 245 | 257 | ||
| 246 | expect( | 258 | expect(visitElements(panel, isRefreshButton)).toBeNull(); |
| 247 | visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"), | ||
| 248 | ).toBeNull(); | ||
| 249 | expect(visitElements(panel, isAddProviderButton)).toBeNull(); | 259 | expect(visitElements(panel, isAddProviderButton)).toBeNull(); |
| 250 | }); | 260 | }); |
| 251 | 261 | ||
| ... | @@ -256,9 +266,7 @@ describe("EnvironmentProviderSettings routing", () => { | ... | @@ -256,9 +266,7 @@ describe("EnvironmentProviderSettings routing", () => { |
| 256 | expect( | 266 | expect( |
| 257 | visitElements(panel, (element) => element.props.title === "Limited permissions"), | 267 | visitElements(panel, (element) => element.props.title === "Limited permissions"), |
| 258 | ).toBeNull(); | 268 | ).toBeNull(); |
| 259 | expect( | 269 | expect(visitElements(panel, isRefreshButton)).not.toBeNull(); |
| 260 | visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"), | ||
| 261 | ).not.toBeNull(); | ||
| 262 | expect(visitElements(panel, isAddProviderButton)).not.toBeNull(); | 270 | expect(visitElements(panel, isAddProviderButton)).not.toBeNull(); |
| 263 | }); | 271 | }); |
| 264 | 272 |
apps/web/src/components/settings/ProviderSettingsPanel.tsx+76-74| ... | @@ -135,11 +135,11 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null } | ... | @@ -135,11 +135,11 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null } |
| 135 | } | 135 | } |
| 136 | 136 | ||
| 137 | if (lastCheckedRelative.status === "invalid") { | 137 | if (lastCheckedRelative.status === "invalid") { |
| 138 | return <span className="text-[11px] text-muted-foreground/50">Checked unavailable</span>; | 138 | return <span>Checked unavailable</span>; |
| 139 | } | 139 | } |
| 140 | 140 | ||
| 141 | return ( | 141 | return ( |
| 142 | <span className="text-[11px] text-muted-foreground/60"> | 142 | <span> |
| 143 | {lastCheckedRelative.suffix ? ( | 143 | {lastCheckedRelative.suffix ? ( |
| 144 | <> | 144 | <> |
| 145 | Checked <span className="font-mono tabular-nums">{lastCheckedRelative.value}</span>{" "} | 145 | Checked <span className="font-mono tabular-nums">{lastCheckedRelative.value}</span>{" "} |
| ... | @@ -858,33 +858,31 @@ export function EnvironmentProviderSettings({ | ... | @@ -858,33 +858,31 @@ export function EnvironmentProviderSettings({ |
| 858 | <SettingsSection | 858 | <SettingsSection |
| 859 | {...searchableSetting("providers")} | 859 | {...searchableSetting("providers")} |
| 860 | headerAction={ | 860 | headerAction={ |
| 861 | <div className="flex min-w-0 items-center gap-1.5"> | 861 | <div className="flex min-w-0 items-center gap-2"> |
| 862 | {/* | 862 | {readOnly ? ( |
| 863 | The 11px size must sit on this flex item, not just the span | 863 | <span className="min-w-0 truncate text-xs text-muted-foreground"> |
| 864 | inside: the item's line box is struck from its own font size, | 864 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> |
| 865 | and an inherited 16px strut hangs the smaller text below the | 865 | </span> |
| 866 | vertical center of the row. | 866 | ) : ( |
| 867 | */} | ||
| 868 | <span className="hidden min-w-0 truncate text-[11px] sm:inline"> | ||
| 869 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> | ||
| 870 | </span> | ||
| 871 | {!readOnly ? ( | ||
| 872 | <> | 867 | <> |
| 873 | <Tooltip> | 868 | <Tooltip> |
| 874 | <TooltipTrigger | 869 | <TooltipTrigger |
| 875 | render={ | 870 | render={ |
| 876 | <Button | 871 | <Button |
| 877 | size="icon-micro" | 872 | size="compact" |
| 878 | variant="ghost-muted" | 873 | variant="ghost-muted" |
| 879 | disabled={isRefreshingProviders} | 874 | disabled={isRefreshingProviders} |
| 880 | onClick={() => void refreshProviders()} | 875 | onClick={() => void refreshProviders()} |
| 881 | aria-label="Refresh provider status" | ||
| 882 | > | 876 | > |
| 883 | {isRefreshingProviders ? ( | 877 | {isRefreshingProviders ? ( |
| 884 | <LoaderIcon className="size-3 animate-spin" /> | 878 | <LoaderIcon className="animate-spin" /> |
| 885 | ) : ( | 879 | ) : ( |
| 886 | <RefreshCwIcon className="size-3" /> | 880 | <RefreshCwIcon /> |
| 887 | )} | 881 | )} |
| 882 | <span className="sr-only">Refresh provider status</span> | ||
| 883 | <span className="hidden min-w-0 truncate sm:inline"> | ||
| 884 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> | ||
| 885 | </span> | ||
| 888 | </Button> | 886 | </Button> |
| 889 | } | 887 | } |
| 890 | /> | 888 | /> |
| ... | @@ -906,7 +904,7 @@ export function EnvironmentProviderSettings({ | ... | @@ -906,7 +904,7 @@ export function EnvironmentProviderSettings({ |
| 906 | <TooltipPopup side="top">Add provider</TooltipPopup> | 904 | <TooltipPopup side="top">Add provider</TooltipPopup> |
| 907 | </Tooltip> | 905 | </Tooltip> |
| 908 | </> | 906 | </> |
| 909 | ) : null} | 907 | )} |
| 910 | </div> | 908 | </div> |
| 911 | } | 909 | } |
| 912 | > | 910 | > |
| ... | @@ -948,27 +946,24 @@ export function EnvironmentProviderSettings({ | ... | @@ -948,27 +946,24 @@ export function EnvironmentProviderSettings({ |
| 948 | Advanced | 946 | Advanced |
| 949 | </CollapsibleTrigger> | 947 | </CollapsibleTrigger> |
| 950 | <CollapsibleContent> | 948 | <CollapsibleContent> |
| 951 | <div | 949 | {/* Only the write controls go inert; the title and its policy tooltip stay readable. */} |
| 952 | inert={readOnly} | 950 | <SettingsRow |
| 953 | aria-disabled={readOnly || undefined} | 951 | id={searchableSetting("provider-health-check-interval").id} |
| 954 | className={readOnly ? "opacity-50 select-none" : undefined} | 952 | title={ |
| 955 | > | 953 | <span className="inline-flex items-center gap-1.5"> |
| 956 | <SettingsRow | 954 | {searchableSetting("provider-health-check-interval").title} |
| 957 | id={searchableSetting("provider-health-check-interval").id} | 955 | <PolicyTooltip> |
| 958 | title={ | 956 | This interval is configured here, then the shared Background activity policy |
| 959 | <span className="inline-flex items-center gap-1.5"> | 957 | decides whether provider probes may run when the timer fires. Custom intervals |
| 960 | {searchableSetting("provider-health-check-interval").title} | 958 | appear as Advanced in General settings. |
| 961 | <PolicyTooltip> | 959 | </PolicyTooltip> |
| 962 | This interval is configured here, then the shared Background activity policy | 960 | </span> |
| 963 | decides whether provider probes may run when the timer fires. Custom | 961 | } |
| 964 | intervals appear as Advanced in General settings. | 962 | description="Refresh availability, versions, auth state, and models in the background. 0 seconds turns background checks off." |
| 965 | </PolicyTooltip> | 963 | resetAction={ |
| 966 | </span> | 964 | providerHealthRefreshIntervalSeconds !== |
| 967 | } | 965 | defaultProviderHealthRefreshIntervalSeconds ? ( |
| 968 | description="Refresh availability, versions, auth state, and models in the background. 0 seconds turns background checks off." | 966 | <span inert={readOnly} className={readOnly ? "opacity-50" : undefined}> |
| 969 | resetAction={ | ||
| 970 | providerHealthRefreshIntervalSeconds !== | ||
| 971 | defaultProviderHealthRefreshIntervalSeconds ? ( | ||
| 972 | <SettingResetButton | 967 | <SettingResetButton |
| 973 | label="provider health check interval" | 968 | label="provider health check interval" |
| 974 | onClick={() => | 969 | onClick={() => |
| ... | @@ -981,41 +976,48 @@ export function EnvironmentProviderSettings({ | ... | @@ -981,41 +976,48 @@ export function EnvironmentProviderSettings({ |
| 981 | ) | 976 | ) |
| 982 | } | 977 | } |
| 983 | /> | 978 | /> |
| 984 | ) : null | 979 | </span> |
| 985 | } | 980 | ) : null |
| 986 | control={ | 981 | } |
| 987 | <div className="flex shrink-0 items-center gap-2"> | 982 | control={ |
| 988 | <NumberField | 983 | <div |
| 989 | value={providerHealthRefreshIntervalSeconds} | 984 | inert={readOnly} |
| 990 | min={0} | 985 | aria-disabled={readOnly || undefined} |
| 991 | step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS} | 986 | className={cn( |
| 992 | size="sm" | 987 | "flex shrink-0 items-center gap-2", |
| 993 | className="w-32" | 988 | readOnly && "opacity-50 select-none", |
| 994 | onValueChange={(value) => | 989 | )} |
| 995 | updateSettings( | 990 | > |
| 996 | backgroundActivityOverrideSettings( | 991 | <NumberField |
| 997 | settings.backgroundActivity, | 992 | value={providerHealthRefreshIntervalSeconds} |
| 998 | resolvedBackgroundActivity, | 993 | min={0} |
| 999 | { | 994 | step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS} |
| 1000 | providerHealthRefreshInterval: Duration.seconds( | 995 | size="sm" |
| 1001 | normalizeIntervalSeconds(value), | 996 | className="w-32" |
| 1002 | ), | 997 | onValueChange={(value) => |
| 1003 | }, | 998 | updateSettings( |
| 1004 | ), | 999 | backgroundActivityOverrideSettings( |
| 1005 | ) | 1000 | settings.backgroundActivity, |
| 1006 | } | 1001 | resolvedBackgroundActivity, |
| 1007 | > | 1002 | { |
| 1008 | <NumberFieldGroup> | 1003 | providerHealthRefreshInterval: Duration.seconds( |
| 1009 | <NumberFieldDecrement aria-label="Decrease provider health check interval" /> | 1004 | normalizeIntervalSeconds(value), |
| 1010 | <NumberFieldInput aria-label="Provider health check interval in seconds" /> | 1005 | ), |
| 1011 | <NumberFieldIncrement aria-label="Increase provider health check interval" /> | 1006 | }, |
| 1012 | </NumberFieldGroup> | 1007 | ), |
| 1013 | </NumberField> | 1008 | ) |
| 1014 | <span className="text-xs text-muted-foreground">seconds</span> | 1009 | } |
| 1015 | </div> | 1010 | > |
| 1016 | } | 1011 | <NumberFieldGroup> |
| 1017 | /> | 1012 | <NumberFieldDecrement aria-label="Decrease provider health check interval" /> |
| 1018 | </div> | 1013 | <NumberFieldInput aria-label="Provider health check interval in seconds" /> |
| 1014 | <NumberFieldIncrement aria-label="Increase provider health check interval" /> | ||
| 1015 | </NumberFieldGroup> | ||
| 1016 | </NumberField> | ||
| 1017 | <span className="text-xs text-muted-foreground">seconds</span> | ||
| 1018 | </div> | ||
| 1019 | } | ||
| 1020 | /> | ||
| 1019 | </CollapsibleContent> | 1021 | </CollapsibleContent> |
| 1020 | </Collapsible> | 1022 | </Collapsible> |
| 1021 | </div> | 1023 | </div> |