authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-27 12:58:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-27 12:58:21-07:00
loge2d4d12a81516b55abbecdc64794971f781cacd8
treef37de0e44772d625c3986c0b8e9a171f0bdfe49a
parent348367dcc6e1ac8b11baf94a31c47f33d46313cd
signature Signed by PGP key B5690EEEBB952194

feat(web): split provider settings into list and editor (#8380)


5 files changed, 728 insertions(+), 547 deletions(-)

apps/web/src/components/settings/ProviderInstanceCard.tsx+318-262
......@@ -2,7 +2,6 @@
22
33import {
44 ArrowUpCircleIcon,
5 ChevronDownIcon,
65 CopyIcon,
76 DownloadIcon,
87 LoaderIcon,
......@@ -12,7 +11,7 @@ import {
1211} from "lucide-react";
1312import * as Arr from "effect/Array";
1413import * as Result from "effect/Result";
15import { useState, type ReactNode } from "react";
14import { useEffect, useRef, useState, type ReactNode } from "react";
1615import {
1716 isProviderDriverKind,
1817 resolveProviderInstanceEnabled,
......@@ -30,7 +29,6 @@ import { normalizeProviderAccentColor } from "../../providerInstances";
3029import { Badge } from "../ui/badge";
3130import { Button } from "../ui/button";
3231import { Checkbox } from "../ui/checkbox";
33import { Collapsible, CollapsibleContent } from "../ui/collapsible";
3432import { DraftInput } from "../ui/draft-input";
3533import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
3634import { ScrollArea } from "../ui/scroll-area";
......@@ -39,11 +37,11 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
3937import { stackedThreadToast, toastManager } from "../ui/toast";
4038import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
4139import type { DriverOption } from "./providerDriverMeta";
40import { providerSettingsTabClassName } from "./providerSettingsTabs";
4241import { ProviderSettingsForm } from "./ProviderSettingsForm";
4342import { ProviderModelsSection } from "./ProviderModelsSection";
44import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
43import { ProviderInstanceIcon, providerInstanceInitials } from "../chat/ProviderInstanceIcon";
4544import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
46import { RedactedSensitiveText } from "./RedactedSensitiveText";
4745import {
4846 getProviderVersionAdvisoryPresentation,
4947 PROVIDER_STATUS_STYLES,
......@@ -78,6 +76,25 @@ function makeEnvironmentDraftRow(
7876 };
7977}
8078
79function 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
8198/**
8299 * Read a string[] at `key` from the opaque config blob, filtering out
83100 * non-string entries. Used for `customModels`, which is always typed as
......@@ -132,28 +149,6 @@ export function deriveProviderModelsForDisplay(input: {
132149 return [...serverModels, ...customModels];
133150}
134151
135function 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
157152function ProviderEnvironmentSection(props: {
158153 readonly environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>;
159154 readonly onChange: (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => void;
......@@ -161,6 +156,26 @@ function ProviderEnvironmentSection(props: {
161156 const [rows, setRows] = useState<ReadonlyArray<EnvironmentDraftRow>>(() =>
162157 props.environment.map(makeEnvironmentDraftRow),
163158 );
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]);
164179
165180 const publishRows = (nextRows: ReadonlyArray<EnvironmentDraftRow>) => {
166181 const published: ProviderInstanceEnvironmentVariable[] = [];
......@@ -180,6 +195,7 @@ function ProviderEnvironmentSection(props: {
180195 const { id: _id, ...rest } = row;
181196 published.push({ ...rest, name });
182197 }
198 lastPublishedEnvironmentRef.current = published;
183199 props.onChange(published);
184200 };
185201
......@@ -324,8 +340,10 @@ interface ProviderInstanceCardProps {
324340 readonly instance: ProviderInstanceConfig;
325341 readonly driverOption: DriverOption | undefined;
326342 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;
329347 readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void;
330348 /**
331349 * Pass `undefined` to hide the delete button entirely. Built-in default
......@@ -353,12 +371,9 @@ interface ProviderInstanceCardProps {
353371}
354372
355373/**
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.
362377 *
363378 * Behavior notes:
364379 * - `liveProvider` is matched by the caller via `instanceId`; when no
......@@ -379,8 +394,10 @@ export function ProviderInstanceCard({
379394 instance,
380395 driverOption,
381396 liveProvider,
382 isExpanded,
383 onExpandedChange,
397 mode,
398 selected = false,
399 onSelect,
400 readOnly = false,
384401 onUpdate,
385402 onDelete,
386403 headerAction,
......@@ -393,21 +410,17 @@ export function ProviderInstanceCard({
393410 onRunUpdate,
394411 isUpdating = false,
395412}: ProviderInstanceCardProps) {
413 const [activeTab, setActiveTab] = useState<"models" | "configuration">("configuration");
396414 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";
402420 const statusStyle = PROVIDER_STATUS_STYLES[statusKey];
403421 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");
411424 const versionLabel = getProviderVersionLabel(liveProvider?.version);
412425 const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);
413426 const updateCommand = versionAdvisory?.updateCommand ?? null;
......@@ -441,6 +454,7 @@ export function ProviderInstanceCard({
441454 const driverKind: ProviderDriverKind | null = isProviderDriverKind(instance.driver)
442455 ? instance.driver
443456 : null;
457 const visibleTab = driverOption === undefined ? "configuration" : activeTab;
444458
445459 const customModels = readConfigStringArray(instance.config, "customModels");
446460 // Server-returned models may lag behind settings writes. Treat probe
......@@ -506,25 +520,21 @@ export function ProviderInstanceCard({
506520 displayName={displayName}
507521 accentColor={accentColor}
508522 showBadge={Boolean(accentColor)}
509 statusDotClassName={statusStyle.dot}
510 indicatorBackground="var(--card)"
511523 className="size-5"
512524 iconClassName="size-4 text-foreground/80"
513525 badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-[7px]"
514526 />
515527 ) : 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">
517529 <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 />
525530 </span>
526531 ) : (
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>
528538 );
529539
530540 const titleHeadNode = (
......@@ -576,229 +586,275 @@ export function ProviderInstanceCard({
576586 </>
577587 );
578588
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
597589 const versionCodeNode = versionLabel ? (
598590 <code className="text-xs text-muted-foreground">{versionLabel}</code>
599591 ) : null;
600592
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>
608615 {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
617683 className={cn(
618 "size-5 rounded-sm p-0",
684 "text-xs leading-snug",
619685 versionAdvisory.emphasis === "strong"
620 ? "text-warning hover:text-warning"
621 : "text-update-foreground hover:text-update-foreground",
686 ? "text-warning"
687 : "text-muted-foreground",
622688 )}
623 aria-label="Update available — view details"
624689 >
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"}
626704 </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" />
649711 </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}
724748 </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}
725755 </div>
726756 </div>
727757
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>
747778
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}
755795 />
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>
757801
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>
764811
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>
774818
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 />
799855 </div>
800 </CollapsibleContent>
801 </Collapsible>
856 ) : null}
857 </div>
802858 </div>
803859 );
804860}
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+57-10
......@@ -178,21 +178,45 @@ describe("EnvironmentProviderSettings routing", () => {
178178 });
179179 });
180180
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 };
182191 atoms.providers = [provider()];
183 const panel = renderPanel({ readOnly: true });
192 let panel = renderPanel({ readOnly: true });
184193
185194 const inertWrapper = visitElements(panel, (element) => element.props.inert === true);
186195 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();
189211
190212 const notice = visitElements(panel, (element) => element.props.title === "Limited permissions");
191213 expect(notice).not.toBeNull();
192214
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();
196220 expect(
197221 visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"),
198222 ).toBeNull();
......@@ -205,6 +229,11 @@ describe("EnvironmentProviderSettings routing", () => {
205229 expect(
206230 visitElements(panel, (element) => element.props.title === "Limited permissions"),
207231 ).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();
208237 });
209238
210239 it("deletes and resets provider configuration without erasing shared preferences", () => {
......@@ -225,8 +254,17 @@ describe("EnvironmentProviderSettings routing", () => {
225254 },
226255 favorites: [{ provider: customId, model: "favorite" }],
227256 };
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 );
230268 expect(customCard).not.toBeNull();
231269 (customCard?.props.onDelete as (() => void) | undefined)?.();
232270
......@@ -237,7 +275,16 @@ describe("EnvironmentProviderSettings routing", () => {
237275 });
238276
239277 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 );
241288 const resetAction = defaultCard?.props.headerAction;
242289 const resetButton = visitElements(
243290 resetAction,
apps/web/src/components/settings/ProviderSettingsPanel.tsx+342-274
......@@ -24,6 +24,7 @@ import * as Duration from "effect/Duration";
2424import * as Equal from "effect/Equal";
2525import * as Result from "effect/Result";
2626import {
27 ChevronDownIcon,
2728 CloudIcon,
2829 LaptopIcon,
2930 LoaderIcon,
......@@ -32,7 +33,7 @@ import {
3233 RefreshCwIcon,
3334 TerminalIcon,
3435} from "lucide-react";
35import { useCallback, useMemo, useRef, useState } from "react";
36import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
3637
3738import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
3839import { isElectron } from "../../env";
......@@ -62,6 +63,7 @@ import {
6263 type ProviderUpdateCandidate,
6364} from "../ProviderUpdateLaunchNotification.logic";
6465import { Button } from "../ui/button";
66import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible";
6567import {
6668 NumberField,
6769 NumberFieldDecrement,
......@@ -69,11 +71,13 @@ import {
6971 NumberFieldIncrement,
7072 NumberFieldInput,
7173} from "../ui/number-field";
72import { stackedThreadToast, toastManager } from "../ui/toast";
74import { ScrollArea } from "../ui/scroll-area";
7375import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
76import { stackedThreadToast, toastManager } from "../ui/toast";
7477import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
7578import { ProviderInstanceCard } from "./ProviderInstanceCard";
7679import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
80import { providerSettingsTabClassName } from "./providerSettingsTabs";
7781import { searchableSetting } from "./settingsSearch";
7882import {
7983 backgroundActivityOverrideSettings,
......@@ -165,9 +169,11 @@ function providerEnvironmentDetail(environment: EnvironmentPresentation): string
165169function EnvironmentUnavailableRow({
166170 environment,
167171 access,
172 deviceTabs,
168173}: {
169174 readonly environment: EnvironmentPresentation;
170175 readonly access: Exclude<ProviderEnvironmentAccess, { kind: "editable" | "read-only" }>;
176 readonly deviceTabs?: ReactNode;
171177}) {
172178 const isLoading = access.kind === "loading";
173179 const title = isLoading
......@@ -184,6 +190,7 @@ function EnvironmentUnavailableRow({
184190 // continuously repainting animation would run the whole time.
185191 return (
186192 <SettingsSection title="Providers">
193 {deviceTabs}
187194 <SettingsRow title={title} description={description} />
188195 </SettingsSection>
189196 );
......@@ -211,64 +218,63 @@ export function ProviderSettingsPanel() {
211218 options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null;
212219 const onlyPrimaryDevice =
213220 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;
214265
215266 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 />
272278 </SettingsSection>
273279 ) : null}
274280
......@@ -276,6 +282,7 @@ export function ProviderSettingsPanel() {
276282 <SelectedEnvironmentProviderSettings
277283 key={selectedEnvironment.environmentId}
278284 environment={selectedEnvironment}
285 deviceTabs={deviceTabs}
279286 />
280287 ) : null}
281288 </SettingsPageContainer>
......@@ -284,25 +291,37 @@ export function ProviderSettingsPanel() {
284291
285292function SelectedEnvironmentProviderSettings({
286293 environment,
294 deviceTabs,
287295}: {
288296 readonly environment: EnvironmentPresentation;
297 readonly deviceTabs?: ReactNode;
289298}) {
290299 const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";
291300 if (isPrimary) {
292301 // The desktop app owns its primary server outright; a browser session
293302 // checks the scopes its cookie session was granted.
294303 if (isElectron) {
295 return <AccessGatedProviderSettings environment={environment} operateAccess="granted" />;
304 return (
305 <AccessGatedProviderSettings
306 environment={environment}
307 operateAccess="granted"
308 deviceTabs={deviceTabs}
309 />
310 );
296311 }
297 return <PrimarySessionGatedProviderSettings environment={environment} />;
312 return (
313 <PrimarySessionGatedProviderSettings environment={environment} deviceTabs={deviceTabs} />
314 );
298315 }
299 return <RemoteSessionGatedProviderSettings environment={environment} />;
316 return <RemoteSessionGatedProviderSettings environment={environment} deviceTabs={deviceTabs} />;
300317}
301318
302319function PrimarySessionGatedProviderSettings({
303320 environment,
321 deviceTabs,
304322}: {
305323 readonly environment: EnvironmentPresentation;
324 readonly deviceTabs?: ReactNode;
306325}) {
307326 const primarySessionState = usePrimarySessionState();
308327 const operateAccess = resolvePrimaryOperateAccess({
......@@ -312,13 +331,21 @@ function PrimarySessionGatedProviderSettings({
312331 isPending: primarySessionState.isPending,
313332 hasError: primarySessionState.error !== null,
314333 });
315 return <AccessGatedProviderSettings environment={environment} operateAccess={operateAccess} />;
334 return (
335 <AccessGatedProviderSettings
336 environment={environment}
337 operateAccess={operateAccess}
338 deviceTabs={deviceTabs}
339 />
340 );
316341}
317342
318343function RemoteSessionGatedProviderSettings({
319344 environment,
345 deviceTabs,
320346}: {
321347 readonly environment: EnvironmentPresentation;
348 readonly deviceTabs?: ReactNode;
322349}) {
323350 const sessionState = useEnvironmentSessionState(environment.environmentId);
324351 const operateAccess = resolveRemoteOperateAccess({
......@@ -326,15 +353,23 @@ function RemoteSessionGatedProviderSettings({
326353 isPending: sessionState.isPending,
327354 hasError: sessionState.hasError,
328355 });
329 return <AccessGatedProviderSettings environment={environment} operateAccess={operateAccess} />;
356 return (
357 <AccessGatedProviderSettings
358 environment={environment}
359 operateAccess={operateAccess}
360 deviceTabs={deviceTabs}
361 />
362 );
330363}
331364
332365function AccessGatedProviderSettings({
333366 environment,
334367 operateAccess,
368 deviceTabs,
335369}: {
336370 readonly environment: EnvironmentPresentation;
337371 readonly operateAccess: ProviderOperateAccess;
372 readonly deviceTabs?: ReactNode;
338373}) {
339374 const access = classifyProviderEnvironmentAccess({
340375 connectionPhase: environment.connection.phase,
......@@ -342,13 +377,20 @@ function AccessGatedProviderSettings({
342377 operateAccess,
343378 });
344379 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 );
346387 }
347388 return (
348389 <EnvironmentProviderSettings
349390 environmentId={environment.environmentId}
350391 environmentLabel={environment.label}
351392 readOnly={access.kind === "read-only"}
393 deviceTabs={deviceTabs}
352394 />
353395 );
354396}
......@@ -357,9 +399,11 @@ export function EnvironmentProviderSettings({
357399 environmentId,
358400 environmentLabel,
359401 readOnly = false,
402 deviceTabs,
360403}: {
361404 readonly environmentId: EnvironmentId;
362405 readonly environmentLabel: string;
406 readonly deviceTabs?: ReactNode;
363407 /**
364408 * Render the full provider layout, greyed out and inert, when this session's
365409 * credential lacks `orchestration:operate` on the environment. Showing the
......@@ -380,10 +424,12 @@ export function EnvironmentProviderSettings({
380424 });
381425 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
382426 const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);
427 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(null);
428 const [advancedOpen, setAdvancedOpen] = useState(false);
429 const advancedVisible = readOnly || advancedOpen;
383430 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<
384431 ReadonlySet<ProviderDriverKind>
385432 >(() => new Set());
386 const [openInstanceDetails, setOpenInstanceDetails] = useState<Record<string, boolean>>({});
387433 const refreshingRef = useRef(false);
388434 const updatingDriversRef = useRef<Set<ProviderDriverKind>>(new Set());
389435
......@@ -577,6 +623,8 @@ export function EnvironmentProviderSettings({
577623 }
578624 }
579625
626 const selectedRow = rows.find((row) => row.instanceId === selectedInstanceId) ?? rows[0] ?? null;
627
580628 const updateProviderInstance = (
581629 row: InstanceRow,
582630 next: ProviderInstanceConfig,
......@@ -666,233 +714,253 @@ export function EnvironmentProviderSettings({
666714 });
667715 };
668716
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
669815 return (
670816 <>
671817 <SettingsSection
672818 {...searchableSetting("providers")}
673819 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
716830 }
717831 >
832 {deviceTabs}
718833 {readOnly ? (
719834 <SettingsRow
720835 title="Limited permissions"
721836 description={`This session can view ${environmentLabel}'s providers, but its credential does not allow changing their configuration.`}
722837 />
723838 ) : 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>
792845 </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>
795873
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")}
833896 />
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>
860910 }
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 )
887925 }
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>
896964 </div>
897965 </SettingsSection>
898966
apps/web/src/components/settings/providerSettingsTabs.ts created+10
......@@ -0,0 +1,10 @@
1import { cn } from "../../lib/utils";
2
3export 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
66 */
77export const PROVIDER_STATUS_STYLES = {
88 disabled: {
9 dot: "bg-amber-400",
9 dot: "bg-muted-foreground/50",
1010 },
1111 error: {
1212 dot: "bg-destructive",