History

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

Wout Stiens committed 2 months ago

Commit 5e63aea1 parent c1c2d544 files · +392 −294Browse files

Unified · Split

4 files
  1. apps/web/src/components/settings/ProviderInstanceCard.test.ts +41 −2
  2. apps/web/src/components/settings/ProviderInstanceCard.tsx +251 −201
  3. apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +9 −10
  4. apps/web/src/components/settings/ProviderSettingsPanel.tsx +91 −81
apps/web/src/components/settings/ProviderInstanceCard.test.ts+41−2
@@ -41,7 +41,7 @@ describe("deriveProviderModelsForDisplay", () => { · 40 unmodified lines
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", () => { · 27 unmodified lines
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({ · 425 unmodified lines
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({ · 163 unmodified lines
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({ · 3 unmodified lines
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({ · 4 unmodified lines
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?: { · 126 unmodified lines
127127 }) as ReactElement<Record<string, unknown>>;
128128 }
129129
130+function isAddProviderButton(element: ReactElement<Record<string, unknown>>): boolean {
131+ return element.props["aria-label"] === "Add provider";
132+}
133+
130134 async function flushPromises(): Promise<void> {
131135 await Promise.resolve();
132136 await Promise.resolve();
@@ -212,14 +216,10 @@ describe("EnvironmentProviderSettings routing", () => { · 79 unmodified lines
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", () => { · 3 unmodified lines
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() { · 219 unmodified lines
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() { · 12 unmodified lines
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() { · 11 unmodified lines
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({ · 134 unmodified lines
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({ · 11 unmodified lines
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({ · 384 unmodified lines
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({ · 4 unmodified lines
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({ · 5 unmodified lines
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({ · 5 unmodified lines
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({ · 43 unmodified lines
965975 </div>
966976 }
967977 />
968− </CollapsibleContent>
969− </Collapsible>
970− </div>
978+ </div>
979+ </CollapsibleContent>
980+ </Collapsible>
971981 </div>
972982 </SettingsSection>
973983