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", () => {
4141 ).toEqual(["server-model", "kept-custom"]);
4242 });
4343
44 it("shows a redacted provider email in Configuration", () => {
44 it("shows a redacted provider email in the editor header status line", () => {
4545 const instanceId = ProviderInstanceId.make("codex");
4646 const driver = ProviderDriverKind.make("codex");
4747 const liveProvider: ServerProvider = {
......@@ -75,9 +75,48 @@ describe("deriveProviderModelsForDisplay", () => {
7575 }),
7676 );
7777
78 expect(markup).toContain("Account email");
78 expect(markup).toContain("Authenticated as");
7979 expect(markup).toContain('aria-label="Toggle account email visibility"');
8080 expect(markup).toContain("blur-[2px]");
8181 expect(markup).not.toContain("developer@example.com");
8282 });
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 });
83122});
apps/web/src/components/settings/ProviderInstanceCard.tsx+251-201
......@@ -426,18 +426,26 @@ export function ProviderInstanceCard({
426426 onRunUpdate,
427427 isUpdating = false,
428428}: ProviderInstanceCardProps) {
429 const [activeTab, setActiveTab] = useState<"models" | "configuration">("configuration");
429 const [activeTab, setActiveTab] = useState<"configuration" | "models">("configuration");
430430 const enabled = resolveProviderInstanceEnabled(instance);
431 // A locally disabled provider stays neutral even if its last server status
432 // is stale. Enabled providers use the server status when one is available.
431 // A locally disabled provider reads "Disabled" with a muted dot even if its
432 // last server status is stale. Enabled providers use the server status.
433433 const statusKey: ProviderStatusKey = enabled
434434 ? ((liveProvider?.status as ProviderStatusKey | undefined) ?? "warning")
435435 : "disabled";
436436 const statusStyle = PROVIDER_STATUS_STYLES[statusKey];
437 const rawSummary = getProviderSummary(liveProvider);
438 const summary = enabled ? rawSummary : { headline: "Disabled", detail: null };
439 const authEmail = liveProvider?.auth.email;
440 const showEditorStatus = enabled && (statusKey === "warning" || statusKey === "error");
437 const summary = enabled
438 ? getProviderSummary(liveProvider)
439 : { headline: "Disabled", detail: null };
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;
441449 const versionLabel = getProviderVersionLabel(liveProvider?.version);
442450 const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);
443451 const updateCommand = versionAdvisory?.updateCommand ?? null;
......@@ -607,19 +615,34 @@ export function ProviderInstanceCard({
607615 <code className="text-xs text-muted-foreground">{versionLabel}</code>
608616 ) : 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
610631 if (mode === "list") {
611632 return (
612633 <div
613634 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",
615 selected ? "bg-muted/50" : "hover:bg-muted/25",
635 // Sidebar-style selection with a fixed row height so the list stays
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",
616640 )}
617641 >
618 {selected ? <span className="absolute inset-y-0 left-0 w-0.5 bg-primary" /> : null}
619642 <button
620643 type="button"
621644 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",
623646 !enabled && !selected && "opacity-60 group-hover:opacity-100",
624647 )}
625648 onClick={onSelect}
......@@ -629,150 +652,185 @@ export function ProviderInstanceCard({
629652 <span className="min-w-0 flex-1">
630653 <span className="flex min-w-0 items-center gap-2">
631654 <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}
632660 {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}
633666 </span>
634 <span className="mt-0.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
635 <span className={cn("size-1.5 shrink-0 rounded-full", statusStyle.dot)} />
636 <span className="truncate">{summary.headline}</span>
667 <span className="mt-0.5 flex items-start gap-1.5 text-[13px] leading-[1.45] text-muted-foreground/80">
668 {statusDotNode ? (
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>
637675 </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}
643676 </span>
644677 </button>
645 <Switch
646 checked={enabled}
647 disabled={readOnly}
648 onCheckedChange={(checked) => updateEnabled(Boolean(checked))}
649 aria-label={`Enable ${displayName}`}
650 />
678 <span className="flex h-5 shrink-0 items-center">
679 <Switch
680 checked={enabled}
681 disabled={readOnly}
682 onCheckedChange={(checked) => updateEnabled(Boolean(checked))}
683 aria-label={`Enable ${displayName}`}
684 />
685 </span>
651686 </div>
652687 );
653688 }
654689
655690 return (
656691 <div className="min-w-0 lg:flex lg:h-full lg:min-h-0 lg:flex-col">
657 <div
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 >
692 <div className="flex min-h-16 shrink-0 items-start justify-between gap-3 border-b border-border/70 px-4 py-3">
665693 <div className="min-w-0 flex-1 space-y-1">
666694 <div className="flex min-w-0 flex-wrap items-center gap-2">
667695 {titleHeadNode}
668696 {versionCodeNode}
669 {versionAdvisory ? (
670 <Popover>
671 <PopoverTrigger
672 render={
673 <Button
674 type="button"
675 size="icon-xs"
676 variant="ghost"
677 className={cn(
678 "size-5 rounded-sm p-0",
679 versionAdvisory.emphasis === "strong"
680 ? "text-warning hover:text-warning"
681 : "text-update-foreground hover:text-update-foreground",
682 )}
683 aria-label="Update available — view details"
684 >
685 <ArrowUpCircleIcon className="size-3.5" />
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
697 {/*
698 Only the write actions go inert on read-only sessions; the
699 status line below keeps its email reveal clickable.
700 */}
701 <span
702 inert={readOnly}
703 aria-disabled={readOnly || undefined}
704 className={cn("inline-flex items-center gap-2", readOnly && "opacity-50")}
705 >
706 {versionAdvisory ? (
707 <Popover>
708 <PopoverTrigger
709 render={
710 <Button
711 type="button"
712 size="icon-xs"
713 variant="ghost"
700714 className={cn(
701 "text-xs leading-snug",
715 "size-5 rounded-sm p-0",
702716 versionAdvisory.emphasis === "strong"
703 ? "text-warning"
704 : "text-muted-foreground",
717 ? "text-warning hover:text-warning"
718 : "text-update-foreground hover:text-update-foreground",
705719 )}
720 aria-label="Update available — view details"
706721 >
707 {versionAdvisory.detail}
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"}
722 <ArrowUpCircleIcon className="size-3.5" />
721723 </Button>
722 ) : null}
723 {onRunUpdate && updateCommand ? (
724 <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
725 <span aria-hidden className="h-px flex-1 bg-border" />
726 or, update manually using
727 <span aria-hidden className="h-px flex-1 bg-border" />
728 </div>
729 ) : null}
730 {updateCommand ? (
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">
732 <ScrollArea scrollFade className="h-8 min-w-0 flex-1 rounded-none">
733 <code className="flex h-full w-max items-center whitespace-nowrap pr-3 font-mono text-[11px] text-foreground">
734 {updateCommand}
735 </code>
736 </ScrollArea>
737 <Tooltip>
738 <TooltipTrigger
739 render={
740 <Button
741 type="button"
742 size="icon-xs"
743 variant="ghost"
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>
724 }
725 />
726 <PopoverPopup
727 side="bottom"
728 align="start"
729 className="w-[min(21rem,calc(100vw-1.5rem))] [--popup-width:min(21rem,calc(100vw-1.5rem))]"
730 >
731 <div className="grid min-w-0 gap-3">
732 <div className="grid gap-0.5">
733 <p className="text-[13px] font-semibold leading-tight text-foreground">
734 Update available
735 </p>
736 <p
737 className={cn(
738 "text-xs leading-snug",
739 versionAdvisory.emphasis === "strong"
740 ? "text-warning"
741 : "text-muted-foreground",
742 )}
743 >
744 {versionAdvisory.detail}
745 </p>
758746 </div>
759 ) : null}
760 </div>
761 </PopoverPopup>
762 </Popover>
763 ) : null}
764 {titleTailNode}
747 {onRunUpdate ? (
748 <Button
749 type="button"
750 size="xs"
751 variant="default"
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>
765803 </div>
766 {showEditorStatus ? (
767 <p className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
768 <span>{summary.headline}</span>
769 {summary.detail ? <span>· {summary.detail}</span> : null}
804 <p className={statusLineClassName}>
805 {statusDotNode}
806 {isAuthenticated && authEmail ? (
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}
770820 </p>
771821 ) : null}
772822 </div>
773823 </div>
774824
775825 <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>
776834 {driverOption !== undefined ? (
777835 <button
778836 type="button"
......@@ -783,91 +841,83 @@ export function ProviderInstanceCard({
783841 Models
784842 </button>
785843 ) : 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>
794844 </div>
795845
796 <div
797 inert={readOnly}
798 aria-disabled={readOnly || undefined}
799 className={cn("lg:min-h-0 lg:flex-1", readOnly && "opacity-50 select-none")}
800 >
801 <div
802 className="space-y-5 px-4 py-5 lg:h-full lg:overflow-y-auto"
846 <div className="lg:min-h-0 lg:flex-1">
847 <ScrollArea
848 scrollFade
849 chainVerticalScroll
850 className="lg:h-full"
803851 hidden={visibleTab !== "configuration"}
804852 >
805 {authEmail?.trim() ? (
806 <div className="min-w-0">
807 <div className="text-xs font-medium text-foreground">Account email</div>
808 <div className="mt-1.5 flex min-h-8 min-w-0 items-center">
809 <ProviderAuthEmail email={authEmail} />
810 </div>
853 <div
854 inert={readOnly}
855 aria-disabled={readOnly || undefined}
856 className={cn("space-y-5 px-4 py-5", readOnly && "opacity-50 select-none")}
857 >
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>
811873 </div>
812 ) : null}
813874
814 <div>
815 <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block">
816 <span className="text-xs font-medium text-foreground">Display name</span>
817 <DraftInput
818 id={`provider-instance-${instanceId}-display-name`}
819 className="mt-1.5"
820 value={instance.displayName ?? ""}
821 onCommit={updateDisplayName}
822 placeholder={driverOption?.label ?? "Instance label"}
823 spellCheck={false}
875 <div>
876 <ProviderAccentColorPicker
877 displayName={displayName}
878 value={accentColor}
879 onCommit={updateAccentColor}
880 commitDelayMs={120}
881 description="Used to distinguish this instance in picker rails and model lists."
824882 />
825 <span className="mt-1 block text-xs text-muted-foreground">
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}
883 </div>
857884
858 {driverOption === undefined ? (
859885 <div>
860 <p className="text-xs text-muted-foreground">
861 This instance uses a driver (
862 <code className="text-foreground">{String(instance.driver)}</code>) that is not
863 shipped with the current build. Configuration values are preserved but cannot be
864 edited from this surface.
865 </p>
886 <ProviderEnvironmentSection
887 environment={instance.environment ?? []}
888 onChange={updateEnvironment}
889 />
866890 </div>
867 ) : null}
868 </div>
891
892 {driverOption ? (
893 <ProviderSettingsForm
894 definition={driverOption}
895 value={instance.config}
896 idPrefix={`provider-instance-${instanceId}`}
897 variant="card"
898 onChange={updateConfig}
899 />
900 ) : null}
901
902 {driverOption === undefined ? (
903 <div>
904 <p className="text-xs text-muted-foreground">
905 This instance uses a driver (
906 <code className="text-foreground">{String(instance.driver)}</code>) that is not
907 shipped with the current build. Configuration values are preserved but cannot be
908 edited from this surface.
909 </p>
910 </div>
911 ) : null}
912 </div>
913 </ScrollArea>
869914 {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 >
871921 <ProviderModelsSection
872922 instanceId={instanceId}
873923 driverKind={driverKind}
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+9-10
......@@ -127,6 +127,10 @@ function renderPanel(options?: {
127127 }) as ReactElement<Record<string, unknown>>;
128128}
129129
130function isAddProviderButton(element: ReactElement<Record<string, unknown>>): boolean {
131 return element.props["aria-label"] === "Add provider";
132}
133
130134async function flushPromises(): Promise<void> {
131135 await Promise.resolve();
132136 await Promise.resolve();
......@@ -212,14 +216,10 @@ describe("EnvironmentProviderSettings routing", () => {
212216 const notice = visitElements(panel, (element) => element.props.title === "Limited permissions");
213217 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();
220219 expect(
221220 visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"),
222221 ).toBeNull();
222 expect(visitElements(panel, isAddProviderButton)).toBeNull();
223223 });
224224
225225 it("keeps the editable layout interactive when not read only", () => {
......@@ -229,11 +229,10 @@ describe("EnvironmentProviderSettings routing", () => {
229229 expect(
230230 visitElements(panel, (element) => element.props.title === "Limited permissions"),
231231 ).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();
232 expect(
233 visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"),
234 ).not.toBeNull();
235 expect(visitElements(panel, isAddProviderButton)).not.toBeNull();
237236 });
238237
239238 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() {
220220 options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget";
221221 const deviceTabs =
222222 !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">
224224 <div
225225 role="group"
226226 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"
228228 >
229229 {options.map((environment) => {
230230 const Icon = providerEnvironmentIcon(environment);
......@@ -243,10 +243,12 @@ export function ProviderSettingsPanel() {
243243 >
244244 <Icon className="size-3.5 shrink-0" aria-hidden />
245245 <span className="max-w-40 truncate">{environment.label}</span>
246 <ConnectionStatusDot
247 dotClassName={connectionPhaseDotClassName(environment.connection.phase)}
248 pingClassName={connectionPhasePingClassName(environment.connection.phase)}
249 />
246 {environment.connection.phase !== "connected" ? (
247 <ConnectionStatusDot
248 dotClassName={connectionPhaseDotClassName(environment.connection.phase)}
249 pingClassName={connectionPhasePingClassName(environment.connection.phase)}
250 />
251 ) : null}
250252 <span className="sr-only">
251253 {detail}, {statusText}
252254 </span>
......@@ -264,7 +266,7 @@ export function ProviderSettingsPanel() {
264266 ) : null;
265267
266268 return (
267 <SettingsPageContainer width="expanded" className="gap-8">
269 <SettingsPageContainer className="gap-8">
268270 {options.length === 0 ? (
269271 <SettingsSection title="Providers">
270272 <SettingsRow
......@@ -405,10 +407,11 @@ export function EnvironmentProviderSettings({
405407 readonly environmentLabel: string;
406408 readonly deviceTabs?: ReactNode;
407409 /**
408 * Render the full provider layout, greyed out and inert, when this session's
409 * credential lacks `orchestration:operate` on the environment. Showing the
410 * real configuration keeps the view honest; disabling interaction keeps
411 * every one of its writes from being offered and then rejected.
410 * Grey out and freeze every write control when this session's credential
411 * lacks `orchestration:operate` on the environment. Selecting providers and
412 * opening Advanced still work so the real configuration stays readable;
413 * switches, forms, and the health interval are inert so no write is
414 * offered and then rejected.
412415 */
413416 readonly readOnly?: boolean;
414417}) {
......@@ -426,7 +429,6 @@ export function EnvironmentProviderSettings({
426429 const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);
427430 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(null);
428431 const [advancedOpen, setAdvancedOpen] = useState(false);
429 const advancedVisible = readOnly || advancedOpen;
430432 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<
431433 ReadonlySet<ProviderDriverKind>
432434 >(() => new Set());
......@@ -817,16 +819,56 @@ export function EnvironmentProviderSettings({
817819 <SettingsSection
818820 {...searchableSetting("providers")}
819821 headerAction={
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
822 <div className="flex min-w-0 items-center gap-1.5">
823 {/*
824 The 11px size must sit on this flex item, not just the span
825 inside: the item's line box is struck from its own font size,
826 and an inherited 16px strut hangs the smaller text below the
827 vertical center of the row.
828 */}
829 <span className="hidden min-w-0 truncate text-[11px] sm:inline">
830 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
831 </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>
830872 }
831873 >
832874 {deviceTabs}
......@@ -837,45 +879,17 @@ export function EnvironmentProviderSettings({
837879 />
838880 ) : null}
839881 <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)]">
841883 <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">
843 <span>Provider</span>
844 <span>On</span>
845 </div>
846 <div className="lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
847 {rows.map((row) => renderProviderInstance(row, "list"))}
848 </div>
849 <div
850 className={cn(
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>
884 <ScrollArea scrollFade chainVerticalScroll className="lg:min-h-0 lg:flex-1">
885 <div className="divide-y divide-border/60">
886 {rows.map((row) => (
887 <div key={row.instanceId} className="p-1">
888 {renderProviderInstance(row, "list")}
889 </div>
890 ))}
891 </div>
892 </ScrollArea>
879893 </div>
880894
881895 <div className="min-w-0 lg:min-h-0">
......@@ -887,23 +901,19 @@ export function EnvironmentProviderSettings({
887901 </div>
888902 </div>
889903
890 <div
891 inert={readOnly}
892 aria-disabled={readOnly || undefined}
893 className={readOnly ? "opacity-50 select-none" : undefined}
894 >
895 <Collapsible
896 open={advancedVisible}
897 onOpenChange={setAdvancedOpen}
898 className="mt-2 border-t border-border/70"
899 >
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">
901 <ChevronDownIcon
902 className={cn("size-3 transition-transform", advancedVisible && "rotate-180")}
903 />
904 Advanced
905 </CollapsibleTrigger>
906 <CollapsibleContent>
904 <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="mt-1">
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">
906 <ChevronDownIcon
907 className={cn("size-3 transition-transform", advancedOpen && "rotate-180")}
908 />
909 Advanced
910 </CollapsibleTrigger>
911 <CollapsibleContent>
912 <div
913 inert={readOnly}
914 aria-disabled={readOnly || undefined}
915 className={readOnly ? "opacity-50 select-none" : undefined}
916 >
907917 <SettingsRow
908918 title={
909919 <span className="inline-flex items-center gap-1.5">
......@@ -915,7 +925,7 @@ export function EnvironmentProviderSettings({
915925 </PolicyTooltip>
916926 </span>
917927 }
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."
919929 resetAction={
920930 providerHealthRefreshIntervalSeconds !==
921931 defaultProviderHealthRefreshIntervalSeconds ? (
......@@ -965,9 +975,9 @@ export function EnvironmentProviderSettings({
965975 </div>
966976 }
967977 />
968 </CollapsibleContent>
969 </Collapsible>
970 </div>
978 </div>
979 </CollapsibleContent>
980 </Collapsible>
971981 </div>
972982 </SettingsSection>
973983