| import { useState, useEffect, useRef, useMemo, useCallback } from "react"; |
| import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| import { useTranslation } from "react-i18next"; |
| import type { |
| Agent, |
| AdapterEnvironmentTestResult, |
| CompanySecret, |
| EnvBinding, |
| Environment, |
| } from "@penclipai/shared"; |
| import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapter } from "@penclipai/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, |
| DEFAULT_CODEX_LOCAL_MODEL, |
| } from "@penclipai/adapter-codex-local"; |
| import { DEFAULT_CURSOR_LOCAL_MODEL } from "@penclipai/adapter-cursor-local"; |
| import { DEFAULT_GEMINI_LOCAL_MODEL } from "@penclipai/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"; |
| import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; |
|
|
| |
|
|
| |
| |
| export type { CreateConfigValues } from "@penclipai/adapter-utils"; |
| import type { CreateConfigValues } from "@penclipai/adapter-utils"; |
|
|
| |
|
|
| 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; |
| |
| hidePromptTemplate?: boolean; |
| |
| sectionLayout?: "inline" | "cards"; |
| } & ( |
| | { |
| mode: "create"; |
| values: CreateConfigValues; |
| onChange: (patch: Partial<CreateConfigValues>) => void; |
| } |
| | { |
| mode: "edit"; |
| agent: Agent; |
| onSave: (patch: Record<string, unknown>) => void; |
| isSaving?: boolean; |
| } |
| ); |
|
|
| |
|
|
| const emptyOverlay: AgentConfigOverlay = { |
| identity: {}, |
| adapterConfig: {}, |
| heartbeat: {}, |
| runtime: {}, |
| }; |
|
|
| |
| 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 |
| ); |
| } |
|
|
| |
| 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; |
|
|
|
|
| |
|
|
| export function AgentConfigForm(props: AgentConfigFormProps) { |
| const { t } = useTranslation(); |
| 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(); |
|
|
| |
| 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; |
|
|
| const { data: environments = [] } = useQuery<Environment[]>({ |
| queryKey: selectedCompanyId ? queryKeys.environments.list(selectedCompanyId) : ["environments", "none"], |
| queryFn: () => environmentsApi.list(selectedCompanyId!), |
| enabled: Boolean(selectedCompanyId) && environmentsEnabled, |
| }); |
| const createSecret = useMutation({ |
| mutationFn: (input: { name: string; value: string }) => { |
| if (!selectedCompanyId) { |
| throw new Error(t("agentConfig.selectCompanyToCreateSecrets", { defaultValue: "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(t("agentConfig.selectCompanyToUploadImages", { defaultValue: "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]); |
|
|
| const isDirty = !isCreate && isOverlayDirty(overlay); |
|
|
| |
| 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; |
| } |
|
|
| |
| function mark(group: keyof Omit<AgentConfigOverlay, "adapterType">, field: string, value: unknown) { |
| setOverlay((prev) => ({ |
| ...prev, |
| [group]: { ...prev[group], [field]: value }, |
| })); |
| } |
|
|
| |
| 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]); |
|
|
| |
| 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 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], |
| ); |
|
|
| |
| const modelQueryKey = selectedCompanyId |
| ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType) |
| : ["agents", "none", "adapter-models", adapterType]; |
| const { |
| data: fetchedModels, |
| error: fetchedModelsError, |
| } = useQuery({ |
| queryKey: modelQueryKey, |
| queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType), |
| enabled: Boolean(selectedCompanyId), |
| }); |
| const [refreshModelsError, setRefreshModelsError] = useState<string | null>(null); |
| const [refreshingModels, setRefreshingModels] = useState(false); |
| const models = fetchedModels ?? externalModels ?? []; |
| const adapterCommandField = |
| adapterType === "hermes_local" ? "hermesCommand" : "command"; |
| const { |
| data: detectedModelData, |
| refetch: refetchDetectedModel, |
| } = useQuery({ |
| queryKey: selectedCompanyId |
| ? queryKeys.agents.detectModel(selectedCompanyId, adapterType) |
| : ["agents", "none", "detect-model", adapterType], |
| queryFn: () => { |
| if (!selectedCompanyId) { |
| throw new Error(t("agentConfig.selectCompanyToTestEnvironment", { defaultValue: "Select a company to test adapter environment" })); |
| } |
| 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), |
| }); |
|
|
| |
| 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, |
| }; |
|
|
| |
| const [runPolicyAdvancedOpen, setRunPolicyAdvancedOpen] = useState(false); |
| |
| const [modelOpen, setModelOpen] = useState(false); |
| const [thinkingEffortOpen, setThinkingEffortOpen] = useState(false); |
|
|
| |
| 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>; |
| 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(t("agentConfig.selectCompanyToTestEnvironment", { defaultValue: "Select a company to test adapter environment" })); |
| } |
| return agentsApi.testEnvironment(selectedCompanyId, adapterType, { |
| adapterConfig: buildAdapterConfigForTest(), |
| }); |
| }, |
| }); |
|
|
| |
| 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 === "cursor" |
| ? "mode" |
| : adapterType === "opencode_local" |
| ? "variant" |
| : "effort"; |
| const thinkingEffortOptions = |
| adapterType === "codex_local" |
| ? codexThinkingEffortOptions |
| : adapterType === "cursor" |
| ? cursorModeOptions |
| : adapterType === "opencode_local" |
| ? openCodeThinkingEffortOptions |
| : claudeThinkingEffortOptions; |
| const localizedThinkingEffortOptions = useMemo( |
| () => |
| thinkingEffortOptions.map((option) => ({ |
| ...option, |
| label: t(`agentConfig.${option.id || "auto"}`, { defaultValue: option.label }), |
| })), |
| [t, thinkingEffortOptions], |
| ); |
| 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]); |
| const currentDefaultEnvironmentId = isCreate |
| ? val!.defaultEnvironmentId ?? "" |
| : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? ""); |
| 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">{t("agentConfig.unsavedChanges", { defaultValue: "Unsaved changes" })}</span> |
| <Button |
| size="sm" |
| onClick={handleSave} |
| disabled={!isCreate && props.isSaving} |
| > |
| {!isCreate && props.isSaving |
| ? t("Saving...", { defaultValue: "Saving..." }) |
| : t("Save", { defaultValue: "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">{t("agentConfig.identity", { defaultValue: "Identity" })}</h3> |
| : <div className="px-4 py-2 text-xs font-medium text-muted-foreground">{t("agentConfig.identity", { defaultValue: "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={t("agentConfig.name", { defaultValue: "Name" })} hint={help.name}> |
| <DraftInput |
| value={eff("identity", "name", props.agent.name)} |
| onCommit={(v) => mark("identity", "name", v)} |
| immediate |
| className={inputClass} |
| placeholder={t("agentConfig.agentName", { defaultValue: "Agent name" })} |
| /> |
| </Field> |
| <Field label={t("agentConfig.title", { defaultValue: "Title" })} hint={help.title}> |
| <DraftInput |
| value={eff("identity", "title", props.agent.title ?? "")} |
| onCommit={(v) => mark("identity", "title", v || null)} |
| immediate |
| className={inputClass} |
| placeholder={t("agentConfig.titlePlaceholder", { defaultValue: "e.g. VP of Engineering" })} |
| /> |
| </Field> |
| <Field label={t("agentConfig.reportsTo", { defaultValue: "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={t("agentConfig.chooseManager", { defaultValue: "Choose manager…" })} |
| /> |
| </Field> |
| <Field label={t("agentConfig.capabilities", { defaultValue: "Capabilities" })} hint={help.capabilities}> |
| <MarkdownEditor |
| value={eff("identity", "capabilities", props.agent.capabilities ?? "") ?? ""} |
| onChange={(v) => mark("identity", "capabilities", v || null)} |
| placeholder={t("agentConfig.capabilitiesPlaceholder", { defaultValue: "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={t("agentConfig.promptTemplate", { defaultValue: "Prompt Template" })} hint={help.promptTemplate}> |
| <MarkdownEditor |
| value={eff( |
| "adapterConfig", |
| "promptTemplate", |
| String(config.promptTemplate ?? ""), |
| )} |
| onChange={(v) => mark("adapterConfig", "promptTemplate", v ?? "")} |
| placeholder={t("agentConfig.promptTemplatePlaceholder", { defaultValue: "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"> |
| {t("agentConfig.promptTemplateWarning", { defaultValue: "Prompt template is replayed on every heartbeat. Keep it concise and stable so context does not keep ballooning." })} |
| </div> |
| </> |
| )} |
| </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">{t("agentConfig.execution", { defaultValue: "Execution" })}</h3> |
| : <div className="px-4 py-2 text-xs font-medium text-muted-foreground">{t("agentConfig.execution", { defaultValue: "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={t("agentConfig.defaultEnvironment", { defaultValue: "Default environment" })} |
| hint={t("agentConfig.defaultEnvironmentHint", { |
| defaultValue: "Agent-level default execution target. Project and issue 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="">{t("agentConfig.companyDefaultLocal", { defaultValue: "Company default (Local)" })}</option> |
| {runnableEnvironments.map((environment) => ( |
| <option key={environment.id} value={environment.id}> |
| {environment.name} · {environment.driver} |
| </option> |
| ))} |
| </select> |
| </Field> |
| </div> |
| </div> |
| ) : null} |
|
|
| {} |
| <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">{t("agentConfig.adapter", { defaultValue: "Adapter" })}</h3> |
| : <span className="text-xs font-medium text-muted-foreground">{t("agentConfig.adapter", { defaultValue: "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 |
| ? t("agentConfig.testingEnvironment", { defaultValue: "Testing..." }) |
| : t("agentConfig.testEnvironment", { defaultValue: "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={t("agentConfig.adapterType", { defaultValue: "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 |
| : t("agentConfig.environmentTestFailed", { defaultValue: "Environment test failed" })} |
| </div> |
| )} |
| |
| {testEnvironment.data && ( |
| <AdapterEnvironmentResult result={testEnvironment.data} /> |
| )} |
| |
| {/* Working directory */} |
| {showLegacyWorkingDirectoryField && ( |
| <Field label={t("agentConfig.workingDirectoryDeprecated", { defaultValue: "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={t("agentConfig.promptTemplate", { defaultValue: "Prompt Template" })} hint={help.promptTemplate}> |
| <MarkdownEditor |
| value={val!.promptTemplate} |
| onChange={(v) => set!({ promptTemplate: v })} |
| placeholder={t("agentConfig.promptTemplatePlaceholder", { defaultValue: "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"> |
| {t("agentConfig.promptTemplateCreateWarning", { defaultValue: "After saving, this prompt template is replayed on every heartbeat. Keep it concise and stable so context does not keep ballooning." })} |
| </div> |
| </> |
| )} |
|
|
| {} |
| </div> |
|
|
| </div> |
|
|
| {} |
| {isLocal && ( |
| <div className={cn(!cards && "border-b border-border")}> |
| {cards |
| ? <h3 className="text-sm font-medium mb-3">{t("agentConfig.permissionsAndConfiguration", { defaultValue: "Permissions & Configuration" })}</h3> |
| : <div className="px-4 py-2 text-xs font-medium text-muted-foreground">{t("agentConfig.permissionsAndConfiguration", { defaultValue: "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={t("agentConfig.command", { defaultValue: "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> |
| |
| <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; |
| }} |
| onRefreshModels={adapterType === "codex_local" ? handleRefreshModels : undefined} |
| refreshingModels={refreshingModels} |
| detectModelLabel={t("agentConfig.detectModel", { defaultValue: "Detect model" })} |
| emptyDetectHint={t("agentConfig.noDetectedModelSelectManually", { |
| defaultValue: "No model detected. Select or enter one manually.", |
| })} |
| /> |
| {(refreshModelsError || fetchedModelsError) && ( |
| <p className="text-xs text-destructive"> |
| {refreshModelsError |
| ?? (fetchedModelsError instanceof Error |
| ? fetchedModelsError.message |
| : t("agentConfig.failedToLoadAdapterModels", { defaultValue: "Failed to load adapter models." }))} |
| </p> |
| )} |
| |
| {showThinkingEffort && ( |
| <> |
| <ThinkingEffortDropdown |
| value={currentThinkingEffort} |
| options={localizedThinkingEffortOptions} |
| 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"> |
| {t("agentConfig.codexMinimalSearchWarning", { defaultValue: "Codex may reject `minimal` thinking when search is enabled." })} |
| </p> |
| )} |
| </> |
| )} |
| {!isCreate && typeof config.bootstrapPromptTemplate === "string" && config.bootstrapPromptTemplate && ( |
| <> |
| <Field label={t("agentConfig.bootstrapPromptLegacy", { defaultValue: "Bootstrap prompt (legacy)" })} hint={help.bootstrapPrompt}> |
| <MarkdownEditor |
| value={eff( |
| "adapterConfig", |
| "bootstrapPromptTemplate", |
| String(config.bootstrapPromptTemplate ?? ""), |
| )} |
| onChange={(v) => |
| mark("adapterConfig", "bootstrapPromptTemplate", v || undefined) |
| } |
| placeholder={t("agentConfig.bootstrapPromptPlaceholder", { defaultValue: "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"> |
| {t("agentConfig.bootstrapPromptLegacyWarning", { defaultValue: "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={t("agentConfig.extraArgs", { defaultValue: "Extra args" })} 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={t("agentConfig.extraArgsPlaceholder", { defaultValue: "Comma-separated, e.g. --verbose, --search" })} |
| /> |
| </Field> |
| |
| <Field label={t("agentConfig.environmentVariables", { defaultValue: "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={t("agentConfig.timeoutSec", { defaultValue: "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={t("agentConfig.graceSec", { defaultValue: "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> |
| )} |
|
|
| {} |
| {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" /> {t("agentConfig.runPolicy", { defaultValue: "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" /> {t("agentConfig.runPolicy", { defaultValue: "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={t("agentConfig.heartbeatOnInterval", { defaultValue: "Heartbeat on interval" })} |
| hint={help.heartbeatInterval} |
| checked={val!.heartbeatEnabled} |
| onCheckedChange={(v) => set!({ heartbeatEnabled: v })} |
| number={val!.intervalSec} |
| onNumberChange={(v) => set!({ intervalSec: v })} |
| numberLabel={t("agentConfig.seconds", { defaultValue: "sec" })} |
| numberPrefix={t("agentConfig.runHeartbeatEvery", { defaultValue: "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" /> {t("agentConfig.runPolicy", { defaultValue: "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" /> {t("agentConfig.runPolicy", { defaultValue: "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={t("agentConfig.heartbeatOnInterval", { defaultValue: "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={t("agentConfig.seconds", { defaultValue: "sec" })} |
| numberPrefix={t("agentConfig.runHeartbeatEvery", { defaultValue: "Run heartbeat every" })} |
| numberHint={help.intervalSec} |
| showNumber={eff("heartbeat", "enabled", heartbeat.enabled === true)} |
| /> |
| </div> |
| <CollapsibleSection |
| title={t("agentConfig.advancedRunPolicy", { defaultValue: "Advanced Run Policy" })} |
| bordered={cards} |
| open={runPolicyAdvancedOpen} |
| onToggle={() => setRunPolicyAdvancedOpen(!runPolicyAdvancedOpen)} |
| > |
| <div className="space-y-3"> |
| <ToggleField |
| label={t("agentConfig.wakeOnDemand", { defaultValue: "Wake on demand" })} |
| hint={help.wakeOnDemand} |
| checked={eff( |
| "heartbeat", |
| "wakeOnDemand", |
| heartbeat.wakeOnDemand !== false, |
| )} |
| onChange={(v) => mark("heartbeat", "wakeOnDemand", v)} |
| /> |
| <Field label={t("agentConfig.cooldownSec", { defaultValue: "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={t("agentConfig.maxConcurrentRuns", { defaultValue: "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> |
| </CollapsibleSection> |
| </div> |
| </div> |
| ) : null} |
|
|
| </div> |
| ); |
| } |
|
|
| function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestResult }) { |
| const { t } = useTranslation(); |
| const statusLabel = |
| result.status === "pass" |
| ? t("agentConfig.passed", { defaultValue: "Passed" }) |
| : result.status === "warn" |
| ? t("agentConfig.warnings", { defaultValue: "Warnings" }) |
| : t("agentConfig.failed", { defaultValue: "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">{t("agentConfig.hint", { defaultValue: "Hint" })}: {check.hint}</span>} |
| </div> |
| ))} |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
|
|
| function AdapterTypeDropdown({ |
| value, |
| onChange, |
| disabledTypes, |
| }: { |
| value: string; |
| onChange: (type: string) => void; |
| disabledTypes: Set<string>; |
| }) { |
| const { t } = useTranslation(); |
| 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">{t("agentConfig.comingSoon", { defaultValue: "Coming soon" })}</span> |
| )} |
| </button> |
| ))} |
| </PopoverContent> |
| </Popover> |
| ); |
| } |
|
|
| function ModelDropdown({ |
| models, |
| value, |
| onChange, |
| open, |
| onOpenChange, |
| allowDefault, |
| required, |
| groupByProvider, |
| creatable, |
| detectedModel, |
| detectedModelCandidates, |
| onDetectModel, |
| onRefreshModels, |
| refreshingModels, |
| 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>; |
| onRefreshModels?: () => Promise<void>; |
| refreshingModels?: boolean; |
| detectModelLabel?: string; |
| emptyDetectHint?: string; |
| }) { |
| const { t } = useTranslation(); |
| 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()), |
| ); |
| |
| 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={t("agentConfig.model", { defaultValue: "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 |
| ? t("agentConfig.default", { defaultValue: "Default" }) |
| : required |
| ? t("agentConfig.selectModelRequired", { defaultValue: "Select model (required)" }) |
| : t("agentConfig.selectModel", { defaultValue: "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 |
| ? t("agentConfig.searchModelsCreateHint", { defaultValue: "Search models... (type to create)" }) |
| : t("agentConfig.searchModels", { defaultValue: "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 |
| ? t("agentConfig.detectingModel", { defaultValue: "Detecting..." }) |
| : detectedModel |
| ? (detectModelLabel?.replace(/^Detect\b/, "Re-detect") ?? t("agentConfig.detectFromConfig", { defaultValue: "Detect from config" })) |
| : (detectModelLabel ?? t("agentConfig.detectFromConfig", { defaultValue: "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"> |
| {t("agentConfig.currentBadge", { defaultValue: "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"> |
| {t("agentConfig.detectedBadge", { defaultValue: "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"> |
| {t("agentConfig.detectFromConfig", { defaultValue: "Detect from 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); |
| }} |
| > |
| {t("agentConfig.default", { defaultValue: "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>{t("agentConfig.useManualModel", { defaultValue: "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 ?? t("agentConfig.selectModel", { defaultValue: "Select model" })) |
| : t("agentConfig.noModelsFound", { defaultValue: "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 { t } = useTranslation(); |
| const selected = options.find((option) => option.id === value) ?? options[0]; |
|
|
| return ( |
| <Field label={t("agentConfig.thinkingEffort", { defaultValue: "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 ?? t("agentConfig.auto", { defaultValue: "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> |
| ); |
| } |
|
|