mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The execution subsystem runs those agents in environments (local, ssh, sandbox), and sandbox-provider plugins let an environment materialize per-run sandboxes > - Stage 1 (#5790) contributed a first-party Kubernetes sandbox-provider plugin, but the server core has no way to adopt it operationally: no per-run adapter selection, no way to force an instance onto sandboxed execution, no declarative adapter/model configuration, and the plugin must be installed by hand > - Without this, a multi-tenant or security-conscious deployment cannot guarantee that agent runs never execute on the host, and a single environment cannot serve agents with different harnesses > - This pull request adds the server + SDK integration: per-run adapterType on the lease protocol, an env-gated forced-Kubernetes execution policy with provisioning and a per-run allowlist guard, a declarative adapter registry and model list, in-cluster env passthrough for sandbox plugin workers, fail-safe auto-install of the bundled plugin, and the matching UI affordance > - The benefit is that sandbox-provider plugins become fully usable for Kubernetes execution: operators configure everything via environment variables and GitOps, while self-hosters who set none of the variables see exactly the behavior they have today ## Linked Issues or Issue Description Refs #5790 (stage 1 of 3: the Kubernetes sandbox-provider plugin package). No existing issue. Feature description: the server core lacks the integration seams to operate a sandbox-provider plugin as the mandatory execution path of an instance. This PR is stage 2 of 3 of the staged Kubernetes contribution; stage 3 will contribute the agent runtime images and their build pipeline. ## What Changed One line per piece: - `packages/plugins/sdk/protocol.ts`: optional `adapterType` on `PluginEnvironmentAcquireLeaseParams` so a provider can select the runtime image per run; existing providers simply ignore it - `server/services/environment-runtime.ts` + `environment-run-orchestrator.ts`: thread the agent's adapter type into both lease-acquiring drivers, including the heartbeat path (the two call sites have historically drifted, hence the pinned test) - `server/services/environments.ts`: `ensureKubernetesEnvironment` / `findKubernetesEnvironment`, an idempotent managed Kubernetes environment per company, identified by a metadata marker and refreshed (not recreated) on config change; `timeoutMs` rides on the config for slow cold-start leases - `server/services/execution-allowlist.ts`: pure (driver, provider, policy) -> allow/deny guard; `executionMode=kubernetes` only allows the kubernetes sandbox provider - `server/services/execution-policy-bootstrap.ts` + startup hook in `server/index.ts`: parse `PAPERCLIP_EXECUTION_MODE` / `PAPERCLIP_K8S_*`, persist `executionMode` into instance general settings, and provision the managed environment for every company; fails loud on misconfiguration - `server/services/heartbeat.ts`: when the policy forces Kubernetes, pin run selection to the managed environment (also overriding any persisted workspace environment id), refuse to fall back to local, and re-check the actually acquired environment against the allowlist as defense in depth - `server/services/adapter-registry-bootstrap.ts` + shared `AdapterRegistryEntry` type/validator: declarative `PAPERCLIP_ADAPTERS` registry (inline JSON or file) that reconciles adapter availability at startup and rides on the Kubernetes environment config - `server/services/adapter-models-env.ts` + `adapters/registry.ts`: `PAPERCLIP_ADAPTER_MODELS` lets an operator declare picker model lists the server cannot CLI-discover - `server/services/plugin-loader.ts`: pass `KUBERNETES_SERVICE_HOST/PORT(_HTTPS)` through to plugin workers that register environment drivers, so in-cluster API clients can be constructed; all other host env stays stripped - `server/app.ts`: fail-safe auto-install of the bundled kubernetes plugin at boot; no-ops when the bundle is absent and never blocks startup on error - `packages/shared` types/validators: `InstanceExecutionMode` on general settings (optional, strict schema) - `ui/lib/forced-kubernetes-environment.ts` + `AgentConfigForm`: when the policy is active, show a read-only Kubernetes environment instead of the environment picker and default new agents onto the managed environment - Tests for every new module plus the adapterType pin in `heartbeat-plugin-environment` and the managed-environment lifecycle in `environment-service` Everything is gated: with `PAPERCLIP_EXECUTION_MODE`, `PAPERCLIP_ADAPTERS`, and `PAPERCLIP_ADAPTER_MODELS` unset (and no bundled plugin present), every code path reduces to current behavior. The per-run `adapterType` is an optional SDK parameter that existing providers ignore. ## Verification - `cd server && npx tsc --noEmit`: clean (0 errors); `ui` typecheck also clean - Targeted suites all green (11 files, 90 tests): `npx vitest run server/src/__tests__/heartbeat-plugin-environment.test.ts server/src/__tests__/environment-service.test.ts server/src/__tests__/environment-runtime.test.ts server/src/__tests__/environment-run-orchestrator.test.ts server/src/__tests__/plugin-database.test.ts server/src/services/execution-policy-bootstrap.test.ts server/src/services/execution-allowlist.test.ts server/src/services/adapter-registry-bootstrap.test.ts server/src/services/adapter-registry-bootstrap.reconcile.test.ts server/src/services/adapter-models-env.test.ts packages/shared/src/validators/adapter-registry.test.ts` - `npx vitest run ui/src/components/AgentConfigForm.test.ts`: green (6 tests) - Full `npx vitest run server/src/__tests__`: 2323 passed, 1 skipped; the only failures (heartbeat-process-recovery pid-retry, workspace-runtime symbolic-ref/git tests) reproduce identically on pristine `master` in the same environment, so they are machine-environment issues unrelated to this change; `server-startup-feedback-export` needed its `services/index.js` mock extended with the new export and is green - This integration has been running in production on a hosted multi-tenant deployment, where it executes agent runs across five different harnesses through the stage 1 plugin ## Risks - Low for existing deployments: every behavior is env-gated and the defaults preserve current semantics; the auto-install block is wrapped fail-safe and skips silently when the plugin bundle is absent - `executionMode` is a new optional field on a strict zod schema; absent input normalizes exactly as before - The forced policy intentionally fails runs loudly (rather than falling back to local) when no managed Kubernetes environment exists; this only affects instances that explicitly set `PAPERCLIP_EXECUTION_MODE=kubernetes` ## Model Used Claude Opus 4.8 (claude-opus-4-8, 1M context), extended thinking, agentic tool use via Claude Code. ## UI screenshots The UI change is a new read-only "Execution" section in `AgentConfigForm`, shown only when the instance execution policy forces Kubernetes (`executionMode=kubernetes`); there is no "before" state for it (the section did not exist, and instances without the forced policy render the existing picker unchanged). Captured from the new Storybook stories added in this PR (`Product/Agent Management`): Managed Kubernetes environment present (read-only display, no local/SSH picker):  No managed environment available yet (warning notice, no silent local fallback):  ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1901 lines
76 KiB
TypeScript
1901 lines
76 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 { EnvVarEditor } from "./EnvVarEditor";
|
|
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;
|
|
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();
|
|
|
|
// 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),
|
|
});
|
|
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: 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 },
|
|
}));
|
|
}
|
|
|
|
/** Build accumulated patch and send to parent */
|
|
const handleCancel = useCallback(() => {
|
|
setOverlay({ ...emptyOverlay });
|
|
}, []);
|
|
|
|
const handleSave = useCallback(() => {
|
|
if (isCreate || !isDirty) return;
|
|
props.onSave(buildAgentUpdatePatch(props.agent, overlay));
|
|
}, [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 currentDefaultEnvironmentId = isCreate
|
|
? val!.defaultEnvironmentId ?? ""
|
|
: eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? "");
|
|
const currentDefaultEnvironment = useMemo(
|
|
() => environments.find((environment) => environment.id === currentDefaultEnvironmentId) ?? null,
|
|
[currentDefaultEnvironmentId, environments],
|
|
);
|
|
|
|
// 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 !== "sandbox") return true;
|
|
const provider = typeof environment.config?.provider === "string" ? environment.config.provider : null;
|
|
return provider !== null && provider !== "fake";
|
|
}),
|
|
[environments, supportedEnvironmentDrivers],
|
|
);
|
|
|
|
// 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 =
|
|
adapterType === "hermes_local" ? "hermesCommand" : "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(): Record<string, unknown> {
|
|
if (isCreate) {
|
|
return uiAdapter.buildAdapterConfig(val!);
|
|
}
|
|
const base = config as Record<string, unknown>;
|
|
const next = { ...base, ...overlay.adapterConfig };
|
|
if (adapterType === "hermes_local") {
|
|
const hermesCommand =
|
|
typeof next.hermesCommand === "string" && next.hermesCommand.length > 0
|
|
? next.hermesCommand
|
|
: typeof next.command === "string" && next.command.length > 0
|
|
? next.command
|
|
: undefined;
|
|
if (hermesCommand) {
|
|
next.hermesCommand = hermesCommand;
|
|
}
|
|
}
|
|
return next;
|
|
}
|
|
|
|
const testEnvironment = useMutation({
|
|
mutationFn: async () => {
|
|
if (!selectedCompanyId) {
|
|
throw new Error("Select a company to test adapter environment");
|
|
}
|
|
return agentsApi.testEnvironment(selectedCompanyId, adapterType, {
|
|
adapterConfig: buildAdapterConfigForTest(),
|
|
environmentId: currentDefaultEnvironmentId || null,
|
|
});
|
|
},
|
|
});
|
|
const testEnvironmentDisabled = testEnvironment.isPending || !selectedCompanyId;
|
|
const triggerTestEnvironment = useCallback(() => {
|
|
if (testEnvironmentDisabled) return;
|
|
testEnvironment.mutate();
|
|
}, [testEnvironment.mutate, testEnvironmentDisabled]);
|
|
|
|
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: testEnvironment.isPending,
|
|
});
|
|
return () => {
|
|
props.onTestActionStateChange?.({ disabled: true, pending: false });
|
|
};
|
|
}, [
|
|
showAdapterTestEnvironmentButton,
|
|
props.onTestActionStateChange,
|
|
testEnvironmentDisabled,
|
|
testEnvironment.isPending,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!props.onTestFeedbackChange) return;
|
|
props.onTestFeedbackChange({
|
|
errorMessage: 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, 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 = (cheap.adapterConfig ?? {}) as Record<string, unknown>;
|
|
return {
|
|
enabled: cheap.enabled !== false,
|
|
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">Execution</h3>
|
|
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Execution</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>
|
|
) : environmentsEnabled ? (
|
|
<div className={cn(!cards && (isCreate ? "border-t border-border" : "border-b border-border"))}>
|
|
{cards
|
|
? <h3 className="text-sm font-medium mb-3">Execution</h3>
|
|
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Execution</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="Agent-level default execution target. Project and task settings can still override this."
|
|
>
|
|
<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="">Company default (Local)</option>
|
|
{runnableEnvironments.map((environment) => (
|
|
<option key={environment.id} value={environment.id}>
|
|
{environment.name} · {environment.driver}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</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}
|
|
>
|
|
{testEnvironment.isPending ? "Testing..." : "Test"}
|
|
</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 && testEnvironment.error && (
|
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
|
{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>
|
|
)}
|
|
|
|
{/* 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 & Configuration</h3>
|
|
: <div className="px-4 py-2 text-xs font-medium text-muted-foreground">Permissions & 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(
|
|
(adapterType === "hermes_local"
|
|
? config.hermesCommand ?? config.command
|
|
: 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'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)
|
|
}
|
|
immediate
|
|
className={inputClass}
|
|
placeholder="e.g. --verbose, --foo=bar"
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Environment variables" hint={help.envVars}>
|
|
<EnvVarEditor
|
|
value={
|
|
isCreate
|
|
? ((val!.envBindings ?? EMPTY_ENV) as Record<string, EnvBinding>)
|
|
: ((eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>))
|
|
)
|
|
}
|
|
secrets={availableSecrets}
|
|
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>
|
|
);
|
|
}
|