| 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 | 228 | readonly onCommit: (value: string) => void; |
| 229 | 229 | readonly description?: string; |
| 230 | 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 | 242 | const [optimisticValue, setOptimisticValue] = useState(() => value ?? ""); |
| 234 | 243 | const commitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
| 235 | 244 | const pendingCommitRef = useRef<string | null>(null); |
| ... | ... | @@ -295,40 +304,52 @@ export function ProviderAccentColorPicker(props: { |
| 295 | 304 | : ""; |
| 296 | 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 | 349 | return ( |
| 299 | 350 | <div className="grid gap-2"> |
| 300 | 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"> | |
| 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> | |
| 352 | {swatchRow} | |
| 332 | 353 | {description ? <span className="text-xs text-muted-foreground">{description}</span> : null} |
| 333 | 354 | </div> |
| 334 | 355 | ); |
apps/web/src/components/settings/ProviderInstanceCard.tsx+181-190| ... | ... | @@ -5,6 +5,8 @@ import { |
| 5 | 5 | CopyIcon, |
| 6 | 6 | DownloadIcon, |
| 7 | 7 | LoaderIcon, |
| 8 | LockIcon, | |
| 9 | LockOpenIcon, | |
| 8 | 10 | PlusIcon, |
| 9 | 11 | Trash2Icon, |
| 10 | 12 | XIcon, |
| ... | ... | @@ -28,12 +30,10 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 28 | 30 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 29 | 31 | import { Badge } from "../ui/badge"; |
| 30 | 32 | import { Button } from "../ui/button"; |
| 31 | import { Checkbox } from "../ui/checkbox"; | |
| 32 | 33 | import { DraftInput } from "../ui/draft-input"; |
| 33 | 34 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 34 | 35 | import { ScrollArea } from "../ui/scroll-area"; |
| 35 | 36 | import { Switch } from "../ui/switch"; |
| 36 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"; | |
| 37 | 37 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 38 | 38 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 39 | 39 | import type { DriverOption } from "./providerDriverMeta"; |
| ... | ... | @@ -53,6 +53,13 @@ import { |
| 53 | 53 | |
| 54 | 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 | 63 | let environmentVariableDraftId = 0; |
| 57 | 64 | const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; |
| 58 | 65 | |
| ... | ... | @@ -235,118 +242,104 @@ function ProviderEnvironmentSection(props: { |
| 235 | 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 | 256 | return ( |
| 239 | <div className="grid gap-2"> | |
| 240 | <div className="flex items-center justify-between gap-3"> | |
| 241 | <span className="text-xs font-medium text-foreground">Environment variables</span> | |
| 242 | <Button | |
| 243 | type="button" | |
| 244 | size="sm" | |
| 245 | variant="outline" | |
| 246 | className="h-7 gap-1.5 px-2 text-xs" | |
| 247 | onClick={() => | |
| 248 | setRows([ | |
| 249 | ...rows, | |
| 250 | { | |
| 251 | id: nextEnvironmentVariableDraftId(), | |
| 252 | name: "", | |
| 253 | value: "", | |
| 254 | sensitive: true, | |
| 255 | }, | |
| 256 | ]) | |
| 257 | } | |
| 258 | > | |
| 257 | <div className="min-w-0 space-y-1.5"> | |
| 258 | {rows.map((variable, index) => ( | |
| 259 | <div key={variable.id} className="flex min-w-0 items-center gap-1.5"> | |
| 260 | <DraftInput | |
| 261 | size="compact" | |
| 262 | className="w-44 shrink-0 font-mono" | |
| 263 | value={variable.name} | |
| 264 | onCommit={(name) => updateVariable(variable.id, { name: name.trim() })} | |
| 265 | placeholder="VARIABLE_NAME" | |
| 266 | spellCheck={false} | |
| 267 | aria-label={`Environment variable name ${index + 1}`} | |
| 268 | /> | |
| 269 | <span className="text-xs text-muted-foreground" aria-hidden> | |
| 270 | = | |
| 271 | </span> | |
| 272 | <DraftInput | |
| 273 | size="compact" | |
| 274 | className="min-w-0 flex-1 font-mono" | |
| 275 | value={variable.valueRedacted ? "" : variable.value} | |
| 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 | 334 | <PlusIcon className="size-3" /> |
| 260 | Add | |
| 335 | Add variable | |
| 261 | 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 | 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 | 343 | </div> |
| 351 | 344 | ); |
| 352 | 345 | } |
| ... | ... | @@ -362,7 +355,7 @@ interface ProviderInstanceCardProps { |
| 362 | 355 | readonly readOnly?: boolean | undefined; |
| 363 | 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 | 359 | * instance slots use `undefined` — they can't be deleted without losing |
| 367 | 360 | * the slot, and their "reset to defaults" affordance lives on an outer |
| 368 | 361 | * reset button instead. Explicit `| undefined` in the type accommodates |
| ... | ... | @@ -489,6 +482,9 @@ export function ProviderInstanceCard({ |
| 489 | 482 | liveModels: liveProvider?.models, |
| 490 | 483 | customModels, |
| 491 | 484 | }); |
| 485 | const hiddenModelCount = modelsForDisplay.filter( | |
| 486 | (model) => !model.isCustom && hiddenModels.includes(model.slug), | |
| 487 | ).length; | |
| 492 | 488 | |
| 493 | 489 | const updateDisplayName = (value: string) => { |
| 494 | 490 | const trimmed = value.trim(); |
| ... | ... | @@ -581,35 +577,9 @@ export function ProviderInstanceCard({ |
| 581 | 577 | </> |
| 582 | 578 | ); |
| 583 | 579 | |
| 584 | const titleTailNode = ( | |
| 585 | <> | |
| 586 | {headerAction ? ( | |
| 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 | ); | |
| 580 | const titleTailNode = headerAction ? ( | |
| 581 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">{headerAction}</span> | |
| 582 | ) : null; | |
| 613 | 583 | |
| 614 | 584 | const versionCodeNode = versionLabel ? ( |
| 615 | 585 | <code className="text-xs text-muted-foreground">{versionLabel}</code> |
| ... | ... | @@ -634,7 +604,7 @@ export function ProviderInstanceCard({ |
| 634 | 604 | className={cn( |
| 635 | 605 | // Sidebar-style selection with a fixed row height so the list stays |
| 636 | 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 | 608 | // Foreground-alpha tint so the fill reads the same in light and dark themes. |
| 639 | 609 | selected ? "bg-foreground/8" : "hover:bg-foreground/4", |
| 640 | 610 | )} |
| ... | ... | @@ -712,7 +682,7 @@ export function ProviderInstanceCard({ |
| 712 | 682 | size="icon-xs" |
| 713 | 683 | variant="ghost" |
| 714 | 684 | className={cn( |
| 715 | "size-5 rounded-sm p-0", | |
| 685 | "size-5 rounded-sm p-0 [--control-icon-color:currentColor]", | |
| 716 | 686 | versionAdvisory.emphasis === "strong" |
| 717 | 687 | ? "text-warning hover:text-warning" |
| 718 | 688 | : "text-muted-foreground hover:text-foreground", |
| ... | ... | @@ -820,6 +790,25 @@ export function ProviderInstanceCard({ |
| 820 | 790 | </p> |
| 821 | 791 | ) : null} |
| 822 | 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 | 812 | </div> |
| 824 | 813 | |
| 825 | 814 | <div className="flex h-11 shrink-0 border-b border-border/70 px-1"> |
| ... | ... | @@ -835,10 +824,14 @@ export function ProviderInstanceCard({ |
| 835 | 824 | <button |
| 836 | 825 | type="button" |
| 837 | 826 | aria-pressed={visibleTab === "models"} |
| 838 | className={providerSettingsTabClassName(visibleTab === "models")} | |
| 827 | className={cn(providerSettingsTabClassName(visibleTab === "models"), "gap-1.5")} | |
| 839 | 828 | onClick={() => setActiveTab("models")} |
| 840 | 829 | > |
| 841 | 830 | Models |
| 831 | <span className="font-normal text-muted-foreground/70"> | |
| 832 | {modelsForDisplay.length} | |
| 833 | {hiddenModelCount > 0 ? ` · ${hiddenModelCount} hidden` : ""} | |
| 834 | </span> | |
| 842 | 835 | </button> |
| 843 | 836 | ) : null} |
| 844 | 837 | </div> |
| ... | ... | @@ -853,62 +846,60 @@ export function ProviderInstanceCard({ |
| 853 | 846 | <div |
| 854 | 847 | inert={readOnly} |
| 855 | 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> | |
| 859 | <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block"> | |
| 860 | <span className="text-xs font-medium text-foreground">Display name</span> | |
| 851 | <div className={PROVIDER_FIELD_GRID_CLASS_NAME}> | |
| 852 | <label | |
| 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 | 859 | <DraftInput |
| 862 | 860 | id={`provider-instance-${instanceId}-display-name`} |
| 863 | className="mt-1.5" | |
| 861 | className="w-44" | |
| 864 | 862 | value={instance.displayName ?? ""} |
| 865 | 863 | onCommit={updateDisplayName} |
| 866 | 864 | placeholder={driverOption?.label ?? "Instance label"} |
| 867 | 865 | spellCheck={false} |
| 868 | 866 | /> |
| 869 | <span className="mt-1 block text-xs text-muted-foreground"> | |
| 870 | Optional label shown in the provider list. | |
| 871 | </span> | |
| 872 | </label> | |
| 873 | </div> | |
| 874 | ||
| 875 | <div> | |
| 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> | |
| 867 | <ProviderAccentColorPicker | |
| 868 | layout="inline" | |
| 869 | displayName={displayName} | |
| 870 | value={accentColor} | |
| 871 | onCommit={updateAccentColor} | |
| 872 | commitDelayMs={120} | |
| 873 | /> | |
| 910 | 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 | 903 | </div> |
| 913 | 904 | </ScrollArea> |
| 914 | 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 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import { | |
| 4 | ArrowDownIcon, | |
| 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"; | |
| 3 | import { ArrowDownIcon, ArrowUpIcon, PlusIcon, StarIcon, XIcon } from "lucide-react"; | |
| 4 | import { useEffect, useMemo, useRef, useState } from "react"; | |
| 14 | 5 | import { |
| 15 | 6 | ProviderDriverKind, |
| 16 | 7 | type ProviderInstanceId, |
| ... | ... | @@ -23,7 +14,7 @@ import { sortModelsForProviderInstance } from "../../modelOrdering"; |
| 23 | 14 | import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection"; |
| 24 | 15 | import { Button } from "../ui/button"; |
| 25 | 16 | import { Input } from "../ui/input"; |
| 26 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | |
| 17 | import { Switch } from "../ui/switch"; | |
| 27 | 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 | 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 | 98 | interface ProviderModelsSectionProps { |
| 42 | 99 | /** Identifier used to namespace input ids within the DOM. */ |
| 43 | 100 | readonly instanceId: ProviderInstanceId; |
| ... | ... | @@ -99,17 +156,50 @@ export function ProviderModelsSection({ |
| 99 | 156 | onModelOrderChange, |
| 100 | 157 | }: ProviderModelsSectionProps) { |
| 101 | 158 | const [input, setInput] = useState(""); |
| 159 | const [isAdding, setIsAdding] = useState(false); | |
| 160 | const [filter, setFilter] = useState(""); | |
| 102 | 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 | 165 | const hiddenModelSet = useMemo(() => new Set(hiddenModels), [hiddenModels]); |
| 105 | 166 | const favoriteModelSet = useMemo(() => new Set(favoriteModels), [favoriteModels]); |
| 106 | const orderedModels = useMemo(() => { | |
| 107 | return sortModelsForProviderInstance(models, { | |
| 108 | favoriteModels: favoriteModelSet, | |
| 109 | groupFavorites: true, | |
| 110 | modelOrder, | |
| 111 | }); | |
| 112 | }, [favoriteModelSet, modelOrder, models]); | |
| 167 | const displayModels = useMemo( | |
| 168 | () => | |
| 169 | groupModelsForDisplay(models, { | |
| 170 | favoriteModels: favoriteModelSet, | |
| 171 | hiddenModels: hiddenModelSet, | |
| 172 | modelOrder, | |
| 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 | 204 | const handleAdd = () => { |
| 115 | 205 | const normalized = normalizeCustomModelSlug(input); |
| ... | ... | @@ -130,24 +220,20 @@ export function ProviderModelsSection({ |
| 130 | 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 | 227 | onChange([...customModels, normalized]); |
| 134 | 228 | setInput(""); |
| 135 | 229 | setError(null); |
| 230 | setIsAdding(false); | |
| 231 | }; | |
| 136 | 232 | |
| 137 | // Scroll the new row into view once the DOM reflects the commit. | |
| 138 | // `MutationObserver` handles the one-frame gap between `onChange` and | |
| 139 | // the `models` prop update; the `requestAnimationFrame` covers the | |
| 140 | // common case where the parent updates synchronously. | |
| 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); | |
| 233 | const cancelAdd = () => { | |
| 234 | setInput(""); | |
| 235 | setError(null); | |
| 236 | setIsAdding(false); | |
| 151 | 237 | }; |
| 152 | 238 | |
| 153 | 239 | const handleRemove = (slug: string) => { |
| ... | ... | @@ -157,12 +243,11 @@ export function ProviderModelsSection({ |
| 157 | 243 | setError(null); |
| 158 | 244 | }; |
| 159 | 245 | |
| 160 | const handleToggleHidden = (slug: string) => { | |
| 161 | if (hiddenModelSet.has(slug)) { | |
| 162 | onHiddenModelsChange(hiddenModels.filter((model) => model !== slug)); | |
| 163 | return; | |
| 164 | } | |
| 165 | onHiddenModelsChange([...hiddenModels, slug]); | |
| 246 | const setHidden = (slug: string, hidden: boolean) => { | |
| 247 | if (hidden === hiddenModelSet.has(slug)) return; | |
| 248 | onHiddenModelsChange( | |
| 249 | hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug), | |
| 250 | ); | |
| 166 | 251 | }; |
| 167 | 252 | |
| 168 | 253 | const handleToggleFavorite = (slug: string) => { |
| ... | ... | @@ -173,237 +258,297 @@ export function ProviderModelsSection({ |
| 173 | 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 | 269 | const handleMove = (slug: string, direction: -1 | 1) => { |
| 177 | const slugs = orderedModels.map((model) => model.slug); | |
| 178 | const index = slugs.indexOf(slug); | |
| 270 | const index = displayModels.findIndex((model) => model.slug === slug); | |
| 179 | 271 | const nextIndex = index + direction; |
| 180 | if (index < 0 || nextIndex < 0 || nextIndex >= slugs.length) { | |
| 181 | return; | |
| 182 | } | |
| 183 | const next = [...slugs]; | |
| 272 | if (index < 0 || nextIndex < 0 || nextIndex >= displayModels.length) return; | |
| 273 | if (groupOf(displayModels[index]!) !== groupOf(displayModels[nextIndex]!)) return; | |
| 274 | const next = displayModels.map((model) => model.slug); | |
| 184 | 275 | [next[index], next[nextIndex]] = [next[nextIndex]!, next[index]!]; |
| 185 | 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 | 458 | return ( |
| 189 | 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> | |
| 191 | <div className="mt-1 text-xs text-muted-foreground"> | |
| 192 | {models.length} model{models.length === 1 ? "" : "s"} available. | |
| 460 | <div className="flex flex-wrap items-center justify-between gap-2"> | |
| 461 | {showFilter ? ( | |
| 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 | 477 | </div> |
| 194 | 478 | <div |
| 195 | 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) => { | |
| 199 | const caps = model.capabilities; | |
| 200 | const capLabels: string[] = []; | |
| 201 | const isHidden = !model.isCustom && hiddenModelSet.has(model.slug); | |
| 202 | const isFavorite = favoriteModelSet.has(model.slug); | |
| 203 | const previousModel = orderedModels[index - 1]; | |
| 204 | const nextModel = orderedModels[index + 1]; | |
| 205 | const canMoveUp = | |
| 206 | previousModel !== undefined && favoriteModelSet.has(previousModel.slug) === isFavorite; | |
| 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 | ||
| 482 | {visibleModels.length === 0 ? ( | |
| 483 | <p className="px-2 py-2 text-xs text-muted-foreground"> | |
| 484 | {isFiltering ? "No models match." : "No models reported for this provider yet."} | |
| 485 | </p> | |
| 486 | ) : null} | |
| 487 | {visibleModels.map((model, index) => { | |
| 488 | const group = groupOf(model); | |
| 489 | const previous = visibleModels[index - 1]; | |
| 490 | const startsGroup = previous === undefined || groupOf(previous) !== group; | |
| 230 | 491 | return ( |
| 231 | <div | |
| 232 | key={`${instanceId}:${model.slug}`} | |
| 233 | className={cn( | |
| 234 | "grid min-h-7 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 py-1", | |
| 235 | isHidden && "text-muted-foreground", | |
| 236 | )} | |
| 237 | > | |
| 238 | <div className="flex min-w-0 items-center gap-1"> | |
| 239 | <span | |
| 240 | className={cn( | |
| 241 | "min-w-0 truncate text-xs", | |
| 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> | |
| 492 | <div key={`${instanceId}:${model.slug}:group`}> | |
| 493 | {startsGroup && favoriteCount > 0 && group === "favorite" | |
| 494 | ? groupLabel("Favorites", index === 0) | |
| 495 | : null} | |
| 496 | {startsGroup && favoriteCount > 0 && group === "visible" | |
| 497 | ? groupLabel("All", index === 0) | |
| 498 | : null} | |
| 499 | {startsGroup && group === "hidden" | |
| 500 | ? groupLabel("Hidden from picker", index === 0) | |
| 501 | : null} | |
| 502 | {renderRow(model)} | |
| 381 | 503 | </div> |
| 382 | 504 | ); |
| 383 | 505 | })} |
| 384 | 506 | </div> |
| 385 | 507 | |
| 386 | <div className="mt-3 flex flex-col gap-2 sm:flex-row"> | |
| 387 | <Input | |
| 388 | id={`provider-instance-${instanceId}-custom-model`} | |
| 389 | value={input} | |
| 390 | onChange={(event) => { | |
| 391 | setInput(event.target.value); | |
| 392 | if (error) setError(null); | |
| 393 | }} | |
| 394 | onKeyDown={(event) => { | |
| 395 | if (event.key !== "Enter") return; | |
| 396 | event.preventDefault(); | |
| 397 | handleAdd(); | |
| 398 | }} | |
| 399 | placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"} | |
| 400 | spellCheck={false} | |
| 401 | /> | |
| 402 | <Button className="shrink-0" variant="outline" onClick={handleAdd}> | |
| 403 | <PlusIcon className="size-3.5" /> | |
| 404 | Add | |
| 508 | {isAdding ? ( | |
| 509 | <div className="mt-3 flex flex-col gap-2 sm:flex-row"> | |
| 510 | <Input | |
| 511 | id={`provider-instance-${instanceId}-custom-model`} | |
| 512 | autoFocus | |
| 513 | value={input} | |
| 514 | onChange={(event) => { | |
| 515 | setInput(event.target.value); | |
| 516 | if (error) setError(null); | |
| 517 | }} | |
| 518 | onKeyDown={(event) => { | |
| 519 | if (event.key === "Escape") { | |
| 520 | event.preventDefault(); | |
| 521 | cancelAdd(); | |
| 522 | return; | |
| 523 | } | |
| 524 | if (event.key !== "Enter") return; | |
| 525 | event.preventDefault(); | |
| 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 | 550 | </Button> |
| 406 | </div> | |
| 551 | )} | |
| 407 | 552 | |
| 408 | 553 | {error ? <p className="mt-2 text-xs text-destructive">{error}</p> : null} |
| 409 | 554 | </div> |
apps/web/src/components/settings/ProviderSettingsForm.tsx+71-4| ... | ... | @@ -158,7 +158,13 @@ interface ProviderSettingsFormProps { |
| 158 | 158 | readonly definition: ProviderClientDefinition; |
| 159 | 159 | readonly value: unknown; |
| 160 | 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 | 168 | readonly onChange: (nextConfig: Record<string, unknown> | undefined) => void; |
| 163 | 169 | } |
| 164 | 170 | |
| ... | ... | @@ -189,14 +195,75 @@ function ProviderSettingsFieldRow({ |
| 189 | 195 | }: ProviderSettingsFieldRowProps) { |
| 190 | 196 | const inputId = `${idPrefix}-${field.key}`; |
| 191 | 197 | const descriptionClassName = |
| 192 | variant === "card" | |
| 193 | ? "mt-1 block text-xs text-muted-foreground" | |
| 194 | : "text-[11px] text-muted-foreground"; | |
| 198 | variant === "dialog" | |
| 199 | ? "text-[11px] text-muted-foreground" | |
| 200 | : "mt-1 block text-xs text-muted-foreground"; | |
| 195 | 201 | const label = <span className="text-xs font-medium text-foreground">{field.label}</span>; |
| 196 | 202 | const description = field.description ? ( |
| 197 | 203 | <span className={descriptionClassName}>{field.description}</span> |
| 198 | 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 | 267 | if (field.control === "switch") { |
| 201 | 268 | return ( |
| 202 | 269 | <FieldFrame variant={variant}> |
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+18-10| ... | ... | @@ -141,6 +141,21 @@ function renderPanel(options?: { |
| 141 | 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 | 159 | function isAddProviderButton(element: ReactElement<Record<string, unknown>>): boolean { |
| 145 | 160 | return element.props["aria-label"] === "Add provider"; |
| 146 | 161 | } |
| ... | ... | @@ -184,10 +199,7 @@ describe("EnvironmentProviderSettings routing", () => { |
| 184 | 199 | it("routes refresh and provider update commands to the selected environment", async () => { |
| 185 | 200 | atoms.providers = [provider()]; |
| 186 | 201 | const panel = renderPanel(); |
| 187 | const refreshButton = visitElements( | |
| 188 | panel, | |
| 189 | (element) => element.props["aria-label"] === "Refresh provider status", | |
| 190 | ); | |
| 202 | const refreshButton = visitElements(panel, isRefreshButton); | |
| 191 | 203 | expect(refreshButton).not.toBeNull(); |
| 192 | 204 | (refreshButton?.props.onClick as (() => void) | undefined)?.(); |
| 193 | 205 | await flushPromises(); |
| ... | ... | @@ -243,9 +255,7 @@ describe("EnvironmentProviderSettings routing", () => { |
| 243 | 255 | const notice = visitElements(panel, (element) => element.props.title === "Limited permissions"); |
| 244 | 256 | expect(notice).not.toBeNull(); |
| 245 | 257 | |
| 246 | expect( | |
| 247 | visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"), | |
| 248 | ).toBeNull(); | |
| 258 | expect(visitElements(panel, isRefreshButton)).toBeNull(); | |
| 249 | 259 | expect(visitElements(panel, isAddProviderButton)).toBeNull(); |
| 250 | 260 | }); |
| 251 | 261 | |
| ... | ... | @@ -256,9 +266,7 @@ describe("EnvironmentProviderSettings routing", () => { |
| 256 | 266 | expect( |
| 257 | 267 | visitElements(panel, (element) => element.props.title === "Limited permissions"), |
| 258 | 268 | ).toBeNull(); |
| 259 | expect( | |
| 260 | visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"), | |
| 261 | ).not.toBeNull(); | |
| 269 | expect(visitElements(panel, isRefreshButton)).not.toBeNull(); | |
| 262 | 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 | 135 | } |
| 136 | 136 | |
| 137 | 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 | 141 | return ( |
| 142 | <span className="text-[11px] text-muted-foreground/60"> | |
| 142 | <span> | |
| 143 | 143 | {lastCheckedRelative.suffix ? ( |
| 144 | 144 | <> |
| 145 | 145 | Checked <span className="font-mono tabular-nums">{lastCheckedRelative.value}</span>{" "} |
| ... | ... | @@ -858,33 +858,31 @@ export function EnvironmentProviderSettings({ |
| 858 | 858 | <SettingsSection |
| 859 | 859 | {...searchableSetting("providers")} |
| 860 | 860 | headerAction={ |
| 861 | <div className="flex min-w-0 items-center gap-1.5"> | |
| 862 | {/* | |
| 863 | The 11px size must sit on this flex item, not just the span | |
| 864 | inside: the item's line box is struck from its own font size, | |
| 865 | and an inherited 16px strut hangs the smaller text below the | |
| 866 | vertical center of the row. | |
| 867 | */} | |
| 868 | <span className="hidden min-w-0 truncate text-[11px] sm:inline"> | |
| 869 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> | |
| 870 | </span> | |
| 871 | {!readOnly ? ( | |
| 861 | <div className="flex min-w-0 items-center gap-2"> | |
| 862 | {readOnly ? ( | |
| 863 | <span className="min-w-0 truncate text-xs text-muted-foreground"> | |
| 864 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> | |
| 865 | </span> | |
| 866 | ) : ( | |
| 872 | 867 | <> |
| 873 | 868 | <Tooltip> |
| 874 | 869 | <TooltipTrigger |
| 875 | 870 | render={ |
| 876 | 871 | <Button |
| 877 | size="icon-micro" | |
| 872 | size="compact" | |
| 878 | 873 | variant="ghost-muted" |
| 879 | 874 | disabled={isRefreshingProviders} |
| 880 | 875 | onClick={() => void refreshProviders()} |
| 881 | aria-label="Refresh provider status" | |
| 882 | 876 | > |
| 883 | 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 | 886 | </Button> |
| 889 | 887 | } |
| 890 | 888 | /> |
| ... | ... | @@ -906,7 +904,7 @@ export function EnvironmentProviderSettings({ |
| 906 | 904 | <TooltipPopup side="top">Add provider</TooltipPopup> |
| 907 | 905 | </Tooltip> |
| 908 | 906 | </> |
| 909 | ) : null} | |
| 907 | )} | |
| 910 | 908 | </div> |
| 911 | 909 | } |
| 912 | 910 | > |
| ... | ... | @@ -948,27 +946,24 @@ export function EnvironmentProviderSettings({ |
| 948 | 946 | Advanced |
| 949 | 947 | </CollapsibleTrigger> |
| 950 | 948 | <CollapsibleContent> |
| 951 | <div | |
| 952 | inert={readOnly} | |
| 953 | aria-disabled={readOnly || undefined} | |
| 954 | className={readOnly ? "opacity-50 select-none" : undefined} | |
| 955 | > | |
| 956 | <SettingsRow | |
| 957 | id={searchableSetting("provider-health-check-interval").id} | |
| 958 | title={ | |
| 959 | <span className="inline-flex items-center gap-1.5"> | |
| 960 | {searchableSetting("provider-health-check-interval").title} | |
| 961 | <PolicyTooltip> | |
| 962 | This interval is configured here, then the shared Background activity policy | |
| 963 | decides whether provider probes may run when the timer fires. Custom | |
| 964 | intervals appear as Advanced in General settings. | |
| 965 | </PolicyTooltip> | |
| 966 | </span> | |
| 967 | } | |
| 968 | description="Refresh availability, versions, auth state, and models in the background. 0 seconds turns background checks off." | |
| 969 | resetAction={ | |
| 970 | providerHealthRefreshIntervalSeconds !== | |
| 971 | defaultProviderHealthRefreshIntervalSeconds ? ( | |
| 949 | {/* Only the write controls go inert; the title and its policy tooltip stay readable. */} | |
| 950 | <SettingsRow | |
| 951 | id={searchableSetting("provider-health-check-interval").id} | |
| 952 | title={ | |
| 953 | <span className="inline-flex items-center gap-1.5"> | |
| 954 | {searchableSetting("provider-health-check-interval").title} | |
| 955 | <PolicyTooltip> | |
| 956 | This interval is configured here, then the shared Background activity policy | |
| 957 | decides whether provider probes may run when the timer fires. Custom intervals | |
| 958 | appear as Advanced in General settings. | |
| 959 | </PolicyTooltip> | |
| 960 | </span> | |
| 961 | } | |
| 962 | description="Refresh availability, versions, auth state, and models in the background. 0 seconds turns background checks off." | |
| 963 | resetAction={ | |
| 964 | providerHealthRefreshIntervalSeconds !== | |
| 965 | defaultProviderHealthRefreshIntervalSeconds ? ( | |
| 966 | <span inert={readOnly} className={readOnly ? "opacity-50" : undefined}> | |
| 972 | 967 | <SettingResetButton |
| 973 | 968 | label="provider health check interval" |
| 974 | 969 | onClick={() => |
| ... | ... | @@ -981,41 +976,48 @@ export function EnvironmentProviderSettings({ |
| 981 | 976 | ) |
| 982 | 977 | } |
| 983 | 978 | /> |
| 984 | ) : null | |
| 985 | } | |
| 986 | control={ | |
| 987 | <div className="flex shrink-0 items-center gap-2"> | |
| 988 | <NumberField | |
| 989 | value={providerHealthRefreshIntervalSeconds} | |
| 990 | min={0} | |
| 991 | step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS} | |
| 992 | size="sm" | |
| 993 | className="w-32" | |
| 994 | onValueChange={(value) => | |
| 995 | updateSettings( | |
| 996 | backgroundActivityOverrideSettings( | |
| 997 | settings.backgroundActivity, | |
| 998 | resolvedBackgroundActivity, | |
| 999 | { | |
| 1000 | providerHealthRefreshInterval: Duration.seconds( | |
| 1001 | normalizeIntervalSeconds(value), | |
| 1002 | ), | |
| 1003 | }, | |
| 1004 | ), | |
| 1005 | ) | |
| 1006 | } | |
| 1007 | > | |
| 1008 | <NumberFieldGroup> | |
| 1009 | <NumberFieldDecrement aria-label="Decrease provider health check interval" /> | |
| 1010 | <NumberFieldInput aria-label="Provider health check interval in seconds" /> | |
| 1011 | <NumberFieldIncrement aria-label="Increase provider health check interval" /> | |
| 1012 | </NumberFieldGroup> | |
| 1013 | </NumberField> | |
| 1014 | <span className="text-xs text-muted-foreground">seconds</span> | |
| 1015 | </div> | |
| 1016 | } | |
| 1017 | /> | |
| 1018 | </div> | |
| 979 | </span> | |
| 980 | ) : null | |
| 981 | } | |
| 982 | control={ | |
| 983 | <div | |
| 984 | inert={readOnly} | |
| 985 | aria-disabled={readOnly || undefined} | |
| 986 | className={cn( | |
| 987 | "flex shrink-0 items-center gap-2", | |
| 988 | readOnly && "opacity-50 select-none", | |
| 989 | )} | |
| 990 | > | |
| 991 | <NumberField | |
| 992 | value={providerHealthRefreshIntervalSeconds} | |
| 993 | min={0} | |
| 994 | step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS} | |
| 995 | size="sm" | |
| 996 | className="w-32" | |
| 997 | onValueChange={(value) => | |
| 998 | updateSettings( | |
| 999 | backgroundActivityOverrideSettings( | |
| 1000 | settings.backgroundActivity, | |
| 1001 | resolvedBackgroundActivity, | |
| 1002 | { | |
| 1003 | providerHealthRefreshInterval: Duration.seconds( | |
| 1004 | normalizeIntervalSeconds(value), | |
| 1005 | ), | |
| 1006 | }, | |
| 1007 | ), | |
| 1008 | ) | |
| 1009 | } | |
| 1010 | > | |
| 1011 | <NumberFieldGroup> | |
| 1012 | <NumberFieldDecrement aria-label="Decrease provider health check interval" /> | |
| 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 | 1021 | </CollapsibleContent> |
| 1020 | 1022 | </Collapsible> |
| 1021 | 1023 | </div> |