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