Files
PaperClipAI/ui/src/components/AgentConfigForm.tsx
T
ad961227f5 feat(secrets): add user-specific runtime secrets (#8825)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent runs often need provider credentials, API tokens, and other
environment-bound secrets.
> - Company-level secrets work for shared credentials, but they do not
model values that should differ by human operator.
> - Without a user-scoped model, a run can dispatch without knowing
whether the responsible human has supplied the needed value.
> - Paperclip also needs run attribution to make those user-scoped
runtime checks deterministic and auditable.
> - This pull request adds user-specific secret definitions, per-user
values, environment bindings, responsible-user attribution, and runtime
resolution gates.
> - The benefit is that teams can define the secret once, let each user
provide their own value, and block runs before dispatch when required
user secrets or active definitions are unavailable.

## Linked Issues or Issue Description

Refs #224
Refs #6057

This PR implements user-specific secret support as a core
secret-management capability rather than a one-off adapter setting. It
is related to existing public work on company secrets UI and runtime
secret refs, but is distinct because the value is owned by the
responsible user and resolved at run dispatch time.

Related PR search before opening found existing secrets work such as
#1550, #8256, #8614, #8634, and #8647; none of those add the full
user-secret definition/value/runtime gate covered here.

## What Changed

- Added user-secret definitions and per-user "My secrets" values,
keeping stored values out of access metadata.
- Added `user_secret_ref` environment bindings and UI affordances to
pick them alongside existing secret refs.
- Added responsible-user runtime resolution so user-secret refs resolve
against the human responsible for the run.
- Added pre-dispatch missing-secret gates so runs fail before adapter
dispatch when required user values are absent or definitions are
inactive.
- Added low-trust allowlist hardening for user-secret runtime access.
- Added issue, routine, run, and agent API key responsible-user
attribution and fail-closed dispatch behavior when attribution cannot be
resolved.
- Added denial-copy mapping so responsible-user authorization failures
surface as actionable run outcomes instead of opaque setup failures.
- Added OpenAPI documentation for the user-secret routes.
- Rebases cleanly on current `master`; migrations were renumbered
incrementally as `0128_user_specific_secrets`,
`0129_agent_api_key_responsible_user`, and
`0130_run_responsible_user_invariant` after upstream `0126`/`0127`
migrations.
- Removed previously committed local design screenshots so the PR
contains code/docs/tests only.

## Verification

- PASS: PR head `2527febd106bcf3ca264ca0da7fca491084192d6` is based on
`paperclipai/paperclip:master`.
- PASS: `git diff --check`
- PASS: `git diff --name-only public/master...HEAD | rg
'^(pnpm-lock\\.yaml|\\.github/workflows/|screenshots/)' || true`
produced no files.
- PASS: migration journal audit confirmed unique indexes through `130`
with tail entries `0126_issue_comment_derived_attribution`,
`0127_environment_custom_images_instance_scoped`,
`0128_user_specific_secrets`, `0129_agent_api_key_responsible_user`, and
`0130_run_responsible_user_invariant`.
- PASS: `pnpm --filter @paperclipai/ui typecheck`
- PASS: `pnpm --filter @paperclipai/server typecheck`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-responsible-user-invariant.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-active-run-output-watchdog.test.ts
src/__tests__/heartbeat-stale-queue-invalidation.test.ts
src/__tests__/heartbeat-workspace-finalize-branch.test.ts
src/__tests__/issue-monitor-scheduler.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-comment-wake-batching.test.ts
src/__tests__/heartbeat-retry-scheduling.test.ts
src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts
src/__tests__/heartbeat-plugin-environment.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/low-trust-red-team-routes.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/secrets-service.test.ts` (55 tests)
- PASS: `pnpm vitest run server/src/__tests__/secrets-routes.test.ts
server/src/__tests__/secrets-service.test.ts` (89 tests after final
Greptile cleanup fixes)
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-issue-liveness-escalation.test.ts` (17 tests
after the final rebase CI fix)
- PASS: focused server Vitest batches covering heartbeat recovery,
project env, plugin env, routines, low-trust, pipelines, monitors,
watchdog, and stale queue paths.
- PASS: GitHub checks are green on
`2527febd106bcf3ca264ca0da7fca491084192d6`, including Typecheck +
Release Registry, Build, General tests, serialized server suites, e2e,
Canary Dry Run, verify, security checks, and Greptile Review.
- PASS: Greptile Review completed successfully on
`2527febd106bcf3ca264ca0da7fca491084192d6` with Confidence Score 5/5,
and GraphQL review-thread audit returned zero unresolved non-outdated
threads.

## Risks

- Runtime behavior now depends on a run having a correct responsible
user; missing or incorrect responsibility assignment can block runs
before adapter dispatch.
- `user_secret_ref` bindings intentionally expose metadata without
values, but UI/API callers may need to handle the new binding kind
explicitly.
- External secret providers and IAM policies are not automatically
provisioned by this PR; operators still need to configure provider-side
access for non-local vaults.
- The PR is broad across db/shared/server/UI/runtime paths, so release
validation should include both API and UI secret workflows before merge.
- The migration renumbering is intentionally incremental after upstream
migrations; the branch migrations use guarded
column/table/index/constraint creation so users who tested the older
draft numbering should not hit duplicate DDL for the existing objects.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5-based coding agent (`gpt-5`), Codex local adapter
with shell/tool use and code execution. Context window and internal
reasoning mode are not exposed by the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [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>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 05:58:20 -05:00

2108 lines
84 KiB
TypeScript

import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
Agent,
AdapterEnvironmentTestResult,
CompanySecret,
EnvBinding,
Environment,
} from "@paperclipai/shared";
import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapter } from "@paperclipai/shared";
import type { AdapterModel } from "../api/agents";
import { agentsApi } from "../api/agents";
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 } from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { FolderOpen, Heart, ChevronDown, X } from "lucide-react";
import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils";
import { extractModelName, extractProviderId } from "../lib/model-utils";
import { queryKeys } from "../lib/queryKeys";
import { useCompany } from "../context/CompanyContext";
import {
Field,
ToggleField,
ToggleWithNumber,
CollapsibleSection,
DraftInput,
DraftNumberInput,
help,
adapterLabels,
} from "./agent-config-primitives";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { defaultCreateValues } from "./agent-config-defaults";
import { getUIAdapter } from "../adapters";
import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields";
import { MarkdownEditor } from "./MarkdownEditor";
import { ChoosePathButton } from "./PathInstructionsModal";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { ReportsToPicker } from "./ReportsToPicker";
import {
EnvironmentVariablesEditor,
type EnvironmentVariablesEditorHandle,
} from "./environment-variables-editor";
import { 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, type AgentConfigOverlay } from "../lib/agent-config-patch";
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
import { filterAcpxModelsByAgent } from "../lib/acpx-model-filter";
import { resolveForcedKubernetesEnvironment } from "../lib/forced-kubernetes-environment";
/* ---- 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 type { CreateConfigValues } from "@paperclipai/adapter-utils";
/* ---- 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;
}) => void;
hideInlineSave?: boolean;
showAdapterTypeField?: boolean;
showAdapterTestEnvironmentButton?: boolean;
showCreateRunPolicySection?: boolean;
hideInstructionsFile?: boolean;
/** Hide the prompt template field from the Identity section (used when it's shown in a separate Prompts tab). */
hidePromptTemplate?: boolean;
/** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */
sectionLayout?: "inline" | "cards";
} & (
| {
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: {},
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 === "acpx_local";
}
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.runtime).length > 0 ||
o.modelProfiles?.cheap !== undefined
);
}
/* ---- 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 codexThinkingEffortOptions = [
{ id: "", label: "Auto" },
{ id: "minimal", label: "Minimal" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "xhigh", label: "X-High" },
] as const;
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;
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;
}
/* ---- 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;
const showInlineAdapterTestEnvironmentFeedback = !props.onTestFeedbackChange;
const showCreateRunPolicySection = props.showCreateRunPolicySection ?? true;
const hideInstructionsFile = props.hideInstructionsFile ?? false;
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,
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const environmentsEnabled = experimentalSettings?.enableEnvironments === true;
// 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 a company 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 a company to upload images");
return assetsApi.uploadImage(selectedCompanyId, file, namespace);
},
});
// ---- Edit mode: overlay for dirty tracking ----
const [overlay, setOverlay] = useState<AgentConfigOverlay>(emptyOverlay);
const agentRef = useRef<Agent | null>(null);
// Clear overlay when agent data refreshes (after save)
useEffect(() => {
if (!isCreate) {
if (agentRef.current !== null && props.agent !== agentRef.current) {
setOverlay({ ...emptyOverlay });
}
agentRef.current = props.agent;
}
}, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps
const isDirty = !isCreate && isOverlayDirty(overlay);
type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "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;
}
/** Build accumulated patch and send to parent */
const handleCancel = useCallback(() => {
setOverlay({ ...emptyOverlay });
}, []);
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 adapterType = isCreate
? props.values.adapterType
: overlay.adapterType ?? props.agent.adapterType;
const getCapabilities = useAdapterCapabilities();
const adapterCaps = getCapabilities(adapterType);
const isLocal = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt;
const showLegacyWorkingDirectoryField =
isLocal && 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;
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],
);
// 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
? `${instanceDefaultEnvironment.name} (${instanceDefaultEnvironment.driver})`
: "Local";
// Fetch adapter models for the effective adapter type
const modelQueryKey = selectedCompanyId
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null)
: ["agents", "none", "adapter-models", adapterType];
const {
data: fetchedModels,
error: fetchedModelsError,
} = useQuery({
queryKey: modelQueryKey,
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, {
environmentId: currentDefaultEnvironmentId || null,
}),
enabled: Boolean(selectedCompanyId),
});
const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null);
const [refreshingModels, setRefreshingModels] = useState(false);
const rawModels = fetchedModels ?? externalModels ?? [];
const adapterCommandField = "command";
const acpxAgent =
adapterType === "acpx_local"
? isCreate
? String(val!.adapterSchemaValues?.agent ?? "claude")
: eff("adapterConfig", "agent", String(config.agent ?? "claude"))
: "";
const models = useMemo(
() => adapterType === "acpx_local"
? filterAcpxModelsByAgent(rawModels, acpxAgent)
: rawModels,
[adapterType, rawModels, acpxAgent],
);
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 a company to detect the model");
}
return agentsApi.detectModel(selectedCompanyId, adapterType);
},
enabled: Boolean(selectedCompanyId && isLocal && adapterType !== "opencode_local"),
});
const detectedModel = detectedModelData?.model ?? null;
const detectedModelCandidates = detectedModelData?.candidates ?? [];
const { data: companyAgents = [] } = useQuery({
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"],
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: Boolean(!isCreate && selectedCompanyId),
});
/** Props passed to adapter-specific config field components */
const adapterFieldProps = {
mode,
isCreate,
adapterType,
values: isCreate ? props.values : null,
set: isCreate ? (patch: Partial<CreateConfigValues>) => props.onChange(patch) : null,
config,
eff: eff as <T>(group: "adapterConfig", field: string, original: T) => T,
mark: mark as (group: "adapterConfig", field: string, value: unknown) => void,
models,
hideInstructionsFile,
};
// Section toggle state — advanced always starts collapsed
const [runPolicyAdvancedOpen, setRunPolicyAdvancedOpen] = useState(false);
// Popover states
const [modelOpen, setModelOpen] = useState(false);
const [cheapModelOpen, setCheapModelOpen] = useState(false);
const [thinkingEffortOpen, setThinkingEffortOpen] = useState(false);
// Cheap model profile state — only relevant when the adapter advertises
// `supportsModelProfiles`. Defaults are sourced from the adapter's
// /model-profiles endpoint so the UI does not encode adapter-specific
// cheap defaults.
const supportsModelProfiles = adapterCaps.supportsModelProfiles;
const { data: adapterCheapProfileDefinitions } = useQuery({
queryKey: selectedCompanyId
? queryKeys.agents.adapterModelProfiles(selectedCompanyId, adapterType)
: ["agents", "none", "adapter-model-profiles", adapterType],
queryFn: () => agentsApi.adapterModelProfiles(selectedCompanyId!, adapterType),
enabled: Boolean(selectedCompanyId) && supportsModelProfiles,
});
const adapterCheapDefault = useMemo(() => {
return (adapterCheapProfileDefinitions ?? []).find((profile) => profile.key === "cheap") ?? null;
}, [adapterCheapProfileDefinitions]);
const adapterCheapDefaultModel = useMemo(() => {
const adapterConfig = adapterCheapDefault?.adapterConfig ?? {};
const value = (adapterConfig as Record<string, unknown>).model;
return typeof value === "string" ? value : "";
}, [adapterCheapDefault]);
function buildAdapterConfigForTest(adapterConfigPatch?: Record<string, unknown>): Record<string, unknown> {
if (isCreate) {
const next = uiAdapter.buildAdapterConfig(val!);
if (adapterConfigPatch) {
Object.assign(next, adapterConfigPatch);
}
return next;
}
const base = config as Record<string, unknown>;
const next = { ...base, ...overlay.adapterConfig };
if (adapterConfigPatch) {
Object.assign(next, adapterConfigPatch);
}
return next;
}
function buildCheapAdapterConfigForTest(adapterConfigPatch?: Record<string, unknown>): Record<string, unknown> {
const adapterDefaultConfig = asObject(adapterCheapDefault?.adapterConfig);
const createCheapModel = isCreate ? (val!.cheapModel ?? "").trim() : "";
const cheapAdapterConfig = isCreate
? {
...adapterDefaultConfig,
...(createCheapModel ? { model: createCheapModel } : {}),
}
: {
...adapterDefaultConfig,
...cheapProfileFromAgent.adapterConfig,
...asObject(cheapOverlay?.adapterConfig),
};
return buildAdapterConfigForTest({ ...cheapAdapterConfig, ...adapterConfigPatch });
}
function getCheapModelTestCase(adapterConfigPatch?: Record<string, unknown>): { model: string; adapterConfig: Record<string, unknown> } | null {
if (!currentCheapEnabled) return null;
const adapterConfig = buildCheapAdapterConfigForTest(adapterConfigPatch);
const configModel = typeof adapterConfig.model === "string" ? adapterConfig.model.trim() : "";
const model = configModel || currentCheapModel.trim();
if (!model) return null;
adapterConfig.model = model;
return { model, adapterConfig };
}
function prefixEnvironmentTestChecks(
result: AdapterEnvironmentTestResult,
label: string,
model: string | null,
): AdapterEnvironmentTestResult {
const modelLabel = model ? ` (${model})` : "";
return {
...result,
checks: [
{
code: `${label.toLowerCase().replace(/[^a-z0-9]+/g, "_")}_test_started`,
level: "info",
message: `${label} test${modelLabel}`,
},
...result.checks.map((check) => ({
...check,
message: `${label} test${modelLabel}: ${check.message}`,
})),
],
};
}
async function runEnvironmentTestCase(
label: string,
model: string | null,
adapterConfig: Record<string, unknown>,
environmentId: string | null,
): Promise<AdapterEnvironmentTestResult> {
const result = await agentsApi.testEnvironment(selectedCompanyId!, adapterType, {
adapterConfig,
environmentId,
});
return prefixEnvironmentTestChecks(result, label, model);
}
function mergeEnvironmentTestResults(
results: AdapterEnvironmentTestResult[],
): AdapterEnvironmentTestResult {
const checks = results.flatMap((result) => result.checks);
const status = results.some((result) => result.status === "fail")
? "fail"
: results.some((result) => result.status === "warn")
? "warn"
: "pass";
const testedAt = results[results.length - 1]?.testedAt ?? new Date().toISOString();
return {
adapterType,
status,
checks,
testedAt,
};
}
const testEnvironment = useMutation({
mutationFn: async () => {
if (!selectedCompanyId) {
throw new Error("Select a company to test adapter environment");
}
const flushedEnv = flushEnvironmentDraft();
const adapterConfigPatch = flushedEnv ? { env: flushedEnv } : undefined;
const primaryModel = currentModelId.trim() || null;
const cheapTestCase = getCheapModelTestCase(adapterConfigPatch);
const environmentId = currentDefaultEnvironmentId || null;
const testResults: Array<{ label: string; model: string | null; result: AdapterEnvironmentTestResult }> = [
{
label: "Primary model",
model: primaryModel,
result: await runEnvironmentTestCase(
"Primary model",
primaryModel,
buildAdapterConfigForTest(adapterConfigPatch),
environmentId,
),
},
];
if (cheapTestCase) {
testResults.push({
label: "Cheap model",
model: cheapTestCase.model,
result: await runEnvironmentTestCase(
"Cheap model",
cheapTestCase.model,
cheapTestCase.adapterConfig,
environmentId,
),
});
}
return testResults.length > 1
? mergeEnvironmentTestResults(testResults.map(({ result }) => result))
: testResults[0]!.result;
},
});
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;
const runEnvironmentTest = useCallback(async () => {
if (!selectedCompanyId) {
throw new Error("Select a company 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,
});
return () => {
props.onTestFeedbackChange?.({ errorMessage: null, result: null });
};
}, [props.onTestFeedbackChange, testActionError, testEnvironment.data, testEnvironment.error]);
// Current model for display
const currentModelId = isCreate
? val!.model
: eff("adapterConfig", "model", String(config.model ?? ""));
async function handleRefreshModels() {
if (!selectedCompanyId) return;
setRefreshingModels(true);
setRefreshModelsError(null);
try {
const refreshed = await agentsApi.adapterModels(selectedCompanyId, adapterType, { refresh: true });
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 === "acpx_local" && acpxAgent === "codex"
? "modelReasoningEffort"
: adapterType === "cursor"
? "mode"
: adapterType === "opencode_local"
? "variant"
: "effort";
const thinkingEffortOptions =
adapterType === "codex_local"
? codexThinkingEffortOptions
: adapterType === "acpx_local" && acpxAgent === "codex"
? codexThinkingEffortOptions
: adapterType === "cursor"
? cursorModeOptions
: adapterType === "opencode_local"
? openCodeThinkingEffortOptions
: claudeThinkingEffortOptions;
const currentThinkingEffort = isCreate
? val!.thinkingEffort
: adapterType === "codex_local"
? eff(
"adapterConfig",
"modelReasoningEffort",
String(config.modelReasoningEffort ?? config.reasoningEffort ?? ""),
)
: adapterType === "acpx_local" && acpxAgent === "codex"
? eff(
"adapterConfig",
"modelReasoningEffort",
String(config.modelReasoningEffort ?? config.reasoningEffort ?? config.effort ?? ""),
)
: adapterType === "cursor"
? eff("adapterConfig", "mode", String(config.mode ?? ""))
: adapterType === "opencode_local"
? eff("adapterConfig", "variant", String(config.variant ?? ""))
: eff("adapterConfig", "effort", String(config.effort ?? ""));
const showThinkingEffort = adapterType !== "gemini_local" && adapterType !== "cursor_cloud";
const codexSearchEnabled = adapterType === "codex_local"
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
: false;
// Cheap profile read/write helpers. Edit-mode values come from
// runtimeConfig.modelProfiles.cheap with overlay overrides on top; create-mode
// values come straight from CreateConfigValues (cheapModel + cheapModelEnabled).
const cheapProfileFromAgent = useMemo(() => {
const profiles = (runtimeConfig.modelProfiles ?? {}) as Record<string, unknown>;
const cheap = (profiles.cheap ?? {}) as Record<string, unknown>;
const cheapAdapterConfig = asObject(cheap.adapterConfig);
return {
enabled: cheap.enabled !== false,
adapterConfig: cheapAdapterConfig,
model: typeof cheapAdapterConfig.model === "string" ? cheapAdapterConfig.model : "",
};
}, [runtimeConfig]);
const cheapOverlay = !isCreate ? overlay.modelProfiles?.cheap : undefined;
const currentCheapEnabled = isCreate
? val!.cheapModelEnabled ?? false
: cheapOverlay?.enabled ?? cheapProfileFromAgent.enabled;
const currentCheapModel = isCreate
? val!.cheapModel ?? ""
: (() => {
const overlayModel = (cheapOverlay?.adapterConfig as Record<string, unknown> | undefined)?.model;
if (typeof overlayModel === "string") return overlayModel;
return cheapProfileFromAgent.model;
})();
function setCheapEnabled(next: boolean) {
if (isCreate) {
set!({ cheapModelEnabled: next });
return;
}
setOverlay((prev) => ({
...prev,
modelProfiles: {
cheap: {
...(prev.modelProfiles?.cheap ?? {}),
enabled: next,
},
},
}));
}
function setCheapModel(next: string) {
if (isCreate) {
set!({ cheapModel: next });
return;
}
setOverlay((prev) => {
const existing = prev.modelProfiles?.cheap ?? {};
const nextAdapterConfig = {
...((existing.adapterConfig ?? {}) as Record<string, unknown>),
model: next || undefined,
};
return {
...prev,
modelProfiles: {
cheap: {
...existing,
adapterConfig: nextAdapterConfig,
},
},
};
});
}
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,
});
}
return (
<div 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 className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium mb-3">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>
<Field label="Capabilities" hint={help.capabilities}>
<MarkdownEditor
value={eff("identity", "capabilities", props.agent.capabilities ?? "") ?? ""}
onChange={(v) => mark("identity", "capabilities", v || null)}
placeholder="Describe what this agent can do..."
contentClassName="min-h-[44px] text-sm font-mono"
imageUploadHandler={async (file) => {
const asset = await uploadMarkdownImage.mutateAsync({
file,
namespace: `agents/${props.agent.id}/capabilities`,
});
return asset.contentPath;
}}
/>
</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-[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-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 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-200">
This instance requires the Kubernetes sandbox, but no managed Kubernetes
environment is available for this company yet. Configure one before creating
agents; execution will not fall back to local.
</div>
)}
</Field>
</div>
</div>
) : showEnvironmentOverrideControl ? (
<div 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}>
{environment.name} · {environment.driver}
</option>
))}
</select>
</div>
</Field>
</div>
</div>
) : null}
{/* ---- Adapter ---- */}
<div 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">Adapter</h3>
: <span className="text-xs font-medium text-muted-foreground">Adapter</span>
}
{showInlineAdapterTestEnvironmentButton && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2.5 text-xs"
onClick={triggerTestEnvironment}
disabled={testEnvironmentDisabled}
>
{testActionPending ? `${testActionLabel}...` : testActionLabel}
</Button>
)}
</div>
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
{showAdapterTypeField && (
<Field label="Adapter type" hint={help.adapterType}>
<AdapterTypeDropdown
value={adapterType}
disabledTypes={disabledTypes}
onChange={(t) => {
if (isCreate) {
// Reset all adapter-specific fields to defaults when switching adapter type
const { adapterType: _at, ...defaults } = defaultCreateValues;
const nextValues: CreateConfigValues = { ...defaults, adapterType: t };
if (t === "codex_local") {
nextValues.dangerouslyBypassSandbox =
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
} else if (t === "gemini_local") {
nextValues.model = DEFAULT_GEMINI_LOCAL_MODEL;
} else if (t === "cursor") {
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
} else if (t === "opencode_local") {
nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL;
}
set!(nextValues);
} else {
// Clear all adapter config and explicitly blank out model + effort/mode keys
// so the old adapter's values don't bleed through via eff()
setOverlay((prev) => ({
...prev,
adapterType: t,
modelProfiles: { cheap: { cleared: true } },
adapterConfig: {
model:
t === "gemini_local"
? DEFAULT_GEMINI_LOCAL_MODEL
: t === "opencode_local"
? DEFAULT_OPENCODE_LOCAL_MODEL
: t === "cursor"
? DEFAULT_CURSOR_LOCAL_MODEL
: "",
effort: "",
modelReasoningEffort: "",
variant: "",
mode: "",
...(t === "codex_local"
? {
dangerouslyBypassApprovalsAndSandbox:
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
}
: {}),
},
}));
}
}}
/>
</Field>
)}
{showInlineAdapterTestEnvironmentFeedback && (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 && testEnvironment.data && (
<AdapterEnvironmentResult result={testEnvironment.data} />
)}
{/* 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>
)}
{!isLocal && <uiAdapter.ConfigFields {...adapterFieldProps} />}
{/* Local adapter-specific fields are rendered inside Permissions & Configuration */}
</div>
</div>
{/* ---- Permissions & Configuration ---- */}
{isLocal && (
<div className={cn(!cards && "border-b border-border")}>
{cards
? <h3 className="text-sm font-medium mb-3">Permissions &amp; Configuration</h3>
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Permissions &amp; Configuration</div>
}
<div className={cn(cards ? "border border-border rounded-lg p-4 space-y-3" : "px-4 pb-3 space-y-3")}>
<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",
pi_local: "pi",
cursor: "agent",
opencode_local: "opencode",
} as Record<string, string>)[adapterType] ?? adapterType.replace(/_local$/, "")
}
/>
</Field>
{supportsModelProfiles && (
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">Primary model</div>
)}
<ModelDropdown
models={models}
value={currentModelId}
onChange={(v) =>
isCreate
? set!({ model: v })
: mark("adapterConfig", "model", v || undefined)
}
open={modelOpen}
onOpenChange={setModelOpen}
allowDefault={adapterType !== "opencode_local"}
required={adapterType === "opencode_local"}
groupByProvider={adapterType === "opencode_local"}
creatable
detectedModel={detectedModel}
detectedModelCandidates={[]}
onDetectModel={adapterType === "opencode_local"
? undefined
: async () => {
const result = await refetchDetectedModel();
return result.data?.model ?? null;
}}
onRefreshModels={
supportsAdapterModelRefresh(adapterType)
? handleRefreshModels
: undefined
}
refreshingModels={refreshingModels}
detectModelLabel="Detect model"
emptyDetectHint="No model detected. Select or enter one manually."
/>
{(refreshModelsError || fetchedModelsError) && (
<p className="text-xs text-destructive">
{refreshModelsError
?? (fetchedModelsError instanceof Error
? fetchedModelsError.message
: "Failed to load adapter models.")}
</p>
)}
{adapterType === "opencode_local"
&& currentDefaultEnvironment
&& currentDefaultEnvironment.driver !== "local" && (
<p className="text-xs text-muted-foreground">
Live OpenCode model discovery only runs for Local environments. Using the curated list and manual entry for {currentDefaultEnvironment.name}.
</p>
)}
{supportsModelProfiles && (
<CheapModelSection
enabled={currentCheapEnabled}
model={currentCheapModel}
models={models}
adapterType={adapterType}
adapterDefaultModel={adapterCheapDefaultModel}
onEnabledChange={setCheapEnabled}
onModelChange={setCheapModel}
open={cheapModelOpen}
onOpenChange={setCheapModelOpen}
/>
)}
{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>
)}
</>
)}
{!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-[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-200">
Bootstrap prompt is legacy and will be removed in a future release. Consider moving this content into the agent&apos;s prompt template or instructions file instead.
</div>
</>
)}
{adapterType === "claude_local" && (
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
)}
<uiAdapter.ConfigFields {...adapterFieldProps} />
<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>
<Field label="Environment variables" hint={help.envVars}>
<EnvironmentVariablesEditor
ref={environmentVariablesEditorRef}
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)
}
/>
</Field>
{/* Edit-only: timeout + grace period */}
{!isCreate && (
<>
<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>
<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>
</>
)}
</div>
</div>
)}
{/* ---- Run Policy ---- */}
{isCreate && showCreateRunPolicySection ? (
<div 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}
/>
</div>
</div>
) : !isCreate ? (
<div 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">
<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}
</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-[11px] 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-[11px] 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 ---- */
function AdapterTypeDropdown({
value,
onChange,
disabledTypes,
}: {
value: string;
onChange: (type: string) => void;
disabledTypes: Set<string>;
}) {
const [open, setOpen] = useState(false);
const selectedDisplay = getAdapterDisplay(value);
const adapterList = useMemo(
() =>
listAdapterOptions((type) => adapterLabels[type] ?? getAdapterLabel(type)).filter(
(item) => !disabledTypes.has(item.value),
),
[disabledTypes],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className="inline-flex min-w-0 items-center gap-1.5">
{value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
<span className="truncate">{adapterLabels[value] ?? getAdapterLabel(value)}</span>
{selectedDisplay.experimental && <ExperimentalBadge />}
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
{adapterList.map((item) => (
<button
key={item.value}
disabled={item.comingSoon}
className={cn(
"flex items-center justify-between w-full px-2 py-1.5 text-sm rounded",
item.comingSoon
? "opacity-40 cursor-not-allowed"
: "hover:bg-accent/50",
item.value === value && !item.comingSoon && "bg-accent",
)}
onClick={() => {
if (!item.comingSoon) {
onChange(item.value);
setOpen(false);
}
}}
>
<span className="inline-flex items-center gap-1.5">
{item.value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
<span>{item.label}</span>
{item.experimental && <ExperimentalBadge />}
</span>
{item.comingSoon && (
<span className="text-[10px] 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-[10px] font-medium leading-none text-amber-700 dark:text-amber-200">
Experimental
</span>
);
}
function ModelDropdown({
models,
value,
onChange,
open,
onOpenChange,
allowDefault,
required,
groupByProvider,
creatable,
detectedModel,
detectedModelCandidates,
onDetectModel,
onRefreshModels,
refreshingModels,
detectModelLabel,
emptyDetectHint,
defaultLabel,
}: {
models: AdapterModel[];
value: string;
onChange: (id: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
allowDefault: boolean;
required: boolean;
groupByProvider: boolean;
creatable?: boolean;
detectedModel?: string | null;
detectedModelCandidates?: string[];
onDetectModel?: () => Promise<string | null>;
onRefreshModels?: () => Promise<void>;
refreshingModels?: boolean;
detectModelLabel?: string;
emptyDetectHint?: string;
defaultLabel?: string;
}) {
const [modelSearch, setModelSearch] = useState("");
const [detectingModel, setDetectingModel] = useState(false);
const selected = models.find((m) => m.id === value);
const manualModel = modelSearch.trim();
const canCreateManualModel = Boolean(
creatable &&
manualModel &&
!models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()),
);
// Model IDs already shown as detected/candidate badges — exclude from regular list
const promotedModelIds = useMemo(() => {
const set = new Set<string>();
if (detectedModel) set.add(detectedModel);
for (const c of detectedModelCandidates ?? []) {
if (c) set.add(c);
}
return set;
}, [detectedModel, detectedModelCandidates]);
const filteredModels = useMemo(() => {
return models.filter((m) => {
if (promotedModelIds.has(m.id)) return false;
if (!modelSearch.trim()) return true;
const q = modelSearch.toLowerCase();
const provider = extractProviderId(m.id) ?? "";
return (
m.id.toLowerCase().includes(q) ||
m.label.toLowerCase().includes(q) ||
provider.toLowerCase().includes(q)
);
});
}, [models, modelSearch, promotedModelIds]);
const groupedModels = useMemo(() => {
if (!groupByProvider) {
return [
{
provider: "models",
entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id)),
},
];
}
const map = new Map<string, AdapterModel[]>();
for (const model of filteredModels) {
const provider = extractProviderId(model.id) ?? "other";
const group = map.get(provider) ?? [];
group.push(model);
map.set(provider, group);
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([provider, entries]) => ({
provider,
entries: [...entries].sort((a, b) => a.id.localeCompare(b.id)),
}));
}, [filteredModels, groupByProvider]);
async function handleDetectModel() {
if (!onDetectModel) return;
setDetectingModel(true);
try {
const nextModel = await onDetectModel();
if (nextModel) {
onChange(nextModel);
onOpenChange(false);
setModelSearch("");
}
} finally {
setDetectingModel(false);
}
}
return (
<Field label="Model" hint={help.model}>
<Popover
open={open}
onOpenChange={(nextOpen) => {
onOpenChange(nextOpen);
if (!nextOpen) setModelSearch("");
}}
>
<PopoverTrigger asChild>
<button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}>
{selected
? selected.label
: value
|| (allowDefault ? (defaultLabel ?? "Default") : required ? "Select model (required)" : "Select model")}
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--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>
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 border border-green-500/20">
current
</span>
</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>
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 border border-blue-500/20">
detected
</span>
</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>
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-sky-500/15 text-sky-400 border border-sky-500/20">
config
</span>
</button>
);
})}
<div className="max-h-[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-[10px] uppercase tracking-wide text-muted-foreground">
{group.provider} ({group.entries.length})
</div>
)}
{group.entries.map((m) => (
<button
type="button"
key={m.id}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
m.id === value && "bg-accent",
)}
onClick={() => {
onChange(m.id);
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate" title={m.id}>
{groupByProvider ? extractModelName(m.id) : m.label}
</span>
</button>
))}
</div>
))}
{filteredModels.length === 0 && !canCreateManualModel && promotedModelIds.size === 0 && (
<div className="px-2 py-2 space-y-2">
<p className="text-xs text-muted-foreground">
{onDetectModel
? (emptyDetectHint ?? "No model detected yet. Enter a provider/model manually.")
: "No models found."}
</p>
</div>
)}
</div>
</PopoverContent>
</Popover>
</Field>
);
}
function CheapModelSection({
enabled,
model,
models,
adapterType,
adapterDefaultModel,
onEnabledChange,
onModelChange,
open,
onOpenChange,
}: {
enabled: boolean;
model: string;
models: AdapterModel[];
adapterType: string;
adapterDefaultModel: string;
onEnabledChange: (next: boolean) => void;
onModelChange: (next: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const placeholderHint = adapterDefaultModel
? `Adapter default · ${adapterDefaultModel}`
: "No adapter default — choose a cheaper model";
return (
<div className="rounded-md border border-border/70 bg-muted/20 p-3 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">Cheap model</div>
<p className="text-xs text-muted-foreground">
Used when a run requests the cheap profile (e.g. routine summaries). The primary model stays unchanged.
</p>
</div>
<ToggleSwitch checked={enabled} onCheckedChange={onEnabledChange} />
</div>
{enabled ? (
<ModelDropdown
models={models}
value={model}
onChange={onModelChange}
open={open}
onOpenChange={onOpenChange}
allowDefault
required={false}
groupByProvider={adapterType === "opencode_local"}
creatable
detectedModel={null}
detectedModelCandidates={[]}
emptyDetectHint={placeholderHint}
defaultLabel={placeholderHint}
/>
) : null}
{enabled && !model && adapterDefaultModel ? (
<p className="text-[11px] text-muted-foreground">
No explicit cheap model selected — runtime falls back to <code>{adapterDefaultModel}</code>.
</p>
) : null}
{enabled && !model && !adapterDefaultModel ? (
<p className="text-[11px] text-amber-500">
No cheap model selected and the adapter has no default. Cheap-lane runs will continue on the primary model with a fallback note.
</p>
) : null}
</div>
);
}
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-[var(--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>
);
}