| 1 | "use client"; |
| 2 | |
| 3 | import { Spinner } from "~/components/ui/spinner"; |
| 4 | |
| 5 | import { |
| 6 | AlertTriangleIcon, |
| 7 | CheckIcon, |
| 8 | ArrowUpCircleIcon, |
| 9 | CopyIcon, |
| 10 | DownloadIcon, |
| 11 | ExternalLinkIcon, |
| 12 | LayersIcon, |
| 13 | PlusIcon, |
| 14 | Trash2Icon, |
| 15 | XIcon, |
| 16 | } from "lucide-react"; |
| 17 | import { Lock as LockGlyph, LockOpen } from "lucide"; |
| 18 | import * as Arr from "effect/Array"; |
| 19 | import * as Result from "effect/Result"; |
| 20 | import { useEffect, useRef, useState, type ReactElement, type ReactNode } from "react"; |
| 21 | import { |
| 22 | isProviderDriverKind, |
| 23 | resolveProviderInstanceEnabled, |
| 24 | type ProviderInstanceConfig, |
| 25 | type ProviderInstanceEnvironmentVariable, |
| 26 | type ProviderInstanceId, |
| 27 | type AcpRegistryUrlAuthAction, |
| 28 | type EnvironmentId, |
| 29 | type ProjectId, |
| 30 | type ProviderDriverKind, |
| 31 | type ServerProvider, |
| 32 | type ServerProviderModel, |
| 33 | } from "@t3tools/contracts"; |
| 34 | |
| 35 | import { |
| 36 | type CustomModelDefinition, |
| 37 | readCustomModelEntries, |
| 38 | toCustomModelSetting, |
| 39 | } from "@t3tools/shared/model"; |
| 40 | import { cn } from "../../lib/utils"; |
| 41 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; |
| 42 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 43 | import { Badge } from "../ui/badge"; |
| 44 | import { Button } from "../ui/button"; |
| 45 | import { MorphIcon } from "~/components/MorphIcon"; |
| 46 | import { DraftInput } from "../ui/draft-input"; |
| 47 | import { SecretInput } from "./SecretInput"; |
| 48 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 49 | import { Switch } from "../ui/switch"; |
| 50 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 51 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 52 | import type { |
| 53 | ProviderClientDefinition, |
| 54 | ProviderEnvironmentField, |
| 55 | } from "@t3tools/provider-core/client"; |
| 56 | import { deriveProviderSettingsFields, ProviderSettingsForm } from "./ProviderSettingsForm"; |
| 57 | import { ProviderModelsSection } from "./ProviderModelsSection"; |
| 58 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 59 | import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; |
| 60 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 61 | import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; |
| 62 | import { AcpSessionManagementSection } from "./AcpSessionManagementSection"; |
| 63 | import { FoldedSettingsSection } from "./FoldedSettingsSection"; |
| 64 | import { readCodexSetupMode } from "./CodexSetupSection.logic"; |
| 65 | import { |
| 66 | getProviderVersionAdvisoryPresentation, |
| 67 | PROVIDER_STATUS_STYLES, |
| 68 | getProviderSummary, |
| 69 | getProviderVersionLabel, |
| 70 | type ProviderStatusKey, |
| 71 | } from "./providerStatus"; |
| 72 | |
| 73 | const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; |
| 74 | |
| 75 | function ProviderStatusDiagnostic({ |
| 76 | detail, |
| 77 | children, |
| 78 | }: { |
| 79 | detail: string | null; |
| 80 | children: ReactElement; |
| 81 | }) { |
| 82 | if (!detail) return children; |
| 83 | return ( |
| 84 | <Tooltip> |
| 85 | <TooltipTrigger render={children} /> |
| 86 | <TooltipPopup side="top">{detail}</TooltipPopup> |
| 87 | </Tooltip> |
| 88 | ); |
| 89 | } |
| 90 | |
| 91 | let environmentVariableDraftId = 0; |
| 92 | const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; |
| 93 | |
| 94 | type EnvironmentDraftRow = { |
| 95 | readonly id: string; |
| 96 | readonly name: string; |
| 97 | readonly value: string; |
| 98 | readonly sensitive: boolean; |
| 99 | readonly valueRedacted?: boolean; |
| 100 | }; |
| 101 | |
| 102 | function makeEnvironmentDraftRow( |
| 103 | variable: ProviderInstanceEnvironmentVariable, |
| 104 | index: number, |
| 105 | ): EnvironmentDraftRow { |
| 106 | return { |
| 107 | id: `${index}:${variable.name}`, |
| 108 | name: variable.name, |
| 109 | value: variable.value, |
| 110 | sensitive: variable.sensitive, |
| 111 | ...(variable.valueRedacted !== undefined ? { valueRedacted: variable.valueRedacted } : {}), |
| 112 | }; |
| 113 | } |
| 114 | |
| 115 | function providerEnvironmentsEqual( |
| 116 | left: ReadonlyArray<ProviderInstanceEnvironmentVariable>, |
| 117 | right: ReadonlyArray<ProviderInstanceEnvironmentVariable>, |
| 118 | ): boolean { |
| 119 | return ( |
| 120 | left.length === right.length && |
| 121 | left.every((variable, index) => { |
| 122 | const other = right[index]; |
| 123 | return ( |
| 124 | other !== undefined && |
| 125 | variable.name === other.name && |
| 126 | variable.value === other.value && |
| 127 | variable.sensitive === other.sensitive && |
| 128 | variable.valueRedacted === other.valueRedacted |
| 129 | ); |
| 130 | }) |
| 131 | ); |
| 132 | } |
| 133 | |
| 134 | /** |
| 135 | * Read `customModels` from the opaque config blob. The concrete driver |
| 136 | * schemas type it as `CustomModelSetting[]`, but it arrives here as |
| 137 | * `Schema.Unknown`, so the shared reader does the shape checking. |
| 138 | */ |
| 139 | function readConfigCustomModels(config: unknown): ReadonlyArray<CustomModelDefinition> { |
| 140 | if (config === null || typeof config !== "object") return []; |
| 141 | return readCustomModelEntries((config as Record<string, unknown>).customModels); |
| 142 | } |
| 143 | |
| 144 | function readConfigString(config: unknown, key: string): string | null { |
| 145 | if (config === null || typeof config !== "object") return null; |
| 146 | const value = (config as Record<string, unknown>)[key]; |
| 147 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * Set `key` to an arbitrary value on the opaque config blob. Unlike |
| 152 | * provider settings field updates, does not drop empty-looking values — the |
| 153 | * caller is responsible for deciding whether an empty array / empty |
| 154 | * object should be stored explicitly (e.g. `customModels: []` is a |
| 155 | * meaningful "user cleared their custom list" state distinct from |
| 156 | * "driver default"). |
| 157 | */ |
| 158 | function nextConfigBlobWithValue( |
| 159 | config: unknown, |
| 160 | key: string, |
| 161 | value: unknown, |
| 162 | ): Record<string, unknown> { |
| 163 | const base: Record<string, unknown> = |
| 164 | config !== null && typeof config === "object" ? { ...(config as Record<string, unknown>) } : {}; |
| 165 | base[key] = value; |
| 166 | return base; |
| 167 | } |
| 168 | |
| 169 | /** |
| 170 | * Custom rows come from current settings so name/descriptor edits show |
| 171 | * instantly; a bare entry falls back to the live row's driver-default |
| 172 | * capabilities (the server fills those in on its next probe). |
| 173 | */ |
| 174 | export function deriveProviderModelsForDisplay(input: { |
| 175 | readonly liveModels: ReadonlyArray<ServerProviderModel> | undefined; |
| 176 | readonly customModels: ReadonlyArray<CustomModelDefinition>; |
| 177 | }): ReadonlyArray<ServerProviderModel> { |
| 178 | const liveCustomModelsBySlug = new Map( |
| 179 | Arr.filterMap(input.liveModels ?? [], (model) => |
| 180 | model.isCustom ? Result.succeed([model.slug, model] as const) : Result.failVoid, |
| 181 | ), |
| 182 | ); |
| 183 | const serverModels = input.liveModels?.filter((model) => !model.isCustom) ?? []; |
| 184 | const serverSlugs = new Set(serverModels.map((model) => model.slug)); |
| 185 | // A custom entry with a built-in slug only renames that built-in on the |
| 186 | // server, so the built-in row already shows it. |
| 187 | const customModels = input.customModels.flatMap((entry) => |
| 188 | serverSlugs.has(entry.slug) |
| 189 | ? [] |
| 190 | : [ |
| 191 | { |
| 192 | slug: entry.slug, |
| 193 | name: entry.name, |
| 194 | isCustom: true, |
| 195 | capabilities: |
| 196 | entry.capabilities ?? liveCustomModelsBySlug.get(entry.slug)?.capabilities ?? null, |
| 197 | }, |
| 198 | ], |
| 199 | ); |
| 200 | return [...serverModels, ...customModels]; |
| 201 | } |
| 202 | |
| 203 | function ProviderAuthEmail(props: { readonly email: string | undefined }) { |
| 204 | const email = props.email?.trim(); |
| 205 | if (!email) return null; |
| 206 | |
| 207 | return ( |
| 208 | <RedactedSensitiveText |
| 209 | value={email} |
| 210 | ariaLabel="Toggle account email visibility" |
| 211 | revealTooltip="Click to reveal email" |
| 212 | hideTooltip="Click to hide email" |
| 213 | className="max-w-full truncate" |
| 214 | /> |
| 215 | ); |
| 216 | } |
| 217 | |
| 218 | export function readProviderEnvironmentVariable( |
| 219 | environment: ReadonlyArray<ProviderInstanceEnvironmentVariable> | undefined, |
| 220 | name: string, |
| 221 | ): ProviderInstanceEnvironmentVariable | undefined { |
| 222 | return environment?.find((variable) => variable.name === name); |
| 223 | } |
| 224 | |
| 225 | export function providerEnvironmentWithoutNames( |
| 226 | environment: ReadonlyArray<ProviderInstanceEnvironmentVariable> | undefined, |
| 227 | names: ReadonlySet<string>, |
| 228 | ): ReadonlyArray<ProviderInstanceEnvironmentVariable> { |
| 229 | return (environment ?? []).filter((variable) => !names.has(variable.name)); |
| 230 | } |
| 231 | |
| 232 | export function nextProviderEnvironmentWithFieldValue( |
| 233 | environment: ReadonlyArray<ProviderInstanceEnvironmentVariable> | undefined, |
| 234 | field: ProviderEnvironmentField, |
| 235 | value: string, |
| 236 | ): ReadonlyArray<ProviderInstanceEnvironmentVariable> { |
| 237 | const trimmed = value.trim(); |
| 238 | const next: ProviderInstanceEnvironmentVariable[] = []; |
| 239 | let found = false; |
| 240 | |
| 241 | for (const variable of environment ?? []) { |
| 242 | if (variable.name !== field.name) { |
| 243 | next.push(variable); |
| 244 | continue; |
| 245 | } |
| 246 | found = true; |
| 247 | if (trimmed.length > 0) { |
| 248 | next.push({ |
| 249 | name: variable.name, |
| 250 | value: trimmed, |
| 251 | sensitive: field.sensitive ?? true, |
| 252 | }); |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | if (!found && trimmed.length > 0) { |
| 257 | next.push({ |
| 258 | name: field.name, |
| 259 | value: trimmed, |
| 260 | sensitive: field.sensitive ?? true, |
| 261 | }); |
| 262 | } |
| 263 | |
| 264 | return next; |
| 265 | } |
| 266 | |
| 267 | function ProviderEnvironmentFieldRow(props: { |
| 268 | readonly field: ProviderEnvironmentField; |
| 269 | readonly variable: ProviderInstanceEnvironmentVariable | undefined; |
| 270 | readonly idPrefix: string; |
| 271 | readonly onCommit: (field: ProviderEnvironmentField, value: string) => void; |
| 272 | readonly onRemove: (field: ProviderEnvironmentField) => void; |
| 273 | }) { |
| 274 | const inputId = `${props.idPrefix}-environment-${props.field.name}`; |
| 275 | const value = props.variable?.valueRedacted ? "" : (props.variable?.value ?? ""); |
| 276 | |
| 277 | return ( |
| 278 | <SettingsRow |
| 279 | title={<label htmlFor={inputId}>{props.field.label}</label>} |
| 280 | description={props.field.description} |
| 281 | control={ |
| 282 | <div className="flex w-full min-w-0 items-center gap-2 @min-[32rem]/settings-row:w-56"> |
| 283 | {props.field.sensitive === false ? ( |
| 284 | <DraftInput |
| 285 | id={inputId} |
| 286 | size="sm" |
| 287 | className="min-w-0 flex-1" |
| 288 | autoComplete="off" |
| 289 | value={value} |
| 290 | onCommit={(next) => props.onCommit(props.field, next)} |
| 291 | placeholder={props.field.placeholder} |
| 292 | spellCheck={false} |
| 293 | /> |
| 294 | ) : ( |
| 295 | <SecretInput |
| 296 | id={inputId} |
| 297 | size="sm" |
| 298 | className="min-w-0 flex-1" |
| 299 | value={value} |
| 300 | onCommit={(next) => props.onCommit(props.field, next)} |
| 301 | placeholder={props.field.placeholder} |
| 302 | configured={props.variable?.valueRedacted === true} |
| 303 | spellCheck={false} |
| 304 | /> |
| 305 | )} |
| 306 | {props.variable ? ( |
| 307 | <Button |
| 308 | type="button" |
| 309 | size="icon-sm" |
| 310 | variant="ghost-destructive" |
| 311 | onClick={() => props.onRemove(props.field)} |
| 312 | aria-label={`Clear ${props.field.label}`} |
| 313 | > |
| 314 | <XIcon className="size-3.5" /> |
| 315 | </Button> |
| 316 | ) : null} |
| 317 | </div> |
| 318 | } |
| 319 | /> |
| 320 | ); |
| 321 | } |
| 322 | |
| 323 | export function ProviderEnvironmentSection(props: { |
| 324 | readonly environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>; |
| 325 | readonly onChange: (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => void; |
| 326 | }) { |
| 327 | const [rows, setRows] = useState<ReadonlyArray<EnvironmentDraftRow>>(() => |
| 328 | props.environment.map(makeEnvironmentDraftRow), |
| 329 | ); |
| 330 | const previousEnvironmentRef = useRef(props.environment); |
| 331 | const lastPublishedEnvironmentRef = useRef< |
| 332 | ReadonlyArray<ProviderInstanceEnvironmentVariable> | undefined |
| 333 | >(undefined); |
| 334 | |
| 335 | useEffect(() => { |
| 336 | const previousEnvironment = previousEnvironmentRef.current; |
| 337 | const lastPublishedEnvironment = lastPublishedEnvironmentRef.current; |
| 338 | previousEnvironmentRef.current = props.environment; |
| 339 | lastPublishedEnvironmentRef.current = undefined; |
| 340 | if ( |
| 341 | previousEnvironment === props.environment || |
| 342 | providerEnvironmentsEqual(previousEnvironment, props.environment) || |
| 343 | (lastPublishedEnvironment !== undefined && |
| 344 | providerEnvironmentsEqual(lastPublishedEnvironment, props.environment)) |
| 345 | ) { |
| 346 | return; |
| 347 | } |
| 348 | setRows(props.environment.map(makeEnvironmentDraftRow)); |
| 349 | }, [props.environment]); |
| 350 | |
| 351 | const publishRows = (nextRows: ReadonlyArray<EnvironmentDraftRow>) => { |
| 352 | const published: ProviderInstanceEnvironmentVariable[] = []; |
| 353 | for (const row of nextRows) { |
| 354 | const name = row.name.trim(); |
| 355 | if (!ENVIRONMENT_VARIABLE_NAME_PATTERN.test(name)) { |
| 356 | if ( |
| 357 | name.length > 0 || |
| 358 | row.value.length > 0 || |
| 359 | row.sensitive !== true || |
| 360 | row.valueRedacted !== undefined |
| 361 | ) { |
| 362 | return; |
| 363 | } |
| 364 | continue; |
| 365 | } |
| 366 | const { id: _id, ...rest } = row; |
| 367 | published.push({ ...rest, name }); |
| 368 | } |
| 369 | lastPublishedEnvironmentRef.current = published; |
| 370 | props.onChange(published); |
| 371 | }; |
| 372 | |
| 373 | const updateVariable = (id: string, patch: Partial<Omit<EnvironmentDraftRow, "id">>) => { |
| 374 | const nextRows = rows.map((row) => |
| 375 | row.id === id |
| 376 | ? { |
| 377 | ...row, |
| 378 | ...patch, |
| 379 | ...(patch.value !== undefined ? { valueRedacted: false } : {}), |
| 380 | } |
| 381 | : row, |
| 382 | ); |
| 383 | setRows(nextRows); |
| 384 | publishRows(nextRows); |
| 385 | }; |
| 386 | |
| 387 | const removeVariable = (id: string) => { |
| 388 | const nextRows = rows.filter((row) => row.id !== id); |
| 389 | setRows(nextRows); |
| 390 | publishRows(nextRows); |
| 391 | }; |
| 392 | |
| 393 | const addVariable = () => |
| 394 | setRows([ |
| 395 | ...rows, |
| 396 | { |
| 397 | id: nextEnvironmentVariableDraftId(), |
| 398 | name: "", |
| 399 | value: "", |
| 400 | sensitive: true, |
| 401 | }, |
| 402 | ]); |
| 403 | |
| 404 | return ( |
| 405 | <SettingsRow |
| 406 | title="Variables" |
| 407 | description="API keys, base URLs, and other per-instance CLI settings." |
| 408 | control={ |
| 409 | <Button type="button" size="sm" variant="outline" onClick={addVariable}> |
| 410 | <PlusIcon className="size-3" /> |
| 411 | Add variable |
| 412 | </Button> |
| 413 | } |
| 414 | > |
| 415 | {rows.length > 0 ? ( |
| 416 | <div className="mt-3 min-w-0 space-y-2 pb-2"> |
| 417 | {rows.map((variable, index) => ( |
| 418 | <div key={variable.id} className="flex min-w-0 flex-wrap items-center gap-1.5"> |
| 419 | <DraftInput |
| 420 | size="sm" |
| 421 | font="mono" |
| 422 | className="w-full min-w-0 sm:w-44 sm:shrink-0" |
| 423 | value={variable.name} |
| 424 | onCommit={(name) => updateVariable(variable.id, { name: name.trim() })} |
| 425 | placeholder="VARIABLE_NAME" |
| 426 | spellCheck={false} |
| 427 | aria-label={`Environment variable name ${index + 1}`} |
| 428 | /> |
| 429 | <span className="hidden text-xs text-muted-foreground sm:inline" aria-hidden> |
| 430 | = |
| 431 | </span> |
| 432 | {variable.sensitive ? ( |
| 433 | <SecretInput |
| 434 | size="sm" |
| 435 | font="mono" |
| 436 | className="min-w-0 flex-1" |
| 437 | value={variable.valueRedacted ? "" : variable.value} |
| 438 | onCommit={(value) => updateVariable(variable.id, { value })} |
| 439 | placeholder="value" |
| 440 | configured={variable.valueRedacted === true} |
| 441 | spellCheck={false} |
| 442 | aria-label={`Environment variable value ${index + 1}`} |
| 443 | /> |
| 444 | ) : ( |
| 445 | <DraftInput |
| 446 | size="sm" |
| 447 | font="mono" |
| 448 | className="min-w-0 flex-1" |
| 449 | value={variable.valueRedacted ? "" : variable.value} |
| 450 | onCommit={(value) => updateVariable(variable.id, { value })} |
| 451 | autoComplete="off" |
| 452 | placeholder="value" |
| 453 | spellCheck={false} |
| 454 | aria-label={`Environment variable value ${index + 1}`} |
| 455 | /> |
| 456 | )} |
| 457 | <Tooltip> |
| 458 | <TooltipTrigger |
| 459 | render={ |
| 460 | <Button |
| 461 | type="button" |
| 462 | size="icon-micro" |
| 463 | variant="ghost-muted" |
| 464 | onClick={() => { |
| 465 | const sensitive = !variable.sensitive; |
| 466 | updateVariable(variable.id, { |
| 467 | sensitive, |
| 468 | ...(sensitive && variable.valueRedacted === undefined |
| 469 | ? {} |
| 470 | : { valueRedacted: sensitive ? variable.valueRedacted : false }), |
| 471 | }); |
| 472 | }} |
| 473 | aria-pressed={variable.sensitive} |
| 474 | aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`} |
| 475 | > |
| 476 | <MorphIcon |
| 477 | className="size-3" |
| 478 | icon={variable.sensitive ? LockGlyph : LockOpen} |
| 479 | /> |
| 480 | </Button> |
| 481 | } |
| 482 | /> |
| 483 | <TooltipPopup side="top"> |
| 484 | {variable.sensitive ? "Sensitive, stored separately" : "Plain text"} |
| 485 | </TooltipPopup> |
| 486 | </Tooltip> |
| 487 | <Button |
| 488 | type="button" |
| 489 | size="icon-micro" |
| 490 | variant="ghost-destructive" |
| 491 | onClick={() => removeVariable(variable.id)} |
| 492 | aria-label={`Remove environment variable ${variable.name || index + 1}`} |
| 493 | > |
| 494 | <XIcon className="size-3" /> |
| 495 | </Button> |
| 496 | </div> |
| 497 | ))} |
| 498 | <p className="text-xs text-muted-foreground"> |
| 499 | Sensitive values are stored separately and never returned to the app. |
| 500 | </p> |
| 501 | </div> |
| 502 | ) : null} |
| 503 | </SettingsRow> |
| 504 | ); |
| 505 | } |
| 506 | |
| 507 | /** |
| 508 | * When a project is in scope, the enable control reads and writes that |
| 509 | * project's override instead of the environment's `instance.enabled`. |
| 510 | * `value` is the project's explicit override (`undefined` means it |
| 511 | * inherits the environment); `effectiveEnabled` is what the Switch shows. |
| 512 | */ |
| 513 | /** |
| 514 | * The project-scope counterpart of a settings row's inheritance indicator: |
| 515 | * hover for the summary, open for the project and device values side by side. |
| 516 | */ |
| 517 | function ProjectOverrideInheritance({ |
| 518 | projectEnabled, |
| 519 | deviceEnabled, |
| 520 | }: { |
| 521 | readonly projectEnabled: boolean; |
| 522 | readonly deviceEnabled: boolean; |
| 523 | }) { |
| 524 | const layers = [ |
| 525 | { key: "project", label: "Project", enabled: projectEnabled, effective: true }, |
| 526 | { key: "device", label: "Device", enabled: deviceEnabled, effective: false }, |
| 527 | ]; |
| 528 | return ( |
| 529 | <Popover> |
| 530 | <Tooltip> |
| 531 | <TooltipTrigger |
| 532 | render={ |
| 533 | <PopoverTrigger |
| 534 | render={ |
| 535 | <Button |
| 536 | size="icon-micro" |
| 537 | variant="ghost-muted" |
| 538 | aria-label="Overridden for this project. Show where this value comes from" |
| 539 | /> |
| 540 | } |
| 541 | /> |
| 542 | } |
| 543 | > |
| 544 | <LayersIcon className="size-3 text-primary" /> |
| 545 | </TooltipTrigger> |
| 546 | <TooltipPopup side="top">Overridden for this project</TooltipPopup> |
| 547 | </Tooltip> |
| 548 | <PopoverPopup align="end" width="md" padding="none"> |
| 549 | <ol role="list" className="px-3 py-2.5 text-sm"> |
| 550 | {layers.map((layer) => ( |
| 551 | <li |
| 552 | key={layer.key} |
| 553 | className={cn( |
| 554 | "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 rounded-md px-2 py-1", |
| 555 | layer.effective && "bg-foreground/[0.06]", |
| 556 | )} |
| 557 | > |
| 558 | <span |
| 559 | className={cn( |
| 560 | "min-w-0 truncate", |
| 561 | layer.effective ? "font-medium text-foreground" : "text-muted-foreground", |
| 562 | )} |
| 563 | > |
| 564 | {layer.label} |
| 565 | </span> |
| 566 | <span |
| 567 | className={cn( |
| 568 | "flex items-center gap-1.5", |
| 569 | layer.effective ? "text-foreground" : "text-muted-foreground", |
| 570 | )} |
| 571 | > |
| 572 | {layer.enabled ? "On" : "Off"} |
| 573 | {layer.effective ? ( |
| 574 | <CheckIcon aria-hidden className="size-3.5 shrink-0 text-primary" /> |
| 575 | ) : ( |
| 576 | <span aria-hidden className="size-3.5 shrink-0" /> |
| 577 | )} |
| 578 | </span> |
| 579 | </li> |
| 580 | ))} |
| 581 | </ol> |
| 582 | </PopoverPopup> |
| 583 | </Popover> |
| 584 | ); |
| 585 | } |
| 586 | |
| 587 | interface ProjectOverrideControl { |
| 588 | readonly value: boolean | undefined; |
| 589 | readonly effectiveEnabled: boolean; |
| 590 | readonly onChange: (next: boolean) => void; |
| 591 | readonly onReset: () => void; |
| 592 | } |
| 593 | |
| 594 | interface ProviderInstanceCardProps { |
| 595 | readonly instanceId: ProviderInstanceId; |
| 596 | readonly instance: ProviderInstanceConfig; |
| 597 | readonly driverOption: ProviderClientDefinition | undefined; |
| 598 | readonly liveProvider: ServerProvider | undefined; |
| 599 | readonly mode: "list" | "editor"; |
| 600 | readonly selected?: boolean | undefined; |
| 601 | readonly onSelect?: (() => void) | undefined; |
| 602 | readonly readOnly?: boolean | undefined; |
| 603 | readonly canWriteSettings?: boolean; |
| 604 | readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void; |
| 605 | /** |
| 606 | * Pass `undefined` to hide the delete footer entirely. Built-in default |
| 607 | * instance slots use `undefined` — they can't be deleted without losing |
| 608 | * the slot, and their "reset to defaults" affordance lives on an outer |
| 609 | * reset button instead. Explicit `| undefined` in the type accommodates |
| 610 | * `exactOptionalPropertyTypes: true`, where an absent key and |
| 611 | * `{ onDelete: undefined }` are treated as distinct shapes. |
| 612 | */ |
| 613 | readonly onDelete?: (() => void) | undefined; |
| 614 | /** |
| 615 | * Optional outer reset button rendered next to the driver icon. Built-in |
| 616 | * default slots supply a reset-to-factory control here; custom instances |
| 617 | * omit it. |
| 618 | */ |
| 619 | readonly headerAction?: ReactNode | undefined; |
| 620 | readonly setup?: ReactNode; |
| 621 | readonly runtime?: ReactNode; |
| 622 | readonly hiddenModels: ReadonlyArray<string>; |
| 623 | readonly favoriteModels: ReadonlyArray<string>; |
| 624 | readonly modelOrder: ReadonlyArray<string>; |
| 625 | readonly onHiddenModelsChange: ( |
| 626 | next: ReadonlyArray<string>, |
| 627 | affectedModels?: ReadonlyArray<string>, |
| 628 | ) => void; |
| 629 | readonly onFavoriteModelsChange: (next: ReadonlyArray<string>) => void; |
| 630 | readonly onToggleFavorite: (model: string) => void; |
| 631 | readonly onModelOrderChange: (next: ReadonlyArray<string>) => void; |
| 632 | readonly onRunUpdate?: (() => void) | undefined; |
| 633 | readonly onInstallRecommended?: (() => void) | undefined; |
| 634 | readonly isUpdating?: boolean | undefined; |
| 635 | readonly onAcceptUrlAuth?: ((action: AcpRegistryUrlAuthAction) => void) | undefined; |
| 636 | readonly environmentId?: EnvironmentId | undefined; |
| 637 | /** Omit this prop entirely for environment-wide editing. */ |
| 638 | readonly projectOverride?: ProjectOverrideControl | undefined; |
| 639 | readonly acpProjects?: |
| 640 | | ReadonlyArray<{ |
| 641 | readonly id: ProjectId; |
| 642 | readonly title: string; |
| 643 | readonly workspaceRoot: string; |
| 644 | }> |
| 645 | | undefined; |
| 646 | } |
| 647 | |
| 648 | const EMPTY_ACP_PROJECTS: NonNullable<ProviderInstanceCardProps["acpProjects"]> = []; |
| 649 | |
| 650 | /** |
| 651 | * Renders one provider instance as either a compact selectable list row or |
| 652 | * the full editor shown beside that list. Both modes use the same enabled |
| 653 | * state and provider metadata. |
| 654 | * |
| 655 | * Behavior notes: |
| 656 | * - `liveProvider` is matched by the caller via `instanceId`; when no |
| 657 | * match is available (e.g. the server hasn't checked it yet, or the |
| 658 | * driver is not shipped by the current build) the card still renders |
| 659 | * with a neutral "checking" summary. |
| 660 | * - Unknown drivers (`driverOption === undefined`) get a read-only |
| 661 | * notice instead of editable fields, so fork instances round-trip |
| 662 | * without accidentally destroying their config. |
| 663 | * - The enabled Switch writes to the envelope's `instance.enabled` |
| 664 | * field, which is the single enabled flag: the server folds any legacy |
| 665 | * driver-specific `config.enabled` into the envelope on load and both |
| 666 | * sides resolve through `resolveProviderInstanceEnabled` (an explicit |
| 667 | * false wins, then envelope, then config, then the driver default). |
| 668 | * With `projectOverride` set, the Switch instead reads and writes |
| 669 | * that project's override, leaving the environment's `instance.enabled` |
| 670 | * untouched. |
| 671 | */ |
| 672 | export function ProviderInstanceCard({ |
| 673 | instanceId, |
| 674 | instance, |
| 675 | driverOption, |
| 676 | liveProvider, |
| 677 | mode, |
| 678 | selected = false, |
| 679 | onSelect, |
| 680 | readOnly = false, |
| 681 | canWriteSettings = true, |
| 682 | onUpdate, |
| 683 | onDelete, |
| 684 | headerAction, |
| 685 | setup, |
| 686 | runtime, |
| 687 | hiddenModels, |
| 688 | favoriteModels, |
| 689 | modelOrder, |
| 690 | onHiddenModelsChange, |
| 691 | onFavoriteModelsChange, |
| 692 | onToggleFavorite, |
| 693 | onModelOrderChange, |
| 694 | onRunUpdate, |
| 695 | onInstallRecommended, |
| 696 | isUpdating = false, |
| 697 | onAcceptUrlAuth, |
| 698 | environmentId, |
| 699 | acpProjects = EMPTY_ACP_PROJECTS, |
| 700 | projectOverride, |
| 701 | }: ProviderInstanceCardProps) { |
| 702 | const enabled = projectOverride?.effectiveEnabled ?? resolveProviderInstanceEnabled(instance); |
| 703 | const compatibility = enabled ? liveProvider?.compatibilityAdvisory : undefined; |
| 704 | // A locally disabled provider reads "Disabled" with a muted dot even if its |
| 705 | // last server status is stale. Enabled providers use the server status. |
| 706 | const statusKey: ProviderStatusKey = enabled |
| 707 | ? ((liveProvider?.status as ProviderStatusKey | undefined) ?? "warning") |
| 708 | : "disabled"; |
| 709 | const statusStyle = PROVIDER_STATUS_STYLES[statusKey]; |
| 710 | // `liveProvider.enabled` is machine-wide; a project override can turn it on. |
| 711 | const summary = enabled |
| 712 | ? getProviderSummary(liveProvider && { ...liveProvider, enabled: true }) |
| 713 | : { headline: "Disabled", detail: null }; |
| 714 | const authEmail = liveProvider?.auth.email?.trim(); |
| 715 | const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated"; |
| 716 | const authLabel = |
| 717 | enabled && liveProvider?.auth.status === "authenticated" |
| 718 | ? (liveProvider.auth.label ?? liveProvider.auth.type ?? null) |
| 719 | : null; |
| 720 | const versionLabel = getProviderVersionLabel(liveProvider?.version); |
| 721 | const versionAdvisory = getProviderVersionAdvisoryPresentation( |
| 722 | liveProvider?.versionAdvisory, |
| 723 | liveProvider?.compatibilityAdvisory, |
| 724 | enabled, |
| 725 | ); |
| 726 | const updateCommand = versionAdvisory?.updateCommand ?? null; |
| 727 | const updateState = liveProvider?.updateState; |
| 728 | // The server reports each update step. `isUpdating` also covers the moment |
| 729 | // between the click and the server's first report. |
| 730 | const updateProgress = isUpdating |
| 731 | ? ((updateState?.status === "queued" || updateState?.status === "running" |
| 732 | ? updateState.message |
| 733 | : null) ?? "Starting update") |
| 734 | : null; |
| 735 | const updateProblem = |
| 736 | !isUpdating && (updateState?.status === "failed" || updateState?.status === "unchanged") |
| 737 | ? updateState.message |
| 738 | : null; |
| 739 | const hasCompatibilityWarning = |
| 740 | compatibility !== undefined && |
| 741 | compatibility.status !== "supported" && |
| 742 | compatibility.status !== "unknown"; |
| 743 | const VersionAdvisoryIcon = hasCompatibilityWarning ? AlertTriangleIcon : ArrowUpCircleIcon; |
| 744 | const onRunVersionAction = readOnly |
| 745 | ? undefined |
| 746 | : versionAdvisory?.targetVersion |
| 747 | ? onInstallRecommended |
| 748 | : onRunUpdate; |
| 749 | const urlAuthAction = liveProvider?.auth.action; |
| 750 | const displayName = |
| 751 | instance.displayName?.trim() || driverOption?.label || String(instance.driver); |
| 752 | const accentColor = normalizeProviderAccentColor(instance.accentColor); |
| 753 | const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({ |
| 754 | onCopy: ({ providerName }) => { |
| 755 | toastManager.add({ |
| 756 | type: "success", |
| 757 | title: `${providerName} update command copied`, |
| 758 | description: "Run it in a terminal when you are ready to update.", |
| 759 | }); |
| 760 | }, |
| 761 | onError: (error, { providerName }) => { |
| 762 | toastManager.add( |
| 763 | stackedThreadToast({ |
| 764 | type: "error", |
| 765 | title: `Could not copy ${providerName} update command`, |
| 766 | description: error.message, |
| 767 | }), |
| 768 | ); |
| 769 | }, |
| 770 | }); |
| 771 | |
| 772 | // Narrow `instance.driver` for callers that key on the closed |
| 773 | // `ProviderDriverKind` union (e.g. `normalizeModelSlug`'s alias table). Custom |
| 774 | // fork drivers pass through as `null` and those callers fall back to |
| 775 | // verbatim behaviour. |
| 776 | const driverKind: ProviderDriverKind | null = isProviderDriverKind(instance.driver) |
| 777 | ? instance.driver |
| 778 | : null; |
| 779 | const customModels = |
| 780 | instance.driver === "antigravity" ? [] : readConfigCustomModels(instance.config); |
| 781 | // Server-returned models may lag behind settings writes. Treat server |
| 782 | // models as the source for built-ins only; custom rows come directly |
| 783 | // from the current instance config so add/remove reflects immediately. |
| 784 | const modelsForDisplay = deriveProviderModelsForDisplay({ |
| 785 | liveModels: liveProvider?.models, |
| 786 | customModels, |
| 787 | }); |
| 788 | const updateDisplayName = (value: string) => { |
| 789 | const trimmed = value.trim(); |
| 790 | const { displayName: _omit, ...rest } = instance; |
| 791 | onUpdate( |
| 792 | trimmed.length > 0 |
| 793 | ? ({ ...rest, displayName: trimmed } as ProviderInstanceConfig) |
| 794 | : (rest as ProviderInstanceConfig), |
| 795 | ); |
| 796 | }; |
| 797 | |
| 798 | const updateEnabled = (value: boolean) => { |
| 799 | if (projectOverride) { |
| 800 | projectOverride.onChange(value); |
| 801 | return; |
| 802 | } |
| 803 | onUpdate({ ...instance, enabled: value }); |
| 804 | }; |
| 805 | |
| 806 | const updateAccentColor = (value: string) => { |
| 807 | const normalized = normalizeProviderAccentColor(value); |
| 808 | const { accentColor: _omit, ...rest } = instance; |
| 809 | onUpdate( |
| 810 | normalized |
| 811 | ? ({ ...rest, accentColor: normalized } as ProviderInstanceConfig) |
| 812 | : (rest as ProviderInstanceConfig), |
| 813 | ); |
| 814 | }; |
| 815 | |
| 816 | const updateConfig = (nextConfig: Record<string, unknown> | undefined) => { |
| 817 | const { config: _omit, ...rest } = instance; |
| 818 | onUpdate( |
| 819 | nextConfig !== undefined |
| 820 | ? ({ ...rest, config: nextConfig } as ProviderInstanceConfig) |
| 821 | : (rest as ProviderInstanceConfig), |
| 822 | ); |
| 823 | }; |
| 824 | |
| 825 | const updateCustomModels = (next: ReadonlyArray<CustomModelDefinition>) => { |
| 826 | const nextConfig = nextConfigBlobWithValue( |
| 827 | instance.config, |
| 828 | "customModels", |
| 829 | next.map(toCustomModelSetting), |
| 830 | ); |
| 831 | const { config: _omit, ...rest } = instance; |
| 832 | onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig); |
| 833 | }; |
| 834 | |
| 835 | const updateEnvironment = (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => { |
| 836 | const cleaned = environment.filter((variable) => variable.name.trim().length > 0); |
| 837 | const { environment: _omit, ...rest } = instance; |
| 838 | onUpdate( |
| 839 | cleaned.length > 0 |
| 840 | ? ({ ...rest, environment: cleaned } as ProviderInstanceConfig) |
| 841 | : (rest as ProviderInstanceConfig), |
| 842 | ); |
| 843 | }; |
| 844 | // Drivers that need a named secret (Cursor's API key) get a dedicated field; |
| 845 | // the generic editor only shows the remaining variables. |
| 846 | const environmentFields = driverOption?.environmentFields ?? []; |
| 847 | const environmentFieldNames = new Set(environmentFields.map((field) => field.name)); |
| 848 | const genericEnvironment = providerEnvironmentWithoutNames( |
| 849 | instance.environment, |
| 850 | environmentFieldNames, |
| 851 | ); |
| 852 | const updateGenericEnvironment = ( |
| 853 | environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>, |
| 854 | ) => { |
| 855 | const dedicatedEnvironment = (instance.environment ?? []).filter((variable) => |
| 856 | environmentFieldNames.has(variable.name), |
| 857 | ); |
| 858 | updateEnvironment([...dedicatedEnvironment, ...environment]); |
| 859 | }; |
| 860 | const updateEnvironmentField = (field: ProviderEnvironmentField, value: string) => { |
| 861 | updateEnvironment(nextProviderEnvironmentWithFieldValue(instance.environment, field, value)); |
| 862 | }; |
| 863 | const removeEnvironmentField = (field: ProviderEnvironmentField) => { |
| 864 | updateEnvironment(providerEnvironmentWithoutNames(instance.environment, new Set([field.name]))); |
| 865 | }; |
| 866 | |
| 867 | const titleIconNode = ( |
| 868 | <ProviderInstanceIcon |
| 869 | driverKind={driverKind ?? instance.driver} |
| 870 | displayName={displayName} |
| 871 | accentColor={accentColor} |
| 872 | acpRegistryAgentId={ |
| 873 | readConfigString(instance.config, "source") === "local" |
| 874 | ? undefined |
| 875 | : (readConfigString(instance.config, "agentId") ?? undefined) |
| 876 | } |
| 877 | acpRegistryIconUrl={ |
| 878 | readConfigString(instance.config, "source") === "local" |
| 879 | ? undefined |
| 880 | : (readConfigString(instance.config, "registryIconUrl") ?? undefined) |
| 881 | } |
| 882 | showBadge={Boolean(accentColor)} |
| 883 | className="size-5" |
| 884 | iconClassName="size-4 text-foreground/80" |
| 885 | badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-5xs" |
| 886 | /> |
| 887 | ); |
| 888 | |
| 889 | const titleTailNode = headerAction ? ( |
| 890 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">{headerAction}</span> |
| 891 | ) : null; |
| 892 | |
| 893 | const versionCodeNode = versionLabel ? ( |
| 894 | <code className="text-xs text-muted-foreground">{versionLabel}</code> |
| 895 | ) : null; |
| 896 | |
| 897 | // Healthy and disabled rows read fine from their text; only trouble gets a dot. |
| 898 | const statusDotNode = |
| 899 | statusKey === "warning" || statusKey === "error" ? ( |
| 900 | <span className={cn("size-1.5 shrink-0 rounded-full", statusStyle.dot)} aria-hidden /> |
| 901 | ) : null; |
| 902 | const needsAttention = statusKey === "warning" || statusKey === "error"; |
| 903 | const statusDiagnostic = hasCompatibilityWarning && needsAttention ? summary.detail : null; |
| 904 | // Keep compatibility copy compact; the version popover carries the explanation. |
| 905 | const inlineStatusDetail = hasCompatibilityWarning |
| 906 | ? compatibility?.status === "broken" |
| 907 | ? "Incompatible" |
| 908 | : compatibility?.status === "unsupported" |
| 909 | ? "Unsupported" |
| 910 | : "Limited support" |
| 911 | : summary.detail; |
| 912 | const editorStatusNode = |
| 913 | isAuthenticated && authEmail ? ( |
| 914 | <> |
| 915 | {needsAttention ? statusDotNode : null} |
| 916 | <span>Authenticated as</span> |
| 917 | <ProviderAuthEmail email={authEmail} /> |
| 918 | {authLabel ? <span>· {authLabel}</span> : null} |
| 919 | {inlineStatusDetail ? ( |
| 920 | <span className="min-w-0 [overflow-wrap:anywhere]">· {inlineStatusDetail}</span> |
| 921 | ) : null} |
| 922 | </> |
| 923 | ) : ( |
| 924 | <> |
| 925 | {statusDotNode} |
| 926 | <span>{summary.headline}</span> |
| 927 | {inlineStatusDetail ? ( |
| 928 | <span className="min-w-0 [overflow-wrap:anywhere]">· {inlineStatusDetail}</span> |
| 929 | ) : null} |
| 930 | </> |
| 931 | ); |
| 932 | const versionAdvisoryNode = versionAdvisory ? ( |
| 933 | <Popover> |
| 934 | <Tooltip> |
| 935 | <TooltipTrigger |
| 936 | render={ |
| 937 | <PopoverTrigger |
| 938 | render={ |
| 939 | <Button |
| 940 | type="button" |
| 941 | size={mode === "list" ? "icon-micro" : "icon-xs"} |
| 942 | variant="ghost-muted" |
| 943 | className={mode === "list" ? "pointer-events-auto relative shrink-0" : undefined} |
| 944 | aria-label={`${updateProgress ? "Updating" : versionAdvisory.title} — view details`} |
| 945 | > |
| 946 | {updateProgress ? ( |
| 947 | <Spinner tone="muted" {...(mode === "list" ? { size: "sm" as const } : {})} /> |
| 948 | ) : ( |
| 949 | <VersionAdvisoryIcon |
| 950 | className={cn( |
| 951 | mode === "list" && "size-3.5", |
| 952 | hasCompatibilityWarning && "text-warning", |
| 953 | )} |
| 954 | /> |
| 955 | )} |
| 956 | </Button> |
| 957 | } |
| 958 | /> |
| 959 | } |
| 960 | /> |
| 961 | <TooltipPopup side="top"> |
| 962 | {updateProgress ? "Updating" : versionAdvisory.title} |
| 963 | </TooltipPopup> |
| 964 | </Tooltip> |
| 965 | <PopoverPopup side="bottom" align="end" width="md" aria-label={versionAdvisory.title}> |
| 966 | <div className="grid min-w-0 gap-3"> |
| 967 | <div className="grid gap-0.5"> |
| 968 | <p className="text-sm font-semibold leading-tight text-foreground"> |
| 969 | {versionAdvisory.title} |
| 970 | </p> |
| 971 | <p |
| 972 | className={cn( |
| 973 | "text-xs leading-snug", |
| 974 | versionAdvisory.emphasis === "strong" ? "text-warning" : "text-muted-foreground", |
| 975 | )} |
| 976 | > |
| 977 | {versionAdvisory.detail} |
| 978 | </p> |
| 979 | </div> |
| 980 | {onRunVersionAction ? ( |
| 981 | <Button |
| 982 | type="button" |
| 983 | size="xs" |
| 984 | variant="outline" |
| 985 | className="w-full" |
| 986 | disabled={isUpdating} |
| 987 | onClick={onRunVersionAction} |
| 988 | > |
| 989 | {isUpdating ? <Spinner /> : <DownloadIcon />} |
| 990 | {isUpdating |
| 991 | ? "Updating" |
| 992 | : versionAdvisory.targetVersion |
| 993 | ? `Install ${getProviderVersionLabel(versionAdvisory.targetVersion)}` |
| 994 | : "Update now"} |
| 995 | </Button> |
| 996 | ) : null} |
| 997 | {updateProgress || updateProblem ? ( |
| 998 | <p |
| 999 | aria-live="polite" |
| 1000 | className={cn( |
| 1001 | "text-xs leading-snug [overflow-wrap:anywhere]", |
| 1002 | updateProblem ? "text-warning" : "text-muted-foreground", |
| 1003 | )} |
| 1004 | > |
| 1005 | {updateProgress ?? updateProblem} |
| 1006 | </p> |
| 1007 | ) : null} |
| 1008 | {onRunVersionAction && updateCommand ? ( |
| 1009 | <div className="flex items-center gap-2 text-3xs font-medium uppercase tracking-wider text-muted-foreground"> |
| 1010 | <span aria-hidden className="h-px flex-1 bg-border" /> |
| 1011 | or, update manually using |
| 1012 | <span aria-hidden className="h-px flex-1 bg-border" /> |
| 1013 | </div> |
| 1014 | ) : null} |
| 1015 | {updateCommand ? ( |
| 1016 | <div className="flex min-w-0 items-center gap-1 rounded-md border border-border/70 bg-muted/40 py-0.5 pr-0.5 pl-2"> |
| 1017 | <code className="min-w-0 flex-1 truncate font-mono text-2xs text-foreground"> |
| 1018 | {updateCommand} |
| 1019 | </code> |
| 1020 | <Tooltip> |
| 1021 | <TooltipTrigger |
| 1022 | render={ |
| 1023 | <Button |
| 1024 | type="button" |
| 1025 | size="icon-xs" |
| 1026 | variant="ghost-muted" |
| 1027 | className="shrink-0" |
| 1028 | onClick={() => copyToClipboard(updateCommand, { providerName: displayName })} |
| 1029 | aria-label="Copy update command" |
| 1030 | > |
| 1031 | <CopyIcon className="size-3" /> |
| 1032 | </Button> |
| 1033 | } |
| 1034 | /> |
| 1035 | <TooltipPopup side="top">Copy command</TooltipPopup> |
| 1036 | </Tooltip> |
| 1037 | </div> |
| 1038 | ) : null} |
| 1039 | </div> |
| 1040 | </PopoverPopup> |
| 1041 | </Popover> |
| 1042 | ) : null; |
| 1043 | |
| 1044 | if (mode === "list") { |
| 1045 | return ( |
| 1046 | <div |
| 1047 | data-slot="settings-row" |
| 1048 | className={cn( |
| 1049 | "group flex min-h-18 items-center gap-3 px-3 py-3 transition-colors sm:px-4", |
| 1050 | selected ? "bg-muted/45" : "hover:bg-muted/25", |
| 1051 | )} |
| 1052 | > |
| 1053 | <div |
| 1054 | className={cn( |
| 1055 | "pointer-events-none relative flex min-w-0 flex-1 items-start gap-3 rounded-md text-left transition-opacity", |
| 1056 | !enabled && !selected && "opacity-60 group-hover:opacity-100", |
| 1057 | )} |
| 1058 | > |
| 1059 | <button |
| 1060 | type="button" |
| 1061 | className="pointer-events-auto absolute inset-0 cursor-pointer rounded-md outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring" |
| 1062 | onClick={onSelect} |
| 1063 | aria-label={`Select ${displayName}`} |
| 1064 | aria-pressed={selected} |
| 1065 | /> |
| 1066 | {titleIconNode} |
| 1067 | <span className="min-w-0 flex-1"> |
| 1068 | <span className="flex min-w-0 items-center gap-2"> |
| 1069 | <span className="truncate text-sm font-medium text-foreground">{displayName}</span> |
| 1070 | {versionLabel ? ( |
| 1071 | <code className="max-w-24 shrink-0 truncate text-xs text-muted-foreground"> |
| 1072 | {versionLabel} |
| 1073 | </code> |
| 1074 | ) : null} |
| 1075 | {versionAdvisoryNode} |
| 1076 | </span> |
| 1077 | <span className="mt-0.5 flex items-start gap-1.5 text-xs leading-normal text-muted-foreground/80"> |
| 1078 | {/* The dot describes provider health, not the update in progress. */} |
| 1079 | {statusDotNode && !updateProgress ? ( |
| 1080 | <span className="flex h-[1.45em] shrink-0 items-center">{statusDotNode}</span> |
| 1081 | ) : null} |
| 1082 | <ProviderStatusDiagnostic detail={statusDiagnostic}> |
| 1083 | <span |
| 1084 | tabIndex={statusDiagnostic ? 0 : undefined} |
| 1085 | aria-live="polite" |
| 1086 | className="pointer-events-auto line-clamp-2 [overflow-wrap:anywhere]" |
| 1087 | > |
| 1088 | {updateProgress ? ( |
| 1089 | `Updating · ${updateProgress}` |
| 1090 | ) : ( |
| 1091 | <> |
| 1092 | {summary.headline} |
| 1093 | {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null} |
| 1094 | </> |
| 1095 | )} |
| 1096 | </span> |
| 1097 | </ProviderStatusDiagnostic> |
| 1098 | </span> |
| 1099 | </span> |
| 1100 | </div> |
| 1101 | <span className="flex h-5 shrink-0 items-center gap-1"> |
| 1102 | {projectOverride?.value !== undefined ? ( |
| 1103 | <span className="flex items-center"> |
| 1104 | <ProjectOverrideInheritance |
| 1105 | projectEnabled={projectOverride.value} |
| 1106 | deviceEnabled={resolveProviderInstanceEnabled(instance)} |
| 1107 | /> |
| 1108 | <SettingResetButton |
| 1109 | label={`${displayName} enablement`} |
| 1110 | tooltip="Reset to the device setting" |
| 1111 | disabled={readOnly || !canWriteSettings} |
| 1112 | onClick={projectOverride.onReset} |
| 1113 | /> |
| 1114 | </span> |
| 1115 | ) : null} |
| 1116 | <Switch |
| 1117 | checked={enabled} |
| 1118 | disabled={readOnly || (projectOverride !== undefined && !canWriteSettings)} |
| 1119 | onCheckedChange={(checked) => updateEnabled(Boolean(checked))} |
| 1120 | aria-label={`Enable ${displayName}`} |
| 1121 | /> |
| 1122 | </span> |
| 1123 | </div> |
| 1124 | ); |
| 1125 | } |
| 1126 | |
| 1127 | const editorHeaderAction = ( |
| 1128 | <div className="flex shrink-0 items-center gap-1.5"> |
| 1129 | {driverOption?.badgeLabel ? ( |
| 1130 | <Badge variant="warning" size="sm" className="shrink-0"> |
| 1131 | {driverOption.badgeLabel} |
| 1132 | </Badge> |
| 1133 | ) : null} |
| 1134 | {versionCodeNode} |
| 1135 | {versionAdvisoryNode} |
| 1136 | <span |
| 1137 | inert={readOnly} |
| 1138 | aria-disabled={readOnly || undefined} |
| 1139 | className={cn("inline-flex items-center gap-1", readOnly && "opacity-50")} |
| 1140 | > |
| 1141 | {titleTailNode} |
| 1142 | {onDelete ? ( |
| 1143 | <Button |
| 1144 | type="button" |
| 1145 | size="icon-xs" |
| 1146 | variant="ghost-destructive" |
| 1147 | disabled={readOnly} |
| 1148 | onClick={onDelete} |
| 1149 | aria-label={`Delete instance ${instanceId}`} |
| 1150 | > |
| 1151 | <Trash2Icon /> |
| 1152 | </Button> |
| 1153 | ) : null} |
| 1154 | </span> |
| 1155 | </div> |
| 1156 | ); |
| 1157 | |
| 1158 | const runtimeFields = driverOption ? ( |
| 1159 | <ProviderSettingsForm |
| 1160 | definition={driverOption} |
| 1161 | value={instance.config} |
| 1162 | idPrefix={`provider-instance-${instanceId}`} |
| 1163 | variant="settings" |
| 1164 | onChange={updateConfig} |
| 1165 | /> |
| 1166 | ) : ( |
| 1167 | <SettingsRow |
| 1168 | title="Driver" |
| 1169 | description={ |
| 1170 | <span> |
| 1171 | This instance uses <code className="text-foreground">{String(instance.driver)}</code>, |
| 1172 | which is not available in this build. Its configuration is preserved. |
| 1173 | </span> |
| 1174 | } |
| 1175 | /> |
| 1176 | ); |
| 1177 | |
| 1178 | return ( |
| 1179 | <> |
| 1180 | <SettingsSection title={displayName} icon={titleIconNode} headerAction={editorHeaderAction}> |
| 1181 | <SettingsRow |
| 1182 | title="Display name" |
| 1183 | status={ |
| 1184 | <> |
| 1185 | <ProviderStatusDiagnostic detail={statusDiagnostic}> |
| 1186 | <div |
| 1187 | tabIndex={statusDiagnostic ? 0 : undefined} |
| 1188 | className="flex min-w-0 flex-wrap items-baseline gap-x-1.5" |
| 1189 | > |
| 1190 | {editorStatusNode} |
| 1191 | </div> |
| 1192 | </ProviderStatusDiagnostic> |
| 1193 | {urlAuthAction && onAcceptUrlAuth ? ( |
| 1194 | <div className="grid max-w-xl gap-1.5 pt-1 text-xs"> |
| 1195 | <p>{urlAuthAction.message}</p> |
| 1196 | <code className="break-all text-2xs">{urlAuthAction.url}</code> |
| 1197 | <Button |
| 1198 | render={ |
| 1199 | <a |
| 1200 | href={urlAuthAction.url} |
| 1201 | target="_blank" |
| 1202 | rel="noopener noreferrer" |
| 1203 | onClick={() => onAcceptUrlAuth(urlAuthAction)} |
| 1204 | /> |
| 1205 | } |
| 1206 | size="xs" |
| 1207 | variant="outline" |
| 1208 | className="w-fit" |
| 1209 | disabled={readOnly} |
| 1210 | > |
| 1211 | <ExternalLinkIcon /> |
| 1212 | Continue authentication |
| 1213 | </Button> |
| 1214 | </div> |
| 1215 | ) : null} |
| 1216 | </> |
| 1217 | } |
| 1218 | control={ |
| 1219 | <div |
| 1220 | inert={readOnly} |
| 1221 | aria-disabled={readOnly || undefined} |
| 1222 | className={cn( |
| 1223 | "flex w-full min-w-0 items-center justify-end gap-2 @min-[32rem]/settings-row:w-auto", |
| 1224 | readOnly && "opacity-50 select-none", |
| 1225 | )} |
| 1226 | > |
| 1227 | <ProviderAccentColorPicker |
| 1228 | layout="inline" |
| 1229 | displayName={displayName} |
| 1230 | value={accentColor} |
| 1231 | onCommit={updateAccentColor} |
| 1232 | commitDelayMs={120} |
| 1233 | /> |
| 1234 | <DraftInput |
| 1235 | id={`provider-instance-${instanceId}-display-name`} |
| 1236 | size="sm" |
| 1237 | className="min-w-0 flex-1 @min-[32rem]/settings-row:w-56" |
| 1238 | value={instance.displayName ?? ""} |
| 1239 | onCommit={updateDisplayName} |
| 1240 | placeholder={driverOption?.label ?? "Instance label"} |
| 1241 | spellCheck={false} |
| 1242 | /> |
| 1243 | </div> |
| 1244 | } |
| 1245 | /> |
| 1246 | </SettingsSection> |
| 1247 | |
| 1248 | {setup || environmentFields.length > 0 ? ( |
| 1249 | <SettingsSection title="Setup"> |
| 1250 | {setup} |
| 1251 | <div |
| 1252 | inert={readOnly} |
| 1253 | aria-disabled={readOnly || undefined} |
| 1254 | className={readOnly ? "opacity-50 select-none" : undefined} |
| 1255 | > |
| 1256 | {environmentFields.length > 0 ? ( |
| 1257 | <> |
| 1258 | {environmentFields.map((field) => ( |
| 1259 | <ProviderEnvironmentFieldRow |
| 1260 | key={field.name} |
| 1261 | field={field} |
| 1262 | variable={readProviderEnvironmentVariable(instance.environment, field.name)} |
| 1263 | idPrefix={`provider-instance-${instanceId}`} |
| 1264 | onCommit={updateEnvironmentField} |
| 1265 | onRemove={removeEnvironmentField} |
| 1266 | /> |
| 1267 | ))} |
| 1268 | </> |
| 1269 | ) : null} |
| 1270 | </div> |
| 1271 | </SettingsSection> |
| 1272 | ) : null} |
| 1273 | |
| 1274 | {instance.driver === "codex" && readCodexSetupMode(instance.config) === "managed" ? ( |
| 1275 | <div |
| 1276 | inert={readOnly} |
| 1277 | aria-disabled={readOnly || undefined} |
| 1278 | className={readOnly ? "opacity-50 select-none" : undefined} |
| 1279 | > |
| 1280 | <FoldedSettingsSection |
| 1281 | key={instanceId} |
| 1282 | id={`provider-instance-${instanceId}-runtime`} |
| 1283 | title="Runtime" |
| 1284 | headerPlacement="outside" |
| 1285 | > |
| 1286 | {runtime ?? runtimeFields} |
| 1287 | </FoldedSettingsSection> |
| 1288 | </div> |
| 1289 | ) : !driverOption || deriveProviderSettingsFields(driverOption).length > 0 ? ( |
| 1290 | <SettingsSection |
| 1291 | title="Runtime" |
| 1292 | inert={readOnly} |
| 1293 | aria-disabled={readOnly || undefined} |
| 1294 | className={readOnly ? "opacity-50 select-none" : undefined} |
| 1295 | > |
| 1296 | {runtimeFields} |
| 1297 | </SettingsSection> |
| 1298 | ) : null} |
| 1299 | |
| 1300 | <SettingsSection |
| 1301 | title="Environment" |
| 1302 | inert={readOnly} |
| 1303 | aria-disabled={readOnly || undefined} |
| 1304 | className={readOnly ? "opacity-50 select-none" : undefined} |
| 1305 | > |
| 1306 | <ProviderEnvironmentSection |
| 1307 | environment={genericEnvironment} |
| 1308 | onChange={updateGenericEnvironment} |
| 1309 | /> |
| 1310 | </SettingsSection> |
| 1311 | {environmentId !== undefined && liveProvider?.driver === "acpRegistry" ? ( |
| 1312 | <AcpSessionManagementSection |
| 1313 | environmentId={environmentId} |
| 1314 | instanceId={instanceId} |
| 1315 | provider={liveProvider} |
| 1316 | projects={acpProjects} |
| 1317 | readOnly={readOnly} |
| 1318 | /> |
| 1319 | ) : null} |
| 1320 | |
| 1321 | {driverOption !== undefined ? ( |
| 1322 | <SettingsSection title="Models"> |
| 1323 | <div className="px-3 py-3 sm:px-4"> |
| 1324 | <p className="mb-3 text-xs text-muted-foreground"> |
| 1325 | Model preferences and custom models are saved on the selected environment and shared |
| 1326 | across connected devices. |
| 1327 | </p> |
| 1328 | <ProviderModelsSection |
| 1329 | canManageCustomModels={!readOnly} |
| 1330 | canWritePreferences={canWriteSettings} |
| 1331 | instanceId={instanceId} |
| 1332 | driverKind={driverKind} |
| 1333 | models={modelsForDisplay} |
| 1334 | customModels={customModels} |
| 1335 | hiddenModels={hiddenModels} |
| 1336 | favoriteModels={favoriteModels} |
| 1337 | modelOrder={modelOrder} |
| 1338 | onChange={updateCustomModels} |
| 1339 | onHiddenModelsChange={onHiddenModelsChange} |
| 1340 | onFavoriteModelsChange={onFavoriteModelsChange} |
| 1341 | onToggleFavorite={onToggleFavorite} |
| 1342 | onModelOrderChange={onModelOrderChange} |
| 1343 | /> |
| 1344 | </div> |
| 1345 | </SettingsSection> |
| 1346 | ) : null} |
| 1347 | </> |
| 1348 | ); |
| 1349 | } |