paperclip/ui/src/components/AgentConfigForm.tsx

4049 lines
177 KiB
TypeScript

import { testAgentSetup } from "@/lib/test-agent-setup";
import { RuntimeTestCard } from "./RuntimeTestCard";
import { useState, useEffect, useRef, useMemo, useCallback, Children, isValidElement, type ReactNode } from "react";
import type { AdapterConfigSection } from "../adapters/types";
import { useConfigSchema } from "../adapters/schema-config-fields";
import { schemaFieldSection } from "../adapters/config-sections";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
Agent,
AdapterAuthSessionPrompt,
AdapterAuthSessionStatus,
CodexAccountBindingClaim,
AdapterEnvironmentTestResult,
CompanySecret,
EnvBinding,
EnvSecretRefBinding,
Environment,
} from "@paperclipai/shared";
import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapter, isValidBrowserCode, ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
import type { AdapterModel } from "../api/agents";
import { agentsApi } from "../api/agents";
import { ApiError } from "../api/client";
import { environmentsApi } from "../api/environments";
import { instanceSettingsApi } from "../api/instanceSettings";
import { secretsApi } from "../api/secrets";
import { assetsApi } from "../api/assets";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
DEFAULT_CODEX_LOCAL_MODEL,
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CLAUDE_LOCAL_MODEL } from "@paperclipai/adapter-claude-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { DEFAULT_KIMI_LOCAL_MODEL } from "@paperclipai/adapter-kimi-local";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { FolderOpen, Heart, ChevronDown, X, Copy, Check, ExternalLink, Loader2, TriangleAlert, Bug } from "lucide-react";
import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils";
import { copyTextToClipboard } from "../lib/clipboard";
import {
connectSourceName,
OnboardingLoginCard,
OnboardingCardField,
OnboardingLoginCodeRow,
type AdapterLoginChrome,
} from "./AdapterLoginChrome";
import {
resolveAdapterTestEnvironmentId,
resolveLocalDefaultEnvironmentId,
resolveManagedSandboxEnvironmentId,
} from "../lib/adapter-test-environment";
import { environmentDisplayLabel } from "../lib/managed-sandbox-environment";
import { extractModelName, extractProviderId } from "../lib/model-utils";
import { queryKeys } from "../lib/queryKeys";
import { useCompany } from "../context/CompanyContext";
import {
Field,
ToggleField,
ToggleWithNumber,
CollapsibleSection,
DraftInput,
DraftNumberInput,
help,
adapterLabels,
} from "./agent-config-primitives";
import { defaultCreateValues } from "./agent-config-defaults";
import { getUIAdapter } from "../adapters";
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
import { MarkdownEditor } from "./MarkdownEditor";
import { ChoosePathButton } from "./PathInstructionsModal";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { ReportsToPicker } from "./ReportsToPicker";
import {
EnvironmentVariablesEditor,
type EnvironmentVariablesEditorHandle,
} from "./environment-variables-editor";
import { buildFixedClaudeOAuthBinding, CLAUDE_OAUTH_TOKEN_ENV_KEY } from "./environment-variables-editor/model";
import { AgentSecretAccessEditor } from "./AgentSecretAccessEditor";
import { useProposalReview } from "../pages/secrets/proposal-review";
import { AGENT_ACCESS_CONFIG_PATH_PREFIX } from "../lib/secret-delivery";
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata";
import { getAdapterDisplay, getAdapterLabel } from "../adapters/adapter-display-registry";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
import { buildAgentUpdatePatch, omitUndefinedEntries, type AgentConfigOverlay } from "../lib/agent-config-patch";
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
import { resolveForcedKubernetesEnvironment } from "../lib/forced-kubernetes-environment";
import { codexReasoningEffortOptions } from "../lib/codex-reasoning-effort";
/* ---- Create mode values ---- */
// Canonical type lives in @paperclipai/adapter-utils; re-exported here
// so existing imports from this file keep working.
export type { CreateConfigValues } from "@paperclipai/adapter-utils";
import {
PAPERCLIP_RUNNER_PERMISSION_CAPABILITIES,
paperclipRunnerTransitionConfig,
type CreateConfigValues,
} from "@paperclipai/adapter-utils";
import { Badge } from "@/components/ui/badge";
/* ---- Props ---- */
type AgentConfigFormProps = {
adapterModels?: AdapterModel[];
onDirtyChange?: (dirty: boolean) => void;
onSaveActionChange?: (save: (() => void) | null) => void;
onCancelActionChange?: (cancel: (() => void) | null) => void;
onTestActionChange?: (test: (() => void) | null) => void;
onTestActionStateChange?: (state: { disabled: boolean; pending: boolean }) => void;
onTestFeedbackChange?: (feedback: {
errorMessage: string | null;
result: AdapterEnvironmentTestResult | null;
// The login panel descriptor when the current target is a sandbox with no
// ready authentication, otherwise null. A parent that lifts the test
// feedback must render `AdapterLoginPanel` from this descriptor. The inline
// feedback branch renders the panel itself, so this descriptor is the only
// way the panel reaches a parent that hides the inline branch.
login: AdapterLoginDescriptor | null;
}) => void;
hideInlineSave?: boolean;
showAdapterTypeField?: boolean;
showAdapterTestEnvironmentButton?: boolean;
compactTestFeedback?: boolean;
showCreateRunPolicySection?: boolean;
hideInstructionsFile?: boolean;
/** Allow instance administrators to configure short-lived raw provider capture. */
canConfigureProviderTrace?: boolean;
/** Hide the prompt template field from the Identity section (used when it's shown in a separate Prompts tab). */
hidePromptTemplate?: boolean;
/** Render the main configuration sections or the dedicated edit-only Secrets surface. */
content?: "configuration" | "secrets";
/** Keep variable bindings beside secret access in a unified edit surface. */
environmentVariablesPlacement?: "configuration" | "secrets";
/** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */
sectionLayout?: "inline" | "cards";
/** Optional settings composition; sorting changes DOM order as well as visual order. */
sectionOrder?: readonly string[];
sectionTitles?: Record<string, string>;
} & (
| {
mode: "create";
values: CreateConfigValues;
onChange: (patch: Partial<CreateConfigValues>) => void;
}
| {
mode: "edit";
agent: Agent;
onSave: (patch: Record<string, unknown>) => void | Promise<unknown>;
isSaving?: boolean;
}
);
/* ---- Edit mode overlay (dirty tracking) ---- */
const emptyOverlay: AgentConfigOverlay = {
identity: {},
adapterConfig: {},
heartbeat: {},
debug: {},
runtime: {},
};
/** Stable empty object used as fallback for missing env config to avoid new-object-per-render. */
const EMPTY_ENV: Record<string, EnvBinding> = {};
export function supportsAdapterModelRefresh(adapterType: string): boolean {
return adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "paperclip_runner" || adapterType === "opencode_local";
}
export function resolvePaperclipRunnerTransitionModel(
previousAdapterType: string,
previousModel: unknown,
): string {
return paperclipRunnerTransitionConfig(previousAdapterType, previousModel).model as string;
}
function isOverlayDirty(o: AgentConfigOverlay): boolean {
return (
Object.keys(o.identity).length > 0 ||
o.adapterType !== undefined ||
Object.keys(o.adapterConfig).length > 0 ||
Object.keys(o.heartbeat).length > 0 ||
Object.keys(o.debug).length > 0 ||
Object.keys(o.runtime).length > 0
);
}
/**
* Structural equality for overlay entry values. Overlay values are
* JSON-shaped (scalars, env maps, argument arrays), so a reference compare
* alone would keep an edit-then-restore of a structured value falsely dirty
* after a refresh subtracts the persisted snapshot.
*/
export function overlayValuesEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true;
if (Array.isArray(a) && Array.isArray(b)) {
return a.length === b.length && a.every((item, index) => overlayValuesEqual(item, b[index]));
}
if (
typeof a === "object" && a !== null && !Array.isArray(a) &&
typeof b === "object" && b !== null && !Array.isArray(b)
) {
const aEntries = Object.entries(a as Record<string, unknown>);
const bRecord = b as Record<string, unknown>;
return (
aEntries.length === Object.keys(bRecord).length &&
aEntries.every(([key, value]) => key in bRecord && overlayValuesEqual(value, bRecord[key]))
);
}
return false;
}
/**
* Remove from `current` every entry `persisted` carried with a structurally
* equal value, keeping entries the user added or changed after `persisted`
* was snapshotted. The refresh that follows a background save consumes this
* so edits made while that save was in flight survive as pending dirty state
* instead of being wiped with the rest of the overlay.
*/
export function subtractPersistedOverlay(
current: AgentConfigOverlay,
persisted: AgentConfigOverlay,
): AgentConfigOverlay {
const subtractGroup = (
currentGroup: Record<string, unknown>,
persistedGroup: Record<string, unknown>,
): Record<string, unknown> =>
Object.fromEntries(
Object.entries(currentGroup).filter(
([field, value]) =>
!(field in persistedGroup) || !overlayValuesEqual(value, persistedGroup[field]),
),
);
return {
identity: subtractGroup(current.identity, persisted.identity),
...(current.adapterType !== undefined && current.adapterType !== persisted.adapterType
? { adapterType: current.adapterType }
: {}),
adapterConfig: subtractGroup(current.adapterConfig, persisted.adapterConfig),
heartbeat: subtractGroup(current.heartbeat, persisted.heartbeat),
debug: subtractGroup(current.debug, persisted.debug),
runtime: subtractGroup(current.runtime, persisted.runtime),
};
}
/* ---- Shared input class ---- */
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
function parseCommaArgs(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function formatArgList(value: unknown): string {
if (Array.isArray(value)) {
return value
.filter((item): item is string => typeof item === "string")
.join(", ");
}
return typeof value === "string" ? value : "";
}
const openCodeThinkingEffortOptions = [
{ id: "", label: "Auto" },
{ id: "minimal", label: "Minimal" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "xhigh", label: "X-High" },
{ id: "max", label: "Max" },
] as const;
const cursorModeOptions = [
{ id: "", label: "Auto" },
{ id: "plan", label: "Plan" },
{ id: "ask", label: "Ask" },
] as const;
const claudeThinkingEffortOptions = [
{ id: "", label: "Auto" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
] as const;
// Kimi exposes low/high/max (no "medium") via each model's support_efforts;
// the kimi_local adapter maps a legacy "medium" onto "high" at runtime.
const kimiThinkingEffortOptions = [
{ id: "", label: "Auto" },
{ id: "low", label: "Low" },
{ id: "high", label: "High" },
{ id: "max", label: "Max" },
] as const;
const MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS = 2;
const MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP = 10;
const MAX_TURN_CONTINUATION_DEFAULT_DELAY_SEC = 1;
const MAX_TURN_CONTINUATION_MAX_DELAY_SEC = 300;
function clampInteger(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, Math.floor(value)));
}
function clampDelayMsFromSeconds(value: number) {
return clampInteger(value, 0, MAX_TURN_CONTINUATION_MAX_DELAY_SEC) * 1000;
}
function ConfigSections({ order, className, children }: {
order?: readonly string[];
className: string;
children: ReactNode;
}) {
if (!order) return <div className={className}>{children}</div>;
const rank = (child: ReactNode) => {
const key = isValidElement<{ "data-config-section"?: string }>(child)
? child.props["data-config-section"]
: undefined;
const index = key ? order.indexOf(key) : -1;
return index < 0 ? order.length : index;
};
const sections = Children.toArray(children).sort((a, b) => rank(a) - rank(b));
return <div className={className}>{sections}</div>;
}
/* ---- Form ---- */
export function AgentConfigForm(props: AgentConfigFormProps) {
const { mode, adapterModels: externalModels } = props;
const isCreate = mode === "create";
const cards = props.sectionLayout === "cards";
const showAdapterTypeField = props.showAdapterTypeField ?? true;
const showAdapterTestEnvironmentButton = props.showAdapterTestEnvironmentButton ?? true;
const showInlineAdapterTestEnvironmentButton =
showAdapterTestEnvironmentButton && !props.onTestActionChange && !props.compactTestFeedback;
const showInlineAdapterTestEnvironmentFeedback = !props.onTestFeedbackChange;
const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true;
const hideInstructionsFile = props.hideInstructionsFile ?? false;
const canConfigureProviderTrace = props.canConfigureProviderTrace === true;
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
const environmentVariablesEditorRef = useRef<EnvironmentVariablesEditorHandle | null>(null);
// Sync disabled adapter types from server so dropdown filters them out.
const disabledTypes = useDisabledAdaptersSync();
const { data: availableSecrets = [] } = useQuery({
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
queryFn: () => secretsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
// User-secret definitions power the "User secret" env binding source. Requires
// secret-admin; non-admins simply get the free-text key fallback in the editor.
const { data: userSecretDefinitions = [] } = useQuery({
queryKey: selectedCompanyId
? queryKeys.secrets.userDefinitions(selectedCompanyId)
: ["user-secret-definitions", "none"],
queryFn: () => secretsApi.listUserSecretDefinitions(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
retry: false,
});
// Pending binding proposals targeting this agent (PAP-14731). Board-only route;
// non-permitted viewers simply get an empty list.
const editAgentId = !isCreate ? props.agent.id : null;
const { data: pendingProposals = [] } = useQuery({
queryKey: selectedCompanyId
? queryKeys.secrets.proposals(selectedCompanyId, "pending")
: ["secret-proposals", "none"],
queryFn: () => secretsApi.listProposals(selectedCompanyId!, "pending"),
enabled: Boolean(selectedCompanyId) && !isCreate,
retry: false,
});
const agentBindingProposals = useMemo(
() =>
pendingProposals.filter(
(proposal) => proposal.kind === "binding" && proposal.target?.id === editAgentId,
),
[pendingProposals, editAgentId],
);
const proposalReview = useProposalReview(selectedCompanyId, []);
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const adapterPickerDisabledTypes = useMemo(() => {
const next = new Set(disabledTypes);
// Fail closed while settings load. Existing native agents still render
// their current value in edit mode, but the picker does not offer a fresh
// native selection until the explicit experimental opt-in is known true.
if (experimentalSettings?.enableNativeRunner !== true) {
next.add("paperclip_runner");
}
return next;
}, [disabledTypes, experimentalSettings?.enableNativeRunner]);
const environmentsEnabled = experimentalSettings?.enableEnvironments === true;
// Managed-sandbox-only policy: every agent runs in the platform-managed
// environment, so the form hides each host filesystem path and each
// execution-engine choice. Declared here because the field gates below and
// the adapter field props both read it.
const managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true;
// The gate the host-path fields use. It fails closed whenever the policy is
// unknown — in flight and also on a failed read: an unresolved policy reads as
// "not managed", which would show a stored working directory or
// instructions-file path.
const hideHostPaths = experimentalSettings === undefined || managedSandboxOnly;
// Instance execution policy (general settings). When `executionMode` is
// "kubernetes" the instance FORCES all execution onto the managed Kubernetes
// sandbox; "any"/absent leaves the full environment/adapter choice intact.
// Reuses the same general-settings query the rest of the UI uses.
const { data: generalSettings } = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
retry: false,
});
const { data: instanceSettings } = useQuery({
queryKey: queryKeys.instance.settings,
queryFn: () => instanceSettingsApi.get(),
retry: false,
});
const { data: environments = [] } = useQuery<Environment[]>({
queryKey: selectedCompanyId ? queryKeys.environments.list(selectedCompanyId) : ["environments", "none"],
queryFn: () => environmentsApi.list(selectedCompanyId!),
// Load environments when the picker is enabled OR when execution is forced
// onto Kubernetes (so we can resolve and default to the managed K8s env even
// when the experimental environments picker is otherwise hidden).
enabled:
Boolean(selectedCompanyId) &&
(environmentsEnabled || generalSettings?.executionMode === "kubernetes"),
});
// Setting-driven: resolve whether the instance forces Kubernetes execution and
// which loaded environment is the managed Kubernetes sandbox.
const { forced: forcedKubernetes, kubernetesEnvironment } = useMemo(
() => resolveForcedKubernetesEnvironment(generalSettings?.executionMode, environments),
[generalSettings?.executionMode, environments],
);
const createSecret = useMutation({
mutationFn: (input: { name: string; value: string }) => {
if (!selectedCompanyId) throw new Error("Select an organization to create secrets");
return secretsApi.create(selectedCompanyId, input);
},
onSuccess: () => {
if (!selectedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
},
});
const uploadMarkdownImage = useMutation({
mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => {
if (!selectedCompanyId) throw new Error("Select an organization to upload images");
return assetsApi.uploadImage(selectedCompanyId, file, namespace);
},
});
// ---- Edit mode: overlay for dirty tracking ----
const [overlay, setOverlay] = useState<AgentConfigOverlay>(emptyOverlay);
const [environmentDraftDirty, setEnvironmentDraftDirty] = useState(false);
const [environmentEditorKey, setEnvironmentEditorKey] = useState(0);
const agentRef = useRef<Agent | null>(null);
// The overlay snapshot a background account-binding save persisted. The form
// stays editable while that save is in flight, so the agent refresh that
// follows it must not wipe edits made during the save. The refresh subtracts
// only what the save persisted; a user-initiated Save leaves the snapshot
// null and keeps the full wipe. An UNRELATED refresh can land while the save
// is still in flight — that refresh does not carry the persisted binding
// yet, so it must neither consume the snapshot nor subtract it: subtracting
// would drop the binding entry from the overlay while `props.agent` also
// lacks it, and an ordinary Save racing the binding refresh would then
// replace the config without the binding and undo the just-persisted bind.
// The overlay stays untouched until the save settles; the refresh after
// settlement consumes the snapshot and subtracts it.
const backgroundSaveOverlayRef = useRef<AgentConfigOverlay | null>(null);
const backgroundSaveInFlightRef = useRef(false);
// Clear overlay when agent data refreshes (after save)
useEffect(() => {
if (!isCreate) {
if (
agentRef.current !== null &&
props.agent !== agentRef.current &&
!backgroundSaveInFlightRef.current
) {
const persisted = backgroundSaveOverlayRef.current;
backgroundSaveOverlayRef.current = null;
setOverlay((prev) =>
persisted ? subtractPersistedOverlay(prev, persisted) : { ...emptyOverlay },
);
}
agentRef.current = props.agent;
}
}, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps
const isDirty = !isCreate && (isOverlayDirty(overlay) || environmentDraftDirty);
type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "debug" | "runtime";
/** Read effective value: overlay if dirty, else original */
function eff<T>(group: RecordOverlayGroup, field: string, original: T): T {
const o = overlay[group];
if (field in o) return o[field] as T;
return original;
}
/** Mark field dirty in overlay */
function mark(group: RecordOverlayGroup, field: string, value: unknown) {
setOverlay((prev) => ({
...prev,
[group]: { ...prev[group], [field]: value },
}));
}
function flushEnvironmentDraft() {
return environmentVariablesEditorRef.current?.flushPendingDraft() ?? null;
}
/**
* Replace the agent's API-access grants (top-level `access.<ALIAS>` keys) with
* the complete set emitted by the Secret access editor. Added/changed aliases
* are marked into the overlay; aliases dropped from the set are marked
* `undefined` so `buildAgentUpdatePatch` strips them.
*/
const applyAccessGrants = useCallback((next: Record<string, EnvSecretRefBinding>) => {
if (isCreate) return;
setOverlay((prev) => {
const effective = { ...(props.agent.adapterConfig ?? {}), ...prev.adapterConfig } as Record<string, unknown>;
const nextAdapterConfig: Record<string, unknown> = { ...prev.adapterConfig };
for (const [alias, binding] of Object.entries(next)) {
nextAdapterConfig[`${AGENT_ACCESS_CONFIG_PATH_PREFIX}${alias}`] = binding;
}
for (const key of Object.keys(effective)) {
if (!key.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) continue;
const alias = key.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length);
if (!(alias in next)) nextAdapterConfig[key] = undefined;
}
return { ...prev, adapterConfig: nextAdapterConfig };
});
}, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps
/** Build accumulated patch and send to parent */
const handleCancel = useCallback(() => {
setOverlay({ ...emptyOverlay });
setEnvironmentDraftDirty(false);
setEnvironmentEditorKey(key => key + 1);
}, []);
const handleSave = useCallback(async () => {
if (isCreate) return;
const flushedEnv = flushEnvironmentDraft();
const nextOverlay = flushedEnv
? {
...overlay,
adapterConfig: {
...overlay.adapterConfig,
env: flushedEnv,
},
}
: overlay;
if (!isOverlayDirty(nextOverlay)) return;
await props.onSave(buildAgentUpdatePatch(props.agent, nextOverlay));
}, [isCreate, isDirty, overlay, props]);
useEffect(() => {
if (!isCreate) {
props.onDirtyChange?.(isDirty);
props.onSaveActionChange?.(handleSave);
props.onCancelActionChange?.(handleCancel);
}
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]);
useEffect(() => {
if (isCreate) return;
return () => {
props.onSaveActionChange?.(null);
props.onCancelActionChange?.(null);
props.onDirtyChange?.(false);
};
}, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]);
// ---- Resolve values ----
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};
const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record<string, unknown>) : {};
const heartbeat = !isCreate ? ((runtimeConfig.heartbeat ?? {}) as Record<string, unknown>) : {};
const debug = !isCreate ? ((runtimeConfig.debug ?? {}) as Record<string, unknown>) : {};
const adapterType = isCreate
? props.values.adapterType
: overlay.adapterType ?? props.agent.adapterType;
const getCapabilities = useAdapterCapabilities();
const adapterCaps = getCapabilities(adapterType);
const isLocal = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt;
// The legacy working directory is an absolute path on the host, so the
// managed-sandbox-only policy hides it. A stored value stays untouched; it is
// inert while every run happens in the platform-managed environment.
const showLegacyWorkingDirectoryField =
isLocal
&& !hideHostPaths
&& shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
const supportedEnvironmentDrivers = useMemo(
() => new Set(supportedEnvironmentDriversForAdapter(adapterType)),
[adapterType],
);
const val = isCreate ? props.values : null;
const set = isCreate
? (patch: Partial<CreateConfigValues>) => props.onChange(patch)
: null;
// Create mode holds the non-secret stored-session claim after a Claude
// subscription login reaches the server `stored` state. The claim marks the
// fixed `CLAUDE_CODE_OAUTH_TOKEN` binding as present. The form sends the claim
// in the create request; the server binds and enforces the token
// independently. The claim never carries a token value.
const claudeStoredSessionId = isCreate ? val!.claudeStoredSessionId ?? null : null;
// After a login binds CLAUDE_CODE_OAUTH_TOKEN, the user-secret-definitions list
// in the cache is stale. The form reads that list once at page load, so it does
// not contain the definition the login just bound. The bound row then compares
// its key against the stale list and shows a false "no longer exists" health
// error. Invalidate the list so the row reads the fresh definitions and clears
// the error.
const invalidateUserSecretDefinitions = () => {
if (!selectedCompanyId) return;
void queryClient.invalidateQueries({
queryKey: queryKeys.secrets.userDefinitions(selectedCompanyId),
});
};
// Add the fixed binding to the create-mode form state after the login stores.
// Keep every unrelated binding. Hold the claim so the create request sends it.
const handleClaudeLoginStored = (storedSessionId: string) => {
if (!isCreate || !set) return;
const existingBindings = (val!.envBindings ?? {}) as Record<string, EnvBinding>;
set({
envBindings: { ...existingBindings, ...buildFixedClaudeOAuthBinding() },
claudeStoredSessionId: storedSessionId,
});
invalidateUserSecretDefinitions();
};
// Edit mode: after a login stores the token, add the fixed binding to the
// existing agent and persist it at once. The server already stored the token;
// this step binds `CLAUDE_CODE_OAUTH_TOKEN` to the agent's environment through
// the normal agent-update patch path, so no manual bind step remains. Flush any
// pending editor draft first, keep every unrelated binding, and mark the merged
// set into the overlay so the editor and the saved patch agree.
//
// An update can never consume a fresh login's stored-session claim -- only the
// create and hire paths can, per `enforceClaudeOAuthBindingClaim`. So this save
// must set `applyStoredClaudeLogin`, the same way `handleApplyStoredClaudeLoginEdit`
// does, or the server rejects the patch with the fixed claim error even though
// the login already stored the token. Without the flag every fresh login on an
// existing agent's own page fails to save the binding.
const handleClaudeLoginStoredEdit = async () => {
if (isCreate) return;
const flushedEnv = flushEnvironmentDraft();
const baseEnv =
flushedEnv ??
(eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>));
const nextEnv = { ...baseEnv, ...buildFixedClaudeOAuthBinding() };
const nextOverlay: AgentConfigOverlay = {
...overlay,
adapterConfig: { ...overlay.adapterConfig, env: nextEnv },
};
setOverlay(nextOverlay);
await props.onSave({
...buildAgentUpdatePatch(props.agent, nextOverlay),
applyStoredClaudeLogin: true,
});
invalidateUserSecretDefinitions();
};
// Edit mode: a Codex login that signed in to a DIFFERENT account than the
// company default cannot take effect through the shared company home — the
// promotion never displaces another account's claim there. Bind this
// agent's CODEX_HOME to the login's account-home secret and persist at
// once, the same one-step shape as the Claude stored-login bind above.
// Same-account logins skip the bind on purpose: the company-home refresh
// already carried them, and an unbound agent keeps following the company
// default across later credential rotations. No claim flag is needed —
// the secret already exists company-scoped, so this is an ordinary
// secret-reference binding through the normal agent-update patch.
const handleCodexAccountBindingEdit = async (claim: CodexAccountBindingClaim) => {
if (isCreate || !claim.companyIdentityDiffers) return;
const flushedEnv = flushEnvironmentDraft();
const baseEnv =
flushedEnv ??
(eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>));
const nextEnv: Record<string, EnvBinding> = {
...baseEnv,
CODEX_HOME: { type: "secret_ref", secretId: claim.secretId, version: "latest" },
};
const nextOverlay: AgentConfigOverlay = {
...overlay,
adapterConfig: { ...overlay.adapterConfig, env: nextEnv },
};
setOverlay(nextOverlay);
// This save runs in the background while the form stays editable. Record
// exactly what it persists so the agent refresh it triggers keeps edits
// made during the save (see the refresh effect) instead of wiping them
// with the persisted entries. The in-flight flag protects the snapshot
// from an unrelated refresh landing mid-save. A failed save never
// refreshes the agent with the binding, so clear the snapshot there — a
// later unrelated refresh then wipes normally.
backgroundSaveOverlayRef.current = nextOverlay;
backgroundSaveInFlightRef.current = true;
try {
await props.onSave(buildAgentUpdatePatch(props.agent, nextOverlay));
} catch (err) {
backgroundSaveOverlayRef.current = null;
throw err;
} finally {
backgroundSaveInFlightRef.current = false;
}
invalidateUserSecretDefinitions();
};
// Create mode: bind the fixed reference to an existing stored login with no new
// login round trip. Add the fixed binding and set the apply-existing flag. The
// create request sends the flag; the server binds the token only for a user
// actor and only when a stored value exists. Keep every unrelated binding.
const handleApplyStoredClaudeLogin = () => {
if (!isCreate || !set) return;
const existingBindings = (val!.envBindings ?? {}) as Record<string, EnvBinding>;
set({
envBindings: { ...existingBindings, ...buildFixedClaudeOAuthBinding() },
claudeApplyStoredLogin: true,
});
invalidateUserSecretDefinitions();
};
// Edit mode: bind the fixed reference to an existing stored login with no new
// login round trip, then persist it at once. Add the fixed binding to the
// existing agent and save the patch with the apply-existing flag. Flush any
// pending editor draft first and keep every unrelated binding.
const handleApplyStoredClaudeLoginEdit = async () => {
if (isCreate) return;
const flushedEnv = flushEnvironmentDraft();
const baseEnv =
flushedEnv ??
(eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>));
const nextEnv = { ...baseEnv, ...buildFixedClaudeOAuthBinding() };
const nextOverlay: AgentConfigOverlay = {
...overlay,
adapterConfig: { ...overlay.adapterConfig, env: nextEnv },
};
setOverlay(nextOverlay);
await props.onSave({
...buildAgentUpdatePatch(props.agent, nextOverlay),
applyStoredClaudeLogin: true,
});
invalidateUserSecretDefinitions();
};
const rawCurrentDefaultEnvironmentId = isCreate
? val!.defaultEnvironmentId ?? ""
: eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? "");
const currentDefaultEnvironmentId = useMemo(() => {
if (!rawCurrentDefaultEnvironmentId) return "";
const selected = environments.find((environment) => environment.id === rawCurrentDefaultEnvironmentId) ?? null;
return selected?.driver === "local" ? "" : rawCurrentDefaultEnvironmentId;
}, [environments, rawCurrentDefaultEnvironmentId]);
const currentDefaultEnvironment = useMemo(
() => environments.find((environment) => environment.id === currentDefaultEnvironmentId) ?? null,
[currentDefaultEnvironmentId, environments],
);
const instanceDefaultEnvironmentId = useMemo(() => {
const environmentId = instanceSettings?.defaultEnvironmentId ?? null;
if (!environmentId) return "";
const selected = environments.find((environment) => environment.id === environmentId) ?? null;
return selected?.driver === "local" ? "" : environmentId;
}, [environments, instanceSettings?.defaultEnvironmentId]);
const instanceDefaultEnvironment = useMemo(
() => environments.find((environment) => environment.id === instanceDefaultEnvironmentId) ?? null,
[environments, instanceDefaultEnvironmentId],
);
// The environment a login session runs in. It mirrors the Test resolution: the
// agent's own environment wins, otherwise the instance default, otherwise the
// local default. The login affordance shows only when this environment is a
// sandbox, because the canonical auth-missing check comes only from a sandbox
// target.
//
// The resolution passes the same managed-sandbox-only policy inputs as the
// adapter Test target, so both resolve to the same environment. Under the
// policy a resolution that lands on the local environment redirects to the
// managed sandbox the real run uses. Without the redirect the login target
// stays local while the Test and the real run use the managed sandbox, so the
// login affordance reads the wrong target. The resolver throws when the policy
// is on but no managed sandbox is available; a render must not throw, so this
// resolution catches that case and resolves no login environment. The Test
// mutation surfaces the same case as a fail-closed error.
const effectiveLoginEnvironmentId = useMemo(() => {
try {
return resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: rawCurrentDefaultEnvironmentId || null,
instanceDefaultEnvironmentId: instanceSettings?.defaultEnvironmentId ?? null,
localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(environments),
managedSandboxOnly: experimentalSettings?.enableManagedSandboxOnly === true,
managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(environments),
// The policy hides the local environment, so an agent default that still
// points at the hidden local row names no visible environment. Pass the
// visible ids so the resolver redirects that stale local default to the
// managed sandbox instead of the hidden local id.
visibleEnvironmentIds: environments.map((environment) => environment.id),
});
} catch {
return null;
}
}, [
rawCurrentDefaultEnvironmentId,
instanceSettings?.defaultEnvironmentId,
environments,
experimentalSettings?.enableManagedSandboxOnly,
]);
const effectiveLoginEnvironment = useMemo(
() => environments.find((environment) => environment.id === effectiveLoginEnvironmentId) ?? null,
[environments, effectiveLoginEnvironmentId],
);
// Load the sandbox provider capabilities. A login that runs on a real
// pseudo-terminal needs a provider that advertises the login pseudo-terminal
// capability. The login panel gate reads this to hide the panel for a provider
// without the capability. Enable the query when the adapter declares a login
// capability: every login runs on a real pseudo-terminal, so every login
// consults this data. The gate is advisory; the server resolves the capability
// again and fails closed.
const { data: environmentCapabilities } = useQuery({
queryKey: selectedCompanyId
? queryKeys.environments.capabilities(selectedCompanyId)
: ["environment-capabilities", "none"],
queryFn: () => environmentsApi.capabilities(selectedCompanyId!),
enabled: Boolean(selectedCompanyId) && adapterCaps.login != null,
});
// When the instance forces Kubernetes execution, new agents must default to the
// managed Kubernetes sandbox environment (never the implicit local default).
// Only applies in create mode and only once the K8s environment is loaded; if
// none is available the UI surfaces a notice instead of silently selecting it.
useEffect(() => {
if (!isCreate || !set || !forcedKubernetes || !kubernetesEnvironment) return;
if (currentDefaultEnvironmentId === kubernetesEnvironment.id) return;
set({ defaultEnvironmentId: kubernetesEnvironment.id });
}, [isCreate, set, forcedKubernetes, kubernetesEnvironment, currentDefaultEnvironmentId]);
const runnableEnvironments = useMemo(
() => environments.filter((environment) => {
if (!supportedEnvironmentDrivers.has(environment.driver)) return false;
if (environment.driver === "local") return false;
if (environment.driver !== "sandbox") return true;
const provider = typeof environment.config?.provider === "string" ? environment.config.provider : null;
return provider !== null && provider !== "fake";
}),
[environments, supportedEnvironmentDrivers],
);
const environmentOptions = useMemo(() => {
if (!currentDefaultEnvironment) return runnableEnvironments;
if (runnableEnvironments.some((environment) => environment.id === currentDefaultEnvironment.id)) {
return runnableEnvironments;
}
return [...runnableEnvironments, currentDefaultEnvironment];
}, [currentDefaultEnvironment, runnableEnvironments]);
// `runnableEnvironments` excludes the always-available Local environment, so a
// single entry already means the user has more than one environment configured
// (Local + that environment) and the override selector is meaningful.
const showEnvironmentOverrideControl = environmentsEnabled && (
forcedKubernetes ||
currentDefaultEnvironmentId.length > 0 ||
runnableEnvironments.length >= 1
);
const inheritedEnvironmentLabel = instanceDefaultEnvironment
? environmentDisplayLabel(instanceDefaultEnvironment)
: managedSandboxOnly
? "Paperclip Computer"
: "Local";
const runnerProvider = adapterType === "paperclip_runner"
? String(isCreate ? props.values.adapterSchemaValues?.provider ?? "codex"
: eff("adapterConfig", "provider", config.provider === "acpx" && config.acpxAgent === "codex" ? "codex" : config.provider ?? "codex"))
: undefined;
// Fetch adapter models for the effective provider, including unsaved changes.
const modelQueryKey = selectedCompanyId
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null, runnerProvider)
: ["agents", "none", "adapter-models", adapterType];
const {
data: fetchedModels,
error: fetchedModelsError,
} = useQuery({
queryKey: modelQueryKey,
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, {
environmentId: currentDefaultEnvironmentId || null,
provider: runnerProvider,
}),
enabled: Boolean(selectedCompanyId),
});
const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null);
const [refreshingModels, setRefreshingModels] = useState(false);
const models = fetchedModels ?? externalModels ?? [];
const adapterCommandField = "command";
const {
data: detectedModelData,
refetch: refetchDetectedModel,
} = useQuery({
queryKey: selectedCompanyId
? queryKeys.agents.detectModel(selectedCompanyId, adapterType)
: ["agents", "none", "detect-model", adapterType],
queryFn: () => {
if (!selectedCompanyId) {
throw new Error("Select an organization to detect the model");
}
return agentsApi.detectModel(selectedCompanyId, adapterType);
},
enabled: Boolean(selectedCompanyId && isLocal && adapterType !== "opencode_local" && adapterType !== "paperclip_runner"),
});
const detectedModel = detectedModelData?.model ?? null;
const detectedModelCandidates = detectedModelData?.candidates ?? [];
const { data: companyAgents = [] } = useQuery({
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"],
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: Boolean(!isCreate && selectedCompanyId),
});
/** Props passed to adapter-specific config field components */
const adapterFieldProps = {
mode,
isCreate,
adapterType,
values: isCreate ? props.values : null,
set: isCreate ? (patch: Partial<CreateConfigValues>) => props.onChange(patch) : null,
config,
eff: eff as <T>(group: "adapterConfig", field: string, original: T) => T,
mark: mark as (group: "adapterConfig", field: string, value: unknown) => void,
models,
// Resolve the effective instructions-file gate once. The instructions file
// is an absolute host path, so the managed-sandbox-only policy hides it for
// every adapter without a per-adapter edit.
hideInstructionsFile: hideInstructionsFile || hideHostPaths,
managedSandboxOnly: hideHostPaths,
};
// Section toggle state — advanced always starts collapsed
const [runPolicyAdvancedOpen, setRunPolicyAdvancedOpen] = useState(false);
const [configurationAdvancedOpen, setConfigurationAdvancedOpen] = useState(false);
const configSchema = useConfigSchema(adapterType);
const renderAdapterFields = (section: AdapterConfigSection) => (
<>
{adapterType === "claude_local" && <ClaudeLocalAdvancedFields {...adapterFieldProps} section={section} />}
<uiAdapter.ConfigFields {...adapterFieldProps} section={section} hideModel={isLocal} />
</>
);
// Popover states
const [modelOpen, setModelOpen] = useState(false);
const [thinkingEffortOpen, setThinkingEffortOpen] = useState(false);
function buildAdapterConfigForTest(adapterConfigPatch?: Record<string, unknown>): Record<string, unknown> {
if (isCreate) {
const next = uiAdapter.buildAdapterConfig(val!);
if (adapterConfigPatch) {
Object.assign(next, adapterConfigPatch);
}
return omitUndefinedEntries(next);
}
const base = config as Record<string, unknown>;
const next = { ...base, ...overlay.adapterConfig };
if (adapterConfigPatch) {
Object.assign(next, adapterConfigPatch);
}
return omitUndefinedEntries(next);
}
const testEnvironment = useMutation({
mutationFn: async () => {
if (!selectedCompanyId) {
throw new Error("Select an organization to test adapter environment");
}
const flushedEnv = flushEnvironmentDraft();
const adapterConfigPatch = flushedEnv ? { env: flushedEnv } : undefined;
// Probe where a real run would actually execute: the agent's own
// environment, else the instance default. Testing the host for an
// agent that runs in the instance-default sandbox reports failures
// (e.g. a CLI that only exists in the sandbox image) a real run would
// never hit. The raw id is sent even for a local environment — the
// server resolves the driver and probes the host in that case.
//
// Test can be clicked before the settings query settles (or after it
// failed with retry:false), so when the agent relies on the instance
// default, resolve the settings here rather than trusting the
// render-time cache. A fetch that still fails FAILS the test with an
// honest diagnostic — silently probing the host instead would report
// the exact false command-not-found failure this resolution exists to
// fix. Agents with their own environment never need the settings.
let settings = instanceSettings;
let environmentList = environments;
let managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true;
if (!rawCurrentDefaultEnvironmentId) {
// The agent has no own environment, so the Test resolves the instance
// default, the local default, or the managed sandbox. Resolve the
// settings, the environment list, and the managed-sandbox-only policy
// here, because the render-time queries can be unsettled, or the
// environments query can be disabled under the managed-sandbox-only
// policy. A failure surfaces an honest error, not a silent host probe
// that reports a false result.
try {
const [resolvedSettings, resolvedEnvironments, resolvedExperimental] =
await Promise.all([
queryClient.ensureQueryData({
queryKey: queryKeys.instance.settings,
queryFn: () => instanceSettingsApi.get(),
}),
queryClient.ensureQueryData({
queryKey: queryKeys.environments.list(selectedCompanyId),
queryFn: () => environmentsApi.list(selectedCompanyId),
}),
queryClient.ensureQueryData({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
}),
]);
settings = resolvedSettings;
environmentList = resolvedEnvironments;
managedSandboxOnly = resolvedExperimental?.enableManagedSandboxOnly === true;
} catch {
throw new Error(
"Could not load environment settings to determine which environment to test in. Retry the test.",
);
}
}
// Mirror the server run-time resolution, including the managed-sandbox-only
// redirect: when the resolution lands on the local environment and the
// policy is on, probe the managed sandbox the real run uses instead. The
// resolver throws when no managed sandbox is available, which the mutation
// surfaces as a fail-closed error rather than a local host probe.
const environmentId = resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: rawCurrentDefaultEnvironmentId || null,
instanceDefaultEnvironmentId: settings?.defaultEnvironmentId ?? null,
localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(environmentList),
managedSandboxOnly,
managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(environmentList),
// The policy hides the local environment, so an agent default that still
// points at the hidden local row names no visible environment. Pass the
// visible ids so the resolver redirects that stale local default to the
// managed sandbox instead of sending the hidden local id to the server.
visibleEnvironmentIds: environmentList.map((environment) => environment.id),
});
const adapterConfig = buildAdapterConfigForTest(adapterConfigPatch);
const agentId = isCreate ? undefined : props.agent.id;
if (props.compactTestFeedback) {
const providerAdapter = adapterType === "paperclip_runner"
? adapterConfig.provider === "codex" ? "codex_local"
: adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "claude" ? "claude_local"
: adapterType
: adapterType;
return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, environmentId });
}
return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, environmentId });
},
});
const [testActionPending, setTestActionPending] = useState(false);
const [testActionError, setTestActionError] = useState<string | null>(null);
const testActionLabel = "Test";
const isSavePending = !isCreate && Boolean(props.isSaving);
const testEnvironmentDisabled = testActionPending || isSavePending || !selectedCompanyId;
// Drop a stale Test result when the adapter type or the effective environment
// changes. A held result would keep the login affordance visible for a target
// the user no longer selected. The reset unmounts the login panel too, so its
// session state clears with it. Hold `reset` in a ref so the effect does not
// re-run on every render (the mutation object has a new identity each render).
const resetTestEnvironmentRef = useRef(testEnvironment.reset);
resetTestEnvironmentRef.current = testEnvironment.reset;
// Clear a stale Claude login claim when the adapter type or the effective
// environment changes. A login binds `CLAUDE_CODE_OAUTH_TOKEN` to one specific
// environment. After the environment changes, the held claim and the fixed
// binding no longer match the new target. The create request would still send
// the claim, and the server would reject it. So drop the claim, the
// apply-existing flag, and the fixed binding, and return the login panel to
// its initial state. Only create mode holds this form state; edit mode saves
// the binding at login time. Hold the clear in a ref so the effect does not
// re-run on every render (`set` and `val` have a new identity each render).
const clearClaudeLoginClaimRef = useRef<() => void>(() => {});
clearClaudeLoginClaimRef.current = () => {
if (!isCreate || !set) return;
const bindings = (val!.envBindings ?? {}) as Record<string, EnvBinding>;
const hasClaim =
val!.claudeStoredSessionId != null || val!.claudeApplyStoredLogin === true;
const hasBinding = CLAUDE_OAUTH_TOKEN_ENV_KEY in bindings;
if (!hasClaim && !hasBinding) return;
const nextBindings = { ...bindings };
delete nextBindings[CLAUDE_OAUTH_TOKEN_ENV_KEY];
set({
envBindings: nextBindings,
claudeStoredSessionId: null,
claudeApplyStoredLogin: false,
});
};
useEffect(() => {
resetTestEnvironmentRef.current();
setTestActionError(null);
clearClaudeLoginClaimRef.current();
}, [adapterType, effectiveLoginEnvironmentId]);
// Show the login affordance only for a current sandbox adapter that declares a
// login capability, and whose most recent Test result carries the canonical
// auth-missing check. The form reads the projected capability, not the adapter
// name. The result keeps its own `adapterType`, so the form reads the
// capability for that result adapter; a result from an adapter with no login
// capability never gates the panel.
const adapterSupportsSandboxLogin = adapterCaps.login != null;
const testResult = testEnvironment.data;
const testResultSupportsSandboxLogin =
testResult != null && getCapabilities(testResult.adapterType).login != null;
const authMissingCheck =
testResult && testResultSupportsSandboxLogin
? testResult.checks.find((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE) ?? null
: null;
// A login runs on a real pseudo-terminal, so it needs a provider that
// advertises the login pseudo-terminal capability. Read the capability for the
// effective environment provider. The form reads the adapter login capability,
// not the adapter name: every adapter login gates on this provider capability.
const effectiveLoginProvider =
typeof effectiveLoginEnvironment?.config?.provider === "string"
? effectiveLoginEnvironment.config.provider
: null;
const providerSupportsLoginPty =
effectiveLoginProvider != null &&
environmentCapabilities?.sandboxProviders?.[effectiveLoginProvider]?.supportsLoginPty === true;
const loginNeedsPty = adapterCaps.login != null;
const showAdapterLogin =
adapterSupportsSandboxLogin &&
effectiveLoginEnvironment?.driver === "sandbox" &&
Boolean(effectiveLoginEnvironmentId) &&
Boolean(selectedCompanyId) &&
Boolean(authMissingCheck) &&
(!loginNeedsPty || providerSupportsLoginPty);
const runEnvironmentTest = useCallback(async () => {
if (!selectedCompanyId) {
throw new Error("Select an organization to test adapter environment");
}
setTestActionPending(true);
setTestActionError(null);
testEnvironment.reset();
try {
return await testEnvironment.mutateAsync();
} catch (error) {
setTestActionError(error instanceof Error ? error.message : "Environment test failed");
throw error;
} finally {
setTestActionPending(false);
}
}, [selectedCompanyId, testEnvironment]);
// `runEnvironmentTest` (and `testEnvironmentDisabled`) change identity on every
// render because `useMutation` returns a fresh result object each time. Hold the
// latest behavior in a ref so the trigger handed to the parent stays referentially
// stable — otherwise the `onTestActionChange` effect below re-runs every render,
// pushing a new function into parent state and causing an infinite update loop.
const triggerRef = useRef<() => void>(() => {});
useEffect(() => {
triggerRef.current = () => {
if (testEnvironmentDisabled) return;
void runEnvironmentTest().catch(() => undefined);
};
}, [runEnvironmentTest, testEnvironmentDisabled]);
const triggerTestEnvironment = useCallback(() => {
triggerRef.current();
}, []);
useEffect(() => {
if (!showAdapterTestEnvironmentButton || !props.onTestActionChange) return;
props.onTestActionChange(triggerTestEnvironment);
return () => {
props.onTestActionChange?.(null);
};
}, [showAdapterTestEnvironmentButton, props.onTestActionChange, triggerTestEnvironment]);
useEffect(() => {
if (!showAdapterTestEnvironmentButton || !props.onTestActionStateChange) return;
props.onTestActionStateChange({
disabled: testEnvironmentDisabled,
pending: testActionPending,
});
return () => {
props.onTestActionStateChange?.({ disabled: true, pending: false });
};
}, [
showAdapterTestEnvironmentButton,
props.onTestActionStateChange,
testEnvironmentDisabled,
testActionPending,
]);
useEffect(() => {
if (!props.onTestFeedbackChange) return;
props.onTestFeedbackChange({
errorMessage: testActionError
?? (testEnvironment.error instanceof Error
? testEnvironment.error.message
: testEnvironment.error
? "Environment test failed"
: null),
result: testEnvironment.data ?? null,
// `showAdapterLogin` already requires a selected company and a non-empty
// environment id, so both are present here.
login:
showAdapterLogin && selectedCompanyId && effectiveLoginEnvironmentId
? { companyId: selectedCompanyId, adapterType, environmentId: effectiveLoginEnvironmentId }
: null,
});
return () => {
props.onTestFeedbackChange?.({ errorMessage: null, result: null, login: null });
};
}, [
props.onTestFeedbackChange,
testActionError,
testEnvironment.data,
testEnvironment.error,
showAdapterLogin,
selectedCompanyId,
adapterType,
effectiveLoginEnvironmentId,
]);
// Current model for display
const currentModelValue = isCreate
? val!.model ?? ""
: eff("adapterConfig", "model", String(config.model ?? ""));
const currentModelId = typeof currentModelValue === "string" ? currentModelValue : "";
async function handleRefreshModels() {
if (!selectedCompanyId) return;
setRefreshingModels(true);
setRefreshModelsError(null);
try {
const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: runnerProvider });
queryClient.setQueryData(modelQueryKey, refreshed);
} catch (error) {
setRefreshModelsError(error instanceof Error ? error.message : "Failed to refresh adapter models.");
} finally {
setRefreshingModels(false);
}
}
const thinkingEffortKey =
adapterType === "codex_local"
? "modelReasoningEffort"
: adapterType === "cursor"
? "mode"
: adapterType === "opencode_local"
? "variant"
: adapterType === "pi_local" ? "thinking" : "effort";
const thinkingEffortOptions =
adapterType === "codex_local"
? codexReasoningEffortOptions(currentModelId, "Auto").map((option) => ({
id: option.value,
label: option.label,
}))
: adapterType === "cursor"
? cursorModeOptions
: adapterType === "opencode_local"
? openCodeThinkingEffortOptions
: adapterType === "kimi_local"
? kimiThinkingEffortOptions
: adapterType === "pi_local"
? [{ id: "", label: "Auto" }, ...["off", "minimal", "low", "medium", "high", "xhigh"].map(id => ({ id, label: id }))]
: claudeThinkingEffortOptions;
const currentThinkingEffort = isCreate
? val!.thinkingEffort
: adapterType === "codex_local"
? eff(
"adapterConfig",
"modelReasoningEffort",
String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""),
)
: adapterType === "cursor"
? eff("adapterConfig", "mode", String(config.mode ?? ""))
: adapterType === "opencode_local"
? eff("adapterConfig", "variant", String(config.variant ?? ""))
: eff("adapterConfig", thinkingEffortKey, String(config[thinkingEffortKey] ?? ""));
const showThinkingEffort = adapterType !== "gemini_local"
&& adapterType !== "cursor_cloud"
&& adapterType !== "paperclip_runner";
const codexSearchEnabled = adapterType === "codex_local"
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
: false;
const effectiveRuntimeConfig = useMemo(() => {
if (isCreate) {
return {
heartbeat: {
enabled: val!.heartbeatEnabled,
intervalSec: val!.intervalSec,
},
};
}
const mergedHeartbeat = {
...(runtimeConfig.heartbeat && typeof runtimeConfig.heartbeat === "object"
? runtimeConfig.heartbeat as Record<string, unknown>
: {}),
...overlay.heartbeat,
};
return {
...runtimeConfig,
heartbeat: mergedHeartbeat,
};
}, [isCreate, overlay.heartbeat, runtimeConfig, val]);
const effectiveHeartbeat = asObject(effectiveRuntimeConfig.heartbeat);
const maxTurnContinuation = asObject(effectiveHeartbeat.maxTurnContinuation);
const maxTurnContinuationEnabled = asBoolean(maxTurnContinuation.enabled, true);
const maxTurnContinuationMaxAttempts = clampInteger(
asFiniteNumber(maxTurnContinuation.maxAttempts, MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS),
0,
MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP,
);
const maxTurnContinuationDelaySec = clampInteger(
asFiniteNumber(maxTurnContinuation.delayMs, MAX_TURN_CONTINUATION_DEFAULT_DELAY_SEC * 1000) / 1000,
0,
MAX_TURN_CONTINUATION_MAX_DELAY_SEC,
);
function updateMaxTurnContinuation(patch: Record<string, unknown>) {
mark("heartbeat", "maxTurnContinuation", {
...maxTurnContinuation,
...patch,
});
}
const environmentVariablesEditor = (
<EnvironmentVariablesEditor
ref={environmentVariablesEditorRef}
key={environmentEditorKey}
onDirtyChange={setEnvironmentDraftDirty}
hideDraftActions={!isCreate && props.hideInlineSave}
value={
isCreate
? ((val!.envBindings ?? EMPTY_ENV) as Record<string, EnvBinding>)
: (eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>))
}
secrets={availableSecrets}
userSecretDefinitions={userSecretDefinitions}
onCreateSecret={async (name, value) => {
const created = await createSecret.mutateAsync({ name, value });
return created;
}}
onChange={(env) =>
isCreate
? set!({ envBindings: env ?? {}, envVars: "" })
: mark("adapterConfig", "env", env)
}
/>
);
if (!isCreate && props.content === "secrets") {
return (
<div className={cn("relative", cards && "space-y-6")}>
{isDirty && !props.hideInlineSave && (
<div className="sticky top-0 z-10 flex items-center justify-end border-b border-primary/20 bg-background/90 px-4 py-2 backdrop-blur-sm">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">Unsaved changes</span>
<Button size="sm" onClick={handleSave} disabled={props.isSaving}>
{props.isSaving ? "Saving..." : "Save"}
</Button>
</div>
</div>
)}
{props.environmentVariablesPlacement === "secrets" && (
<div data-config-section="environment-variables" className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="mb-3 text-sm font-medium">Environment variables</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment variables</div>
}
<div className={cn(cards ? "rounded-lg border border-border p-4" : "px-4 pb-3")}>
{environmentVariablesEditor}
</div>
</div>
)}
<div data-config-section="secrets" className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="mb-3 text-sm font-medium">Secret access</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Secret access</div>
}
<div className={cn(cards ? "space-y-3 rounded-lg border border-border p-4" : "space-y-3 px-4 pb-3")}>
<p className="text-xs text-muted-foreground">{help.secretAccess}</p>
<AgentSecretAccessEditor
config={{ ...config, ...overlay.adapterConfig }}
secrets={availableSecrets}
onChange={applyAccessGrants}
onCreateSecret={(name, value) => createSecret.mutateAsync({ name, value })}
proposals={agentBindingProposals}
onApproveProposal={proposalReview.requestApprove}
onRejectProposal={proposalReview.requestReject}
/>
{proposalReview.dialogs}
</div>
</div>
</div>
);
}
return (
<ConfigSections order={props.sectionOrder} className={cn("relative", cards && "space-y-6")}>
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
{isDirty && !props.hideInlineSave && (
<div className="sticky top-0 z-10 flex items-center justify-end px-4 py-2 bg-background/90 backdrop-blur-sm border-b border-primary/20">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">Unsaved changes</span>
<Button
size="sm"
onClick={handleSave}
disabled={!isCreate && props.isSaving}
>
{!isCreate && props.isSaving ? "Saving..." : "Save"}
</Button>
</div>
</div>
)}
{/* ---- Identity (edit only) ---- */}
{!isCreate && (
<div data-config-section="identity" className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium mb-3">{props.sectionTitles?.["identity"] ?? "Identity"}</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Identity</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<Field label="Name" hint={help.name}>
<DraftInput
value={eff("identity", "name", props.agent.name)}
onCommit={(v) => mark("identity", "name", v)}
immediate
className={inputClass}
placeholder="Agent name"
/>
</Field>
<Field label="Title" hint={help.title}>
<DraftInput
value={eff("identity", "title", props.agent.title ?? "")}
onCommit={(v) => mark("identity", "title", v || null)}
immediate
className={inputClass}
placeholder="e.g. VP of Engineering"
/>
</Field>
<Field label="Reports to" hint={help.reportsTo}>
<ReportsToPicker
agents={companyAgents}
value={eff("identity", "reportsTo", props.agent.reportsTo ?? null)}
onChange={(id) => mark("identity", "reportsTo", id)}
excludeAgentIds={[props.agent.id]}
chooseLabel="Choose manager…"
/>
</Field>
{isLocal && !props.hidePromptTemplate && (
<>
<Field label="Prompt Template" hint={help.promptTemplate}>
<MarkdownEditor
value={eff(
"adapterConfig",
"promptTemplate",
String(config.promptTemplate ?? ""),
)}
onChange={(v) => mark("adapterConfig", "promptTemplate", v ?? "")}
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
contentClassName="min-h-(--sz-88px) text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = `agents/${props.agent.id}/prompt-template`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-100">
Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn.
</div>
</>
)}
</div>
</div>
)}
{/* ---- Execution ---- */}
{forcedKubernetes ? (
// Instance execution policy forces the managed Kubernetes sandbox
// (executionMode=kubernetes): never offer local / non-Kubernetes targets.
// Render the environment read-only instead of the selectable picker.
<div data-config-section="environment" className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
{cards
? <h3 className="text-sm font-medium mb-3">Environment</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<Field
label="Default environment"
hint="This instance runs all agents in the Kubernetes sandbox. Local execution is disabled."
>
{kubernetesEnvironment ? (
<div className={cn(inputClass, "flex items-center text-muted-foreground")}>
{kubernetesEnvironment.name} · Kubernetes sandbox
</div>
) : (
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
This instance requires the Kubernetes sandbox, but no managed Kubernetes
environment is available for this organization yet. Configure one before creating
agents; execution will not fall back to local.
</div>
)}
</Field>
</div>
</div>
) : showEnvironmentOverrideControl ? (
<div data-config-section="environment" className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
{cards
? <h3 className="text-sm font-medium mb-3">Environment</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<Field label="Environment override">
<div className="space-y-2">
<select
className={inputClass}
value={currentDefaultEnvironmentId}
onChange={(event) => {
const nextValue = event.target.value;
if (isCreate) {
set!({ defaultEnvironmentId: nextValue });
return;
}
mark("identity", "defaultEnvironmentId", nextValue || null);
}}
>
<option value="">Default: {inheritedEnvironmentLabel}</option>
{environmentOptions.map((environment) => (
<option key={environment.id} value={environment.id}>
{environmentDisplayLabel(environment)}
</option>
))}
</select>
</div>
</Field>
</div>
</div>
) : null}
{/* ---- Adapter ---- */}
<div data-config-section="adapter" className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
<div className={cn(cards ? "flex items-center justify-between mb-3" : "px-4 py-2 flex items-center justify-between gap-2")}>
{cards
? <h3 className="text-sm font-medium">{props.sectionTitles?.["adapter"] ?? "Adapter"}</h3>
: <span className="text-xs font-medium text-muted-foreground">Adapter</span>
}
{showInlineAdapterTestEnvironmentButton && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2.5 text-xs"
onClick={triggerTestEnvironment}
disabled={testEnvironmentDisabled}
>
{testActionPending ? `${testActionLabel}...` : testActionLabel}
</Button>
)}
</div>
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
{showAdapterTypeField && (
<Field label="Adapter type" hint={help.adapterType}>
<AdapterTypeDropdown
value={adapterType}
disabledTypes={adapterPickerDisabledTypes}
onChange={(t) => {
if (isCreate) {
// Reset all adapter-specific fields to defaults when switching adapter type
const { adapterType: _at, ...defaults } = defaultCreateValues;
const nextValues: CreateConfigValues = { ...defaults, adapterType: t };
if (t === "codex_local") {
nextValues.dangerouslyBypassSandbox =
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
} else if (t === "gemini_local") {
nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL;
} else if (t === "kimi_local") {
nextValues.model = DEFAULT_KIMI_LOCAL_MODEL;
} else if (t === "cursor") {
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
} else if (t === "opencode_local") {
nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL;
} else if (t === "paperclip_runner") {
nextValues.model = DEFAULT_CODEX_LOCAL_MODEL;
}
set!(nextValues);
} else {
// Clear all adapter config and explicitly blank out model + effort/mode keys
// so the old adapter's values don't bleed through via eff()
setOverlay((prev) => ({
...prev,
adapterType: t,
adapterConfig: {
model:
t === "gemini_local"
? DEFAULT_GEMINI_LOCAL_MODEL
: t === "kimi_local"
? DEFAULT_KIMI_LOCAL_MODEL
: t === "opencode_local"
? DEFAULT_OPENCODE_LOCAL_MODEL
: t === "cursor"
? DEFAULT_CURSOR_LOCAL_MODEL
: t === "paperclip_runner"
? resolvePaperclipRunnerTransitionModel(adapterType, config.model)
: "",
effort: "",
modelReasoningEffort: "",
variant: "",
mode: "",
...(t === "codex_local"
? {
dangerouslyBypassApprovalsAndSandbox:
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
}
: t === "paperclip_runner"
? {
...paperclipRunnerTransitionConfig(adapterType, eff("adapterConfig", "model", config.model)),
}
: {}),
},
}));
}
}}
/>
</Field>
)}
{showInlineAdapterTestEnvironmentFeedback && !props.compactTestFeedback && (testActionError || testEnvironment.error) && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{testActionError
?? (testEnvironment.error instanceof Error
? testEnvironment.error.message
: "Environment test failed")}
</div>
)}
{showInlineAdapterTestEnvironmentFeedback && !props.compactTestFeedback && testEnvironment.data && (
<AdapterEnvironmentResult result={testEnvironment.data} />
)}
{showInlineAdapterTestEnvironmentFeedback && showAdapterLogin && (
<AdapterLoginPanel
key={`${adapterType}:${effectiveLoginEnvironmentId}`}
companyId={selectedCompanyId!}
adapterType={adapterType}
environmentId={effectiveLoginEnvironmentId!}
onStored={isCreate ? handleClaudeLoginStored : handleClaudeLoginStoredEdit}
onApplyStored={
isCreate ? handleApplyStoredClaudeLogin : handleApplyStoredClaudeLoginEdit
}
onAccountBinding={isCreate ? undefined : handleCodexAccountBindingEdit}
/>
)}
{/* Working directory */}
{showLegacyWorkingDirectoryField && (
<Field label="Working directory (deprecated)" hint={help.cwd}>
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<DraftInput
value={
isCreate
? val!.cwd
: eff("adapterConfig", "cwd", String(config.cwd ?? ""))
}
onCommit={(v) =>
isCreate
? set!({ cwd: v })
: mark("adapterConfig", "cwd", v || undefined)
}
immediate
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
placeholder="/path/to/project"
/>
<ChoosePathButton />
</div>
</Field>
)}
{renderAdapterFields("adapter")}
{isLocal && (<>
<ModelDropdown
models={models}
value={currentModelId}
onChange={(v) => {
const supportedEfforts = codexReasoningEffortOptions(v, "Auto");
const clearUnsupportedEffort = adapterType === "codex_local"
&& Boolean(currentThinkingEffort)
&& !supportedEfforts.some((option) => option.value === currentThinkingEffort);
if (isCreate) {
set!({
model: v,
...(clearUnsupportedEffort ? { thinkingEffort: "" } : {}),
});
return;
}
mark("adapterConfig", "model", v || undefined);
if (clearUnsupportedEffort) {
mark("adapterConfig", thinkingEffortKey, undefined);
mark("adapterConfig", "reasoningEffort", undefined);
}
}}
open={modelOpen}
onOpenChange={setModelOpen}
defaultLabel={adapterType === "claude_local" ? `Default (${DEFAULT_CLAUDE_LOCAL_MODEL})` : undefined}
allowDefault={adapterType !== "opencode_local" && adapterType !== "pi_local" && adapterType !== "paperclip_runner"}
required={adapterType === "opencode_local" || adapterType === "pi_local"}
groupByProvider={adapterType === "opencode_local" || adapterType === "pi_local"}
creatable
detectedModel={detectedModel}
detectedModelCandidates={[]}
onDetectModel={adapterType === "opencode_local" || adapterType === "paperclip_runner"
? undefined
: async () => {
const result = await refetchDetectedModel();
return result.data?.model ?? null;
}}
onRefreshModels={
supportsAdapterModelRefresh(adapterType)
? handleRefreshModels
: undefined
}
refreshingModels={refreshingModels}
detectModelLabel="Detect model"
emptyDetectHint="No model detected. Select or enter one manually."
/>
{(refreshModelsError || fetchedModelsError) && (
<p className="text-xs text-destructive">
{refreshModelsError
?? (fetchedModelsError instanceof Error
? fetchedModelsError.message
: "Failed to load adapter models.")}
</p>
)}
{adapterType === "opencode_local"
&& currentDefaultEnvironment
&& currentDefaultEnvironment.driver !== "local" && (
<p className="text-xs text-muted-foreground">
Live OpenCode model discovery only runs for Local environments. Using the curated list and manual entry for {currentDefaultEnvironment.name}.
</p>
)}
{showThinkingEffort && (
<>
<ThinkingEffortDropdown
value={currentThinkingEffort}
options={thinkingEffortOptions}
onChange={(v) =>
isCreate
? set!({ thinkingEffort: v })
: mark("adapterConfig", thinkingEffortKey, v || undefined)
}
open={thinkingEffortOpen}
onOpenChange={setThinkingEffortOpen}
/>
{adapterType === "codex_local" &&
codexSearchEnabled &&
currentThinkingEffort === "minimal" && (
<p className="text-xs text-amber-400">
Codex may reject `minimal` thinking when search is enabled.
</p>
)}
</>
)}
</>)}
</div>
</div>
{/* ---- Configuration ---- */}
{(
<div data-config-section="configuration" className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium mb-3">Configuration</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Configuration</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
{!isCreate && typeof config.bootstrapPromptTemplate === "string" && config.bootstrapPromptTemplate && (
<>
<Field label="Bootstrap prompt (legacy)" hint={help.bootstrapPrompt}>
<MarkdownEditor
value={eff(
"adapterConfig",
"bootstrapPromptTemplate",
String(config.bootstrapPromptTemplate ?? ""),
)}
onChange={(v) =>
mark("adapterConfig", "bootstrapPromptTemplate", v || undefined)
}
placeholder="Optional initial setup prompt for the first run"
contentClassName="min-h-(--sz-44px) text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = `agents/${props.agent.id}/bootstrap-prompt`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
Bootstrap prompt is legacy and will be removed in a future release. Consider moving this content into the agent&apos;s prompt template or instructions file instead.
</div>
</>
)}
{renderAdapterFields("configuration")}
{(isLocal || adapterType === "process" || configSchema?.fields.some((field) => schemaFieldSection(field.key) === "advanced")) && (
<CollapsibleSection
title="Advanced"
open={configurationAdvancedOpen}
onToggle={() => setConfigurationAdvancedOpen(!configurationAdvancedOpen)}
>
<div className="space-y-3">
{isLocal && (<> {/*
The command names a binary on the execution host, so the
managed-sandbox-only policy hides it: the platform-managed image
owns the binary. Hiding is presentation only. A stored
`adapterConfig.command` stays as it is and the server does not
reject one, because an import carries adapter configuration
written on another instance; rejecting it would break that flow.
The value is inert while the policy is on. The field also stays
hidden until the policy is known, so a stored command never
flashes on a managed instance.
*/}
{!hideHostPaths && (
<Field label="Command" hint={help.localCommand}>
<DraftInput
value={
isCreate
? val!.command
: eff(
"adapterConfig",
adapterCommandField,
String(
config.command ?? "",
),
)
}
onCommit={(v) =>
isCreate
? set!({ command: v })
: mark("adapterConfig", adapterCommandField, v || null)
}
immediate
className={inputClass}
placeholder={
({
claude_local: "claude",
codex_local: "codex",
gemini_local: "gemini",
kimi_local: "kimi",
pi_local: "pi",
cursor: "agent",
opencode_local: "opencode",
} as Record<string, string>)[adapterType] ?? adapterType.replace(/_local$/, "")
}
/>
</Field>
)}
<Field label="Extra args (comma-separated)" hint={help.extraArgs}>
<DraftInput
value={
isCreate
? val!.extraArgs
: eff("adapterConfig", "extraArgs", formatArgList(config.extraArgs))
}
onCommit={(v) =>
isCreate
? set!({ extraArgs: v })
: mark("adapterConfig", "extraArgs", v?.trim() ? parseCommaArgs(v) : null)
}
className={inputClass}
placeholder="e.g. --verbose, --foo=bar"
/>
</Field>
</>)}
{renderAdapterFields("advanced")}
</div>
</CollapsibleSection>
)}
</div>
</div>
)}
{props.environmentVariablesPlacement !== "secrets" && (isLocal || configSchema?.fields.some((field) => schemaFieldSection(field.key) === "environment")) && (
<div data-config-section="environment-variables" className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium mb-3">Environment variables</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment variables</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
{isLocal ? environmentVariablesEditor : renderAdapterFields("environment")}
</div>
</div>
)}
{/* ---- Run Policy ---- */}
{isCreate && showCreateRunPolicySection ? (
<div data-config-section="run-policy" className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2"><Heart className="h-3 w-3" /> Run Policy</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<ToggleWithNumber
label="Heartbeat on interval"
hint={help.heartbeatInterval}
checked={val!.heartbeatEnabled}
onCheckedChange={(v) => set!({ heartbeatEnabled: v })}
number={val!.intervalSec}
onNumberChange={(v) => set!({ intervalSec: v })}
numberLabel="sec"
numberPrefix="Run heartbeat every"
numberHint={help.intervalSec}
showNumber={val!.heartbeatEnabled}
/>
<CollapsibleSection title="Advanced Run Policy" open={runPolicyAdvancedOpen} onToggle={() => setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)}>
<div className="space-y-3">{renderAdapterFields("runPolicy")}</div>
</CollapsibleSection>
</div>
</div>
) : !isCreate ? (
<div data-config-section="run-policy" className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2"><Heart className="h-3 w-3" /> Run Policy</div>
}
<div className={cn(cards ? "border border-border rounded-lg overflow-hidden" : "")}>
<div className={cn(cards ? "p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<ToggleWithNumber
label="Heartbeat on interval"
hint={help.heartbeatInterval}
checked={eff("heartbeat", "enabled", heartbeat.enabled === true)}
onCheckedChange={(v) => mark("heartbeat", "enabled", v)}
number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))}
onNumberChange={(v) => mark("heartbeat", "intervalSec", v)}
numberLabel="sec"
numberPrefix="Run heartbeat every"
numberHint={help.intervalSec}
showNumber={eff("heartbeat", "enabled", heartbeat.enabled === true)}
/>
</div>
<CollapsibleSection
title="Advanced Run Policy"
bordered={cards}
open={runPolicyAdvancedOpen}
onToggle={() => setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)}
>
<div className="space-y-3">
{renderAdapterFields("runPolicy")}
{isLocal && (<>
{/* Edit-only: timeout + grace period */}
{!isCreate && (
<>
{!configSchema?.fields.some((field) => field.key === "timeoutSec") && (
<Field label="Timeout (sec)" hint={help.timeoutSec}>
<DraftNumberInput
value={eff(
"adapterConfig",
"timeoutSec",
Number(config.timeoutSec ?? 0),
)}
onCommit={(v) => mark("adapterConfig", "timeoutSec", v)}
immediate
className={inputClass}
/>
</Field>
)}
{!configSchema?.fields.some((field) => field.key === "graceSec") && (
<Field label="Interrupt grace period (sec)" hint={help.graceSec}>
<DraftNumberInput
value={eff(
"adapterConfig",
"graceSec",
Number(config.graceSec ?? 15),
)}
onCommit={(v) => mark("adapterConfig", "graceSec", v)}
immediate
className={inputClass}
/>
</Field>
)}
</>
)}
</>)}
<ToggleField
label="Wake on demand"
hint={help.wakeOnDemand}
checked={eff(
"heartbeat",
"wakeOnDemand",
heartbeat.wakeOnDemand !== false,
)}
onChange={(v) => mark("heartbeat", "wakeOnDemand", v)}
/>
<Field label="Cooldown (sec)" hint={help.cooldownSec}>
<DraftNumberInput
value={eff(
"heartbeat",
"cooldownSec",
Number(heartbeat.cooldownSec ?? 10),
)}
onCommit={(v) => mark("heartbeat", "cooldownSec", v)}
immediate
className={inputClass}
/>
</Field>
<Field label="Max concurrent runs" hint={help.maxConcurrentRuns}>
<DraftNumberInput
value={eff(
"heartbeat",
"maxConcurrentRuns",
Number(heartbeat.maxConcurrentRuns ?? AGENT_DEFAULT_MAX_CONCURRENT_RUNS),
)}
onCommit={(v) => mark("heartbeat", "maxConcurrentRuns", v)}
immediate
className={inputClass}
/>
</Field>
<div className="rounded-md border border-border/70 px-3 py-2">
<ToggleField
label="Continue after max-turn stop"
hint={help.maxTurnContinuationEnabled}
checked={maxTurnContinuationEnabled}
onChange={(v) => updateMaxTurnContinuation({ enabled: v })}
/>
{maxTurnContinuationEnabled ? (
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<Field label="Continuation attempts" hint={help.maxTurnContinuationMaxAttempts}>
<DraftNumberInput
value={maxTurnContinuationMaxAttempts}
onCommit={(v) =>
updateMaxTurnContinuation({
maxAttempts: clampInteger(v, 0, MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP),
})}
immediate
className={inputClass}
/>
</Field>
<Field label="Continuation delay (sec)" hint={help.maxTurnContinuationDelaySec}>
<DraftNumberInput
value={maxTurnContinuationDelaySec}
onCommit={(v) =>
updateMaxTurnContinuation({
delayMs: clampDelayMsFromSeconds(v),
})}
immediate
className={inputClass}
/>
</Field>
</div>
) : null}
</div>
</div>
</CollapsibleSection>
</div>
</div>
) : null}
{/* ---- Debugging ---- */}
{!isCreate && canConfigureProviderTrace ? (
<div className={cn(!cards && "border-b border-border")}>
{cards ? (
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium">
<Bug className="h-3 w-3" /> Debugging
</h3>
) : (
<div className="flex items-center gap-2 px-4 py-2 text-xs font-medium text-muted-foreground">
<Bug className="h-3 w-3" /> Debugging
</div>
)}
<div
className={cn(
"border-border bg-accent/30",
cards
? "rounded-lg border p-4"
: "mx-4 mb-4 rounded-md border px-3 py-3",
)}
>
<ToggleField
label="Capture raw provider traces"
hint="Stores exact provider traffic for every future run until disabled. Traces may contain sensitive prompts and tool arguments, are administrator-only, and expire after 24 hours."
checked={eff<unknown>("debug", "providerTrace", debug.providerTrace) === "raw"}
onChange={(enabled) =>
mark("debug", "providerTrace", enabled ? "raw" : undefined)
}
/>
{eff<unknown>("debug", "providerTrace", debug.providerTrace) === "raw" ? (
<div className="mt-3 flex items-start gap-2 rounded-md border border-border bg-background/60 px-3 py-2 text-xs text-foreground">
<Bug className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Raw tracing is on for future runs. Paperclip keeps at most 64 MiB per run and automatically deletes it after 24 hours.
</span>
</div>
) : null}
</div>
</div>
) : null}
{props.compactTestFeedback && showInlineAdapterTestEnvironmentFeedback && showAdapterTestEnvironmentButton && (
<RuntimeTestCard
state={testActionPending ? "running" : testActionError || testEnvironment.error ? "fail" : testResult?.status ?? "idle"}
result={testResult ?? null}
error={testActionError ?? (testEnvironment.error instanceof Error ? testEnvironment.error.message : null)}
onTest={triggerTestEnvironment}
disabled={testEnvironmentDisabled}
/>
)}
</ConfigSections>
);
}
// The public session states that end a login. The panel stops the status poll
// and shows a terminal message when the session reaches one of these.
const ADAPTER_LOGIN_TERMINAL_STATUSES = new Set<AdapterAuthSessionStatus>([
"authenticated",
"failed",
"timed_out",
"cancelled",
]);
// The status route poll interval while a session is active (Decision A). The
// poll stops at a terminal state.
const ADAPTER_LOGIN_POLL_INTERVAL_MS = 2000;
// A copy-to-clipboard button. It mirrors the workspace service control bar: a
// short "copied" flash, then it returns to the copy icon.
function AdapterLoginCopyButton({ value, label }: { value: string; label: string }) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
},
[],
);
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={label}
title={label}
className="text-muted-foreground hover:text-foreground"
onClick={async () => {
try {
await copyTextToClipboard(value);
setCopied(true);
} catch {
setCopied(false);
}
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setCopied(false), 1500);
}}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
</Button>
);
}
// The terminal message for a finished login. It never shows a secret. It shows
// only the fixed, non-secret failure message the server returns.
function AdapterLoginTerminalState({
status,
message,
}: {
status: AdapterAuthSessionStatus;
message: string | null;
}) {
if (status === "authenticated") {
return (
<div className="flex items-center gap-2 text-(length:--text-micro) text-foreground">
<Check className="size-3 shrink-0" />
<span>Authenticated. The environment has credentials now.</span>
</div>
);
}
const label =
status === "timed_out"
? "Login timed out"
: status === "cancelled"
? "Login cancelled"
: "Login failed";
return (
<div className="flex items-start gap-2 text-(length:--text-micro) text-destructive">
<TriangleAlert className="size-3 shrink-0" />
<span>
{label}
{message ? `: ${message}` : "."}
</span>
</div>
);
}
// The login panel for one adapter in one sandbox environment. It starts a login
// session, polls the status route, and shows the one-time code and the
// authentication URL with copy and open actions. It shows the terminal states.
// It never writes the code, the URL, or any credential byte to a log line.
//
// The panel holds its own session state. The parent gives it a stable `key` from
// the adapter type and the environment id, so a change to either remounts the
// panel with a fresh session state.
// The props that identify one login panel: one adapter in one sandbox
// environment for one company. A parent that lifts the test feedback renders
// the panel from this descriptor.
export type AdapterLoginDescriptor = {
companyId: string;
adapterType: string;
environmentId: string;
};
// The panel props. `onStored` reports the non-secret `storedSessionId` claim from
// a Claude login that reaches the server `stored` state. The create-mode form
// holds that claim for the fixed binding; the callback never carries a token.
// `onApplyStored` binds the fixed reference to an existing stored login with no
// new login round trip. The panel shows the apply-existing affordance only when
// the status route reports a stored value.
// `autoStart`, `onCancel`, `onConnected` and `chrome` are what the onboarding
// connect step needs, and each is off or absent by default so the two settings
// surfaces that render this panel keep the behaviour they have.
//
// They are props on the existing panels rather than a second implementation
// because the part onboarding needs unchanged is the whole of it: the session
// start, the two polls, the server deadline, the one-shot completion read. A
// copy drawn to the new design would have had to reproduce all of that
// correctly, and the first thing to rot would have been the timeout and
// cleanup paths, which are the ones nobody exercises by hand.
export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
onStored?: (storedSessionId: string) => void;
onApplyStored?: () => void;
// Applies the non-secret Codex account-binding claim from an authenticated
// owner read: the company secret that names the signed-in account's own
// home. The panel calls this only when the company default home stayed on a
// DIFFERENT account — the one case where the login cannot take effect
// through the shared company home — and it AWAITS the handler, rendering
// saving/bound/failed states with an explicit Retry on failure, so a
// rejected save is never silently swallowed. The claim never carries a
// token byte or an account identifier.
onAccountBinding?: (claim: CodexAccountBindingClaim) => void | Promise<void>;
// Start the login on mount instead of waiting for a press. The connect step's
// footer button is the press — by the time the panel is rendered there, the
// customer has already asked for this.
autoStart?: boolean;
// The login reached its success state. Onboarding advances on this, which is
// why the `onboarding` chrome draws no success state of its own — the screen
// it would appear on is already gone.
onConnected?: () => void;
// The pasted code went to the server. Fires as the submit starts rather than
// when the login finishes, so a caller can show the work the moment the
// customer has done their part: the round trip to `onConnected` is a poll
// and a completion read, long enough to read as nothing having happened.
onCodeSubmitted?: () => void;
// A submitted code did not become a stored login — the submit was refused,
// the completion failed, or the session failed or ran out of time. The pair
// of `onCodeSubmitted`, so a caller that showed work can stop showing it.
onSubmitFailed?: () => void;
chrome?: AdapterLoginChrome;
/**
* The address the customer has to open, once the server has produced one.
*
* The one fact about a running login that the step needs outside the card:
* its own button is what sends the customer there, and a prompt arriving is
* what moves the step from waiting to ready. Everything else it needs the
* panel already does — the paste submits itself, and the submit and how it
* ended are reported through `onCodeSubmitted`, `onSubmitFailed` and
* `onConnected` — so this stays a single value rather than a whole session
* handed upward.
*/
onPromptReady?: (authorizationUrl: string | null) => void;
};
// The login panel dispatcher. It picks the panel from the projected panel mode,
// not from the adapter name. The `submitted_browser_code` mode shows the
// submitted-browser-code panel; every other mode shows the displayed-code panel.
/**
* The account a source signs in to, named where one is known.
*
* "Sign in to the environment" describes the plumbing — a login performed inside
* a sandbox — and is the honest label when the provider is unknown. But for the
* two sources onboarding offers, the customer is signing in to Anthropic or to
* OpenAI, and naming that is what tells them which password manager entry to
* reach for. The generic wording stays for anything not listed, where a guess
* would be worse than a description.
*/
const ADAPTER_LOGIN_PROVIDER: Record<string, string> = {
claude_local: "Anthropic",
codex_local: "OpenAI",
};
function adapterLoginTitle(adapterType: string): string {
const provider = ADAPTER_LOGIN_PROVIDER[adapterType];
return provider ? `Sign in to ${provider}` : "Sign in to the environment";
}
export function AdapterLoginPanel(props: AdapterLoginPanelProps) {
const getCapabilities = useAdapterCapabilities();
const panelMode = getCapabilities(props.adapterType).login?.panelMode;
if (panelMode === "submitted_browser_code") {
return <SubmittedBrowserCodeLoginPanel {...props} />;
}
return <DisplayedCodeLoginPanel {...props} />;
}
function DisplayedCodeLoginPanel({
companyId,
adapterType,
environmentId,
autoStart,
onConnected,
onAccountBinding,
chrome = "panel",
onPromptReady,
}: AdapterLoginPanelProps) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [startError, setStartError] = useState<string | null>(null);
// The server delivers the one-time prompt on the first owner read only. Latch
// it so a later poll that returns a null prompt does not hide the code and the
// URL.
const [latchedPrompt, setLatchedPrompt] = useState<AdapterAuthSessionPrompt | null>(null);
// The cross-account bind's own lifecycle (see the binding block below).
// Declared with the panel's state because `startDisabled` reads it: a
// saving bind blocks a new Sign in.
const [accountBindState, setAccountBindState] = useState<"idle" | "saving" | "bound" | "failed">(
"idle",
);
// True for the session currently held in `sessionId` when it came from the
// owner-scoped resume read rather than a fresh `startLogin`. It marks the
// one case that needs the extra release-on-error path below: a session this
// browser instance did not just start, so a broken poll cannot fall back to
// the ordinary "let the user press Sign in again" recovery — the owner has
// no local memory of ever starting it.
const resumedRef = useRef(false);
const startLogin = useMutation({
mutationFn: () => agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId }),
onSuccess: (session) => {
resumedRef.current = false;
setStartError(null);
setLatchedPrompt(null);
// A fresh login is a fresh bind decision: clear the previous session's
// bind narration so its outcome cannot masquerade as this session's.
setAccountBindState("idle");
setSessionId(session.sessionId);
},
onError: (error) => {
setStartError(error instanceof Error ? error.message : "Could not start the login.");
},
});
// Reset local state, so the panel returns to its idle start state and the
// Sign in button is available again.
const clearActiveSession = useCallback(() => {
resumedRef.current = false;
setSessionId(null);
setLatchedPrompt(null);
setStartError(null);
}, []);
const cancelLogin = useMutation({
mutationFn: () => agentsApi.cancelAdapterAuthLogin(companyId, adapterType, sessionId!),
onSuccess: clearActiveSession,
onError: (error) => {
setStartError(error instanceof Error ? error.message : "Could not cancel the login.");
},
});
// Read the caller's active session on mount, with no session id, so the
// browser rediscovers its own session after a reload with no local state. A
// 404 means no active session for the caller.
const activeSessionQuery = useQuery({
queryKey: ["adapter-login-active-session", companyId, adapterType],
queryFn: async () => {
try {
return await agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType);
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
},
retry: false,
// Never answered from cache. This read decides whether to adopt a running
// session or start a new one, and a cached "none" from an earlier mount is
// exactly wrong after Back: the panel would read `isFetched` immediately,
// see the stale null, and start a second login while the refetch was still
// in flight — which the per-owner cap then rejects.
gcTime: 0,
staleTime: 0,
});
// While the panel releases a resumed session it cannot recover (see below),
// it keeps showing the login as active rather than dropping back to idle, so
// it does not clear local state before the release finishes.
const [releasingResumedSession, setReleasingResumedSession] = useState(false);
const statusQuery = useQuery({
queryKey: ["adapter-login-status", companyId, adapterType, sessionId],
queryFn: () => agentsApi.getAdapterAuthLoginStatus(companyId, adapterType, sessionId!),
enabled: Boolean(sessionId) && !releasingResumedSession,
// A status 404 is unrecoverable: the server removed the row, so a retry
// cannot bring it back. Stop at once and fail loudly.
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status === 404) return false;
return failureCount < 3;
},
refetchInterval: (query) => {
const status = query.state.data?.status;
return status && ADAPTER_LOGIN_TERMINAL_STATUSES.has(status)
? false
: ADAPTER_LOGIN_POLL_INTERVAL_MS;
},
});
// Latch the first non-null prompt for the current session. A later poll
// returns a null prompt after the one-time delivery, so keep the latched value.
useEffect(() => {
const next = statusQuery.data?.prompt ?? null;
if (next) setLatchedPrompt(next);
}, [statusQuery.data]);
const session = statusQuery.data ?? startLogin.data ?? null;
const status = session?.status ?? null;
const prompt = latchedPrompt;
const isTerminal = status ? ADAPTER_LOGIN_TERMINAL_STATUSES.has(status) : false;
const isActive = Boolean(sessionId) && !isTerminal;
// A saving bind also blocks a new Sign in: the bind is an agent-update save,
// and a second login started while it is in flight could finish its own
// save first — the older save would then land last and silently revert the
// agent to the previous account while the panel reports the newer bind.
// Serializing at the only entry point is the whole fix; the panel has no
// other way to start a login mid-save.
const startDisabled = startLogin.isPending || isActive || accountBindState === "saving";
// Adopt the caller's active session once, on mount. This is what makes a
// page reload keep the session: with no local state at all, the panel would
// otherwise show its idle start state even though the server still holds an
// active login for this owner.
const resumeAttemptedRef = useRef(false);
useEffect(() => {
if (resumeAttemptedRef.current || !activeSessionQuery.isFetched) return;
resumeAttemptedRef.current = true;
const active = activeSessionQuery.data;
if (!active) return;
resumedRef.current = true;
setStartError(null);
setLatchedPrompt(active.prompt ?? null);
setSessionId(active.sessionId);
}, [activeSessionQuery.isFetched, activeSessionQuery.data]);
// A resumed session's status poll found the session already gone: the read
// that discovered it and the poll that tried to use it raced, and the
// session lost. The panel cannot resume it, and there is no unmount cleanup
// left to fall back on, so it releases the reservation itself and waits for
// that release before it returns to the idle start state.
useEffect(() => {
const error = statusQuery.error;
if (!(error instanceof ApiError && error.status === 404)) return;
if (!resumedRef.current || releasingResumedSession) return;
setReleasingResumedSession(true);
const id = sessionId;
void (async () => {
if (id) {
await agentsApi.cancelAdapterAuthLogin(companyId, adapterType, id).catch(() => {
// The session is already gone either way; nothing more to do.
});
}
setReleasingResumedSession(false);
clearActiveSession();
})();
}, [statusQuery.error, releasingResumedSession, sessionId, companyId, adapterType, clearActiveSession]);
// Start once, on mount, when the caller has already taken the press, and
// only once the resume read has answered: a resumed session takes over
// instead of a fresh start. The ref is the guard rather than the mutation's
// own pending flag: `startLogin` settles, and without a latch a re-render
// after it settles would read "not pending, no session yet" during the gap
// before the session id lands and start a second login the server would
// count against the per-owner cap.
const autoStartedRef = useRef(false);
const startLoginRef = useRef(startLogin.mutate);
startLoginRef.current = startLogin.mutate;
useEffect(() => {
if (!autoStart || autoStartedRef.current) return;
// A failed lookup is not proof that no session exists: only a successful
// lookup is. Show the failure to the user instead of starting a second
// login the server would reject against the per-owner cap.
if (activeSessionQuery.isError) {
autoStartedRef.current = true;
setStartError(
activeSessionQuery.error instanceof Error
? activeSessionQuery.error.message
: "Could not check for an active login.",
);
return;
}
if (!activeSessionQuery.isSuccess) return;
autoStartedRef.current = true;
if (activeSessionQuery.data) return;
startLoginRef.current();
}, [
autoStart,
activeSessionQuery.isSuccess,
activeSessionQuery.isError,
activeSessionQuery.data,
activeSessionQuery.error,
]);
// Report success upward once. `authenticated` is this panel's terminal
// success: unlike the Claude login there is no completion read after it, so
// the status is the whole of the news.
const connectedRef = useRef(false);
const onConnectedRef = useRef(onConnected);
onConnectedRef.current = onConnected;
useEffect(() => {
if (status !== "authenticated" || connectedRef.current) return;
connectedRef.current = true;
onConnectedRef.current?.();
}, [status]);
// Drive the account-binding hand-off as a visible state machine, not a
// fire-and-forget latch. The bind saves the agent, and the status poll
// stops at the terminal state — so a rejected save behind a silently
// latched claim would leave nothing to re-fire it and no way to retry.
// A cross-account claim moves saving → bound | failed, and failed renders
// an explicit Retry that re-runs the same handler with the same claim.
// Latched per SESSION, not per mount: the terminal state re-enables Sign in
// inside the same mounted panel, and a second cross-account login must run
// its own bind — a mount-scoped boolean would silently skip it and leave
// the agent on the previous account.
const accountBindSessionRef = useRef<string | null>(null);
const onAccountBindingRef = useRef(onAccountBinding);
onAccountBindingRef.current = onAccountBinding;
const accountBinding = statusQuery.data?.codexAccountBinding ?? null;
const runAccountBinding = useCallback(async (claim: CodexAccountBindingClaim) => {
const handler = onAccountBindingRef.current;
if (!handler) return;
setAccountBindState("saving");
try {
await handler(claim);
setAccountBindState("bound");
} catch {
setAccountBindState("failed");
}
}, []);
useEffect(() => {
if (status !== "authenticated" || !sessionId) return;
if (accountBindSessionRef.current === sessionId) return;
if (!accountBinding || !accountBinding.companyIdentityDiffers || !onAccountBindingRef.current) {
return;
}
accountBindSessionRef.current = sessionId;
void runAccountBinding(accountBinding);
}, [status, sessionId, accountBinding, runAccountBinding]);
// Report the prompt's URL upward, the way the submitted-browser-code panel
// does. The caller's loading beat ends when this arrives, so without it the
// onboarding step waits on a card that has already opened: the code is on
// screen and the button stays disabled. Fires with null on mount, before the
// one-time prompt lands, which is the same null the caller starts from.
const onPromptReadyRef = useRef(onPromptReady);
onPromptReadyRef.current = onPromptReady;
useEffect(() => {
onPromptReadyRef.current?.(prompt?.url ?? null);
}, [prompt]);
if (chrome === "onboarding") {
const failed = isTerminal && status && status !== "authenticated";
return (
<OnboardingLoginCard
loading={!prompt && !startError && !failed}
instruction={
<>
{/* The same destination as the step's own button. Two ways to one
link: the button for the customer following the flow, the anchor
for anyone finishing in another browser. */}
<a
href={prompt?.url}
target="_blank"
rel="noreferrer noopener"
className="underline underline-offset-2 hover:text-foreground"
>
Sign in to {connectSourceName(adapterType)}
</a>
{" by providing the authorization code below"}
</>
}
>
{startError ? (
<p role="alert" className="pl-2 text-xs text-destructive">
{startError}
</p>
) : failed ? (
<p role="alert" className="pl-2 text-xs text-destructive">
{status === "timed_out"
? "The login timed out. Start it again."
: status === "cancelled"
? "The login was cancelled."
: "The login did not finish. Start it again."}
</p>
) : (
<OnboardingLoginCodeRow code={prompt?.code ?? ""} autoCopy />
)}
</OnboardingLoginCard>
);
}
return (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 flex flex-col gap-2">
{/* `gap`, not `space-y`: the live region below collapses to
`display: none` whenever it has nothing to announce, and
`space-y` would still put its 8px on the row above — dead space
inside the card that pushes the row off centre. A gap only
applies between children that render. */}
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-foreground">{adapterLoginTitle(adapterType)}</span>
<div className="flex items-center gap-1.5">
{isActive && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs text-muted-foreground hover:text-foreground"
disabled={cancelLogin.isPending}
onClick={() => cancelLogin.mutate()}
>
Cancel
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2.5 text-xs"
disabled={startDisabled}
onClick={() => startLogin.mutate()}
>
Sign in
</Button>
</div>
</div>
{startError && (
<div role="alert" className="text-(length:--text-micro) text-destructive">
{startError}
</div>
)}
{/* One live region announces the loading, prompt, and terminal states, so a
screen reader reports each transition without a re-navigation. */}
<div role="status" aria-live="polite" className="space-y-2 empty:hidden">
{isActive && !prompt && (
<div className="flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
<Loader2 className="size-3 animate-spin shrink-0" />
<span>Preparing...</span>
</div>
)}
{isActive && prompt && (
<div className="space-y-2">
<div className="text-(length:--text-micro) text-muted-foreground">
Copy the code, then open the authentication page.
</div>
{/* Code first, then the URL, and the sentence and the numbering both
say so.
This used to run the other way, on the reasoning that handing over
a code before the page it belongs to was getting ahead of the
customer. What that missed is where the two rows are used: opening
the page is what leaves this screen, and the form waiting on the
other side wants the code that was on this one. Reaching back for
it is the step worth removing, so the code is read and copied
while it is still in front of you.
The onboarding card is ordered the same way and for the same
reason. The Claude panel below is not, and should not be — its
second row is a field to type *into*, so there the page genuinely
does come first. */}
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">
1. Code
</div>
<span className="font-mono text-xs text-foreground break-all">{prompt.code}</span>
</div>
<AdapterLoginCopyButton value={prompt.code} label="Copy code" />
</div>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">
2. Authentication URL
</div>
<span className="font-mono text-xs text-foreground break-all">{prompt.url}</span>
</div>
<div className="flex items-center">
<AdapterLoginCopyButton value={prompt.url} label="Copy URL" />
<Button
asChild
type="button"
variant="ghost"
size="icon-xs"
aria-label="Open the authentication page"
title="Open the authentication page"
className="text-muted-foreground hover:text-foreground"
>
<a href={prompt.url} target="_blank" rel="noreferrer noopener">
<ExternalLink className="size-3" />
</a>
</Button>
</div>
</div>
</div>
)}
{isTerminal && status && (
<AdapterLoginTerminalState status={status} message={session?.failure?.message ?? null} />
)}
{/* The cross-account bind's own state, below the login's success line.
The bind is a second, separate save — showing it as part of the
login would report success for a write that can still fail. */}
{status === "authenticated" && accountBindState === "saving" && (
<div className="flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
<Loader2 className="size-3 animate-spin shrink-0" />
<span>Binding this agent to the signed-in account...</span>
</div>
)}
{status === "authenticated" && accountBindState === "bound" && (
<div className="flex items-center gap-2 text-(length:--text-micro) text-foreground">
<Check className="size-3 shrink-0" />
<span>Agent bound to the signed-in account.</span>
</div>
)}
{status === "authenticated" && accountBindState === "failed" && (
<div className="flex items-center gap-2 text-(length:--text-micro)">
<TriangleAlert className="size-3 shrink-0 text-destructive" />
<span className="text-destructive">
Could not bind this agent to the signed-in account.
</span>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
if (accountBinding) void runAccountBinding(accountBinding);
}}
>
Retry
</Button>
</div>
)}
</div>
</div>
);
}
// The public session states that fail a Claude login. The panel returns to its
// start state and shows a fixed message at one of these.
const CLAUDE_LOGIN_FAILURE_STATUSES = new Set<AdapterAuthSessionStatus>([
"failed",
"timed_out",
"cancelled",
]);
// The fixed, non-secret message for a failed Claude login. The panel shows this
// text and returns to its start state. It never shows a provider message that
// could carry a secret.
const CLAUDE_LOGIN_FAILED_MESSAGE = "The login did not finish. Start the login again.";
// The client wall-clock cap for one active login. The panel polls the status
// route and the prompt route every two seconds. The server can leave a session
// short of a terminal status, and the prompt route returns 404 until the URL is
// ready. Without a cap, the panel polls forever. When this cap passes, the panel
// stops both polls and shows the timed-out state, so the user can start again.
// A generous client failsafe for a live login whose server deadline has not
// arrived yet. The server `expiresAt` is the real cutoff; the panel drives the
// timer from it. This failsafe only bounds a stuck poll while the status route
// has not yet returned `expiresAt`, so a login can never poll forever. The
// value is far longer than the server budget, so it never cuts a normal login
// short (the earlier fixed 60-second cutoff did, which drove this fix).
const CLAUDE_LOGIN_FAILSAFE_TIMEOUT_MS = 15 * 60_000;
// The fixed, non-secret message for a timed-out Claude login. The panel shows
// this text, stops both polls, and returns to its start state.
const CLAUDE_LOGIN_TIMED_OUT_MESSAGE = "The login timed out. Start the login again.";
// The submitted-browser-code login panel for the Claude adapter. It starts a
// setup-token login, polls the status route, and reads the authorization URL
// from the guarded prompt route. The user opens the URL, reads the browser code,
// and submits it. The panel clears the code right after submit. The panel treats
// only the server `stored` state as success, and it never shows the OAuth token.
function SubmittedBrowserCodeLoginPanel({
companyId,
adapterType,
environmentId,
onStored,
onApplyStored,
autoStart,
onConnected,
onCodeSubmitted,
onSubmitFailed,
chrome = "panel",
onPromptReady,
}: AdapterLoginPanelProps) {
const [sessionId, setSessionId] = useState<string | null>(null);
const [startError, setStartError] = useState<string | null>(null);
// The server delivers the authorization URL on the guarded prompt read only.
// Latch it so a later poll does not hide the URL.
const [authorizationUrl, setAuthorizationUrl] = useState<string | null>(null);
// True when the guarded prompt read reports a non-confidential transport. The
// server does not block a plain-HTTP login. The panel shows a non-blocking
// disclaimer and the login still proceeds. Latch it beside the URL.
const [transportInsecure, setTransportInsecure] = useState(false);
// The browser code the user submits. The panel clears it right after submit, so
// the secret never lingers in the input.
const [browserCode, setBrowserCode] = useState("");
// The non-secret stored-session claim from a completed login. It marks the
// server `stored` state, the only success state.
const [storedSessionId, setStoredSessionId] = useState<string | null>(null);
// True after a completion read fails. The panel returns to its start state.
const [completionFailed, setCompletionFailed] = useState(false);
// True after the client wall-clock cap passes for the active login. The panel
// stops both polls and shows the timed-out state.
const [timedOut, setTimedOut] = useState(false);
// A code has gone to the server and has not yet come back as a stored login
// or a failure. The field is locked for that stretch: the step's button is
// saying "Connecting" above it, and a second paste would submit again.
const [codeSubmitted, setCodeSubmitted] = useState(false);
// True after the status poll returns 404. The server removes the row and the
// in-memory session at once on any non-stored terminal state, so a status 404
// means the login failed and the server cleaned up. The panel stops both
// polls and shows a terminal failure state. It shows no credential material.
const [statusGone, setStatusGone] = useState(false);
// Guards the one completion read per session.
const completionStartedRef = useRef(false);
// True after the owner applies an existing stored login in this panel. The
// apply-existing path binds the fixed reference with no new login round trip,
// so the panel shows the applied confirmation and hides the apply affordance.
const [appliedStored, setAppliedStored] = useState(false);
// True for the session currently held in `sessionId` when it came from the
// owner-scoped resume read rather than a fresh `startLogin`. It marks the
// one case that needs the extra release-on-error path below: a session this
// browser instance did not just start, so a broken poll cannot fall back to
// the ordinary "let the user press Sign in again" recovery — the owner has
// no local memory of ever starting it.
const resumedRef = useRef(false);
const resetLocalState = () => {
setStartError(null);
setAuthorizationUrl(null);
setTransportInsecure(false);
setBrowserCode("");
setStoredSessionId(null);
setCompletionFailed(false);
setTimedOut(false);
setStatusGone(false);
setCodeSubmitted(false);
completionStartedRef.current = false;
};
// Read the stored Claude OAuth token status when the panel mounts. A 200 body
// carries the secret id and the version of the owner value; a 404 means the
// owner has no stored value. The panel applies the stored token first: when a
// value exists, a replacement login rotates it under the captured version
// instead of a blind first write. The server rejects a stale capture, so a
// change by another process after the capture fails the overwrite.
const storedTokenQuery = useQuery({
queryKey: ["claude-oauth-token-status", companyId],
queryFn: async () => {
try {
return await agentsApi.getClaudeOAuthTokenStatus(companyId);
} catch (error) {
// A 404 means the owner has no stored value (indistinguishable from a
// foreign value). The panel treats it as "no stored token".
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
},
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status === 404) return false;
return failureCount < 2;
},
});
const storedToken = storedTokenQuery.data ?? null;
const startLogin = useMutation({
mutationFn: () =>
agentsApi.startClaudeSetupTokenLogin(companyId, {
environmentId,
// When the owner already has a stored token, the login rotates it under
// the captured version, so a replacement login never conflicts with an
// existing value. Without a stored token the login is a first write.
...(storedToken
? {
overwrite: {
expectedSecretId: storedToken.secretId,
expectedLatestVersion: storedToken.latestVersion,
},
}
: {}),
}),
onSuccess: (session) => {
resumedRef.current = false;
resetLocalState();
setSessionId(session.sessionId);
},
onError: (error) => {
setStartError(error instanceof Error ? error.message : "Could not start the login.");
},
});
const clearActiveSession = () => {
// Return the panel to its idle start state. The Log in button is available
// again, and both polls stop because the session id is null.
resumedRef.current = false;
setSessionId(null);
resetLocalState();
};
const cancelLogin = useMutation({
mutationFn: () => agentsApi.cancelClaudeSetupTokenLogin(companyId, sessionId!),
onSuccess: () => {
clearActiveSession();
},
onError: (error) => {
// The cancel is resilient. The server removes a terminal session, so a
// cancel of an already-terminal or unknown session can return a 404. Treat
// that 404 the same as a successful cancel: clear the session and stop the
// polls. The panel never keeps polling a session the server no longer
// holds. Any other error surfaces and keeps the active login.
if (error instanceof ApiError && error.status === 404) {
clearActiveSession();
return;
}
setStartError(error instanceof Error ? error.message : "Could not cancel the login.");
},
});
// Release the server session at once, without a change to the panel state. The
// client-cutoff timer uses this. The server holds a
// per-owner reservation until the session reaches a terminal state, so an
// abandoned session locks the owner out until the server deadline. A best-
// effort cancel frees that reservation now, so the same owner can start a new
// login and does not hit the "too many active sessions" cap. The server
// removes a terminal session, so a 404 means the session is already gone. Treat
// that 404 the same as a successful cancel. This is a fire-and-forget cleanup,
// so it drops every error. The manual Cancel button uses the `cancelLogin`
// mutation instead, because that path also returns the panel to its idle start
// state.
const releaseServerSession = useCallback(
(id: string) => {
void agentsApi.cancelClaudeSetupTokenLogin(companyId, id).catch(() => {
// Drop the error. A 404 means the server already removed the session. A
// cleanup path cannot surface any other error, so it stays silent.
});
},
[companyId],
);
// Read the caller's active Claude setup-token session on mount, with no
// session id, so the browser rediscovers its own session after a reload
// with no local state. A 404 means no active session for the caller.
const activeSessionQuery = useQuery({
queryKey: ["claude-setup-token-active-session", companyId],
queryFn: async () => {
try {
return await agentsApi.getActiveClaudeSetupTokenLoginSession(companyId);
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
},
retry: false,
// Never answered from cache. This read decides whether to adopt a running
// session or start a new one, and a cached "none" from an earlier mount is
// exactly wrong after Back: the panel would read `isFetched` immediately,
// see the stale null, and start a second login while the refetch was still
// in flight — which the per-owner cap then rejects.
gcTime: 0,
staleTime: 0,
});
// While the panel releases a resumed session it cannot recover (see below),
// it keeps showing the login as active rather than dropping back to idle, so
// it does not clear local state before the release finishes.
const [releasingResumedSession, setReleasingResumedSession] = useState(false);
// Both polls run only while a session is active and the client cap has not
// passed. The timeout stops the polls, so the panel never polls forever. A
// status 404 also stops the polls: the server cleaned up the session, so the
// panel enters a terminal failure state instead.
const pollingEnabled =
Boolean(sessionId) && !timedOut && !statusGone && !releasingResumedSession;
const statusQuery = useQuery({
queryKey: ["claude-setup-token-status", companyId, sessionId],
queryFn: () => agentsApi.getClaudeSetupTokenLoginStatus(companyId, sessionId!),
enabled: pollingEnabled,
// A status 404 is terminal. The server removes a cleaned-up session at once,
// so a retry cannot recover it. Stop at once and fail loudly.
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status === 404) return false;
return failureCount < 3;
},
refetchInterval: (query) => {
if (timedOut || statusGone) return false;
const status = query.state.data?.status;
return status && ADAPTER_LOGIN_TERMINAL_STATUSES.has(status)
? false
: ADAPTER_LOGIN_POLL_INTERVAL_MS;
},
});
// Fail loudly on a status 404. The server cleans up the reservation, the row,
// and the in-memory session at once on any non-stored terminal state, so the
// status route returns 404 when a login fails and the server cleanup wins the
// race against the next poll. React Query keeps the last successful data on
// error, so without this branch the panel would hold stale data and show
// nothing. Enter the terminal failure state, which stops both polls.
//
// A resumed session takes a different path: the read that discovered it and
// the poll that tried to use it raced, and the session lost. There is no
// unmount cleanup left to fall back on, so the panel releases the
// reservation itself and waits for that release before it returns to the
// idle start state, instead of trusting the 404 alone.
useEffect(() => {
const error = statusQuery.error;
if (!(error instanceof ApiError && error.status === 404)) return;
if (resumedRef.current) {
if (releasingResumedSession) return;
setReleasingResumedSession(true);
const id = sessionId;
void (async () => {
if (id) {
await agentsApi.cancelClaudeSetupTokenLogin(companyId, id).catch(() => {
// The session is already gone either way; nothing more to do.
});
}
setReleasingResumedSession(false);
clearActiveSession();
})();
return;
}
setStatusGone(true);
}, [statusQuery.error, releasingResumedSession, sessionId, companyId]);
// Adopt the caller's active session once, on mount. This is what makes a
// page reload keep the session: with no local state at all, the panel would
// otherwise show its idle start state even though the server still holds an
// active login for this owner.
const resumeAttemptedRef = useRef(false);
useEffect(() => {
if (resumeAttemptedRef.current || !activeSessionQuery.isFetched) return;
resumeAttemptedRef.current = true;
const active = activeSessionQuery.data;
if (!active) return;
resumedRef.current = true;
resetLocalState();
setSessionId(active.sessionId);
if (active.prompt) {
setAuthorizationUrl(active.prompt.authorizationUrl);
if (active.prompt.transportAdvisory) setTransportInsecure(true);
}
}, [activeSessionQuery.isFetched, activeSessionQuery.data]);
// Poll the guarded prompt route until it returns the authorization URL. The
// route returns 404 until the URL is ready, so the panel treats a 404 as
// not-ready and keeps polling.
const promptQuery = useQuery({
queryKey: ["claude-setup-token-prompt", companyId, sessionId],
enabled: pollingEnabled && !authorizationUrl,
queryFn: async () => {
try {
return await agentsApi.getClaudeSetupTokenLoginPrompt(companyId, sessionId!);
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
},
refetchInterval: (query) =>
timedOut || query.state.data?.authorizationUrl ? false : ADAPTER_LOGIN_POLL_INTERVAL_MS,
});
useEffect(() => {
const url = promptQuery.data?.authorizationUrl ?? null;
if (url) setAuthorizationUrl(url);
// The advisory rides the same guarded prompt read as the URL. Latch it, so a
// later poll does not clear the disclaimer.
if (promptQuery.data?.transportAdvisory) setTransportInsecure(true);
}, [promptQuery.data]);
const submitCode = useMutation({
mutationFn: (code: string) =>
agentsApi.submitClaudeSetupTokenBrowserCode(companyId, sessionId!, code),
onError: (error) => {
setStartError(error instanceof Error ? error.message : "Could not submit the browser code.");
},
});
const completeLogin = useMutation({
mutationFn: () => agentsApi.completeClaudeSetupTokenLogin(companyId, sessionId!),
onSuccess: (result) => {
// The server reached the `stored` state. Hold the non-secret claim and
// report it to the parent. The response carries no token.
setStoredSessionId(result.storedSessionId);
onStored?.(result.storedSessionId);
},
onError: () => {
setCompletionFailed(true);
},
});
const status = statusQuery.data?.status ?? startLogin.data?.status ?? null;
// The server deadline for the active session (ISO string). The status route
// and the start response both carry it. The panel drives the client cutoff
// from this value, so the client and the server share one deadline.
const expiresAt = statusQuery.data?.expiresAt ?? startLogin.data?.expiresAt ?? null;
// Complete the login once the server authenticates the session. The completion
// read returns the non-secret stored-session claim. Fire it once per session.
const completeLoginRef = useRef(completeLogin.mutate);
completeLoginRef.current = completeLogin.mutate;
useEffect(() => {
if (status === "authenticated" && !completionStartedRef.current) {
completionStartedRef.current = true;
completeLoginRef.current();
}
}, [status]);
const isStored = storedSessionId !== null;
const isFailure =
completionFailed ||
statusGone ||
Boolean(status && CLAUDE_LOGIN_FAILURE_STATUSES.has(status));
const isCompleting = status === "authenticated" && !isStored && !completionFailed;
const isActive = Boolean(sessionId) && !isStored && !isFailure && !timedOut;
const startDisabled = startLogin.isPending || isActive;
// Cap the active login at the server deadline. The timer arms when the login
// becomes active and clears when the login leaves the active state (a terminal
// status, a stored success, or a new login). It re-arms when `expiresAt`
// arrives or changes. When it fires, the panel enters the timed-out state. The
// `isActive` guard already excludes `timedOut`, so the timer does not re-arm
// after it fires.
//
// The delay comes from the server `expiresAt`, so the client cutoff matches
// the server session budget instead of a fixed short window. While `expiresAt`
// has not arrived for the live session, the panel uses a generous failsafe, so
// a stuck poll still stops.
useEffect(() => {
if (!isActive) return;
const deadlineMs = expiresAt !== null ? new Date(expiresAt).getTime() : Number.NaN;
const remainingMs = Number.isFinite(deadlineMs)
? Math.max(0, deadlineMs - Date.now())
: CLAUDE_LOGIN_FAILSAFE_TIMEOUT_MS;
const timer = setTimeout(() => {
// Release the server session first, then show the timed-out state. The
// cancel frees the server reservation now, so an immediate retry by the
// same owner starts a new session instead of a lockout on the per-owner
// cap. The panel keeps the timed-out state, so the user sees why the login
// stopped.
if (sessionId) releaseServerSession(sessionId);
setTimedOut(true);
}, remainingMs);
return () => clearTimeout(timer);
}, [isActive, expiresAt, sessionId, releaseServerSession]);
const trimmedCode = browserCode.trim();
const canSubmit =
isActive &&
Boolean(authorizationUrl) &&
!isCompleting &&
isValidBrowserCode(trimmedCode) &&
!submitCode.isPending &&
!codeSubmitted;
const onCodeSubmittedRef = useRef(onCodeSubmitted);
onCodeSubmittedRef.current = onCodeSubmitted;
const onSubmitFailedRef = useRef(onSubmitFailed);
onSubmitFailedRef.current = onSubmitFailed;
const handleSubmit = () => {
if (!canSubmit) return;
// A new attempt supersedes the last attempt's error, and has to: the
// failure report below watches for an error after a submit, and one left
// over from before it would end this attempt the moment it began.
setStartError(null);
submitCode.mutate(trimmedCode);
// Reported now, not when the login finishes. A stored login is a poll and a
// completion read away, long enough that a button still offering "Waiting
// for code" after the paste read as the paste not having registered.
setCodeSubmitted(true);
onCodeSubmittedRef.current?.();
// Onboarding keeps the code on screen; the panel still clears it.
//
// Clearing emptied the input in the same frame the paste landed, so on the
// connect step the only feedback for the seconds that followed was a field
// that had just gone blank — reported from staging as the paste looking
// dropped, or the step looking stuck. There the field is disabled from here
// on and the step's own button carries the status, so the code can stay:
// `resetLocalState` clears it whenever a session starts, resumes or is
// cleared, the value dies with the panel moments later, and the code is
// single-use and already spent.
//
// The panel is not that. It sits in a form that stays open long after the
// login, with its own status area doing the reporting — so there the code
// does have somewhere to linger, and clearing it remains right.
if (chrome !== "onboarding") setBrowserCode("");
};
// Start once, on mount, when the caller has already taken the press, and
// only once the resume read has answered: a resumed session takes over
// instead of a fresh start. Latched for the same reason as the
// displayed-code panel: a second start would burn an owner reservation, and
// here it would also rotate the stored token twice.
const autoStartedRef = useRef(false);
const startLoginRef = useRef(startLogin.mutate);
startLoginRef.current = startLogin.mutate;
useEffect(() => {
if (!autoStart || autoStartedRef.current) return;
// A failed lookup is not proof that no session exists: only a successful
// lookup is. Show the failure to the user instead of starting a second
// login the server would reject against the per-owner cap.
if (activeSessionQuery.isError) {
autoStartedRef.current = true;
setStartError(
activeSessionQuery.error instanceof Error
? activeSessionQuery.error.message
: "Could not check for an active login.",
);
return;
}
if (!activeSessionQuery.isSuccess) return;
autoStartedRef.current = true;
if (activeSessionQuery.data) return;
startLoginRef.current();
}, [
autoStart,
activeSessionQuery.isSuccess,
activeSessionQuery.isError,
activeSessionQuery.data,
activeSessionQuery.error,
]);
/**
* Submit the pasted code without a press.
*
* Only in the onboarding chrome, and only here: this is the login where the
* code comes *back* off the clipboard, so the paste is the answer and a
* Submit button after it adds a step that can be missed. The displayed-code
* login has no field to watch.
*
* Driven by the paste rather than by the value, which is the part that is
* easy to get wrong. `isValidBrowserCode` looks like a completeness check and
* is not one: it accepts any run of printable ASCII from a single character
* up, deliberately, because the provider's exact format has never been
* pinned down. Keying the submit off the value therefore fires on the first
* keystroke of anyone who types the code instead of pasting it — submitting
* one character, failing, and clearing the field they were typing into.
*
* So the paste arms it and the shape check still gates it, which leaves
* typing to Enter. A paste that is not usable simply sits in the field.
*
* `submitCode.isPending` is inside `canSubmit` and `handleSubmit` clears the
* field, so one paste can only submit once.
*/
const handleSubmitRef = useRef(handleSubmit);
handleSubmitRef.current = handleSubmit;
const autoSubmit = chrome === "onboarding";
const pastedRef = useRef(false);
useEffect(() => {
if (!autoSubmit || !pastedRef.current) return;
pastedRef.current = false;
if (!canSubmit) return;
handleSubmitRef.current();
}, [autoSubmit, canSubmit, browserCode]);
// Report success upward once. The `stored` state is the only success state,
// which is why this watches `isStored` and not the `authenticated` status the
// completion read still has to follow.
const connectedRef = useRef(false);
const onConnectedRef = useRef(onConnected);
onConnectedRef.current = onConnected;
useEffect(() => {
if (!isStored || connectedRef.current) return;
connectedRef.current = true;
onConnectedRef.current?.();
}, [isStored]);
// The other end of `onCodeSubmitted`. Any of these after a submit means the
// code is not going to become a stored login, and a caller still showing
// "Connecting" would otherwise spin for good. Once per submit; the field
// unlocks with it. Not reset on success: the field stays locked through the
// hold that follows, rather than reopening under a button saying Connecting.
useEffect(() => {
if (!codeSubmitted) return;
if (!startError && !isFailure && !timedOut) return;
setCodeSubmitted(false);
onSubmitFailedRef.current?.();
}, [codeSubmitted, startError, isFailure, timedOut]);
const onPromptReadyRef = useRef(onPromptReady);
onPromptReadyRef.current = onPromptReady;
useEffect(() => {
onPromptReadyRef.current?.(authorizationUrl);
}, [authorizationUrl]);
if (chrome === "onboarding") {
const failedNow = isFailure || timedOut;
return (
<OnboardingLoginCard
loading={!authorizationUrl && !startError && !failedNow}
instruction={
<>
<a
href={authorizationUrl ?? undefined}
target="_blank"
rel="noreferrer noopener"
className="underline underline-offset-2 hover:text-foreground"
>
Sign in to {connectSourceName(adapterType)}
</a>
{" then come back and enter authorization code"}
</>
}
>
{/* The plain-HTTP advisory survives the redesign. It is the one thing on
this card not about getting the login done, and dropping it to keep
the card tidy would remove a warning about a code travelling in
clear text. */}
{transportInsecure && (
<p className="flex items-start gap-2 pl-2 text-xs text-amber-700 dark:text-amber-200">
<TriangleAlert className="mt-0.5 size-3 shrink-0" />
This connection is not encrypted. The login code travels in clear text on this
network. Continue only on a network you trust.
</p>
)}
{startError ? (
<p role="alert" className="pl-2 text-xs text-destructive">
{startError}
</p>
) : failedNow ? (
<p role="alert" className="pl-2 text-xs text-destructive">
{timedOut && !isFailure ? CLAUDE_LOGIN_TIMED_OUT_MESSAGE : CLAUDE_LOGIN_FAILED_MESSAGE}
</p>
) : (
<OnboardingCardField
value={browserCode}
onChange={setBrowserCode}
onSubmit={handleSubmit}
onPaste={() => {
pastedRef.current = true;
}}
// Dots, not the code. It stays in the field after the paste so the
// customer can see something landed, and that is all they need to
// see of it.
masked
disabled={submitCode.isPending || isCompleting || codeSubmitted}
/>
)}
</OnboardingLoginCard>
);
}
return (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 flex flex-col gap-2">
{/* `gap`, not `space-y`: the live region below collapses to
`display: none` whenever it has nothing to announce, and
`space-y` would still put its 8px on the row above — dead space
inside the card that pushes the row off centre. A gap only
applies between children that render. */}
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-foreground">{adapterLoginTitle(adapterType)}</span>
<div className="flex items-center gap-1.5">
{isActive && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs text-muted-foreground hover:text-foreground"
disabled={cancelLogin.isPending}
onClick={() => cancelLogin.mutate()}
>
Cancel
</Button>
)}
{/* Apply the existing stored login with no new login round trip. The
affordance shows only when the status route reports a stored value
and no active or completed login runs in this panel. */}
{onApplyStored && storedToken && !isActive && !isStored && !appliedStored && (
<Button
type="button"
variant="default"
size="sm"
className="h-7 px-2.5 text-xs"
onClick={() => {
onApplyStored();
setAppliedStored(true);
}}
>
Use saved login
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2.5 text-xs"
disabled={startDisabled}
onClick={() => startLogin.mutate()}
>
{storedToken && !isActive && !isStored ? "Sign in to replace" : "Sign in"}
</Button>
</div>
</div>
{/* When the owner already has a stored Claude login, the panel applies it
first and does not force a fresh login. Use saved login binds the stored
token; a replacement login rotates it under the captured version. */}
{storedToken && !isActive && !isStored && !appliedStored && (
<div className="text-(length:--text-micro) text-muted-foreground">
You have a saved Claude login. Use it to bind this agent, or log in again to replace the
stored token.
</div>
)}
{appliedStored && (
<div className="flex items-center gap-2 text-(length:--text-micro) text-foreground">
<Check className="size-3 shrink-0" />
<span>The saved Claude login is bound to this agent now.</span>
</div>
)}
{startError && (
<div role="alert" className="text-(length:--text-micro) text-destructive">
{startError}
</div>
)}
{/* One live region announces the loading, prompt, completing, and terminal
states, so a screen reader reports each transition. */}
<div role="status" aria-live="polite" className="space-y-2 empty:hidden">
{isActive && !authorizationUrl && !isCompleting && (
<div className="flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
<Loader2 className="size-3 animate-spin shrink-0" />
<span>Preparing...</span>
</div>
)}
{isActive && authorizationUrl && !isCompleting && (
<div className="space-y-2">
{transportInsecure && (
<div
className="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-2 py-1.5 text-(length:--text-micro) text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-200"
>
<TriangleAlert className="size-3 shrink-0 mt-0.5" />
<span>
This connection is not encrypted. The login code travels in clear text on this
network. Continue only on a network you trust.
</span>
</div>
)}
<div className="text-(length:--text-micro) text-muted-foreground">
Open the authorization page, then enter the browser code it shows.
</div>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">
1. Authorization URL
</div>
<span className="font-mono text-xs text-foreground break-all">{authorizationUrl}</span>
</div>
<div className="flex items-center">
<AdapterLoginCopyButton value={authorizationUrl} label="Copy URL" />
<Button
asChild
type="button"
variant="ghost"
size="icon-xs"
aria-label="Open the authorization page"
title="Open the authorization page"
className="text-muted-foreground hover:text-foreground"
>
<a href={authorizationUrl} target="_blank" rel="noreferrer noopener">
<ExternalLink className="size-3" />
</a>
</Button>
</div>
</div>
<div className="space-y-1">
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">
2. Browser code
</div>
<div className="flex items-center gap-2">
<input
aria-label="Browser code"
type="text"
autoComplete="off"
spellCheck={false}
value={browserCode}
onChange={(event) => setBrowserCode(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleSubmit();
}
}}
className="flex-1 min-w-0 rounded-md border border-border bg-background px-2 py-1 font-mono text-xs text-foreground"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2.5 text-xs"
disabled={!canSubmit}
onClick={handleSubmit}
>
Submit
</Button>
</div>
</div>
</div>
)}
{isCompleting && (
<div className="flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
<Loader2 className="size-3 animate-spin shrink-0" />
<span>Completing the login</span>
</div>
)}
{isStored && (
<div className="flex items-center gap-2 text-(length:--text-micro) text-foreground">
<Check className="size-3 shrink-0" />
<span>Authenticated. The environment has credentials now.</span>
</div>
)}
{isFailure && (
<div className="flex items-start gap-2 text-(length:--text-micro) text-destructive">
<TriangleAlert className="size-3 shrink-0" />
<span>{CLAUDE_LOGIN_FAILED_MESSAGE}</span>
</div>
)}
{timedOut && !isFailure && !isStored && (
<div className="flex items-start gap-2 text-(length:--text-micro) text-destructive">
<TriangleAlert className="size-3 shrink-0" />
<span>{CLAUDE_LOGIN_TIMED_OUT_MESSAGE}</span>
</div>
)}
</div>
</div>
);
}
export function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestResult }) {
const statusLabel =
result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed";
const statusClass =
result.status === "pass"
? "text-green-700 dark:text-green-300 border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10"
: result.status === "warn"
? "text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10"
: "text-red-700 dark:text-red-300 border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10";
return (
<div className={`rounded-md border px-3 py-2 text-xs ${statusClass}`}>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{statusLabel}</span>
<span className="text-(length:--text-micro) opacity-80">
{new Date(result.testedAt).toLocaleTimeString()}
</span>
</div>
<div className="mt-2 space-y-1.5">
{result.checks.map((check, idx) => (
<div key={`${check.code}-${idx}`} className="text-(length:--text-micro) leading-relaxed break-words">
<span className="font-medium uppercase tracking-wide opacity-80">
{check.level}
</span>
<span className="mx-1 opacity-60">·</span>
<span>{check.message}</span>
{check.detail && <span className="block opacity-75 break-all">({check.detail})</span>}
{check.hint && <span className="block opacity-90 break-words">Hint: {check.hint}</span>}
</div>
))}
</div>
</div>
);
}
/* ---- Internal sub-components ---- */
export function AdapterTypeDropdown({
value,
onChange,
disabledTypes,
}: {
value: string;
onChange: (type: string) => void;
disabledTypes: Set<string>;
}) {
const [open, setOpen] = useState(false);
const selectedDisplay = getAdapterDisplay(value);
const adapterList = useMemo(
() =>
listAdapterOptions((type) => adapterLabels[type] ?? getAdapterLabel(type)).filter(
(item) => !disabledTypes.has(item.value),
),
[disabledTypes],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className="inline-flex min-w-0 items-center gap-1.5">
{value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
<span className="truncate">{adapterLabels[value] ?? getAdapterLabel(value)}</span>
{selectedDisplay.experimental && <ExperimentalBadge />}
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-1" align="start">
{adapterList.map((item) => (
<button
key={item.value}
disabled={item.comingSoon}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded",
item.comingSoon
? "opacity-40 cursor-not-allowed"
: "hover:bg-accent/50",
item.value === value && !item.comingSoon && "bg-accent",
)}
onClick={() => {
if (!item.comingSoon) {
onChange(item.value);
setOpen(false);
}
}}
>
<span className="inline-flex items-center gap-1.5">
{item.value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
<span>{item.label}</span>
{item.experimental && <ExperimentalBadge />}
</span>
{item.comingSoon && (
<span className="text-(length:--text-nano) text-muted-foreground">Coming soon</span>
)}
</button>
))}
</PopoverContent>
</Popover>
);
}
function ExperimentalBadge() {
return (
<span className="shrink-0 rounded border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-(length:--text-nano) font-medium leading-none text-amber-700 dark:text-amber-200">
Experimental
</span>
);
}
export function ModelDropdown({
models,
value,
onChange,
open,
onOpenChange,
allowDefault,
required,
groupByProvider,
creatable,
detectedModel,
detectedModelCandidates,
onDetectModel,
onRefreshModels,
refreshingModels,
detectModelLabel,
emptyDetectHint,
defaultLabel,
}: {
models: AdapterModel[];
value: string;
onChange: (id: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
allowDefault: boolean;
required: boolean;
groupByProvider: boolean;
creatable?: boolean;
detectedModel?: string | null;
detectedModelCandidates?: string[];
onDetectModel?: () => Promise<string | null>;
onRefreshModels?: () => Promise<void>;
refreshingModels?: boolean;
detectModelLabel?: string;
emptyDetectHint?: string;
defaultLabel?: string;
}) {
const [modelSearch, setModelSearch] = useState("");
const [detectingModel, setDetectingModel] = useState(false);
const selected = models.find((m) => m.id === value);
const manualModel = modelSearch.trim();
const canCreateManualModel = Boolean(
creatable &&
manualModel &&
!models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()),
);
// Model IDs already shown as detected/candidate badges — exclude from regular list
const promotedModelIds = useMemo(() => {
const set = new Set<string>();
if (detectedModel) set.add(detectedModel);
for (const c of detectedModelCandidates ?? []) {
if (c) set.add(c);
}
return set;
}, [detectedModel, detectedModelCandidates]);
const filteredModels = useMemo(() => {
return models.filter((m) => {
if (promotedModelIds.has(m.id)) return false;
if (!modelSearch.trim()) return true;
const q = modelSearch.toLowerCase();
const provider = extractProviderId(m.id) ?? "";
return (
m.id.toLowerCase().includes(q) ||
m.label.toLowerCase().includes(q) ||
provider.toLowerCase().includes(q)
);
});
}, [models, modelSearch, promotedModelIds]);
const groupedModels = useMemo(() => {
if (!groupByProvider) {
return [
{
provider: "models",
entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)),
},
];
}
const map = new Map<string, AdapterModel[]>();
for (const model of filteredModels) {
const provider = extractProviderId(model.id) ?? "other";
const group = map.get(provider) ?? [];
group.push(model);
map.set(provider, group);
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([provider, entries]) => ({
provider,
entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)),
}));
}, [filteredModels, groupByProvider]);
async function handleDetectModel() {
if (!onDetectModel) return;
setDetectingModel(true);
try {
const nextModel = await onDetectModel();
if (nextModel) {
onChange(nextModel);
onOpenChange(false);
setModelSearch("");
}
} finally {
setDetectingModel(false);
}
}
return (
<Field label="Model" hint={help.model}>
<Popover
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) setModelSearch("");
}}
>
<PopoverTrigger asChild>
<button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}>
{selected
? selected.label
: value
|| (allowDefault ? (defaultLabel ?? "Default") : required ? "Select model (required)" : "Select model")}
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-1" align="start">
<div className="relative mb-1">
<input
className="w-full px-2 py-1.5 pr-6 text-xs bg-transparent outline-none border-b border-border placeholder:text-muted-foreground/50"
placeholder={creatable ? "Search models... (type to create)" : "Search models..."}
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
autoFocus
/>
{modelSearch && (
<button
type="button"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setModelSearch("")}
>
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
{onDetectModel && !modelSearch.trim() && (
<button
type="button"
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
onClick={() => {
void handleDetectModel();
}}
disabled={detectingModel}
>
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
{detectingModel ? "Detecting..." : detectedModel ? (detectModelLabel?.replace(/^Detect\b/, "Re-detect") ?? "Re-detect from config") : (detectModelLabel ?? "Detect from config")}
</button>
)}
{onRefreshModels && !modelSearch.trim() && (
<button
type="button"
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
onClick={() => {
void onRefreshModels();
}}
disabled={refreshingModels}
>
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 0 1 15.28-6.36L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-15.28 6.36L3 16" />
<path d="M8 16H3v5" />
</svg>
{refreshingModels ? "Refreshing..." : "Refresh models"}
</button>
)}
{value && (!models.some((m) => m.id === value) || promotedModelIds.has(value)) && (
<button
type="button"
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded bg-accent/50",
)}
onClick={() => {
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate font-mono text-xs" title={value}>
{models.find((m) => m.id === value)?.label ?? value}
</span>
<Badge variant="outline" className="ml-auto text-(length:--text-nano) px-1.5 bg-green-500/15 text-green-400 border-green-500/20">
current
</Badge>
</button>
)}
{detectedModel && detectedModel !== value && (
<button
type="button"
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
)}
onClick={() => {
onChange(detectedModel);
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate font-mono text-xs" title={detectedModel}>
{models.find((m) => m.id === detectedModel)?.label ?? detectedModel}
</span>
<Badge variant="outline" className="ml-auto text-(length:--text-nano) px-1.5 bg-blue-500/15 text-blue-400 border-blue-500/20">
detected
</Badge>
</button>
)}
{detectedModelCandidates
?.filter((candidate) => candidate && candidate !== detectedModel && candidate !== value)
.map((candidate) => {
const entry = models.find((m) => m.id === candidate);
return (
<button
key={`detected-${candidate}`}
type="button"
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
)}
onClick={() => {
onChange(candidate);
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate font-mono text-xs" title={candidate}>
{entry?.label ?? candidate}
</span>
<Badge variant="outline" className="ml-auto text-(length:--text-nano) px-1.5 bg-sky-500/15 text-sky-400 border-sky-500/20">
config
</Badge>
</button>
);
})}
<div className="max-h-(--sz-240px) overflow-y-auto">
{allowDefault && (
<button
type="button"
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!value && "bg-accent",
)}
onClick={() => {
onChange("");
onOpenChange(false);
}}
>
Default
</button>
)}
{canCreateManualModel && (
<button
type="button"
className="flex items-center justify-between gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50"
onClick={() => {
onChange(manualModel);
onOpenChange(false);
setModelSearch("");
}}
>
<span>Use manual model</span>
<span className="text-xs font-mono text-muted-foreground">{manualModel}</span>
</button>
)}
{groupedModels.map((group) => (
<div key={group.provider} className="mb-1 last:mb-0">
{groupByProvider && (
<div className="px-2 py-1 text-(length:--text-nano) uppercase tracking-wide text-muted-foreground">
{group.provider} ({group.entries.length})
</div>
)}
{group.entries.map((m) => (
<button
type="button"
key={m.id}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === value && "bg-accent",
)}
onClick={() => {
onChange(m.id);
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate" title={m.id}>
{groupByProvider ? extractModelName(m.id) : m.label}
</span>
</button>
))}
</div>
))}
{filteredModels.length === 0 && !canCreateManualModel && promotedModelIds.size === 0 && (
<div className="px-2 py-2 space-y-2">
<p className="text-xs text-muted-foreground">
{onDetectModel
? (emptyDetectHint ?? "No model detected yet. Enter a provider/model manually.")
: "No models found."}
</p>
</div>
)}
</div>
</PopoverContent>
</Popover>
</Field>
);
}
function ThinkingEffortDropdown({
value,
options,
onChange,
open,
onOpenChange,
}: {
value: string;
options: ReadonlyArray<{ id: string; label: string }>;
onChange: (id: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const selected = options.find((option) => option.id === value) ?? options[0];
return (
<Field label="Thinking effort" hint={help.thinkingEffort}>
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}>{selected?.label ?? "Auto"}</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-(--radix-popover-trigger-width) p-1" align="start">
{options.map((option) => (
<button
key={option.id || "auto"}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
option.id === value && "bg-accent",
)}
onClick={() => {
onChange(option.id);
onOpenChange(false);
}}
>
<span>{option.label}</span>
{option.id ? <span className="text-xs text-muted-foreground font-mono">{option.id}</span> : null}
</button>
))}
</PopoverContent>
</Popover>
</Field>
);
}