authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-29 07:51:09+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 22:51:09-07:00
log5e63aea2df03e8ccc5c8a56cee4c067ecbd3d703
tree9436f7153f4372417e4bbb70c4b8a5e12ec4f2ef
parentc1c2d5401de5a352cf6722959de076c3da63d233
signature Signed by PGP key B5690EEEBB952194

fix(web): clean up provider settings list and editor (#8504)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

4 files changed, 392 insertions(+), 294 deletions(-)

apps/web/src/components/settings/ProviderInstanceCard.test.ts+41-2
...@@ -41,7 +41,7 @@ describe("deriveProviderModelsForDisplay", () => {...@@ -41,7 +41,7 @@ describe("deriveProviderModelsForDisplay", () => {
41 ).toEqual(["server-model", "kept-custom"]);41 ).toEqual(["server-model", "kept-custom"]);
42 });42 });
4343
44 it("shows a redacted provider email in Configuration", () => {44 it("shows a redacted provider email in the editor header status line", () => {
45 const instanceId = ProviderInstanceId.make("codex");45 const instanceId = ProviderInstanceId.make("codex");
46 const driver = ProviderDriverKind.make("codex");46 const driver = ProviderDriverKind.make("codex");
47 const liveProvider: ServerProvider = {47 const liveProvider: ServerProvider = {
...@@ -75,9 +75,48 @@ describe("deriveProviderModelsForDisplay", () => {...@@ -75,9 +75,48 @@ describe("deriveProviderModelsForDisplay", () => {
75 }),75 }),
76 );76 );
7777
78 expect(markup).toContain("Account email");78 expect(markup).toContain("Authenticated as");
79 expect(markup).toContain('aria-label="Toggle account email visibility"');79 expect(markup).toContain('aria-label="Toggle account email visibility"');
80 expect(markup).toContain("blur-[2px]");80 expect(markup).toContain("blur-[2px]");
81 expect(markup).not.toContain("developer@example.com");81 expect(markup).not.toContain("developer@example.com");
82 });82 });
83 it("surfaces a failed probe message in both the list row and the editor", () => {
84 const instanceId = ProviderInstanceId.make("codex_work");
85 const driver = ProviderDriverKind.make("codex");
86 const message =
87 "Codex app-server provider probe failed: Cannot create Codex shadow home entry 'auth.json' because '/home/me/.codex-t3/work/auth.json' already exists and is not a symlink.";
88 const liveProvider: ServerProvider = {
89 instanceId,
90 driver,
91 enabled: true,
92 installed: true,
93 version: null,
94 status: "error",
95 auth: { status: "unknown" },
96 checkedAt: "2026-08-28T12:00:00.000Z",
97 models: [],
98 slashCommands: [],
99 skills: [],
100 message,
101 };
102 const props = {
103 instanceId,
104 instance: { driver },
105 driverOption: undefined,
106 liveProvider,
107 onUpdate: () => undefined,
108 hiddenModels: [],
109 favoriteModels: [],
110 modelOrder: [],
111 onHiddenModelsChange: () => undefined,
112 onFavoriteModelsChange: () => undefined,
113 onModelOrderChange: () => undefined,
114 } as const;
115
116 for (const mode of ["list", "editor"] as const) {
117 const markup = renderToStaticMarkup(createElement(ProviderInstanceCard, { ...props, mode }));
118 expect(markup).toContain("Unavailable");
119 expect(markup).toContain("is not a symlink");
120 }
121 });
83});122});
apps/web/src/components/settings/ProviderInstanceCard.tsx+251-201
...@@ -426,18 +426,26 @@ export function ProviderInstanceCard({...@@ -426,18 +426,26 @@ export function ProviderInstanceCard({
426 onRunUpdate,426 onRunUpdate,
427 isUpdating = false,427 isUpdating = false,
428}: ProviderInstanceCardProps) {428}: ProviderInstanceCardProps) {
429 const [activeTab, setActiveTab] = useState<"models" | "configuration">("configuration");429 const [activeTab, setActiveTab] = useState<"configuration" | "models">("configuration");
430 const enabled = resolveProviderInstanceEnabled(instance);430 const enabled = resolveProviderInstanceEnabled(instance);
431 // A locally disabled provider stays neutral even if its last server status431 // A locally disabled provider reads "Disabled" with a muted dot even if its
432 // is stale. Enabled providers use the server status when one is available.432 // last server status is stale. Enabled providers use the server status.
433 const statusKey: ProviderStatusKey = enabled433 const statusKey: ProviderStatusKey = enabled
434 ? ((liveProvider?.status as ProviderStatusKey | undefined) ?? "warning")434 ? ((liveProvider?.status as ProviderStatusKey | undefined) ?? "warning")
435 : "disabled";435 : "disabled";
436 const statusStyle = PROVIDER_STATUS_STYLES[statusKey];436 const statusStyle = PROVIDER_STATUS_STYLES[statusKey];
437 const rawSummary = getProviderSummary(liveProvider);437 const summary = enabled
438 const summary = enabled ? rawSummary : { headline: "Disabled", detail: null };438 ? getProviderSummary(liveProvider)
439 const authEmail = liveProvider?.auth.email;439 : { headline: "Disabled", detail: null };
440 const showEditorStatus = enabled && (statusKey === "warning" || statusKey === "error");440 const authEmail = liveProvider?.auth.email?.trim();
441 // The editor header folds the account email into the status line —
442 // "Authenticated as <email> · <plan>" — with the email redacted until its
443 // reveal toggle is clicked.
444 const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated";
445 const authLabel =
446 enabled && liveProvider?.auth.status === "authenticated"
447 ? (liveProvider.auth.label ?? liveProvider.auth.type ?? null)
448 : null;
441 const versionLabel = getProviderVersionLabel(liveProvider?.version);449 const versionLabel = getProviderVersionLabel(liveProvider?.version);
442 const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);450 const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);
443 const updateCommand = versionAdvisory?.updateCommand ?? null;451 const updateCommand = versionAdvisory?.updateCommand ?? null;
...@@ -607,19 +615,34 @@ export function ProviderInstanceCard({...@@ -607,19 +615,34 @@ export function ProviderInstanceCard({
607 <code className="text-xs text-muted-foreground">{versionLabel}</code>615 <code className="text-xs text-muted-foreground">{versionLabel}</code>
608 ) : null;616 ) : null;
609617
618 // Healthy and disabled rows read fine from their text; only trouble gets a dot.
619 const statusDotNode =
620 statusKey === "warning" || statusKey === "error" ? (
621 <span className={cn("size-1.5 shrink-0 rounded-full", statusStyle.dot)} aria-hidden />
622 ) : null;
623 const statusHeadlineNode = <span>{summary.headline}</span>;
624 // Trouble states carry the server's explanation (a failed probe, a shadow
625 // home entry that is not a symlink, a missing binary). Show it wherever the
626 // headline shows so the user can act without opening the editor.
627 const needsAttention = statusKey === "warning" || statusKey === "error";
628 const statusLineClassName =
629 "flex min-w-0 flex-wrap items-center gap-x-1.5 text-[13px] leading-[1.45] text-muted-foreground/80";
630
610 if (mode === "list") {631 if (mode === "list") {
611 return (632 return (
612 <div633 <div
613 className={cn(634 className={cn(
614 "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",635 // Sidebar-style selection with a fixed row height so the list stays
615 selected ? "bg-muted/50" : "hover:bg-muted/25",636 // even; the status line clamps to two lines instead of growing.
637 "group flex h-19 items-start gap-3 rounded-md px-3 py-2 transition-colors",
638 // Foreground-alpha tint so the fill reads the same in light and dark themes.
639 selected ? "bg-foreground/8" : "hover:bg-foreground/4",
616 )}640 )}
617 >641 >
618 {selected ? <span className="absolute inset-y-0 left-0 w-0.5 bg-primary" /> : null}
619 <button642 <button
620 type="button"643 type="button"
621 className={cn(644 className={cn(
622 "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",645 "flex min-w-0 flex-1 cursor-pointer items-start gap-3 rounded-sm text-left outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-ring",
623 !enabled && !selected && "opacity-60 group-hover:opacity-100",646 !enabled && !selected && "opacity-60 group-hover:opacity-100",
624 )}647 )}
625 onClick={onSelect}648 onClick={onSelect}
...@@ -629,150 +652,185 @@ export function ProviderInstanceCard({...@@ -629,150 +652,185 @@ export function ProviderInstanceCard({
629 <span className="min-w-0 flex-1">652 <span className="min-w-0 flex-1">
630 <span className="flex min-w-0 items-center gap-2">653 <span className="flex min-w-0 items-center gap-2">
631 <span className="truncate text-sm font-medium text-foreground">{displayName}</span>654 <span className="truncate text-sm font-medium text-foreground">{displayName}</span>
655 {String(instanceId) !== String(instance.driver) ? (
656 <code className="min-w-0 truncate rounded bg-muted/60 px-1 py-0.5 text-[10px] text-muted-foreground">
657 {instanceId}
658 </code>
659 ) : null}
632 {versionCodeNode}660 {versionCodeNode}
661 {versionAdvisory ? (
662 <span role="img" aria-label="Update available" className="inline-flex shrink-0">
663 <ArrowUpCircleIcon className="size-3.5 text-update-foreground" />
664 </span>
665 ) : null}
633 </span>666 </span>
634 <span className="mt-0.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">667 <span className="mt-0.5 flex items-start gap-1.5 text-[13px] leading-[1.45] text-muted-foreground/80">
635 <span className={cn("size-1.5 shrink-0 rounded-full", statusStyle.dot)} />668 {statusDotNode ? (
636 <span className="truncate">{summary.headline}</span>669 <span className="flex h-[1.45em] shrink-0 items-center">{statusDotNode}</span>
670 ) : null}
671 <span className="line-clamp-2 [overflow-wrap:anywhere]">
672 {summary.headline}
673 {needsAttention && summary.detail ? ` · ${summary.detail}` : null}
674 </span>
637 </span>675 </span>
638 {String(instanceId) !== String(instance.driver) ? (
639 <code className="mt-0.5 block truncate text-[10px] text-muted-foreground/70">
640 {instanceId}
641 </code>
642 ) : null}
643 </span>676 </span>
644 </button>677 </button>
645 <Switch678 <span className="flex h-5 shrink-0 items-center">
646 checked={enabled}679 <Switch
647 disabled={readOnly}680 checked={enabled}
648 onCheckedChange={(checked) => updateEnabled(Boolean(checked))}681 disabled={readOnly}
649 aria-label={`Enable ${displayName}`}682 onCheckedChange={(checked) => updateEnabled(Boolean(checked))}
650 />683 aria-label={`Enable ${displayName}`}
684 />
685 </span>
651 </div>686 </div>
652 );687 );
653 }688 }
654689
655 return (690 return (
656 <div className="min-w-0 lg:flex lg:h-full lg:min-h-0 lg:flex-col">691 <div className="min-w-0 lg:flex lg:h-full lg:min-h-0 lg:flex-col">
657 <div692 <div className="flex min-h-16 shrink-0 items-start justify-between gap-3 border-b border-border/70 px-4 py-3">
658 inert={readOnly}
659 aria-disabled={readOnly || undefined}
660 className={cn(
661 "flex min-h-16 shrink-0 items-center justify-between gap-3 border-b border-border/70 px-4 py-3",
662 readOnly && "opacity-50 select-none",
663 )}
664 >
665 <div className="min-w-0 flex-1 space-y-1">693 <div className="min-w-0 flex-1 space-y-1">
666 <div className="flex min-w-0 flex-wrap items-center gap-2">694 <div className="flex min-w-0 flex-wrap items-center gap-2">
667 {titleHeadNode}695 {titleHeadNode}
668 {versionCodeNode}696 {versionCodeNode}
669 {versionAdvisory ? (697 {/*
670 <Popover>698 Only the write actions go inert on read-only sessions; the
671 <PopoverTrigger699 status line below keeps its email reveal clickable.
672 render={700 */}
673 <Button701 <span
674 type="button"702 inert={readOnly}
675 size="icon-xs"703 aria-disabled={readOnly || undefined}
676 variant="ghost"704 className={cn("inline-flex items-center gap-2", readOnly && "opacity-50")}
677 className={cn(705 >
678 "size-5 rounded-sm p-0",706 {versionAdvisory ? (
679 versionAdvisory.emphasis === "strong"707 <Popover>
680 ? "text-warning hover:text-warning"708 <PopoverTrigger
681 : "text-update-foreground hover:text-update-foreground",709 render={
682 )}710 <Button
683 aria-label="Update available — view details"711 type="button"
684 >712 size="icon-xs"
685 <ArrowUpCircleIcon className="size-3.5" />713 variant="ghost"
686 </Button>
687 }
688 />
689 <PopoverPopup
690 side="bottom"
691 align="start"
692 className="w-[min(21rem,calc(100vw-1.5rem))] [--popup-width:min(21rem,calc(100vw-1.5rem))]"
693 >
694 <div className="grid min-w-0 gap-3">
695 <div className="grid gap-0.5">
696 <p className="text-[13px] font-semibold leading-tight text-foreground">
697 Update available
698 </p>
699 <p
700 className={cn(714 className={cn(
701 "text-xs leading-snug",715 "size-5 rounded-sm p-0",
702 versionAdvisory.emphasis === "strong"716 versionAdvisory.emphasis === "strong"
703 ? "text-warning"717 ? "text-warning hover:text-warning"
704 : "text-muted-foreground",718 : "text-update-foreground hover:text-update-foreground",
705 )}719 )}
720 aria-label="Update available — view details"
706 >721 >
707 {versionAdvisory.detail}722 <ArrowUpCircleIcon className="size-3.5" />
708 </p>
709 </div>
710 {onRunUpdate ? (
711 <Button
712 type="button"
713 size="xs"
714 variant="default"
715 className="w-full"
716 disabled={isUpdating}
717 onClick={onRunUpdate}
718 >
719 {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />}
720 {isUpdating ? "Updating" : "Update now"}
721 </Button>723 </Button>
722 ) : null}724 }
723 {onRunUpdate && updateCommand ? (725 />
724 <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">726 <PopoverPopup
725 <span aria-hidden className="h-px flex-1 bg-border" />727 side="bottom"
726 or, update manually using728 align="start"
727 <span aria-hidden className="h-px flex-1 bg-border" />729 className="w-[min(21rem,calc(100vw-1.5rem))] [--popup-width:min(21rem,calc(100vw-1.5rem))]"
728 </div>730 >
729 ) : null}731 <div className="grid min-w-0 gap-3">
730 {updateCommand ? (732 <div className="grid gap-0.5">
731 <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 <p className="text-[13px] font-semibold leading-tight text-foreground">
732 <ScrollArea scrollFade className="h-8 min-w-0 flex-1 rounded-none">734 Update available
733 <code className="flex h-full w-max items-center whitespace-nowrap pr-3 font-mono text-[11px] text-foreground">735 </p>
734 {updateCommand}736 <p
735 </code>737 className={cn(
736 </ScrollArea>738 "text-xs leading-snug",
737 <Tooltip>739 versionAdvisory.emphasis === "strong"
738 <TooltipTrigger740 ? "text-warning"
739 render={741 : "text-muted-foreground",
740 <Button742 )}
741 type="button"743 >
742 size="icon-xs"744 {versionAdvisory.detail}
743 variant="ghost"745 </p>
744 className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground"
745 onClick={() =>
746 copyToClipboard(updateCommand, {
747 providerName: displayName,
748 })
749 }
750 aria-label="Copy update command"
751 >
752 <CopyIcon className="size-3" />
753 </Button>
754 }
755 />
756 <TooltipPopup side="top">Copy command</TooltipPopup>
757 </Tooltip>
758 </div>746 </div>
759 ) : null}747 {onRunUpdate ? (
760 </div>748 <Button
761 </PopoverPopup>749 type="button"
762 </Popover>750 size="xs"
763 ) : null}751 variant="default"
764 {titleTailNode}752 className="w-full"
753 disabled={isUpdating}
754 onClick={onRunUpdate}
755 >
756 {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />}
757 {isUpdating ? "Updating" : "Update now"}
758 </Button>
759 ) : null}
760 {onRunUpdate && updateCommand ? (
761 <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
762 <span aria-hidden className="h-px flex-1 bg-border" />
763 or, update manually using
764 <span aria-hidden className="h-px flex-1 bg-border" />
765 </div>
766 ) : null}
767 {updateCommand ? (
768 <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">
769 <ScrollArea scrollFade className="h-8 min-w-0 flex-1 rounded-none">
770 <code className="flex h-full w-max items-center whitespace-nowrap pr-3 font-mono text-[11px] text-foreground">
771 {updateCommand}
772 </code>
773 </ScrollArea>
774 <Tooltip>
775 <TooltipTrigger
776 render={
777 <Button
778 type="button"
779 size="icon-xs"
780 variant="ghost"
781 className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground"
782 onClick={() =>
783 copyToClipboard(updateCommand, {
784 providerName: displayName,
785 })
786 }
787 aria-label="Copy update command"
788 >
789 <CopyIcon className="size-3" />
790 </Button>
791 }
792 />
793 <TooltipPopup side="top">Copy command</TooltipPopup>
794 </Tooltip>
795 </div>
796 ) : null}
797 </div>
798 </PopoverPopup>
799 </Popover>
800 ) : null}
801 {titleTailNode}
802 </span>
765 </div>803 </div>
766 {showEditorStatus ? (804 <p className={statusLineClassName}>
767 <p className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">805 {statusDotNode}
768 <span>{summary.headline}</span>806 {isAuthenticated && authEmail ? (
769 {summary.detail ? <span>· {summary.detail}</span> : null}807 <>
808 <span>Authenticated as</span>
809 <ProviderAuthEmail email={authEmail} />
810 {authLabel ? <span>· {authLabel}</span> : null}
811 </>
812 ) : (
813 statusHeadlineNode
814 )}
815 {summary.detail && !needsAttention ? <span>· {summary.detail}</span> : null}
816 </p>
817 {summary.detail && needsAttention ? (
818 <p className="text-[13px] leading-[1.45] text-muted-foreground/80 [overflow-wrap:anywhere]">
819 {summary.detail}
770 </p>820 </p>
771 ) : null}821 ) : null}
772 </div>822 </div>
773 </div>823 </div>
774824
775 <div className="flex h-11 shrink-0 border-b border-border/70 px-1">825 <div className="flex h-11 shrink-0 border-b border-border/70 px-1">
826 <button
827 type="button"
828 aria-pressed={visibleTab === "configuration"}
829 className={providerSettingsTabClassName(visibleTab === "configuration")}
830 onClick={() => setActiveTab("configuration")}
831 >
832 Configuration
833 </button>
776 {driverOption !== undefined ? (834 {driverOption !== undefined ? (
777 <button835 <button
778 type="button"836 type="button"
...@@ -783,91 +841,83 @@ export function ProviderInstanceCard({...@@ -783,91 +841,83 @@ export function ProviderInstanceCard({
783 Models841 Models
784 </button>842 </button>
785 ) : null}843 ) : null}
786 <button
787 type="button"
788 aria-pressed={visibleTab === "configuration"}
789 className={providerSettingsTabClassName(visibleTab === "configuration")}
790 onClick={() => setActiveTab("configuration")}
791 >
792 Configuration
793 </button>
794 </div>844 </div>
795845
796 <div846 <div className="lg:min-h-0 lg:flex-1">
797 inert={readOnly}847 <ScrollArea
798 aria-disabled={readOnly || undefined}848 scrollFade
799 className={cn("lg:min-h-0 lg:flex-1", readOnly && "opacity-50 select-none")}849 chainVerticalScroll
800 >850 className="lg:h-full"
801 <div
802 className="space-y-5 px-4 py-5 lg:h-full lg:overflow-y-auto"
803 hidden={visibleTab !== "configuration"}851 hidden={visibleTab !== "configuration"}
804 >852 >
805 {authEmail?.trim() ? (853 <div
806 <div className="min-w-0">854 inert={readOnly}
807 <div className="text-xs font-medium text-foreground">Account email</div>855 aria-disabled={readOnly || undefined}
808 <div className="mt-1.5 flex min-h-8 min-w-0 items-center">856 className={cn("space-y-5 px-4 py-5", readOnly && "opacity-50 select-none")}
809 <ProviderAuthEmail email={authEmail} />857 >
810 </div>858 <div>
859 <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block">
860 <span className="text-xs font-medium text-foreground">Display name</span>
861 <DraftInput
862 id={`provider-instance-${instanceId}-display-name`}
863 className="mt-1.5"
864 value={instance.displayName ?? ""}
865 onCommit={updateDisplayName}
866 placeholder={driverOption?.label ?? "Instance label"}
867 spellCheck={false}
868 />
869 <span className="mt-1 block text-xs text-muted-foreground">
870 Optional label shown in the provider list.
871 </span>
872 </label>
811 </div>873 </div>
812 ) : null}
813874
814 <div>875 <div>
815 <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block">876 <ProviderAccentColorPicker
816 <span className="text-xs font-medium text-foreground">Display name</span>877 displayName={displayName}
817 <DraftInput878 value={accentColor}
818 id={`provider-instance-${instanceId}-display-name`}879 onCommit={updateAccentColor}
819 className="mt-1.5"880 commitDelayMs={120}
820 value={instance.displayName ?? ""}881 description="Used to distinguish this instance in picker rails and model lists."
821 onCommit={updateDisplayName}
822 placeholder={driverOption?.label ?? "Instance label"}
823 spellCheck={false}
824 />882 />
825 <span className="mt-1 block text-xs text-muted-foreground">883 </div>
826 Optional label shown in the provider list.
827 </span>
828 </label>
829 </div>
830
831 <div>
832 <ProviderAccentColorPicker
833 displayName={displayName}
834 value={accentColor}
835 onCommit={updateAccentColor}
836 commitDelayMs={120}
837 description="Used to distinguish this instance in picker rails and model lists."
838 />
839 </div>
840
841 <div>
842 <ProviderEnvironmentSection
843 environment={instance.environment ?? []}
844 onChange={updateEnvironment}
845 />
846 </div>
847
848 {driverOption ? (
849 <ProviderSettingsForm
850 definition={driverOption}
851 value={instance.config}
852 idPrefix={`provider-instance-${instanceId}`}
853 variant="card"
854 onChange={updateConfig}
855 />
856 ) : null}
857884
858 {driverOption === undefined ? (
859 <div>885 <div>
860 <p className="text-xs text-muted-foreground">886 <ProviderEnvironmentSection
861 This instance uses a driver (887 environment={instance.environment ?? []}
862 <code className="text-foreground">{String(instance.driver)}</code>) that is not888 onChange={updateEnvironment}
863 shipped with the current build. Configuration values are preserved but cannot be889 />
864 edited from this surface.
865 </p>
866 </div>890 </div>
867 ) : null}891
868 </div>892 {driverOption ? (
893 <ProviderSettingsForm
894 definition={driverOption}
895 value={instance.config}
896 idPrefix={`provider-instance-${instanceId}`}
897 variant="card"
898 onChange={updateConfig}
899 />
900 ) : null}
901
902 {driverOption === undefined ? (
903 <div>
904 <p className="text-xs text-muted-foreground">
905 This instance uses a driver (
906 <code className="text-foreground">{String(instance.driver)}</code>) that is not
907 shipped with the current build. Configuration values are preserved but cannot be
908 edited from this surface.
909 </p>
910 </div>
911 ) : null}
912 </div>
913 </ScrollArea>
869 {driverOption !== undefined ? (914 {driverOption !== undefined ? (
870 <div className="px-4 py-5 lg:h-full lg:min-h-0" hidden={visibleTab !== "models"}>915 <div
916 inert={readOnly}
917 aria-disabled={readOnly || undefined}
918 className={cn("px-4 py-5 lg:h-full lg:min-h-0", readOnly && "opacity-50 select-none")}
919 hidden={visibleTab !== "models"}
920 >
871 <ProviderModelsSection921 <ProviderModelsSection
872 instanceId={instanceId}922 instanceId={instanceId}
873 driverKind={driverKind}923 driverKind={driverKind}
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+9-10
...@@ -127,6 +127,10 @@ function renderPanel(options?: {...@@ -127,6 +127,10 @@ function renderPanel(options?: {
127 }) as ReactElement<Record<string, unknown>>;127 }) as ReactElement<Record<string, unknown>>;
128}128}
129129
130function isAddProviderButton(element: ReactElement<Record<string, unknown>>): boolean {
131 return element.props["aria-label"] === "Add provider";
132}
133
130async function flushPromises(): Promise<void> {134async function flushPromises(): Promise<void> {
131 await Promise.resolve();135 await Promise.resolve();
132 await Promise.resolve();136 await Promise.resolve();
...@@ -212,14 +216,10 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -212,14 +216,10 @@ describe("EnvironmentProviderSettings routing", () => {
212 const notice = visitElements(panel, (element) => element.props.title === "Limited permissions");216 const notice = visitElements(panel, (element) => element.props.title === "Limited permissions");
213 expect(notice).not.toBeNull();217 expect(notice).not.toBeNull();
214218
215 const providersSection = visitElements(
216 panel,
217 (element) => element.props.title === "Providers" && "headerAction" in element.props,
218 );
219 expect(providersSection?.props.headerAction).toBeNull();
220 expect(219 expect(
221 visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"),220 visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"),
222 ).toBeNull();221 ).toBeNull();
222 expect(visitElements(panel, isAddProviderButton)).toBeNull();
223 });223 });
224224
225 it("keeps the editable layout interactive when not read only", () => {225 it("keeps the editable layout interactive when not read only", () => {
...@@ -229,11 +229,10 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -229,11 +229,10 @@ describe("EnvironmentProviderSettings routing", () => {
229 expect(229 expect(
230 visitElements(panel, (element) => element.props.title === "Limited permissions"),230 visitElements(panel, (element) => element.props.title === "Limited permissions"),
231 ).toBeNull();231 ).toBeNull();
232 const providersSection = visitElements(232 expect(
233 panel,233 visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"),
234 (element) => element.props.title === "Providers" && "headerAction" in element.props,234 ).not.toBeNull();
235 );235 expect(visitElements(panel, isAddProviderButton)).not.toBeNull();
236 expect(providersSection?.props.headerAction).not.toBeNull();
237 });236 });
238237
239 it("deletes and resets provider configuration without erasing shared preferences", () => {238 it("deletes and resets provider configuration without erasing shared preferences", () => {
apps/web/src/components/settings/ProviderSettingsPanel.tsx+91-81
...@@ -220,11 +220,11 @@ export function ProviderSettingsPanel() {...@@ -220,11 +220,11 @@ export function ProviderSettingsPanel() {
220 options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget";220 options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget";
221 const deviceTabs =221 const deviceTabs =
222 !onlyPrimaryDevice && options.length > 0 ? (222 !onlyPrimaryDevice && options.length > 0 ? (
223 <ScrollArea hideScrollbars scrollFade className="h-11 min-w-0 rounded-none">223 <ScrollArea hideScrollbars scrollFade className="mx-3 h-11 min-w-0 rounded-none sm:mx-4">
224 <div224 <div
225 role="group"225 role="group"
226 aria-label="Devices"226 aria-label="Devices"
227 className="flex h-full w-max min-w-full border-b border-border/70 px-3 sm:px-4"227 className="flex h-full w-max min-w-full border-b border-border/70 px-1"
228 >228 >
229 {options.map((environment) => {229 {options.map((environment) => {
230 const Icon = providerEnvironmentIcon(environment);230 const Icon = providerEnvironmentIcon(environment);
...@@ -243,10 +243,12 @@ export function ProviderSettingsPanel() {...@@ -243,10 +243,12 @@ export function ProviderSettingsPanel() {
243 >243 >
244 <Icon className="size-3.5 shrink-0" aria-hidden />244 <Icon className="size-3.5 shrink-0" aria-hidden />
245 <span className="max-w-40 truncate">{environment.label}</span>245 <span className="max-w-40 truncate">{environment.label}</span>
246 <ConnectionStatusDot246 {environment.connection.phase !== "connected" ? (
247 dotClassName={connectionPhaseDotClassName(environment.connection.phase)}247 <ConnectionStatusDot
248 pingClassName={connectionPhasePingClassName(environment.connection.phase)}248 dotClassName={connectionPhaseDotClassName(environment.connection.phase)}
249 />249 pingClassName={connectionPhasePingClassName(environment.connection.phase)}
250 />
251 ) : null}
250 <span className="sr-only">252 <span className="sr-only">
251 {detail}, {statusText}253 {detail}, {statusText}
252 </span>254 </span>
...@@ -264,7 +266,7 @@ export function ProviderSettingsPanel() {...@@ -264,7 +266,7 @@ export function ProviderSettingsPanel() {
264 ) : null;266 ) : null;
265267
266 return (268 return (
267 <SettingsPageContainer width="expanded" className="gap-8">269 <SettingsPageContainer className="gap-8">
268 {options.length === 0 ? (270 {options.length === 0 ? (
269 <SettingsSection title="Providers">271 <SettingsSection title="Providers">
270 <SettingsRow272 <SettingsRow
...@@ -405,10 +407,11 @@ export function EnvironmentProviderSettings({...@@ -405,10 +407,11 @@ export function EnvironmentProviderSettings({
405 readonly environmentLabel: string;407 readonly environmentLabel: string;
406 readonly deviceTabs?: ReactNode;408 readonly deviceTabs?: ReactNode;
407 /**409 /**
408 * Render the full provider layout, greyed out and inert, when this session's410 * Grey out and freeze every write control when this session's credential
409 * credential lacks `orchestration:operate` on the environment. Showing the411 * lacks `orchestration:operate` on the environment. Selecting providers and
410 * real configuration keeps the view honest; disabling interaction keeps412 * opening Advanced still work so the real configuration stays readable;
411 * every one of its writes from being offered and then rejected.413 * switches, forms, and the health interval are inert so no write is
414 * offered and then rejected.
412 */415 */
413 readonly readOnly?: boolean;416 readonly readOnly?: boolean;
414}) {417}) {
...@@ -426,7 +429,6 @@ export function EnvironmentProviderSettings({...@@ -426,7 +429,6 @@ export function EnvironmentProviderSettings({
426 const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);429 const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);
427 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(null);430 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(null);
428 const [advancedOpen, setAdvancedOpen] = useState(false);431 const [advancedOpen, setAdvancedOpen] = useState(false);
429 const advancedVisible = readOnly || advancedOpen;
430 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<432 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<
431 ReadonlySet<ProviderDriverKind>433 ReadonlySet<ProviderDriverKind>
432 >(() => new Set());434 >(() => new Set());
...@@ -817,16 +819,56 @@ export function EnvironmentProviderSettings({...@@ -817,16 +819,56 @@ export function EnvironmentProviderSettings({
817 <SettingsSection819 <SettingsSection
818 {...searchableSetting("providers")}820 {...searchableSetting("providers")}
819 headerAction={821 headerAction={
820 !readOnly ? (822 <div className="flex min-w-0 items-center gap-1.5">
821 <Button823 {/*
822 size="compact"824 The 11px size must sit on this flex item, not just the span
823 variant="outline"825 inside: the item's line box is struck from its own font size,
824 onClick={() => setIsAddInstanceDialogOpen(true)}826 and an inherited 16px strut hangs the smaller text below the
825 >827 vertical center of the row.
826 <PlusIcon className="size-3.5" />828 */}
827 Add provider829 <span className="hidden min-w-0 truncate text-[11px] sm:inline">
828 </Button>830 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
829 ) : null831 </span>
832 {!readOnly ? (
833 <>
834 <Tooltip>
835 <TooltipTrigger
836 render={
837 <Button
838 size="icon-micro"
839 variant="ghost-muted"
840 disabled={isRefreshingProviders}
841 onClick={() => void refreshProviders()}
842 aria-label="Refresh provider status"
843 >
844 {isRefreshingProviders ? (
845 <LoaderIcon className="size-3 animate-spin" />
846 ) : (
847 <RefreshCwIcon className="size-3" />
848 )}
849 </Button>
850 }
851 />
852 <TooltipPopup side="top">Refresh provider status</TooltipPopup>
853 </Tooltip>
854 <Tooltip>
855 <TooltipTrigger
856 render={
857 <Button
858 size="icon-micro"
859 variant="ghost-muted"
860 onClick={() => setIsAddInstanceDialogOpen(true)}
861 aria-label="Add provider"
862 >
863 <PlusIcon className="size-3" />
864 </Button>
865 }
866 />
867 <TooltipPopup side="top">Add provider</TooltipPopup>
868 </Tooltip>
869 </>
870 ) : null}
871 </div>
830 }872 }
831 >873 >
832 {deviceTabs}874 {deviceTabs}
...@@ -837,45 +879,17 @@ export function EnvironmentProviderSettings({...@@ -837,45 +879,17 @@ export function EnvironmentProviderSettings({
837 />879 />
838 ) : null}880 ) : null}
839 <div className="space-y-1">881 <div className="space-y-1">
840 <div className="overflow-hidden rounded-lg border border-border/70 lg:grid lg:h-[min(38rem,calc(100dvh-16rem))] lg:min-h-[30rem] lg:grid-cols-[20rem_minmax(0,1fr)]">882 <div className="mx-3 overflow-hidden rounded-lg border border-border/70 sm:mx-4 lg:grid lg:h-[min(38rem,calc(100dvh-16rem))] lg:min-h-[30rem] lg:grid-cols-[20rem_minmax(0,1fr)]">
841 <div className="border-b border-border/70 lg:flex lg:min-h-0 lg:flex-col lg:border-r lg:border-b-0">883 <div className="border-b border-border/70 lg:flex lg:min-h-0 lg:flex-col lg:border-r lg:border-b-0">
842 <div className="flex min-h-9 shrink-0 items-center justify-between border-b border-border/70 px-3 text-[11px] font-medium text-muted-foreground">884 <ScrollArea scrollFade chainVerticalScroll className="lg:min-h-0 lg:flex-1">
843 <span>Provider</span>885 <div className="divide-y divide-border/60">
844 <span>On</span>886 {rows.map((row) => (
845 </div>887 <div key={row.instanceId} className="p-1">
846 <div className="lg:min-h-0 lg:flex-1 lg:overflow-y-auto">888 {renderProviderInstance(row, "list")}
847 {rows.map((row) => renderProviderInstance(row, "list"))}889 </div>
848 </div>890 ))}
849 <div891 </div>
850 className={cn(892 </ScrollArea>
851 "flex min-h-10 shrink-0 items-center justify-between px-3",
852 rows.length > 0 && "border-t border-border/60",
853 )}
854 >
855 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
856 {!readOnly ? (
857 <Tooltip>
858 <TooltipTrigger
859 render={
860 <Button
861 size="icon-micro"
862 variant="ghost-muted"
863 disabled={isRefreshingProviders}
864 onClick={() => void refreshProviders()}
865 aria-label="Refresh provider status"
866 >
867 {isRefreshingProviders ? (
868 <LoaderIcon className="size-3 animate-spin" />
869 ) : (
870 <RefreshCwIcon className="size-3" />
871 )}
872 </Button>
873 }
874 />
875 <TooltipPopup side="top">Refresh provider status</TooltipPopup>
876 </Tooltip>
877 ) : null}
878 </div>
879 </div>893 </div>
880894
881 <div className="min-w-0 lg:min-h-0">895 <div className="min-w-0 lg:min-h-0">
...@@ -887,23 +901,19 @@ export function EnvironmentProviderSettings({...@@ -887,23 +901,19 @@ export function EnvironmentProviderSettings({
887 </div>901 </div>
888 </div>902 </div>
889903
890 <div904 <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="mt-1">
891 inert={readOnly}905 <CollapsibleTrigger className="flex h-10 w-full items-center gap-2 px-3 text-xs text-muted-foreground hover:text-foreground sm:px-4">
892 aria-disabled={readOnly || undefined}906 <ChevronDownIcon
893 className={readOnly ? "opacity-50 select-none" : undefined}907 className={cn("size-3 transition-transform", advancedOpen && "rotate-180")}
894 >908 />
895 <Collapsible909 Advanced
896 open={advancedVisible}910 </CollapsibleTrigger>
897 onOpenChange={setAdvancedOpen}911 <CollapsibleContent>
898 className="mt-2 border-t border-border/70"912 <div
899 >913 inert={readOnly}
900 <CollapsibleTrigger className="flex h-10 w-full items-center gap-2 px-3 text-xs text-muted-foreground hover:text-foreground sm:px-4">914 aria-disabled={readOnly || undefined}
901 <ChevronDownIcon915 className={readOnly ? "opacity-50 select-none" : undefined}
902 className={cn("size-3 transition-transform", advancedVisible && "rotate-180")}916 >
903 />
904 Advanced
905 </CollapsibleTrigger>
906 <CollapsibleContent>
907 <SettingsRow917 <SettingsRow
908 title={918 title={
909 <span className="inline-flex items-center gap-1.5">919 <span className="inline-flex items-center gap-1.5">
...@@ -915,7 +925,7 @@ export function EnvironmentProviderSettings({...@@ -915,7 +925,7 @@ export function EnvironmentProviderSettings({
915 </PolicyTooltip>925 </PolicyTooltip>
916 </span>926 </span>
917 }927 }
918 description="Set this to 0 seconds to use manual refresh only."928 description="Refresh availability, versions, auth state, and models in the background. 0 seconds turns background checks off."
919 resetAction={929 resetAction={
920 providerHealthRefreshIntervalSeconds !==930 providerHealthRefreshIntervalSeconds !==
921 defaultProviderHealthRefreshIntervalSeconds ? (931 defaultProviderHealthRefreshIntervalSeconds ? (
...@@ -965,9 +975,9 @@ export function EnvironmentProviderSettings({...@@ -965,9 +975,9 @@ export function EnvironmentProviderSettings({
965 </div>975 </div>
966 }976 }
967 />977 />
968 </CollapsibleContent>978 </div>
969 </Collapsible>979 </CollapsibleContent>
970 </div>980 </Collapsible>
971 </div>981 </div>
972 </SettingsSection>982 </SettingsSection>
973983