From 9981ee342ba5647d455f7729d39133a52b4ad919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 17 Apr 2026 19:50:53 -0400 Subject: [PATCH] refactor(agent-form): split EditFormBody into 5 semantic sections + add Cancel/dirty-state + always-visible Thinking Effort + binary OrgChart hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the agent Edit modal was a 351-LOC wall of 17 stacked form fields with no visual grouping, mixed header styles between the skills heading and the field labels, a Save button that was always enabled, no Cancel affordance, and a Thinking Effort field that silently disappeared when the selected connector did not support it. This commit restructures the edit surface into five named sections — Identity, Runtime, Budget & scope, Behavior, Skills — each with a consistent SectionHeader (11px uppercase muted, border-top divider). Every section lives in its own ≤150-LOC file under agent-form-fields/edit-sections/ so nothing stays god-sized: EditFormBody.tsx 199 LOC (orchestrator: isDirty, handlers) IdentitySection.tsx 74 LOC RuntimeSection.tsx 114 LOC ConstraintsSection.tsx 58 LOC SkillsSection.tsx 36 LOC EditActionBar.tsx 87 LOC SectionHeader.tsx 18 LOC Behavioral changes: - Save is disabled until the form is dirty (trimmed-string + numeric budget comparison against the persisted agent record). The board sees whether changes are captured without clicking Save to find out. - Cancel button added, wired through AgentForm → AgentEditor → OrganizationSections, which clears selectedAgentId (same as the modal's close X — a proper bottom action avoids users reaching for the corner). - Thinking Effort is always shown. When the connector provider does not accept the hint the Select is disabled with an inline note, so the option is discoverable instead of silently hidden. - Budget shows a live BudgetBar + "Spent $X of $Y" line when the budget or spend is non-zero. The raw number input on its own left burn state invisible. - Capabilities placeholder replaced with a concrete example ("writes React components, reviews PRs, ...") — the previous "What this agent does" was a circular prompt. - OrgChart role bar went from three tiers (CEO 4px primary / manager 3px warning / IC 2px muted 30%) to binary (root 4px primary / everyone else 2px border). Orange-warning for managers read as an alert, not a rank; managerial position is already encoded in the tree's topology. Also added i18n keys (zh + en): agent.form.sectionIdentity / sectionRuntime / sectionConstraints / sectionBehavior / effortNotSupported / budgetBurn; updated capabilitiesPlaceholder copy. Covers J2 / J7 / J8 / S5 / S6 / S7 / S8. --- .../components/organization/AgentEditor.tsx | 3 + .../components/organization/OrgChart.tsx | 9 +- .../organization/agent-form/AgentForm.tsx | 3 + .../agent-form-fields/EditFormBody.tsx | 352 +++++------------- .../edit-sections/ConstraintsSection.tsx | 58 +++ .../edit-sections/EditActionBar.tsx | 87 +++++ .../edit-sections/IdentitySection.tsx | 74 ++++ .../edit-sections/RuntimeSection.tsx | 114 ++++++ .../edit-sections/SectionHeader.tsx | 18 + .../edit-sections/SkillsSection.tsx | 36 ++ src/renderer/i18n/en.ts | 8 +- src/renderer/i18n/zh.ts | 8 +- .../views/sections/OrganizationSections.tsx | 1 + 13 files changed, 510 insertions(+), 261 deletions(-) create mode 100644 src/renderer/components/organization/agent-form/agent-form-fields/edit-sections/ConstraintsSection.tsx create mode 100644 src/renderer/components/organization/agent-form/agent-form-fields/edit-sections/EditActionBar.tsx create mode 100644 src/renderer/components/organization/agent-form/agent-form-fields/edit-sections/IdentitySection.tsx create mode 100644 src/renderer/components/organization/agent-form/agent-form-fields/edit-sections/RuntimeSection.tsx create mode 100644 src/renderer/components/organization/agent-form/agent-form-fields/edit-sections/SectionHeader.tsx create mode 100644 src/renderer/components/organization/agent-form/agent-form-fields/edit-sections/SkillsSection.tsx 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 })), - ]} - /> - -