feat(org): add OrgChart status legend + CEO delete/terminate guards
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.
This commit is contained in:
1 parent
50a7196b3e
commit
62d0964af3
4 files changed
+45
-2
No files matched your search
@@ -6,6 +6,16 @@ import { EmptyState } from "../ui";
|
|||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import { buildOrgTree, layoutForest, flattenLayout, collectEdges, statusDotColor, CARD_W, CARD_H, CHART_PADDING } from "./orgTreeLayout";
|
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 }) {
|
export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentRecord[]; connectors?: ConnectorRecord[]; onSelectAgent?: (agentId: string) => void }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -18,6 +28,11 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR
|
|||||||
const layout = useMemo(() => layoutForest(orgTree), [orgTree]);
|
const layout = useMemo(() => layoutForest(orgTree), [orgTree]);
|
||||||
const allNodes = useMemo(() => flattenLayout(layout), [layout]);
|
const allNodes = useMemo(() => flattenLayout(layout), [layout]);
|
||||||
const edges = useMemo(() => collectEdges(layout), [layout]);
|
const edges = useMemo(() => collectEdges(layout), [layout]);
|
||||||
|
const legendEntries = useMemo(() => {
|
||||||
|
const present = new Set<string>();
|
||||||
|
for (const a of agents) present.add(a.status);
|
||||||
|
return LEGEND_ORDER.filter((entry) => present.has(entry.status));
|
||||||
|
}, [agents]);
|
||||||
const bounds = useMemo(() => {
|
const bounds = useMemo(() => {
|
||||||
if (allNodes.length === 0) return { width: 800, height: 600 };
|
if (allNodes.length === 0) return { width: 800, height: 600 };
|
||||||
let maxX = 0, maxY = 0;
|
let maxX = 0, maxY = 0;
|
||||||
@@ -85,6 +100,17 @@ export function OrgChart({ agents, connectors, onSelectAgent }: { agents: AgentR
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{legendEntries.length > 0 ? (
|
||||||
|
<div className="absolute left-3 top-3 z-10 flex flex-col gap-1 rounded-lg border border-border bg-card/95 px-3 py-2 text-[11px] backdrop-blur-sm" aria-label={t("org.statusLegend")}>
|
||||||
|
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-muted-foreground">{t("org.statusLegend")}</div>
|
||||||
|
{legendEntries.map((entry) => (
|
||||||
|
<div key={entry.status} className="flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: statusDotColor[entry.status] ?? "var(--muted-foreground)" }} aria-hidden="true" />
|
||||||
|
<span className="text-muted-foreground">{t(entry.labelKey)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<svg className="pointer-events-none absolute inset-0" style={{ width: "100%", height: "100%" }}>
|
<svg className="pointer-events-none absolute inset-0" style={{ width: "100%", height: "100%" }}>
|
||||||
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
|
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
|
||||||
{edges.map(({ parent, child }) => {
|
{edges.map(({ parent, child }) => {
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ export function EditFormBody({
|
|||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
const pendingApproval = agent.status === "pending_approval";
|
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 skills = useAppStore((s) => s.skills);
|
||||||
const setSkills = useAppStore((s) => s.setSkills);
|
const setSkills = useAppStore((s) => s.setSkills);
|
||||||
@@ -211,13 +216,16 @@ export function EditFormBody({
|
|||||||
{ value: "running", label: t("agent.form.statusRunning") },
|
{ value: "running", label: t("agent.form.statusRunning") },
|
||||||
{ value: "paused", label: t("agent.form.statusPaused") },
|
{ value: "paused", label: t("agent.form.statusPaused") },
|
||||||
{ value: "error", label: t("agent.form.statusError") },
|
{ value: "error", label: t("agent.form.statusError") },
|
||||||
{ value: "terminated", label: t("agent.form.statusTerminated") },
|
...(isRootAgent ? [] : [{ value: "terminated", label: t("agent.form.statusTerminated") }]),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{pendingApproval ? (
|
{pendingApproval ? (
|
||||||
<p className="text-[12px] text-muted-foreground">{t("agent.pendingHireNote")}</p>
|
<p className="text-[12px] text-muted-foreground">{t("agent.pendingHireNote")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isRootAgent && !pendingApproval ? (
|
||||||
|
<p className="text-[12px] text-muted-foreground">{t("agent.form.rootTerminateBlocked")}</p>
|
||||||
|
) : null}
|
||||||
<Select
|
<Select
|
||||||
label={t("agent.connector")}
|
label={t("agent.connector")}
|
||||||
value={connectorId}
|
value={connectorId}
|
||||||
@@ -334,7 +342,7 @@ export function EditFormBody({
|
|||||||
label={t("agent.form.delete")}
|
label={t("agent.form.delete")}
|
||||||
tone="danger"
|
tone="danger"
|
||||||
icon={Trash}
|
icon={Trash}
|
||||||
disabled={pendingApproval}
|
disabled={pendingApproval || isRootAgent}
|
||||||
onClick={() => setConfirmingDelete(true)}
|
onClick={() => setConfirmingDelete(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -344,6 +352,9 @@ export function EditFormBody({
|
|||||||
{t("agent.form.pendingReadOnly")}
|
{t("agent.form.pendingReadOnly")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isRootAgent && !pendingApproval ? (
|
||||||
|
<p className="text-[12px] text-muted-foreground">{t("agent.form.rootDeleteBlocked")}</p>
|
||||||
|
) : null}
|
||||||
{saveError ? <p className="text-[12px] text-destructive">{saveError}</p> : null}
|
{saveError ? <p className="text-[12px] text-destructive">{saveError}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1274,6 +1274,7 @@ export const en: Record<string, string> = {
|
|||||||
"org.zoomIn": "Zoom in",
|
"org.zoomIn": "Zoom in",
|
||||||
"org.zoomOut": "Zoom out",
|
"org.zoomOut": "Zoom out",
|
||||||
"org.fitChart": "Fit chart to screen",
|
"org.fitChart": "Fit chart to screen",
|
||||||
|
"org.statusLegend": "Status",
|
||||||
"org.agentAria": "{name}, {title}, {status}",
|
"org.agentAria": "{name}, {title}, {status}",
|
||||||
"org.statusAria": "Status: {status}",
|
"org.statusAria": "Status: {status}",
|
||||||
|
|
||||||
@@ -1380,6 +1381,8 @@ export const en: Record<string, string> = {
|
|||||||
"agent.form.statusPaused": "Paused",
|
"agent.form.statusPaused": "Paused",
|
||||||
"agent.form.statusError": "Error",
|
"agent.form.statusError": "Error",
|
||||||
"agent.form.statusTerminated": "Terminated",
|
"agent.form.statusTerminated": "Terminated",
|
||||||
|
"agent.form.rootDeleteBlocked": "Top-of-chain agents cannot be deleted. Reassign their direct reports to another manager first.",
|
||||||
|
"agent.form.rootTerminateBlocked": "Top-of-chain agents cannot be terminated. Reassign their direct reports to another manager first.",
|
||||||
"agent.form.pendingReadOnly": "Pending hires are read-only here. Use Hiring or Approvals to approve or reject the request before editing the agent profile.",
|
"agent.form.pendingReadOnly": "Pending hires are read-only here. Use Hiring or Approvals to approve or reject the request before editing the agent profile.",
|
||||||
"agent.form.saveFailed": "Failed to save agent.",
|
"agent.form.saveFailed": "Failed to save agent.",
|
||||||
"agent.form.wakeFailed": "Failed to wake agent.",
|
"agent.form.wakeFailed": "Failed to wake agent.",
|
||||||
|
|||||||
@@ -1274,6 +1274,7 @@ export const zh: Record<string, string> = {
|
|||||||
"org.zoomIn": "放大",
|
"org.zoomIn": "放大",
|
||||||
"org.zoomOut": "缩小",
|
"org.zoomOut": "缩小",
|
||||||
"org.fitChart": "适应画布",
|
"org.fitChart": "适应画布",
|
||||||
|
"org.statusLegend": "状态",
|
||||||
"org.agentAria": "{name},{title},{status}",
|
"org.agentAria": "{name},{title},{status}",
|
||||||
"org.statusAria": "状态:{status}",
|
"org.statusAria": "状态:{status}",
|
||||||
|
|
||||||
@@ -1380,6 +1381,8 @@ export const zh: Record<string, string> = {
|
|||||||
"agent.form.statusPaused": "已暂停",
|
"agent.form.statusPaused": "已暂停",
|
||||||
"agent.form.statusError": "错误",
|
"agent.form.statusError": "错误",
|
||||||
"agent.form.statusTerminated": "已终止",
|
"agent.form.statusTerminated": "已终止",
|
||||||
|
"agent.form.rootDeleteBlocked": "顶层成员无法删除。先把下属重新分配给其他上级。",
|
||||||
|
"agent.form.rootTerminateBlocked": "顶层成员无法终止。先把下属重新分配给其他上级。",
|
||||||
"agent.form.pendingReadOnly": "待招聘状态为只读。先在招聘或审批里批准或拒绝,再编辑智能体资料。",
|
"agent.form.pendingReadOnly": "待招聘状态为只读。先在招聘或审批里批准或拒绝,再编辑智能体资料。",
|
||||||
"agent.form.saveFailed": "保存智能体失败。",
|
"agent.form.saveFailed": "保存智能体失败。",
|
||||||
"agent.form.wakeFailed": "唤醒智能体失败。",
|
"agent.form.wakeFailed": "唤醒智能体失败。",
|
||||||
|
|||||||
Reference in new issue
Block a user