diff --git a/src/renderer/components/organization/AgentEditor.tsx b/src/renderer/components/organization/AgentEditor.tsx index 4131f6af..ecabb2c8 100644 --- a/src/renderer/components/organization/AgentEditor.tsx +++ b/src/renderer/components/organization/AgentEditor.tsx @@ -8,6 +8,7 @@ export function AgentEditor({ agents, companyId, onSaved, + onCancel, onDeleted, }: { agent: AgentRecord; @@ -16,6 +17,7 @@ export function AgentEditor({ agents: AgentRecord[]; companyId: string; onSaved: () => Promise; + onCancel: () => void; onDeleted: () => Promise; }) { return ( @@ -27,6 +29,7 @@ export function AgentEditor({ agents={agents} agent={agent} onSaved={onSaved} + onCancel={onCancel} onDeleted={onDeleted} /> ); diff --git a/src/renderer/components/organization/OrgChart.tsx b/src/renderer/components/organization/OrgChart.tsx index 4e093768..2a73425e 100644 --- a/src/renderer/components/organization/OrgChart.tsx +++ b/src/renderer/components/organization/OrgChart.tsx @@ -131,10 +131,15 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR const isActive = node.agent.status === "active" || isRunning; const isError = node.agent.status === "error"; const isPending = node.agent.status === "pending_approval"; - const isCEO = node.children.length > 0 && !node.agent.reportsTo; + // Binary hierarchy: the root (no reportsTo) gets a bold primary bar; + // everyone else gets a hairline neutral bar. Managerial rank is + // already communicated by tree position — duplicating it via a + // color (previously "warning" orange, which read as an alert + // rather than a rank) was misleading. + const isRoot = !node.agent.reportsTo; return (
onSelectAgent?.(node.agent.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelectAgent?.(node.agent.id); } }}> -
0 ? "h-[3px] bg-warning" : "h-[2px] bg-[color:var(--muted-foreground)] opacity-30"}`} /> +
{initials}
diff --git a/src/renderer/components/organization/agent-form/AgentForm.tsx b/src/renderer/components/organization/agent-form/AgentForm.tsx index 1e2a92d0..21462f70 100644 --- a/src/renderer/components/organization/agent-form/AgentForm.tsx +++ b/src/renderer/components/organization/agent-form/AgentForm.tsx @@ -20,6 +20,7 @@ interface AgentFormProps { agents: AgentRecord[]; agent?: AgentRecord; onSaved: () => Promise; + onCancel?: () => void; onDeleted?: () => Promise; } @@ -31,6 +32,7 @@ export function AgentForm({ agents, agent, onSaved, + onCancel, onDeleted, }: AgentFormProps) { const form = useAgentForm({ @@ -105,6 +107,7 @@ export function AgentForm({ persistedConnectorIssue={persistedConnectorIssue} form={form} onSaved={onSaved} + onCancel={onCancel ?? (() => {})} onDeleted={onDeleted} /> ); diff --git a/src/renderer/components/organization/agent-form/agent-form-fields/EditFormBody.tsx b/src/renderer/components/organization/agent-form/agent-form-fields/EditFormBody.tsx index e8ea775b..ea474b49 100644 --- a/src/renderer/components/organization/agent-form/agent-form-fields/EditFormBody.tsx +++ b/src/renderer/components/organization/agent-form/agent-form-fields/EditFormBody.tsx @@ -1,20 +1,20 @@ -import { useEffect, useState } from "react"; -import { Pencil, Trash } from "../../../ui/icons"; -import { DEPARTMENT_OPTIONS } from "@shared/constants"; +import { useMemo, useState } from "react"; import type { AgentRecord, ConnectorRecord, - DepartmentId, WorkspaceRecord, } from "@shared/types"; import { unwrap } from "../../../../lib/desktop"; import { buildBudgetPolicyInput } from "../../../../lib/budget-policy-input"; -import { useAppStore } from "../../../../state/store"; import { useT } from "../../../../i18n"; -import { ActionButton, Input, Select, TextArea } from "../../../ui"; -import { SkillAgentToggles } from "../../../skills/SkillAgentToggles"; -import { ConfirmDelete, HeartbeatSection } from "../../helpers"; +import { HeartbeatSection } from "../../helpers"; import type { AgentFormState } from "../useAgentForm"; +import { IdentitySection } from "./edit-sections/IdentitySection"; +import { RuntimeSection } from "./edit-sections/RuntimeSection"; +import { ConstraintsSection } from "./edit-sections/ConstraintsSection"; +import { SkillsSection } from "./edit-sections/SkillsSection"; +import { EditActionBar } from "./edit-sections/EditActionBar"; +import { SectionHeader } from "./edit-sections/SectionHeader"; interface EditFormBodyProps { agent: AgentRecord; @@ -27,9 +27,22 @@ interface EditFormBodyProps { persistedConnectorIssue: string | null; form: AgentFormState; onSaved: () => Promise; + onCancel: () => void; onDeleted?: () => Promise; } +/** + * Orchestrator for the agent Edit modal. Owns the save / wake / delete + * side effects, the save-error latch, and the dirty-state derivation; + * renders through five section components so each concern (identity, + * runtime, constraints, behavior, skills) lives in a ≤200 LOC file. + * + * "Dirty" is computed from the form state vs. the persisted agent record + * plus the persisted budget policy. We intentionally compare trimmed + * strings and numeric budget so that whitespace-only edits do not falsely + * enable Save. Root-agent guards against Delete / terminate live here + * and are threaded into the relevant sections. + */ export function EditFormBody({ agent, companyId, @@ -40,97 +53,72 @@ export function EditFormBody({ persistedConnectorIssue, form, onSaved, + onCancel, onDeleted, }: EditFormBodyProps) { const t = useT(); - const { - name, - setName, - role, - setRole, - title, - setTitle, - department, - setDepartment, - status, - setStatus, - connectorId, - setConnectorId, - model, - setModel, - availableModels, - workspaceId, - setWorkspaceId, - budget, - setBudget, - capabilities, - setCapabilities, - reportsTo, - setReportsTo, - thinkingEffort, - setThinkingEffort, - existingMeta, - supportsEffort, - selectedDepartment, - agentPolicy, - } = form; - const [saveError, setSaveError] = useState(null); - const [confirmingDelete, setConfirmingDelete] = useState(false); + const pendingApproval = agent.status === "pending_approval"; - // Root agents (CEO / no reportsTo) must not be terminable or deletable from - // this form — their removal requires reassigning direct reports first, and - // we would rather the user see a disabled control + reason than discover - // the orphaning side-effect only after clicking through a confirm. const isRootAgent = !agent.reportsTo; - const skills = useAppStore((s) => s.skills); - const setSkills = useAppStore((s) => s.setSkills); - useEffect(() => { - if (skills.length === 0 && companyId) { - window.agentCompany.listSkills(companyId).then((result) => { - if (result.ok) setSkills(result.data); - }); - } - }, [companyId, skills.length, setSkills]); - - const managerCandidates = agents.filter((a) => a.id !== agent.id); + const isDirty = useMemo(() => { + if (pendingApproval) return false; + const persistedEffort = + typeof form.existingMeta.thinkingEffort === "string" + ? (form.existingMeta.thinkingEffort as string) + : ""; + return ( + form.name.trim() !== agent.name || + form.role.trim() !== agent.role || + form.title.trim() !== (agent.title ?? "") || + (form.department || null) !== (agent.department ?? null) || + form.status !== agent.status || + form.connectorId !== agent.connectorId || + (form.model || null) !== (agent.model ?? null) || + (form.workspaceId || null) !== (agent.workspaceId ?? null) || + (form.reportsTo || null) !== (agent.reportsTo ?? null) || + Number(form.budget || 0) !== agent.budgetMonthlyUsd || + form.capabilities.trim() !== (agent.capabilities ?? "") || + form.thinkingEffort !== persistedEffort + ); + }, [form, agent, pendingApproval]); async function persistAgentDraft() { - const meta = { ...existingMeta }; - if (thinkingEffort) meta.thinkingEffort = thinkingEffort; + const meta = { ...form.existingMeta }; + if (form.thinkingEffort) meta.thinkingEffort = form.thinkingEffort; else delete meta.thinkingEffort; - const budgetDollars = Number(budget || 0); + const budgetDollars = Number(form.budget || 0); unwrap( await window.agentCompany.saveAgent({ id: agent.id, companyId, - name: name.trim(), - role: role.trim(), - title: title.trim(), - department: selectedDepartment, - status, - connectorId, - workspaceId: workspaceId || null, - reportsTo: reportsTo || null, - model: model || null, + name: form.name.trim(), + role: form.role.trim(), + title: form.title.trim(), + department: form.selectedDepartment, + status: form.status, + connectorId: form.connectorId, + workspaceId: form.workspaceId || null, + reportsTo: form.reportsTo || null, + model: form.model || null, metadataJson: JSON.stringify(meta), budgetMonthlyUsd: budgetDollars, - capabilities: capabilities.trim(), + capabilities: form.capabilities.trim(), }), ); if (budgetDollars > 0 && agent.id) { const payload = buildBudgetPolicyInput({ - policyId: agentPolicy?.id ?? null, + policyId: form.agentPolicy?.id ?? null, companyId, scopeType: "agent", scopeId: agent.id, amountDollars: String(budgetDollars), - warnPercent: String(agentPolicy?.warnPercent ?? 80), - hardStopEnabled: agentPolicy?.hardStopEnabled ?? true, - notifyEnabled: agentPolicy?.notifyEnabled ?? true, + warnPercent: String(form.agentPolicy?.warnPercent ?? 80), + hardStopEnabled: form.agentPolicy?.hardStopEnabled ?? true, + notifyEnabled: form.agentPolicy?.notifyEnabled ?? true, }); if (!payload) { throw new Error(t("agent.form.budgetPolicyInvalid")); @@ -166,196 +154,46 @@ export function EditFormBody({ } } + async function handleDelete() { + await window.agentCompany.deleteAgent({ id: agent.id, companyId }); + if (onDeleted) await onDeleted(); + } + return ( -
- - - - setStatus(v as AgentRecord["status"])} - disabled={pendingApproval} - options={ - pendingApproval - ? [{ value: "pending_approval", label: t("agent.pendingApproval") }] - : [ - { value: "idle", label: t("agent.form.statusIdle") }, - { value: "active", label: t("agent.form.statusActive") }, - { value: "running", label: t("agent.form.statusRunning") }, - { value: "paused", label: t("agent.form.statusPaused") }, - { value: "error", label: t("agent.form.statusError") }, - ...(isRootAgent ? [] : [{ value: "terminated", label: t("agent.form.statusTerminated") }]), - ] - } - /> - {pendingApproval ? ( -

{t("agent.pendingHireNote")}

- ) : null} - {isRootAgent && !pendingApproval ? ( -

{t("agent.form.rootTerminateBlocked")}

- ) : null} - ({ value: m.id, label: m.label }))} - /> - ) : null} - {supportsEffort ? ( - ({ value: w.id, label: w.name })), - ]} - /> - -