mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 05:31:46 +02:00
## Thinking Path
> - Paperclip manages AI agents, their work, and their permissions.
> - Paperclip Runner supplies the same admitted tool authority to each
provider.
> - The Dot provider in #15402 needs reliable onboarding and useful
agent capabilities.
> - An idle Dot could not start work, assign a human, read skills, or
produce a workspace artifact.
> - Pairing also relied on a second configuration save before ordinary
admission could work.
> - This pull request adds governed idle admission and shared Runner
tools, and completes pairing atomically.
> - Operators can test Dot with fresh data while keeping normal company,
approval, budget, and run ownership checks.
## Linked Issues or Issue Description
**Subsystem affected**
Paperclip Runner, dedicated Dot MCP access, OAuth onboarding,
experimental settings, and empty worktree startup. This PR builds on
merged provider PR #15402. It reuses the merged MCP gateway from #14846
and assistant connection work from #14933 and #15380.
**Problem or motivation**
An idle Dot could see assignments but could not act on a conversation
request until someone created a task first. Its Runner catalog could not
assign tasks to humans or read pinned skills and workspace files.
First-time OAuth discovery and pairing also needed browser fixes, and a
completed pairing did not persist its binding reference on the agent.
**Proposed solution**
Keep Dot within the existing Runner. Admit a visible agent-authored
intake task for idle requests. Add people, human assignment, cross-task,
skill, and optional sandbox workspace tools through shared authority.
Relay assigned app calls through the configured MCP gateway. Add lease
renewal and follow-up references. Save pairing and its configuration
revision atomically. Show prerequisites and provide a complete copy
prompt.
**Roadmap alignment**
This extends the experimental provider in #15402. It uses the existing
governed gateway, task model, skills, artifact path, and native Runner.
It adds no separate execution subsystem.
## What Changed
- Add a standalone OpenAI Dot agent choice with an independent
experimental opt-in. It works with the general Runner option off.
Require Assistant connections (MCP), authenticated sign-in, and public
HTTPS for pairing.
- Use Dot’s own option for company package import. Allow an unpaired Dot
configuration to save after external billing acknowledgement; task
admission still requires pairing. Prepare the shared dev binary for
Dot-only opt-in.
- Label saved Dot agents as OpenAI Dot. Use prerequisite-check copy in
setup and runtime configuration.
- Route Dot creation directly to pairing after explicit external billing
acknowledgement. Hide local CLI, model, and harness setup for Dot.
Preserve the shared Runner implementation and canonical API type. Other
Runner providers still require their general opt-in.
- Add an empty worktree option with fresh signing keys and no production
data copy.
- Fix public-client OAuth negotiation, discovery compatibility, and
optional separate browser authorization origin.
- Add one-use pairing consent preview, clear copied setup instructions,
and atomic binding persistence and cleanup.
- Add idle request admission with stable request IDs and normal
scheduling, permissions, budgets, and task ownership.
- Add identity and people discovery, human task assignment and
reassignment, and authorized cross-task comments and documents.
- Read assigned skill files from pinned manifests. Relay assigned app
calls through the merged gateway without exposing credentials.
- Add an off-by-default workspace bridge. Constrain paths and writes.
Run commands in a deny-by-default OS sandbox with no network or injected
credentials. Reserve mutations before effects and never blindly repeat
uncertain work.
- Add rolling lease renewal, task pagination, bounded operation limits,
and deduplicated follow-up references without comment bodies in
webhooks.
- Add an off-by-default attachment reading setting. Restrict reads to
files on the current assigned task. Verify size and hash, cache bounded
verified copies per run, paginate text or binary bytes, and recheck live
authority before returning.
- Keep file grants operator-owned. Reject agent self-grants across
configuration routes. Preserve attachment consent in create/import
forms. Close generic API file bypasses while retaining current-run
response snapshots and permitted uploads.
- Keep provider limits explicit. Do not inherit a Dot binding,
attachment permission, or workspace permission when hiring another
agent.
- Include the required Markdown format in cross-task document writes and
validate the API title limit. Verify real creation and revision
persistence.
- Restrict command execution to Linux bubblewrap with descendant
containment. macOS retains workspace file tools and artifact publishing,
while refusing command calls. Explain the platform limit in setup.
- Exclude Paperclip instance state from workspace files, uploads,
artifact publication, and sandbox commands. Protect nested directories
and case variants. Fail closed when the directory protection scan
exceeds 4,096 directories.
- Add static UI compression for slow public tunnels. Document setup, the
complete tool inventory, and qualification limits.
## Verification
- Merge preparation on `00ca2c75b` integrates merged base #15402 and
master `fc6304dfe`. The ancestry commit preserves the reviewed follow-up
source tree. The subsequent security fix excludes instance state from
file tools, uploads, artifact publication, and sandbox commands. It
preserves private task authorization and task monitors. It regenerates
the combined tool catalog, seeded catalog digest, and protocol manifest.
Workspace typecheck, full build, and UI token gates pass. All sixteen
real Dot broker cases and 93 company import cases pass after the review
fixes and creator-attribution test correction. The exported
human-assignment catalog and Unicode page boundaries are also fixed. All
ten catalog tests and nine workspace/skill bridge tests pass. All 35
workspace bridge and authority tests passed after the instance-state
fix, including real macOS commands in that intermediate version. The
subsequent document and descendant-containment fixes pass 44 focused
tests across bridge, authority, and setup UI, with six Linux command
cases skipped on macOS. Real cross-task documents pass the route
validator and persist two revisions. macOS refuses command execution and
does not advertise the tool. Full workspace typecheck and build, changed
server/UI typechecks, and token gates pass again on the final commit.
Greptile rates final head 00ca2c75b 5/5 and its completed check
concludes success; all review threads are resolved. All 58 current-head
checks are complete: 54 passed and 4 intentionally skipped. This
includes full tests, typecheck, build, Runner, browser E2E, release
verification, and Canary Dry Run. The older full local root attempt
finished with 16,602 passes, 93 skips, and ten failures across two
suites: it began before source edits and retained earlier imported
implementations while loading later tests. Both suites pass a clean
final-head rerun (25 passed, six Linux-only command cases skipped on
macOS). This mixed-source full attempt is not a final-head full-suite
pass; use the fresh CI evidence below.
- Full workspace typecheck and build pass for the standalone Dot change
on head `392d54f80`. UI token gates are clean. The full workspace
typecheck passed before the final importer UI edit; the changed UI
typecheck, build, and token gates pass again on the final commit. The
server build passes for its unchanged final source.
- Standalone selection, creation, settings, harness visibility,
inventory, and runtime admission checks pass. OAuth onboarding and the
real Rust/PostgreSQL broker suite pass 27 cases with the general Runner
option disabled. Dot and MCP revocation still block pairing; other
Runner providers remain disabled. Create, hire, conversion, and
inherited-hire route regressions pass 68 cases. The runtime selection
suite passes 20 cases. The setup UI suite passes 50 cases. Company
import and dev binary checks pass 97 cases. Import UI checks pass 29
cases, including Dot-only selection, preservation of imported Dot
agents, generic Runner fallback, canonical configuration serialization,
and required billing acknowledgement. A read of the synthetic instance
reports the native binary required with Dot enabled, the general rollout
disabled, and no persisted native work.
- The latest attachment, operator-consent, generic API file-guard, and
bridge checks pass 80 tests. Cache and native lifecycle checks pass 550
tests; create/import builders pass 45 tests; attachment setup UI checks
pass 16 tests. The real Rust/PostgreSQL Dot broker suite passes 15
cases.
- Earlier authority, human assignment, pinned skill, confinement,
cancellation, inheritance, onboarding, replay, OpenAPI, and gateway
regressions pass their focused rechecks. Workspace writes reject
concurrent stale hashes.
- In the live empty test-drive, turn the general Runner option off and
leave Dot and MCP on. Add agent shows a separate OpenAI Dot choice.
Create rejects missing billing acknowledgement, saves the canonical Dot
configuration, and opens pairing. The existing paired Dot remains ready
and passes its prerequisite checks. No new plugin pairing was needed for
this UI change. The final browser import preview keeps a Dot source
agent as OpenAI Dot, falls back a generic Runner agent while its switch
is off, and offers Dot independently.
- Real Dot completed OAuth, signed MCP Events readiness, and an
event-only assigned document task in an empty synthetic instance.
Pairing saved its binding without a second configuration save.
- Real Dot verified human assignment, cross-task comments and documents,
pinned skill reading, sandbox commands, lease renewal, follow-up input,
and a downloaded artifact whose bytes and hash matched its receipt. An
idle conversation request created an intake, created a task for its
human owner, continued after a definite missing-file read error, and
finalized Done with exit code 0.
- After operator approval, real Dot read a synthetic assigned-task
attachment and wrote its file-only random proof into an agent-authored
document. Its first test required accepting review because the existing
document tool removed the final newline.
- On head `2626c8f9b`, real Dot read a 13,849-byte synthetic attachment
in two pages, used `expectedSha256` on page two, saved exactly its final
random marker, verified readback, and finalized Done with exit code 0. A
direct inbox check was required for this attachment qualification; it
does not claim event-only delivery.
- Previous head `392d54f80` passes all 55 checks (53 passed, two
intentionally skipped), including the full test, typecheck, build,
native Runner, browser, and release verification gates. Greptile rates
this head 5/5; all review threads are resolved.
- Head `2626c8f9b` passed all 56 checks: 54 passed and two intentionally
skipped. This includes full typecheck, build, native Runner, server
tests, serialized suites, browser E2E, and Canary Dry Run. The unchanged
Cursor managed-runtime test exceeded its five-second deadline on the
first attempt; its focused local suite passed all eight cases, and the
single CI rerun plus dependent verify gate passed.
- A prior full local serial test attempt reported 19 failures (16,141
passed, 88 skipped) and stopped before later wrapper groups. It began
before the final source edits. Every failed suite has a passing fresh
recheck; the macOS snapshot stress case passes alone in 227 seconds. The
previous head `000fb9261` passed all 56 CI checks. PRs leave
`pnpm-lock.yaml` unchanged; CI and the refresh bot own dependency
resolution.
## Risks
- Dot remains off by default. Its own opt-in does not enable other
Runner providers. It still requires the MCP option; disabling Dot blocks
new work while keeping existing recovery and saved bindings.
- Dot requires a stable public HTTPS origin. Its base provider in #15402
is merged. Temporary tunnels are useful for testing but are not
permanent deployments.
- Idle intake creates a visible agent-authored task. It does not
fabricate a human message or bypass ordinary admission.
- Workspace commands require Linux bubblewrap with a private PID
namespace; deployment qualification is still needed. macOS file tools
and artifact publishing remain available, but commands are disabled
because sandbox-exec does not contain detached descendants. There is no
unrestricted fallback. Command protection fails closed above 4,096
workspace directories. The historical macOS command walkthrough does not
qualify the current Linux implementation.
- Provider model choice, token usage, cost, native thread control, and
global external stopping remain unavailable. Known Paperclip budget
gates still apply.
- The workspace bridge and attachment reader are separate opt-in
settings. Reading assigned task files sends their contents to OpenAI.
Revocation prevents future reads but cannot withdraw bytes already sent.
Files are read on request; automatic inbound attachment staging remains
disabled.
- An existing event registration can retain earlier instructions that
prohibit extra tasks. Dot asked for permission before a second intake
after the final event-only bootstrap; that extra no-nudge continuation
is not live-qualified under that earlier registration. The later
attachment qualification submitted normally through the composer. The
revised onboarding prompt describes the new idle entry point, and its
protocol polling path is tested.
- OpenAI event delivery can be delayed; a webhook acknowledgement is not
proof that Dot has begun work.
- A running Dot can retain an old plugin catalog after tool refresh.
Refresh and actual tool exposure must be checked before using new
top-level actions.
- Hosted and remote controller modes are not qualified.
## Model Used
OpenAI Codex, based on GPT-6. The exact deployment ID and context window
size are not exposed in this session. Capabilities used: reasoning,
repository editing, code execution, test inspection, and browser
control.
## 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 linked existing issues or described the issue in-PR
following the relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (focused suites and all
fresh failure rechecks pass; the earlier full attempt is disclosed
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
- [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>
4179 lines
186 KiB
TypeScript
4179 lines
186 KiB
TypeScript
import { useConnectionModels } from "./ai-connections/useConnectionModels";
|
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type {
|
|
Agent,
|
|
AdapterAuthSessionPrompt,
|
|
AdapterAuthSessionStatus,
|
|
CodexAccountBindingClaim,
|
|
AdapterEnvironmentTestResult,
|
|
CompanySecret,
|
|
EnvBinding,
|
|
EnvSecretRefBinding,
|
|
Environment,
|
|
} from "@paperclipai/shared";
|
|
import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapter, isValidBrowserCode, ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
|
|
import type { AdapterModel } from "../api/agents";
|
|
import { agentsApi } from "../api/agents";
|
|
import { ApiError } from "../api/client";
|
|
import { environmentsApi } from "../api/environments";
|
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
|
import { secretsApi } from "../api/secrets";
|
|
import { assetsApi } from "../api/assets";
|
|
import {
|
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
|
DEFAULT_CODEX_LOCAL_MODEL,
|
|
} from "@paperclipai/adapter-codex-local";
|
|
import { DEFAULT_CLAUDE_LOCAL_MODEL } from "@paperclipai/adapter-claude-local";
|
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
|
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
|
import { DEFAULT_KIMI_LOCAL_MODEL } from "@paperclipai/adapter-kimi-local";
|
|
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { NativeSelect, selectTriggerClassName } from "@/components/ui/select";
|
|
import { AdapterMark } from "./AdapterMark";
|
|
import { FolderOpen, Heart, ChevronDown, X, Copy, Check, ExternalLink, Loader2, TriangleAlert, Bug } from "lucide-react";
|
|
import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils";
|
|
import { copyTextToClipboard } from "../lib/clipboard";
|
|
import {
|
|
connectSourceName,
|
|
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 ||
|
|
Object.keys(o.adapterConfig).length > 0 ||
|
|
Object.keys(o.heartbeat).length > 0 ||
|
|
Object.keys(o.debug).length > 0 ||
|
|
Object.keys(o.runtime).length > 0
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Structural equality for overlay entry values. Overlay values are
|
|
* JSON-shaped (scalars, env maps, argument arrays), so a reference compare
|
|
* alone would keep an edit-then-restore of a structured value falsely dirty
|
|
* after a refresh subtracts the persisted snapshot.
|
|
*/
|
|
export function overlayValuesEqual(a: unknown, b: unknown): boolean {
|
|
if (Object.is(a, b)) return true;
|
|
if (Array.isArray(a) && Array.isArray(b)) {
|
|
return a.length === b.length && a.every((item, index) => overlayValuesEqual(item, b[index]));
|
|
}
|
|
if (
|
|
typeof a === "object" && a !== null && !Array.isArray(a) &&
|
|
typeof b === "object" && b !== null && !Array.isArray(b)
|
|
) {
|
|
const aEntries = Object.entries(a as Record<string, unknown>);
|
|
const bRecord = b as Record<string, unknown>;
|
|
return (
|
|
aEntries.length === Object.keys(bRecord).length &&
|
|
aEntries.every(([key, value]) => key in bRecord && overlayValuesEqual(value, bRecord[key]))
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Remove from `current` every entry `persisted` carried with a structurally
|
|
* equal value, keeping entries the user added or changed after `persisted`
|
|
* was snapshotted. The refresh that follows a background save consumes this
|
|
* so edits made while that save was in flight survive as pending dirty state
|
|
* instead of being wiped with the rest of the overlay.
|
|
*/
|
|
export function subtractPersistedOverlay(
|
|
current: AgentConfigOverlay,
|
|
persisted: AgentConfigOverlay,
|
|
): AgentConfigOverlay {
|
|
const subtractGroup = (
|
|
currentGroup: Record<string, unknown>,
|
|
persistedGroup: Record<string, unknown>,
|
|
): Record<string, unknown> =>
|
|
Object.fromEntries(
|
|
Object.entries(currentGroup).filter(
|
|
([field, value]) =>
|
|
!(field in persistedGroup) || !overlayValuesEqual(value, persistedGroup[field]),
|
|
),
|
|
);
|
|
return {
|
|
identity: subtractGroup(current.identity, persisted.identity),
|
|
...(current.adapterType !== undefined && current.adapterType !== persisted.adapterType
|
|
? { adapterType: current.adapterType }
|
|
: {}),
|
|
adapterConfig: subtractGroup(current.adapterConfig, persisted.adapterConfig),
|
|
heartbeat: subtractGroup(current.heartbeat, persisted.heartbeat),
|
|
debug: subtractGroup(current.debug, persisted.debug),
|
|
runtime: subtractGroup(current.runtime, persisted.runtime),
|
|
};
|
|
}
|
|
|
|
/* ---- Shared input class ---- */
|
|
const inputClass =
|
|
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
|
|
|
function parseCommaArgs(value: string): string[] {
|
|
return value
|
|
.split(",")
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function formatArgList(value: unknown): string {
|
|
if (Array.isArray(value)) {
|
|
return value
|
|
.filter((item): item is string => typeof item === "string")
|
|
.join(", ");
|
|
}
|
|
return typeof value === "string" ? value : "";
|
|
}
|
|
|
|
const openCodeThinkingEffortOptions = [
|
|
{ id: "", label: "Auto" },
|
|
{ id: "minimal", label: "Minimal" },
|
|
{ id: "low", label: "Low" },
|
|
{ id: "medium", label: "Medium" },
|
|
{ id: "high", label: "High" },
|
|
{ id: "xhigh", label: "X-High" },
|
|
{ id: "max", label: "Max" },
|
|
] as const;
|
|
|
|
const cursorModeOptions = [
|
|
{ id: "", label: "Auto" },
|
|
{ id: "plan", label: "Plan" },
|
|
{ id: "ask", label: "Ask" },
|
|
] as const;
|
|
|
|
const claudeThinkingEffortOptions = [
|
|
{ id: "", label: "Auto" },
|
|
{ id: "low", label: "Low" },
|
|
{ id: "medium", label: "Medium" },
|
|
{ id: "high", label: "High" },
|
|
] as const;
|
|
|
|
// Kimi exposes low/high/max (no "medium") via each model's support_efforts;
|
|
// the kimi_local adapter maps a legacy "medium" onto "high" at runtime.
|
|
const kimiThinkingEffortOptions = [
|
|
{ id: "", label: "Auto" },
|
|
{ id: "low", label: "Low" },
|
|
{ id: "high", label: "High" },
|
|
{ id: "max", label: "Max" },
|
|
] as const;
|
|
|
|
const MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS = 2;
|
|
const MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP = 10;
|
|
const MAX_TURN_CONTINUATION_DEFAULT_DELAY_SEC = 1;
|
|
const MAX_TURN_CONTINUATION_MAX_DELAY_SEC = 300;
|
|
|
|
function clampInteger(value: number, min: number, max: number) {
|
|
return Math.max(min, Math.min(max, Math.floor(value)));
|
|
}
|
|
|
|
function clampDelayMsFromSeconds(value: number) {
|
|
return clampInteger(value, 0, MAX_TURN_CONTINUATION_MAX_DELAY_SEC) * 1000;
|
|
}
|
|
|
|
function ConfigSections({ order, className, children }: {
|
|
order?: readonly string[];
|
|
className: string;
|
|
children: ReactNode;
|
|
}) {
|
|
if (!order) return <div className={className}>{children}</div>;
|
|
const rank = (child: ReactNode) => {
|
|
const key = isValidElement<{ "data-config-section"?: string }>(child)
|
|
? child.props["data-config-section"]
|
|
: undefined;
|
|
const index = key ? order.indexOf(key) : -1;
|
|
return index < 0 ? order.length : index;
|
|
};
|
|
const sections = Children.toArray(children).sort((a, b) => rank(a) - rank(b));
|
|
return <div className={className}>{sections}</div>;
|
|
}
|
|
|
|
/* ---- Form ---- */
|
|
|
|
export function AgentConfigForm(props: AgentConfigFormProps) {
|
|
const { mode, adapterModels: externalModels } = props;
|
|
const isCreate = mode === "create";
|
|
const cards = props.sectionLayout === "cards";
|
|
const showAdapterTypeField = props.showAdapterTypeField ?? true;
|
|
const showAdapterTestEnvironmentButton = props.showAdapterTestEnvironmentButton ?? true;
|
|
const showInlineAdapterTestEnvironmentButton =
|
|
showAdapterTestEnvironmentButton && !props.onTestActionChange && !props.compactTestFeedback;
|
|
const showInlineAdapterTestEnvironmentFeedback = !props.onTestFeedbackChange;
|
|
const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true;
|
|
const hideInstructionsFile = props.hideInstructionsFile ?? false;
|
|
const canConfigureProviderTrace = props.canConfigureProviderTrace === true;
|
|
const { selectedCompanyId } = useCompany();
|
|
const queryClient = useQueryClient();
|
|
const environmentVariablesEditorRef = useRef<EnvironmentVariablesEditorHandle | null>(null);
|
|
|
|
// Sync disabled adapter types from server so dropdown filters them out.
|
|
const disabledTypes = useDisabledAdaptersSync();
|
|
|
|
const { data: availableSecrets = [] } = useQuery({
|
|
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
|
|
queryFn: () => secretsApi.list(selectedCompanyId!),
|
|
enabled: Boolean(selectedCompanyId),
|
|
});
|
|
// User-secret definitions power the "User secret" env binding source. Requires
|
|
// secret-admin; non-admins simply get the free-text key fallback in the editor.
|
|
const { data: userSecretDefinitions = [] } = useQuery({
|
|
queryKey: selectedCompanyId
|
|
? queryKeys.secrets.userDefinitions(selectedCompanyId)
|
|
: ["user-secret-definitions", "none"],
|
|
queryFn: () => secretsApi.listUserSecretDefinitions(selectedCompanyId!),
|
|
enabled: Boolean(selectedCompanyId),
|
|
retry: false,
|
|
});
|
|
// Pending binding proposals targeting this agent (PAP-14731). Board-only route;
|
|
// non-permitted viewers simply get an empty list.
|
|
const editAgentId = !isCreate ? props.agent.id : null;
|
|
const { data: pendingProposals = [] } = useQuery({
|
|
queryKey: selectedCompanyId
|
|
? queryKeys.secrets.proposals(selectedCompanyId, "pending")
|
|
: ["secret-proposals", "none"],
|
|
queryFn: () => secretsApi.listProposals(selectedCompanyId!, "pending"),
|
|
enabled: Boolean(selectedCompanyId) && !isCreate,
|
|
retry: false,
|
|
});
|
|
const agentBindingProposals = useMemo(
|
|
() =>
|
|
pendingProposals.filter(
|
|
(proposal) => proposal.kind === "binding" && proposal.target?.id === editAgentId,
|
|
),
|
|
[pendingProposals, editAgentId],
|
|
);
|
|
const proposalReview = useProposalReview(selectedCompanyId, []);
|
|
const { data: experimentalSettings } = useQuery({
|
|
queryKey: queryKeys.instance.experimentalSettings,
|
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
|
retry: false,
|
|
});
|
|
const adapterPickerDisabledTypes = useMemo(() => {
|
|
const next = new Set(disabledTypes);
|
|
// Fail closed while settings load. Existing native agents still render
|
|
// their current value in edit mode, but the picker does not offer a fresh
|
|
// native selection until the explicit experimental opt-in is known true.
|
|
if (experimentalSettings?.enableNativeRunner !== true) {
|
|
next.add("paperclip_runner");
|
|
}
|
|
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 || generalSettings?.executionMode === "kubernetes"),
|
|
});
|
|
|
|
// Setting-driven: resolve whether the instance forces Kubernetes execution and
|
|
// which loaded environment is the managed Kubernetes sandbox.
|
|
const { forced: forcedKubernetes, kubernetesEnvironment } = useMemo(
|
|
() => resolveForcedKubernetesEnvironment(generalSettings?.executionMode, environments),
|
|
[generalSettings?.executionMode, environments],
|
|
);
|
|
const createSecret = useMutation({
|
|
mutationFn: (input: { name: string; value: string }) => {
|
|
if (!selectedCompanyId) throw new Error("Select an organization to create secrets");
|
|
return secretsApi.create(selectedCompanyId, input);
|
|
},
|
|
onSuccess: () => {
|
|
if (!selectedCompanyId) return;
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
|
|
},
|
|
});
|
|
|
|
const uploadMarkdownImage = useMutation({
|
|
mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => {
|
|
if (!selectedCompanyId) throw new Error("Select an organization to upload images");
|
|
return assetsApi.uploadImage(selectedCompanyId, file, namespace);
|
|
},
|
|
});
|
|
|
|
// ---- Edit mode: overlay for dirty tracking ----
|
|
const [overlay, setOverlay] = useState<AgentConfigOverlay>(emptyOverlay);
|
|
const [environmentDraftDirty, setEnvironmentDraftDirty] = useState(false);
|
|
const [environmentEditorKey, setEnvironmentEditorKey] = useState(0);
|
|
const agentRef = useRef<Agent | null>(null);
|
|
// The overlay snapshot a background account-binding save persisted. The form
|
|
// stays editable while that save is in flight, so the agent refresh that
|
|
// follows it must not wipe edits made during the save. The refresh subtracts
|
|
// only what the save persisted; a user-initiated Save leaves the snapshot
|
|
// null and keeps the full wipe. An UNRELATED refresh can land while the save
|
|
// is still in flight — that refresh does not carry the persisted binding
|
|
// yet, so it must neither consume the snapshot nor subtract it: subtracting
|
|
// would drop the binding entry from the overlay while `props.agent` also
|
|
// lacks it, and an ordinary Save racing the binding refresh would then
|
|
// replace the config without the binding and undo the just-persisted bind.
|
|
// The overlay stays untouched until the save settles; the refresh after
|
|
// settlement consumes the snapshot and subtracts it.
|
|
const backgroundSaveOverlayRef = useRef<AgentConfigOverlay | null>(null);
|
|
const backgroundSaveInFlightRef = useRef(false);
|
|
|
|
// Clear overlay when agent data refreshes (after save)
|
|
useEffect(() => {
|
|
if (!isCreate) {
|
|
if (
|
|
agentRef.current !== null &&
|
|
props.agent !== agentRef.current &&
|
|
!backgroundSaveInFlightRef.current
|
|
) {
|
|
const persisted = backgroundSaveOverlayRef.current;
|
|
backgroundSaveOverlayRef.current = null;
|
|
setOverlay((prev) =>
|
|
persisted ? subtractPersistedOverlay(prev, persisted) : { ...emptyOverlay },
|
|
);
|
|
}
|
|
agentRef.current = props.agent;
|
|
}
|
|
}, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const isDirty = !isCreate && (isOverlayDirty(overlay) || environmentDraftDirty);
|
|
|
|
type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "debug" | "runtime";
|
|
|
|
/** Read effective value: overlay if dirty, else original */
|
|
function eff<T>(group: RecordOverlayGroup, field: string, original: T): T {
|
|
const o = overlay[group];
|
|
if (field in o) return o[field] as T;
|
|
return original;
|
|
}
|
|
|
|
/** Mark field dirty in overlay */
|
|
function mark(group: RecordOverlayGroup, field: string, value: unknown) {
|
|
setOverlay((prev) => ({
|
|
...prev,
|
|
[group]: { ...prev[group], [field]: value },
|
|
}));
|
|
}
|
|
|
|
function flushEnvironmentDraft() {
|
|
return environmentVariablesEditorRef.current?.flushPendingDraft() ?? null;
|
|
}
|
|
|
|
/**
|
|
* Replace the agent's API-access grants (top-level `access.<ALIAS>` keys) with
|
|
* the complete set emitted by the Secret access editor. Added/changed aliases
|
|
* are marked into the overlay; aliases dropped from the set are marked
|
|
* `undefined` so `buildAgentUpdatePatch` strips them.
|
|
*/
|
|
const applyAccessGrants = useCallback((next: Record<string, EnvSecretRefBinding>) => {
|
|
if (isCreate) return;
|
|
setOverlay((prev) => {
|
|
const effective = { ...(props.agent.adapterConfig ?? {}), ...prev.adapterConfig } as Record<string, unknown>;
|
|
const nextAdapterConfig: Record<string, unknown> = { ...prev.adapterConfig };
|
|
for (const [alias, binding] of Object.entries(next)) {
|
|
nextAdapterConfig[`${AGENT_ACCESS_CONFIG_PATH_PREFIX}${alias}`] = binding;
|
|
}
|
|
for (const key of Object.keys(effective)) {
|
|
if (!key.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) continue;
|
|
const alias = key.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length);
|
|
if (!(alias in next)) nextAdapterConfig[key] = undefined;
|
|
}
|
|
return { ...prev, adapterConfig: nextAdapterConfig };
|
|
});
|
|
}, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
/** Build accumulated patch and send to parent */
|
|
const handleCancel = useCallback(() => {
|
|
setOverlay({ ...emptyOverlay });
|
|
setEnvironmentDraftDirty(false);
|
|
setEnvironmentEditorKey(key => key + 1);
|
|
}, []);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
if (isCreate) return;
|
|
const flushedEnv = flushEnvironmentDraft();
|
|
const nextOverlay = flushedEnv
|
|
? {
|
|
...overlay,
|
|
adapterConfig: {
|
|
...overlay.adapterConfig,
|
|
env: flushedEnv,
|
|
},
|
|
}
|
|
: overlay;
|
|
if (!isOverlayDirty(nextOverlay)) return;
|
|
await props.onSave(buildAgentUpdatePatch(props.agent, nextOverlay));
|
|
}, [isCreate, isDirty, overlay, props]);
|
|
|
|
useEffect(() => {
|
|
if (!isCreate) {
|
|
props.onDirtyChange?.(isDirty);
|
|
props.onSaveActionChange?.(handleSave);
|
|
props.onCancelActionChange?.(handleCancel);
|
|
}
|
|
}, [isCreate, isDirty, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange, handleSave, handleCancel]);
|
|
|
|
useEffect(() => {
|
|
if (isCreate) return;
|
|
return () => {
|
|
props.onSaveActionChange?.(null);
|
|
props.onCancelActionChange?.(null);
|
|
props.onDirtyChange?.(false);
|
|
};
|
|
}, [isCreate, props.onDirtyChange, props.onSaveActionChange, props.onCancelActionChange]);
|
|
|
|
// ---- Resolve values ----
|
|
const config = !isCreate ? ((props.agent.adapterConfig ?? {}) as Record<string, unknown>) : {};
|
|
const runtimeConfig = !isCreate ? ((props.agent.runtimeConfig ?? {}) as Record<string, unknown>) : {};
|
|
const heartbeat = !isCreate ? ((runtimeConfig.heartbeat ?? {}) as Record<string, unknown>) : {};
|
|
const debug = !isCreate ? ((runtimeConfig.debug ?? {}) as Record<string, unknown>) : {};
|
|
|
|
const adapterType = isCreate
|
|
? props.values.adapterType
|
|
: overlay.adapterType ?? props.agent.adapterType;
|
|
const getCapabilities = useAdapterCapabilities();
|
|
const adapterCaps = getCapabilities(adapterType);
|
|
const isDotRunner = adapterType === "paperclip_runner" && (isCreate ? props.values.adapterSchemaValues?.provider : eff("adapterConfig", "provider", config.provider)) === "openai_dot";
|
|
const isLocal = !isDotRunner && (adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt);
|
|
|
|
// The legacy working directory is an absolute path on the host, so the
|
|
// managed-sandbox-only policy hides it. A stored value stays untouched; it is
|
|
// inert while every run happens in the platform-managed environment.
|
|
const showLegacyWorkingDirectoryField =
|
|
isLocal
|
|
&& !hideHostPaths
|
|
&& shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
|
|
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
|
const supportedEnvironmentDrivers = useMemo(
|
|
() => new Set(supportedEnvironmentDriversForAdapter(adapterType)),
|
|
[adapterType],
|
|
);
|
|
const val = isCreate ? props.values : null;
|
|
const set = isCreate
|
|
? (patch: Partial<CreateConfigValues>) => props.onChange(patch)
|
|
: null;
|
|
|
|
// Create mode holds the non-secret stored-session claim after a Claude
|
|
// subscription login reaches the server `stored` state. The claim marks the
|
|
// fixed `CLAUDE_CODE_OAUTH_TOKEN` binding as present. The form sends the claim
|
|
// in the create request; the server binds and enforces the token
|
|
// independently. The claim never carries a token value.
|
|
const claudeStoredSessionId = isCreate ? val!.claudeStoredSessionId ?? null : null;
|
|
|
|
// After a login binds CLAUDE_CODE_OAUTH_TOKEN, the user-secret-definitions list
|
|
// in the cache is stale. The form reads that list once at page load, so it does
|
|
// not contain the definition the login just bound. The bound row then compares
|
|
// its key against the stale list and shows a false "no longer exists" health
|
|
// error. Invalidate the list so the row reads the fresh definitions and clears
|
|
// the error.
|
|
const invalidateUserSecretDefinitions = () => {
|
|
if (!selectedCompanyId) return;
|
|
void queryClient.invalidateQueries({
|
|
queryKey: queryKeys.secrets.userDefinitions(selectedCompanyId),
|
|
});
|
|
};
|
|
|
|
// Add the fixed binding to the create-mode form state after the login stores.
|
|
// Keep every unrelated binding. Hold the claim so the create request sends it.
|
|
const handleClaudeLoginStored = (storedSessionId: string) => {
|
|
if (!isCreate || !set) return;
|
|
const existingBindings = (val!.envBindings ?? {}) as Record<string, EnvBinding>;
|
|
set({
|
|
envBindings: { ...existingBindings, ...buildFixedClaudeOAuthBinding() },
|
|
claudeStoredSessionId: storedSessionId,
|
|
});
|
|
invalidateUserSecretDefinitions();
|
|
};
|
|
|
|
// Edit mode: after a login stores the token, add the fixed binding to the
|
|
// existing agent and persist it at once. The server already stored the token;
|
|
// this step binds `CLAUDE_CODE_OAUTH_TOKEN` to the agent's environment through
|
|
// the normal agent-update patch path, so no manual bind step remains. Flush any
|
|
// pending editor draft first, keep every unrelated binding, and mark the merged
|
|
// set into the overlay so the editor and the saved patch agree.
|
|
//
|
|
// An update can never consume a fresh login's stored-session claim -- only the
|
|
// create and hire paths can, per `enforceClaudeOAuthBindingClaim`. So this save
|
|
// must set `applyStoredClaudeLogin`, the same way `handleApplyStoredClaudeLoginEdit`
|
|
// does, or the server rejects the patch with the fixed claim error even though
|
|
// the login already stored the token. Without the flag every fresh login on an
|
|
// existing agent's own page fails to save the binding.
|
|
const handleClaudeLoginStoredEdit = async () => {
|
|
if (isCreate) return;
|
|
const flushedEnv = flushEnvironmentDraft();
|
|
const baseEnv =
|
|
flushedEnv ??
|
|
(eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>));
|
|
const nextEnv = { ...baseEnv, ...buildFixedClaudeOAuthBinding() };
|
|
const nextOverlay: AgentConfigOverlay = {
|
|
...overlay,
|
|
adapterConfig: { ...overlay.adapterConfig, env: nextEnv },
|
|
};
|
|
setOverlay(nextOverlay);
|
|
await props.onSave({
|
|
...buildAgentUpdatePatch(props.agent, nextOverlay),
|
|
applyStoredClaudeLogin: true,
|
|
});
|
|
invalidateUserSecretDefinitions();
|
|
};
|
|
|
|
// Edit mode: a Codex login that signed in to a DIFFERENT account than the
|
|
// company default cannot take effect through the shared company home — the
|
|
// promotion never displaces another account's claim there. Bind this
|
|
// agent's CODEX_HOME to the login's account-home secret and persist at
|
|
// once, the same one-step shape as the Claude stored-login bind above.
|
|
// Same-account logins skip the bind on purpose: the company-home refresh
|
|
// already carried them, and an unbound agent keeps following the company
|
|
// default across later credential rotations. No claim flag is needed —
|
|
// the secret already exists company-scoped, so this is an ordinary
|
|
// secret-reference binding through the normal agent-update patch.
|
|
const handleCodexAccountBindingEdit = async (claim: CodexAccountBindingClaim) => {
|
|
if (isCreate || !claim.companyIdentityDiffers) return;
|
|
const flushedEnv = flushEnvironmentDraft();
|
|
const baseEnv =
|
|
flushedEnv ??
|
|
(eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>));
|
|
const nextEnv: Record<string, EnvBinding> = {
|
|
...baseEnv,
|
|
CODEX_HOME: { type: "secret_ref", secretId: claim.secretId, version: "latest" },
|
|
};
|
|
const nextOverlay: AgentConfigOverlay = {
|
|
...overlay,
|
|
adapterConfig: { ...overlay.adapterConfig, env: nextEnv },
|
|
};
|
|
setOverlay(nextOverlay);
|
|
// This save runs in the background while the form stays editable. Record
|
|
// exactly what it persists so the agent refresh it triggers keeps edits
|
|
// made during the save (see the refresh effect) instead of wiping them
|
|
// with the persisted entries. The in-flight flag protects the snapshot
|
|
// from an unrelated refresh landing mid-save. A failed save never
|
|
// refreshes the agent with the binding, so clear the snapshot there — a
|
|
// later unrelated refresh then wipes normally.
|
|
backgroundSaveOverlayRef.current = nextOverlay;
|
|
backgroundSaveInFlightRef.current = true;
|
|
try {
|
|
await props.onSave(buildAgentUpdatePatch(props.agent, nextOverlay));
|
|
} catch (err) {
|
|
backgroundSaveOverlayRef.current = null;
|
|
throw err;
|
|
} finally {
|
|
backgroundSaveInFlightRef.current = false;
|
|
}
|
|
invalidateUserSecretDefinitions();
|
|
};
|
|
|
|
// Create mode: bind the fixed reference to an existing stored login with no new
|
|
// login round trip. Add the fixed binding and set the apply-existing flag. The
|
|
// create request sends the flag; the server binds the token only for a user
|
|
// actor and only when a stored value exists. Keep every unrelated binding.
|
|
const handleApplyStoredClaudeLogin = () => {
|
|
if (!isCreate || !set) return;
|
|
const existingBindings = (val!.envBindings ?? {}) as Record<string, EnvBinding>;
|
|
set({
|
|
envBindings: { ...existingBindings, ...buildFixedClaudeOAuthBinding() },
|
|
claudeApplyStoredLogin: true,
|
|
});
|
|
invalidateUserSecretDefinitions();
|
|
};
|
|
|
|
// Edit mode: bind the fixed reference to an existing stored login with no new
|
|
// login round trip, then persist it at once. Add the fixed binding to the
|
|
// existing agent and save the patch with the apply-existing flag. Flush any
|
|
// pending editor draft first and keep every unrelated binding.
|
|
const handleApplyStoredClaudeLoginEdit = async () => {
|
|
if (isCreate) return;
|
|
const flushedEnv = flushEnvironmentDraft();
|
|
const baseEnv =
|
|
flushedEnv ??
|
|
(eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>));
|
|
const nextEnv = { ...baseEnv, ...buildFixedClaudeOAuthBinding() };
|
|
const nextOverlay: AgentConfigOverlay = {
|
|
...overlay,
|
|
adapterConfig: { ...overlay.adapterConfig, env: nextEnv },
|
|
};
|
|
setOverlay(nextOverlay);
|
|
await props.onSave({
|
|
...buildAgentUpdatePatch(props.agent, nextOverlay),
|
|
applyStoredClaudeLogin: true,
|
|
});
|
|
invalidateUserSecretDefinitions();
|
|
};
|
|
|
|
const rawCurrentDefaultEnvironmentId = isCreate
|
|
? val!.defaultEnvironmentId ?? ""
|
|
: eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? "");
|
|
const currentDefaultEnvironmentId = useMemo(() => {
|
|
if (!rawCurrentDefaultEnvironmentId) return "";
|
|
const selected = environments.find((environment) => environment.id === rawCurrentDefaultEnvironmentId) ?? null;
|
|
return selected?.driver === "local" ? "" : rawCurrentDefaultEnvironmentId;
|
|
}, [environments, rawCurrentDefaultEnvironmentId]);
|
|
const currentDefaultEnvironment = useMemo(
|
|
() => environments.find((environment) => environment.id === currentDefaultEnvironmentId) ?? null,
|
|
[currentDefaultEnvironmentId, environments],
|
|
);
|
|
const instanceDefaultEnvironmentId = useMemo(() => {
|
|
const environmentId = instanceSettings?.defaultEnvironmentId ?? null;
|
|
if (!environmentId) return "";
|
|
const selected = environments.find((environment) => environment.id === environmentId) ?? null;
|
|
return selected?.driver === "local" ? "" : environmentId;
|
|
}, [environments, instanceSettings?.defaultEnvironmentId]);
|
|
const instanceDefaultEnvironment = useMemo(
|
|
() => environments.find((environment) => environment.id === instanceDefaultEnvironmentId) ?? null,
|
|
[environments, instanceDefaultEnvironmentId],
|
|
);
|
|
|
|
// The environment a login session runs in. It mirrors the Test resolution: the
|
|
// agent's own environment wins, otherwise the instance default, otherwise the
|
|
// local default. The login affordance shows only when this environment is a
|
|
// sandbox, because the canonical auth-missing check comes only from a sandbox
|
|
// target.
|
|
//
|
|
// The resolution passes the same managed-sandbox-only policy inputs as the
|
|
// adapter Test target, so both resolve to the same environment. Under the
|
|
// policy a resolution that lands on the local environment redirects to the
|
|
// managed sandbox the real run uses. Without the redirect the login target
|
|
// stays local while the Test and the real run use the managed sandbox, so the
|
|
// login affordance reads the wrong target. The resolver throws when the policy
|
|
// is on but no managed sandbox is available; a render must not throw, so this
|
|
// resolution catches that case and resolves no login environment. The Test
|
|
// mutation surfaces the same case as a fail-closed error.
|
|
const effectiveLoginEnvironmentId = useMemo(() => {
|
|
try {
|
|
return resolveAdapterTestEnvironmentId({
|
|
agentDefaultEnvironmentId: rawCurrentDefaultEnvironmentId || null,
|
|
instanceDefaultEnvironmentId: instanceSettings?.defaultEnvironmentId ?? null,
|
|
localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(environments),
|
|
managedSandboxOnly: experimentalSettings?.enableManagedSandboxOnly === true,
|
|
managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(environments),
|
|
// The policy hides the local environment, so an agent default that still
|
|
// points at the hidden local row names no visible environment. Pass the
|
|
// visible ids so the resolver redirects that stale local default to the
|
|
// managed sandbox instead of the hidden local id.
|
|
visibleEnvironmentIds: environments.map((environment) => environment.id),
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}, [
|
|
rawCurrentDefaultEnvironmentId,
|
|
instanceSettings?.defaultEnvironmentId,
|
|
environments,
|
|
experimentalSettings?.enableManagedSandboxOnly,
|
|
]);
|
|
const effectiveLoginEnvironment = useMemo(
|
|
() => environments.find((environment) => environment.id === effectiveLoginEnvironmentId) ?? null,
|
|
[environments, effectiveLoginEnvironmentId],
|
|
);
|
|
// Load the sandbox provider capabilities. A login that runs on a real
|
|
// pseudo-terminal needs a provider that advertises the login pseudo-terminal
|
|
// capability. The login panel gate reads this to hide the panel for a provider
|
|
// without the capability. Enable the query when the adapter declares a login
|
|
// capability: every login runs on a real pseudo-terminal, so every login
|
|
// consults this data. The gate is advisory; the server resolves the capability
|
|
// again and fails closed.
|
|
const { data: environmentCapabilities } = useQuery({
|
|
queryKey: selectedCompanyId
|
|
? queryKeys.environments.capabilities(selectedCompanyId)
|
|
: ["environment-capabilities", "none"],
|
|
queryFn: () => environmentsApi.capabilities(selectedCompanyId!),
|
|
enabled: Boolean(selectedCompanyId) && adapterCaps.login != null,
|
|
});
|
|
|
|
// When the instance forces Kubernetes execution, new agents must default to the
|
|
// managed Kubernetes sandbox environment (never the implicit local default).
|
|
// Only applies in create mode and only once the K8s environment is loaded; if
|
|
// none is available the UI surfaces a notice instead of silently selecting it.
|
|
useEffect(() => {
|
|
if (!isCreate || !set || !forcedKubernetes || !kubernetesEnvironment) return;
|
|
if (currentDefaultEnvironmentId === kubernetesEnvironment.id) return;
|
|
set({ defaultEnvironmentId: kubernetesEnvironment.id });
|
|
}, [isCreate, set, forcedKubernetes, kubernetesEnvironment, currentDefaultEnvironmentId]);
|
|
|
|
const runnableEnvironments = useMemo(
|
|
() => environments.filter((environment) => {
|
|
if (!supportedEnvironmentDrivers.has(environment.driver)) return false;
|
|
if (environment.driver === "local") return false;
|
|
if (environment.driver !== "sandbox") return true;
|
|
const provider = typeof environment.config?.provider === "string" ? environment.config.provider : null;
|
|
return provider !== null && provider !== "fake";
|
|
}),
|
|
[environments, supportedEnvironmentDrivers],
|
|
);
|
|
const environmentOptions = useMemo(() => {
|
|
if (!currentDefaultEnvironment) return runnableEnvironments;
|
|
if (runnableEnvironments.some((environment) => environment.id === currentDefaultEnvironment.id)) {
|
|
return runnableEnvironments;
|
|
}
|
|
return [...runnableEnvironments, currentDefaultEnvironment];
|
|
}, [currentDefaultEnvironment, runnableEnvironments]);
|
|
// `runnableEnvironments` excludes the always-available Local environment, so a
|
|
// single entry already means the user has more than one environment configured
|
|
// (Local + that environment) and the override selector is meaningful.
|
|
const showEnvironmentOverrideControl = environmentsEnabled && (
|
|
forcedKubernetes ||
|
|
currentDefaultEnvironmentId.length > 0 ||
|
|
runnableEnvironments.length >= 1
|
|
);
|
|
const inheritedEnvironmentLabel = instanceDefaultEnvironment
|
|
? environmentDisplayLabel(instanceDefaultEnvironment)
|
|
: managedSandboxOnly
|
|
? "Paperclip Computer"
|
|
: "Local";
|
|
|
|
const runnerProvider = adapterType === "paperclip_runner"
|
|
? String(isCreate ? props.values.adapterSchemaValues?.provider ?? "codex"
|
|
: eff("adapterConfig", "provider", config.provider === "acpx" && config.acpxAgent === "codex" ? "codex" : config.provider ?? "codex"))
|
|
: undefined;
|
|
const modelProvider = adapterType === "opencode_local" && aiConnectionBindingSchema.safeParse(
|
|
(overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection,
|
|
).data?.provider === "openrouter" ? "openrouter" : runnerProvider;
|
|
const connectionModels = useConnectionModels(selectedCompanyId, isCreate ? undefined : aiConnectionBindingSchema.safeParse((overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? runtimeConfig.aiConnection).data, aiRoutingHarness(adapterType, runnerProvider, eff("adapterConfig", "acpxAgent", config.acpxAgent)));
|
|
// Fetch adapter models for the effective provider, including unsaved changes.
|
|
const modelQueryKey = selectedCompanyId
|
|
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null, modelProvider)
|
|
: ["agents", "none", "adapter-models", adapterType];
|
|
const {
|
|
data: fetchedModels,
|
|
error: fetchedModelsError,
|
|
isLoading: fetchingModels,
|
|
} = useQuery({
|
|
queryKey: modelQueryKey,
|
|
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, {
|
|
environmentId: currentDefaultEnvironmentId || null,
|
|
provider: modelProvider,
|
|
}),
|
|
enabled: Boolean(selectedCompanyId) && !connectionModels,
|
|
});
|
|
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,
|
|
values: isCreate ? props.values : null,
|
|
set: isCreate ? (patch: Partial<CreateConfigValues>) => props.onChange(patch) : null,
|
|
config,
|
|
eff: eff as <T>(group: "adapterConfig", field: string, original: T) => T,
|
|
// 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 || isDotRunner,
|
|
managedSandboxOnly: hideHostPaths || isDotRunner,
|
|
openAiDotEnabled: experimentalSettings?.enableOpenAiDot === 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} />}
|
|
<uiAdapter.ConfigFields {...adapterFieldProps} section={section} hideModel={isLocal} />
|
|
</>
|
|
);
|
|
// Popover states
|
|
const [modelOpen, setModelOpen] = useState(false);
|
|
const [thinkingEffortOpen, setThinkingEffortOpen] = useState(false);
|
|
|
|
function buildAdapterConfigForTest(adapterConfigPatch?: Record<string, unknown>): Record<string, unknown> {
|
|
if (isCreate) {
|
|
const next = uiAdapter.buildAdapterConfig(val!);
|
|
if (adapterConfigPatch) {
|
|
Object.assign(next, adapterConfigPatch);
|
|
}
|
|
return omitUndefinedEntries(next);
|
|
}
|
|
const base = config as Record<string, unknown>;
|
|
const next = { ...base, ...overlay.adapterConfig };
|
|
if (adapterConfigPatch) {
|
|
Object.assign(next, adapterConfigPatch);
|
|
}
|
|
return omitUndefinedEntries(next);
|
|
}
|
|
|
|
const testEnvironment = useMutation({
|
|
mutationFn: async () => {
|
|
if (!selectedCompanyId) {
|
|
throw new Error("Select an organization to test adapter environment");
|
|
}
|
|
const flushedEnv = flushEnvironmentDraft();
|
|
const adapterConfigPatch = flushedEnv ? { env: flushedEnv } : undefined;
|
|
// Probe where a real run would actually execute: the agent's own
|
|
// environment, else the instance default. Testing the host for an
|
|
// agent that runs in the instance-default sandbox reports failures
|
|
// (e.g. a CLI that only exists in the sandbox image) a real run would
|
|
// never hit. The raw id is sent even for a local environment — the
|
|
// server resolves the driver and probes the host in that case.
|
|
//
|
|
// Test can be clicked before the settings query settles (or after it
|
|
// failed with retry:false), so when the agent relies on the instance
|
|
// default, resolve the settings here rather than trusting the
|
|
// render-time cache. A fetch that still fails FAILS the test with an
|
|
// honest diagnostic — silently probing the host instead would report
|
|
// the exact false command-not-found failure this resolution exists to
|
|
// fix. Agents with their own environment never need the settings.
|
|
let settings = instanceSettings;
|
|
let environmentList = environments;
|
|
let managedSandboxOnly = experimentalSettings?.enableManagedSandboxOnly === true;
|
|
if (!rawCurrentDefaultEnvironmentId) {
|
|
// The agent has no own environment, so the Test resolves the instance
|
|
// default, the local default, or the managed sandbox. Resolve the
|
|
// settings, the environment list, and the managed-sandbox-only policy
|
|
// here, because the render-time queries can be unsettled, or the
|
|
// environments query can be disabled under the managed-sandbox-only
|
|
// policy. A failure surfaces an honest error, not a silent host probe
|
|
// that reports a false result.
|
|
try {
|
|
const [resolvedSettings, resolvedEnvironments, resolvedExperimental] =
|
|
await Promise.all([
|
|
queryClient.ensureQueryData({
|
|
queryKey: queryKeys.instance.settings,
|
|
queryFn: () => instanceSettingsApi.get(),
|
|
}),
|
|
queryClient.ensureQueryData({
|
|
queryKey: queryKeys.environments.list(selectedCompanyId),
|
|
queryFn: () => environmentsApi.list(selectedCompanyId),
|
|
}),
|
|
queryClient.ensureQueryData({
|
|
queryKey: queryKeys.instance.experimentalSettings,
|
|
queryFn: () => instanceSettingsApi.getExperimental(),
|
|
}),
|
|
]);
|
|
settings = resolvedSettings;
|
|
environmentList = resolvedEnvironments;
|
|
managedSandboxOnly = resolvedExperimental?.enableManagedSandboxOnly === true;
|
|
} catch {
|
|
throw new Error(
|
|
"Could not load environment settings to determine which environment to test in. Retry the test.",
|
|
);
|
|
}
|
|
}
|
|
// Mirror the server run-time resolution, including the managed-sandbox-only
|
|
// redirect: when the resolution lands on the local environment and the
|
|
// policy is on, probe the managed sandbox the real run uses instead. The
|
|
// resolver throws when no managed sandbox is available, which the mutation
|
|
// surfaces as a fail-closed error rather than a local host probe.
|
|
const environmentId = resolveAdapterTestEnvironmentId({
|
|
agentDefaultEnvironmentId: rawCurrentDefaultEnvironmentId || null,
|
|
instanceDefaultEnvironmentId: settings?.defaultEnvironmentId ?? null,
|
|
localDefaultEnvironmentId: resolveLocalDefaultEnvironmentId(environmentList),
|
|
managedSandboxOnly,
|
|
managedSandboxEnvironmentId: resolveManagedSandboxEnvironmentId(environmentList),
|
|
// The policy hides the local environment, so an agent default that still
|
|
// points at the hidden local row names no visible environment. Pass the
|
|
// visible ids so the resolver redirects that stale local default to the
|
|
// managed sandbox instead of sending the hidden local id to the server.
|
|
visibleEnvironmentIds: environmentList.map((environment) => environment.id),
|
|
});
|
|
const adapterConfig = buildAdapterConfigForTest(adapterConfigPatch);
|
|
const agentId = isCreate ? undefined : props.agent.id;
|
|
const aiConnection = isCreate ? undefined : aiRuntimeConnectionBindingSchema.safeParse(
|
|
(overlay.runtime.runtimeConfig as Record<string, unknown> | undefined)?.aiConnection ?? props.agent.runtimeConfig.aiConnection,
|
|
).data;
|
|
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 });
|
|
}
|
|
return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, aiConnection, environmentId });
|
|
},
|
|
});
|
|
const [testActionPending, setTestActionPending] = useState(false);
|
|
const [testActionError, setTestActionError] = useState<string | null>(null);
|
|
const testActionLabel = "Test";
|
|
const isSavePending = !isCreate && Boolean(props.isSaving);
|
|
const testEnvironmentDisabled = testActionPending || isSavePending || !selectedCompanyId;
|
|
|
|
// Drop a stale Test result when the adapter type or the effective environment
|
|
// changes. A held result would keep the login affordance visible for a target
|
|
// the user no longer selected. The reset unmounts the login panel too, so its
|
|
// session state clears with it. Hold `reset` in a ref so the effect does not
|
|
// re-run on every render (the mutation object has a new identity each render).
|
|
const resetTestEnvironmentRef = useRef(testEnvironment.reset);
|
|
resetTestEnvironmentRef.current = testEnvironment.reset;
|
|
|
|
// Clear a stale Claude login claim when the adapter type or the effective
|
|
// environment changes. A login binds `CLAUDE_CODE_OAUTH_TOKEN` to one specific
|
|
// environment. After the environment changes, the held claim and the fixed
|
|
// binding no longer match the new target. The create request would still send
|
|
// the claim, and the server would reject it. So drop the claim, the
|
|
// apply-existing flag, and the fixed binding, and return the login panel to
|
|
// its initial state. Only create mode holds this form state; edit mode saves
|
|
// the binding at login time. Hold the clear in a ref so the effect does not
|
|
// re-run on every render (`set` and `val` have a new identity each render).
|
|
const clearClaudeLoginClaimRef = useRef<() => void>(() => {});
|
|
clearClaudeLoginClaimRef.current = () => {
|
|
if (!isCreate || !set) return;
|
|
const bindings = (val!.envBindings ?? {}) as Record<string, EnvBinding>;
|
|
const hasClaim =
|
|
val!.claudeStoredSessionId != null || val!.claudeApplyStoredLogin === true;
|
|
const hasBinding = CLAUDE_OAUTH_TOKEN_ENV_KEY in bindings;
|
|
if (!hasClaim && !hasBinding) return;
|
|
const nextBindings = { ...bindings };
|
|
delete nextBindings[CLAUDE_OAUTH_TOKEN_ENV_KEY];
|
|
set({
|
|
envBindings: nextBindings,
|
|
claudeStoredSessionId: null,
|
|
claudeApplyStoredLogin: false,
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
resetTestEnvironmentRef.current();
|
|
setTestActionError(null);
|
|
clearClaudeLoginClaimRef.current();
|
|
}, [adapterType, effectiveLoginEnvironmentId]);
|
|
|
|
// Show the login affordance only for a current sandbox adapter that declares a
|
|
// login capability, and whose most recent Test result carries the canonical
|
|
// auth-missing check. The form reads the projected capability, not the adapter
|
|
// name. The result keeps its own `adapterType`, so the form reads the
|
|
// capability for that result adapter; a result from an adapter with no login
|
|
// capability never gates the panel.
|
|
const adapterSupportsSandboxLogin = adapterCaps.login != null;
|
|
const testResult = testEnvironment.data;
|
|
const testResultSupportsSandboxLogin =
|
|
testResult != null && getCapabilities(testResult.adapterType).login != null;
|
|
const authMissingCheck =
|
|
testResult && testResultSupportsSandboxLogin
|
|
? testResult.checks.find((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE) ?? null
|
|
: null;
|
|
// A login runs on a real pseudo-terminal, so it needs a provider that
|
|
// advertises the login pseudo-terminal capability. Read the capability for the
|
|
// effective environment provider. The form reads the adapter login capability,
|
|
// not the adapter name: every adapter login gates on this provider capability.
|
|
const effectiveLoginProvider =
|
|
typeof effectiveLoginEnvironment?.config?.provider === "string"
|
|
? effectiveLoginEnvironment.config.provider
|
|
: null;
|
|
const providerSupportsLoginPty =
|
|
effectiveLoginProvider != null &&
|
|
environmentCapabilities?.sandboxProviders?.[effectiveLoginProvider]?.supportsLoginPty === true;
|
|
const loginNeedsPty = adapterCaps.login != null;
|
|
const showAdapterLogin =
|
|
(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, environmentId: effectiveLoginEnvironmentId }
|
|
: null,
|
|
});
|
|
return () => {
|
|
props.onTestFeedbackChange?.({ errorMessage: null, result: null, login: null });
|
|
};
|
|
}, [
|
|
props.onTestFeedbackChange,
|
|
testActionError,
|
|
testEnvironment.data,
|
|
testEnvironment.error,
|
|
showAdapterLogin,
|
|
selectedCompanyId,
|
|
adapterType,
|
|
effectiveLoginEnvironmentId,
|
|
]);
|
|
|
|
// Current model for display
|
|
const currentModelValue = isCreate
|
|
? val!.model ?? ""
|
|
: eff("adapterConfig", "model", String(config.model ?? ""));
|
|
const currentModelId = typeof currentModelValue === "string" ? currentModelValue : "";
|
|
|
|
async function handleRefreshModels() {
|
|
if (!selectedCompanyId) return;
|
|
setRefreshingModels(true);
|
|
setRefreshModelsError(null);
|
|
try {
|
|
const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true, environmentId: currentDefaultEnvironmentId || null, provider: modelProvider });
|
|
queryClient.setQueryData(modelQueryKey, refreshed);
|
|
} catch (error) {
|
|
setRefreshModelsError(error instanceof Error ? error.message : "Failed to refresh adapter models.");
|
|
} finally {
|
|
setRefreshingModels(false);
|
|
}
|
|
}
|
|
|
|
const thinkingEffortKey =
|
|
adapterType === "codex_local"
|
|
? "modelReasoningEffort"
|
|
: adapterType === "cursor"
|
|
? "mode"
|
|
: adapterType === "opencode_local"
|
|
? "variant"
|
|
: adapterType === "grok_local" ? "reasoningEffort"
|
|
: adapterType === "pi_local" ? "thinking" : "effort";
|
|
const thinkingEffortOptions =
|
|
adapterType === "codex_local"
|
|
? codexReasoningEffortOptions(currentModelId, "Auto").map((option) => ({
|
|
id: option.value,
|
|
label: option.label,
|
|
}))
|
|
: adapterType === "cursor"
|
|
? cursorModeOptions
|
|
: adapterType === "opencode_local"
|
|
? openCodeThinkingEffortOptions
|
|
: adapterType === "kimi_local"
|
|
? kimiThinkingEffortOptions
|
|
: adapterType === "pi_local"
|
|
? [{ id: "", label: "Auto" }, ...["off", "minimal", "low", "medium", "high", "xhigh"].map(id => ({ id, label: id }))]
|
|
: adapterType === "claude_local" || adapterType === "grok_local"
|
|
? [{ id: "", label: "Auto" }, ...setupEfforts(adapterType, currentModelId).map((id) => ({
|
|
id,
|
|
label: id === "xhigh" ? "X-High" : id[0].toUpperCase() + id.slice(1),
|
|
}))]
|
|
: claudeThinkingEffortOptions;
|
|
const currentThinkingEffort = isCreate
|
|
? val!.thinkingEffort
|
|
: adapterType === "codex_local"
|
|
? eff(
|
|
"adapterConfig",
|
|
"modelReasoningEffort",
|
|
String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""),
|
|
)
|
|
: adapterType === "cursor"
|
|
? eff("adapterConfig", "mode", String(config.mode ?? ""))
|
|
: adapterType === "opencode_local"
|
|
? eff("adapterConfig", "variant", String(config.variant ?? ""))
|
|
: eff("adapterConfig", thinkingEffortKey, String(config[thinkingEffortKey] ?? ""));
|
|
const showThinkingEffort = adapterType !== "gemini_local"
|
|
&& adapterType !== "cursor_cloud"
|
|
&& adapterType !== "paperclip_runner";
|
|
const codexSearchEnabled = adapterType === "codex_local"
|
|
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
|
: false;
|
|
const effectiveRuntimeConfig = useMemo(() => {
|
|
if (isCreate) {
|
|
return {
|
|
heartbeat: {
|
|
enabled: val!.heartbeatEnabled,
|
|
intervalSec: val!.intervalSec,
|
|
},
|
|
};
|
|
}
|
|
const mergedHeartbeat = {
|
|
...(runtimeConfig.heartbeat && typeof runtimeConfig.heartbeat === "object"
|
|
? runtimeConfig.heartbeat as Record<string, unknown>
|
|
: {}),
|
|
...overlay.heartbeat,
|
|
};
|
|
return {
|
|
...runtimeConfig,
|
|
heartbeat: mergedHeartbeat,
|
|
};
|
|
}, [isCreate, overlay.heartbeat, runtimeConfig, val]);
|
|
const effectiveHeartbeat = asObject(effectiveRuntimeConfig.heartbeat);
|
|
const maxTurnContinuation = asObject(effectiveHeartbeat.maxTurnContinuation);
|
|
const maxTurnContinuationEnabled = asBoolean(maxTurnContinuation.enabled, true);
|
|
const maxTurnContinuationMaxAttempts = clampInteger(
|
|
asFiniteNumber(maxTurnContinuation.maxAttempts, MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS),
|
|
0,
|
|
MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP,
|
|
);
|
|
const maxTurnContinuationDelaySec = clampInteger(
|
|
asFiniteNumber(maxTurnContinuation.delayMs, MAX_TURN_CONTINUATION_DEFAULT_DELAY_SEC * 1000) / 1000,
|
|
0,
|
|
MAX_TURN_CONTINUATION_MAX_DELAY_SEC,
|
|
);
|
|
|
|
function updateMaxTurnContinuation(patch: Record<string, unknown>) {
|
|
mark("heartbeat", "maxTurnContinuation", {
|
|
...maxTurnContinuation,
|
|
...patch,
|
|
});
|
|
}
|
|
|
|
const environmentVariablesEditor = (
|
|
<EnvironmentVariablesEditor
|
|
ref={environmentVariablesEditorRef}
|
|
key={environmentEditorKey}
|
|
onDirtyChange={setEnvironmentDraftDirty}
|
|
hideDraftActions={!isCreate && props.hideInlineSave}
|
|
value={
|
|
isCreate
|
|
? ((val!.envBindings ?? EMPTY_ENV) as Record<string, EnvBinding>)
|
|
: (eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>))
|
|
}
|
|
secrets={availableSecrets}
|
|
userSecretDefinitions={userSecretDefinitions}
|
|
onCreateSecret={async (name, value) => {
|
|
const created = await createSecret.mutateAsync({ name, value });
|
|
return created;
|
|
}}
|
|
onChange={(env) =>
|
|
isCreate
|
|
? set!({ envBindings: env ?? {}, envVars: "" })
|
|
: mark("adapterConfig", "env", env)
|
|
}
|
|
/>
|
|
);
|
|
|
|
|
|
if (!isCreate && props.content === "secrets") {
|
|
return (
|
|
<div className={cn("relative", cards && "space-y-6")}>
|
|
{isDirty && !props.hideInlineSave && (
|
|
<div className="sticky top-0 z-10 flex items-center justify-end border-b border-primary/20 bg-background/90 px-4 py-2 backdrop-blur-sm">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
|
<Button size="sm" onClick={handleSave} disabled={props.isSaving}>
|
|
{props.isSaving ? "Saving..." : "Save"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{props.environmentVariablesPlacement === "secrets" && (
|
|
<div data-config-section="environment-variables" className={cn(!cards && "border-b border-border")}>
|
|
{cards
|
|
? <h3 className="mb-3 text-sm font-medium">Environment variables</h3>
|
|
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment variables</div>
|
|
}
|
|
<div className={cn(cards ? "rounded-lg border border-border p-4" : "px-4 pb-3")}>
|
|
{environmentVariablesEditor}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div data-config-section="secrets" className={cn(!cards && "border-b border-border")}>
|
|
{cards
|
|
? <h3 className="mb-3 text-sm font-medium">Secret access</h3>
|
|
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Secret access</div>
|
|
}
|
|
<div className={cn(cards ? "space-y-3 rounded-lg border border-border p-4" : "space-y-3 px-4 pb-3")}>
|
|
<p className="text-xs text-muted-foreground">{help.secretAccess}</p>
|
|
<AgentSecretAccessEditor
|
|
config={{ ...config, ...overlay.adapterConfig }}
|
|
secrets={availableSecrets}
|
|
onChange={applyAccessGrants}
|
|
onCreateSecret={(name, value) => createSecret.mutateAsync({ name, value })}
|
|
proposals={agentBindingProposals}
|
|
onApproveProposal={proposalReview.requestApprove}
|
|
onRejectProposal={proposalReview.requestReject}
|
|
/>
|
|
{proposalReview.dialogs}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ConfigSections order={props.sectionOrder} className={cn("relative", cards && "space-y-6")}>
|
|
{/* ---- Floating Save button (edit mode, when dirty) ---- */}
|
|
{isDirty && !props.hideInlineSave && (
|
|
<div className="sticky top-0 z-10 flex items-center justify-end px-4 py-2 bg-background/90 backdrop-blur-sm border-b border-primary/20">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSave}
|
|
disabled={!isCreate && props.isSaving}
|
|
>
|
|
{!isCreate && props.isSaving ? "Saving..." : "Save"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ---- Identity (edit only) ---- */}
|
|
{!isCreate && (
|
|
<div data-config-section="identity" className={cn(!cards && "border-b border-border")}>
|
|
{cards
|
|
? <h3 className="text-sm font-medium mb-3">{props.sectionTitles?.["identity"] ?? "Identity"}</h3>
|
|
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Identity</div>
|
|
}
|
|
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
|
<Field label="Name" hint={help.name}>
|
|
<DraftInput
|
|
value={eff("identity", "name", props.agent.name)}
|
|
onCommit={(v) => mark("identity", "name", v)}
|
|
immediate
|
|
className={inputClass}
|
|
placeholder="Agent name"
|
|
/>
|
|
</Field>
|
|
<Field label="Title" hint={help.title}>
|
|
<DraftInput
|
|
value={eff("identity", "title", props.agent.title ?? "")}
|
|
onCommit={(v) => mark("identity", "title", v || null)}
|
|
immediate
|
|
className={inputClass}
|
|
placeholder="e.g. VP of Engineering"
|
|
/>
|
|
</Field>
|
|
<Field label="Reports to" hint={help.reportsTo}>
|
|
<ReportsToPicker
|
|
agents={companyAgents}
|
|
value={eff("identity", "reportsTo", props.agent.reportsTo ?? null)}
|
|
onChange={(id) => mark("identity", "reportsTo", id)}
|
|
excludeAgentIds={[props.agent.id]}
|
|
chooseLabel="Choose manager…"
|
|
/>
|
|
</Field>
|
|
{isLocal && !props.hidePromptTemplate && (
|
|
<>
|
|
<Field label="Prompt Template" hint={help.promptTemplate}>
|
|
<MarkdownEditor
|
|
value={eff(
|
|
"adapterConfig",
|
|
"promptTemplate",
|
|
String(config.promptTemplate ?? ""),
|
|
)}
|
|
onChange={(v) => mark("adapterConfig", "promptTemplate", v ?? "")}
|
|
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
|
contentClassName="min-h-(--sz-88px) text-sm font-mono"
|
|
imageUploadHandler={async (file) => {
|
|
const namespace = `agents/${props.agent.id}/prompt-template`;
|
|
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
|
return asset.contentPath;
|
|
}}
|
|
/>
|
|
</Field>
|
|
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-100">
|
|
Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn.
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ---- Execution ---- */}
|
|
{forcedKubernetes ? (
|
|
// Instance execution policy forces the managed Kubernetes sandbox
|
|
// (executionMode=kubernetes): never offer local / non-Kubernetes targets.
|
|
// Render the environment read-only instead of the selectable picker.
|
|
<div data-config-section="environment" className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
|
|
{cards
|
|
? <h3 className="text-sm font-medium mb-3">Environment</h3>
|
|
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment</div>
|
|
}
|
|
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
|
<Field
|
|
label="Default environment"
|
|
hint="This instance runs all agents in the Kubernetes sandbox. Local execution is disabled."
|
|
>
|
|
{kubernetesEnvironment ? (
|
|
<div className={cn(inputClass, "flex items-center text-muted-foreground")}>
|
|
{kubernetesEnvironment.name} · Kubernetes sandbox
|
|
</div>
|
|
) : (
|
|
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
|
|
This instance requires the Kubernetes sandbox, but no managed Kubernetes
|
|
environment is available for this organization yet. Configure one before creating
|
|
agents; execution will not fall back to local.
|
|
</div>
|
|
)}
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
) : showEnvironmentOverrideControl ? (
|
|
<div data-config-section="environment" className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
|
|
{cards
|
|
? <h3 className="text-sm font-medium mb-3">Environment</h3>
|
|
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment</div>
|
|
}
|
|
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
|
<Field label="Environment override">
|
|
<div className="space-y-2">
|
|
<select
|
|
className={inputClass}
|
|
value={currentDefaultEnvironmentId}
|
|
onChange={(event) => {
|
|
const nextValue = event.target.value;
|
|
if (isCreate) {
|
|
set!({ defaultEnvironmentId: nextValue });
|
|
return;
|
|
}
|
|
mark("identity", "defaultEnvironmentId", nextValue || null);
|
|
}}
|
|
>
|
|
<option value="">Default: {inheritedEnvironmentLabel}</option>
|
|
{environmentOptions.map((environment) => (
|
|
<option key={environment.id} value={environment.id}>
|
|
{environmentDisplayLabel(environment)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* ---- Adapter ---- */}
|
|
<div data-config-section="adapter" className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
|
|
<div className={cn(cards ? "flex items-center justify-between mb-3" : "px-4 py-2 flex items-center justify-between gap-2")}>
|
|
{cards
|
|
? <h3 className="text-sm font-medium">{props.sectionTitles?.["adapter"] ?? "Adapter"}</h3>
|
|
: <span className="text-xs font-medium text-muted-foreground">Adapter</span>
|
|
}
|
|
{showInlineAdapterTestEnvironmentButton && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 px-2.5 text-xs"
|
|
onClick={triggerTestEnvironment}
|
|
disabled={testEnvironmentDisabled}
|
|
>
|
|
{testActionPending ? `${testActionLabel}...` : testActionLabel}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
|
{showAdapterTypeField && (
|
|
<Field label="Adapter type" hint={help.adapterType}>
|
|
<AdapterTypeDropdown
|
|
value={isDotRunner ? "openai_dot" : adapterType}
|
|
disabledTypes={adapterPickerDisabledTypes}
|
|
openAiDotEnabled={experimentalSettings?.enableOpenAiDot === true}
|
|
onChange={(choice) => {
|
|
const dot = choice === "openai_dot";
|
|
const t = dot ? "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 };
|
|
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 (dot) {
|
|
nextValues.model = "";
|
|
nextValues.adapterSchemaValues = { provider: "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,
|
|
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
|
|
: dot ? ""
|
|
: t === "paperclip_runner"
|
|
? resolvePaperclipRunnerTransitionModel(adapterType, config.model)
|
|
: "",
|
|
effort: "",
|
|
modelReasoningEffort: "",
|
|
variant: "",
|
|
mode: "",
|
|
...(t === "codex_local"
|
|
? {
|
|
dangerouslyBypassApprovalsAndSandbox:
|
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
|
}
|
|
: dot
|
|
? { provider: "openai_dot", lifecycleMode: "per_turn", allowUnmeteredProvider: false, dotAttachmentAccess: false, dotWorkspaceAccess: false }
|
|
: t === "paperclip_runner"
|
|
? {
|
|
...paperclipRunnerTransitionConfig(adapterType, eff("adapterConfig", "model", config.model)),
|
|
}
|
|
: {}),
|
|
},
|
|
}));
|
|
}
|
|
}}
|
|
/>
|
|
</Field>
|
|
)}
|
|
|
|
{!isDotRunner && !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 })} />}
|
|
|
|
{showInlineAdapterTestEnvironmentFeedback && !props.compactTestFeedback && (testActionError || testEnvironment.error) && (
|
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
|
{testActionError
|
|
?? (testEnvironment.error instanceof Error
|
|
? testEnvironment.error.message
|
|
: "Environment test failed")}
|
|
</div>
|
|
)}
|
|
|
|
{showInlineAdapterTestEnvironmentFeedback && !props.compactTestFeedback && testEnvironment.data && (
|
|
<AdapterEnvironmentResult result={testEnvironment.data} />
|
|
)}
|
|
|
|
{showInlineAdapterTestEnvironmentFeedback && showAdapterLogin && (
|
|
<AdapterLoginPanel
|
|
key={`${adapterType}:${effectiveLoginEnvironmentId}`}
|
|
companyId={selectedCompanyId!}
|
|
adapterType={adapterType}
|
|
environmentId={effectiveLoginEnvironmentId!}
|
|
onStored={isCreate ? handleClaudeLoginStored : handleClaudeLoginStoredEdit}
|
|
onApplyStored={
|
|
isCreate ? handleApplyStoredClaudeLogin : handleApplyStoredClaudeLoginEdit
|
|
}
|
|
onAccountBinding={isCreate ? undefined : handleCodexAccountBindingEdit}
|
|
/>
|
|
)}
|
|
|
|
{/* Working directory */}
|
|
{showLegacyWorkingDirectoryField && (
|
|
<Field label="Working directory (deprecated)" hint={help.cwd}>
|
|
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
|
|
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
<DraftInput
|
|
value={
|
|
isCreate
|
|
? val!.cwd
|
|
: eff("adapterConfig", "cwd", String(config.cwd ?? ""))
|
|
}
|
|
onCommit={(v) =>
|
|
isCreate
|
|
? set!({ cwd: v })
|
|
: mark("adapterConfig", "cwd", v || undefined)
|
|
}
|
|
immediate
|
|
className="w-full bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"
|
|
placeholder="/path/to/project"
|
|
/>
|
|
<ChoosePathButton />
|
|
</div>
|
|
</Field>
|
|
)}
|
|
|
|
{renderAdapterFields("adapter")}
|
|
{isLocal && (<>
|
|
<ModelDropdown
|
|
models={models}
|
|
loadingModels={connectionModels?.isLoading ?? fetchingModels}
|
|
value={currentModelId}
|
|
onChange={(v) => {
|
|
const supportedEfforts = setupEfforts(adapterType, v);
|
|
const clearUnsupportedEffort = ["codex_local", "claude_local", "grok_local"].includes(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) {
|
|
mark("adapterConfig", thinkingEffortKey, undefined);
|
|
mark("adapterConfig", "reasoningEffort", undefined);
|
|
}
|
|
}}
|
|
open={modelOpen}
|
|
onOpenChange={setModelOpen}
|
|
defaultLabel={adapterType === "claude_local" ? `Default (${DEFAULT_CLAUDE_LOCAL_MODEL})` : undefined}
|
|
allowDefault={adapterType !== "opencode_local" && adapterType !== "pi_local" && adapterType !== "paperclip_runner"}
|
|
required={adapterType === "opencode_local" || adapterType === "pi_local"}
|
|
groupByProvider={!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)
|
|
}
|
|
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's prompt template or instructions file instead.
|
|
</div>
|
|
</>
|
|
)}
|
|
{renderAdapterFields("configuration")}
|
|
{(isLocal || adapterType === "process" || configSchema?.fields.some((field) => schemaFieldSection(field.key) === "advanced")) && (
|
|
<CollapsibleSection
|
|
title="Advanced"
|
|
open={configurationAdvancedOpen}
|
|
onToggle={() => setConfigurationAdvancedOpen(!configurationAdvancedOpen)}
|
|
>
|
|
<div className="space-y-3">
|
|
{isLocal && (<> {/*
|
|
The command names a binary on the execution host, so the
|
|
managed-sandbox-only policy hides it: the platform-managed image
|
|
owns the binary. Hiding is presentation only. A stored
|
|
`adapterConfig.command` stays as it is and the server does not
|
|
reject one, because an import carries adapter configuration
|
|
written on another instance; rejecting it would break that flow.
|
|
The value is inert while the policy is on. The field also stays
|
|
hidden until the policy is known, so a stored command never
|
|
flashes on a managed instance.
|
|
*/}
|
|
{!hideHostPaths && (
|
|
<Field label="Command" hint={help.localCommand}>
|
|
<DraftInput
|
|
value={
|
|
isCreate
|
|
? val!.command
|
|
: eff(
|
|
"adapterConfig",
|
|
adapterCommandField,
|
|
String(
|
|
config.command ?? "",
|
|
),
|
|
)
|
|
}
|
|
onCommit={(v) =>
|
|
isCreate
|
|
? set!({ command: v })
|
|
: mark("adapterConfig", adapterCommandField, v || null)
|
|
}
|
|
immediate
|
|
className={inputClass}
|
|
placeholder={
|
|
({
|
|
claude_local: "claude",
|
|
codex_local: "codex",
|
|
gemini_local: "gemini",
|
|
kimi_local: "kimi",
|
|
pi_local: "pi",
|
|
cursor: "agent",
|
|
opencode_local: "opencode",
|
|
} as Record<string, string>)[adapterType] ?? adapterType.replace(/_local$/, "")
|
|
}
|
|
/>
|
|
</Field>
|
|
)}
|
|
|
|
<Field label="Extra args (comma-separated)" hint={help.extraArgs}>
|
|
<DraftInput
|
|
value={
|
|
isCreate
|
|
? val!.extraArgs
|
|
: eff("adapterConfig", "extraArgs", formatArgList(config.extraArgs))
|
|
}
|
|
onCommit={(v) =>
|
|
isCreate
|
|
? set!({ extraArgs: v })
|
|
: mark("adapterConfig", "extraArgs", v?.trim() ? parseCommaArgs(v) : null)
|
|
}
|
|
className={inputClass}
|
|
placeholder="e.g. --verbose, --foo=bar"
|
|
/>
|
|
</Field>
|
|
|
|
</>)}
|
|
{renderAdapterFields("advanced")}
|
|
</div>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{props.environmentVariablesPlacement !== "secrets" && (isLocal || configSchema?.fields.some((field) => schemaFieldSection(field.key) === "environment")) && (
|
|
<div data-config-section="environment-variables" className={cn(!cards && "border-b border-border")}>
|
|
{cards
|
|
? <h3 className="text-sm font-medium mb-3">Environment variables</h3>
|
|
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Environment variables</div>
|
|
}
|
|
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
|
{isLocal ? environmentVariablesEditor : renderAdapterFields("environment")}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ---- Run Policy ---- */}
|
|
{isCreate && showCreateRunPolicySection ? (
|
|
<div data-config-section="run-policy" className={cn(!cards && "border-b border-border")}>
|
|
{cards
|
|
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
|
|
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2"><Heart className="h-3 w-3" /> Run Policy</div>
|
|
}
|
|
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
|
<ToggleWithNumber
|
|
label="Heartbeat on interval"
|
|
hint={help.heartbeatInterval}
|
|
checked={val!.heartbeatEnabled}
|
|
onCheckedChange={(v) => set!({ heartbeatEnabled: v })}
|
|
number={val!.intervalSec}
|
|
onNumberChange={(v) => set!({ intervalSec: v })}
|
|
numberLabel="sec"
|
|
numberPrefix="Run heartbeat every"
|
|
numberHint={help.intervalSec}
|
|
showNumber={val!.heartbeatEnabled}
|
|
/>
|
|
<CollapsibleSection title="Advanced Run Policy" open={runPolicyAdvancedOpen} onToggle={() => setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)}>
|
|
<div className="space-y-3">{renderAdapterFields("runPolicy")}</div>
|
|
</CollapsibleSection>
|
|
</div>
|
|
</div>
|
|
) : !isCreate ? (
|
|
<div data-config-section="run-policy" className={cn(!cards && "border-b border-border")}>
|
|
{cards
|
|
? <h3 className="text-sm font-medium flex items-center gap-2 mb-3"><Heart className="h-3 w-3" /> Run Policy</h3>
|
|
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2"><Heart className="h-3 w-3" /> Run Policy</div>
|
|
}
|
|
<div className={cn(cards ? "border border-border rounded-lg overflow-hidden" : "")}>
|
|
<div className={cn(cards ? "p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
|
|
<ToggleWithNumber
|
|
label="Heartbeat on interval"
|
|
hint={help.heartbeatInterval}
|
|
checked={eff("heartbeat", "enabled", heartbeat.enabled === true)}
|
|
onCheckedChange={(v) => mark("heartbeat", "enabled", v)}
|
|
number={eff("heartbeat", "intervalSec", Number(heartbeat.intervalSec ?? 300))}
|
|
onNumberChange={(v) => mark("heartbeat", "intervalSec", v)}
|
|
numberLabel="sec"
|
|
numberPrefix="Run heartbeat every"
|
|
numberHint={help.intervalSec}
|
|
showNumber={eff("heartbeat", "enabled", heartbeat.enabled === true)}
|
|
/>
|
|
</div>
|
|
<CollapsibleSection
|
|
title="Advanced Run Policy"
|
|
bordered={cards}
|
|
open={runPolicyAdvancedOpen}
|
|
onToggle={() => setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)}
|
|
>
|
|
<div className="space-y-3">
|
|
{renderAdapterFields("runPolicy")}
|
|
{isLocal && (<>
|
|
{/* Edit-only: timeout + grace period */}
|
|
{!isCreate && (
|
|
<>
|
|
{!configSchema?.fields.some((field) => field.key === "timeoutSec") && (
|
|
<Field label="Timeout (sec)" hint={help.timeoutSec}>
|
|
<DraftNumberInput
|
|
value={eff(
|
|
"adapterConfig",
|
|
"timeoutSec",
|
|
Number(config.timeoutSec ?? 0),
|
|
)}
|
|
onCommit={(v) => mark("adapterConfig", "timeoutSec", v)}
|
|
immediate
|
|
className={inputClass}
|
|
/>
|
|
</Field>
|
|
)}
|
|
{!configSchema?.fields.some((field) => field.key === "graceSec") && (
|
|
<Field label="Interrupt grace period (sec)" hint={help.graceSec}>
|
|
<DraftNumberInput
|
|
value={eff(
|
|
"adapterConfig",
|
|
"graceSec",
|
|
Number(config.graceSec ?? 15),
|
|
)}
|
|
onCommit={(v) => mark("adapterConfig", "graceSec", v)}
|
|
immediate
|
|
className={inputClass}
|
|
/>
|
|
</Field>
|
|
)}
|
|
</>
|
|
)}
|
|
</>)}
|
|
<ToggleField
|
|
label="Wake on demand"
|
|
hint={help.wakeOnDemand}
|
|
checked={eff(
|
|
"heartbeat",
|
|
"wakeOnDemand",
|
|
heartbeat.wakeOnDemand !== false,
|
|
)}
|
|
onChange={(v) => mark("heartbeat", "wakeOnDemand", v)}
|
|
/>
|
|
<Field label="Cooldown (sec)" hint={help.cooldownSec}>
|
|
<DraftNumberInput
|
|
value={eff(
|
|
"heartbeat",
|
|
"cooldownSec",
|
|
Number(heartbeat.cooldownSec ?? 10),
|
|
)}
|
|
onCommit={(v) => mark("heartbeat", "cooldownSec", v)}
|
|
immediate
|
|
className={inputClass}
|
|
/>
|
|
</Field>
|
|
<Field label="Max concurrent runs" hint={help.maxConcurrentRuns}>
|
|
<DraftNumberInput
|
|
value={eff(
|
|
"heartbeat",
|
|
"maxConcurrentRuns",
|
|
Number(heartbeat.maxConcurrentRuns ?? AGENT_DEFAULT_MAX_CONCURRENT_RUNS),
|
|
)}
|
|
onCommit={(v) => mark("heartbeat", "maxConcurrentRuns", v)}
|
|
immediate
|
|
className={inputClass}
|
|
/>
|
|
</Field>
|
|
<div className="rounded-md border border-border/70 px-3 py-2">
|
|
<ToggleField
|
|
label="Continue after max-turn stop"
|
|
hint={help.maxTurnContinuationEnabled}
|
|
checked={maxTurnContinuationEnabled}
|
|
onChange={(v) => updateMaxTurnContinuation({ enabled: v })}
|
|
/>
|
|
{maxTurnContinuationEnabled ? (
|
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
|
<Field label="Continuation attempts" hint={help.maxTurnContinuationMaxAttempts}>
|
|
<DraftNumberInput
|
|
value={maxTurnContinuationMaxAttempts}
|
|
onCommit={(v) =>
|
|
updateMaxTurnContinuation({
|
|
maxAttempts: clampInteger(v, 0, MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP),
|
|
})}
|
|
immediate
|
|
className={inputClass}
|
|
/>
|
|
</Field>
|
|
<Field label="Continuation delay (sec)" hint={help.maxTurnContinuationDelaySec}>
|
|
<DraftNumberInput
|
|
value={maxTurnContinuationDelaySec}
|
|
onCommit={(v) =>
|
|
updateMaxTurnContinuation({
|
|
delayMs: clampDelayMsFromSeconds(v),
|
|
})}
|
|
immediate
|
|
className={inputClass}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</CollapsibleSection>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{/* ---- Debugging ---- */}
|
|
{!isCreate && canConfigureProviderTrace ? (
|
|
<div className={cn(!cards && "border-b border-border")}>
|
|
{cards ? (
|
|
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium">
|
|
<Bug className="h-3 w-3" /> Debugging
|
|
</h3>
|
|
) : (
|
|
<div className="flex items-center gap-2 px-4 py-2 text-xs font-medium text-muted-foreground">
|
|
<Bug className="h-3 w-3" /> Debugging
|
|
</div>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
"border-border bg-accent/30",
|
|
cards
|
|
? "rounded-lg border p-4"
|
|
: "mx-4 mb-4 rounded-md border px-3 py-3",
|
|
)}
|
|
>
|
|
<ToggleField
|
|
label="Capture raw provider traces"
|
|
hint="Stores exact provider traffic for every future run until disabled. Traces may contain sensitive prompts and tool arguments, are administrator-only, and expire after 24 hours."
|
|
checked={eff<unknown>("debug", "providerTrace", debug.providerTrace) === "raw"}
|
|
onChange={(enabled) =>
|
|
mark("debug", "providerTrace", enabled ? "raw" : undefined)
|
|
}
|
|
/>
|
|
{eff<unknown>("debug", "providerTrace", debug.providerTrace) === "raw" ? (
|
|
<div className="mt-3 flex items-start gap-2 rounded-md border border-border bg-background/60 px-3 py-2 text-xs text-foreground">
|
|
<Bug className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
<span>
|
|
Raw tracing is on for future runs. Paperclip keeps at most 64 MiB per run and automatically deletes it after 24 hours.
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{props.compactTestFeedback && showInlineAdapterTestEnvironmentFeedback && showAdapterTestEnvironmentButton && (
|
|
<RuntimeTestCard
|
|
variant={isDotRunner ? "prerequisites" : "connection"}
|
|
state={testActionPending ? "running" : testActionError || testEnvironment.error ? "fail" : testResult?.status ?? "idle"}
|
|
result={testResult ?? null}
|
|
error={testActionError ?? (testEnvironment.error instanceof Error ? testEnvironment.error.message : null)}
|
|
onTest={triggerTestEnvironment}
|
|
disabled={testEnvironmentDisabled}
|
|
/>
|
|
)}
|
|
</ConfigSections>
|
|
);
|
|
}
|
|
|
|
// The public session states that end a login. The panel stops the status poll
|
|
// and shows a terminal message when the session reaches one of these.
|
|
const ADAPTER_LOGIN_TERMINAL_STATUSES = new Set<AdapterAuthSessionStatus>([
|
|
"authenticated",
|
|
"failed",
|
|
"timed_out",
|
|
"cancelled",
|
|
]);
|
|
|
|
// The status route poll interval while a session is active (Decision A). The
|
|
// poll stops at a terminal state.
|
|
const ADAPTER_LOGIN_POLL_INTERVAL_MS = 2000;
|
|
|
|
// A copy-to-clipboard button. It mirrors the workspace service control bar: a
|
|
// short "copied" flash, then it returns to the copy icon.
|
|
function AdapterLoginCopyButton({ value, label }: { value: string; label: string }) {
|
|
const [copied, setCopied] = useState(false);
|
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
useEffect(
|
|
() => () => {
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
},
|
|
[],
|
|
);
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
aria-label={label}
|
|
title={label}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
onClick={async () => {
|
|
try {
|
|
await copyTextToClipboard(value);
|
|
setCopied(true);
|
|
} catch {
|
|
setCopied(false);
|
|
}
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
timeoutRef.current = setTimeout(() => setCopied(false), 1500);
|
|
}}
|
|
>
|
|
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
// The terminal message for a finished login. It never shows a secret. It shows
|
|
// only the fixed, non-secret failure message the server returns.
|
|
function AdapterLoginTerminalState({
|
|
status,
|
|
message,
|
|
}: {
|
|
status: AdapterAuthSessionStatus;
|
|
message: string | null;
|
|
}) {
|
|
if (status === "authenticated") {
|
|
return (
|
|
<div className="flex items-center gap-2 text-(length:--text-micro) text-foreground">
|
|
<Check className="size-3 shrink-0" />
|
|
<span>Authenticated. The environment has credentials now.</span>
|
|
</div>
|
|
);
|
|
}
|
|
const label =
|
|
status === "timed_out"
|
|
? "Login timed out"
|
|
: status === "cancelled"
|
|
? "Login cancelled"
|
|
: "Login failed";
|
|
return (
|
|
<div className="flex items-start gap-2 text-(length:--text-micro) text-destructive">
|
|
<TriangleAlert className="size-3 shrink-0" />
|
|
<span>
|
|
{label}
|
|
{message ? `: ${message}` : "."}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// The login panel for one adapter in one sandbox environment. It starts a login
|
|
// session, polls the status route, and shows the one-time code and the
|
|
// authentication URL with copy and open actions. It shows the terminal states.
|
|
// It never writes the code, the URL, or any credential byte to a log line.
|
|
//
|
|
// The panel holds its own session state. The parent gives it a stable `key` from
|
|
// the adapter type and the environment id, so a change to either remounts the
|
|
// panel with a fresh session state.
|
|
// The props that identify one login panel: one adapter in one sandbox
|
|
// environment for one company. A parent that lifts the test feedback renders
|
|
// the panel from this descriptor.
|
|
export type AdapterLoginDescriptor = {
|
|
companyId: string;
|
|
adapterType: string;
|
|
environmentId: string;
|
|
};
|
|
|
|
// The panel props. `onStored` reports the non-secret `storedSessionId` claim from
|
|
// a Claude login that reaches the server `stored` state. The create-mode form
|
|
// holds that claim for the fixed binding; the callback never carries a token.
|
|
// `onApplyStored` binds the fixed reference to an existing stored login with no
|
|
// new login round trip. The panel shows the apply-existing affordance only when
|
|
// the status route reports a stored value.
|
|
// `autoStart`, `onCancel`, `onConnected` and `chrome` are what the onboarding
|
|
// connect step needs, and each is off or absent by default so the two settings
|
|
// surfaces that render this panel keep the behaviour they have.
|
|
//
|
|
// They are props on the existing panels rather than a second implementation
|
|
// because the part onboarding needs unchanged is the whole of it: the session
|
|
// start, the two polls, the server deadline, the one-shot completion read. A
|
|
// copy drawn to the new design would have had to reproduce all of that
|
|
// correctly, and the first thing to rot would have been the timeout and
|
|
// cleanup paths, which are the ones nobody exercises by hand.
|
|
export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
|
|
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,
|
|
}: {
|
|
value: string;
|
|
onChange: (type: string) => void;
|
|
disabledTypes: Set<string>;
|
|
openAiDotEnabled?: boolean;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const selectedDisplay = getAdapterDisplay(value);
|
|
const adapterList = useMemo(
|
|
() =>
|
|
[...listAdapterOptions((type) => adapterLabels[type] ?? getAdapterLabel(type)),
|
|
...(openAiDotEnabled ? [{ value: "openai_dot", label: "OpenAI Dot", experimental: true, comingSoon: false }] : []),
|
|
].filter(
|
|
(item) => !disabledTypes.has(item.value),
|
|
),
|
|
[disabledTypes, openAiDotEnabled],
|
|
);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
data-size="default"
|
|
className={cn(selectTriggerClassName, "w-full")}
|
|
>
|
|
<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>
|
|
<ChevronDown className="size-4 opacity-50" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-(--radix-popover-trigger-width) p-1" align="start">
|
|
{adapterList.map((item) => (
|
|
<button
|
|
key={item.value}
|
|
type="button"
|
|
disabled={item.comingSoon}
|
|
className={cn(
|
|
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded",
|
|
item.comingSoon
|
|
? "opacity-40 cursor-not-allowed"
|
|
: "hover:bg-accent/50",
|
|
item.value === value && !item.comingSoon && "bg-accent",
|
|
)}
|
|
onClick={() => {
|
|
if (!item.comingSoon) {
|
|
onChange(item.value);
|
|
setOpen(false);
|
|
}
|
|
}}
|
|
>
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<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>
|
|
{item.comingSoon && (
|
|
<span className="text-(length:--text-nano) text-muted-foreground">Coming soon</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function ExperimentalBadge() {
|
|
return (
|
|
<span className="shrink-0 rounded border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-(length:--text-nano) font-medium leading-none text-amber-700 dark:text-amber-200">
|
|
Experimental
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function ModelDropdown({
|
|
models,
|
|
value,
|
|
onChange,
|
|
open,
|
|
onOpenChange,
|
|
allowDefault,
|
|
required,
|
|
groupByProvider,
|
|
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}>
|
|
<Popover
|
|
open={open}
|
|
onOpenChange={(nextOpen) => {
|
|
onOpenChange(nextOpen);
|
|
if (!nextOpen) setModelSearch("");
|
|
}}
|
|
>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
type="button"
|
|
data-size="default"
|
|
className={cn(selectTriggerClassName, "w-full")}
|
|
>
|
|
<span className={cn("truncate", !value && "text-muted-foreground")}>
|
|
{selected
|
|
? selected.label
|
|
: value
|
|
|| (allowDefault ? (defaultLabel ?? "Default") : required ? "Select model (required)" : "Select model")}
|
|
</span>
|
|
<ChevronDown className="size-4 opacity-50" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-(--radix-popover-trigger-width) p-1" align="start">
|
|
<div className="relative mb-1">
|
|
<input
|
|
className="w-full px-2 py-1.5 pr-6 text-xs bg-transparent outline-none border-b border-border placeholder:text-muted-foreground/50"
|
|
placeholder={creatable ? "Search models... (type to create)" : "Search models..."}
|
|
value={modelSearch}
|
|
onChange={(e) => setModelSearch(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
{modelSearch && (
|
|
<button
|
|
type="button"
|
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
onClick={() => setModelSearch("")}
|
|
>
|
|
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
{onDetectModel && !modelSearch.trim() && (
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
|
|
onClick={() => {
|
|
void handleDetectModel();
|
|
}}
|
|
disabled={detectingModel}
|
|
>
|
|
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
|
<path d="M3 3v5h5" />
|
|
</svg>
|
|
{detectingModel ? "Detecting..." : detectedModel ? (detectModelLabel?.replace(/^Detect\b/, "Re-detect") ?? "Re-detect from config") : (detectModelLabel ?? "Detect from config")}
|
|
</button>
|
|
)}
|
|
{onRefreshModels && !modelSearch.trim() && (
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
|
|
onClick={() => {
|
|
void onRefreshModels();
|
|
}}
|
|
disabled={refreshingModels}
|
|
>
|
|
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M3 12a9 9 0 0 1 15.28-6.36L21 8" />
|
|
<path d="M21 3v5h-5" />
|
|
<path d="M21 12a9 9 0 0 1-15.28 6.36L3 16" />
|
|
<path d="M8 16H3v5" />
|
|
</svg>
|
|
{refreshingModels ? "Refreshing..." : "Refresh models"}
|
|
</button>
|
|
)}
|
|
{value && (!models.some((m) => m.id === value) || promotedModelIds.has(value)) && (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex items-center w-full px-2 py-1.5 text-sm rounded bg-accent/50",
|
|
)}
|
|
onClick={() => {
|
|
onOpenChange(false);
|
|
}}
|
|
>
|
|
<span className="block w-full text-left truncate font-mono text-xs" title={value}>
|
|
{models.find((m) => m.id === value)?.label ?? value}
|
|
</span>
|
|
<Badge variant="outline" className="ml-auto text-(length:--text-nano) px-1.5 bg-green-500/15 text-green-400 border-green-500/20">
|
|
current
|
|
</Badge>
|
|
</button>
|
|
)}
|
|
{detectedModel && detectedModel !== value && (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
|
)}
|
|
onClick={() => {
|
|
onChange(detectedModel);
|
|
onOpenChange(false);
|
|
}}
|
|
>
|
|
<span className="block w-full text-left truncate font-mono text-xs" title={detectedModel}>
|
|
{models.find((m) => m.id === detectedModel)?.label ?? detectedModel}
|
|
</span>
|
|
<Badge variant="outline" className="ml-auto text-(length:--text-nano) px-1.5 bg-blue-500/15 text-blue-400 border-blue-500/20">
|
|
detected
|
|
</Badge>
|
|
</button>
|
|
)}
|
|
{detectedModelCandidates
|
|
?.filter((candidate) => candidate && candidate !== detectedModel && candidate !== value)
|
|
.map((candidate) => {
|
|
const entry = models.find((m) => m.id === candidate);
|
|
return (
|
|
<button
|
|
key={`detected-${candidate}`}
|
|
type="button"
|
|
className={cn(
|
|
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
|
)}
|
|
onClick={() => {
|
|
onChange(candidate);
|
|
onOpenChange(false);
|
|
}}
|
|
>
|
|
<span className="block w-full text-left truncate font-mono text-xs" title={candidate}>
|
|
{entry?.label ?? candidate}
|
|
</span>
|
|
<Badge variant="outline" className="ml-auto text-(length:--text-nano) px-1.5 bg-sky-500/15 text-sky-400 border-sky-500/20">
|
|
config
|
|
</Badge>
|
|
</button>
|
|
);
|
|
})}
|
|
<div className="max-h-(--sz-240px) overflow-y-auto">
|
|
{allowDefault && (
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
|
!value && "bg-accent",
|
|
)}
|
|
onClick={() => {
|
|
onChange("");
|
|
onOpenChange(false);
|
|
}}
|
|
>
|
|
Default
|
|
</button>
|
|
)}
|
|
{canCreateManualModel && (
|
|
<button
|
|
type="button"
|
|
className="flex items-center justify-between gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50"
|
|
onClick={() => {
|
|
onChange(manualModel);
|
|
onOpenChange(false);
|
|
setModelSearch("");
|
|
}}
|
|
>
|
|
<span>Use manual model</span>
|
|
<span className="text-xs font-mono text-muted-foreground">{manualModel}</span>
|
|
</button>
|
|
)}
|
|
{groupedModels.map((group) => (
|
|
<div key={group.provider} className="mb-1 last:mb-0">
|
|
{groupByProvider && (
|
|
<div className="px-2 py-1 text-(length:--text-nano) uppercase tracking-wide text-muted-foreground">
|
|
{group.provider} ({group.entries.length})
|
|
</div>
|
|
)}
|
|
{group.entries.map((m) => (
|
|
<button
|
|
type="button"
|
|
key={m.id}
|
|
className={cn(
|
|
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
|
m.id === value && "bg-accent",
|
|
)}
|
|
onClick={() => {
|
|
onChange(m.id);
|
|
onOpenChange(false);
|
|
}}
|
|
>
|
|
<span className="block w-full text-left truncate" title={m.id}>
|
|
{groupByProvider ? extractModelName(m.id) : m.label}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
))}
|
|
{filteredModels.length === 0 && !canCreateManualModel && promotedModelIds.size === 0 && (
|
|
<div className="px-2 py-2 space-y-2">
|
|
<p className="text-xs text-muted-foreground">
|
|
{loadingModels ? "Loading models…" : onDetectModel
|
|
? (emptyDetectHint ?? "No model detected yet. Enter a provider/model manually.")
|
|
: "No models found."}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</Field>
|
|
);
|
|
}
|
|
|
|
function ThinkingEffortDropdown({
|
|
value,
|
|
options,
|
|
onChange,
|
|
open,
|
|
onOpenChange,
|
|
}: {
|
|
value: string;
|
|
options: ReadonlyArray<{ id: string; label: string }>;
|
|
onChange: (id: string) => void;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
const selected = options.find((option) => option.id === value) ?? options[0];
|
|
|
|
return (
|
|
<Field label="Thinking effort" hint={help.thinkingEffort}>
|
|
<Popover open={open} onOpenChange={onOpenChange}>
|
|
<PopoverTrigger asChild>
|
|
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
|
|
<span className={cn(!value && "text-muted-foreground")}>{selected?.label ?? "Auto"}</span>
|
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-(--radix-popover-trigger-width) p-1" align="start">
|
|
{options.map((option) => (
|
|
<button
|
|
key={option.id || "auto"}
|
|
className={cn(
|
|
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
|
|
option.id === value && "bg-accent",
|
|
)}
|
|
onClick={() => {
|
|
onChange(option.id);
|
|
onOpenChange(false);
|
|
}}
|
|
>
|
|
<span>{option.label}</span>
|
|
{option.id ? <span className="text-xs text-muted-foreground font-mono">{option.id}</span> : null}
|
|
</button>
|
|
))}
|
|
</PopoverContent>
|
|
</Popover>
|
|
</Field>
|
|
);
|
|
}
|