forked from farhoodlabs/paperclip
44d94d0add
Agent configuration edits already had an API path for replacing the full adapterConfig, but the edit form was still sending merge-style patches. That meant clearing the last environment variable serialized as undefined, the key disappeared from JSON, and the server merged the old env bindings back into the saved config. Build adapter config save payloads as full replacement patches, strip undefined keys before send, and reuse the existing replaceAdapterConfig contract so explicit clears persist correctly. Add regression coverage for the cleared-env case and for adapter-type changes that still need to preserve adapter-agnostic fields. Fixes #3179
1378 lines
54 KiB
TypeScript
1378 lines
54 KiB
TypeScript
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type {
|
|
Agent,
|
|
AdapterEnvironmentTestResult,
|
|
CompanySecret,
|
|
EnvBinding,
|
|
} from "@paperclipai/shared";
|
|
import type { AdapterModel } from "../api/agents";
|
|
import { agentsApi } from "../api/agents";
|
|
import { secretsApi } from "../api/secrets";
|
|
import { assetsApi } from "../api/assets";
|
|
import {
|
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
|
DEFAULT_CODEX_LOCAL_MODEL,
|
|
} from "@paperclipai/adapter-codex-local";
|
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
|
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { Button } from "@/components/ui/button";
|
|
import { FolderOpen, Heart, ChevronDown, X } from "lucide-react";
|
|
import { 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 { 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 { getAdapterLabel } from "../adapters/adapter-display-registry";
|
|
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
|
import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch";
|
|
|
|
/* ---- 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;
|
|
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> = {};
|
|
|
|
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
|
|
);
|
|
}
|
|
|
|
/* ---- 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;
|
|
|
|
|
|
/* ---- 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 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 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);
|
|
|
|
/** Read effective value: overlay if dirty, else original */
|
|
function eff<T>(group: keyof Omit<AgentConfigOverlay, "adapterType">, 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: keyof Omit<AgentConfigOverlay, "adapterType">, 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 NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]);
|
|
const isLocal = !NONLOCAL_TYPES.has(adapterType);
|
|
|
|
const showLegacyWorkingDirectoryField =
|
|
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
|
|
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
|
|
|
|
// Fetch adapter models for the effective adapter type
|
|
const {
|
|
data: fetchedModels,
|
|
error: fetchedModelsError,
|
|
} = useQuery({
|
|
queryKey: selectedCompanyId
|
|
? queryKeys.agents.adapterModels(selectedCompanyId, adapterType)
|
|
: ["agents", "none", "adapter-models", adapterType],
|
|
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType),
|
|
enabled: Boolean(selectedCompanyId),
|
|
});
|
|
const models = fetchedModels ?? externalModels ?? [];
|
|
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),
|
|
});
|
|
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 [thinkingEffortOpen, setThinkingEffortOpen] = useState(false);
|
|
|
|
// Create mode helpers
|
|
const val = isCreate ? props.values : null;
|
|
const set = isCreate
|
|
? (patch: Partial<CreateConfigValues>) => props.onChange(patch)
|
|
: null;
|
|
|
|
function buildAdapterConfigForTest(): Record<string, unknown> {
|
|
if (isCreate) {
|
|
return uiAdapter.buildAdapterConfig(val!);
|
|
}
|
|
const base = config as Record<string, unknown>;
|
|
return { ...base, ...overlay.adapterConfig };
|
|
}
|
|
|
|
const testEnvironment = useMutation({
|
|
mutationFn: async () => {
|
|
if (!selectedCompanyId) {
|
|
throw new Error("Select a company to test adapter environment");
|
|
}
|
|
return agentsApi.testEnvironment(selectedCompanyId, adapterType, {
|
|
adapterConfig: buildAdapterConfigForTest(),
|
|
});
|
|
},
|
|
});
|
|
|
|
// Current model for display
|
|
const currentModelId = isCreate
|
|
? val!.model
|
|
: eff("adapterConfig", "model", String(config.model ?? ""));
|
|
|
|
const thinkingEffortKey =
|
|
adapterType === "codex_local"
|
|
? "modelReasoningEffort"
|
|
: adapterType === "cursor"
|
|
? "mode"
|
|
: adapterType === "opencode_local"
|
|
? "variant"
|
|
: "effort";
|
|
const thinkingEffortOptions =
|
|
adapterType === "codex_local"
|
|
? 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 === "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";
|
|
const codexSearchEnabled = adapterType === "codex_local"
|
|
? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search)))
|
|
: false;
|
|
const effectiveRuntimeConfig = useMemo(() => {
|
|
if (isCreate) {
|
|
return {
|
|
heartbeat: {
|
|
enabled: val!.heartbeatEnabled,
|
|
intervalSec: val!.intervalSec,
|
|
},
|
|
};
|
|
}
|
|
const mergedHeartbeat = {
|
|
...(runtimeConfig.heartbeat && typeof runtimeConfig.heartbeat === "object"
|
|
? runtimeConfig.heartbeat as Record<string, unknown>
|
|
: {}),
|
|
...overlay.heartbeat,
|
|
};
|
|
return {
|
|
...runtimeConfig,
|
|
heartbeat: mergedHeartbeat,
|
|
};
|
|
}, [isCreate, overlay.heartbeat, runtimeConfig, val]);
|
|
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>
|
|
)}
|
|
|
|
{/* ---- 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>
|
|
}
|
|
{showAdapterTestEnvironmentButton && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 px-2.5 text-xs"
|
|
onClick={() => testEnvironment.mutate()}
|
|
disabled={testEnvironment.isPending || !selectedCompanyId}
|
|
>
|
|
{testEnvironment.isPending ? "Testing..." : "Test environment"}
|
|
</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.model = DEFAULT_CODEX_LOCAL_MODEL;
|
|
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 = "";
|
|
}
|
|
set!(nextValues);
|
|
} else {
|
|
// Clear all adapter config and explicitly blank out model + effort/mode keys
|
|
// so the old adapter's values don't bleed through via eff()
|
|
setOverlay((prev) => ({
|
|
...prev,
|
|
adapterType: t,
|
|
adapterConfig: {
|
|
model:
|
|
t === "codex_local"
|
|
? DEFAULT_CODEX_LOCAL_MODEL
|
|
: t === "gemini_local"
|
|
? DEFAULT_GEMINI_LOCAL_MODEL
|
|
: t === "cursor"
|
|
? DEFAULT_CURSOR_LOCAL_MODEL
|
|
: "",
|
|
effort: "",
|
|
modelReasoningEffort: "",
|
|
variant: "",
|
|
mode: "",
|
|
...(t === "codex_local"
|
|
? {
|
|
dangerouslyBypassApprovalsAndSandbox:
|
|
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
|
}
|
|
: {}),
|
|
},
|
|
}));
|
|
}
|
|
}}
|
|
/>
|
|
</Field>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
|
|
{/* Prompt template (create mode only — edit mode shows this in Identity) */}
|
|
{isLocal && isCreate && (
|
|
<>
|
|
<Field label="Prompt Template" hint={help.promptTemplate}>
|
|
<MarkdownEditor
|
|
value={val!.promptTemplate}
|
|
onChange={(v) => set!({ 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/drafts/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. Prefer small task framing and variables like <code>{"{{ context.* }}"}</code> or <code>{"{{ run.* }}"}</code>; avoid repeating stable instructions here.
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* 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", "command", String(config.command ?? ""))
|
|
}
|
|
onCommit={(v) =>
|
|
isCreate
|
|
? set!({ command: v })
|
|
: mark("adapterConfig", "command", 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>
|
|
|
|
<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={async () => {
|
|
const result = await refetchDetectedModel();
|
|
return result.data?.model ?? null;
|
|
}}
|
|
detectModelLabel="Detect model"
|
|
emptyDetectHint="No model detected. Select or enter one manually."
|
|
/>
|
|
{fetchedModelsError && (
|
|
<p className="text-xs text-destructive">
|
|
{fetchedModelsError instanceof Error
|
|
? fetchedModelsError.message
|
|
: "Failed to load adapter models."}
|
|
</p>
|
|
)}
|
|
|
|
{showThinkingEffort && (
|
|
<>
|
|
<ThinkingEffortDropdown
|
|
value={currentThinkingEffort}
|
|
options={thinkingEffortOptions}
|
|
onChange={(v) =>
|
|
isCreate
|
|
? set!({ thinkingEffort: v })
|
|
: mark("adapterConfig", thinkingEffortKey, v || undefined)
|
|
}
|
|
open={thinkingEffortOpen}
|
|
onOpenChange={setThinkingEffortOpen}
|
|
/>
|
|
{adapterType === "codex_local" &&
|
|
codexSearchEnabled &&
|
|
currentThinkingEffort === "minimal" && (
|
|
<p className="text-xs text-amber-400">
|
|
Codex may reject `minimal` thinking when search is enabled.
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
{!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 ?? 1),
|
|
)}
|
|
onCommit={(v) => mark("heartbeat", "maxConcurrentRuns", v)}
|
|
immediate
|
|
className={inputClass}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
</CollapsibleSection>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 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 items-center gap-1.5">
|
|
{value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
|
|
<span>{adapterLabels[value] ?? getAdapterLabel(value)}</span>
|
|
</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>
|
|
</span>
|
|
{item.comingSoon && (
|
|
<span className="text-[10px] text-muted-foreground">Coming soon</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
function ModelDropdown({
|
|
models,
|
|
value,
|
|
onChange,
|
|
open,
|
|
onOpenChange,
|
|
allowDefault,
|
|
required,
|
|
groupByProvider,
|
|
creatable,
|
|
detectedModel,
|
|
detectedModelCandidates,
|
|
onDetectModel,
|
|
detectModelLabel,
|
|
emptyDetectHint,
|
|
}: {
|
|
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>;
|
|
detectModelLabel?: string;
|
|
emptyDetectHint?: 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 ? "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>
|
|
)}
|
|
{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 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>
|
|
);
|
|
}
|