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

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


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

apps/web/src/components/settings/ProviderInstanceCard.tsx+318-262
...@@ -2,7 +2,6 @@...@@ -2,7 +2,6 @@
22
3import {3import {
4 ArrowUpCircleIcon,4 ArrowUpCircleIcon,
5 ChevronDownIcon,
6 CopyIcon,5 CopyIcon,
7 DownloadIcon,6 DownloadIcon,
8 LoaderIcon,7 LoaderIcon,
...@@ -12,7 +11,7 @@ import {...@@ -12,7 +11,7 @@ import {
12} from "lucide-react";11} from "lucide-react";
13import * as Arr from "effect/Array";12import * as Arr from "effect/Array";
14import * as Result from "effect/Result";13import * as Result from "effect/Result";
15import { useState, type ReactNode } from "react";14import { useEffect, useRef, useState, type ReactNode } from "react";
16import {15import {
17 isProviderDriverKind,16 isProviderDriverKind,
18 resolveProviderInstanceEnabled,17 resolveProviderInstanceEnabled,
...@@ -30,7 +29,6 @@ import { normalizeProviderAccentColor } from "../../providerInstances";...@@ -30,7 +29,6 @@ import { normalizeProviderAccentColor } from "../../providerInstances";
30import { Badge } from "../ui/badge";29import { Badge } from "../ui/badge";
31import { Button } from "../ui/button";30import { Button } from "../ui/button";
32import { Checkbox } from "../ui/checkbox";31import { Checkbox } from "../ui/checkbox";
33import { Collapsible, CollapsibleContent } from "../ui/collapsible";
34import { DraftInput } from "../ui/draft-input";32import { DraftInput } from "../ui/draft-input";
35import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";33import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
36import { ScrollArea } from "../ui/scroll-area";34import { ScrollArea } from "../ui/scroll-area";
...@@ -39,11 +37,11 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "....@@ -39,11 +37,11 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
39import { stackedThreadToast, toastManager } from "../ui/toast";37import { stackedThreadToast, toastManager } from "../ui/toast";
40import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";38import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
41import type { DriverOption } from "./providerDriverMeta";39import type { DriverOption } from "./providerDriverMeta";
40import { providerSettingsTabClassName } from "./providerSettingsTabs";
42import { ProviderSettingsForm } from "./ProviderSettingsForm";41import { ProviderSettingsForm } from "./ProviderSettingsForm";
43import { ProviderModelsSection } from "./ProviderModelsSection";42import { ProviderModelsSection } from "./ProviderModelsSection";
44import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";43import { ProviderInstanceIcon, providerInstanceInitials } from "../chat/ProviderInstanceIcon";
45import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";44import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
46import { RedactedSensitiveText } from "./RedactedSensitiveText";
47import {45import {
48 getProviderVersionAdvisoryPresentation,46 getProviderVersionAdvisoryPresentation,
49 PROVIDER_STATUS_STYLES,47 PROVIDER_STATUS_STYLES,
...@@ -78,6 +76,25 @@ function makeEnvironmentDraftRow(...@@ -78,6 +76,25 @@ function makeEnvironmentDraftRow(
78 };76 };
79}77}
8078
79function providerEnvironmentsEqual(
80 left: ReadonlyArray<ProviderInstanceEnvironmentVariable>,
81 right: ReadonlyArray<ProviderInstanceEnvironmentVariable>,
82): boolean {
83 return (
84 left.length === right.length &&
85 left.every((variable, index) => {
86 const other = right[index];
87 return (
88 other !== undefined &&
89 variable.name === other.name &&
90 variable.value === other.value &&
91 variable.sensitive === other.sensitive &&
92 variable.valueRedacted === other.valueRedacted
93 );
94 })
95 );
96}
97
81/**98/**
82 * Read a string[] at `key` from the opaque config blob, filtering out99 * Read a string[] at `key` from the opaque config blob, filtering out
83 * non-string entries. Used for `customModels`, which is always typed as100 * non-string entries. Used for `customModels`, which is always typed as
...@@ -132,28 +149,6 @@ export function deriveProviderModelsForDisplay(input: {...@@ -132,28 +149,6 @@ export function deriveProviderModelsForDisplay(input: {
132 return [...serverModels, ...customModels];149 return [...serverModels, ...customModels];
133}150}
134151
135function ProviderAuthEmail(props: {
136 readonly email: string | undefined;
137 readonly prefix?: string;
138 readonly separator?: boolean;
139}) {
140 const trimmed = props.email?.trim();
141 if (!trimmed) return null;
142
143 return (
144 <span className="inline-flex min-w-0 items-center gap-1.5">
145 {props.separator ? <span aria-hidden>·</span> : null}
146 {props.prefix ? <span className="text-muted-foreground/80">{props.prefix}</span> : null}
147 <RedactedSensitiveText
148 value={trimmed}
149 ariaLabel="Toggle account email visibility"
150 revealTooltip="Click to reveal email"
151 hideTooltip="Click to hide email"
152 />
153 </span>
154 );
155}
156
157function ProviderEnvironmentSection(props: {152function ProviderEnvironmentSection(props: {
158 readonly environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>;153 readonly environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>;
159 readonly onChange: (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => void;154 readonly onChange: (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => void;
...@@ -161,6 +156,26 @@ function ProviderEnvironmentSection(props: {...@@ -161,6 +156,26 @@ function ProviderEnvironmentSection(props: {
161 const [rows, setRows] = useState<ReadonlyArray<EnvironmentDraftRow>>(() =>156 const [rows, setRows] = useState<ReadonlyArray<EnvironmentDraftRow>>(() =>
162 props.environment.map(makeEnvironmentDraftRow),157 props.environment.map(makeEnvironmentDraftRow),
163 );158 );
159 const previousEnvironmentRef = useRef(props.environment);
160 const lastPublishedEnvironmentRef = useRef<
161 ReadonlyArray<ProviderInstanceEnvironmentVariable> | undefined
162 >(undefined);
163
164 useEffect(() => {
165 const previousEnvironment = previousEnvironmentRef.current;
166 const lastPublishedEnvironment = lastPublishedEnvironmentRef.current;
167 previousEnvironmentRef.current = props.environment;
168 lastPublishedEnvironmentRef.current = undefined;
169 if (
170 previousEnvironment === props.environment ||
171 providerEnvironmentsEqual(previousEnvironment, props.environment) ||
172 (lastPublishedEnvironment !== undefined &&
173 providerEnvironmentsEqual(lastPublishedEnvironment, props.environment))
174 ) {
175 return;
176 }
177 setRows(props.environment.map(makeEnvironmentDraftRow));
178 }, [props.environment]);
164179
165 const publishRows = (nextRows: ReadonlyArray<EnvironmentDraftRow>) => {180 const publishRows = (nextRows: ReadonlyArray<EnvironmentDraftRow>) => {
166 const published: ProviderInstanceEnvironmentVariable[] = [];181 const published: ProviderInstanceEnvironmentVariable[] = [];
...@@ -180,6 +195,7 @@ function ProviderEnvironmentSection(props: {...@@ -180,6 +195,7 @@ function ProviderEnvironmentSection(props: {
180 const { id: _id, ...rest } = row;195 const { id: _id, ...rest } = row;
181 published.push({ ...rest, name });196 published.push({ ...rest, name });
182 }197 }
198 lastPublishedEnvironmentRef.current = published;
183 props.onChange(published);199 props.onChange(published);
184 };200 };
185201
...@@ -324,8 +340,10 @@ interface ProviderInstanceCardProps {...@@ -324,8 +340,10 @@ interface ProviderInstanceCardProps {
324 readonly instance: ProviderInstanceConfig;340 readonly instance: ProviderInstanceConfig;
325 readonly driverOption: DriverOption | undefined;341 readonly driverOption: DriverOption | undefined;
326 readonly liveProvider: ServerProvider | undefined;342 readonly liveProvider: ServerProvider | undefined;
327 readonly isExpanded: boolean;343 readonly mode: "list" | "editor";
328 readonly onExpandedChange: (open: boolean) => void;344 readonly selected?: boolean | undefined;
345 readonly onSelect?: (() => void) | undefined;
346 readonly readOnly?: boolean | undefined;
329 readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void;347 readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void;
330 /**348 /**
331 * Pass `undefined` to hide the delete button entirely. Built-in default349 * Pass `undefined` to hide the delete button entirely. Built-in default
...@@ -353,12 +371,9 @@ interface ProviderInstanceCardProps {...@@ -353,12 +371,9 @@ interface ProviderInstanceCardProps {
353}371}
354372
355/**373/**
356 * A single configured provider-instance row in the Providers settings374 * Renders one provider instance as either a compact selectable list row or
357 * section. Used for every row — both the built-in default instance for a375 * the full editor shown beside that list. Both modes use the same enabled
358 * driver (rendered with `onDelete` omitted) and user-authored custom376 * state and provider metadata.
359 * instances (`onDelete` supplied). The only UI difference between the two
360 * is whether the trash button is visible; every other field (display
361 * name, config fields, models) behaves identically.
362 *377 *
363 * Behavior notes:378 * Behavior notes:
364 * - `liveProvider` is matched by the caller via `instanceId`; when no379 * - `liveProvider` is matched by the caller via `instanceId`; when no
...@@ -379,8 +394,10 @@ export function ProviderInstanceCard({...@@ -379,8 +394,10 @@ export function ProviderInstanceCard({
379 instance,394 instance,
380 driverOption,395 driverOption,
381 liveProvider,396 liveProvider,
382 isExpanded,397 mode,
383 onExpandedChange,398 selected = false,
399 onSelect,
400 readOnly = false,
384 onUpdate,401 onUpdate,
385 onDelete,402 onDelete,
386 headerAction,403 headerAction,
...@@ -393,21 +410,17 @@ export function ProviderInstanceCard({...@@ -393,21 +410,17 @@ export function ProviderInstanceCard({
393 onRunUpdate,410 onRunUpdate,
394 isUpdating = false,411 isUpdating = false,
395}: ProviderInstanceCardProps) {412}: ProviderInstanceCardProps) {
413 const [activeTab, setActiveTab] = useState<"models" | "configuration">("configuration");
396 const enabled = resolveProviderInstanceEnabled(instance);414 const enabled = resolveProviderInstanceEnabled(instance);
397 // The server-reported status wins when present; otherwise fall back to415 // A locally disabled provider stays neutral even if its last server status
398 // "disabled"/"warning" based on the local `enabled` flag so the dot416 // is stale. Enabled providers use the server status when one is available.
399 // reflects the persisted intent even before the first probe completes.417 const statusKey: ProviderStatusKey = enabled
400 const statusKey: ProviderStatusKey =418 ? ((liveProvider?.status as ProviderStatusKey | undefined) ?? "warning")
401 (liveProvider?.status as ProviderStatusKey | undefined) ?? (enabled ? "warning" : "disabled");419 : "disabled";
402 const statusStyle = PROVIDER_STATUS_STYLES[statusKey];420 const statusStyle = PROVIDER_STATUS_STYLES[statusKey];
403 const rawSummary = getProviderSummary(liveProvider);421 const rawSummary = getProviderSummary(liveProvider);
404 const authEmail = liveProvider?.auth.email;422 const summary = enabled ? rawSummary : { headline: "Disabled", detail: null };
405 const hasAuthenticatedEmail =423 const showEditorStatus = enabled && (statusKey === "warning" || statusKey === "error");
406 liveProvider?.auth.status === "authenticated" && Boolean(authEmail?.trim());
407 const authenticatedDetail = hasAuthenticatedEmail
408 ? (liveProvider?.auth.label ?? liveProvider?.auth.type ?? null)
409 : null;
410 const summary = rawSummary;
411 const versionLabel = getProviderVersionLabel(liveProvider?.version);424 const versionLabel = getProviderVersionLabel(liveProvider?.version);
412 const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);425 const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);
413 const updateCommand = versionAdvisory?.updateCommand ?? null;426 const updateCommand = versionAdvisory?.updateCommand ?? null;
...@@ -441,6 +454,7 @@ export function ProviderInstanceCard({...@@ -441,6 +454,7 @@ export function ProviderInstanceCard({
441 const driverKind: ProviderDriverKind | null = isProviderDriverKind(instance.driver)454 const driverKind: ProviderDriverKind | null = isProviderDriverKind(instance.driver)
442 ? instance.driver455 ? instance.driver
443 : null;456 : null;
457 const visibleTab = driverOption === undefined ? "configuration" : activeTab;
444458
445 const customModels = readConfigStringArray(instance.config, "customModels");459 const customModels = readConfigStringArray(instance.config, "customModels");
446 // Server-returned models may lag behind settings writes. Treat probe460 // Server-returned models may lag behind settings writes. Treat probe
...@@ -506,25 +520,21 @@ export function ProviderInstanceCard({...@@ -506,25 +520,21 @@ export function ProviderInstanceCard({
506 displayName={displayName}520 displayName={displayName}
507 accentColor={accentColor}521 accentColor={accentColor}
508 showBadge={Boolean(accentColor)}522 showBadge={Boolean(accentColor)}
509 statusDotClassName={statusStyle.dot}
510 indicatorBackground="var(--card)"
511 className="size-5"523 className="size-5"
512 iconClassName="size-4 text-foreground/80"524 iconClassName="size-4 text-foreground/80"
513 badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-[7px]"525 badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-[7px]"
514 />526 />
515 ) : FallbackIconComponent ? (527 ) : FallbackIconComponent ? (
516 <span className="relative inline-flex size-5 shrink-0 items-center justify-center">528 <span className="inline-flex size-5 shrink-0 items-center justify-center">
517 <FallbackIconComponent className="size-4 text-foreground/80" aria-hidden />529 <FallbackIconComponent className="size-4 text-foreground/80" aria-hidden />
518 <span
519 className={cn(
520 "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-card",
521 statusStyle.dot,
522 )}
523 aria-hidden
524 />
525 </span>530 </span>
526 ) : (531 ) : (
527 <span className={cn("size-2 shrink-0 rounded-full", statusStyle.dot)} />532 <span
533 className="inline-flex size-5 shrink-0 items-center justify-center text-[10px] font-semibold leading-none text-foreground/80"
534 aria-hidden
535 >
536 {providerInstanceInitials(displayName)}
537 </span>
528 );538 );
529539
530 const titleHeadNode = (540 const titleHeadNode = (
...@@ -576,229 +586,275 @@ export function ProviderInstanceCard({...@@ -576,229 +586,275 @@ export function ProviderInstanceCard({
576 </>586 </>
577 );587 );
578588
579 const authRowNode = (
580 <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-[13px] leading-[1.45] text-muted-foreground/80">
581 {hasAuthenticatedEmail ? (
582 <>
583 <span>Authenticated as</span>
584 <ProviderAuthEmail email={authEmail} />
585 {authenticatedDetail ? <span>· {authenticatedDetail}</span> : null}
586 </>
587 ) : (
588 <>
589 <span>{summary.headline}</span>
590 <ProviderAuthEmail email={authEmail} separator prefix="Email" />
591 </>
592 )}
593 {summary.detail ? <span>- {summary.detail}</span> : null}
594 </p>
595 );
596
597 const versionCodeNode = versionLabel ? (589 const versionCodeNode = versionLabel ? (
598 <code className="text-xs text-muted-foreground">{versionLabel}</code>590 <code className="text-xs text-muted-foreground">{versionLabel}</code>
599 ) : null;591 ) : null;
600592
601 return (593 if (mode === "list") {
602 <div className="rounded-xl transition-colors hover:bg-muted/20">594 return (
603 <div className="px-3 py-3 sm:px-4">595 <div
604 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">596 className={cn(
605 <div className="min-w-0 flex-1 space-y-1">597 "group relative flex min-h-16 items-center gap-3 border-b border-border/60 px-3 py-2.5 transition-colors last:border-b-0",
606 <div className="flex min-w-0 flex-wrap items-center gap-2">598 selected ? "bg-muted/50" : "hover:bg-muted/25",
607 {titleHeadNode}599 )}
600 >
601 {selected ? <span className="absolute inset-y-0 left-0 w-0.5 bg-primary" /> : null}
602 <button
603 type="button"
604 className={cn(
605 "flex min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-sm text-left outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-ring",
606 !enabled && !selected && "opacity-60 group-hover:opacity-100",
607 )}
608 onClick={onSelect}
609 aria-pressed={selected}
610 >
611 {titleIconNode}
612 <span className="min-w-0 flex-1">
613 <span className="flex min-w-0 items-center gap-2">
614 <span className="truncate text-sm font-medium text-foreground">{displayName}</span>
608 {versionCodeNode}615 {versionCodeNode}
609 {versionAdvisory ? (616 </span>
610 <Popover>617 <span className="mt-0.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
611 <PopoverTrigger618 <span className={cn("size-1.5 shrink-0 rounded-full", statusStyle.dot)} />
612 render={619 <span className="truncate">{summary.headline}</span>
613 <Button620 </span>
614 type="button"621 {String(instanceId) !== String(instance.driver) ? (
615 size="icon-xs"622 <code className="mt-0.5 block truncate text-[10px] text-muted-foreground/70">
616 variant="ghost"623 {instanceId}
624 </code>
625 ) : null}
626 </span>
627 </button>
628 <Switch
629 checked={enabled}
630 disabled={readOnly}
631 onCheckedChange={(checked) => updateEnabled(Boolean(checked))}
632 aria-label={`Enable ${displayName}`}
633 />
634 </div>
635 );
636 }
637
638 return (
639 <div className="min-w-0">
640 <div
641 inert={readOnly}
642 aria-disabled={readOnly || undefined}
643 className={cn(
644 "flex min-h-16 items-center justify-between gap-3 border-b border-border/70 px-4 py-3",
645 readOnly && "opacity-50 select-none",
646 )}
647 >
648 <div className="min-w-0 flex-1 space-y-1">
649 <div className="flex min-w-0 flex-wrap items-center gap-2">
650 {titleHeadNode}
651 {versionCodeNode}
652 {versionAdvisory ? (
653 <Popover>
654 <PopoverTrigger
655 render={
656 <Button
657 type="button"
658 size="icon-xs"
659 variant="ghost"
660 className={cn(
661 "size-5 rounded-sm p-0",
662 versionAdvisory.emphasis === "strong"
663 ? "text-warning hover:text-warning"
664 : "text-update-foreground hover:text-update-foreground",
665 )}
666 aria-label="Update available — view details"
667 >
668 <ArrowUpCircleIcon className="size-3.5" />
669 </Button>
670 }
671 />
672 <PopoverPopup
673 side="bottom"
674 align="start"
675 className="w-[min(21rem,calc(100vw-1.5rem))] [--popup-width:min(21rem,calc(100vw-1.5rem))]"
676 >
677 <div className="grid min-w-0 gap-3">
678 <div className="grid gap-0.5">
679 <p className="text-[13px] font-semibold leading-tight text-foreground">
680 Update available
681 </p>
682 <p
617 className={cn(683 className={cn(
618 "size-5 rounded-sm p-0",684 "text-xs leading-snug",
619 versionAdvisory.emphasis === "strong"685 versionAdvisory.emphasis === "strong"
620 ? "text-warning hover:text-warning"686 ? "text-warning"
621 : "text-update-foreground hover:text-update-foreground",687 : "text-muted-foreground",
622 )}688 )}
623 aria-label="Update available — view details"
624 >689 >
625 <ArrowUpCircleIcon className="size-3.5 [animation:bounce_2.4s_ease-in-out_infinite] motion-reduce:animate-none" />690 {versionAdvisory.detail}
691 </p>
692 </div>
693 {onRunUpdate ? (
694 <Button
695 type="button"
696 size="xs"
697 variant="default"
698 className="w-full"
699 disabled={isUpdating}
700 onClick={onRunUpdate}
701 >
702 {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />}
703 {isUpdating ? "Updating" : "Update now"}
626 </Button>704 </Button>
627 }705 ) : null}
628 />706 {onRunUpdate && updateCommand ? (
629 <PopoverPopup707 <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
630 side="bottom"708 <span aria-hidden className="h-px flex-1 bg-border" />
631 align="start"709 or, update manually using
632 className="w-[min(21rem,calc(100vw-1.5rem))] [--popup-width:min(21rem,calc(100vw-1.5rem))]"710 <span aria-hidden className="h-px flex-1 bg-border" />
633 >
634 <div className="grid min-w-0 gap-3">
635 <div className="grid gap-0.5">
636 <p className="text-[13px] font-semibold leading-tight text-foreground">
637 Update available
638 </p>
639 <p
640 className={cn(
641 "text-xs leading-snug",
642 versionAdvisory.emphasis === "strong"
643 ? "text-warning"
644 : "text-muted-foreground",
645 )}
646 >
647 {versionAdvisory.detail}
648 </p>
649 </div>711 </div>
650 {onRunUpdate ? (712 ) : null}
651 <Button713 {updateCommand ? (
652 type="button"714 <div className="flex min-w-0 items-center gap-1 rounded-md border border-border/70 bg-muted/40 py-0.5 pr-0.5 pl-2">
653 size="xs"715 <ScrollArea scrollFade className="h-8 min-w-0 flex-1 rounded-none">
654 variant="default"716 <code className="flex h-full w-max items-center whitespace-nowrap pr-3 font-mono text-[11px] text-foreground">
655 className="w-full"717 {updateCommand}
656 disabled={isUpdating}718 </code>
657 onClick={onRunUpdate}719 </ScrollArea>
658 >720 <Tooltip>
659 {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />}721 <TooltipTrigger
660 {isUpdating ? "Updating" : "Update now"}722 render={
661 </Button>723 <Button
662 ) : null}724 type="button"
663 {onRunUpdate && updateCommand ? (725 size="icon-xs"
664 <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">726 variant="ghost"
665 <span aria-hidden className="h-px flex-1 bg-border" />727 className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground"
666 or, update manually using728 onClick={() =>
667 <span aria-hidden className="h-px flex-1 bg-border" />729 copyToClipboard(updateCommand, {
668 </div>730 providerName: displayName,
669 ) : null}731 })
670 {updateCommand ? (732 }
671 <div className="flex min-w-0 items-center gap-1 rounded-md border border-border/70 bg-muted/40 py-0.5 pr-0.5 pl-2">733 aria-label="Copy update command"
672 <ScrollArea scrollFade className="h-8 min-w-0 flex-1 rounded-none">734 >
673 <code className="flex h-full w-max items-center whitespace-nowrap pr-3 font-mono text-[11px] text-foreground">735 <CopyIcon className="size-3" />
674 {updateCommand}736 </Button>
675 </code>737 }
676 </ScrollArea>738 />
677 <Tooltip>739 <TooltipPopup side="top">Copy command</TooltipPopup>
678 <TooltipTrigger740 </Tooltip>
679 render={741 </div>
680 <Button742 ) : null}
681 type="button"743 </div>
682 size="icon-xs"744 </PopoverPopup>
683 variant="ghost"745 </Popover>
684 className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground"746 ) : null}
685 onClick={() =>747 {titleTailNode}
686 copyToClipboard(updateCommand, {
687 providerName: displayName,
688 })
689 }
690 aria-label="Copy update command"
691 >
692 <CopyIcon className="size-3" />
693 </Button>
694 }
695 />
696 <TooltipPopup side="top">Copy command</TooltipPopup>
697 </Tooltip>
698 </div>
699 ) : null}
700 </div>
701 </PopoverPopup>
702 </Popover>
703 ) : null}
704 {titleTailNode}
705 </div>
706 {authRowNode}
707 </div>
708 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
709 <Button
710 size="compact"
711 variant="ghost-muted"
712 onClick={() => onExpandedChange(!isExpanded)}
713 aria-label={`Toggle ${displayName} details`}
714 >
715 <ChevronDownIcon
716 className={cn("size-3.5 transition-transform", isExpanded && "rotate-180")}
717 />
718 </Button>
719 <Switch
720 checked={enabled}
721 onCheckedChange={(checked) => updateEnabled(Boolean(checked))}
722 aria-label={`Enable ${displayName}`}
723 />
724 </div>748 </div>
749 {showEditorStatus ? (
750 <p className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
751 <span>{summary.headline}</span>
752 {summary.detail ? <span>· {summary.detail}</span> : null}
753 </p>
754 ) : null}
725 </div>755 </div>
726 </div>756 </div>
727757
728 <Collapsible open={isExpanded} onOpenChange={onExpandedChange}>758 <div className="flex h-11 border-b border-border/70 px-1">
729 <CollapsibleContent>759 {driverOption !== undefined ? (
730 <div className="space-y-5 px-3 pb-4 pt-2 sm:px-4">760 <button
731 <div>761 type="button"
732 <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block">762 aria-pressed={visibleTab === "models"}
733 <span className="text-xs font-medium text-foreground">Display name</span>763 className={providerSettingsTabClassName(visibleTab === "models")}
734 <DraftInput764 onClick={() => setActiveTab("models")}
735 id={`provider-instance-${instanceId}-display-name`}765 >
736 className="mt-1.5"766 Models
737 value={instance.displayName ?? ""}767 </button>
738 onCommit={updateDisplayName}768 ) : null}
739 placeholder={driverOption?.label ?? "Instance label"}769 <button
740 spellCheck={false}770 type="button"
741 />771 aria-pressed={visibleTab === "configuration"}
742 <span className="mt-1 block text-xs text-muted-foreground">772 className={providerSettingsTabClassName(visibleTab === "configuration")}
743 Optional label shown in the provider list.773 onClick={() => setActiveTab("configuration")}
744 </span>774 >
745 </label>775 Configuration
746 </div>776 </button>
777 </div>
747778
748 <div>779 <div
749 <ProviderAccentColorPicker780 inert={readOnly}
750 displayName={displayName}781 aria-disabled={readOnly || undefined}
751 value={accentColor}782 className={cn("px-4 py-5", readOnly && "opacity-50 select-none")}
752 onCommit={updateAccentColor}783 >
753 commitDelayMs={120}784 <div className="space-y-5" hidden={visibleTab !== "configuration"}>
754 description="Used to distinguish this instance in picker rails and model lists."785 <div>
786 <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block">
787 <span className="text-xs font-medium text-foreground">Display name</span>
788 <DraftInput
789 id={`provider-instance-${instanceId}-display-name`}
790 className="mt-1.5"
791 value={instance.displayName ?? ""}
792 onCommit={updateDisplayName}
793 placeholder={driverOption?.label ?? "Instance label"}
794 spellCheck={false}
755 />795 />
756 </div>796 <span className="mt-1 block text-xs text-muted-foreground">
797 Optional label shown in the provider list.
798 </span>
799 </label>
800 </div>
757801
758 <div>802 <div>
759 <ProviderEnvironmentSection803 <ProviderAccentColorPicker
760 environment={instance.environment ?? []}804 displayName={displayName}
761 onChange={updateEnvironment}805 value={accentColor}
762 />806 onCommit={updateAccentColor}
763 </div>807 commitDelayMs={120}
808 description="Used to distinguish this instance in picker rails and model lists."
809 />
810 </div>
764811
765 {driverOption ? (812 <div>
766 <ProviderSettingsForm813 <ProviderEnvironmentSection
767 definition={driverOption}814 environment={instance.environment ?? []}
768 value={instance.config}815 onChange={updateEnvironment}
769 idPrefix={`provider-instance-${instanceId}`}816 />
770 variant="card"817 </div>
771 onChange={updateConfig}
772 />
773 ) : null}
774818
775 {driverOption !== undefined ? (819 {driverOption ? (
776 <ProviderModelsSection820 <ProviderSettingsForm
777 instanceId={instanceId}821 definition={driverOption}
778 driverKind={driverKind}822 value={instance.config}
779 models={modelsForDisplay}823 idPrefix={`provider-instance-${instanceId}`}
780 customModels={customModels}824 variant="card"
781 hiddenModels={hiddenModels}825 onChange={updateConfig}
782 favoriteModels={favoriteModels}826 />
783 modelOrder={modelOrder}827 ) : null}
784 onChange={updateCustomModels}828
785 onHiddenModelsChange={onHiddenModelsChange}829 {driverOption === undefined ? (
786 onFavoriteModelsChange={onFavoriteModelsChange}830 <div>
787 onModelOrderChange={onModelOrderChange}831 <p className="text-xs text-muted-foreground">
788 />832 This instance uses a driver (
789 ) : (833 <code className="text-foreground">{String(instance.driver)}</code>) that is not
790 <div>834 shipped with the current build. Configuration values are preserved but cannot be
791 <p className="text-xs text-muted-foreground">835 edited from this surface.
792 This instance uses a driver (836 </p>
793 <code className="text-foreground">{String(instance.driver)}</code>) that is not837 </div>
794 shipped with the current build. Configuration values are preserved but cannot be838 ) : null}
795 edited from this surface.839 </div>
796 </p>840 {driverOption !== undefined ? (
797 </div>841 <div hidden={visibleTab !== "models"}>
798 )}842 <ProviderModelsSection
843 instanceId={instanceId}
844 driverKind={driverKind}
845 models={modelsForDisplay}
846 customModels={customModels}
847 hiddenModels={hiddenModels}
848 favoriteModels={favoriteModels}
849 modelOrder={modelOrder}
850 onChange={updateCustomModels}
851 onHiddenModelsChange={onHiddenModelsChange}
852 onFavoriteModelsChange={onFavoriteModelsChange}
853 onModelOrderChange={onModelOrderChange}
854 />
799 </div>855 </div>
800 </CollapsibleContent>856 ) : null}
801 </Collapsible>857 </div>
802 </div>858 </div>
803 );859 );
804}860}
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+57-10
...@@ -178,21 +178,45 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -178,21 +178,45 @@ describe("EnvironmentProviderSettings routing", () => {
178 });178 });
179 });179 });
180180
181 it("renders the provider layout inert with a limited-permissions notice when read only", () => {181 it("keeps provider selection available while write controls are read only", () => {
182 settingsState.value = {
183 ...DEFAULT_UNIFIED_SETTINGS,
184 providerInstances: {
185 [customId]: {
186 driver: ProviderDriverKind.make("codex"),
187 enabled: true,
188 },
189 },
190 };
182 atoms.providers = [provider()];191 atoms.providers = [provider()];
183 const panel = renderPanel({ readOnly: true });192 let panel = renderPanel({ readOnly: true });
184193
185 const inertWrapper = visitElements(panel, (element) => element.props.inert === true);194 const inertWrapper = visitElements(panel, (element) => element.props.inert === true);
186 expect(inertWrapper).not.toBeNull();195 expect(inertWrapper).not.toBeNull();
187 const providerCard = visitElements(panel, (element) => element.props.instanceId === codexId);196
188 expect(providerCard).not.toBeNull();197 const customRow = visitElements(
198 panel,
199 (element) => element.props.instanceId === customId && element.props.mode === "list",
200 );
201 expect(customRow?.props.readOnly).toBe(true);
202 expect(customRow?.props.onSelect).toBeTypeOf("function");
203 (customRow?.props.onSelect as (() => void) | undefined)?.();
204
205 panel = renderPanel({ readOnly: true });
206 const customEditor = visitElements(
207 panel,
208 (element) => element.props.instanceId === customId && element.props.mode === "editor",
209 );
210 expect(customEditor).not.toBeNull();
189211
190 const notice = visitElements(panel, (element) => element.props.title === "Limited permissions");212 const notice = visitElements(panel, (element) => element.props.title === "Limited permissions");
191 expect(notice).not.toBeNull();213 expect(notice).not.toBeNull();
192214
193 expect(215 const providersSection = visitElements(
194 visitElements(panel, (element) => element.props["aria-label"] === "Add provider instance"),216 panel,
195 ).toBeNull();217 (element) => element.props.title === "Providers" && "headerAction" in element.props,
218 );
219 expect(providersSection?.props.headerAction).toBeNull();
196 expect(220 expect(
197 visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"),221 visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"),
198 ).toBeNull();222 ).toBeNull();
...@@ -205,6 +229,11 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -205,6 +229,11 @@ describe("EnvironmentProviderSettings routing", () => {
205 expect(229 expect(
206 visitElements(panel, (element) => element.props.title === "Limited permissions"),230 visitElements(panel, (element) => element.props.title === "Limited permissions"),
207 ).toBeNull();231 ).toBeNull();
232 const providersSection = visitElements(
233 panel,
234 (element) => element.props.title === "Providers" && "headerAction" in element.props,
235 );
236 expect(providersSection?.props.headerAction).not.toBeNull();
208 });237 });
209238
210 it("deletes and resets provider configuration without erasing shared preferences", () => {239 it("deletes and resets provider configuration without erasing shared preferences", () => {
...@@ -225,8 +254,17 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -225,8 +254,17 @@ describe("EnvironmentProviderSettings routing", () => {
225 },254 },
226 favorites: [{ provider: customId, model: "favorite" }],255 favorites: [{ provider: customId, model: "favorite" }],
227 };256 };
228 const panel = renderPanel();257 let panel = renderPanel();
229 const customCard = visitElements(panel, (element) => element.props.instanceId === customId);258 const customRow = visitElements(
259 panel,
260 (element) => element.props.instanceId === customId && element.props.mode === "list",
261 );
262 (customRow?.props.onSelect as (() => void) | undefined)?.();
263 panel = renderPanel();
264 const customCard = visitElements(
265 panel,
266 (element) => element.props.instanceId === customId && element.props.mode === "editor",
267 );
230 expect(customCard).not.toBeNull();268 expect(customCard).not.toBeNull();
231 (customCard?.props.onDelete as (() => void) | undefined)?.();269 (customCard?.props.onDelete as (() => void) | undefined)?.();
232270
...@@ -237,7 +275,16 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -237,7 +275,16 @@ describe("EnvironmentProviderSettings routing", () => {
237 });275 });
238276
239 settingsState.updateSettings.mockClear();277 settingsState.updateSettings.mockClear();
240 const defaultCard = visitElements(panel, (element) => element.props.instanceId === codexId);278 const defaultRow = visitElements(
279 panel,
280 (element) => element.props.instanceId === codexId && element.props.mode === "list",
281 );
282 (defaultRow?.props.onSelect as (() => void) | undefined)?.();
283 panel = renderPanel();
284 const defaultCard = visitElements(
285 panel,
286 (element) => element.props.instanceId === codexId && element.props.mode === "editor",
287 );
241 const resetAction = defaultCard?.props.headerAction;288 const resetAction = defaultCard?.props.headerAction;
242 const resetButton = visitElements(289 const resetButton = visitElements(
243 resetAction,290 resetAction,
apps/web/src/components/settings/ProviderSettingsPanel.tsx+342-274
...@@ -24,6 +24,7 @@ import * as Duration from "effect/Duration";...@@ -24,6 +24,7 @@ import * as Duration from "effect/Duration";
24import * as Equal from "effect/Equal";24import * as Equal from "effect/Equal";
25import * as Result from "effect/Result";25import * as Result from "effect/Result";
26import {26import {
27 ChevronDownIcon,
27 CloudIcon,28 CloudIcon,
28 LaptopIcon,29 LaptopIcon,
29 LoaderIcon,30 LoaderIcon,
...@@ -32,7 +33,7 @@ import {...@@ -32,7 +33,7 @@ import {
32 RefreshCwIcon,33 RefreshCwIcon,
33 TerminalIcon,34 TerminalIcon,
34} from "lucide-react";35} from "lucide-react";
35import { useCallback, useMemo, useRef, useState } from "react";36import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
3637
37import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";38import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
38import { isElectron } from "../../env";39import { isElectron } from "../../env";
...@@ -62,6 +63,7 @@ import {...@@ -62,6 +63,7 @@ import {
62 type ProviderUpdateCandidate,63 type ProviderUpdateCandidate,
63} from "../ProviderUpdateLaunchNotification.logic";64} from "../ProviderUpdateLaunchNotification.logic";
64import { Button } from "../ui/button";65import { Button } from "../ui/button";
66import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible";
65import {67import {
66 NumberField,68 NumberField,
67 NumberFieldDecrement,69 NumberFieldDecrement,
...@@ -69,11 +71,13 @@ import {...@@ -69,11 +71,13 @@ import {
69 NumberFieldIncrement,71 NumberFieldIncrement,
70 NumberFieldInput,72 NumberFieldInput,
71} from "../ui/number-field";73} from "../ui/number-field";
72import { stackedThreadToast, toastManager } from "../ui/toast";74import { ScrollArea } from "../ui/scroll-area";
73import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";75import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
76import { stackedThreadToast, toastManager } from "../ui/toast";
74import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";77import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
75import { ProviderInstanceCard } from "./ProviderInstanceCard";78import { ProviderInstanceCard } from "./ProviderInstanceCard";
76import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";79import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta";
80import { providerSettingsTabClassName } from "./providerSettingsTabs";
77import { searchableSetting } from "./settingsSearch";81import { searchableSetting } from "./settingsSearch";
78import {82import {
79 backgroundActivityOverrideSettings,83 backgroundActivityOverrideSettings,
...@@ -165,9 +169,11 @@ function providerEnvironmentDetail(environment: EnvironmentPresentation): string...@@ -165,9 +169,11 @@ function providerEnvironmentDetail(environment: EnvironmentPresentation): string
165function EnvironmentUnavailableRow({169function EnvironmentUnavailableRow({
166 environment,170 environment,
167 access,171 access,
172 deviceTabs,
168}: {173}: {
169 readonly environment: EnvironmentPresentation;174 readonly environment: EnvironmentPresentation;
170 readonly access: Exclude<ProviderEnvironmentAccess, { kind: "editable" | "read-only" }>;175 readonly access: Exclude<ProviderEnvironmentAccess, { kind: "editable" | "read-only" }>;
176 readonly deviceTabs?: ReactNode;
171}) {177}) {
172 const isLoading = access.kind === "loading";178 const isLoading = access.kind === "loading";
173 const title = isLoading179 const title = isLoading
...@@ -184,6 +190,7 @@ function EnvironmentUnavailableRow({...@@ -184,6 +190,7 @@ function EnvironmentUnavailableRow({
184 // continuously repainting animation would run the whole time.190 // continuously repainting animation would run the whole time.
185 return (191 return (
186 <SettingsSection title="Providers">192 <SettingsSection title="Providers">
193 {deviceTabs}
187 <SettingsRow title={title} description={description} />194 <SettingsRow title={title} description={description} />
188 </SettingsSection>195 </SettingsSection>
189 );196 );
...@@ -211,64 +218,63 @@ export function ProviderSettingsPanel() {...@@ -211,64 +218,63 @@ export function ProviderSettingsPanel() {
211 options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null;218 options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null;
212 const onlyPrimaryDevice =219 const onlyPrimaryDevice =
213 options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget";220 options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget";
221 const deviceTabs =
222 !onlyPrimaryDevice && options.length > 0 ? (
223 <ScrollArea hideScrollbars scrollFade className="h-11 min-w-0 rounded-none">
224 <div
225 role="group"
226 aria-label="Devices"
227 className="flex h-full w-max min-w-full border-b border-border/70 px-3 sm:px-4"
228 >
229 {options.map((environment) => {
230 const Icon = providerEnvironmentIcon(environment);
231 const selected = environment.environmentId === effectiveEnvironmentId;
232 const detail = providerEnvironmentDetail(environment);
233 const statusText = connectionStatusText(environment.connection);
234 return (
235 <Tooltip key={environment.environmentId}>
236 <TooltipTrigger
237 render={
238 <button
239 type="button"
240 aria-pressed={selected}
241 className={cn(providerSettingsTabClassName(selected), "gap-2 text-left")}
242 onClick={() => setSelectedEnvironmentId(environment.environmentId)}
243 >
244 <Icon className="size-3.5 shrink-0" aria-hidden />
245 <span className="max-w-40 truncate">{environment.label}</span>
246 <ConnectionStatusDot
247 dotClassName={connectionPhaseDotClassName(environment.connection.phase)}
248 pingClassName={connectionPhasePingClassName(environment.connection.phase)}
249 />
250 <span className="sr-only">
251 {detail}, {statusText}
252 </span>
253 </button>
254 }
255 />
256 <TooltipPopup side="top">
257 {detail} · {statusText}
258 </TooltipPopup>
259 </Tooltip>
260 );
261 })}
262 </div>
263 </ScrollArea>
264 ) : null;
214265
215 return (266 return (
216 <SettingsPageContainer>267 <SettingsPageContainer width="expanded" className="gap-8">
217 {!onlyPrimaryDevice ? (268 {options.length === 0 ? (
218 <SettingsSection title="Devices">269 <SettingsSection title="Providers">
219 {options.length === 0 ? (270 <SettingsRow
220 // The catalog hydrates asynchronously, so an empty list before it is271 title={isReady ? "No connected devices" : "Loading devices"}
221 // ready means "not loaded yet", not "nothing is connected".272 description={
222 <SettingsRow273 isReady
223 title={isReady ? "No connected devices" : "Loading devices"}274 ? "Connect an execution environment before configuring providers."
224 description={275 : "Reading connected execution environments."
225 isReady276 }
226 ? "Connect an execution environment before configuring providers."277 />
227 : "Reading connected execution environments."
228 }
229 />
230 ) : (
231 <div className="grid gap-1 sm:grid-cols-2">
232 {options.map((environment) => {
233 const Icon = providerEnvironmentIcon(environment);
234 const selected = environment.environmentId === effectiveEnvironmentId;
235 const statusText = connectionStatusText(environment.connection);
236 return (
237 <button
238 key={environment.environmentId}
239 type="button"
240 aria-pressed={selected}
241 className={cn(
242 "flex min-w-0 items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors sm:px-4",
243 selected
244 ? "bg-primary/8 ring-1 ring-primary/25 dark:bg-primary/12"
245 : "hover:bg-muted/40",
246 )}
247 onClick={() => setSelectedEnvironmentId(environment.environmentId)}
248 >
249 <span className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-background text-muted-foreground">
250 <Icon className="size-4" aria-hidden />
251 </span>
252 <span className="min-w-0 flex-1">
253 <span className="flex items-center gap-1.5">
254 <ConnectionStatusDot
255 tooltipText={statusText}
256 dotClassName={connectionPhaseDotClassName(environment.connection.phase)}
257 pingClassName={connectionPhasePingClassName(environment.connection.phase)}
258 />
259 <span className="truncate text-sm font-medium text-foreground">
260 {environment.label}
261 </span>
262 </span>
263 <span className="block truncate pl-[18px] text-xs text-muted-foreground">
264 {providerEnvironmentDetail(environment)} · {statusText}
265 </span>
266 </span>
267 </button>
268 );
269 })}
270 </div>
271 )}
272 </SettingsSection>278 </SettingsSection>
273 ) : null}279 ) : null}
274280
...@@ -276,6 +282,7 @@ export function ProviderSettingsPanel() {...@@ -276,6 +282,7 @@ export function ProviderSettingsPanel() {
276 <SelectedEnvironmentProviderSettings282 <SelectedEnvironmentProviderSettings
277 key={selectedEnvironment.environmentId}283 key={selectedEnvironment.environmentId}
278 environment={selectedEnvironment}284 environment={selectedEnvironment}
285 deviceTabs={deviceTabs}
279 />286 />
280 ) : null}287 ) : null}
281 </SettingsPageContainer>288 </SettingsPageContainer>
...@@ -284,25 +291,37 @@ export function ProviderSettingsPanel() {...@@ -284,25 +291,37 @@ export function ProviderSettingsPanel() {
284291
285function SelectedEnvironmentProviderSettings({292function SelectedEnvironmentProviderSettings({
286 environment,293 environment,
294 deviceTabs,
287}: {295}: {
288 readonly environment: EnvironmentPresentation;296 readonly environment: EnvironmentPresentation;
297 readonly deviceTabs?: ReactNode;
289}) {298}) {
290 const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";299 const isPrimary = environment.entry.target._tag === "PrimaryConnectionTarget";
291 if (isPrimary) {300 if (isPrimary) {
292 // The desktop app owns its primary server outright; a browser session301 // The desktop app owns its primary server outright; a browser session
293 // checks the scopes its cookie session was granted.302 // checks the scopes its cookie session was granted.
294 if (isElectron) {303 if (isElectron) {
295 return <AccessGatedProviderSettings environment={environment} operateAccess="granted" />;304 return (
305 <AccessGatedProviderSettings
306 environment={environment}
307 operateAccess="granted"
308 deviceTabs={deviceTabs}
309 />
310 );
296 }311 }
297 return <PrimarySessionGatedProviderSettings environment={environment} />;312 return (
313 <PrimarySessionGatedProviderSettings environment={environment} deviceTabs={deviceTabs} />
314 );
298 }315 }
299 return <RemoteSessionGatedProviderSettings environment={environment} />;316 return <RemoteSessionGatedProviderSettings environment={environment} deviceTabs={deviceTabs} />;
300}317}
301318
302function PrimarySessionGatedProviderSettings({319function PrimarySessionGatedProviderSettings({
303 environment,320 environment,
321 deviceTabs,
304}: {322}: {
305 readonly environment: EnvironmentPresentation;323 readonly environment: EnvironmentPresentation;
324 readonly deviceTabs?: ReactNode;
306}) {325}) {
307 const primarySessionState = usePrimarySessionState();326 const primarySessionState = usePrimarySessionState();
308 const operateAccess = resolvePrimaryOperateAccess({327 const operateAccess = resolvePrimaryOperateAccess({
...@@ -312,13 +331,21 @@ function PrimarySessionGatedProviderSettings({...@@ -312,13 +331,21 @@ function PrimarySessionGatedProviderSettings({
312 isPending: primarySessionState.isPending,331 isPending: primarySessionState.isPending,
313 hasError: primarySessionState.error !== null,332 hasError: primarySessionState.error !== null,
314 });333 });
315 return <AccessGatedProviderSettings environment={environment} operateAccess={operateAccess} />;334 return (
335 <AccessGatedProviderSettings
336 environment={environment}
337 operateAccess={operateAccess}
338 deviceTabs={deviceTabs}
339 />
340 );
316}341}
317342
318function RemoteSessionGatedProviderSettings({343function RemoteSessionGatedProviderSettings({
319 environment,344 environment,
345 deviceTabs,
320}: {346}: {
321 readonly environment: EnvironmentPresentation;347 readonly environment: EnvironmentPresentation;
348 readonly deviceTabs?: ReactNode;
322}) {349}) {
323 const sessionState = useEnvironmentSessionState(environment.environmentId);350 const sessionState = useEnvironmentSessionState(environment.environmentId);
324 const operateAccess = resolveRemoteOperateAccess({351 const operateAccess = resolveRemoteOperateAccess({
...@@ -326,15 +353,23 @@ function RemoteSessionGatedProviderSettings({...@@ -326,15 +353,23 @@ function RemoteSessionGatedProviderSettings({
326 isPending: sessionState.isPending,353 isPending: sessionState.isPending,
327 hasError: sessionState.hasError,354 hasError: sessionState.hasError,
328 });355 });
329 return <AccessGatedProviderSettings environment={environment} operateAccess={operateAccess} />;356 return (
357 <AccessGatedProviderSettings
358 environment={environment}
359 operateAccess={operateAccess}
360 deviceTabs={deviceTabs}
361 />
362 );
330}363}
331364
332function AccessGatedProviderSettings({365function AccessGatedProviderSettings({
333 environment,366 environment,
334 operateAccess,367 operateAccess,
368 deviceTabs,
335}: {369}: {
336 readonly environment: EnvironmentPresentation;370 readonly environment: EnvironmentPresentation;
337 readonly operateAccess: ProviderOperateAccess;371 readonly operateAccess: ProviderOperateAccess;
372 readonly deviceTabs?: ReactNode;
338}) {373}) {
339 const access = classifyProviderEnvironmentAccess({374 const access = classifyProviderEnvironmentAccess({
340 connectionPhase: environment.connection.phase,375 connectionPhase: environment.connection.phase,
...@@ -342,13 +377,20 @@ function AccessGatedProviderSettings({...@@ -342,13 +377,20 @@ function AccessGatedProviderSettings({
342 operateAccess,377 operateAccess,
343 });378 });
344 if (access.kind !== "editable" && access.kind !== "read-only") {379 if (access.kind !== "editable" && access.kind !== "read-only") {
345 return <EnvironmentUnavailableRow environment={environment} access={access} />;380 return (
381 <EnvironmentUnavailableRow
382 environment={environment}
383 access={access}
384 deviceTabs={deviceTabs}
385 />
386 );
346 }387 }
347 return (388 return (
348 <EnvironmentProviderSettings389 <EnvironmentProviderSettings
349 environmentId={environment.environmentId}390 environmentId={environment.environmentId}
350 environmentLabel={environment.label}391 environmentLabel={environment.label}
351 readOnly={access.kind === "read-only"}392 readOnly={access.kind === "read-only"}
393 deviceTabs={deviceTabs}
352 />394 />
353 );395 );
354}396}
...@@ -357,9 +399,11 @@ export function EnvironmentProviderSettings({...@@ -357,9 +399,11 @@ export function EnvironmentProviderSettings({
357 environmentId,399 environmentId,
358 environmentLabel,400 environmentLabel,
359 readOnly = false,401 readOnly = false,
402 deviceTabs,
360}: {403}: {
361 readonly environmentId: EnvironmentId;404 readonly environmentId: EnvironmentId;
362 readonly environmentLabel: string;405 readonly environmentLabel: string;
406 readonly deviceTabs?: ReactNode;
363 /**407 /**
364 * Render the full provider layout, greyed out and inert, when this session's408 * Render the full provider layout, greyed out and inert, when this session's
365 * credential lacks `orchestration:operate` on the environment. Showing the409 * credential lacks `orchestration:operate` on the environment. Showing the
...@@ -380,10 +424,12 @@ export function EnvironmentProviderSettings({...@@ -380,10 +424,12 @@ export function EnvironmentProviderSettings({
380 });424 });
381 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);425 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
382 const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);426 const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);
427 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(null);
428 const [advancedOpen, setAdvancedOpen] = useState(false);
429 const advancedVisible = readOnly || advancedOpen;
383 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<430 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<
384 ReadonlySet<ProviderDriverKind>431 ReadonlySet<ProviderDriverKind>
385 >(() => new Set());432 >(() => new Set());
386 const [openInstanceDetails, setOpenInstanceDetails] = useState<Record<string, boolean>>({});
387 const refreshingRef = useRef(false);433 const refreshingRef = useRef(false);
388 const updatingDriversRef = useRef<Set<ProviderDriverKind>>(new Set());434 const updatingDriversRef = useRef<Set<ProviderDriverKind>>(new Set());
389435
...@@ -577,6 +623,8 @@ export function EnvironmentProviderSettings({...@@ -577,6 +623,8 @@ export function EnvironmentProviderSettings({
577 }623 }
578 }624 }
579625
626 const selectedRow = rows.find((row) => row.instanceId === selectedInstanceId) ?? rows[0] ?? null;
627
580 const updateProviderInstance = (628 const updateProviderInstance = (
581 row: InstanceRow,629 row: InstanceRow,
582 next: ProviderInstanceConfig,630 next: ProviderInstanceConfig,
...@@ -666,233 +714,253 @@ export function EnvironmentProviderSettings({...@@ -666,233 +714,253 @@ export function EnvironmentProviderSettings({
666 });714 });
667 };715 };
668716
717 const renderProviderInstance = (row: InstanceRow, mode: "list" | "editor") => {
718 const driverOption = getDriverOption(row.driver);
719 const liveProvider = serverProviders.find(
720 (candidate) => candidate.instanceId === row.instanceId,
721 );
722 const updateCandidate = liveProvider
723 ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId)
724 : undefined;
725 const isDriverUpdateRunning =
726 updateCandidate !== undefined &&
727 (updatingProviderDrivers.has(updateCandidate.driver) ||
728 serverProviders.some(
729 (provider) =>
730 provider.driver === updateCandidate.driver && isProviderUpdateActive(provider),
731 ));
732 const showInlineUpdateButton =
733 updateCandidate !== undefined &&
734 hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders);
735 const canRunInlineUpdate =
736 updateCandidate !== undefined &&
737 canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) &&
738 !updatingProviderDrivers.has(updateCandidate.driver);
739 const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {
740 hiddenModels: [],
741 modelOrder: [],
742 };
743 const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) =>
744 favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid,
745 );
746 const resetLabel = driverOption?.label ?? String(row.driver);
747
748 return (
749 <ProviderInstanceCard
750 key={row.instanceId}
751 instanceId={row.instanceId}
752 instance={row.instance}
753 driverOption={driverOption}
754 liveProvider={liveProvider}
755 mode={mode}
756 selected={mode === "list" && selectedRow?.instanceId === row.instanceId}
757 onSelect={mode === "list" ? () => setSelectedInstanceId(row.instanceId) : undefined}
758 readOnly={readOnly}
759 onUpdate={(next) => {
760 const wasEnabled = resolveProviderInstanceEnabled(row.instance);
761 const isDisabling = next.enabled === false && wasEnabled;
762 const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId;
763 updateProviderInstance(
764 row,
765 next,
766 shouldClearTextGen
767 ? {
768 textGenerationModelSelection:
769 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
770 }
771 : undefined,
772 );
773 }}
774 onDelete={
775 mode === "editor" && !row.isDefault
776 ? () => deleteProviderInstance(row.instanceId)
777 : undefined
778 }
779 headerAction={
780 mode === "editor" && row.isDefault && row.isDirty ? (
781 <SettingResetButton
782 label={`${resetLabel} provider settings`}
783 onClick={() => resetDefaultInstance(row.driver)}
784 />
785 ) : null
786 }
787 hiddenModels={modelPreferences.hiddenModels}
788 favoriteModels={favoriteModels}
789 modelOrder={modelPreferences.modelOrder}
790 onHiddenModelsChange={(hiddenModels) =>
791 updateProviderModelPreferences(row.instanceId, {
792 ...modelPreferences,
793 hiddenModels,
794 })
795 }
796 onFavoriteModelsChange={(next) => updateProviderFavoriteModels(row.instanceId, next)}
797 onModelOrderChange={(modelOrder) =>
798 updateProviderModelPreferences(row.instanceId, {
799 ...modelPreferences,
800 modelOrder,
801 })
802 }
803 onRunUpdate={
804 mode === "editor" && showInlineUpdateButton && updateCandidate
805 ? () => {
806 if (canRunInlineUpdate) void runProviderUpdate(updateCandidate);
807 }
808 : undefined
809 }
810 isUpdating={mode === "editor" && showInlineUpdateButton ? isDriverUpdateRunning : undefined}
811 />
812 );
813 };
814
669 return (815 return (
670 <>816 <>
671 <SettingsSection817 <SettingsSection
672 {...searchableSetting("providers")}818 {...searchableSetting("providers")}
673 headerAction={819 headerAction={
674 <div className="flex items-center gap-1.5">820 !readOnly ? (
675 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />821 <Button
676 {!readOnly ? (822 size="compact"
677 <>823 variant="outline"
678 <Tooltip>824 onClick={() => setIsAddInstanceDialogOpen(true)}
679 <TooltipTrigger825 >
680 render={826 <PlusIcon className="size-3.5" />
681 <Button827 Add provider
682 size="icon-micro"828 </Button>
683 variant="ghost-muted"829 ) : null
684 onClick={() => setIsAddInstanceDialogOpen(true)}
685 aria-label="Add provider instance"
686 >
687 <PlusIcon className="size-3" />
688 </Button>
689 }
690 />
691 <TooltipPopup side="top">Add provider instance</TooltipPopup>
692 </Tooltip>
693 <Tooltip>
694 <TooltipTrigger
695 render={
696 <Button
697 size="icon-micro"
698 variant="ghost-muted"
699 disabled={isRefreshingProviders}
700 onClick={() => void refreshProviders()}
701 aria-label="Refresh provider status"
702 >
703 {isRefreshingProviders ? (
704 <LoaderIcon className="size-3 animate-spin" />
705 ) : (
706 <RefreshCwIcon className="size-3" />
707 )}
708 </Button>
709 }
710 />
711 <TooltipPopup side="top">Refresh provider status</TooltipPopup>
712 </Tooltip>
713 </>
714 ) : null}
715 </div>
716 }830 }
717 >831 >
832 {deviceTabs}
718 {readOnly ? (833 {readOnly ? (
719 <SettingsRow834 <SettingsRow
720 title="Limited permissions"835 title="Limited permissions"
721 description={`This session can view ${environmentLabel}'s providers, but its credential does not allow changing their configuration.`}836 description={`This session can view ${environmentLabel}'s providers, but its credential does not allow changing their configuration.`}
722 />837 />
723 ) : null}838 ) : null}
724 <div839 <div className="space-y-1">
725 // `inert` blocks focus and interaction in one attribute, so the840 <div className="overflow-hidden rounded-lg border border-border/70 lg:grid lg:grid-cols-[20rem_minmax(0,1fr)]">
726 // read-only view stays byte-for-byte the editable layout without841 <div className="border-b border-border/70 lg:border-r lg:border-b-0">
727 // threading a disabled flag through every control.842 <div className="flex min-h-9 items-center justify-between border-b border-border/70 px-3 text-[11px] font-medium text-muted-foreground">
728 inert={readOnly}843 <span>Provider</span>
729 aria-disabled={readOnly || undefined}844 <span>On</span>
730 className={readOnly ? "space-y-1 opacity-50 select-none" : "space-y-1"}
731 >
732 <SettingsRow
733 title={
734 <span className="inline-flex items-center gap-1.5">
735 Health check interval
736 <PolicyTooltip>
737 This interval is configured here, then the shared Background activity policy
738 decides whether provider probes may run when the timer fires. Custom intervals
739 appear as Advanced in General settings.
740 </PolicyTooltip>
741 </span>
742 }
743 description="Refresh provider availability, versions, auth state, and model metadata in the background. Set this to 0 seconds to rely on manual refreshes."
744 resetAction={
745 providerHealthRefreshIntervalSeconds !==
746 defaultProviderHealthRefreshIntervalSeconds ? (
747 <SettingResetButton
748 label="provider health check interval"
749 onClick={() =>
750 updateSettings(
751 backgroundActivityOverrideSettings(
752 settings.backgroundActivity,
753 resolvedBackgroundActivity,
754 {
755 providerHealthRefreshInterval: undefined,
756 },
757 ),
758 )
759 }
760 />
761 ) : null
762 }
763 control={
764 <div className="flex shrink-0 items-center gap-2">
765 <NumberField
766 value={providerHealthRefreshIntervalSeconds}
767 min={0}
768 step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS}
769 size="sm"
770 className="w-32"
771 onValueChange={(value) =>
772 updateSettings(
773 backgroundActivityOverrideSettings(
774 settings.backgroundActivity,
775 resolvedBackgroundActivity,
776 {
777 providerHealthRefreshInterval: Duration.seconds(
778 normalizeIntervalSeconds(value),
779 ),
780 },
781 ),
782 )
783 }
784 >
785 <NumberFieldGroup>
786 <NumberFieldDecrement aria-label="Decrease provider health check interval" />
787 <NumberFieldInput aria-label="Provider health check interval in seconds" />
788 <NumberFieldIncrement aria-label="Increase provider health check interval" />
789 </NumberFieldGroup>
790 </NumberField>
791 <span className="text-xs text-muted-foreground">seconds</span>
792 </div>845 </div>
793 }846 {rows.map((row) => renderProviderInstance(row, "list"))}
794 />847 <div className="flex min-h-10 items-center justify-between px-3">
848 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
849 {!readOnly ? (
850 <Tooltip>
851 <TooltipTrigger
852 render={
853 <Button
854 size="icon-micro"
855 variant="ghost-muted"
856 disabled={isRefreshingProviders}
857 onClick={() => void refreshProviders()}
858 aria-label="Refresh provider status"
859 >
860 {isRefreshingProviders ? (
861 <LoaderIcon className="size-3 animate-spin" />
862 ) : (
863 <RefreshCwIcon className="size-3" />
864 )}
865 </Button>
866 }
867 />
868 <TooltipPopup side="top">Refresh provider status</TooltipPopup>
869 </Tooltip>
870 ) : null}
871 </div>
872 </div>
795873
796 {rows.map((row) => {874 <div className="min-w-0">
797 const driverOption = getDriverOption(row.driver);875 {selectedRow ? (
798 const liveProvider = serverProviders.find(876 renderProviderInstance(selectedRow, "editor")
799 (candidate) => candidate.instanceId === row.instanceId,877 ) : (
800 );878 <div className="p-6 text-sm text-muted-foreground">No providers configured.</div>
801 const updateCandidate = liveProvider879 )}
802 ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId)880 </div>
803 : undefined;881 </div>
804 const isDriverUpdateRunning =882
805 updateCandidate !== undefined &&883 <div
806 (updatingProviderDrivers.has(updateCandidate.driver) ||884 inert={readOnly}
807 serverProviders.some(885 aria-disabled={readOnly || undefined}
808 (provider) =>886 className={readOnly ? "opacity-50 select-none" : undefined}
809 provider.driver === updateCandidate.driver && isProviderUpdateActive(provider),887 >
810 ));888 <Collapsible
811 const showInlineUpdateButton =889 open={advancedVisible}
812 updateCandidate !== undefined &&890 onOpenChange={setAdvancedOpen}
813 hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders);891 className="mt-2 border-t border-border/70"
814 const canRunInlineUpdate =892 >
815 updateCandidate !== undefined &&893 <CollapsibleTrigger className="flex h-10 w-full items-center gap-2 px-3 text-xs text-muted-foreground hover:text-foreground sm:px-4">
816 canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) &&894 <ChevronDownIcon
817 !updatingProviderDrivers.has(updateCandidate.driver);895 className={cn("size-3 transition-transform", advancedVisible && "rotate-180")}
818 const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {
819 hiddenModels: [],
820 modelOrder: [],
821 };
822 const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) =>
823 favorite.provider === row.instanceId
824 ? Result.succeed(favorite.model)
825 : Result.failVoid,
826 );
827 const resetLabel = driverOption?.label ?? String(row.driver);
828 const headerAction =
829 row.isDefault && row.isDirty ? (
830 <SettingResetButton
831 label={`${resetLabel} provider settings`}
832 onClick={() => resetDefaultInstance(row.driver)}
833 />896 />
834 ) : null;897 Advanced
835 return (898 </CollapsibleTrigger>
836 <ProviderInstanceCard899 <CollapsibleContent>
837 key={row.instanceId}900 <SettingsRow
838 instanceId={row.instanceId}901 title={
839 instance={row.instance}902 <span className="inline-flex items-center gap-1.5">
840 driverOption={driverOption}903 Health check interval
841 liveProvider={liveProvider}904 <PolicyTooltip>
842 isExpanded={openInstanceDetails[row.instanceId] ?? false}905 This interval is configured here, then the shared Background activity policy
843 onExpandedChange={(open) =>906 decides whether provider probes may run when the timer fires. Custom
844 setOpenInstanceDetails((existing) => ({907 intervals appear as Advanced in General settings.
845 ...existing,908 </PolicyTooltip>
846 [row.instanceId]: open,909 </span>
847 }))
848 }
849 onUpdate={(next) => {
850 const wasEnabled = resolveProviderInstanceEnabled(row.instance);
851 const isDisabling = next.enabled === false && wasEnabled;
852 const shouldClearTextGen = isDisabling && textGenInstanceId === row.instanceId;
853 if (shouldClearTextGen) {
854 updateProviderInstance(row, next, {
855 textGenerationModelSelection:
856 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
857 });
858 } else {
859 updateProviderInstance(row, next);
860 }910 }
861 }}911 description="Set this to 0 seconds to use manual refresh only."
862 onDelete={row.isDefault ? undefined : () => deleteProviderInstance(row.instanceId)}912 resetAction={
863 headerAction={headerAction}913 providerHealthRefreshIntervalSeconds !==
864 hiddenModels={modelPreferences.hiddenModels}914 defaultProviderHealthRefreshIntervalSeconds ? (
865 favoriteModels={favoriteModels}915 <SettingResetButton
866 modelOrder={modelPreferences.modelOrder}916 label="provider health check interval"
867 onHiddenModelsChange={(hiddenModels) =>917 onClick={() =>
868 updateProviderModelPreferences(row.instanceId, {918 updateSettings(
869 ...modelPreferences,919 backgroundActivityOverrideSettings(
870 hiddenModels,920 settings.backgroundActivity,
871 })921 resolvedBackgroundActivity,
872 }922 { providerHealthRefreshInterval: undefined },
873 onFavoriteModelsChange={(favoriteModels) =>923 ),
874 updateProviderFavoriteModels(row.instanceId, favoriteModels)924 )
875 }
876 onModelOrderChange={(modelOrder) =>
877 updateProviderModelPreferences(row.instanceId, {
878 ...modelPreferences,
879 modelOrder,
880 })
881 }
882 onRunUpdate={
883 showInlineUpdateButton && updateCandidate
884 ? () => {
885 if (!canRunInlineUpdate) {
886 return;
887 }925 }
888 void runProviderUpdate(updateCandidate);926 />
889 }927 ) : null
890 : undefined928 }
891 }929 control={
892 isUpdating={showInlineUpdateButton ? isDriverUpdateRunning : undefined}930 <div className="flex shrink-0 items-center gap-2">
893 />931 <NumberField
894 );932 value={providerHealthRefreshIntervalSeconds}
895 })}933 min={0}
934 step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS}
935 size="sm"
936 className="w-32"
937 onValueChange={(value) =>
938 updateSettings(
939 backgroundActivityOverrideSettings(
940 settings.backgroundActivity,
941 resolvedBackgroundActivity,
942 {
943 providerHealthRefreshInterval: Duration.seconds(
944 normalizeIntervalSeconds(value),
945 ),
946 },
947 ),
948 )
949 }
950 >
951 <NumberFieldGroup>
952 <NumberFieldDecrement aria-label="Decrease provider health check interval" />
953 <NumberFieldInput aria-label="Provider health check interval in seconds" />
954 <NumberFieldIncrement aria-label="Increase provider health check interval" />
955 </NumberFieldGroup>
956 </NumberField>
957 <span className="text-xs text-muted-foreground">seconds</span>
958 </div>
959 }
960 />
961 </CollapsibleContent>
962 </Collapsible>
963 </div>
896 </div>964 </div>
897 </SettingsSection>965 </SettingsSection>
898966
apps/web/src/components/settings/providerSettingsTabs.ts created+10
...@@ -0,0 +1,10 @@
1import { cn } from "../../lib/utils";
2
3export function providerSettingsTabClassName(selected: boolean): string {
4 return cn(
5 "relative flex h-full shrink-0 cursor-pointer items-center rounded-sm px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
6 selected
7 ? "text-foreground after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-primary"
8 : "text-muted-foreground hover:text-foreground",
9 );
10}
apps/web/src/components/settings/providerStatus.ts+1-1
...@@ -6,7 +6,7 @@ import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/con...@@ -6,7 +6,7 @@ import type { ServerProvider, ServerProviderVersionAdvisory } from "@t3tools/con
6 */6 */
7export const PROVIDER_STATUS_STYLES = {7export const PROVIDER_STATUS_STYLES = {
8 disabled: {8 disabled: {
9 dot: "bg-amber-400",9 dot: "bg-muted-foreground/50",
10 },10 },
11 error: {11 error: {
12 dot: "bg-destructive",12 dot: "bg-destructive",