1import { SettingsGroup } from "./SettingsGroup";
2import { RefreshIcon } from "~/components/ui/refresh-icon";
3import {
4 AuthSettingsWriteScope,
5 AuthProvidersManageScope,
6 AuthOrchestrationReadScope,
7} from "@t3tools/contracts";
8import { useEnvironmentScope, readEnvironmentScope } from "../../state/session";
9import { useAtomValue } from "@effect/atom-react";
10import { connectionStatusTitle } from "@t3tools/client-runtime/connection";
11import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
12import {
13 isAtomCommandInterrupted,
14 squashAtomCommandFailure,
15} from "@t3tools/client-runtime/state/runtime";
16import {
17 defaultInstanceIdForDriver,
18 type EnvironmentId,
19 type AcpRegistryUrlAuthAction,
20 PROVIDER_DISPLAY_NAMES,
21 ProviderDriverKind,
22 type ProjectId,
23 type ProviderInstanceConfig,
24 type ProviderInstanceId,
25 resolveEnvironmentMachineKind,
26 resolveProjectProviderInstanceEnabled,
27 resolveProviderInstanceEnabled,
28} from "@t3tools/contracts";
29import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
30import {
31 getBackgroundActivityPresetSettings,
32 resolveServerBackgroundActivitySettings,
33} from "@t3tools/shared/backgroundActivitySettings";
34import * as Arr from "effect/Array";
35import * as Duration from "effect/Duration";
36import * as Equal from "effect/Equal";
37import * as Result from "effect/Result";
38import { PlusIcon } from "lucide-react";
39import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
40
41import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
42import {
43 useEnvironmentSettings,
44 usePersistEnvironmentProviderInstanceMutation,
45 useUpdateEnvironmentSettings,
46 useUpdateEnvironmentModelPreferences,
47 useToggleEnvironmentModelFavorite,
48} from "../../hooks/useSettings";
49import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon";
50import { cn } from "../../lib/utils";
51import { resolveAppModelSelectionState } from "../../modelSelection";
52import {
53 useEnvironments,
54 usePrimaryEnvironmentId,
55 type EnvironmentPresentation,
56} from "../../state/environments";
57import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";
58import { useEnvironmentSessionState } from "../../state/session";
59import { useProjects, useServerConfigs } from "../../state/entities";
60import { useAtomCommand } from "../../state/use-atom-command";
61import { getRelativeTimeState } from "../../timestampFormat";
62import {
63 ConnectionStatusDot,
64 connectionPhaseDotClassName,
65 connectionPhasePingClassName,
66} from "../ConnectionStatusDot";
67import {
68 isProviderSettingsUpdateCandidate,
69 isProviderUpdateActive,
70 type ProviderSettingsUpdateCandidate,
71} from "../ProviderUpdateLaunchNotification.logic";
72import { ProviderUpdatesAction } from "../ProviderUpdatesAction";
73import { Button } from "../ui/button";
74import {
75 Empty,
76 EmptyContent,
77 EmptyDescription,
78 EmptyHeader,
79 EmptyMedia,
80 EmptyTitle,
81} from "../ui/empty";
82import {
83 NumberField,
84 NumberFieldDecrement,
85 NumberFieldGroup,
86 NumberFieldIncrement,
87 NumberFieldInput,
88} from "../ui/number-field";
89import { ScrollArea } from "../ui/scroll-area";
90import { Toggle, ToggleGroup } from "../ui/toggle-group";
91import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
92import { stackedThreadToast, toastManager } from "../ui/toast";
93import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog";
94import { ExpandableText } from "./ExpandableText";
95import { ProviderInstanceCard } from "./ProviderInstanceCard";
96import { UsageProviderSettings } from "./UsageProviderSettings";
97import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection";
98import { ProviderAuthenticationSection } from "./ProviderAuthenticationSection";
99import { CodexSetupSection, CodexManagedRuntimeFields } from "./CodexSetupSection";
100import { readCodexSetupMode } from "./CodexSetupSection.logic";
101import { providerClients } from "./providerDriverMeta";
102import { searchableSetting } from "./settingsSearch";
103import {
104 backgroundActivityOverrideSettings,
105 buildProviderInstanceUpdatePatch,
106 durationToSeconds,
107 normalizeIntervalSeconds,
108 PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,
109} from "./SettingsPanels.logic";
110import {
111 PolicyTooltip,
112 SettingResetButton,
113 SettingsPageContainer,
114 SettingsRow,
115 SettingsSection,
116 useRelativeTimeTick,
117 useSettingsSearchTargetId,
118} from "./settingsLayout";
119import {
120 buildProviderEnvironmentOptions,
121 buildProviderInstanceOverridePatch,
122 classifyProviderEnvironmentAccess,
123 isProviderSettingsEnvironmentAvailable,
124 type ProviderEnvironmentAccess,
125 type ProviderOperateAccess,
126 resolveRemoteOperateAccess,
127 resolveSelectedProviderEnvironmentId,
128} from "./ProviderSettingsPanel.logic";
129
130function modelListChanges(current: ReadonlyArray<string>, next: ReadonlyArray<string>) {
131 const currentSet = new Set(current);
132 const nextSet = new Set(next.map((model) => model.trim()).filter(Boolean));
133 return [...new Set([...currentSet, ...nextSet])]
134 .filter((model) => currentSet.has(model) !== nextSet.has(model))
135 .map((model) => ({ model, selected: nextSet.has(model) }));
136}
137
138function providerConfigString(config: unknown, key: string): string | null {
139 if (config === null || typeof config !== "object") return null;
140 const value = (config as Record<string, unknown>)[key];
141 return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
142}
143
144const PROVIDER_SETTINGS = providerClients.definitions.map((definition) => ({
145 provider: definition.driverKind,
146 hasDefaultInstance: definition.hasDefaultInstance !== false,
147}));
148
149function configuredBinaryPath(config: unknown): string {
150 if (config === null || typeof config !== "object" || !("binaryPath" in config)) return "";
151 return typeof config.binaryPath === "string" ? config.binaryPath.trim() : "";
152}
153
154function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) {
155 useRelativeTimeTick();
156 const lastCheckedRelative = getRelativeTimeState(lastCheckedAt);
157
158 if (lastCheckedRelative.status === "missing") {
159 return null;
160 }
161
162 if (lastCheckedRelative.status === "invalid") {
163 return <span>Checked unavailable</span>;
164 }
165
166 return (
167 <span>
168 {lastCheckedRelative.suffix ? (
169 <>
170 Checked <span className="font-mono tabular-nums">{lastCheckedRelative.value}</span>{" "}
171 {lastCheckedRelative.suffix}
172 </>
173 ) : (
174 <>Checked {lastCheckedRelative.value}</>
175 )}
176 </span>
177 );
178}
179
180function providerEnvironmentDetail(environment: EnvironmentPresentation): string {
181 if (environment.entry.target._tag === "PrimaryConnectionTarget") return "Primary device";
182 if (environment.relayManaged) return "T3 Connect";
183 if (environment.entry.target._tag === "SshConnectionTarget") return "SSH";
184 if (isDesktopLocalConnectionTarget(environment.entry.target)) return "Local device";
185 return environment.displayUrl ?? "Remote device";
186}
187
188// Shared by the editor grid and the placeholder states so switching devices
189// never changes the card's footprint.
190const providerCardHeightClassName =
191 "@min-[48rem]/providers:h-[min(44rem,calc(100dvh-11rem))] @min-[48rem]/providers:min-h-[32rem]";
192
193/**
194 * Same chrome as the provider editor (section heading, floating device tabs,
195 * tall card) for states that cannot render provider settings yet.
196 */
197function ProviderSettingsPlaceholder({
198 deviceTabs,
199 icon,
200 title,
201 description,
202 children,
203}: {
204 readonly deviceTabs?: ReactNode;
205 readonly icon: ReactNode;
206 readonly title: string;
207 readonly description: string;
208 readonly children?: ReactNode;
209}) {
210 return (
211 <SettingsSection {...searchableSetting("providers")} variant="plain">
212 {deviceTabs ? (
213 <div className="flex min-h-11 min-w-0 items-center px-3 sm:px-4">{deviceTabs}</div>
214 ) : null}
215 <SettingsGroup
216 divided={false}
217 className={cn(
218 providerCardHeightClassName,
219 "scrollbar-gutter-both flex overflow-x-hidden overflow-y-auto",
220 )}
221 >
222 <Empty>
223 <EmptyMedia variant="icon">{icon}</EmptyMedia>
224 <EmptyHeader>
225 <EmptyTitle>{title}</EmptyTitle>
226 <EmptyDescription>{description}</EmptyDescription>
227 </EmptyHeader>
228 {children ? <EmptyContent className="max-w-xl">{children}</EmptyContent> : null}
229 </Empty>
230 </SettingsGroup>
231 </SettingsSection>
232 );
233}
234
235function EnvironmentUnavailablePlaceholder({
236 environment,
237 access,
238 deviceTabs,
239}: {
240 readonly environment: EnvironmentPresentation;
241 readonly access: Exclude<ProviderEnvironmentAccess, { kind: "editable" | "read-only" }>;
242 readonly deviceTabs?: ReactNode;
243}) {
244 const isLoading = access.kind === "loading";
245 const title = isLoading
246 ? "Loading provider settings"
247 : access.kind === "error"
248 ? "Could not connect to this device"
249 : "Provider settings are unavailable";
250 // Keep the description to a short status; the raw failure can be a
251 // multi-paragraph CLI dump, so it goes below, clamped and expandable.
252 const description = isLoading
253 ? access.reason === "permissions"
254 ? "Checking what this session is allowed to change."
255 : `Waiting for ${environment.label}'s configuration.`
256 : connectionStatusTitle(environment.connection);
257 const error = isLoading ? null : environment.connection.error;
258 // No spinner: this state can persist indefinitely for a wedged device, and a
259 // continuously repainting animation would run the whole time.
260 return (
261 <ProviderSettingsPlaceholder
262 deviceTabs={deviceTabs}
263 icon={
264 <EnvironmentMachineIcon kind={resolveEnvironmentMachineKind(environment.serverConfig)} />
265 }
266 title={title}
267 description={description}
268 >
269 {error ? (
270 <ExpandableText
271 key={environment.environmentId}
272 text={error}
273 className="w-full text-left font-mono text-xs leading-relaxed text-muted-foreground"
274 />
275 ) : null}
276 </ProviderSettingsPlaceholder>
277 );
278}
279
280interface ProviderSettingsTarget {
281 readonly environmentId?: EnvironmentId;
282 readonly instanceId?: ProviderInstanceId;
283 readonly scoped?: boolean;
284 readonly environmentIds?: readonly EnvironmentId[];
285 /**
286 * The project's checkouts on the selected environment, if a project is in
287 * scope. The enable toggle writes each one's override so they never diverge.
288 */
289 readonly projectIds?: readonly ProjectId[];
290}
291
292export function ProviderSettingsPanel(target: ProviderSettingsTarget) {
293 return (
294 <SettingsPageContainer className="@container/providers gap-8">
295 <ProviderSettingsPanelContent
296 key={`${target.environmentId ?? ""}:${target.instanceId ?? ""}`}
297 {...target}
298 />
299 </SettingsPageContainer>
300 );
301}
302
303function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
304 const { environments, isReady } = useEnvironments();
305 const primaryEnvironmentId = usePrimaryEnvironmentId();
306 const searchTargetId = useSettingsSearchTargetId();
307 const options = useMemo(
308 () =>
309 buildProviderEnvironmentOptions(environments, primaryEnvironmentId, target.environmentIds),
310 [environments, primaryEnvironmentId, target.environmentIds],
311 );
312 // Raw user intent; the effective selection is re-derived every render so a
313 // device that drops out of the catalog falls back without erasing the pick —
314 // if it reappears (e.g. after a reconnect) the selection is restored.
315 const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<EnvironmentId | null>(
316 target.environmentId ?? primaryEnvironmentId,
317 );
318 const targetEnvironmentMissing =
319 target.environmentId !== undefined &&
320 selectedEnvironmentId === target.environmentId &&
321 !options.some((environment) => environment.environmentId === target.environmentId);
322 const effectiveEnvironmentId =
323 target.scoped || targetEnvironmentMissing
324 ? target.environmentId
325 : resolveSelectedProviderEnvironmentId(options, selectedEnvironmentId, primaryEnvironmentId);
326 const selectedEnvironment =
327 options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null;
328 const selectedEnvironmentCanRenderSettings =
329 selectedEnvironment !== null &&
330 isProviderSettingsEnvironmentAvailable({
331 connectionPhase: selectedEnvironment.connection.phase,
332 hasServerConfig: selectedEnvironment.serverConfig !== null,
333 });
334 const searchableEnvironmentId = options.find((environment) =>
335 isProviderSettingsEnvironmentAvailable({
336 connectionPhase: environment.connection.phase,
337 hasServerConfig: environment.serverConfig !== null,
338 }),
339 )?.environmentId;
340 const searchableCursorEnvironmentId = options.find(
341 (environment) =>
342 environment.serverConfig?.environment.platform.os === "darwin" &&
343 isProviderSettingsEnvironmentAvailable({
344 connectionPhase: environment.connection.phase,
345 hasServerConfig: true,
346 }),
347 )?.environmentId;
348 useEffect(() => {
349 if (
350 !target.scoped &&
351 searchTargetId === searchableSetting("cursor-keychain-usage").id &&
352 (!selectedEnvironmentCanRenderSettings ||
353 selectedEnvironment?.serverConfig?.environment.platform.os !== "darwin") &&
354 searchableCursorEnvironmentId !== undefined
355 ) {
356 setSelectedEnvironmentId(searchableCursorEnvironmentId);
357 return;
358 }
359 if (
360 !target.scoped &&
361 (searchTargetId === searchableSetting("provider-health-check-interval").id ||
362 searchTargetId === searchableSetting("usage-providers").id) &&
363 !selectedEnvironmentCanRenderSettings &&
364 searchableEnvironmentId !== undefined
365 ) {
366 setSelectedEnvironmentId(searchableEnvironmentId);
367 }
368 }, [
369 searchTargetId,
370 searchableCursorEnvironmentId,
371 searchableEnvironmentId,
372 selectedEnvironment,
373 selectedEnvironmentCanRenderSettings,
374 target.scoped,
375 ]);
376 const onlyPrimaryDevice =
377 options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget";
378 const deviceTabs =
379 !target.scoped && !onlyPrimaryDevice && options.length > 0 ? (
380 <ScrollArea radius="none" hideScrollbars scrollFade className="h-11 min-w-0 flex-1">
381 <ToggleGroup
382 aria-label="Devices"
383 variant="segmented"
384 className="my-2"
385 value={effectiveEnvironmentId ? [effectiveEnvironmentId] : []}
386 onValueChange={(next) => {
387 const environment = options.find((option) => option.environmentId === next[0]);
388 if (environment) setSelectedEnvironmentId(environment.environmentId);
389 }}
390 >
391 {options.map((environment) => {
392 const machine = resolveEnvironmentMachineKind(environment.serverConfig);
393 const detail = providerEnvironmentDetail(environment);
394 const statusText = connectionStatusTitle(environment.connection);
395 return (
396 <Tooltip key={environment.environmentId}>
397 <TooltipTrigger
398 render={
399 <Toggle value={environment.environmentId}>
400 <EnvironmentMachineIcon
401 kind={machine}
402 className="size-3.5 shrink-0"
403 aria-hidden
404 />
405 <span className="max-w-40 truncate">{environment.label}</span>
406 {environment.connection.phase !== "connected" ? (
407 <ConnectionStatusDot
408 dotClassName={connectionPhaseDotClassName(environment.connection.phase)}
409 pingClassName={connectionPhasePingClassName(environment.connection.phase)}
410 />
411 ) : null}
412 <span className="sr-only">
413 {detail}, {statusText}
414 </span>
415 </Toggle>
416 }
417 />
418 <TooltipPopup side="top">
419 {detail} · {statusText}
420 </TooltipPopup>
421 </Tooltip>
422 );
423 })}
424 </ToggleGroup>
425 </ScrollArea>
426 ) : null;
427
428 return (
429 <>
430 {targetEnvironmentMissing ? (
431 <ProviderSettingsPlaceholder
432 deviceTabs={deviceTabs}
433 icon={<EnvironmentMachineIcon kind={resolveEnvironmentMachineKind(null)} />}
434 title="Device unavailable"
435 description="Reconnect this device to set up its provider, or select another device."
436 />
437 ) : null}
438 {options.length === 0 && !targetEnvironmentMissing ? (
439 <ProviderSettingsPlaceholder
440 icon={<EnvironmentMachineIcon kind={resolveEnvironmentMachineKind(null)} />}
441 title={isReady ? "No connected devices" : "Loading devices"}
442 description={
443 isReady
444 ? "Connect an execution environment before configuring providers."
445 : "Reading connected execution environments."
446 }
447 />
448 ) : null}
449
450 {selectedEnvironment ? (
451 <SelectedEnvironmentProviderSettings
452 key={selectedEnvironment.environmentId}
453 environment={selectedEnvironment}
454 deviceTabs={deviceTabs}
455 targetInstanceId={
456 target.environmentId === undefined ||
457 selectedEnvironment.environmentId === target.environmentId
458 ? target.instanceId
459 : undefined
460 }
461 projectIds={target.projectIds ?? []}
462 />
463 ) : null}
464 </>
465 );
466}
467
468function SelectedEnvironmentProviderSettings({
469 environment,
470 deviceTabs,
471 targetInstanceId,
472 projectIds,
473}: {
474 readonly environment: EnvironmentPresentation;
475 readonly deviceTabs?: ReactNode;
476 readonly targetInstanceId?: ProviderInstanceId | undefined;
477 readonly projectIds: readonly ProjectId[];
478}) {
479 return (
480 <RemoteSessionGatedProviderSettings
481 environment={environment}
482 deviceTabs={deviceTabs}
483 targetInstanceId={targetInstanceId}
484 projectIds={projectIds}
485 />
486 );
487}
488
489function RemoteSessionGatedProviderSettings({
490 environment,
491 deviceTabs,
492 targetInstanceId,
493 projectIds,
494}: {
495 readonly environment: EnvironmentPresentation;
496 readonly deviceTabs?: ReactNode;
497 readonly targetInstanceId?: ProviderInstanceId | undefined;
498 readonly projectIds: readonly ProjectId[];
499}) {
500 const sessionState = useEnvironmentSessionState(environment.environmentId);
501 const operateAccess = resolveRemoteOperateAccess({
502 session: sessionState.data,
503 isPending: sessionState.isPending,
504 hasError: sessionState.hasError,
505 });
506 return (
507 <AccessGatedProviderSettings
508 environment={environment}
509 operateAccess={operateAccess}
510 deviceTabs={deviceTabs}
511 targetInstanceId={targetInstanceId}
512 projectIds={projectIds}
513 />
514 );
515}
516
517function AccessGatedProviderSettings({
518 environment,
519 operateAccess,
520 deviceTabs,
521 targetInstanceId,
522 projectIds,
523}: {
524 readonly environment: EnvironmentPresentation;
525 readonly operateAccess: ProviderOperateAccess;
526 readonly deviceTabs?: ReactNode;
527 readonly targetInstanceId?: ProviderInstanceId | undefined;
528 readonly projectIds: readonly ProjectId[];
529}) {
530 const access = classifyProviderEnvironmentAccess({
531 connectionPhase: environment.connection.phase,
532 hasServerConfig: environment.serverConfig !== null,
533 operateAccess,
534 });
535 if (access.kind !== "editable" && access.kind !== "read-only") {
536 return (
537 <EnvironmentUnavailablePlaceholder
538 environment={environment}
539 access={access}
540 deviceTabs={deviceTabs}
541 />
542 );
543 }
544 return (
545 <EnvironmentProviderSettings
546 environmentId={environment.environmentId}
547 environmentLabel={environment.label}
548 readOnly={access.kind === "read-only"}
549 deviceTabs={deviceTabs}
550 targetInstanceId={targetInstanceId}
551 projectIds={projectIds}
552 />
553 );
554}
555
556export function EnvironmentProviderSettings({
557 environmentId,
558 environmentLabel,
559 readOnly = false,
560 deviceTabs,
561 targetInstanceId,
562 projectIds = [],
563}: {
564 readonly environmentId: EnvironmentId;
565 readonly environmentLabel: string;
566 readonly deviceTabs?: ReactNode;
567 readonly targetInstanceId?: ProviderInstanceId | undefined;
568 /** The project's checkouts on this environment, if a project is in scope. */
569 readonly projectIds?: readonly ProjectId[];
570 /**
571 * Grey out and freeze every write control when this session's credential
572 * lacks `providers:manage` on the environment. Selecting providers
573 * still works so the real configuration stays readable; switches, forms,
574 * are inert so no write is offered and then rejected.
575 */
576 readonly readOnly?: boolean;
577}) {
578 const settings = useEnvironmentSettings(environmentId);
579 const canWriteSettings = useEnvironmentScope(environmentId, AuthSettingsWriteScope);
580 const canRefreshProviders = useEnvironmentScope(environmentId, AuthOrchestrationReadScope);
581 const updateSettings = useUpdateEnvironmentSettings(environmentId);
582 const updateModelPreferences = useUpdateEnvironmentModelPreferences(environmentId);
583 const toggleModelFavorite = useToggleEnvironmentModelFavorite(environmentId);
584 const persistProviderInstance = usePersistEnvironmentProviderInstanceMutation(environmentId);
585 const serverProviders =
586 useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS;
587 const projects = useProjects().filter((project) => project.environmentId === environmentId);
588 // Older servers decode an unknown providerInstances key away, so sending
589 // a project override patch would silently do nothing. Hide the switch
590 // instead of offering a control that can never take effect.
591 const supportsProjectProviderOverrides =
592 useServerConfigs().get(environmentId)?.environment.capabilities
593 .projectProviderInstanceOverrides === true;
594 const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, {
595 reportFailure: false,
596 });
597 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {
598 reportFailure: false,
599 });
600 const uninstallAcpRegistryManagedBinary = useAtomCommand(
601 serverEnvironment.uninstallAcpRegistryManagedBinary,
602 { reportFailure: false },
603 );
604 const acceptAcpRegistryUrlAuth = useAtomCommand(serverEnvironment.acceptAcpRegistryUrlAuth, {
605 reportFailure: false,
606 });
607 const [isRefreshingProviders, setIsRefreshingProviders] = useState(false);
608 const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);
609 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(
610 targetInstanceId ?? null,
611 );
612 const [updatingProviderInstanceIds, setUpdatingProviderInstanceIds] = useState<
613 ReadonlySet<ProviderInstanceId>
614 >(() => new Set());
615 const refreshingRef = useRef(false);
616 const updatingInstanceIdsRef = useRef<Set<ProviderInstanceId>>(new Set());
617
618 const acceptUrlAuthentication = useCallback(
619 (instanceId: ProviderInstanceId, action: AcpRegistryUrlAuthAction) => {
620 void acceptAcpRegistryUrlAuth({
621 environmentId,
622 input: { instanceId, elicitationId: action.elicitationId },
623 }).then((result) => {
624 if (result._tag === "Success" && !result.value.accepted) {
625 toastManager.add({
626 type: "warning",
627 title: "Authentication request expired",
628 description: "Refresh the provider and start the authentication flow again.",
629 });
630 return;
631 }
632 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
633 const error = squashAtomCommandFailure(result);
634 toastManager.add({
635 type: "error",
636 title: "Could not continue authentication",
637 description:
638 error instanceof Error ? error.message : "The authentication request expired.",
639 });
640 }
641 });
642 },
643 [acceptAcpRegistryUrlAuth, environmentId],
644 );
645
646 const providerUpdateCandidateByInstanceId = useMemo(
647 () =>
648 new Map(
649 serverProviders
650 .filter(isProviderSettingsUpdateCandidate)
651 .map((candidate) => [candidate.instanceId, candidate]),
652 ),
653 [serverProviders],
654 );
655 const visibleProviderSettings = PROVIDER_SETTINGS.filter(
656 (providerSettings) =>
657 providerSettings.provider !== "cursor" ||
658 serverProviders.some(
659 (provider) =>
660 provider.instanceId === defaultInstanceIdForDriver(ProviderDriverKind.make("cursor")),
661 ),
662 );
663 const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders);
664 const textGenInstanceId = textGenerationModelSelection.instanceId;
665 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
666 const providerHealthPreset = getBackgroundActivityPresetSettings(
667 resolvedBackgroundActivity.profile,
668 ).providerHealthRefreshInterval;
669 const providerHealthRefreshIntervalSeconds = durationToSeconds(
670 resolvedBackgroundActivity.providerHealthRefreshInterval,
671 );
672 const defaultProviderHealthRefreshIntervalSeconds = durationToSeconds(providerHealthPreset);
673 const lastCheckedAt =
674 serverProviders.length > 0
675 ? serverProviders.reduce(
676 (latest, provider) => (provider.checkedAt > latest ? provider.checkedAt : latest),
677 serverProviders[0]!.checkedAt,
678 )
679 : null;
680
681 const refreshProviders = useCallback(() => {
682 if (refreshingRef.current || !readEnvironmentScope(environmentId, AuthOrchestrationReadScope))
683 return;
684 refreshingRef.current = true;
685 setIsRefreshingProviders(true);
686 void (async () => {
687 const result = await refreshServerProviders({
688 environmentId,
689 input: { refreshModels: true },
690 });
691 refreshingRef.current = false;
692 setIsRefreshingProviders(false);
693 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
694 console.warn("Failed to refresh providers", {
695 operation: "refresh-providers",
696 environmentId,
697 ...safeErrorLogAttributes(squashAtomCommandFailure(result)),
698 });
699 }
700 })();
701 }, [environmentId, refreshServerProviders]);
702
703 const runProviderUpdate = useCallback(
704 async (
705 candidate: Pick<ProviderSettingsUpdateCandidate, "driver" | "instanceId">,
706 targetVersion?: string,
707 ) => {
708 if (!readEnvironmentScope(environmentId, AuthProvidersManageScope)) return;
709 // Ref-based re-entry guard, mirroring refreshProviders: a state updater
710 // may run after this function returns, so it cannot gate the dispatch.
711 if (updatingInstanceIdsRef.current.has(candidate.instanceId)) {
712 return;
713 }
714 updatingInstanceIdsRef.current.add(candidate.instanceId);
715 setUpdatingProviderInstanceIds((previous) => new Set(previous).add(candidate.instanceId));
716
717 const result = await updateProvider({
718 environmentId,
719 input: {
720 provider: candidate.driver,
721 instanceId: candidate.instanceId,
722 ...(targetVersion ? { targetVersion } : {}),
723 },
724 });
725 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
726 const error = squashAtomCommandFailure(result);
727 toastManager.add(
728 stackedThreadToast({
729 type: "error",
730 title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`,
731 description:
732 error instanceof Error
733 ? error.message
734 : "The provider update command could not be started.",
735 }),
736 );
737 }
738 updatingInstanceIdsRef.current.delete(candidate.instanceId);
739 setUpdatingProviderInstanceIds((previous) => {
740 if (!previous.has(candidate.instanceId)) {
741 return previous;
742 }
743 const next = new Set(previous);
744 next.delete(candidate.instanceId);
745 return next;
746 });
747 },
748 [environmentId, updateProvider],
749 );
750
751 interface InstanceRow {
752 readonly instanceId: ProviderInstanceId;
753 readonly instance: ProviderInstanceConfig;
754 readonly driver: ProviderDriverKind;
755 readonly isDefault: boolean;
756 readonly isDirty?: boolean;
757 }
758
759 const instancesByDriver = new Map<
760 ProviderDriverKind,
761 Array<[ProviderInstanceId, ProviderInstanceConfig]>
762 >();
763 for (const [rawId, instance] of Object.entries(settings.providerInstances ?? {})) {
764 const driver = instance.driver;
765 const list = instancesByDriver.get(driver) ?? [];
766 list.push([rawId as ProviderInstanceId, instance]);
767 instancesByDriver.set(driver, list);
768 }
769
770 const defaultSlotIdsBySource = new Set<string>(
771 visibleProviderSettings.map((providerSettings) =>
772 String(defaultInstanceIdForDriver(providerSettings.provider)),
773 ),
774 );
775
776 const rows: InstanceRow[] = [];
777 const visibleDriverKinds = new Set<ProviderDriverKind>(
778 visibleProviderSettings.map((providerSettings) => providerSettings.provider),
779 );
780
781 for (const providerSettings of visibleProviderSettings) {
782 const driver = providerSettings.provider;
783 const defaultInstanceId = defaultInstanceIdForDriver(driver);
784 const explicitInstance = settings.providerInstances?.[defaultInstanceId];
785 // An unconfigured default slot runs with the driver's default config.
786 const effectiveInstance: ProviderInstanceConfig = explicitInstance ?? { driver };
787 const isDirty = explicitInstance !== undefined;
788 // Drivers without a default instance list only their configured instances.
789 const hasDefaultSlot = providerSettings.hasDefaultInstance || explicitInstance !== undefined;
790 if (
791 hasDefaultSlot &&
792 (driver === "codex" ||
793 driver === "claudeAgent" ||
794 isDirty ||
795 resolveProviderInstanceEnabled(effectiveInstance) ||
796 defaultInstanceId === targetInstanceId)
797 ) {
798 rows.push({
799 instanceId: defaultInstanceId,
800 instance: effectiveInstance,
801 driver,
802 isDefault: true,
803 isDirty,
804 });
805 }
806 for (const [id, instance] of instancesByDriver.get(providerSettings.provider) ?? []) {
807 if (id === defaultInstanceId) continue;
808 rows.push({ instanceId: id, instance, driver: instance.driver, isDefault: false });
809 }
810 }
811 for (const [driver, list] of instancesByDriver) {
812 if (visibleDriverKinds.has(driver)) continue;
813 for (const [id, instance] of list) {
814 rows.push({
815 instanceId: id,
816 instance,
817 driver: instance.driver,
818 isDefault: defaultSlotIdsBySource.has(String(id)),
819 });
820 }
821 }
822
823 const targetInstanceMissing =
824 targetInstanceId !== undefined &&
825 selectedInstanceId === targetInstanceId &&
826 !rows.some((row) => row.instanceId === targetInstanceId);
827 const selectedRow =
828 rows.find((row) => row.instanceId === selectedInstanceId) ??
829 (targetInstanceMissing ? null : (rows[0] ?? null));
830
831 const updateProviderInstance = async (
832 row: InstanceRow,
833 next: ProviderInstanceConfig,
834 options?: {
835 readonly textGenerationModelSelection?: Parameters<
836 typeof buildProviderInstanceUpdatePatch
837 >[0]["textGenerationModelSelection"];
838 },
839 ) => {
840 const { providerInstances: _providerInstances, ...patch } = buildProviderInstanceUpdatePatch({
841 settings,
842 instanceId: row.instanceId,
843 instance: next,
844 textGenerationModelSelection: options?.textGenerationModelSelection,
845 });
846 const result = await persistProviderInstance(
847 { operation: "upsert", instanceId: row.instanceId, instance: next },
848 patch,
849 );
850 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
851 const error = squashAtomCommandFailure(result);
852 toastManager.add({
853 type: "error",
854 title: "Could not update provider instance",
855 description: error instanceof Error ? error.message : "The settings update failed.",
856 });
857 }
858 };
859
860 const deleteProviderInstance = async (row: InstanceRow) => {
861 const updateResult = await persistProviderInstance({
862 operation: "remove",
863 instanceId: row.instanceId,
864 });
865 if (updateResult._tag === "Failure") {
866 const error = squashAtomCommandFailure(updateResult);
867 toastManager.add({
868 type: "error",
869 title: "Could not delete provider instance",
870 description: error instanceof Error ? error.message : "The settings update failed.",
871 });
872 return;
873 }
874
875 if (row.driver !== ProviderDriverKind.make("acpRegistry")) return;
876 if (providerConfigString(row.instance.config, "source") === "local") return;
877 const agentId = providerConfigString(row.instance.config, "agentId");
878 if (agentId === null) return;
879
880 // The server decides from its latest settings whether this was the last
881 // instance using the managed agent. A client-side snapshot check can race
882 // two removals and make both callers skip cleanup.
883 const uninstallResult = await uninstallAcpRegistryManagedBinary({
884 environmentId,
885 input: { agentId },
886 });
887 if (uninstallResult._tag === "Failure" && !isAtomCommandInterrupted(uninstallResult)) {
888 const error = squashAtomCommandFailure(uninstallResult);
889 toastManager.add({
890 type: "warning",
891 title: "Provider deleted, but managed files remain",
892 description: error instanceof Error ? error.message : "Managed binary cleanup failed.",
893 });
894 }
895 };
896
897 const resetDefaultInstance = async (driverKind: ProviderDriverKind) => {
898 const result = await persistProviderInstance({
899 operation: "remove",
900 instanceId: defaultInstanceIdForDriver(driverKind),
901 });
902 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
903 const error = squashAtomCommandFailure(result);
904 toastManager.add({
905 type: "error",
906 title: "Could not reset provider instance",
907 description: error instanceof Error ? error.message : "The settings update failed.",
908 });
909 }
910 };
911
912 const renderProviderInstance = (row: InstanceRow, mode: "list" | "editor") => {
913 const driverOption = providerClients.get(row.driver);
914 const liveProvider = serverProviders.find(
915 (candidate) => candidate.instanceId === row.instanceId,
916 );
917 const updateCandidate = providerUpdateCandidateByInstanceId.get(row.instanceId);
918 const isInstanceUpdateRunning =
919 updatingProviderInstanceIds.has(row.instanceId) ||
920 (liveProvider !== undefined && isProviderUpdateActive(liveProvider));
921 const showInlineUpdateButton = updateCandidate !== undefined;
922 const canRunInlineUpdate = updateCandidate !== undefined && !isInstanceUpdateRunning;
923 const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? {
924 hiddenModels: [],
925 modelOrder: [],
926 };
927 const favoriteModels = Arr.filterMap(settings.favorites ?? [], (favorite) =>
928 favorite.provider === row.instanceId ? Result.succeed(favorite.model) : Result.failVoid,
929 );
930 const resetLabel = driverOption?.label ?? String(row.driver);
931 const representativeProjectId = projectIds[0];
932 const writeProjectOverride = (next: boolean | undefined) => {
933 updateSettings({
934 projectSettingsOverrides: buildProviderInstanceOverridePatch(
935 settings,
936 projectIds,
937 row.instanceId,
938 next,
939 ),
940 });
941 };
942 const projectOverride =
943 representativeProjectId === undefined || !supportsProjectProviderOverrides
944 ? undefined
945 : {
946 value:
947 settings.projectSettingsOverrides[representativeProjectId]?.providerInstances?.[
948 row.instanceId
949 ]?.enabled,
950 effectiveEnabled: resolveProjectProviderInstanceEnabled(
951 settings,
952 representativeProjectId,
953 row.instanceId,
954 ),
955 onChange: (next: boolean) => writeProjectOverride(next),
956 onReset: () => writeProjectOverride(undefined),
957 };
958
959 return (
960 <ProviderInstanceCard
961 key={row.instanceId}
962 environmentId={environmentId}
963 acpProjects={projects}
964 projectOverride={projectOverride}
965 onAcceptUrlAuth={
966 readOnly ? undefined : (action) => acceptUrlAuthentication(row.instanceId, action)
967 }
968 instanceId={row.instanceId}
969 instance={row.instance}
970 driverOption={driverOption}
971 liveProvider={liveProvider}
972 mode={mode}
973 selected={mode === "list" && selectedRow?.instanceId === row.instanceId}
974 onSelect={mode === "list" ? () => setSelectedInstanceId(row.instanceId) : undefined}
975 readOnly={readOnly}
976 runtime={
977 mode === "editor" &&
978 row.driver === "codex" &&
979 readCodexSetupMode(row.instance.config) === "managed" ? (
980 <CodexManagedRuntimeFields
981 environmentId={environmentId}
982 instanceId={row.instanceId}
983 provider={liveProvider}
984 />
985 ) : undefined
986 }
987 canWriteSettings={canWriteSettings}
988 setup={
989 mode === "editor" && row.driver === "antigravity" ? (
990 <ProviderSetupSection
991 environmentId={environmentId}
992 environmentLabel={environmentLabel}
993 instanceId={row.instanceId}
994 provider={liveProvider}
995 binaryPath={configuredBinaryPath(row.instance.config)}
996 authMethod={readAntigravityAuthMethod(row.instance.config)}
997 enabled={resolveProviderInstanceEnabled(row.instance)}
998 readOnly={readOnly}
999 onEnable={() => updateProviderInstance(row, { ...row.instance, enabled: true })}
1000 />
1001 ) : mode === "editor" &&
1002 row.driver === "codex" &&
1003 readCodexSetupMode(row.instance.config) === "managed" ? (
1004 <CodexSetupSection
1005 environmentId={environmentId}
1006 instanceId={row.instanceId}
1007 provider={liveProvider}
1008 mode={readCodexSetupMode(row.instance.config)}
1009 enabled={resolveProviderInstanceEnabled(row.instance)}
1010 readOnly={readOnly}
1011 onModeChange={(setupMode) =>
1012 updateProviderInstance(row, {
1013 ...row.instance,
1014 enabled: true,
1015 config: {
1016 ...(row.instance.config !== null && typeof row.instance.config === "object"
1017 ? row.instance.config
1018 : {}),
1019 enabled: true,
1020 setupMode,
1021 },
1022 })
1023 }
1024 />
1025 ) : mode === "editor" &&
1026 !readOnly &&
1027 liveProvider &&
1028 (liveProvider.setup?.canAuthenticate ||
1029 (liveProvider.driver === "acpRegistry" && liveProvider.installed)) ? (
1030 <ProviderAuthenticationSection
1031 key={`${environmentId}:${row.instanceId}`}
1032 environmentId={environmentId}
1033 environmentLabel={environmentLabel}
1034 instanceId={row.instanceId}
1035 provider={liveProvider}
1036 readOnly={readOnly}
1037 />
1038 ) : mode === "editor" &&
1039 !readOnly &&
1040 row.driver === "cursor" &&
1041 liveProvider?.setup?.canAuthenticate === false ? (
1042 <SettingsRow
1043 title="Cursor account"
1044 description="Using CURSOR_API_KEY. Remove it from this provider's environment to use browser sign-in."
1045 />
1046 ) : null
1047 }
1048 onUpdate={(next) => {
1049 const wasEnabled = resolveProviderInstanceEnabled(row.instance);
1050 const isDisabling = next.enabled === false && wasEnabled;
1051 const shouldClearTextGen =
1052 isDisabling &&
1053 textGenInstanceId === row.instanceId &&
1054 readEnvironmentScope(environmentId, AuthSettingsWriteScope);
1055 updateProviderInstance(
1056 row,
1057 next,
1058 shouldClearTextGen
1059 ? {
1060 textGenerationModelSelection:
1061 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
1062 }
1063 : undefined,
1064 );
1065 }}
1066 onDelete={
1067 mode === "editor" && !row.isDefault ? () => deleteProviderInstance(row) : undefined
1068 }
1069 headerAction={
1070 mode === "editor" && row.isDefault && row.isDirty ? (
1071 <SettingResetButton
1072 label={`${resetLabel} provider settings`}
1073 onClick={() => resetDefaultInstance(row.driver)}
1074 />
1075 ) : null
1076 }
1077 hiddenModels={modelPreferences.hiddenModels}
1078 favoriteModels={favoriteModels}
1079 modelOrder={modelPreferences.modelOrder}
1080 onHiddenModelsChange={(next, affectedModels) => {
1081 const hidden = new Set(next);
1082 const models =
1083 affectedModels ??
1084 modelListChanges(modelPreferences.hiddenModels, next).map((change) => change.model);
1085 void updateModelPreferences({
1086 setModelsHidden: models.map((model) => ({
1087 provider: row.instanceId,
1088 model,
1089 hidden: hidden.has(model),
1090 })),
1091 });
1092 }}
1093 onToggleFavorite={(model) => {
1094 void toggleModelFavorite(row.instanceId, model);
1095 }}
1096 onFavoriteModelsChange={(next) => {
1097 void updateModelPreferences({
1098 setModelFavorites: modelListChanges(favoriteModels, next).map(
1099 ({ model, selected }) => ({ provider: row.instanceId, model, favorite: selected }),
1100 ),
1101 });
1102 }}
1103 onModelOrderChange={(modelOrder) => {
1104 void updateModelPreferences({
1105 setProviderModelOrder: { provider: row.instanceId, modelOrder },
1106 });
1107 }}
1108 onInstallRecommended={
1109 !readOnly &&
1110 liveProvider?.compatibilityAdvisory?.message &&
1111 liveProvider.compatibilityAdvisory.recommendedVersion &&
1112 liveProvider.versionAdvisory?.canInstallVersion
1113 ? () => {
1114 void runProviderUpdate(
1115 liveProvider,
1116 liveProvider.compatibilityAdvisory?.recommendedVersion ?? undefined,
1117 );
1118 }
1119 : undefined
1120 }
1121 onRunUpdate={
1122 !readOnly && showInlineUpdateButton && updateCandidate
1123 ? () => {
1124 if (canRunInlineUpdate) void runProviderUpdate(updateCandidate);
1125 }
1126 : undefined
1127 }
1128 isUpdating={isInstanceUpdateRunning}
1129 />
1130 );
1131 };
1132
1133 return (
1134 <>
1135 <SettingsSection
1136 {...searchableSetting("providers")}
1137 variant="plain"
1138 titleAction={
1139 !readOnly ? (
1140 <Tooltip>
1141 <TooltipTrigger
1142 render={
1143 <Button
1144 size="icon-xs"
1145 variant="ghost-muted"
1146 onClick={() => setIsAddInstanceDialogOpen(true)}
1147 aria-label="Add provider"
1148 >
1149 <PlusIcon />
1150 </Button>
1151 }
1152 />
1153 <TooltipPopup side="top">Add provider</TooltipPopup>
1154 </Tooltip>
1155 ) : null
1156 }
1157 headerAction={
1158 <div className="flex min-w-0 items-center gap-2">
1159 <ProviderUpdatesAction />
1160 <Tooltip>
1161 <TooltipTrigger
1162 render={
1163 <Button
1164 size="xs"
1165 variant="ghost-muted"
1166 disabled={isRefreshingProviders || !canRefreshProviders}
1167 aria-busy={isRefreshingProviders}
1168 onClick={() => void refreshProviders()}
1169 >
1170 <RefreshIcon refreshing={isRefreshingProviders} />
1171 <span className="sr-only">Refresh provider status</span>
1172 <span className="hidden min-w-0 truncate sm:inline">
1173 {isRefreshingProviders ? (
1174 "Refreshing providers"
1175 ) : (
1176 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
1177 )}
1178 </span>
1179 </Button>
1180 }
1181 />
1182 <TooltipPopup side="top">Refresh provider status</TooltipPopup>
1183 </Tooltip>
1184 </div>
1185 }
1186 >
1187 {deviceTabs ? (
1188 <div className="flex min-h-11 min-w-0 items-center gap-2 px-3 sm:px-4">{deviceTabs}</div>
1189 ) : null}
1190 {readOnly ? (
1191 <SettingsGroup divided={false} className="overflow-hidden">
1192 <SettingsRow
1193 title="Limited permissions"
1194 description={`This session can view ${environmentLabel}'s providers but can't change their settings.`}
1195 />
1196 </SettingsGroup>
1197 ) : null}
1198 <SettingsGroup
1199 divided={false}
1200 className={cn(
1201 providerCardHeightClassName,
1202 "overflow-hidden @min-[48rem]/providers:grid @min-[48rem]/providers:grid-cols-[17rem_minmax(0,1fr)]",
1203 )}
1204 >
1205 <div className="border-b border-border/60 bg-muted/10 @min-[48rem]/providers:flex @min-[48rem]/providers:min-h-0 @min-[48rem]/providers:flex-col @min-[48rem]/providers:border-r @min-[48rem]/providers:border-b-0">
1206 <ScrollArea
1207 scrollFade
1208 chainVerticalScroll
1209 className="@min-[48rem]/providers:min-h-0 @min-[48rem]/providers:flex-1"
1210 >
1211 <div className="divide-y divide-border/50">
1212 {rows.map((row) => renderProviderInstance(row, "list"))}
1213 {!readOnly ? (
1214 <button
1215 type="button"
1216 className="flex w-full cursor-pointer items-center gap-3 px-3 py-3 text-left text-sm text-muted-foreground transition-colors outline-none hover:bg-muted/25 hover:text-foreground focus-visible:bg-muted/25 focus-visible:text-foreground sm:px-4"
1217 onClick={() => setIsAddInstanceDialogOpen(true)}
1218 >
1219 <PlusIcon className="size-4 shrink-0" />
1220 Add provider
1221 </button>
1222 ) : null}
1223 </div>
1224 </ScrollArea>
1225 </div>
1226
1227 <div className="min-w-0 @min-[48rem]/providers:min-h-0">
1228 {selectedRow ? (
1229 <ScrollArea scrollFade chainVerticalScroll className="@min-[48rem]/providers:h-full">
1230 <div className="space-y-6 p-4">{renderProviderInstance(selectedRow, "editor")}</div>
1231 </ScrollArea>
1232 ) : (
1233 <div className="p-6 text-sm text-muted-foreground">
1234 {targetInstanceMissing
1235 ? "This provider instance is no longer available on this device."
1236 : "No providers configured."}
1237 </div>
1238 )}
1239 </div>
1240 </SettingsGroup>
1241 </SettingsSection>
1242
1243 <UsageProviderSettings
1244 key={environmentId}
1245 environmentId={environmentId}
1246 environmentLabel={environmentLabel}
1247 sources={settings.usageLimitSources}
1248 cursorKeychainUsageEnabled={settings.cursorKeychainUsageEnabled}
1249 readOnly={readOnly}
1250 />
1251
1252 <SettingsSection title="Advanced">
1253 <SettingsRow
1254 id={searchableSetting("provider-health-check-interval").id}
1255 title={
1256 <span className="inline-flex items-center gap-1.5">
1257 {searchableSetting("provider-health-check-interval").title}
1258 <PolicyTooltip>
1259 This interval is configured here, then the shared Background activity policy decides
1260 whether provider probes may run when the timer fires. Custom intervals appear as
1261 Advanced in General settings.
1262 </PolicyTooltip>
1263 </span>
1264 }
1265 description="Refresh provider status, versions, and models in the background. Set to 0 to disable."
1266 resetAction={
1267 providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? (
1268 <span
1269 inert={!canWriteSettings}
1270 className={!canWriteSettings ? "opacity-50" : undefined}
1271 >
1272 <SettingResetButton
1273 label="provider health check interval"
1274 onClick={() =>
1275 updateSettings(
1276 backgroundActivityOverrideSettings(
1277 settings.backgroundActivity,
1278 resolvedBackgroundActivity,
1279 { providerHealthRefreshInterval: undefined },
1280 ),
1281 )
1282 }
1283 />
1284 </span>
1285 ) : null
1286 }
1287 control={
1288 <div
1289 inert={!canWriteSettings}
1290 aria-disabled={!canWriteSettings || undefined}
1291 className={cn(
1292 "flex shrink-0 items-center gap-2",
1293 !canWriteSettings && "opacity-50 select-none",
1294 )}
1295 >
1296 <NumberField
1297 value={providerHealthRefreshIntervalSeconds}
1298 min={0}
1299 step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS}
1300 size="sm"
1301 className="w-32"
1302 onValueChange={(value) =>
1303 updateSettings(
1304 backgroundActivityOverrideSettings(
1305 settings.backgroundActivity,
1306 resolvedBackgroundActivity,
1307 {
1308 providerHealthRefreshInterval: Duration.seconds(
1309 normalizeIntervalSeconds(value),
1310 ),
1311 },
1312 ),
1313 )
1314 }
1315 >
1316 <NumberFieldGroup>
1317 <NumberFieldDecrement aria-label="Decrease provider health check interval" />
1318 <NumberFieldInput aria-label="Provider health check interval in seconds" />
1319 <NumberFieldIncrement aria-label="Increase provider health check interval" />
1320 </NumberFieldGroup>
1321 </NumberField>
1322 <span className="text-xs text-muted-foreground">seconds</span>
1323 </div>
1324 }
1325 />
1326 </SettingsSection>
1327
1328 {isAddInstanceDialogOpen && !readOnly ? (
1329 <AddProviderInstanceDialog
1330 open
1331 environmentId={environmentId}
1332 environmentLabel={environmentLabel}
1333 onOpenChange={setIsAddInstanceDialogOpen}
1334 onCreated={setSelectedInstanceId}
1335 />
1336 ) : null}
1337 </>
1338 );
1339}