| 1 | import { SettingsGroup } from "./SettingsGroup"; |
| 2 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 3 | import { |
| 4 | AuthSettingsWriteScope, |
| 5 | AuthProvidersManageScope, |
| 6 | AuthOrchestrationReadScope, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import { useEnvironmentScope, readEnvironmentScope } from "../../state/session"; |
| 9 | import { useAtomValue } from "@effect/atom-react"; |
| 10 | import { connectionStatusTitle } from "@t3tools/client-runtime/connection"; |
| 11 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
| 12 | import { |
| 13 | isAtomCommandInterrupted, |
| 14 | squashAtomCommandFailure, |
| 15 | } from "@t3tools/client-runtime/state/runtime"; |
| 16 | import { |
| 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"; |
| 29 | import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; |
| 30 | import { |
| 31 | getBackgroundActivityPresetSettings, |
| 32 | resolveServerBackgroundActivitySettings, |
| 33 | } from "@t3tools/shared/backgroundActivitySettings"; |
| 34 | import * as Arr from "effect/Array"; |
| 35 | import * as Duration from "effect/Duration"; |
| 36 | import * as Equal from "effect/Equal"; |
| 37 | import * as Result from "effect/Result"; |
| 38 | import { PlusIcon } from "lucide-react"; |
| 39 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 40 | |
| 41 | import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; |
| 42 | import { |
| 43 | useEnvironmentSettings, |
| 44 | usePersistEnvironmentProviderInstanceMutation, |
| 45 | useUpdateEnvironmentSettings, |
| 46 | useUpdateEnvironmentModelPreferences, |
| 47 | useToggleEnvironmentModelFavorite, |
| 48 | } from "../../hooks/useSettings"; |
| 49 | import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; |
| 50 | import { cn } from "../../lib/utils"; |
| 51 | import { resolveAppModelSelectionState } from "../../modelSelection"; |
| 52 | import { |
| 53 | useEnvironments, |
| 54 | usePrimaryEnvironmentId, |
| 55 | type EnvironmentPresentation, |
| 56 | } from "../../state/environments"; |
| 57 | import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; |
| 58 | import { useEnvironmentSessionState } from "../../state/session"; |
| 59 | import { useProjects, useServerConfigs } from "../../state/entities"; |
| 60 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 61 | import { getRelativeTimeState } from "../../timestampFormat"; |
| 62 | import { |
| 63 | ConnectionStatusDot, |
| 64 | connectionPhaseDotClassName, |
| 65 | connectionPhasePingClassName, |
| 66 | } from "../ConnectionStatusDot"; |
| 67 | import { |
| 68 | isProviderSettingsUpdateCandidate, |
| 69 | isProviderUpdateActive, |
| 70 | type ProviderSettingsUpdateCandidate, |
| 71 | } from "../ProviderUpdateLaunchNotification.logic"; |
| 72 | import { ProviderUpdatesAction } from "../ProviderUpdatesAction"; |
| 73 | import { Button } from "../ui/button"; |
| 74 | import { |
| 75 | Empty, |
| 76 | EmptyContent, |
| 77 | EmptyDescription, |
| 78 | EmptyHeader, |
| 79 | EmptyMedia, |
| 80 | EmptyTitle, |
| 81 | } from "../ui/empty"; |
| 82 | import { |
| 83 | NumberField, |
| 84 | NumberFieldDecrement, |
| 85 | NumberFieldGroup, |
| 86 | NumberFieldIncrement, |
| 87 | NumberFieldInput, |
| 88 | } from "../ui/number-field"; |
| 89 | import { ScrollArea } from "../ui/scroll-area"; |
| 90 | import { Toggle, ToggleGroup } from "../ui/toggle-group"; |
| 91 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 92 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 93 | import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; |
| 94 | import { ExpandableText } from "./ExpandableText"; |
| 95 | import { ProviderInstanceCard } from "./ProviderInstanceCard"; |
| 96 | import { UsageProviderSettings } from "./UsageProviderSettings"; |
| 97 | import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection"; |
| 98 | import { ProviderAuthenticationSection } from "./ProviderAuthenticationSection"; |
| 99 | import { CodexSetupSection, CodexManagedRuntimeFields } from "./CodexSetupSection"; |
| 100 | import { readCodexSetupMode } from "./CodexSetupSection.logic"; |
| 101 | import { providerClients } from "./providerDriverMeta"; |
| 102 | import { searchableSetting } from "./settingsSearch"; |
| 103 | import { |
| 104 | backgroundActivityOverrideSettings, |
| 105 | buildProviderInstanceUpdatePatch, |
| 106 | durationToSeconds, |
| 107 | normalizeIntervalSeconds, |
| 108 | PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, |
| 109 | } from "./SettingsPanels.logic"; |
| 110 | import { |
| 111 | PolicyTooltip, |
| 112 | SettingResetButton, |
| 113 | SettingsPageContainer, |
| 114 | SettingsRow, |
| 115 | SettingsSection, |
| 116 | useRelativeTimeTick, |
| 117 | useSettingsSearchTargetId, |
| 118 | } from "./settingsLayout"; |
| 119 | import { |
| 120 | buildProviderEnvironmentOptions, |
| 121 | buildProviderInstanceOverridePatch, |
| 122 | classifyProviderEnvironmentAccess, |
| 123 | isProviderSettingsEnvironmentAvailable, |
| 124 | type ProviderEnvironmentAccess, |
| 125 | type ProviderOperateAccess, |
| 126 | resolveRemoteOperateAccess, |
| 127 | resolveSelectedProviderEnvironmentId, |
| 128 | } from "./ProviderSettingsPanel.logic"; |
| 129 | |
| 130 | function 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 | |
| 138 | function 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 | |
| 144 | const PROVIDER_SETTINGS = providerClients.definitions.map((definition) => ({ |
| 145 | provider: definition.driverKind, |
| 146 | hasDefaultInstance: definition.hasDefaultInstance !== false, |
| 147 | })); |
| 148 | |
| 149 | function 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 | |
| 154 | function 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 | |
| 180 | function 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. |
| 190 | const 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 | */ |
| 197 | function 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 | |
| 235 | function 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 | |
| 280 | interface 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 | |
| 292 | export 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 | |
| 303 | function 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 | |
| 468 | function 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 | |
| 489 | function 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 | |
| 517 | function 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 | |
| 556 | export 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 | } |