| author | |
| committer | |
| log | e2d4d12a81516b55abbecdc64794971f781cacd8 |
| tree | f37de0e44772d625c3986c0b8e9a171f0bdfe49a |
| parent | 348367dcc6e1ac8b11baf94a31c47f33d46313cd |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 728 insertions(+), 547 deletions(-)
apps/web/src/components/settings/ProviderInstanceCard.tsx+318-262| ... | ... | @@ -2,7 +2,6 @@ |
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | ArrowUpCircleIcon, |
| 5 | ChevronDownIcon, | |
| 6 | 5 | CopyIcon, |
| 7 | 6 | DownloadIcon, |
| 8 | 7 | LoaderIcon, |
| ... | ... | @@ -12,7 +11,7 @@ import { |
| 12 | 11 | } from "lucide-react"; |
| 13 | 12 | import * as Arr from "effect/Array"; |
| 14 | 13 | import * as Result from "effect/Result"; |
| 15 | import { useState, type ReactNode } from "react"; | |
| 14 | import { useEffect, useRef, useState, type ReactNode } from "react"; | |
| 16 | 15 | import { |
| 17 | 16 | isProviderDriverKind, |
| 18 | 17 | resolveProviderInstanceEnabled, |
| ... | ... | @@ -30,7 +29,6 @@ import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 30 | 29 | import { Badge } from "../ui/badge"; |
| 31 | 30 | import { Button } from "../ui/button"; |
| 32 | 31 | import { Checkbox } from "../ui/checkbox"; |
| 33 | import { Collapsible, CollapsibleContent } from "../ui/collapsible"; | |
| 34 | 32 | import { DraftInput } from "../ui/draft-input"; |
| 35 | 33 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 36 | 34 | import { ScrollArea } from "../ui/scroll-area"; |
| ... | ... | @@ -39,11 +37,11 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ". |
| 39 | 37 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 40 | 38 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 41 | 39 | import type { DriverOption } from "./providerDriverMeta"; |
| 40 | import { providerSettingsTabClassName } from "./providerSettingsTabs"; | |
| 42 | 41 | import { ProviderSettingsForm } from "./ProviderSettingsForm"; |
| 43 | 42 | import { ProviderModelsSection } from "./ProviderModelsSection"; |
| 44 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | |
| 43 | import { ProviderInstanceIcon, providerInstanceInitials } from "../chat/ProviderInstanceIcon"; | |
| 45 | 44 | import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; |
| 46 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; | |
| 47 | 45 | import { |
| 48 | 46 | getProviderVersionAdvisoryPresentation, |
| 49 | 47 | PROVIDER_STATUS_STYLES, |
| ... | ... | @@ -78,6 +76,25 @@ function makeEnvironmentDraftRow( |
| 78 | 76 | }; |
| 79 | 77 | } |
| 80 | 78 | |
| 79 | function providerEnvironmentsEqual( | |
| 80 | left: ReadonlyArray<ProviderInstanceEnvironmentVariable>, | |
| 81 | right: ReadonlyArray<ProviderInstanceEnvironmentVariable>, | |
| 82 | ): boolean { | |
| 83 | return ( | |
| 84 | left.length === right.length && | |
| 85 | left.every((variable, index) => { | |
| 86 | const other = right[index]; | |
| 87 | return ( | |
| 88 | other !== undefined && | |
| 89 | variable.name === other.name && | |
| 90 | variable.value === other.value && | |
| 91 | variable.sensitive === other.sensitive && | |
| 92 | variable.valueRedacted === other.valueRedacted | |
| 93 | ); | |
| 94 | }) | |
| 95 | ); | |
| 96 | } | |
| 97 | ||
| 81 | 98 | /** |
| 82 | 99 | * Read a string[] at `key` from the opaque config blob, filtering out |
| 83 | 100 | * non-string entries. Used for `customModels`, which is always typed as |
| ... | ... | @@ -132,28 +149,6 @@ export function deriveProviderModelsForDisplay(input: { |
| 132 | 149 | return [...serverModels, ...customModels]; |
| 133 | 150 | } |
| 134 | 151 | |
| 135 | function ProviderAuthEmail(props: { | |
| 136 | readonly email: string | undefined; | |
| 137 | readonly prefix?: string; | |
| 138 | readonly separator?: boolean; | |
| 139 | }) { | |
| 140 | const trimmed = props.email?.trim(); | |
| 141 | if (!trimmed) return null; | |
| 142 | ||
| 143 | return ( | |
| 144 | <span className="inline-flex min-w-0 items-center gap-1.5"> | |
| 145 | {props.separator ? <span aria-hidden>·</span> : null} | |
| 146 | {props.prefix ? <span className="text-muted-foreground/80">{props.prefix}</span> : null} | |
| 147 | <RedactedSensitiveText | |
| 148 | value={trimmed} | |
| 149 | ariaLabel="Toggle account email visibility" | |
| 150 | revealTooltip="Click to reveal email" | |
| 151 | hideTooltip="Click to hide email" | |
| 152 | /> | |
| 153 | </span> | |
| 154 | ); | |
| 155 | } | |
| 156 | ||
| 157 | 152 | function ProviderEnvironmentSection(props: { |
| 158 | 153 | readonly environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>; |
| 159 | 154 | readonly onChange: (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => void; |
| ... | ... | @@ -161,6 +156,26 @@ function ProviderEnvironmentSection(props: { |
| 161 | 156 | const [rows, setRows] = useState<ReadonlyArray<EnvironmentDraftRow>>(() => |
| 162 | 157 | props.environment.map(makeEnvironmentDraftRow), |
| 163 | 158 | ); |
| 159 | const previousEnvironmentRef = useRef(props.environment); | |
| 160 | const lastPublishedEnvironmentRef = useRef< | |
| 161 | ReadonlyArray<ProviderInstanceEnvironmentVariable> | undefined | |
| 162 | >(undefined); | |
| 163 | ||
| 164 | useEffect(() => { | |
| 165 | const previousEnvironment = previousEnvironmentRef.current; | |
| 166 | const lastPublishedEnvironment = lastPublishedEnvironmentRef.current; | |
| 167 | previousEnvironmentRef.current = props.environment; | |
| 168 | lastPublishedEnvironmentRef.current = undefined; | |
| 169 | if ( | |
| 170 | previousEnvironment === props.environment || | |
| 171 | providerEnvironmentsEqual(previousEnvironment, props.environment) || | |
| 172 | (lastPublishedEnvironment !== undefined && | |
| 173 | providerEnvironmentsEqual(lastPublishedEnvironment, props.environment)) | |
| 174 | ) { | |
| 175 | return; | |
| 176 | } | |
| 177 | setRows(props.environment.map(makeEnvironmentDraftRow)); | |
| 178 | }, [props.environment]); | |
| 164 | 179 | |
| 165 | 180 | const publishRows = (nextRows: ReadonlyArray<EnvironmentDraftRow>) => { |
| 166 | 181 | const published: ProviderInstanceEnvironmentVariable[] = []; |
| ... | ... | @@ -180,6 +195,7 @@ function ProviderEnvironmentSection(props: { |
| 180 | 195 | const { id: _id, ...rest } = row; |
| 181 | 196 | published.push({ ...rest, name }); |
| 182 | 197 | } |
| 198 | lastPublishedEnvironmentRef.current = published; | |
| 183 | 199 | props.onChange(published); |
| 184 | 200 | }; |
| 185 | 201 | |
| ... | ... | @@ -324,8 +340,10 @@ interface ProviderInstanceCardProps { |
| 324 | 340 | readonly instance: ProviderInstanceConfig; |
| 325 | 341 | readonly driverOption: DriverOption | undefined; |
| 326 | 342 | readonly liveProvider: ServerProvider | undefined; |
| 327 | readonly isExpanded: boolean; | |
| 328 | readonly onExpandedChange: (open: boolean) => void; | |
| 343 | readonly mode: "list" | "editor"; | |
| 344 | readonly selected?: boolean | undefined; | |
| 345 | readonly onSelect?: (() => void) | undefined; | |
| 346 | readonly readOnly?: boolean | undefined; | |
| 329 | 347 | readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void; |
| 330 | 348 | /** |
| 331 | 349 | * Pass `undefined` to hide the delete button entirely. Built-in default |
| ... | ... | @@ -353,12 +371,9 @@ interface ProviderInstanceCardProps { |
| 353 | 371 | } |
| 354 | 372 | |
| 355 | 373 | /** |
| 356 | * A single configured provider-instance row in the Providers settings | |
| 357 | * section. Used for every row — both the built-in default instance for a | |
| 358 | * driver (rendered with `onDelete` omitted) and user-authored custom | |
| 359 | * instances (`onDelete` supplied). The only UI difference between the two | |
| 360 | * is whether the trash button is visible; every other field (display | |
| 361 | * name, config fields, models) behaves identically. | |
| 374 | * Renders one provider instance as either a compact selectable list row or | |
| 375 | * the full editor shown beside that list. Both modes use the same enabled | |
| 376 | * state and provider metadata. | |
| 362 | 377 | * |
| 363 | 378 | * Behavior notes: |
| 364 | 379 | * - `liveProvider` is matched by the caller via `instanceId`; when no |
| ... | ... | @@ -379,8 +394,10 @@ export function ProviderInstanceCard({ |
| 379 | 394 | instance, |
| 380 | 395 | driverOption, |
| 381 | 396 | liveProvider, |
| 382 | isExpanded, | |
| 383 | onExpandedChange, | |
| 397 | mode, | |
| 398 | selected = false, | |
| 399 | onSelect, | |
| 400 | readOnly = false, | |
| 384 | 401 | onUpdate, |
| 385 | 402 | onDelete, |
| 386 | 403 | headerAction, |
| ... | ... | @@ -393,21 +410,17 @@ export function ProviderInstanceCard({ |
| 393 | 410 | onRunUpdate, |
| 394 | 411 | isUpdating = false, |
| 395 | 412 | }: ProviderInstanceCardProps) { |
| 413 | const [activeTab, setActiveTab] = useState<"models" | "configuration">("configuration"); | |
| 396 | 414 | const enabled = resolveProviderInstanceEnabled(instance); |
| 397 | // The server-reported status wins when present; otherwise fall back to | |
| 398 | // "disabled"/"warning" based on the local `enabled` flag so the dot | |
| 399 | // reflects the persisted intent even before the first probe completes. | |
| 400 | const statusKey: ProviderStatusKey = | |
| 401 | (liveProvider?.status as ProviderStatusKey | undefined) ?? (enabled ? "warning" : "disabled"); | |
| 415 | // A locally disabled provider stays neutral even if its last server status | |
| 416 | // is stale. Enabled providers use the server status when one is available. | |
| 417 | const statusKey: ProviderStatusKey = enabled | |
| 418 | ? ((liveProvider?.status as ProviderStatusKey | undefined) ?? "warning") | |
| 419 | : "disabled"; | |
| 402 | 420 | const statusStyle = PROVIDER_STATUS_STYLES[statusKey]; |
| 403 | 421 | const rawSummary = getProviderSummary(liveProvider); |
| 404 | const authEmail = liveProvider?.auth.email; | |
| 405 | const hasAuthenticatedEmail = | |
| 406 | liveProvider?.auth.status === "authenticated" && Boolean(authEmail?.trim()); | |
| 407 | const authenticatedDetail = hasAuthenticatedEmail | |
| 408 | ? (liveProvider?.auth.label ?? liveProvider?.auth.type ?? null) | |
| 409 | : null; | |
| 410 | const summary = rawSummary; | |
| 422 | const summary = enabled ? rawSummary : { headline: "Disabled", detail: null }; | |
| 423 | const showEditorStatus = enabled && (statusKey === "warning" || statusKey === "error"); | |
| 411 | 424 | const versionLabel = getProviderVersionLabel(liveProvider?.version); |
| 412 | 425 | const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); |
| 413 | 426 | const updateCommand = versionAdvisory?.updateCommand ?? null; |
| ... | ... | @@ -441,6 +454,7 @@ export function ProviderInstanceCard({ |
| 441 | 454 | const driverKind: ProviderDriverKind | null = isProviderDriverKind(instance.driver) |
| 442 | 455 | ? instance.driver |
| 443 | 456 | : null; |
| 457 | const visibleTab = driverOption === undefined ? "configuration" : activeTab; | |
| 444 | 458 | |
| 445 | 459 | const customModels = readConfigStringArray(instance.config, "customModels"); |
| 446 | 460 | // Server-returned models may lag behind settings writes. Treat probe |
| ... | ... | @@ -506,25 +520,21 @@ export function ProviderInstanceCard({ |
| 506 | 520 | displayName={displayName} |
| 507 | 521 | accentColor={accentColor} |
| 508 | 522 | showBadge={Boolean(accentColor)} |
| 509 | statusDotClassName={statusStyle.dot} | |
| 510 | indicatorBackground="var(--card)" | |
| 511 | 523 | className="size-5" |
| 512 | 524 | iconClassName="size-4 text-foreground/80" |
| 513 | 525 | badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-[7px]" |
| 514 | 526 | /> |
| 515 | 527 | ) : FallbackIconComponent ? ( |
| 516 | <span className="relative inline-flex size-5 shrink-0 items-center justify-center"> | |
| 528 | <span className="inline-flex size-5 shrink-0 items-center justify-center"> | |
| 517 | 529 | <FallbackIconComponent className="size-4 text-foreground/80" aria-hidden /> |
| 518 | <span | |
| 519 | className={cn( | |
| 520 | "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-card", | |
| 521 | statusStyle.dot, | |
| 522 | )} | |
| 523 | aria-hidden | |
| 524 | /> | |
| 525 | 530 | </span> |
| 526 | 531 | ) : ( |
| 527 | <span className={cn("size-2 shrink-0 rounded-full", statusStyle.dot)} /> | |
| 532 | <span | |
| 533 | className="inline-flex size-5 shrink-0 items-center justify-center text-[10px] font-semibold leading-none text-foreground/80" | |
| 534 | aria-hidden | |
| 535 | > | |
| 536 | {providerInstanceInitials(displayName)} | |
| 537 | </span> | |
| 528 | 538 | ); |
| 529 | 539 | |
| 530 | 540 | const titleHeadNode = ( |
| ... | ... | @@ -576,229 +586,275 @@ export function ProviderInstanceCard({ |
| 576 | 586 | </> |
| 577 | 587 | ); |
| 578 | 588 | |
| 579 | const authRowNode = ( | |
| 580 | <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-[13px] leading-[1.45] text-muted-foreground/80"> | |
| 581 | {hasAuthenticatedEmail ? ( | |
| 582 | <> | |
| 583 | <span>Authenticated as</span> | |
| 584 | <ProviderAuthEmail email={authEmail} /> | |
| 585 | {authenticatedDetail ? <span>· {authenticatedDetail}</span> : null} | |
| 586 | </> | |
| 587 | ) : ( | |
| 588 | <> | |
| 589 | <span>{summary.headline}</span> | |
| 590 | <ProviderAuthEmail email={authEmail} separator prefix="Email" /> | |
| 591 | </> | |
| 592 | )} | |
| 593 | {summary.detail ? <span>- {summary.detail}</span> : null} | |
| 594 | </p> | |
| 595 | ); | |
| 596 | ||
| 597 | 589 | const versionCodeNode = versionLabel ? ( |
| 598 | 590 | <code className="text-xs text-muted-foreground">{versionLabel}</code> |
| 599 | 591 | ) : null; |
| 600 | 592 | |
| 601 | return ( | |
| 602 | <div className="rounded-xl transition-colors hover:bg-muted/20"> | |
| 603 | <div className="px-3 py-3 sm:px-4"> | |
| 604 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | |
| 605 | <div className="min-w-0 flex-1 space-y-1"> | |
| 606 | <div className="flex min-w-0 flex-wrap items-center gap-2"> | |
| 607 | {titleHeadNode} | |
| 593 | if (mode === "list") { | |
| 594 | return ( | |
| 595 | <div | |
| 596 | className={cn( | |
| 597 | "group relative flex min-h-16 items-center gap-3 border-b border-border/60 px-3 py-2.5 transition-colors last:border-b-0", | |
| 598 | selected ? "bg-muted/50" : "hover:bg-muted/25", | |
| 599 | )} | |
| 600 | > | |
| 601 | {selected ? <span className="absolute inset-y-0 left-0 w-0.5 bg-primary" /> : null} | |
| 602 | <button | |
| 603 | type="button" | |
| 604 | className={cn( | |
| 605 | "flex min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-sm text-left outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-ring", | |
| 606 | !enabled && !selected && "opacity-60 group-hover:opacity-100", | |
| 607 | )} | |
| 608 | onClick={onSelect} | |
| 609 | aria-pressed={selected} | |
| 610 | > | |
| 611 | {titleIconNode} | |
| 612 | <span className="min-w-0 flex-1"> | |
| 613 | <span className="flex min-w-0 items-center gap-2"> | |
| 614 | <span className="truncate text-sm font-medium text-foreground">{displayName}</span> | |
| 608 | 615 | {versionCodeNode} |
| 609 | {versionAdvisory ? ( | |
| 610 | <Popover> | |
| 611 | <PopoverTrigger | |
| 612 | render={ | |
| 613 | <Button | |
| 614 | type="button" | |
| 615 | size="icon-xs" | |
| 616 | variant="ghost" | |
| 616 | </span> | |
| 617 | <span className="mt-0.5 flex items-center gap-1.5 text-[11px] text-muted-foreground"> | |
| 618 | <span className={cn("size-1.5 shrink-0 rounded-full", statusStyle.dot)} /> | |
| 619 | <span className="truncate">{summary.headline}</span> | |
| 620 | </span> | |
| 621 | {String(instanceId) !== String(instance.driver) ? ( | |
| 622 | <code className="mt-0.5 block truncate text-[10px] text-muted-foreground/70"> | |
| 623 | {instanceId} | |
| 624 | </code> | |
| 625 | ) : null} | |
| 626 | </span> | |
| 627 | </button> | |
| 628 | <Switch | |
| 629 | checked={enabled} | |
| 630 | disabled={readOnly} | |
| 631 | onCheckedChange={(checked) => updateEnabled(Boolean(checked))} | |
| 632 | aria-label={`Enable ${displayName}`} | |
| 633 | /> | |
| 634 | </div> | |
| 635 | ); | |
| 636 | } | |
| 637 | ||
| 638 | return ( | |
| 639 | <div className="min-w-0"> | |
| 640 | <div | |
| 641 | inert={readOnly} | |
| 642 | aria-disabled={readOnly || undefined} | |
| 643 | className={cn( | |
| 644 | "flex min-h-16 items-center justify-between gap-3 border-b border-border/70 px-4 py-3", | |
| 645 | readOnly && "opacity-50 select-none", | |
| 646 | )} | |
| 647 | > | |
| 648 | <div className="min-w-0 flex-1 space-y-1"> | |
| 649 | <div className="flex min-w-0 flex-wrap items-center gap-2"> | |
| 650 | {titleHeadNode} | |
| 651 | {versionCodeNode} | |
| 652 | {versionAdvisory ? ( | |
| 653 | <Popover> | |
| 654 | <PopoverTrigger | |
| 655 | render={ | |
| 656 | <Button | |
| 657 | type="button" | |
| 658 | size="icon-xs" | |
| 659 | variant="ghost" | |
| 660 | className={cn( | |
| 661 | "size-5 rounded-sm p-0", | |
| 662 | versionAdvisory.emphasis === "strong" | |
| 663 | ? "text-warning hover:text-warning" | |
| 664 | : "text-update-foreground hover:text-update-foreground", | |
| 665 | )} | |
| 666 | aria-label="Update available — view details" | |
| 667 | > | |
| 668 | <ArrowUpCircleIcon className="size-3.5" /> | |
| 669 | </Button> | |
| 670 | } | |
| 671 | /> | |
| 672 | <PopoverPopup | |
| 673 | side="bottom" | |
| 674 | align="start" | |
| 675 | className="w-[min(21rem,calc(100vw-1.5rem))] [--popup-width:min(21rem,calc(100vw-1.5rem))]" | |
| 676 | > | |
| 677 | <div className="grid min-w-0 gap-3"> | |
| 678 | <div className="grid gap-0.5"> | |
| 679 | <p className="text-[13px] font-semibold leading-tight text-foreground"> | |
| 680 | Update available | |
| 681 | </p> | |
| 682 | <p | |
| 617 | 683 | className={cn( |
| 618 | "size-5 rounded-sm p-0", | |
| 684 | "text-xs leading-snug", | |
| 619 | 685 | versionAdvisory.emphasis === "strong" |
| 620 | ? "text-warning hover:text-warning" | |
| 621 | : "text-update-foreground hover:text-update-foreground", | |
| 686 | ? "text-warning" | |
| 687 | : "text-muted-foreground", | |
| 622 | 688 | )} |
| 623 | aria-label="Update available — view details" | |
| 624 | 689 | > |
| 625 | <ArrowUpCircleIcon className="size-3.5 [animation:bounce_2.4s_ease-in-out_infinite] motion-reduce:animate-none" /> | |
| 690 | {versionAdvisory.detail} | |
| 691 | </p> | |
| 692 | </div> | |
| 693 | {onRunUpdate ? ( | |
| 694 | <Button | |
| 695 | type="button" | |
| 696 | size="xs" | |
| 697 | variant="default" | |
| 698 | className="w-full" | |
| 699 | disabled={isUpdating} | |
| 700 | onClick={onRunUpdate} | |
| 701 | > | |
| 702 | {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />} | |
| 703 | {isUpdating ? "Updating" : "Update now"} | |
| 626 | 704 | </Button> |
| 627 | } | |
| 628 | /> | |
| 629 | <PopoverPopup | |
| 630 | side="bottom" | |
| 631 | align="start" | |
| 632 | className="w-[min(21rem,calc(100vw-1.5rem))] [--popup-width:min(21rem,calc(100vw-1.5rem))]" | |
| 633 | > | |
| 634 | <div className="grid min-w-0 gap-3"> | |
| 635 | <div className="grid gap-0.5"> | |
| 636 | <p className="text-[13px] font-semibold leading-tight text-foreground"> | |
| 637 | Update available | |
| 638 | </p> | |
| 639 | <p | |
| 640 | className={cn( | |
| 641 | "text-xs leading-snug", | |
| 642 | versionAdvisory.emphasis === "strong" | |
| 643 | ? "text-warning" | |
| 644 | : "text-muted-foreground", | |
| 645 | )} | |
| 646 | > | |
| 647 | {versionAdvisory.detail} | |
| 648 | </p> | |
| 705 | ) : null} | |
| 706 | {onRunUpdate && updateCommand ? ( | |
| 707 | <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> | |
| 708 | <span aria-hidden className="h-px flex-1 bg-border" /> | |
| 709 | or, update manually using | |
| 710 | <span aria-hidden className="h-px flex-1 bg-border" /> | |
| 649 | 711 | </div> |
| 650 | {onRunUpdate ? ( | |
| 651 | <Button | |
| 652 | type="button" | |
| 653 | size="xs" | |
| 654 | variant="default" | |
| 655 | className="w-full" | |
| 656 | disabled={isUpdating} | |
| 657 | onClick={onRunUpdate} | |
| 658 | > | |
| 659 | {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />} | |
| 660 | {isUpdating ? "Updating" : "Update now"} | |
| 661 | </Button> | |
| 662 | ) : null} | |
| 663 | {onRunUpdate && updateCommand ? ( | |
| 664 | <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> | |
| 665 | <span aria-hidden className="h-px flex-1 bg-border" /> | |
| 666 | or, update manually using | |
| 667 | <span aria-hidden className="h-px flex-1 bg-border" /> | |
| 668 | </div> | |
| 669 | ) : null} | |
| 670 | {updateCommand ? ( | |
| 671 | <div className="flex min-w-0 items-center gap-1 rounded-md border border-border/70 bg-muted/40 py-0.5 pr-0.5 pl-2"> | |
| 672 | <ScrollArea scrollFade className="h-8 min-w-0 flex-1 rounded-none"> | |
| 673 | <code className="flex h-full w-max items-center whitespace-nowrap pr-3 font-mono text-[11px] text-foreground"> | |
| 674 | {updateCommand} | |
| 675 | </code> | |
| 676 | </ScrollArea> | |
| 677 | <Tooltip> | |
| 678 | <TooltipTrigger | |
| 679 | render={ | |
| 680 | <Button | |
| 681 | type="button" | |
| 682 | size="icon-xs" | |
| 683 | variant="ghost" | |
| 684 | className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground" | |
| 685 | onClick={() => | |
| 686 | copyToClipboard(updateCommand, { | |
| 687 | providerName: displayName, | |
| 688 | }) | |
| 689 | } | |
| 690 | aria-label="Copy update command" | |
| 691 | > | |
| 692 | <CopyIcon className="size-3" /> | |
| 693 | </Button> | |
| 694 | } | |
| 695 | /> | |
| 696 | <TooltipPopup side="top">Copy command</TooltipPopup> | |
| 697 | </Tooltip> | |
| 698 | </div> | |
| 699 | ) : null} | |
| 700 | </div> | |
| 701 | </PopoverPopup> | |
| 702 | </Popover> | |
| 703 | ) : null} | |
| 704 | {titleTailNode} | |
| 705 | </div> | |
| 706 | {authRowNode} | |
| 707 | </div> | |
| 708 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | |
| 709 | <Button | |
| 710 | size="compact" | |
| 711 | variant="ghost-muted" | |
| 712 | onClick={() => onExpandedChange(!isExpanded)} | |
| 713 | aria-label={`Toggle ${displayName} details`} | |
| 714 | > | |
| 715 | <ChevronDownIcon | |
| 716 | className={cn("size-3.5 transition-transform", isExpanded && "rotate-180")} | |
| 717 | /> | |
| 718 | </Button> | |
| 719 | <Switch | |
| 720 | checked={enabled} | |
| 721 | onCheckedChange={(checked) => updateEnabled(Boolean(checked))} | |
| 722 | aria-label={`Enable ${displayName}`} | |
| 723 | /> | |
| 712 | ) : null} | |
| 713 | {updateCommand ? ( | |
| 714 | <div className="flex min-w-0 items-center gap-1 rounded-md border border-border/70 bg-muted/40 py-0.5 pr-0.5 pl-2"> | |
| 715 | <ScrollArea scrollFade className="h-8 min-w-0 flex-1 rounded-none"> | |
| 716 | <code className="flex h-full w-max items-center whitespace-nowrap pr-3 font-mono text-[11px] text-foreground"> | |
| 717 | {updateCommand} | |
| 718 | </code> | |
| 719 | </ScrollArea> | |
| 720 | <Tooltip> | |
| 721 | <TooltipTrigger | |
| 722 | render={ | |
| 723 | <Button | |
| 724 | type="button" | |
| 725 | size="icon-xs" | |
| 726 | variant="ghost" | |
| 727 | className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground" | |
| 728 | onClick={() => | |
| 729 | copyToClipboard(updateCommand, { | |
| 730 | providerName: displayName, | |
| 731 | }) | |
| 732 | } | |
| 733 | aria-label="Copy update command" | |
| 734 | > | |
| 735 | <CopyIcon className="size-3" /> | |
| 736 | </Button> | |
| 737 | } | |
| 738 | /> | |
| 739 | <TooltipPopup side="top">Copy command</TooltipPopup> | |
| 740 | </Tooltip> | |
| 741 | </div> | |
| 742 | ) : null} | |
| 743 | </div> | |
| 744 | </PopoverPopup> | |
| 745 | </Popover> | |
| 746 | ) : null} | |
| 747 | {titleTailNode} | |
| 724 | 748 | </div> |
| 749 | {showEditorStatus ? ( | |
| 750 | <p className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground"> | |
| 751 | <span>{summary.headline}</span> | |
| 752 | {summary.detail ? <span>· {summary.detail}</span> : null} | |
| 753 | </p> | |
| 754 | ) : null} | |
| 725 | 755 | </div> |
| 726 | 756 | </div> |
| 727 | 757 | |
| 728 | <Collapsible open={isExpanded} onOpenChange={onExpandedChange}> | |
| 729 | <CollapsibleContent> | |
| 730 | <div className="space-y-5 px-3 pb-4 pt-2 sm:px-4"> | |
| 731 | <div> | |
| 732 | <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block"> | |
| 733 | <span className="text-xs font-medium text-foreground">Display name</span> | |
| 734 | <DraftInput | |
| 735 | id={`provider-instance-${instanceId}-display-name`} | |
| 736 | className="mt-1.5" | |
| 737 | value={instance.displayName ?? ""} | |
| 738 | onCommit={updateDisplayName} | |
| 739 | placeholder={driverOption?.label ?? "Instance label"} | |
| 740 | spellCheck={false} | |
| 741 | /> | |
| 742 | <span className="mt-1 block text-xs text-muted-foreground"> | |
| 743 | Optional label shown in the provider list. | |
| 744 | </span> | |
| 745 | </label> | |
| 746 | </div> | |
| 758 | <div className="flex h-11 border-b border-border/70 px-1"> | |
| 759 | {driverOption !== undefined ? ( | |
| 760 | <button | |
| 761 | type="button" | |
| 762 | aria-pressed={visibleTab === "models"} | |
| 763 | className={providerSettingsTabClassName(visibleTab === "models")} | |
| 764 | onClick={() => setActiveTab("models")} | |
| 765 | > | |
| 766 | Models | |
| 767 | </button> | |
| 768 | ) : null} | |
| 769 | <button | |
| 770 | type="button" | |
| 771 | aria-pressed={visibleTab === "configuration"} | |
| 772 | className={providerSettingsTabClassName(visibleTab === "configuration")} | |
| 773 | onClick={() => setActiveTab("configuration")} | |
| 774 | > | |
| 775 | Configuration | |
| 776 | </button> | |
| 777 | </div> | |
| 747 | 778 | |
| 748 | <div> | |
| 749 | <ProviderAccentColorPicker | |
| 750 | displayName={displayName} | |
| 751 | value={accentColor} | |
| 752 | onCommit={updateAccentColor} | |
| 753 | commitDelayMs={120} | |
| 754 | description="Used to distinguish this instance in picker rails and model lists." | |
| 779 | <div | |
| 780 | inert={readOnly} | |
| 781 | aria-disabled={readOnly || undefined} | |
| 782 | className={cn("px-4 py-5", readOnly && "opacity-50 select-none")} | |
| 783 | > | |
| 784 | <div className="space-y-5" hidden={visibleTab !== "configuration"}> | |
| 785 | <div> | |
| 786 | <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block"> | |
| 787 | <span className="text-xs font-medium text-foreground">Display name</span> | |
| 788 | <DraftInput | |
| 789 | id={`provider-instance-${instanceId}-display-name`} | |
| 790 | className="mt-1.5" | |
| 791 | value={instance.displayName ?? ""} | |
| 792 | onCommit={updateDisplayName} | |
| 793 | placeholder={driverOption?.label ?? "Instance label"} | |
| 794 | spellCheck={false} | |
| 755 | 795 | /> |
| 756 | </div> | |
| 796 | <span className="mt-1 block text-xs text-muted-foreground"> | |
| 797 | Optional label shown in the provider list. | |
| 798 | </span> | |
| 799 | </label> | |
| 800 | </div> | |
| 757 | 801 | |
| 758 | <div> | |
| 759 | <ProviderEnvironmentSection | |
| 760 | environment={instance.environment ?? []} | |
| 761 | onChange={updateEnvironment} | |
| 762 | /> | |
| 763 | </div> | |
| 802 | <div> | |
| 803 | <ProviderAccentColorPicker | |
| 804 | displayName={displayName} | |
| 805 | value={accentColor} | |
| 806 | onCommit={updateAccentColor} | |
| 807 | commitDelayMs={120} | |
| 808 | description="Used to distinguish this instance in picker rails and model lists." | |
| 809 | /> | |
| 810 | </div> | |
| 764 | 811 | |
| 765 | {driverOption ? ( | |
| 766 | <ProviderSettingsForm | |
| 767 | definition={driverOption} | |
| 768 | value={instance.config} | |
| 769 | idPrefix={`provider-instance-${instanceId}`} | |
| 770 | variant="card" | |
| 771 | onChange={updateConfig} | |
| 772 | /> | |
| 773 | ) : null} | |
| 812 | <div> | |
| 813 | <ProviderEnvironmentSection | |
| 814 | environment={instance.environment ?? []} | |
| 815 | onChange={updateEnvironment} | |
| 816 | /> | |
| 817 | </div> | |
| 774 | 818 | |
| 775 | {driverOption !== undefined ? ( | |
| 776 | <ProviderModelsSection | |
| 777 | instanceId={instanceId} | |
| 778 | driverKind={driverKind} | |
| 779 | models={modelsForDisplay} | |
| 780 | customModels={customModels} | |
| 781 | hiddenModels={hiddenModels} | |
| 782 | favoriteModels={favoriteModels} | |
| 783 | modelOrder={modelOrder} | |
| 784 | onChange={updateCustomModels} | |
| 785 | onHiddenModelsChange={onHiddenModelsChange} | |
| 786 | onFavoriteModelsChange={onFavoriteModelsChange} | |
| 787 | onModelOrderChange={onModelOrderChange} | |
| 788 | /> | |
| 789 | ) : ( | |
| 790 | <div> | |
| 791 | <p className="text-xs text-muted-foreground"> | |
| 792 | This instance uses a driver ( | |
| 793 | <code className="text-foreground">{String(instance.driver)}</code>) that is not | |
| 794 | shipped with the current build. Configuration values are preserved but cannot be | |
| 795 | edited from this surface. | |
| 796 | </p> | |
| 797 | </div> | |
| 798 | )} | |
| 819 | {driverOption ? ( | |
| 820 | <ProviderSettingsForm | |
| 821 | definition={driverOption} | |
| 822 | value={instance.config} | |
| 823 | idPrefix={`provider-instance-${instanceId}`} | |
| 824 | variant="card" | |
| 825 | onChange={updateConfig} | |
| 826 | /> | |
| 827 | ) : null} | |
| 828 | ||
| 829 | {driverOption === undefined ? ( | |
| 830 | <div> | |
| 831 | <p className="text-xs text-muted-foreground"> | |
| 832 | This instance uses a driver ( | |
| 833 | <code className="text-foreground">{String(instance.driver)}</code>) that is not | |
| 834 | shipped with the current build. Configuration values are preserved but cannot be | |
| 835 | edited from this surface. | |
| 836 | </p> | |
| 837 | </div> | |
| 838 | ) : null} | |
| 839 | </div> | |
| 840 | {driverOption !== undefined ? ( | |
| 841 | <div hidden={visibleTab !== "models"}> | |
| 842 | <ProviderModelsSection | |
| 843 | instanceId={instanceId} | |
| 844 | driverKind={driverKind} | |
| 845 | models={modelsForDisplay} | |
| 846 | customModels={customModels} | |
| 847 | hiddenModels={hiddenModels} | |
| 848 | favoriteModels={favoriteModels} | |
| 849 | modelOrder={modelOrder} | |
| 850 | onChange={updateCustomModels} | |
| 851 | onHiddenModelsChange={onHiddenModelsChange} | |
| 852 | onFavoriteModelsChange={onFavoriteModelsChange} | |
| 853 | onModelOrderChange={onModelOrderChange} | |
| 854 | /> | |
| 799 | 855 | </div> |
| 800 | </CollapsibleContent> | |
| 801 | </Collapsible> | |
| 856 | ) : null} | |
| 857 | </div> | |
| 802 | 858 | </div> |
| 803 | 859 | ); |
| 804 | 860 | } |
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+57-10| ... | ... | @@ -178,21 +178,45 @@ describe("EnvironmentProviderSettings routing", () => { |
| 178 | 178 | }); |
| 179 | 179 | }); |
| 180 | 180 | |
| 181 | it("renders the provider layout inert with a limited-permissions notice when read only", () => { | |
| 181 | it("keeps provider selection available while write controls are read only", () => { | |
| 182 | settingsState.value = { | |
| 183 | ...DEFAULT_UNIFIED_SETTINGS, | |
| 184 | providerInstances: { | |
| 185 | [customId]: { | |
| 186 | driver: ProviderDriverKind.make("codex"), | |
| 187 | enabled: true, | |
| 188 | }, | |
| 189 | }, | |
| 190 | }; | |
| 182 | 191 | atoms.providers = [provider()]; |
| 183 | const panel = renderPanel({ readOnly: true }); | |
| 192 | let panel = renderPanel({ readOnly: true }); | |
| 184 | 193 | |
| 185 | 194 | const inertWrapper = visitElements(panel, (element) => element.props.inert === true); |
| 186 | 195 | expect(inertWrapper).not.toBeNull(); |
| 187 | const providerCard = visitElements(panel, (element) => element.props.instanceId === codexId); | |
| 188 | expect(providerCard).not.toBeNull(); | |
| 196 | ||
| 197 | const customRow = visitElements( | |
| 198 | panel, | |
| 199 | (element) => element.props.instanceId === customId && element.props.mode === "list", | |
| 200 | ); | |
| 201 | expect(customRow?.props.readOnly).toBe(true); | |
| 202 | expect(customRow?.props.onSelect).toBeTypeOf("function"); | |
| 203 | (customRow?.props.onSelect as (() => void) | undefined)?.(); | |
| 204 | ||
| 205 | panel = renderPanel({ readOnly: true }); | |
| 206 | const customEditor = visitElements( | |
| 207 | panel, | |
| 208 | (element) => element.props.instanceId === customId && element.props.mode === "editor", | |
| 209 | ); | |
| 210 | expect(customEditor).not.toBeNull(); | |
| 189 | 211 | |
| 190 | 212 | const notice = visitElements(panel, (element) => element.props.title === "Limited permissions"); |
| 191 | 213 | expect(notice).not.toBeNull(); |
| 192 | 214 | |
| 193 | expect( | |
| 194 | visitElements(panel, (element) => element.props["aria-label"] === "Add provider instance"), | |
| 195 | ).toBeNull(); | |
| 215 | const providersSection = visitElements( | |
| 216 | panel, | |
| 217 | (element) => element.props.title === "Providers" && "headerAction" in element.props, | |
| 218 | ); | |
| 219 | expect(providersSection?.props.headerAction).toBeNull(); | |
| 196 | 220 | expect( |
| 197 | 221 | visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"), |
| 198 | 222 | ).toBeNull(); |
| ... | ... | @@ -205,6 +229,11 @@ describe("EnvironmentProviderSettings routing", () => { |
| 205 | 229 | expect( |
| 206 | 230 | visitElements(panel, (element) => element.props.title === "Limited permissions"), |
| 207 | 231 | ).toBeNull(); |
| 232 | const providersSection = visitElements( | |
| 233 | panel, | |
| 234 | (element) => element.props.title === "Providers" && "headerAction" in element.props, | |
| 235 | ); | |
| 236 | expect(providersSection?.props.headerAction).not.toBeNull(); | |
| 208 | 237 | }); |
| 209 | 238 | |
| 210 | 239 | it("deletes and resets provider configuration without erasing shared preferences", () => { |
| ... | ... | @@ -225,8 +254,17 @@ describe("EnvironmentProviderSettings routing", () => { |
| 225 | 254 | }, |
| 226 | 255 | favorites: [{ provider: customId, model: "favorite" }], |
| 227 | 256 | }; |
| 228 | const panel = renderPanel(); | |
| 229 | const customCard = visitElements(panel, (element) => element.props.instanceId === customId); | |
| 257 | let panel = renderPanel(); | |
| 258 | const customRow = visitElements( | |
| 259 | panel, | |
| 260 | (element) => element.props.instanceId === customId && element.props.mode === "list", | |
| 261 | ); | |
| 262 | (customRow?.props.onSelect as (() => void) | undefined)?.(); | |
| 263 | panel = renderPanel(); | |
| 264 | const customCard = visitElements( | |
| 265 | panel, | |
| 266 | (element) => element.props.instanceId === customId && element.props.mode === "editor", | |
| 267 | ); | |
| 230 | 268 | expect(customCard).not.toBeNull(); |
| 231 | 269 | (customCard?.props.onDelete as (() => void) | undefined)?.(); |
| 232 | 270 | |
| ... | ... | @@ -237,7 +275,16 @@ describe("EnvironmentProviderSettings routing", () => { |
| 237 | 275 | }); |
| 238 | 276 | |
| 239 | 277 | settingsState.updateSettings.mockClear(); |
| 240 | const defaultCard = visitElements(panel, (element) => element.props.instanceId === codexId); | |
| 278 | const defaultRow = visitElements( | |
| 279 | panel, | |
| 280 | (element) => element.props.instanceId === codexId && element.props.mode === "list", | |
| 281 | ); | |
| 282 | (defaultRow?.props.onSelect as (() => void) | undefined)?.(); | |
| 283 | panel = renderPanel(); | |
| 284 | const defaultCard = visitElements( | |
| 285 | panel, | |
| 286 | (element) => element.props.instanceId === codexId && element.props.mode === "editor", | |
| 287 | ); | |
| 241 | 288 | const resetAction = defaultCard?.props.headerAction; |
| 242 | 289 | const resetButton = visitElements( |
| 243 | 290 | resetAction, |
apps/web/src/components/settings/ProviderSettingsPanel.tsx+342-274| ... | ... | @@ -24,6 +24,7 @@ import * as Duration from "effect/Duration"; |
| 24 | 24 | import * as Equal from "effect/Equal"; |
| 25 | 25 | import * as Result from "effect/Result"; |
| 26 | 26 | import { |
| 27 | ChevronDownIcon, | |
| 27 | 28 | CloudIcon, |
| 28 | 29 | LaptopIcon, |
| 29 | 30 | LoaderIcon, |
| ... | ... | @@ -32,7 +33,7 @@ import { |
| 32 | 33 | RefreshCwIcon, |
| 33 | 34 | TerminalIcon, |
| 34 | 35 | } from "lucide-react"; |
| 35 | import { useCallback, useMemo, useRef, useState } from "react"; | |
| 36 | import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; | |
| 36 | 37 | |
| 37 | 38 | import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; |
| 38 | 39 | import { isElectron } from "../../env"; |
| ... | ... | @@ -62,6 +63,7 @@ import { |
| 62 | 63 | type ProviderUpdateCandidate, |
| 63 | 64 | } from "../ProviderUpdateLaunchNotification.logic"; |
| 64 | 65 | import { Button } from "../ui/button"; |
| 66 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; | |
| 65 | 67 | import { |
| 66 | 68 | NumberField, |
| 67 | 69 | NumberFieldDecrement, |
| ... | ... | @@ -69,11 +71,13 @@ import { |
| 69 | 71 | NumberFieldIncrement, |
| 70 | 72 | NumberFieldInput, |
| 71 | 73 | } from "../ui/number-field"; |
| 72 | import { stackedThreadToast, toastManager } from "../ui/toast"; | |
| 74 | import { ScrollArea } from "../ui/scroll-area"; | |
| 73 | 75 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 76 | import { stackedThreadToast, toastManager } from "../ui/toast"; | |
| 74 | 77 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; |
| 75 | 78 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; |
| 76 | 79 | import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; |
| 80 | import { providerSettingsTabClassName } from "./providerSettingsTabs"; | |
| 77 | 81 | import { searchableSetting } from "./settingsSearch"; |
| 78 | 82 | import { |
| 79 | 83 | backgroundActivityOverrideSettings, |
| ... | ... | @@ -165,9 +169,11 @@ function providerEnvironmentDetail(environment: EnvironmentPresentation): string |
| 165 | 169 | function EnvironmentUnavailableRow({ |
| 166 | 170 | environment, |
| 167 | 171 | access, |
| 172 | deviceTabs, | |
| 168 | 173 | }: { |
| 169 | 174 | readonly environment: EnvironmentPresentation; |
| 170 | 175 | readonly access: Exclude<ProviderEnvironmentAccess, { kind: "editable" | "read-only" }>; |
| 176 | readonly deviceTabs?: ReactNode; | |
| 171 | 177 | }) { |
| 172 | 178 | const isLoading = access.kind === "loading"; |
| 173 | 179 | const title = isLoading |
| ... | ... | @@ -184,6 +190,7 @@ function EnvironmentUnavailableRow({ |
| 184 | 190 | // continuously repainting animation would run the whole time. |
| 185 | 191 | return ( |
| 186 | 192 | <SettingsSection title="Providers"> |
| 193 | {deviceTabs} | |
| 187 | 194 | <SettingsRow title={title} description={description} /> |
| 188 | 195 | </SettingsSection> |
| 189 | 196 | ); |
| ... | ... | @@ -211,64 +218,63 @@ export function ProviderSettingsPanel() { |
| 211 | 218 | options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null; |
| 212 | 219 | const onlyPrimaryDevice = |
| 213 | 220 | options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget"; |
| 221 | const deviceTabs = | |
| 222 | !onlyPrimaryDevice && options.length > 0 ? ( | |
| 223 | <ScrollArea hideScrollbars scrollFade className="h-11 min-w-0 rounded-none"> | |
| 224 | <div | |
| 225 | role="group" | |
| 226 | aria-label="Devices" | |
| 227 | className="flex h-full w-max min-w-full border-b border-border/70 px-3 sm:px-4" | |
| 228 | > | |
| 229 | {options.map((environment) => { | |
| 230 | const Icon = providerEnvironmentIcon(environment); | |
| 231 | const selected = environment.environmentId === effectiveEnvironmentId; | |
| 232 | const detail = providerEnvironmentDetail(environment); | |
| 233 | const statusText = connectionStatusText(environment.connection); | |
| 234 | return ( | |
| 235 | <Tooltip key={environment.environmentId}> | |
| 236 | <TooltipTrigger | |
| 237 | render={ | |
| 238 | <button | |
| 239 | type="button" | |
| 240 | aria-pressed={selected} | |
| 241 | className={cn(providerSettingsTabClassName(selected), "gap-2 text-left")} | |
| 242 | onClick={() => setSelectedEnvironmentId(environment.environmentId)} | |
| 243 | > | |
| 244 | <Icon className="size-3.5 shrink-0" aria-hidden /> | |
| 245 | <span className="max-w-40 truncate">{environment.label}</span> | |
| 246 | <ConnectionStatusDot | |
| 247 | dotClassName={connectionPhaseDotClassName(environment.connection.phase)} | |
| 248 | pingClassName={connectionPhasePingClassName(environment.connection.phase)} | |
| 249 | /> | |
| 250 | <span className="sr-only"> | |
| 251 | {detail}, {statusText} | |
| 252 | </span> | |
| 253 | </button> | |
| 254 | } | |
| 255 | /> | |
| 256 | <TooltipPopup side="top"> | |
| 257 | {detail} · {statusText} | |
| 258 | </TooltipPopup> | |
| 259 | </Tooltip> | |
| 260 | ); | |
| 261 | })} | |
| 262 | </div> | |
| 263 | </ScrollArea> | |
| 264 | ) : null; | |
| 214 | 265 | |
| 215 | 266 | return ( |
| 216 | <SettingsPageContainer> | |
| 217 | {!onlyPrimaryDevice ? ( | |
| 218 | <SettingsSection title="Devices"> | |
| 219 | {options.length === 0 ? ( | |
| 220 | // The catalog hydrates asynchronously, so an empty list before it is | |
| 221 | // ready means "not loaded yet", not "nothing is connected". | |
| 222 | <SettingsRow | |
| 223 | title={isReady ? "No connected devices" : "Loading devices"} | |
| 224 | description={ | |
| 225 | isReady | |
| 226 | ? "Connect an execution environment before configuring providers." | |
| 227 | : "Reading connected execution environments." | |
| 228 | } | |
| 229 | /> | |
| 230 | ) : ( | |
| 231 | <div className="grid gap-1 sm:grid-cols-2"> | |
| 232 | {options.map((environment) => { | |
| 233 | const Icon = providerEnvironmentIcon(environment); | |
| 234 | const selected = environment.environmentId === effectiveEnvironmentId; | |
| 235 | const statusText = connectionStatusText(environment.connection); | |
| 236 | return ( | |
| 237 | <button | |
| 238 | key={environment.environmentId} | |
| 239 | type="button" | |
| 240 | aria-pressed={selected} | |
| 241 | className={cn( | |
| 242 | "flex min-w-0 items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors sm:px-4", | |
| 243 | selected | |
| 244 | ? "bg-primary/8 ring-1 ring-primary/25 dark:bg-primary/12" | |
| 245 | : "hover:bg-muted/40", | |
| 246 | )} | |
| 247 | onClick={() => setSelectedEnvironmentId(environment.environmentId)} | |
| 248 | > | |
| 249 | <span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-background text-muted-foreground"> | |
| 250 | <Icon className="size-4" aria-hidden /> | |
| 251 | </span> | |
| 252 | <span className="min-w-0 flex-1"> | |
| 253 | <span className="flex items-center gap-1.5"> | |
| 254 | <ConnectionStatusDot | |
| 255 | tooltipText={statusText} | |
| 256 | dotClassName={connectionPhaseDotClassName(environment.connection.phase)} | |
| 257 | pingClassName={connectionPhasePingClassName(environment.connection.phase)} | |
| 258 | /> | |
| 259 | <span className="truncate text-sm font-medium text-foreground"> | |
| 260 | {environment.label} | |
| 261 | </span> | |
| 262 | </span> | |
| 263 | <span className="block truncate pl-[18px] text-xs text-muted-foreground"> | |
| 264 | {providerEnvironmentDetail(environment)} · {statusText} | |
| 265 | </span> | |
| 266 | </span> | |
| 267 | </button> | |
| 268 | ); | |
| 269 | })} | |
| 270 | </div> | |
| 271 | )} | |
| 267 | <SettingsPageContainer width="expanded" className="gap-8"> | |
| 268 | {options.length === 0 ? ( | |
| 269 | <SettingsSection title="Providers"> | |
| 270 | <SettingsRow | |
| 271 | title={isReady ? "No connected devices" : "Loading devices"} | |
| 272 | description={ | |
| 273 | isReady | |
| 274 | ? "Connect an execution environment before configuring providers." | |
| 275 | : "Reading connected execution environments." | |
| 276 | } | |
| 277 | /> | |
| 272 | 278 | </SettingsSection> |
| 273 | 279 | ) : null} |
| 274 | 280 | |
| ... | ... | @@ -276,6 +282,7 @@ export function ProviderSettingsPanel() { |
| 276 | 282 | <SelectedEnvironmentProviderSettings |
| 277 | 283 | key={selectedEnvironment.environmentId} |
| 278 | 284 | environment={selectedEnvironment} |
| 285 | deviceTabs={deviceTabs} | |
| 279 | 286 | /> |
| 280 | 287 | ) : null} |
| 281 | 288 | </SettingsPageContainer> |
| ... | ... | @@ -284,25 +291,37 @@ export function ProviderSettingsPanel() { |
| 284 | 291 | |
| 285 | 292 | function SelectedEnvironmentProviderSettings({ |
| 286 | 293 | environment, |
| 294 | deviceTabs, | |
| 287 | 295 | }: { |
| 288 | 296 | readonly environment: EnvironmentPresentation; |
| 297 | readonly deviceTabs?: ReactNode; | |
| 289 | 298 | }) { |
| 290 | 299 | const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget"; |
| 291 | 300 | if (isPrimary) { |
| 292 | 301 | // The desktop app owns its primary server outright; a browser session |
| 293 | 302 | // checks the scopes its cookie session was granted. |
| 294 | 303 | if (isElectron) { |
| 295 | return <AccessGatedProviderSettings environment={environment} operateAccess="granted" />; | |
| 304 | return ( | |
| 305 | <AccessGatedProviderSettings | |
| 306 | environment={environment} | |
| 307 | operateAccess="granted" | |
| 308 | deviceTabs={deviceTabs} | |
| 309 | /> | |
| 310 | ); | |
| 296 | 311 | } |
| 297 | return <PrimarySessionGatedProviderSettings environment={environment} />; | |
| 312 | return ( | |
| 313 | <PrimarySessionGatedProviderSettings environment={environment} deviceTabs={deviceTabs} /> | |
| 314 | ); | |
| 298 | 315 | } |
| 299 | return <RemoteSessionGatedProviderSettings environment={environment} />; | |
| 316 | return <RemoteSessionGatedProviderSettings environment={environment} deviceTabs={deviceTabs} />; | |
| 300 | 317 | } |
| 301 | 318 | |
| 302 | 319 | function PrimarySessionGatedProviderSettings({ |
| 303 | 320 | environment, |
| 321 | deviceTabs, | |
| 304 | 322 | }: { |
| 305 | 323 | readonly environment: EnvironmentPresentation; |
| 324 | readonly deviceTabs?: ReactNode; | |
| 306 | 325 | }) { |
| 307 | 326 | const primarySessionState = usePrimarySessionState(); |
| 308 | 327 | const operateAccess = resolvePrimaryOperateAccess({ |
| ... | ... | @@ -312,13 +331,21 @@ function PrimarySessionGatedProviderSettings({ |
| 312 | 331 | isPending: primarySessionState.isPending, |
| 313 | 332 | hasError: primarySessionState.error !== null, |
| 314 | 333 | }); |
| 315 | return <AccessGatedProviderSettings environment={environment} operateAccess={operateAccess} />; | |
| 334 | return ( | |
| 335 | <AccessGatedProviderSettings | |
| 336 | environment={environment} | |
| 337 | operateAccess={operateAccess} | |
| 338 | deviceTabs={deviceTabs} | |
| 339 | /> | |
| 340 | ); | |
| 316 | 341 | } |
| 317 | 342 | |
| 318 | 343 | function RemoteSessionGatedProviderSettings({ |
| 319 | 344 | environment, |
| 345 | deviceTabs, | |
| 320 | 346 | }: { |
| 321 | 347 | readonly environment: EnvironmentPresentation; |
| 348 | readonly deviceTabs?: ReactNode; | |
| 322 | 349 | }) { |
| 323 | 350 | const sessionState = useEnvironmentSessionState(environment.environmentId); |
| 324 | 351 | const operateAccess = resolveRemoteOperateAccess({ |
| ... | ... | @@ -326,15 +353,23 @@ function RemoteSessionGatedProviderSettings({ |
| 326 | 353 | isPending: sessionState.isPending, |
| 327 | 354 | hasError: sessionState.hasError, |
| 328 | 355 | }); |
| 329 | return <AccessGatedProviderSettings environment={environment} operateAccess={operateAccess} />; | |
| 356 | return ( | |
| 357 | <AccessGatedProviderSettings | |
| 358 | environment={environment} | |
| 359 | operateAccess={operateAccess} | |
| 360 | deviceTabs={deviceTabs} | |
| 361 | /> | |
| 362 | ); | |
| 330 | 363 | } |
| 331 | 364 | |
| 332 | 365 | function AccessGatedProviderSettings({ |
| 333 | 366 | environment, |
| 334 | 367 | operateAccess, |
| 368 | deviceTabs, | |
| 335 | 369 | }: { |
| 336 | 370 | readonly environment: EnvironmentPresentation; |
| 337 | 371 | readonly operateAccess: ProviderOperateAccess; |
| 372 | readonly deviceTabs?: ReactNode; | |
| 338 | 373 | }) { |
| 339 | 374 | const access = classifyProviderEnvironmentAccess({ |
| 340 | 375 | connectionPhase: environment.connection.phase, |
| ... | ... | @@ -342,13 +377,20 @@ function AccessGatedProviderSettings({ |
| 342 | 377 | operateAccess, |
| 343 | 378 | }); |
| 344 | 379 | if (access.kind !== "editable" && access.kind !== "read-only") { |
| 345 | return <EnvironmentUnavailableRow environment={environment} access={access} />; | |
| 380 | return ( | |
| 381 | <EnvironmentUnavailableRow | |
| 382 | environment={environment} | |
| 383 | access={access} | |
| 384 | deviceTabs={deviceTabs} | |
| 385 | /> | |
| 386 | ); | |
| 346 | 387 | } |
| 347 | 388 | return ( |
| 348 | 389 | <EnvironmentProviderSettings |
| 349 | 390 | environmentId={environment.environmentId} |
| 350 | 391 | environmentLabel={environment.label} |
| 351 | 392 | readOnly={access.kind === "read-only"} |
| 393 | deviceTabs={deviceTabs} | |
| 352 | 394 | /> |
| 353 | 395 | ); |
| 354 | 396 | } |
| ... | ... | @@ -357,9 +399,11 @@ export function EnvironmentProviderSettings({ |
| 357 | 399 | environmentId, |
| 358 | 400 | environmentLabel, |
| 359 | 401 | readOnly = false, |
| 402 | deviceTabs, | |
| 360 | 403 | }: { |
| 361 | 404 | readonly environmentId: EnvironmentId; |
| 362 | 405 | readonly environmentLabel: string; |
| 406 | readonly deviceTabs?: ReactNode; | |
| 363 | 407 | /** |
| 364 | 408 | * Render the full provider layout, greyed out and inert, when this session's |
| 365 | 409 | * credential lacks `orchestration:operate` on the environment. Showing the |
| ... | ... | @@ -380,10 +424,12 @@ export function EnvironmentProviderSettings({ |
| 380 | 424 | }); |
| 381 | 425 | const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); |
| 382 | 426 | const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); |
| 427 | const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(null); | |
| 428 | const [advancedOpen, setAdvancedOpen] = useState(false); | |
| 429 | const advancedVisible = readOnly || advancedOpen; | |
| 383 | 430 | const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< |
| 384 | 431 | ReadonlySet<ProviderDriverKind> |
| 385 | 432 | >(() => new Set()); |
| 386 | const [openInstanceDetails, setOpenInstanceDetails] = useState<Record<string, boolean>>({}); | |
| 387 | 433 | const refreshingRef = useRef(false); |
| 388 | 434 | const updatingDriversRef = useRef<Set<ProviderDriverKind>>(new Set()); |
| 389 | 435 | |
| ... | ... | @@ -577,6 +623,8 @@ export function EnvironmentProviderSettings({ |
| 577 | 623 | } |
| 578 | 624 | } |
| 579 | 625 | |
| 626 | const selectedRow = rows.find((row) => row.instanceId === selectedInstanceId) ?? rows[0] ?? null; | |
| 627 | ||
| 580 | 628 | const updateProviderInstance = ( |
| 581 | 629 | row: InstanceRow, |
| 582 | 630 | next: ProviderInstanceConfig, |
| ... | ... | @@ -666,233 +714,253 @@ export function EnvironmentProviderSettings({ |
| 666 | 714 | }); |
| 667 | 715 | }; |
| 668 | 716 | |
| 717 | const renderProviderInstance = (row: InstanceRow, mode: "list" | "editor") => { | |
| 718 | const driverOption = getDriverOption(row.driver); | |
| 719 | const liveProvider = serverProviders.find( | |
| 720 | (candidate) => candidate.instanceId === row.instanceId, | |
| 721 | ); | |
| 722 | const updateCandidate = liveProvider | |
| 723 | ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) | |
| 724 | : undefined; | |
| 725 | const isDriverUpdateRunning = | |
| 726 | updateCandidate !== undefined && | |
| 727 | (updatingProviderDrivers.has(updateCandidate.driver) || | |
| 728 | serverProviders.some( | |
| 729 | (provider) => | |
| 730 | provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), | |
| 731 | )); | |
| 732 | const showInlineUpdateButton = | |
| 733 | updateCandidate !== undefined && | |
| 734 | hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); | |
| 735 | const canRunInlineUpdate = | |
| 736 | updateCandidate !== undefined && | |
| 737 | canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && | |
| 738 | !updatingProviderDrivers.has(updateCandidate.driver); | |
| 739 | const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { | |
| 740 | hiddenModels: [], | |
| 741 | modelOrder: [], | |
| 742 | }; | |
| 743 | const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => | |
| 744 | favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid, | |
| 745 | ); | |
| 746 | const resetLabel = driverOption?.label ?? String(row.driver); | |
| 747 | ||
| 748 | return ( | |
| 749 | <ProviderInstanceCard | |
| 750 | key={row.instanceId} | |
| 751 | instanceId={row.instanceId} | |
| 752 | instance={row.instance} | |
| 753 | driverOption={driverOption} | |
| 754 | liveProvider={liveProvider} | |
| 755 | mode={mode} | |
| 756 | selected={mode === "list" && selectedRow?.instanceId === row.instanceId} | |
| 757 | onSelect={mode === "list" ? () => setSelectedInstanceId(row.instanceId) : undefined} | |
| 758 | readOnly={readOnly} | |
| 759 | onUpdate={(next) => { | |
| 760 | const wasEnabled = resolveProviderInstanceEnabled(row.instance); | |
| 761 | const isDisabling = next.enabled === false && wasEnabled; | |
| 762 | const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; | |
| 763 | updateProviderInstance( | |
| 764 | row, | |
| 765 | next, | |
| 766 | shouldClearTextGen | |
| 767 | ? { | |
| 768 | textGenerationModelSelection: | |
| 769 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, | |
| 770 | } | |
| 771 | : undefined, | |
| 772 | ); | |
| 773 | }} | |
| 774 | onDelete={ | |
| 775 | mode === "editor" && !row.isDefault | |
| 776 | ? () => deleteProviderInstance(row.instanceId) | |
| 777 | : undefined | |
| 778 | } | |
| 779 | headerAction={ | |
| 780 | mode === "editor" && row.isDefault && row.isDirty ? ( | |
| 781 | <SettingResetButton | |
| 782 | label={`${resetLabel} provider settings`} | |
| 783 | onClick={() => resetDefaultInstance(row.driver)} | |
| 784 | /> | |
| 785 | ) : null | |
| 786 | } | |
| 787 | hiddenModels={modelPreferences.hiddenModels} | |
| 788 | favoriteModels={favoriteModels} | |
| 789 | modelOrder={modelPreferences.modelOrder} | |
| 790 | onHiddenModelsChange={(hiddenModels) => | |
| 791 | updateProviderModelPreferences(row.instanceId, { | |
| 792 | ...modelPreferences, | |
| 793 | hiddenModels, | |
| 794 | }) | |
| 795 | } | |
| 796 | onFavoriteModelsChange={(next) => updateProviderFavoriteModels(row.instanceId, next)} | |
| 797 | onModelOrderChange={(modelOrder) => | |
| 798 | updateProviderModelPreferences(row.instanceId, { | |
| 799 | ...modelPreferences, | |
| 800 | modelOrder, | |
| 801 | }) | |
| 802 | } | |
| 803 | onRunUpdate={ | |
| 804 | mode === "editor" && showInlineUpdateButton && updateCandidate | |
| 805 | ? () => { | |
| 806 | if (canRunInlineUpdate) void runProviderUpdate(updateCandidate); | |
| 807 | } | |
| 808 | : undefined | |
| 809 | } | |
| 810 | isUpdating={mode === "editor" && showInlineUpdateButton ? isDriverUpdateRunning : undefined} | |
| 811 | /> | |
| 812 | ); | |
| 813 | }; | |
| 814 | ||
| 669 | 815 | return ( |
| 670 | 816 | <> |
| 671 | 817 | <SettingsSection |
| 672 | 818 | {...searchableSetting("providers")} |
| 673 | 819 | headerAction={ |
| 674 | <div className="flex items-center gap-1.5"> | |
| 675 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> | |
| 676 | {!readOnly ? ( | |
| 677 | <> | |
| 678 | <Tooltip> | |
| 679 | <TooltipTrigger | |
| 680 | render={ | |
| 681 | <Button | |
| 682 | size="icon-micro" | |
| 683 | variant="ghost-muted" | |
| 684 | onClick={() => setIsAddInstanceDialogOpen(true)} | |
| 685 | aria-label="Add provider instance" | |
| 686 | > | |
| 687 | <PlusIcon className="size-3" /> | |
| 688 | </Button> | |
| 689 | } | |
| 690 | /> | |
| 691 | <TooltipPopup side="top">Add provider instance</TooltipPopup> | |
| 692 | </Tooltip> | |
| 693 | <Tooltip> | |
| 694 | <TooltipTrigger | |
| 695 | render={ | |
| 696 | <Button | |
| 697 | size="icon-micro" | |
| 698 | variant="ghost-muted" | |
| 699 | disabled={isRefreshingProviders} | |
| 700 | onClick={() => void refreshProviders()} | |
| 701 | aria-label="Refresh provider status" | |
| 702 | > | |
| 703 | {isRefreshingProviders ? ( | |
| 704 | <LoaderIcon className="size-3 animate-spin" /> | |
| 705 | ) : ( | |
| 706 | <RefreshCwIcon className="size-3" /> | |
| 707 | )} | |
| 708 | </Button> | |
| 709 | } | |
| 710 | /> | |
| 711 | <TooltipPopup side="top">Refresh provider status</TooltipPopup> | |
| 712 | </Tooltip> | |
| 713 | </> | |
| 714 | ) : null} | |
| 715 | </div> | |
| 820 | !readOnly ? ( | |
| 821 | <Button | |
| 822 | size="compact" | |
| 823 | variant="outline" | |
| 824 | onClick={() => setIsAddInstanceDialogOpen(true)} | |
| 825 | > | |
| 826 | <PlusIcon className="size-3.5" /> | |
| 827 | Add provider | |
| 828 | </Button> | |
| 829 | ) : null | |
| 716 | 830 | } |
| 717 | 831 | > |
| 832 | {deviceTabs} | |
| 718 | 833 | {readOnly ? ( |
| 719 | 834 | <SettingsRow |
| 720 | 835 | title="Limited permissions" |
| 721 | 836 | description={`This session can view ${environmentLabel}'s providers, but its credential does not allow changing their configuration.`} |
| 722 | 837 | /> |
| 723 | 838 | ) : null} |
| 724 | <div | |
| 725 | // `inert` blocks focus and interaction in one attribute, so the | |
| 726 | // read-only view stays byte-for-byte the editable layout without | |
| 727 | // threading a disabled flag through every control. | |
| 728 | inert={readOnly} | |
| 729 | aria-disabled={readOnly || undefined} | |
| 730 | className={readOnly ? "space-y-1 opacity-50 select-none" : "space-y-1"} | |
| 731 | > | |
| 732 | <SettingsRow | |
| 733 | title={ | |
| 734 | <span className="inline-flex items-center gap-1.5"> | |
| 735 | Health check interval | |
| 736 | <PolicyTooltip> | |
| 737 | This interval is configured here, then the shared Background activity policy | |
| 738 | decides whether provider probes may run when the timer fires. Custom intervals | |
| 739 | appear as Advanced in General settings. | |
| 740 | </PolicyTooltip> | |
| 741 | </span> | |
| 742 | } | |
| 743 | description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes." | |
| 744 | resetAction={ | |
| 745 | providerHealthRefreshIntervalSeconds !== | |
| 746 | defaultProviderHealthRefreshIntervalSeconds ? ( | |
| 747 | <SettingResetButton | |
| 748 | label="provider health check interval" | |
| 749 | onClick={() => | |
| 750 | updateSettings( | |
| 751 | backgroundActivityOverrideSettings( | |
| 752 | settings.backgroundActivity, | |
| 753 | resolvedBackgroundActivity, | |
| 754 | { | |
| 755 | providerHealthRefreshInterval: undefined, | |
| 756 | }, | |
| 757 | ), | |
| 758 | ) | |
| 759 | } | |
| 760 | /> | |
| 761 | ) : null | |
| 762 | } | |
| 763 | control={ | |
| 764 | <div className="flex shrink-0 items-center gap-2"> | |
| 765 | <NumberField | |
| 766 | value={providerHealthRefreshIntervalSeconds} | |
| 767 | min={0} | |
| 768 | step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS} | |
| 769 | size="sm" | |
| 770 | className="w-32" | |
| 771 | onValueChange={(value) => | |
| 772 | updateSettings( | |
| 773 | backgroundActivityOverrideSettings( | |
| 774 | settings.backgroundActivity, | |
| 775 | resolvedBackgroundActivity, | |
| 776 | { | |
| 777 | providerHealthRefreshInterval: Duration.seconds( | |
| 778 | normalizeIntervalSeconds(value), | |
| 779 | ), | |
| 780 | }, | |
| 781 | ), | |
| 782 | ) | |
| 783 | } | |
| 784 | > | |
| 785 | <NumberFieldGroup> | |
| 786 | <NumberFieldDecrement aria-label="Decrease provider health check interval" /> | |
| 787 | <NumberFieldInput aria-label="Provider health check interval in seconds" /> | |
| 788 | <NumberFieldIncrement aria-label="Increase provider health check interval" /> | |
| 789 | </NumberFieldGroup> | |
| 790 | </NumberField> | |
| 791 | <span className="text-xs text-muted-foreground">seconds</span> | |
| 839 | <div className="space-y-1"> | |
| 840 | <div className="overflow-hidden rounded-lg border border-border/70 lg:grid lg:grid-cols-[20rem_minmax(0,1fr)]"> | |
| 841 | <div className="border-b border-border/70 lg:border-r lg:border-b-0"> | |
| 842 | <div className="flex min-h-9 items-center justify-between border-b border-border/70 px-3 text-[11px] font-medium text-muted-foreground"> | |
| 843 | <span>Provider</span> | |
| 844 | <span>On</span> | |
| 792 | 845 | </div> |
| 793 | } | |
| 794 | /> | |
| 846 | {rows.map((row) => renderProviderInstance(row, "list"))} | |
| 847 | <div className="flex min-h-10 items-center justify-between px-3"> | |
| 848 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> | |
| 849 | {!readOnly ? ( | |
| 850 | <Tooltip> | |
| 851 | <TooltipTrigger | |
| 852 | render={ | |
| 853 | <Button | |
| 854 | size="icon-micro" | |
| 855 | variant="ghost-muted" | |
| 856 | disabled={isRefreshingProviders} | |
| 857 | onClick={() => void refreshProviders()} | |
| 858 | aria-label="Refresh provider status" | |
| 859 | > | |
| 860 | {isRefreshingProviders ? ( | |
| 861 | <LoaderIcon className="size-3 animate-spin" /> | |
| 862 | ) : ( | |
| 863 | <RefreshCwIcon className="size-3" /> | |
| 864 | )} | |
| 865 | </Button> | |
| 866 | } | |
| 867 | /> | |
| 868 | <TooltipPopup side="top">Refresh provider status</TooltipPopup> | |
| 869 | </Tooltip> | |
| 870 | ) : null} | |
| 871 | </div> | |
| 872 | </div> | |
| 795 | 873 | |
| 796 | {rows.map((row) => { | |
| 797 | const driverOption = getDriverOption(row.driver); | |
| 798 | const liveProvider = serverProviders.find( | |
| 799 | (candidate) => candidate.instanceId === row.instanceId, | |
| 800 | ); | |
| 801 | const updateCandidate = liveProvider | |
| 802 | ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) | |
| 803 | : undefined; | |
| 804 | const isDriverUpdateRunning = | |
| 805 | updateCandidate !== undefined && | |
| 806 | (updatingProviderDrivers.has(updateCandidate.driver) || | |
| 807 | serverProviders.some( | |
| 808 | (provider) => | |
| 809 | provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), | |
| 810 | )); | |
| 811 | const showInlineUpdateButton = | |
| 812 | updateCandidate !== undefined && | |
| 813 | hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); | |
| 814 | const canRunInlineUpdate = | |
| 815 | updateCandidate !== undefined && | |
| 816 | canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && | |
| 817 | !updatingProviderDrivers.has(updateCandidate.driver); | |
| 818 | const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { | |
| 819 | hiddenModels: [], | |
| 820 | modelOrder: [], | |
| 821 | }; | |
| 822 | const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) => | |
| 823 | favorite.provider === row.instanceId | |
| 824 | ? Result.succeed(favorite.model) | |
| 825 | : Result.failVoid, | |
| 826 | ); | |
| 827 | const resetLabel = driverOption?.label ?? String(row.driver); | |
| 828 | const headerAction = | |
| 829 | row.isDefault && row.isDirty ? ( | |
| 830 | <SettingResetButton | |
| 831 | label={`${resetLabel} provider settings`} | |
| 832 | onClick={() => resetDefaultInstance(row.driver)} | |
| 874 | <div className="min-w-0"> | |
| 875 | {selectedRow ? ( | |
| 876 | renderProviderInstance(selectedRow, "editor") | |
| 877 | ) : ( | |
| 878 | <div className="p-6 text-sm text-muted-foreground">No providers configured.</div> | |
| 879 | )} | |
| 880 | </div> | |
| 881 | </div> | |
| 882 | ||
| 883 | <div | |
| 884 | inert={readOnly} | |
| 885 | aria-disabled={readOnly || undefined} | |
| 886 | className={readOnly ? "opacity-50 select-none" : undefined} | |
| 887 | > | |
| 888 | <Collapsible | |
| 889 | open={advancedVisible} | |
| 890 | onOpenChange={setAdvancedOpen} | |
| 891 | className="mt-2 border-t border-border/70" | |
| 892 | > | |
| 893 | <CollapsibleTrigger className="flex h-10 w-full items-center gap-2 px-3 text-xs text-muted-foreground hover:text-foreground sm:px-4"> | |
| 894 | <ChevronDownIcon | |
| 895 | className={cn("size-3 transition-transform", advancedVisible && "rotate-180")} | |
| 833 | 896 | /> |
| 834 | ) : null; | |
| 835 | return ( | |
| 836 | <ProviderInstanceCard | |
| 837 | key={row.instanceId} | |
| 838 | instanceId={row.instanceId} | |
| 839 | instance={row.instance} | |
| 840 | driverOption={driverOption} | |
| 841 | liveProvider={liveProvider} | |
| 842 | isExpanded={openInstanceDetails[row.instanceId] ?? false} | |
| 843 | onExpandedChange={(open) => | |
| 844 | setOpenInstanceDetails((existing) => ({ | |
| 845 | ...existing, | |
| 846 | [row.instanceId]: open, | |
| 847 | })) | |
| 848 | } | |
| 849 | onUpdate={(next) => { | |
| 850 | const wasEnabled = resolveProviderInstanceEnabled(row.instance); | |
| 851 | const isDisabling = next.enabled === false && wasEnabled; | |
| 852 | const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId; | |
| 853 | if (shouldClearTextGen) { | |
| 854 | updateProviderInstance(row, next, { | |
| 855 | textGenerationModelSelection: | |
| 856 | DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, | |
| 857 | }); | |
| 858 | } else { | |
| 859 | updateProviderInstance(row, next); | |
| 897 | Advanced | |
| 898 | </CollapsibleTrigger> | |
| 899 | <CollapsibleContent> | |
| 900 | <SettingsRow | |
| 901 | title={ | |
| 902 | <span className="inline-flex items-center gap-1.5"> | |
| 903 | Health check interval | |
| 904 | <PolicyTooltip> | |
| 905 | This interval is configured here, then the shared Background activity policy | |
| 906 | decides whether provider probes may run when the timer fires. Custom | |
| 907 | intervals appear as Advanced in General settings. | |
| 908 | </PolicyTooltip> | |
| 909 | </span> | |
| 860 | 910 | } |
| 861 | }} | |
| 862 | onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)} | |
| 863 | headerAction={headerAction} | |
| 864 | hiddenModels={modelPreferences.hiddenModels} | |
| 865 | favoriteModels={favoriteModels} | |
| 866 | modelOrder={modelPreferences.modelOrder} | |
| 867 | onHiddenModelsChange={(hiddenModels) => | |
| 868 | updateProviderModelPreferences(row.instanceId, { | |
| 869 | ...modelPreferences, | |
| 870 | hiddenModels, | |
| 871 | }) | |
| 872 | } | |
| 873 | onFavoriteModelsChange={(favoriteModels) => | |
| 874 | updateProviderFavoriteModels(row.instanceId, favoriteModels) | |
| 875 | } | |
| 876 | onModelOrderChange={(modelOrder) => | |
| 877 | updateProviderModelPreferences(row.instanceId, { | |
| 878 | ...modelPreferences, | |
| 879 | modelOrder, | |
| 880 | }) | |
| 881 | } | |
| 882 | onRunUpdate={ | |
| 883 | showInlineUpdateButton && updateCandidate | |
| 884 | ? () => { | |
| 885 | if (!canRunInlineUpdate) { | |
| 886 | return; | |
| 911 | description="Set this to 0 seconds to use manual refresh only." | |
| 912 | resetAction={ | |
| 913 | providerHealthRefreshIntervalSeconds !== | |
| 914 | defaultProviderHealthRefreshIntervalSeconds ? ( | |
| 915 | <SettingResetButton | |
| 916 | label="provider health check interval" | |
| 917 | onClick={() => | |
| 918 | updateSettings( | |
| 919 | backgroundActivityOverrideSettings( | |
| 920 | settings.backgroundActivity, | |
| 921 | resolvedBackgroundActivity, | |
| 922 | { providerHealthRefreshInterval: undefined }, | |
| 923 | ), | |
| 924 | ) | |
| 887 | 925 | } |
| 888 | void runProviderUpdate(updateCandidate); | |
| 889 | } | |
| 890 | : undefined | |
| 891 | } | |
| 892 | isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined} | |
| 893 | /> | |
| 894 | ); | |
| 895 | })} | |
| 926 | /> | |
| 927 | ) : null | |
| 928 | } | |
| 929 | control={ | |
| 930 | <div className="flex shrink-0 items-center gap-2"> | |
| 931 | <NumberField | |
| 932 | value={providerHealthRefreshIntervalSeconds} | |
| 933 | min={0} | |
| 934 | step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS} | |
| 935 | size="sm" | |
| 936 | className="w-32" | |
| 937 | onValueChange={(value) => | |
| 938 | updateSettings( | |
| 939 | backgroundActivityOverrideSettings( | |
| 940 | settings.backgroundActivity, | |
| 941 | resolvedBackgroundActivity, | |
| 942 | { | |
| 943 | providerHealthRefreshInterval: Duration.seconds( | |
| 944 | normalizeIntervalSeconds(value), | |
| 945 | ), | |
| 946 | }, | |
| 947 | ), | |
| 948 | ) | |
| 949 | } | |
| 950 | > | |
| 951 | <NumberFieldGroup> | |
| 952 | <NumberFieldDecrement aria-label="Decrease provider health check interval" /> | |
| 953 | <NumberFieldInput aria-label="Provider health check interval in seconds" /> | |
| 954 | <NumberFieldIncrement aria-label="Increase provider health check interval" /> | |
| 955 | </NumberFieldGroup> | |
| 956 | </NumberField> | |
| 957 | <span className="text-xs text-muted-foreground">seconds</span> | |
| 958 | </div> | |
| 959 | } | |
| 960 | /> | |
| 961 | </CollapsibleContent> | |
| 962 | </Collapsible> | |
| 963 | </div> | |
| 896 | 964 | </div> |
| 897 | 965 | </SettingsSection> |
| 898 | 966 |
apps/web/src/components/settings/providerSettingsTabs.ts created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | import { cn } from "../../lib/utils"; | |
| 2 | ||
| 3 | export function providerSettingsTabClassName(selected: boolean): string { | |
| 4 | return cn( | |
| 5 | "relative flex h-full shrink-0 cursor-pointer items-center rounded-sm px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", | |
| 6 | selected | |
| 7 | ? "text-foreground after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-primary" | |
| 8 | : "text-muted-foreground hover:text-foreground", | |
| 9 | ); | |
| 10 | } |
apps/web/src/components/settings/providerStatus.ts+1-1| ... | ... | @@ -6,7 +6,7 @@ import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/con |
| 6 | 6 | */ |
| 7 | 7 | export const PROVIDER_STATUS_STYLES = { |
| 8 | 8 | disabled: { |
| 9 | dot: "bg-amber-400", | |
| 9 | dot: "bg-muted-foreground/50", | |
| 10 | 10 | }, |
| 11 | 11 | error: { |
| 12 | 12 | dot: "bg-destructive", |