- 51 fix agents covered every dirty batch from the round-1 verifiers: helper-function labels (roles, statuses, relative time), constant label maps (SETTINGS_SECTIONS, PROVIDER_INFO, EVENT_TYPE_LABELS), TUI help overlay + tab labels, toasts, placeholders, aria-labels - Inline markup flattened by round 1 restored with <Trans> (DbCorruptionBanner storage-docs link, UpdateAvailableBanner code chip) - Catalogs merged: +527 en keys across 5 locales; CLI bundles + app locale tree regenerated (6 locales) - All 23 sweep-caused test regressions fixed: delta vs the clean-main baseline is now zero (remaining 4 local failures reproduce identically on origin/main; CI-green upstream) - typecheck/lint clean; TUI 82/82, core locale 9/9 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
904 lines
40 KiB
TypeScript
904 lines
40 KiB
TypeScript
import "./NewAgentDialog.css";
|
||
import { useState, useEffect, useCallback } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { useTranslation } from "react-i18next";
|
||
import type { Agent, AgentCapability, ModelInfo, AgentGenerationSpec, PluginRuntimeInfo, AgentOnboardingSummary } from "../api";
|
||
import { createAgent, fetchAgents, fetchModels } from "../api";
|
||
import * as apiModule from "../api";
|
||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||
import { ProviderIcon } from "./ProviderIcon";
|
||
import { AgentGenerationModal } from "./AgentGenerationModal";
|
||
import { AGENT_PRESETS, type AgentPreset } from "./agent-presets";
|
||
import { SkillMultiselect } from "./SkillMultiselect";
|
||
import { AgentAvatar } from "./AgentAvatar";
|
||
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
|
||
import { useFavorites } from "../hooks/useFavorites";
|
||
|
||
export interface NewAgentDialogProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onCreated: () => void;
|
||
projectId?: string;
|
||
prefillDraft?: AgentOnboardingSummary | null;
|
||
agentOnboardingEnabled?: boolean;
|
||
existingAgents?: Agent[];
|
||
onPrefillDraft?: (draft: AgentOnboardingSummary | null) => void;
|
||
}
|
||
|
||
const AGENT_ROLES: { value: AgentCapability; icon: string }[] = [
|
||
{ value: "triage", icon: "⊕" },
|
||
{ value: "executor", icon: "▶" },
|
||
{ value: "reviewer", icon: "⊙" },
|
||
{ value: "merger", icon: "⊞" },
|
||
{ value: "scheduler", icon: "◷" },
|
||
{ value: "engineer", icon: "⎔" },
|
||
{ value: "custom", icon: "✦" },
|
||
];
|
||
|
||
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high";
|
||
|
||
/** Set of valid AgentCapability values for mapping generated roles */
|
||
const VALID_CAPABILITIES = new Set<string>(["triage", "executor", "reviewer", "merger", "scheduler", "engineer", "custom"]);
|
||
|
||
interface RuntimeConfig {
|
||
model: string;
|
||
thinkingLevel: ThinkingLevel;
|
||
maxTurns: number;
|
||
}
|
||
|
||
type StepZeroTab = "presets" | "custom";
|
||
|
||
export function NewAgentDialog({
|
||
isOpen,
|
||
onClose,
|
||
onCreated,
|
||
projectId,
|
||
prefillDraft = null,
|
||
agentOnboardingEnabled = false,
|
||
existingAgents = [],
|
||
onPrefillDraft,
|
||
}: NewAgentDialogProps) {
|
||
const { t } = useTranslation("app");
|
||
|
||
const getRoleLabel = (value: AgentCapability): string => {
|
||
const labels: Record<AgentCapability, string> = {
|
||
triage: t("agents.roleTriage", "Triage"),
|
||
executor: t("agents.roleExecutor", "Executor"),
|
||
reviewer: t("agents.roleReviewer", "Reviewer"),
|
||
merger: t("agents.roleMerger", "Merger"),
|
||
scheduler: t("agents.roleScheduler", "Scheduler"),
|
||
engineer: t("agents.roleEngineer", "Engineer"),
|
||
custom: t("agents.roleCustom", "Custom"),
|
||
};
|
||
return labels[value] ?? value;
|
||
};
|
||
|
||
const [step, setStep] = useState(0);
|
||
const [stepZeroTab, setStepZeroTab] = useState<StepZeroTab>("presets");
|
||
const [name, setName] = useState("");
|
||
const [title, setTitle] = useState("");
|
||
const [icon, setIcon] = useState("");
|
||
const [role, setRole] = useState<AgentCapability>("custom");
|
||
const [reportsTo, setReportsTo] = useState("");
|
||
const [instructionsPath, setInstructionsPath] = useState("");
|
||
const [instructionsText, setInstructionsText] = useState("");
|
||
const [heartbeatProcedurePath, setHeartbeatProcedurePath] = useState("");
|
||
const [soul, setSoul] = useState("");
|
||
const [memory, setMemory] = useState("");
|
||
const [runtimeConfig, setRuntimeConfig] = useState<RuntimeConfig>({
|
||
model: "",
|
||
thinkingLevel: "off",
|
||
maxTurns: 1000,
|
||
});
|
||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null);
|
||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [isGenerationModalOpen, setIsGenerationModalOpen] = useState(false);
|
||
const [isInterviewOpen, setIsInterviewOpen] = useState(false);
|
||
|
||
// Model dropdown state
|
||
const { favoriteProviders, favoriteModels, toggleFavoriteProvider, toggleFavoriteModel } = useFavorites();
|
||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||
const [modelsLoading, setModelsLoading] = useState(false);
|
||
const [runtimeMode, setRuntimeMode] = useState<"model" | "runtime">("model");
|
||
const [selectedRuntimeId, setSelectedRuntimeId] = useState("");
|
||
const [availableRuntimes, setAvailableRuntimes] = useState<PluginRuntimeInfo[]>([]);
|
||
const [runtimesLoading, setRuntimesLoading] = useState(false);
|
||
|
||
// Manager dropdown state
|
||
const [availableManagers, setAvailableManagers] = useState<Agent[]>([]);
|
||
const [managersLoading, setManagersLoading] = useState(false);
|
||
|
||
// Load models when dialog opens — guard prevents async setState after test assertions
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
setModelsLoading(true);
|
||
fetchModels()
|
||
.then((response) => {
|
||
setAvailableModels(response.models);
|
||
})
|
||
.catch(() => {
|
||
// Gracefully handle — dropdown will show empty list
|
||
})
|
||
.finally(() => setModelsLoading(false));
|
||
}, [isOpen]);
|
||
|
||
// Load manager options when dialog opens
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
setManagersLoading(true);
|
||
setAvailableManagers([]);
|
||
fetchAgents(undefined, projectId)
|
||
.then((agents) => {
|
||
setAvailableManagers(agents);
|
||
})
|
||
.catch(() => {
|
||
// Gracefully handle — manager selector will show "No manager" only
|
||
setAvailableManagers([]);
|
||
})
|
||
.finally(() => setManagersLoading(false));
|
||
}, [isOpen, projectId]);
|
||
|
||
// Load plugin runtimes when dialog opens
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
const fetchPluginRuntimes = apiModule.fetchPluginRuntimes;
|
||
if (typeof fetchPluginRuntimes !== "function") {
|
||
setAvailableRuntimes([]);
|
||
setRuntimesLoading(false);
|
||
return;
|
||
}
|
||
|
||
setRuntimesLoading(true);
|
||
setAvailableRuntimes([]);
|
||
fetchPluginRuntimes(projectId)
|
||
.then((runtimes) => {
|
||
setAvailableRuntimes(runtimes);
|
||
})
|
||
.catch(() => {
|
||
// Gracefully handle — runtime selector will show empty state
|
||
setAvailableRuntimes([]);
|
||
})
|
||
.finally(() => setRuntimesLoading(false));
|
||
}, [isOpen, projectId]);
|
||
|
||
// Selected model in "provider/modelId" format, or "" for default
|
||
const selectedModel = runtimeConfig.model.includes("/")
|
||
? runtimeConfig.model
|
||
: "";
|
||
|
||
const handleGenerated = useCallback((spec: AgentGenerationSpec) => {
|
||
// Map generated role to AgentCapability, default to "custom" if unrecognized
|
||
const mappedRole = VALID_CAPABILITIES.has(spec.role)
|
||
? (spec.role as AgentCapability)
|
||
: "custom";
|
||
|
||
setName(spec.title);
|
||
setTitle(spec.description);
|
||
setIcon(spec.icon);
|
||
setRole(mappedRole);
|
||
// Map generated systemPrompt to instructionsText
|
||
setInstructionsText(spec.systemPrompt);
|
||
setRuntimeConfig(c => ({
|
||
...c,
|
||
thinkingLevel: spec.thinkingLevel,
|
||
maxTurns: spec.maxTurns,
|
||
}));
|
||
setIsGenerationModalOpen(false);
|
||
// Advance to Step 1 so user can review model selection
|
||
setStep(1);
|
||
}, []);
|
||
|
||
const handleModelChange = useCallback((value: string) => {
|
||
// value is "provider/modelId" or "" for default
|
||
setRuntimeConfig(c => ({ ...c, model: value }));
|
||
}, []);
|
||
|
||
const handleRuntimeModeChange = useCallback((mode: "model" | "runtime") => {
|
||
setRuntimeMode(mode);
|
||
if (mode === "model") {
|
||
setSelectedRuntimeId("");
|
||
}
|
||
}, []);
|
||
|
||
|
||
const handlePresetSelect = useCallback((preset: AgentPreset) => {
|
||
setSelectedPresetId(preset.id);
|
||
setName(preset.name);
|
||
setIcon(preset.icon);
|
||
setTitle(preset.description ?? preset.title);
|
||
setRole(preset.role);
|
||
setSoul(preset.soul ?? "");
|
||
setInstructionsText(preset.instructionsText ?? "");
|
||
// Advance to Step 1 so user can review model selection
|
||
setStep(1);
|
||
}, []);
|
||
|
||
const applyDraftToForm = useCallback((draft: AgentOnboardingSummary) => {
|
||
const runtimeHint = draft.runtimeHint?.trim() ?? "";
|
||
const modelSelection = draft.model?.trim() || draft.modelHint?.trim() || "";
|
||
|
||
setStep(1);
|
||
setStepZeroTab("custom");
|
||
setName(draft.name ?? "");
|
||
setTitle(draft.title ?? "");
|
||
setIcon(draft.icon ?? "");
|
||
setRole((VALID_CAPABILITIES.has(draft.role) ? draft.role : "custom") as AgentCapability);
|
||
setReportsTo(draft.reportsTo ?? "");
|
||
setInstructionsText(draft.instructionsText ?? "");
|
||
setHeartbeatProcedurePath(draft.heartbeatProcedurePath ?? "");
|
||
setSoul(draft.soul ?? "");
|
||
setMemory(draft.memory ?? "");
|
||
setSelectedSkills(Array.isArray(draft.skills) ? draft.skills : []);
|
||
setRuntimeConfig((current) => ({
|
||
...current,
|
||
model: runtimeHint ? "" : modelSelection,
|
||
thinkingLevel: draft.thinkingLevel ?? current.thinkingLevel,
|
||
maxTurns: draft.maxTurns ?? current.maxTurns,
|
||
}));
|
||
if (runtimeHint) {
|
||
setRuntimeMode("runtime");
|
||
setSelectedRuntimeId(runtimeHint);
|
||
} else {
|
||
setRuntimeMode("model");
|
||
setSelectedRuntimeId("");
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen || !prefillDraft) return;
|
||
applyDraftToForm(prefillDraft);
|
||
}, [isOpen, prefillDraft, applyDraftToForm]);
|
||
|
||
if (!isOpen) return null;
|
||
|
||
const handleClose = () => {
|
||
setStep(0);
|
||
setStepZeroTab("presets");
|
||
setName("");
|
||
setTitle("");
|
||
setIcon("");
|
||
setRole("custom");
|
||
setReportsTo("");
|
||
setInstructionsPath("");
|
||
setInstructionsText("");
|
||
setHeartbeatProcedurePath("");
|
||
setSoul("");
|
||
setMemory("");
|
||
setRuntimeConfig({ model: "", thinkingLevel: "off", maxTurns: 1000 });
|
||
setRuntimeMode("model");
|
||
setSelectedRuntimeId("");
|
||
setSelectedPresetId(null);
|
||
setSelectedSkills([]);
|
||
setError(null);
|
||
setIsGenerationModalOpen(false);
|
||
setIsInterviewOpen(false);
|
||
onClose();
|
||
};
|
||
|
||
const handleCreate = async () => {
|
||
if (!name.trim()) return;
|
||
setIsSubmitting(true);
|
||
setError(null);
|
||
try {
|
||
const runtimeCfg: Record<string, unknown> = {};
|
||
if (runtimeMode === "runtime") {
|
||
if (selectedRuntimeId.trim()) runtimeCfg.runtimeHint = selectedRuntimeId.trim();
|
||
} else if (runtimeConfig.model.trim()) {
|
||
runtimeCfg.model = runtimeConfig.model.trim();
|
||
}
|
||
if (runtimeConfig.thinkingLevel !== "off") runtimeCfg.thinkingLevel = runtimeConfig.thinkingLevel;
|
||
if (runtimeConfig.maxTurns !== 1000) runtimeCfg.maxTurns = runtimeConfig.maxTurns;
|
||
await createAgent({
|
||
name: name.trim(),
|
||
role,
|
||
...(title.trim() ? { title: title.trim() } : {}),
|
||
...(icon.trim() ? { icon: icon.trim() } : {}),
|
||
...(reportsTo.trim() ? { reportsTo: reportsTo.trim() } : {}),
|
||
...(instructionsPath.trim() ? { instructionsPath: instructionsPath.trim() } : {}),
|
||
...(instructionsText.trim() ? { instructionsText: instructionsText.trim() } : {}),
|
||
...(heartbeatProcedurePath.trim() ? { heartbeatProcedurePath: heartbeatProcedurePath.trim() } : {}),
|
||
...(soul.trim() ? { soul: soul.trim() } : {}),
|
||
...(memory.trim() ? { memory: memory.trim() } : {}),
|
||
...(Object.keys(runtimeCfg).length > 0 ? { runtimeConfig: runtimeCfg } : {}),
|
||
...(selectedSkills.length > 0 ? { metadata: { skills: selectedSkills } } : {}),
|
||
}, projectId);
|
||
handleClose();
|
||
onCreated();
|
||
} catch (err: unknown) {
|
||
setError(err instanceof Error ? err.message : t("agents.createError", "Failed to create agent"));
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const selectedRole = AGENT_ROLES.find(r => r.value === role);
|
||
const selectedReportsToId = reportsTo.trim();
|
||
const selectedManager = selectedReportsToId
|
||
? availableManagers.find((manager) => manager.id === selectedReportsToId)
|
||
: undefined;
|
||
const selectedRuntime = selectedRuntimeId
|
||
? availableRuntimes.find((runtime) => runtime.runtimeId === selectedRuntimeId)
|
||
: undefined;
|
||
|
||
const renderRuntimeSourceSection = (sourceLabelId: string) => (
|
||
<>
|
||
<div className="agent-dialog-field">
|
||
<label id={sourceLabelId}>{t("agents.runtimeSource", "Runtime Source")}</label>
|
||
<div className="agent-runtime-mode-toggle" role="radiogroup" aria-labelledby={sourceLabelId}>
|
||
<label className={`agent-runtime-mode-option${runtimeMode === "model" ? " agent-runtime-mode-option--active" : ""}`}>
|
||
<input
|
||
type="radio"
|
||
name={sourceLabelId}
|
||
value="model"
|
||
checked={runtimeMode === "model"}
|
||
onChange={() => handleRuntimeModeChange("model")}
|
||
/>
|
||
<span>{t("agents.runtimeSourceBuiltIn", "Built-in Model")}</span>
|
||
</label>
|
||
<label className={`agent-runtime-mode-option${runtimeMode === "runtime" ? " agent-runtime-mode-option--active" : ""}`}>
|
||
<input
|
||
type="radio"
|
||
name={sourceLabelId}
|
||
value="runtime"
|
||
checked={runtimeMode === "runtime"}
|
||
onChange={() => handleRuntimeModeChange("runtime")}
|
||
/>
|
||
<span>{t("agents.runtimeSourcePlugin", "Plugin Runtime")}</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
{runtimeMode === "model" ? (
|
||
<div className="agent-dialog-field">
|
||
<label>{t("agents.model", "Model")}</label>
|
||
{modelsLoading ? (
|
||
<div className="agent-dialog-loading">{t("agents.loadingModels", "Loading models…")}</div>
|
||
) : (
|
||
<CustomModelDropdown
|
||
id="agent-model"
|
||
label={t("agents.model", "Model")}
|
||
value={selectedModel}
|
||
onChange={handleModelChange}
|
||
models={availableModels}
|
||
placeholder={t("agents.modelPlaceholder", "Select a model…")}
|
||
favoriteProviders={favoriteProviders}
|
||
onToggleFavorite={toggleFavoriteProvider}
|
||
favoriteModels={favoriteModels}
|
||
onToggleModelFavorite={toggleFavoriteModel}
|
||
/>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="agent-dialog-field">
|
||
<label htmlFor="agent-runtime-hint">{t("agents.runtime", "Runtime")}</label>
|
||
{runtimesLoading ? (
|
||
<div className="agent-dialog-loading">{t("agents.loadingRuntimes", "Loading runtimes…")}</div>
|
||
) : (
|
||
<select
|
||
id="agent-runtime-hint"
|
||
className="select"
|
||
value={selectedRuntimeId}
|
||
onChange={e => setSelectedRuntimeId(e.target.value)}
|
||
>
|
||
<option value="">
|
||
{availableRuntimes.length > 0 ? t("agents.runtimePlaceholder", "Select a plugin runtime…") : t("agents.runtimeEmpty", "No plugin runtimes available")}
|
||
</option>
|
||
{availableRuntimes.map((runtime) => (
|
||
<option key={`${runtime.pluginId}:${runtime.runtimeId}`} value={runtime.runtimeId}>
|
||
{runtime.description ? `${runtime.name} — ${runtime.description}` : runtime.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
// Render through a portal to document.body so the overlay escapes any
|
||
// ancestor stacking context / `overflow: hidden`. Without this, `position:
|
||
// fixed` on the overlay was being trapped under .agents-view, so the
|
||
// dialog rendered with its top hidden behind the in-page Agents header on
|
||
// mobile (the header isn't taller than the dialog top — it's just stacked
|
||
// above it because the dialog couldn't escape its container).
|
||
return createPortal(
|
||
<div className="agent-dialog-overlay" onClick={(e) => { if (e.target === e.currentTarget) handleClose(); }}>
|
||
<div className="agent-dialog" role="dialog" aria-modal="true" aria-label={t("agents.dialogAriaLabel", "Create new agent")}>
|
||
{/* Header */}
|
||
<div className="agent-dialog-header">
|
||
<span className="agent-dialog-header-title">{t("agents.dialogTitle", "New Agent")}</span>
|
||
<button
|
||
className="btn-icon"
|
||
onClick={handleClose}
|
||
aria-label={t("agents.closeAriaLabel", "Close")}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
{/* Step indicator */}
|
||
<div className="agent-dialog-steps">
|
||
{[0, 1, 2].map(i => (
|
||
<div
|
||
key={i}
|
||
className={`agent-dialog-step${i === step ? " active" : i < step ? " completed" : ""}`}
|
||
aria-label={t("agents.stepAriaLabel", "Step {{step}}", { step: i + 1 })}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div className="agent-dialog-body">
|
||
{step === 0 && (
|
||
<div>
|
||
{agentOnboardingEnabled && (
|
||
<div className="agent-dialog-step-zero-actions">
|
||
<button
|
||
type="button"
|
||
className="btn agent-dialog-interview-btn"
|
||
onClick={() => setIsInterviewOpen(true)}
|
||
>
|
||
{t("agents.aiInterview", "AI Interview")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="agent-dialog-tabs" role="tablist" aria-label={t("agents.setupModeAriaLabel", "Agent setup mode")}>
|
||
<button
|
||
id="agent-dialog-tab-presets"
|
||
type="button"
|
||
role="tab"
|
||
aria-controls="agent-dialog-panel-presets"
|
||
aria-selected={stepZeroTab === "presets"}
|
||
tabIndex={stepZeroTab === "presets" ? 0 : -1}
|
||
className={`agent-dialog-tab${stepZeroTab === "presets" ? " active" : ""}`}
|
||
onClick={() => setStepZeroTab("presets")}
|
||
data-testid="agent-dialog-tab-presets"
|
||
>
|
||
{t("agents.tabPresets", "Preset personas")}
|
||
</button>
|
||
<button
|
||
id="agent-dialog-tab-custom"
|
||
type="button"
|
||
role="tab"
|
||
aria-controls="agent-dialog-panel-custom"
|
||
aria-selected={stepZeroTab === "custom"}
|
||
tabIndex={stepZeroTab === "custom" ? 0 : -1}
|
||
className={`agent-dialog-tab${stepZeroTab === "custom" ? " active" : ""}`}
|
||
onClick={() => setStepZeroTab("custom")}
|
||
data-testid="agent-dialog-tab-custom"
|
||
>
|
||
{t("agents.tabCustom", "Custom agent")}
|
||
</button>
|
||
</div>
|
||
|
||
{stepZeroTab === "presets" && (
|
||
<div
|
||
id="agent-dialog-panel-presets"
|
||
className="agent-dialog-tab-panel"
|
||
role="tabpanel"
|
||
aria-labelledby="agent-dialog-tab-presets"
|
||
>
|
||
<div className="agent-presets">
|
||
<div className="agent-presets-header">
|
||
{t("agents.presetsHeader", "Choose a preset persona to prefill role, identity, soul, and instructions")}
|
||
</div>
|
||
<div className="agent-presets-grid">
|
||
{AGENT_PRESETS.map(preset => (
|
||
<button
|
||
key={preset.id}
|
||
type="button"
|
||
className={`agent-preset-card${selectedPresetId === preset.id ? " selected" : ""}`}
|
||
data-testid={`preset-${preset.id}`}
|
||
onClick={() => handlePresetSelect(preset)}
|
||
title={preset.title}
|
||
>
|
||
<span className="agent-preset-icon"><AgentAvatar agent={{ id: preset.id, icon: preset.icon, name: preset.name }} size={28} /></span>
|
||
<span className="agent-preset-name">{preset.name}</span>
|
||
<span className="agent-preset-role">{preset.role}</span>
|
||
{preset.description && (
|
||
<span className="agent-preset-description">{preset.description}</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{stepZeroTab === "custom" && (
|
||
<div
|
||
id="agent-dialog-panel-custom"
|
||
className="agent-dialog-tab-panel"
|
||
role="tabpanel"
|
||
aria-labelledby="agent-dialog-tab-custom"
|
||
>
|
||
<div className="agent-dialog-section">
|
||
<div className="agent-dialog-section-header">{t("agents.sectionIdentity", "Identity")}</div>
|
||
<div className="agent-dialog-field">
|
||
<label htmlFor="agent-name">{t("agents.fieldName", "Name")} {!selectedPresetId && <span className="agent-dialog-required">*</span>}</label>
|
||
<input
|
||
id="agent-name"
|
||
type="text"
|
||
className="input"
|
||
placeholder={t("agents.namePlaceholder", "e.g. Frontend Reviewer")}
|
||
value={name}
|
||
onChange={e => setName(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="agent-dialog-field agent-dialog-field--title">
|
||
<label htmlFor="agent-title">{t("agents.fieldTitle", "Title")} <span className="agent-dialog-optional">{t("agents.optional", "(optional)")}</span></label>
|
||
<input
|
||
id="agent-title"
|
||
type="text"
|
||
className="input"
|
||
placeholder={t("agents.titlePlaceholder", "e.g. Senior Code Reviewer")}
|
||
value={title}
|
||
onChange={e => setTitle(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="agent-dialog-field">
|
||
<label htmlFor="agent-icon">{t("agents.fieldIcon", "Icon")} <span className="agent-dialog-optional">{t("agents.optional", "(optional)")}</span></label>
|
||
<input
|
||
id="agent-icon"
|
||
type="text"
|
||
className="input"
|
||
placeholder={t("agents.iconPlaceholder", "e.g. 🤖")}
|
||
value={icon}
|
||
onChange={e => setIcon(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="agent-dialog-field">
|
||
<label>{t("agents.fieldRole", "Role")}</label>
|
||
<div className="agent-role-grid">
|
||
{AGENT_ROLES.map(r => (
|
||
<button
|
||
key={r.value}
|
||
type="button"
|
||
className={`agent-role-option${role === r.value ? " selected" : ""}`}
|
||
onClick={() => setRole(r.value)}
|
||
>
|
||
<span className="agent-role-option-icon">{r.icon}</span>
|
||
<span className="agent-role-option-label">{getRoleLabel(r.value)}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="agent-dialog-section">
|
||
<div className="agent-dialog-section-header">{t("agents.sectionConfiguration", "Configuration")}</div>
|
||
<div className="agent-dialog-field">
|
||
<label htmlFor="agent-reports-to">{t("agents.fieldReportsTo", "Reports To")} <span className="agent-dialog-optional">{t("agents.optional", "(optional)")}</span></label>
|
||
<select
|
||
id="agent-reports-to"
|
||
className="select"
|
||
value={reportsTo}
|
||
onChange={e => setReportsTo(e.target.value)}
|
||
disabled={managersLoading}
|
||
>
|
||
<option value="">{t("agents.noManager", "No manager")}</option>
|
||
{availableManagers.map((manager) => (
|
||
<option key={manager.id} value={manager.id}>
|
||
{manager.name} ({manager.id})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="agent-dialog-field">
|
||
<label htmlFor="agent-soul">{t("agents.fieldSoul", "Soul")} <span className="agent-dialog-optional">{t("agents.optional", "(optional)")}</span></label>
|
||
<textarea
|
||
id="agent-soul"
|
||
className="input"
|
||
rows={2}
|
||
placeholder={t("agents.soulPlaceholder", "Describe the agent's personality and communication style...")}
|
||
value={soul}
|
||
onChange={e => setSoul(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="agent-dialog-field">
|
||
<label htmlFor="agent-memory">{t("agents.fieldMemory", "Agent Memory")} <span className="agent-dialog-optional">{t("agents.optional", "(optional)")}</span></label>
|
||
<textarea
|
||
id="agent-memory"
|
||
className="input"
|
||
rows={2}
|
||
placeholder={t("agents.memoryPlaceholder", "Private to this agent — durable preferences, operating habits, and context it should carry across tasks...")}
|
||
value={memory}
|
||
onChange={e => setMemory(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="agent-dialog-field">
|
||
<label htmlFor="agent-instructions-path">{t("agents.fieldInstructionsPath", "Instructions Path")} <span className="agent-dialog-optional">{t("agents.optional", "(optional)")}</span></label>
|
||
<input
|
||
id="agent-instructions-path"
|
||
type="text"
|
||
className="input"
|
||
placeholder={t("agents.instructionsPathPlaceholder", "e.g. .fusion/agents/reviewer.md")}
|
||
value={instructionsPath}
|
||
onChange={e => setInstructionsPath(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="agent-dialog-field">
|
||
<label htmlFor="agent-heartbeat-procedure-path">{t("agents.fieldHeartbeatPath", "Heartbeat Procedure Path")} <span className="agent-dialog-optional">{t("agents.optional", "(optional)")}</span></label>
|
||
<input
|
||
id="agent-heartbeat-procedure-path"
|
||
type="text"
|
||
className="input"
|
||
placeholder={t("agents.heartbeatPathPlaceholder", "e.g. .fusion/agents/ceo-agent2736/HEARTBEAT.md")}
|
||
value={heartbeatProcedurePath}
|
||
onChange={e => setHeartbeatProcedurePath(e.target.value)}
|
||
/>
|
||
<p className="agent-dialog-optional agent-dialog-field-hint">
|
||
{t("agents.heartbeatPathHint", "Path to the agent's heartbeat procedure path, typically .fusion/agents/ceo-agent2736/HEARTBEAT.md. Legacy id-only default paths still work.")}
|
||
</p>
|
||
</div>
|
||
<div className="agent-dialog-field">
|
||
<label htmlFor="agent-instructions-text">{t("agents.fieldInstructionsText", "Inline Instructions")} <span className="agent-dialog-optional">{t("agents.optional", "(optional)")}</span></label>
|
||
<textarea
|
||
id="agent-instructions-text"
|
||
className="input"
|
||
rows={4}
|
||
placeholder={t("agents.instructionsTextPlaceholder", "Add custom behavior instructions...")}
|
||
value={instructionsText}
|
||
onChange={e => setInstructionsText(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="agent-dialog-section">
|
||
<div className="agent-dialog-section-header">{t("agents.sectionRuntime", "Runtime")}</div>
|
||
{renderRuntimeSourceSection("agent-runtime-source-step-0")}
|
||
</div>
|
||
{/* AI-assisted generation */}
|
||
<div className="agent-dialog-ai-generate">
|
||
<button
|
||
type="button"
|
||
className="btn btn--ai-generate"
|
||
onClick={() => setIsGenerationModalOpen(true)}
|
||
>
|
||
<span>✨</span>
|
||
{t("agents.generateWithAI", "Generate with AI")}
|
||
</button>
|
||
<p className="agent-dialog-ai-hint">
|
||
{t("agents.generateHint", "Describe your agent's role and let AI generate a specification")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{step === 1 && (
|
||
<div>
|
||
{renderRuntimeSourceSection("agent-runtime-source-step-1")}
|
||
<div className="agent-dialog-field">
|
||
<label htmlFor="agent-thinking">{t("agents.fieldThinkingLevel", "Thinking Level")}</label>
|
||
<select
|
||
id="agent-thinking"
|
||
className="select"
|
||
value={runtimeConfig.thinkingLevel}
|
||
onChange={e => setRuntimeConfig(c => ({ ...c, thinkingLevel: e.target.value as ThinkingLevel }))}
|
||
>
|
||
<option value="off">{t("agents.thinkingOff", "Off")}</option>
|
||
<option value="minimal">{t("agents.thinkingMinimal", "Minimal")}</option>
|
||
<option value="low">{t("agents.thinkingLow", "Low")}</option>
|
||
<option value="medium">{t("agents.thinkingMedium", "Medium")}</option>
|
||
<option value="high">{t("agents.thinkingHigh", "High")}</option>
|
||
</select>
|
||
</div>
|
||
<div className="agent-dialog-field">
|
||
<label htmlFor="agent-max-turns">{t("agents.fieldMaxTurns", "Max Turns")}</label>
|
||
<input
|
||
id="agent-max-turns"
|
||
type="number"
|
||
className="input"
|
||
min={1}
|
||
max={2000}
|
||
value={runtimeConfig.maxTurns}
|
||
onChange={e => setRuntimeConfig(c => ({ ...c, maxTurns: Math.max(1, parseInt(e.target.value, 10) || 1) }))}
|
||
/>
|
||
</div>
|
||
<div className="agent-dialog-field">
|
||
<SkillMultiselect
|
||
id="agent-skills"
|
||
label={t("agents.fieldSkills", "Skills")}
|
||
value={selectedSkills}
|
||
onChange={setSelectedSkills}
|
||
projectId={projectId}
|
||
/>
|
||
<p className="agent-dialog-optional agent-dialog-skills-hint">
|
||
{t("agents.skillsHint", "Optional skills to assign to this agent")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{step === 2 && (
|
||
<div>
|
||
<p className="agent-dialog-info">
|
||
{t("agents.reviewHint", "Review your agent configuration before creating.")}
|
||
</p>
|
||
<div className="agent-dialog-summary">
|
||
<div className="agent-dialog-summary-row agent-dialog-summary-row--editable">
|
||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-name">{t("agents.fieldName", "Name")}</label>
|
||
<input
|
||
id="agent-review-name"
|
||
type="text"
|
||
className="input"
|
||
placeholder={t("agents.namePlaceholder", "e.g. Frontend Reviewer")}
|
||
value={name}
|
||
onChange={e => setName(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="agent-dialog-summary-row agent-dialog-summary-row--editable agent-dialog-summary-row--title">
|
||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-title">{t("agents.fieldTitle", "Title")}</label>
|
||
<input
|
||
id="agent-review-title"
|
||
type="text"
|
||
className="input"
|
||
placeholder={t("agents.titlePlaceholder", "e.g. Senior Code Reviewer")}
|
||
value={title}
|
||
onChange={e => setTitle(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="agent-dialog-summary-row">
|
||
<span className="agent-dialog-summary-row-label">{t("agents.fieldRole", "Role")}</span>
|
||
<span>{selectedRole?.icon} {selectedRole ? getRoleLabel(selectedRole.value) : ""}</span>
|
||
</div>
|
||
{selectedReportsToId && (
|
||
<div className="agent-dialog-summary-row">
|
||
<span className="agent-dialog-summary-row-label">{t("agents.fieldReportsTo", "Reports To")}</span>
|
||
<span>
|
||
{selectedManager
|
||
? `${selectedManager.name} (${selectedManager.id})`
|
||
: selectedReportsToId}
|
||
</span>
|
||
</div>
|
||
)}
|
||
<div className="agent-dialog-summary-row agent-dialog-summary-row--editable">
|
||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-soul">{t("agents.fieldSoul", "Soul")}</label>
|
||
<textarea
|
||
id="agent-review-soul"
|
||
className="input"
|
||
rows={2}
|
||
placeholder={t("agents.soulPlaceholder", "Describe the agent's personality and communication style...")}
|
||
value={soul}
|
||
onChange={e => setSoul(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="agent-dialog-summary-row agent-dialog-summary-row--editable">
|
||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-heartbeat-procedure-path">{t("agents.fieldHeartbeatPath", "Heartbeat Procedure Path")}</label>
|
||
<input
|
||
id="agent-review-heartbeat-procedure-path"
|
||
type="text"
|
||
className="input"
|
||
placeholder={t("agents.heartbeatPathPlaceholder", "e.g. .fusion/agents/ceo-agent2736/HEARTBEAT.md")}
|
||
value={heartbeatProcedurePath}
|
||
onChange={e => setHeartbeatProcedurePath(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="agent-dialog-summary-row agent-dialog-summary-row--editable">
|
||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-instructions-path">{t("agents.fieldInstructionsPath", "Instructions Path")}</label>
|
||
<input
|
||
id="agent-review-instructions-path"
|
||
type="text"
|
||
className="input"
|
||
placeholder={t("agents.instructionsPathPlaceholder", "e.g. .fusion/agents/reviewer.md")}
|
||
value={instructionsPath}
|
||
onChange={e => setInstructionsPath(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="agent-dialog-summary-row agent-dialog-summary-row--editable">
|
||
<label className="agent-dialog-summary-row-label" htmlFor="agent-review-instructions-text">{t("agents.fieldInstructionsText", "Inline Instructions")}</label>
|
||
<textarea
|
||
id="agent-review-instructions-text"
|
||
className="input"
|
||
rows={4}
|
||
placeholder={t("agents.instructionsTextPlaceholder", "Add custom behavior instructions...")}
|
||
value={instructionsText}
|
||
onChange={e => setInstructionsText(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="agent-dialog-summary-row">
|
||
<span className="agent-dialog-summary-row-label">{runtimeMode === "runtime" ? t("agents.runtime", "Runtime") : t("agents.model", "Model")}</span>
|
||
<span>
|
||
{runtimeMode === "runtime" ? (
|
||
selectedRuntime ? (
|
||
selectedRuntime.name
|
||
) : (
|
||
<em className="agent-dialog-summary-row-value--muted">{t("agents.notSelected", "Not selected")}</em>
|
||
)
|
||
) : selectedModel ? (
|
||
<>
|
||
<ProviderIcon provider={selectedModel.split("/")[0]} size="sm" />
|
||
{" "}
|
||
{(() => {
|
||
const slashIdx = selectedModel.indexOf("/");
|
||
const provider = selectedModel.slice(0, slashIdx);
|
||
const modelId = selectedModel.slice(slashIdx + 1);
|
||
const model = availableModels.find(m => m.provider === provider && m.id === modelId);
|
||
return model?.name || selectedModel;
|
||
})()}
|
||
</>
|
||
) : (
|
||
<em className="agent-dialog-summary-row-value--muted">{t("agents.modelDefault", "default")}</em>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<div className="agent-dialog-summary-row">
|
||
<span className="agent-dialog-summary-row-label">{t("agents.fieldThinking", "Thinking")}</span>
|
||
<span className="agent-dialog-summary-row-value--capitalize">{runtimeConfig.thinkingLevel}</span>
|
||
</div>
|
||
<div className="agent-dialog-summary-row">
|
||
<span className="agent-dialog-summary-row-label">{t("agents.fieldMaxTurns", "Max Turns")}</span>
|
||
<span>{runtimeConfig.maxTurns}</span>
|
||
</div>
|
||
{selectedSkills.length > 0 && (
|
||
<div className="agent-dialog-summary-row">
|
||
<span className="agent-dialog-summary-row-label">{t("agents.fieldSkills", "Skills")}</span>
|
||
<span>{t("agents.skillsSelected", "{{count}} skill selected", { count: selectedSkills.length, defaultValue_one: "{{count}} skill selected", defaultValue_other: "{{count}} skills selected" })}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{error && (
|
||
<p className="agent-dialog-error">{error}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="agent-dialog-footer">
|
||
{step > 0 && (
|
||
<button className="btn" onClick={() => setStep(s => s - 1)} disabled={isSubmitting}>
|
||
{t("agents.back", "Back")}
|
||
</button>
|
||
)}
|
||
<button className="btn" onClick={handleClose} disabled={isSubmitting}>
|
||
{t("agents.cancel", "Cancel")}
|
||
</button>
|
||
{step < 2 ? (
|
||
<button
|
||
className="btn btn--primary"
|
||
onClick={() => setStep(s => s + 1)}
|
||
disabled={step === 0 && !name.trim() && !selectedPresetId}
|
||
>
|
||
{t("agents.next", "Next")}
|
||
</button>
|
||
) : (
|
||
<button
|
||
className="btn btn--primary"
|
||
onClick={() => void handleCreate()}
|
||
disabled={isSubmitting || !name.trim()}
|
||
>
|
||
{isSubmitting ? t("agents.creating", "Creating...") : t("agents.create", "Create")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI-assisted agent generation modal */}
|
||
<AgentGenerationModal
|
||
isOpen={isGenerationModalOpen}
|
||
onClose={() => setIsGenerationModalOpen(false)}
|
||
onGenerated={handleGenerated}
|
||
projectId={projectId}
|
||
/>
|
||
|
||
<ExperimentalAgentOnboardingModal
|
||
isOpen={isInterviewOpen}
|
||
onClose={() => setIsInterviewOpen(false)}
|
||
onUseDraft={(draft) => {
|
||
onPrefillDraft?.(draft);
|
||
if (!onPrefillDraft) {
|
||
applyDraftToForm(draft);
|
||
}
|
||
setIsInterviewOpen(false);
|
||
}}
|
||
projectId={projectId}
|
||
existingAgents={existingAgents}
|
||
mode="create"
|
||
/>
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|