From 62d0964af3a8d3d025bc4e72883fd8ce4e1b21e3 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:45:32 -0400 Subject: [PATCH] feat(org): add OrgChart status legend + CEO delete/terminate guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two audit items addressed together because they share the same org-page render surface and both enforce "read the screen correctly before acting": 1) Status dot colors in OrgChart were undocumented — users saw five distinct colors (running/active/idle/paused/error/terminated/ pending_approval) with no key. Added a compact top-left legend that only lists the statuses actually present in the current org, so a healthy all-running company shows two rows, not seven. Label copy reuses existing agent.form.status* keys (zh/en). 2) The agent Edit modal offered Delete and Status=terminated on every agent, including the root (no reportsTo) — the CEO. Deleting or terminating a root orphans every direct report. Added `isRootAgent` check in EditFormBody: - Delete button: disabled, with `rootDeleteBlocked` inline note telling the user to reassign reports first. - Status select: `terminated` option hidden; `rootTerminateBlocked` note explains why. Scope note: this is UI-level guardrail only. The deleteAgent IPC / API route is unchanged — same-company power users with DB access can still do surgical fixes; the board-facing UX just refuses to be a footgun. Covers J5 / S3 / S4. --- .../components/organization/OrgChart.tsx | 26 +++++++++++++++++++ .../agent-form-fields/EditFormBody.tsx | 15 +++++++++-- src/renderer/i18n/en.ts | 3 +++ src/renderer/i18n/zh.ts | 3 +++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/organization/OrgChart.tsx b/src/renderer/components/organization/OrgChart.tsx index 338cdb60..4e093768 100644 --- a/src/renderer/components/organization/OrgChart.tsx +++ b/src/renderer/components/organization/OrgChart.tsx @@ -6,6 +6,16 @@ import { EmptyState } from "../ui"; import { useT } from "../../i18n"; import { buildOrgTree, layoutForest, flattenLayout, collectEdges, statusDotColor, CARD_W, CARD_H, CHART_PADDING } from "./orgTreeLayout"; +const LEGEND_ORDER: Array<{ status: string; labelKey: string }> = [ + { status: "running", labelKey: "agent.form.statusRunning" }, + { status: "active", labelKey: "agent.form.statusActive" }, + { status: "idle", labelKey: "agent.form.statusIdle" }, + { status: "paused", labelKey: "agent.form.statusPaused" }, + { status: "pending_approval", labelKey: "agent.pendingApproval" }, + { status: "error", labelKey: "agent.form.statusError" }, + { status: "terminated", labelKey: "agent.form.statusTerminated" }, +]; + export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentRecord[]; connectors?: ConnectorRecord[]; onSelectAgent?: (agentId: string) => void }) { const t = useT(); const containerRef = useRef(null); @@ -18,6 +28,11 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR const layout = useMemo(() => layoutForest(orgTree), [orgTree]); const allNodes = useMemo(() => flattenLayout(layout), [layout]); const edges = useMemo(() => collectEdges(layout), [layout]); + const legendEntries = useMemo(() => { + const present = new Set(); + for (const a of agents) present.add(a.status); + return LEGEND_ORDER.filter((entry) => present.has(entry.status)); + }, [agents]); const bounds = useMemo(() => { if (allNodes.length === 0) return { width: 800, height: 600 }; let maxX = 0, maxY = 0; @@ -85,6 +100,17 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR ))} + {legendEntries.length > 0 ? ( +
+
{t("org.statusLegend")}
+ {legendEntries.map((entry) => ( +
+
+ ))} +
+ ) : null} {edges.map(({ parent, child }) => { 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 5578c367..e8ea775b 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 @@ -78,6 +78,11 @@ export function EditFormBody({ 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); @@ -211,13 +216,16 @@ export function EditFormBody({ { value: "running", label: t("agent.form.statusRunning") }, { value: "paused", label: t("agent.form.statusPaused") }, { value: "error", label: t("agent.form.statusError") }, - { value: "terminated", label: t("agent.form.statusTerminated") }, + ...(isRootAgent ? [] : [{ value: "terminated", label: t("agent.form.statusTerminated") }]), ] } /> {pendingApproval ? (

{t("agent.pendingHireNote")}

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

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

+ ) : null}