Files
PaperClipAI/ui/src/components/AgentConfigForm.tsx
T
DottaandPaperclip f0877a9c7c fix: recover native work and requalify saved computers (#17724)
<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/32/changes/paperclip/32
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip manages AI agents and preserves their work after an
interruption.
> - Native Stop must confirm termination and save the workspace.
> - Task and run views must show the current recovery receipt.
> - A new external reply can arrive while that cleanup is pending.
> - The reply must return to its original chat owner after cleanup.
> - A successful fresh retry also becomes the task’s bound workspace for
its next ordinary turn.
> - Managed computer updates can invalidate a saved snapshot
qualification.
> - This pull request fixes those instructions, acknowledgements,
handoffs and the existing native-agent Test path.
> - The operator can continue with new input while the stopped turn
remains preserved.

## Linked Issues or Issue Description

**What happened?**

Stopped native runs could keep the initial workspace-repair instruction
after files were recovered. Native Stop could also leave the shared
cancellation state at requested after remote termination was confirmed.
A new GitHub reply saved during cleanup could then remain queued because
cleanup did not ask its original inbound owner to reconsider the
receipt. After a managed snapshot update, an existing native agent could
also need qualification while the generic environment Test required
instance-admin access.

**Expected behavior**

Show the current saved-work state. Acknowledge Stop only after exact
termination proof. Let an authorized operator use the existing
saved-agent Run test to qualify its current computer. After cleanup,
recheck the saved reply's current authority and admit it once as new
input. Preserve the stopped run and uncertain external effects.

**Steps to reproduce**

1. Stop an active native turn before it returns a semantic result.
2. Send a new authorized external reply while cleanup is pending.
3. Let workspace recovery and provider cleanup finish. Inspect the run
instruction, Stop acknowledgement and queued reply.

Related work: #17594 preserves native processes through controller
outages. #17401 addresses a separate bootstrap failure. This pull
request does not replace those fixes.

## What Changed

- Use the exact run's workspace recovery receipt for task and run
guidance, including the Activity ledger.
- Retire an obsolete exact-session notice through the existing bounded
reconciler. Preserve the separate no-replay hold.
- Write the shared native Stop acknowledgement only after the audited
Stop intent and every exact remote lease receipt match.
- Delegate saved external input to the live inbound owner. Recheck its
source, actor, endpoint, principal and conversation generation. Promote
the same receipt in the transaction that creates its new run.
- Use that owner from the cleanup callback and existing stranded-queue
catch-up after a lost callback or restart. A denied source cannot abort
other items.
- Reuse the current private task directory after a successful fresh
retry. Re-read the company/task/workspace binding and validate the
canonical stable or attempt namespace without recreating missing files;
keep a later fresh retry separate.
- Extend the existing agent-scoped qualification owner to native agents.
The clean saved-agent Test qualifies its exact computer before runtime
testing. Reuse a current proof; require a real probe and verified
cleanup for a stale proof. Preserve draft tests and metadata-only
external/pool previews.
- Update execution semantics, capability maps and the recovery
verification recipe. Add a production-page Storybook interaction for the
normal native Test.

## Verification

- **Red:** persisted recovery states returned stale instructions; an
exact terminated native lease left shared Stop requested; the real
GitHub subscribed-message fixture left zero successors after cleanup.
The controlled baseline restored the previous production source,
reproduced the failure, then restored the fixed bytes with matching
SHA-256 hashes.
- **Green:** 382 durable-chat, native-continuation, Stop and recovery
tests pass. The real GitHub/PostgreSQL handoff matrix passes 12/12. It
covers callback, application restart, callback/sweep race, valid input
behind a deleted source, and denial for unknown stop, wrong allocation,
paused endpoint, revoked principal, deleted or changed source, changed
conversation generation and foreign scope. Repeated callbacks preserve
one receipt, its actor, one successor and the unchanged stopped
predecessor.
- **Workspace continuation red/green:** the real PostgreSQL preparation
flow created a fresh attempt and saved a marker, then rejected the next
ordinary turn with the same binding mismatch observed in deployed use.
The repaired preparation and filesystem suites pass 86/86: stable and
attempt-root reuse, saved-marker preservation, a later clean sibling,
changed current bindings, foreign company/task, closed workspace,
traversal, missing path and symlink refusal. No task row reset or
old-turn replay is used.
- **Qualification red/green:** a native saved-agent qualification
returned 422, and its normal Test skipped qualification. The repaired
path passes 224 qualification/immutable-snapshot/environment tests and
176 UI/upgrade tests. It covers revoked authority, foreign or changed
computer, current-proof reuse, failed/missing proof, draft settings,
settings changed during the probe, and external/pool previews. On exact
current source, the real local Harness / Runtime UI also qualified a
newly saved disposable Computer with missing proof through actual
Daytona, then completed the actual OpenCode/OpenRouter hello probe using
`openrouter/deepseek/deepseek-v4.1-flash-1017`. Supported availability
changed from Legacy-only to Paperclip Runner after proof persistence.
Qualification and runtime leases both completed normal release/cleanup.
The five prior task run IDs were unchanged; no task wake was created.
This is macOS source-server/UI with real Linux Daytona and provider
services, not a hosted UI claim. Test probes are archived by the
existing inspection contract; release does not claim physical deletion.
- Full workspace `pnpm -r typecheck`, `pnpm build`, token, map and diff
checks pass at `f6450f4739fc8141815b8b7c35fde22a655c5f90`. The broad
local `pnpm test:run` passed 24,899 tests and reported one existing
nested-clone test timeout at 15 seconds. The complete failing file then
passed 21/21 with the normal timeout; the exact case also passed on the
pinned earlier baseline. The broad runner stopped before its later
groups. No timeout or production workaround was added. Prior head
`f6450f4` has 53 successful checks, two conditional Storybook skips and
Greptile 5/5 with no inline threads. New workspace-reuse head
`fcb13399ec70f92f71bd8250081251dca3f48962` passes workspace typecheck,
full build, 86 scoped tests, maps and diff checks; Current-head CI has
53 successful checks and two conditional Storybook skips; Greptile is
5/5 with no inline findings. Deployed ordinary-turn reuse acceptance
passed as described below.

- **Hosted Stop proof:** the real GitHub issue-mention path used native
OpenCode, `openrouter/deepseek/deepseek-v4.1-flash-1017`, Daytona and an
exact low-trust task boundary. Normal UI Stop interrupted an active Bash
call. Native and shared acknowledgements matched the exact lease.
Automatic recovery saved exactly `STARTED\n` (8 bytes), with no finish
marker. Reload showed Stopped and saved work recovered. Only the
original cancelled run remained. Normal five-minute cleanup completed
its exact provider-destruction transition.
- **Limits:** the provider buffered command output, so the marker was
read after recovery. The approved local provider credential could not
inspect the hosted sandbox; its 404 is not deletion proof. Cleanup
success comes from the exact lease's platform destruction transition. An
earlier hosted stale instruction disappeared after rollout with
unchanged raw run/action data; that historical cause remains
unexplained. Original incident holds and publication receipts were
untouched.
- **Hosted handoff proof:** after QA deployed the combined candidate
containing `46b2e99`, startup admitted the previously saved GitHub
author reply without another comment, Retry or manual action. The new
native run used the exact original wake receipt and saved comment. It
completed once, committed an external-response wait, preserved the
stopped predecessor, emptied the queue and left the task In review. The
answer appeared in the original GitHub conversation. Workspace
finalization and the exact lease's normal destruction transition
succeeded.
- **Hosted workspace proof:** on the combined candidate containing
`fcb13399`, normal Retry preserved the original authenticated GitHub
request and completed a fresh native attempt. One later ordinary
same-head author reply then completed in the exact same workspace ID and
attempt directory, with no Retry/fresh directive. Its input tree digest
matched the Retry's finalized digest; final copyback preserved that
digest. The persisted assessment row and all five prior run records were
unchanged, exactly one new reply run completed, and the task returned to
In review with an empty queue and no live run. Both exact leases
completed normal verified destruction. This used real native OpenCode,
the exact DeepSeek/OpenRouter model and Daytona, without workspace-row
repair or a new remote marker.
- **Publication limit:** the workspace audit compares the persisted
assessment and run history. The ordinary reply exposed a separate
destination-summary fallback that changed the visible GitHub summary
despite the preserved assessment row. The publication track is repairing
that behavior; this proof does not claim the visible summary was
preserved.
- **Deployment boundary:** current-head CI/review and hosted same-source
Retry/ordinary-workspace reuse acceptance passed. The separate
visible-summary publication failure remains owned by the publication
track. Combined hosted UI confirmation of the native-agent Test path is
coordinated separately. Actual missing-proof UI/provider qualification
has passed in the isolated local instance above; prior hosted Stop and
same-input handoff proof remains separate. No merge or deployment is
performed by this pull request.

## Risks

Recovery state and termination receipts must match the exact source run
and allocation. Missing or foreign proof keeps the hold. The chat owner
must retain the original receipt and actor and recheck current
authority. Cleanup cannot grant authority or replay the stopped turn.
Existing native ownership, Stop, budget, permission and uncertain-effect
guards remain in place.

## Model Used

OpenAI GPT-6.1 Sol (`gpt-6.1-sol`) through Codex, with reasoning,
repository tools, source editing and test execution. The context-window
size is not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (affected suites and
isolated failing file pass; broad timeout retained above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green (current fcb has 53 successful
checks/two conditional Storybook skips)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-26 22:21:58 -05:00

4299 lines
200 KiB
TypeScript

import { COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS } from "@/lib/copilot-task-model-catalog";
import { useConnectionModels } from "./ai-connections/useConnectionModels";
import { agentHarnessType, agentRunner, paperclipRunnerProfileForHarness, normalizeClaudeRunnerOptions, type AgentRunnerChoice } from "@paperclipai/shared";
import { adaptersApi } from "../api/adapters";
import { canQualifyRunnerEnvironment, qualifyRunnerEnvironment } from "../lib/qualify-claude-setup";
import { AgentRunnerSelect } from "./AgentRunnerSelect";
import { aiRoutingHarness } from "@paperclipai/shared";
import { AiConnectionField } from "./ai-connections/AiConnectionField";
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema } from "@paperclipai/shared";
import { testAgentSetup } from "@/lib/test-agent-setup";
import { setupEfforts } from "../lib/agent-setup-fields";
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 { hashKey, 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { NativeSelect, SelectPopover } from "@/components/ui/select";
import { AdapterMark } from "./AdapterMark";
import { FolderOpen, Heart, 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,
ProviderSubscriptionCard,
OnboardingCardField,
OnboardingLoginCodeRow,
type AdapterLoginChrome,
} from "./AdapterLoginChrome";
import {
resolveAdapterTestEnvironmentId,
resolveLocalDefaultEnvironmentId,
resolveManagedSandboxEnvironmentId,
} from "../lib/adapter-test-environment";
import { environmentDisplayLabel } from "../lib/managed-sandbox-environment";
import { adapterCuratesModelOrder, 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 { 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 ||
o.runner !== 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.runner !== undefined && current.runner !== persisted.runner ? { runner: current.runner } : {}),
...(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 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");
}
if (experimentalSettings?.enableOpenAiDot !== true || disabledTypes.has("paperclip_runner")) {
next.add("openai_dot");
}
return next;
}, [disabledTypes, experimentalSettings?.enableNativeRunner, experimentalSettings?.enableOpenAiDot]);
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 || !isCreate || 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 [runnerUpgrade, setRunnerUpgrade] = useState<{
agentId: string;
status: "pending" | "upgraded" | "failed";
error?: string;
} | null>(null);
const currentRunnerUpgrade = !isCreate && runnerUpgrade?.agentId === props.agent.id ? runnerUpgrade : null;
const runnerUpgradePending = currentRunnerUpgrade?.status === "pending";
const runnerUpgraded = currentRunnerUpgrade?.status === "upgraded";
const runnerUpgradeError = currentRunnerUpgrade?.error;
useEffect(() => {
if (!isCreate && props.agent.adapterType !== "paperclip_runner") {
setRunnerUpgrade(current => current?.agentId === props.agent.id ? null : current);
}
}, [isCreate, !isCreate ? props.agent.id : undefined, !isCreate ? props.agent.adapterType : undefined]);
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 effectiveConfig = isCreate ? props.values.adapterSchemaValues ?? {} : { ...config, ...overlay.adapterConfig };
const harnessType = agentHarnessType(adapterType, effectiveConfig);
const isCodexHarness = harnessType === "codex_local";
const runnerProfile = paperclipRunnerProfileForHarness(harnessType);
const isRunnerHarness = Boolean(runnerProfile);
const rawCurrentDefaultEnvironmentId = isCreate
? props.values.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 runnerDiscovery = useQuery({
queryKey: queryKeys.adapters.availability(selectedCompanyId, rawCurrentDefaultEnvironmentId || null),
queryFn: () => adaptersApi.list({ companyId: selectedCompanyId!, environmentId: rawCurrentDefaultEnvironmentId || null }),
enabled: Boolean(selectedCompanyId),
});
const runnerAvailability = runnerDiscovery.data?.find(item => item.type === harnessType);
const runner: AgentRunnerChoice = isCreate ? props.values.runner ?? "auto" : overlay.runner ?? agentRunner(adapterType);
const fieldsAdapterType = isRunnerHarness
? (runner === "paperclip" || (runner === "auto" && runnerAvailability?.defaultRunner === "paperclip") ? "paperclip_runner" : harnessType)
: adapterType;
useEffect(() => {
if (props.mode !== "create" || !isRunnerHarness) return;
const resolvedRunner = runner === "auto" ? runnerAvailability?.defaultRunner : runner;
if (props.values.resolvedRunner !== resolvedRunner) props.onChange({ resolvedRunner });
}, [props.mode, harnessType, isRunnerHarness, runner, runnerAvailability?.defaultRunner,
isCreate ? props.values.resolvedRunner : undefined, isCreate ? props.onChange : undefined]);
// A Runner-only environment, such as a plugin environment that runs Runner
// tasks, offers no Legacy runner. Switch a Legacy choice to Paperclip Runner
// so the test and the save both resolve the Runner.
const legacyRunnerUnavailable = isRunnerHarness && runner === "legacy"
&& runnerAvailability?.supportedRunners !== undefined && !runnerAvailability.supportedRunners.includes("legacy");
useEffect(() => {
if (!legacyRunnerUnavailable) return;
if (props.mode === "create") props.onChange({ runner: "paperclip" });
else setOverlay(prev => ({ ...prev, runner: "paperclip" }));
}, [legacyRunnerUnavailable, props.mode, props.mode === "create" ? props.onChange : undefined]);
const getCapabilities = useAdapterCapabilities();
const adapterCaps = getCapabilities(isRunnerHarness ? harnessType : adapterType);
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;
const isDotRunner = runnerProvider === "openai_dot";
const isGrokBotRunner = runnerProvider === "grok_bot";
const isMuseRunner = runnerProvider === "muse";
const isExternalRunner = isDotRunner || isGrokBotRunner || isMuseRunner;
const showAdapterTestEnvironmentButton = !isGrokBotRunner && (props.showAdapterTestEnvironmentButton ?? true);
const showInlineAdapterTestEnvironmentButton =
showAdapterTestEnvironmentButton && !props.onTestActionChange && !props.compactTestFeedback;
const isLocal = !isExternalRunner && (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 fieldsUiAdapter = useMemo(() => getUIAdapter(fieldsAdapterType), [fieldsAdapterType]);
const supportedEnvironmentDrivers = useMemo(
() => new Set(supportedEnvironmentDriversForAdapter(fieldsAdapterType)),
[fieldsAdapterType],
);
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 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 savedDefaultEnvironmentId = isCreate ? null : props.agent.defaultEnvironmentId ?? null;
const { effectiveLoginEnvironmentId, savedLoginEnvironmentId } = useMemo(() => {
const resolve = (agentDefaultEnvironmentId: string | null) => {
try {
return resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId,
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;
}
};
return {
effectiveLoginEnvironmentId: resolve(rawCurrentDefaultEnvironmentId || null),
savedLoginEnvironmentId: resolve(savedDefaultEnvironmentId),
};
}, [
rawCurrentDefaultEnvironmentId,
savedDefaultEnvironmentId,
instanceSettings?.defaultEnvironmentId,
environments,
experimentalSettings?.enableManagedSandboxOnly,
]);
const effectiveLoginEnvironment = useMemo(
() => environments.find((environment) => environment.id === effectiveLoginEnvironmentId) ?? null,
[environments, effectiveLoginEnvironmentId],
);
const nativeRunnerAvailability = runnerDiscovery.data?.find(item => item.type === "paperclip_runner");
const upgradeEnvironment = forcedKubernetes ? kubernetesEnvironment : effectiveLoginEnvironment;
const upgradeEnvironmentId = upgradeEnvironment?.id ?? null;
const upgradeEnvironmentChanged = !isCreate && !forcedKubernetes
&& effectiveLoginEnvironmentId !== savedLoginEnvironmentId;
const canQualifyRunnerUpgrade = upgradeEnvironment?.status === "active"
&& canQualifyRunnerEnvironment(upgradeEnvironment)
&& Boolean(runnerAvailability && !runnerAvailability.disabled && !runnerAvailability.overriddenBuiltin)
&& Boolean(nativeRunnerAvailability && !nativeRunnerAvailability.disabled && !nativeRunnerAvailability.overriddenBuiltin);
const upgradeWarnings = harnessType === "claude_local" ? normalizeClaudeRunnerOptions(effectiveConfig).warnings : [];
const runnerSettingsTargetRef = useRef("");
const runnerSettingsTarget = JSON.stringify([!isCreate ? props.agent.id : null, selectedCompanyId, adapterType,
rawCurrentDefaultEnvironmentId, upgradeEnvironmentId, upgradeEnvironment?.config, effectiveConfig, overlay]);
runnerSettingsTargetRef.current = runnerSettingsTarget;
useEffect(() => () => { runnerSettingsTargetRef.current = ""; }, []);
const showRunnerUpgrade = !isCreate
&& ["codex_local", "claude_local"].includes(props.agent.adapterType)
&& adapterType === props.agent.adapterType
&& !runnerDiscovery.isError
&& (runnerAvailability?.defaultRunner === "paperclip" || canQualifyRunnerUpgrade);
const upgradeRunner = async () => {
if (isCreate || runnerUpgradePending || upgradeEnvironmentChanged) return;
const agentId = props.agent.id;
setRunnerUpgrade({ agentId, status: "pending" });
try {
const flushedEnv = flushEnvironmentDraft();
const nextOverlay = flushedEnv
? { ...overlay, adapterConfig: { ...overlay.adapterConfig, env: flushedEnv } }
: overlay;
const pendingTarget = runnerSettingsTargetRef.current;
if (runnerAvailability?.defaultRunner !== "paperclip") {
if (!canQualifyRunnerUpgrade || !upgradeEnvironmentId || !selectedCompanyId) {
throw new Error("Could not determine which environment to verify. Retry the upgrade.");
}
const qualified = await qualifyRunnerEnvironment({
adapterType: harnessType,
readAdapters: () => adaptersApi.list({ companyId: selectedCompanyId, environmentId: upgradeEnvironmentId }),
probeEnvironment: () => agentsApi.qualifyRunner(agentId, upgradeEnvironmentId, selectedCompanyId),
isCurrent: () => runnerSettingsTargetRef.current === pendingTarget,
});
if (!qualified) throw new Error("Agent settings changed during verification. Retry the upgrade with the current settings.");
queryClient.setQueryData(queryKeys.adapters.availability(selectedCompanyId, rawCurrentDefaultEnvironmentId || null), qualified.adapters);
if (qualified.runner !== "paperclip") throw new Error("This environment does not support Paperclip Runner. Your agent still uses Legacy runner.");
}
await props.onSave({ ...buildAgentUpdatePatch(props.agent, nextOverlay), runner: "paperclip" });
setRunnerUpgrade(current => current?.agentId === agentId ? { agentId, status: "upgraded" } : current);
} catch (error) {
setRunnerUpgrade(current => current?.agentId === agentId ? {
agentId, status: "failed", error: error instanceof Error ? error.message : "Could not upgrade this agent. Try again.",
} : current);
}
};
// 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 modelProvider = adapterType === "opencode_local" && aiConnectionBindingSchema.safeParse(
(overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection,
).data?.provider === "openrouter" ? "openrouter" : runnerProvider;
const runnerAcpxAgent = adapterType === "paperclip_runner" && runnerProvider === "acpx"
? String(isCreate ? props.values.adapterSchemaValues?.acpxAgent ?? "claude" : eff("adapterConfig", "acpxAgent", config.acpxAgent ?? "claude")) : undefined;
const modelAiBinding = aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data;
const modelCacheIdentity = runnerAcpxAgent === "copilot" ? `${modelProvider}:${runnerAcpxAgent}:${isCreate ? "new" : props.agent.id}:${JSON.stringify(modelAiBinding ?? null)}` : modelProvider;
const connectionModels = useConnectionModels(isExternalRunner ? null : selectedCompanyId, isCreate ? undefined : modelAiBinding, aiRoutingHarness(adapterType, runnerProvider, runnerAcpxAgent));
// Fetch adapter models for the effective provider, including unsaved changes.
const modelAdapterType = isRunnerHarness ? harnessType : adapterType;
const modelQueryKey = selectedCompanyId
? queryKeys.agents.adapterModels(selectedCompanyId, modelAdapterType, currentDefaultEnvironmentId || null, modelCacheIdentity)
: ["agents", "none", "adapter-models", adapterType];
const {
data: fetchedModels,
error: fetchedModelsError,
isLoading: fetchingModels,
refetch: refreshConnectionModels,
} = useQuery({
queryKey: modelQueryKey,
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, modelAdapterType, {
environmentId: currentDefaultEnvironmentId || null,
provider: modelProvider,
acpxAgent: runnerAcpxAgent,
aiConnection: modelAiBinding,
agentId: isCreate ? undefined : props.agent.id,
}),
...(runnerAcpxAgent === "copilot" ? COPILOT_MODEL_DISCOVERY_QUERY_OPTIONS : {}),
enabled: Boolean(selectedCompanyId) && !isExternalRunner && !connectionModels,
});
const copilotModelScope = runnerAcpxAgent === "copilot" ? JSON.stringify(modelQueryKey) : null;
useEffect(() => {
if (copilotModelScope && selectedCompanyId && !isExternalRunner && !connectionModels) void refreshConnectionModels();
}, [copilotModelScope, selectedCompanyId, isExternalRunner, connectionModels, refreshConnectionModels]);
const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null);
const [refreshingModels, setRefreshingModels] = useState(false);
const models = connectionModels?.models ?? fetchedModels ?? externalModels ?? [];
const modelError = connectionModels ? connectionModels.error : fetchedModelsError;
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 = {
companyId: selectedCompanyId ?? undefined,
agentId: isCreate ? undefined : props.agent.id,
mode,
isCreate,
adapterType: fieldsAdapterType,
hideRunnerHarness: isRunnerHarness,
values: isCreate ? { ...props.values, ...(runnerProfile && fieldsAdapterType === "paperclip_runner" ? { adapterSchemaValues: { ...props.values.adapterSchemaValues, ...runnerProfile } } : {}) } : null,
set: isCreate ? (patch: Partial<CreateConfigValues>) => props.onChange(patch) : null,
config: runnerProfile && fieldsAdapterType === "paperclip_runner" ? { ...config, ...runnerProfile } : config,
eff: eff as <T>(group: "adapterConfig", field: string, original: T) => T,
// Harness transitions supply the new harness's default model. Resolve
// user-selected model IDs in ModelDropdown, not through the previous
// render's harness when adapter fields change several values together.
mark,
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 || isExternalRunner,
managedSandboxOnly: hideHostPaths || isExternalRunner,
openAiDotEnabled: experimentalSettings?.enableOpenAiDot === true,
museEnabled: experimentalSettings?.enableMuse === true && experimentalSettings?.enableNativeRunner === true,
};
// 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} />}
<fieldsUiAdapter.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;
const aiConnection = isCreate ? undefined : aiRuntimeConnectionBindingSchema.safeParse(
(overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? props.agent.runtimeConfig.aiConnection,
).data;
if (!isCreate && props.agent.adapterType === "paperclip_runner" && adapterType === "paperclip_runner"
&& !isExternalRunner && aiConnection?.mode !== "router" && !isDirty && environmentId && canQualifyRunnerEnvironment(environmentList.find(environment => environment.id === environmentId))) {
const target = runnerSettingsTargetRef.current;
// Qualify only the saved computer. The strict agent-scoped endpoint
// accepts no draft provider config, credentials or replacement target.
const qualification = await agentsApi.qualifyRunner(props.agent.id, environmentId, selectedCompanyId);
if (runnerSettingsTargetRef.current !== target) throw new Error("Agent settings changed during the test. Run test again.");
if (qualification.ok !== true || qualification.details?.platformQualificationRecorded !== true) {
throw new Error(qualification.summary || "Could not verify the saved computer. Run test again.");
}
}
if (props.compactTestFeedback) {
const providerAdapter = adapterType === "paperclip_runner"
? adapterConfig.provider === "codex" ? "codex_local"
: adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "grok" ? "grok_local"
: adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "claude" ? "claude_local"
: adapterType
: adapterType;
return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, aiConnection, environmentId, ...(isRunnerHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) });
}
return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId, ...(isRunnerHarness && (isCreate ? runner !== "auto" : overlay.runner !== undefined) ? { runner } : {}) });
},
});
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, runner, effectiveLoginEnvironmentId, runner === "auto" ? runnerAvailability?.defaultRunner : undefined]);
// 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(isRunnerHarness ? harnessType : 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 =
(isCreate || !((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection)) &&
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: isRunnerHarness ? harnessType : 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, modelAdapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: modelProvider, ...(runnerAcpxAgent === "copilot" ? { acpxAgent: runnerAcpxAgent, aiConnection: modelAiBinding, agentId: !isCreate ? props.agent.id : undefined } : {}) });
queryClient.setQueryData(modelQueryKey, refreshed);
} catch (error) {
setRefreshModelsError(error instanceof Error ? error.message : "Failed to refresh adapter models.");
} finally {
setRefreshingModels(false);
}
}
const thinkingEffortKey =
isCodexHarness || (adapterType === "paperclip_runner" && harnessType === "claude_local")
? "modelReasoningEffort"
: adapterType === "cursor"
? "mode"
: adapterType === "opencode_local"
? "variant"
: adapterType === "grok_local" ? "reasoningEffort"
: adapterType === "pi_local" ? "thinking" : "effort";
const thinkingEffortOptions =
isCodexHarness
? 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 }))]
: harnessType === "claude_local" || adapterType === "grok_local"
? [{ id: "", label: "Auto" }, ...setupEfforts(harnessType, currentModelId).map((id) => ({
id,
label: id === "xhigh" ? "X-High" : id[0].toUpperCase() + id.slice(1),
}))]
: claudeThinkingEffortOptions;
const currentThinkingEffort = isCreate
? val!.thinkingEffort
: isCodexHarness || (adapterType === "paperclip_runner" && harnessType === "claude_local")
? eff(
"adapterConfig",
thinkingEffortKey,
String(config[thinkingEffortKey] ?? config.reasoningEffort ?? config.modelReasoningEffort ?? config.effort ?? ""),
)
: 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 = !(harnessType === "pi_local" && fieldsAdapterType === "paperclip_runner")
&& adapterType !== "gemini_local"
&& adapterType !== "cursor_cloud"
&& (adapterType !== "paperclip_runner" || isRunnerHarness);
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}
disabled={runnerUpgradePending}
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 (
<div className="space-y-6">
{showRunnerUpgrade && !runnerUpgraded ? (
<div className="space-y-3 rounded-lg border border-border bg-muted/30 p-4">
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1 space-y-1">
<p className="text-sm font-medium">Upgrade to Paperclip Runner</p>
<p className="text-sm text-muted-foreground">
Keep {harnessType === "claude_local" ? "Claude" : "Codex"}, your model, account, and compatible settings.
New sessions start fresh; past runs stay available.
</p>
{canQualifyRunnerUpgrade && runnerAvailability?.defaultRunner !== "paperclip" ? (
<p className="text-xs text-muted-foreground">The upgrade will verify your computer first. This can take a few minutes.</p>
) : null}
{upgradeWarnings.map(warning => <p key={warning.field} className="text-xs text-muted-foreground">{warning.message}</p>)}
{upgradeEnvironmentChanged ? (
<p className="text-xs text-muted-foreground">Save your computer change before upgrading the runner.</p>
) : isDirty ? <p className="text-xs text-muted-foreground">Your unsaved changes will be included.</p> : null}
</div>
<Button size="sm" className="self-end sm:self-auto" onClick={upgradeRunner} disabled={runnerUpgradePending || upgradeEnvironmentChanged || (!isCreate && props.isSaving)}>
{runnerUpgradePending ? <Loader2 className="size-4 animate-spin" /> : null}
{runnerUpgradePending ? "Upgrading…" : "Upgrade runner"}
</Button>
</div>
{runnerUpgradeError ? <p role="alert" className="text-sm text-destructive">{runnerUpgradeError}</p> : null}
</div>
) : null}
{runnerUpgraded ? <p role="status" className="text-sm text-muted-foreground">Upgraded to Paperclip Runner.</p> : null}
{adapterType === "paperclip_runner" && upgradeWarnings.length > 0 ? (
<div role="status" className="space-y-1 rounded-lg border border-border bg-muted/30 p-4">
{upgradeWarnings.map(warning => <p key={warning.field} className="text-sm text-muted-foreground">{warning.message}</p>)}
</div>
) : null}
<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">
<SelectPopover
aria-label="Environment override"
value={currentDefaultEnvironmentId}
onValueChange={(nextValue) => {
if (isCreate) {
set!({ defaultEnvironmentId: nextValue });
return;
}
mark("identity", "defaultEnvironmentId", nextValue || null);
}}
options={[
{ value: "", label: `Default: ${inheritedEnvironmentLabel}` },
...environmentOptions.map((environment) => ({
value: environment.id,
label: environmentDisplayLabel(environment),
})),
]}
/>
</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 && isGrokBotRunner && (
<Field label="Harness" hint={help.adapterType}>
<div className="inline-flex items-center gap-1.5">
<AdapterMark type="grok_bot" className="size-4" />
<span>Grok Bot</span>
<ExperimentalBadge />
</div>
</Field>
)}
{showAdapterTypeField && !isGrokBotRunner && (
<Field label="Harness" hint={help.adapterType}>
<AdapterTypeDropdown
value={isDotRunner ? "openai_dot" : isMuseRunner ? "muse" : isRunnerHarness ? harnessType : adapterType}
disabledTypes={adapterPickerDisabledTypes}
openAiDotEnabled={experimentalSettings?.enableOpenAiDot === true}
nativeRunnerEnabled={experimentalSettings?.enableNativeRunner === true}
museEnabled={experimentalSettings?.enableMuse === true && experimentalSettings?.enableNativeRunner === true}
onChange={(choice) => {
const dot = choice === "openai_dot";
const muse = choice === "muse";
const external = dot || muse;
const t = external ? "paperclip_runner" : choice;
if (isCreate) {
// Reset all adapter-specific fields to defaults when switching adapter type
const { adapterType: _at, ...defaults } = defaultCreateValues;
const nextValues: CreateConfigValues = { ...defaults, adapterType: t, runner: undefined };
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;
}
if (external) {
nextValues.model = "";
nextValues.adapterSchemaValues = { provider: muse ? "muse" : "openai_dot" };
}
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,
runner: ["codex_local", "claude_local", "opencode_local", "pi_local"].includes(t) ? "auto" : undefined,
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
: external ? ""
: t === "paperclip_runner"
? resolvePaperclipRunnerTransitionModel(adapterType, config.model)
: "",
effort: "",
modelReasoningEffort: "",
variant: "",
mode: "",
...(external
? { provider: muse ? "muse" : "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: false, ...(dot ? { dotAttachmentAccess: false, dotWorkspaceAccess: false } : {}) }
: t === "paperclip_runner"
? {
...paperclipRunnerTransitionConfig(adapterType, eff("adapterConfig", "model", config.model)),
}
: {}),
},
}));
}
}}
/>
</Field>
)}
{!isExternalRunner && !isCreate && selectedCompanyId && <AiConnectionField companyId={selectedCompanyId} agentId={props.agent.id} agentName={props.agent.name} adapterType={aiRoutingHarness(adapterType, eff("adapterConfig", "provider", config.provider), eff("adapterConfig", "acpxAgent", config.acpxAgent))}
routerAdapterType={adapterType} value={aiRuntimeConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data}
model={String(eff("adapterConfig", "model", config.model) ?? "")} environmentId={currentDefaultEnvironmentId || undefined} legacy
onChange={binding => {
mark("runtime", "runtimeConfig", { ...runtimeConfig, aiConnection: binding });
if (runnerAcpxAgent === "copilot" && hashKey([modelAiBinding ?? null]) === hashKey([binding ?? null])) void refreshConnectionModels();
}} />}
{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={isRunnerHarness ? harnessType : 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}
loadingModels={connectionModels?.isLoading ?? fetchingModels}
value={currentModelId}
onChange={(v) => {
const supportedEfforts = setupEfforts(isRunnerHarness ? harnessType : adapterType, v);
const clearUnsupportedEffort = ["codex_local", "claude_local", "grok_local"].includes(isRunnerHarness ? harnessType : adapterType)
&& Boolean(currentThinkingEffort)
&& !supportedEfforts.includes(String(currentThinkingEffort));
if (isCreate) {
set!({
model: v,
...(clearUnsupportedEffort ? { thinkingEffort: "" } : {}),
});
return;
}
mark("adapterConfig", "model", connectionModels?.resolveModel(v) || v || undefined);
if (clearUnsupportedEffort) {
for (const key of ["modelReasoningEffort", "reasoningEffort", "effort"]) mark("adapterConfig", key, 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={!connectionModels && (adapterType === "opencode_local" || adapterType === "pi_local")}
preserveOrder={Boolean(connectionModels) || adapterCuratesModelOrder(adapterType)}
creatable
detectedModel={connectionModels ? undefined : detectedModel}
detectedModelCandidates={[]}
onDetectModel={connectionModels || adapterType === "opencode_local" || adapterType === "paperclip_runner"
? undefined
: async () => {
const result = await refetchDetectedModel();
return result.data?.model ?? null;
}}
onRefreshModels={
connectionModels ? connectionModels.refreshModels : supportsAdapterModelRefresh(adapterType)
? handleRefreshModels
: undefined
}
refreshingModels={connectionModels?.refreshing ?? refreshingModels}
detectModelLabel="Detect model"
emptyDetectHint="No model detected. Select or enter one manually."
/>
{(refreshModelsError || modelError) && (
<p className="text-xs text-destructive">
{refreshModelsError
?? (modelError instanceof Error
? modelError.message
: "Failed to load adapter models.")}
</p>
)}
{adapterType === "opencode_local"
&& !connectionModels
&& 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);
if (isRunnerHarness) {
for (const key of ["modelReasoningEffort", "reasoningEffort", "effort"]) if (key !== thinkingEffortKey) mark("adapterConfig", key, 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">
{isRunnerHarness && <AgentRunnerSelect value={runner}
pending={runnerDiscovery.isFetching} error={runnerDiscovery.error?.message} onRetry={() => { void runnerDiscovery.refetch(); }}
defaultRunner={runnerAvailability?.defaultRunner}
supportedRunners={runnerAvailability?.supportedRunners}
onChange={next => isCreate ? set!({ runner: next }) : setOverlay(prev => ({ ...prev, runner: next }))} />}
{isLocal && (!isRunnerHarness || fieldsAdapterType !== "paperclip_runner") && (<> {/*
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 ---- */}
{!isExternalRunner && !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
variant={isExternalRunner ? "prerequisites" : "connection"}
metadataOnly={adapterType === "paperclip_runner" && runnerAcpxAgent === "copilot"}
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>
</div>
);
}
// 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 & {
aiConnection?: import("@paperclipai/shared").AiConnectionLoginIntent;
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?: (sessionId?: string) => 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} />;
}
class AdapterLoginConflictError extends Error {
constructor(readonly sessionId: string) {
super("Another sign-in attempt is active. Finish or cancel that attempt before starting a new sign-in.");
}
}
function DisplayedCodeLoginPanel({
companyId,
adapterType,
environmentId,
autoStart,
onConnected,
onAccountBinding,
chrome = "panel",
aiConnection,
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, aiConnection }),
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 {
const active = await agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType);
if (!active) return null;
if ((aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new AdapterLoginConflictError(active.sessionId);
return active;
} 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 cancelConflictingLogin = useMutation({
mutationFn: async () => {
const conflict = activeSessionQuery.error;
if (!(conflict instanceof AdapterLoginConflictError)) return;
await agentsApi.cancelAdapterAuthLogin(companyId, adapterType, conflict.sessionId);
},
onSuccess: async () => {
autoStartedRef.current = false;
resumeAttemptedRef.current = false;
setStartError(null);
await activeSessionQuery.refetch();
},
onError: () => setStartError("Could not cancel the previous sign-in. Retry before starting a new one."),
});
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?.(sessionId ?? undefined);
}, [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 (
<ProviderSubscriptionCard
loading={!prompt && !startError && !failed}
providerName={connectSourceName(adapterType)}
authorizationUrl={prompt?.url}
mode="displayed_code"
>
{startError ? (
<div>
<p role="alert" className="pl-2 text-xs text-destructive">{startError}</p>
{activeSessionQuery.error instanceof AdapterLoginConflictError && (
<Button type="button" variant="outline" disabled={cancelConflictingLogin.isPending}
onClick={() => cancelConflictingLogin.mutate()}>
Cancel previous sign-in and retry
</Button>
)}
</div>
) : 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 />
)}
</ProviderSubscriptionCard>
);
}
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",
aiConnection,
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,
aiConnection,
// 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 && !aiConnection
? {
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 {
const active = await agentsApi.getActiveClaudeSetupTokenLoginSession(companyId);
if (!active) return null;
if ((aiConnection && active.environmentId !== environmentId) || Boolean(active.aiConnection) !== Boolean(aiConnection) || (aiConnection && (active.aiConnection?.provider !== aiConnection.provider || active.aiConnection?.method !== aiConnection.method || active.aiConnection?.connectionId !== aiConnection.connectionId || active.aiConnection?.ownership !== aiConnection.ownership || active.aiConnection?.allAgents !== aiConnection.allAgents || JSON.stringify(active.aiConnection?.agentIds) !== JSON.stringify(aiConnection.agentIds)))) throw new Error("Another sign-in attempt is active. Finish or cancel it in its original account setup before starting this one.");
return active;
} 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?.(sessionId ?? undefined);
}, [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 (
<ProviderSubscriptionCard
loading={!authorizationUrl && !startError && !failedNow}
providerName={connectSourceName(adapterType)}
authorizationUrl={authorizationUrl ?? undefined}
mode="submitted_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}
/>
)}
</ProviderSubscriptionCard>
);
}
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,
openAiDotEnabled = false,
museEnabled = false,
nativeRunnerEnabled = false,
}: {
value: string;
onChange: (type: string) => void;
disabledTypes: Set<string>;
openAiDotEnabled?: boolean;
museEnabled?: boolean;
nativeRunnerEnabled?: boolean;
}) {
const selectedDisplay = getAdapterDisplay(value);
const adapterList = useMemo(() =>
[...listAdapterOptions(type => adapterLabels[type] ?? getAdapterLabel(type)),
...(openAiDotEnabled ? [{ value: "openai_dot", label: "OpenAI Dot", experimental: true, comingSoon: false }] : []),
...(museEnabled ? [{ value: "muse", label: "Muse — Personal agent", experimental: true, comingSoon: false }] : []),
].filter(item => !item.comingSoon && (item.value !== "paperclip_runner" || nativeRunnerEnabled) && !disabledTypes.has(item.value))
.sort((a, b) => Number(a.value === "paperclip_runner") - Number(b.value === "paperclip_runner")),
[disabledTypes, openAiDotEnabled, nativeRunnerEnabled, museEnabled]);
return <SelectPopover aria-label="Harness" value={value} onValueChange={onChange}
displayValue={<span className="inline-flex min-w-0 items-center gap-1.5">
<span aria-hidden="true" className="inline-flex shrink-0"><AdapterMark type={value} className="size-4" /></span>
<span className="truncate">{adapterLabels[value] ?? getAdapterLabel(value)}</span>
{selectedDisplay.experimental && <ExperimentalBadge />}
</span>}
options={adapterList.map(item => ({ value: item.value,
group: item.value === "paperclip_runner" ? "Advanced" : undefined,
label: <span className="inline-flex items-center gap-1.5"><span aria-hidden="true" className="inline-flex shrink-0"><AdapterMark type={item.value} className="size-4" /></span><span>{item.label}</span>{item.experimental && <ExperimentalBadge />}</span>,
}))} />;
}
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,
preserveOrder,
creatable,
detectedModel,
detectedModelCandidates,
onDetectModel,
onRefreshModels,
refreshingModels,
loadingModels,
detectModelLabel,
emptyDetectHint,
defaultLabel,
presentation = "searchable",
}: {
models: AdapterModel[];
value: string;
onChange: (id: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
allowDefault: boolean;
required: boolean;
groupByProvider: boolean;
/** Keep the adapter's list order (curated lists) instead of sorting ungrouped entries by id. */
preserveOrder?: boolean;
creatable?: boolean;
detectedModel?: string | null;
detectedModelCandidates?: string[];
onDetectModel?: () => Promise<string | null>;
onRefreshModels?: () => Promise<void>;
refreshingModels?: boolean;
loadingModels?: boolean;
detectModelLabel?: string;
emptyDetectHint?: string;
defaultLabel?: string;
presentation?: "searchable" | "native";
}) {
const [modelSearch, setModelSearch] = useState("");
const [enteringCustomModel, setEnteringCustomModel] = useState(false);
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) {
// A hand-ordered list (newest release of each family first, older releases at the end) is
// shown as the adapter ordered it; a discovered list has no stable order, so sort it.
const entries = preserveOrder ? filteredModels : [...filteredModels].sort((a, b) => a.id.localeCompare(b.id));
return [{ provider: "models", entries }];
}
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, preserveOrder]);
async function handleDetectModel() {
if (!onDetectModel) return;
setDetectingModel(true);
try {
const nextModel = await onDetectModel();
if (nextModel) {
onChange(nextModel);
onOpenChange(false);
setModelSearch("");
}
} finally {
setDetectingModel(false);
}
}
if (presentation === "native") {
const customOption = "__paperclip_custom_model__";
const extraModels = [...new Set([value, ...promotedModelIds])].filter(id => id && (!models.some(model => model.id === id) || promotedModelIds.has(id)));
return (
<Field label="Model" hint={help.model}>
<NativeSelect
aria-label="Model"
aria-busy={loadingModels || refreshingModels}
value={enteringCustomModel ? customOption : value}
required={required && !enteringCustomModel}
onChange={event => {
const next = event.target.value;
setEnteringCustomModel(next === customOption);
if (next !== customOption) onChange(next);
}}
>
<option value="" disabled={!allowDefault}>
{allowDefault ? (defaultLabel ?? "Default") : loadingModels ? "Loading models…" : required ? "Select model (required)" : "Select model"}
</option>
{extraModels.map(id => <option key={id} value={id}>{models.find(model => model.id === id)?.label ?? id}</option>)}
{groupedModels.map(({ provider, entries }) => groupByProvider ? (
<optgroup key={provider} label={provider}>
{entries.map(model => <option key={model.id} value={model.id}>{model.label}</option>)}
</optgroup>
) : entries.map(model => <option key={model.id} value={model.id}>{model.label}</option>))}
{creatable && <option value={customOption}>Enter custom model…</option>}
</NativeSelect>
{enteringCustomModel && (
<label className="mt-3 block space-y-1 text-xs text-muted-foreground">
Model ID
<Input aria-label="Model ID" value={value} onChange={event => onChange(event.target.value)}
placeholder="Enter model ID or alias" autoFocus required={required} />
</label>
)}
{loadingModels && <p role="status" className="mt-2 text-xs text-muted-foreground">Loading models…</p>}
{onRefreshModels && <Button type="button" variant="ghost" size="sm" disabled={refreshingModels}
onClick={() => void onRefreshModels()}>{refreshingModels ? "Refreshing…" : "Refresh models"}</Button>}
</Field>
);
}
return (
<Field label="Model" hint={help.model}>
<SelectPopover aria-label="Model" value={value}
displayValue={<span className={cn("truncate", !value && "text-muted-foreground")}>
{selected?.label ?? (value || (allowDefault ? (defaultLabel ?? "Default") : required ? "Select model (required)" : "Select model"))}
</span>}
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) setModelSearch("");
}}
>
<div role="listbox" aria-label="Model">
<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"
role="option" aria-selected
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"
role="option" aria-selected={false}
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"
role="option" aria-selected={false}
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"
role="option" aria-selected={!value}
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"
role="option" aria-selected={false}
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}
role="option" aria-selected={m.id === value}
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">
{loadingModels ? "Loading models…" : onDetectModel
? (emptyDetectHint ?? "No model detected yet. Enter a provider/model manually.")
: "No models found."}
</p>
</div>
)}
</div>
</div>
</SelectPopover>
</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;
}) {
return <Field label="Thinking effort" hint={help.thinkingEffort}>
<SelectPopover aria-label="Thinking effort" value={value} onValueChange={onChange}
open={open} onOpenChange={onOpenChange}
options={options.map(option => ({ value: option.id, label: option.label,
suffix: option.id ? <span className="text-xs text-muted-foreground font-mono">{option.id}</span> : undefined,
}))} />
</Field>;
}