From afd96fe42c1b7d089a400914b1085a7f283fd33b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?=
Date: Sat, 30 May 2026 15:52:49 -0400
Subject: [PATCH] Rebuild the org chart with a real tidy-tree layout and
centered connectors
---
.../src/renderer/src/pages/TeamPage.tsx | 81 +-------
.../renderer/src/pages/team/OrgChartTree.tsx | 173 ++++++++++++++++++
AgentCompany-New/src/renderer/src/styles.css | 72 ++++----
3 files changed, 209 insertions(+), 117 deletions(-)
create mode 100644 AgentCompany-New/src/renderer/src/pages/team/OrgChartTree.tsx
diff --git a/AgentCompany-New/src/renderer/src/pages/TeamPage.tsx b/AgentCompany-New/src/renderer/src/pages/TeamPage.tsx
index bd653072..6a1086bd 100644
--- a/AgentCompany-New/src/renderer/src/pages/TeamPage.tsx
+++ b/AgentCompany-New/src/renderer/src/pages/TeamPage.tsx
@@ -1,7 +1,8 @@
import { useMemo, useState, type FormEvent } from "react";
-import { Avatar, Button, Field, Icon, IconButton, Pill } from "../shared/ui";
+import { Avatar, Button, Field, Icon, IconButton } from "../shared/ui";
import { TopbarActions } from "../shared/TopbarActions";
import { Dialog } from "../shared/Dialog";
+import { OrgChartTree } from "./team/OrgChartTree";
import type { AgentRecord, EmployeeRecord, ProviderRecord } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId, money } from "./types";
@@ -81,9 +82,9 @@ export function TeamPage({ snapshot, actions }: PageProps) {
{" "}{agents.length} {agents.length === 1 ? "agent" : "agents"}
-
+
Roster
@@ -164,80 +165,6 @@ export function TeamPage({ snapshot, actions }: PageProps) {
);
}
-function OrgCard({ name, role, human = false }: { name: string; role: string; human?: boolean }) {
- return (
-
-
-
- {human ?
Human :
Agent}
-
-
- );
-}
-
-function OrgChart({ boardChair, agents }: { boardChair: EmployeeRecord | null; agents: AgentRecord[] }) {
- const childrenByManager = useMemo(() => {
- const known = new Set(agents.map((agent) => agent.id));
- const map = new Map();
- for (const agent of agents) {
- // Agents whose manager is missing (or the top-level CEO) hang off the root.
- const key = agent.managerAgentId && known.has(agent.managerAgentId) ? agent.managerAgentId : "__root__";
- const list = map.get(key) ?? [];
- list.push(agent);
- map.set(key, list);
- }
- return map;
- }, [agents]);
-
- const roots = childrenByManager.get("__root__") ?? [];
- if (!boardChair && roots.length === 0) return null;
-
- return (
-
-
- -
- {boardChair ? : }
- {roots.length > 0 && (
-
- {roots.map((agent) => )}
-
- )}
-
-
-
- );
-}
-
-function OrgNode({ agent, childrenByManager }: { agent: AgentRecord; childrenByManager: Map }) {
- const reports = childrenByManager.get(agent.id) ?? [];
- return (
-
-
- {reports.length > 0 && (
-
- {reports.map((report) => )}
-
- )}
-
- );
-}
interface RosterEntry {
id: string;
diff --git a/AgentCompany-New/src/renderer/src/pages/team/OrgChartTree.tsx b/AgentCompany-New/src/renderer/src/pages/team/OrgChartTree.tsx
new file mode 100644
index 00000000..d72a58d1
--- /dev/null
+++ b/AgentCompany-New/src/renderer/src/pages/team/OrgChartTree.tsx
@@ -0,0 +1,173 @@
+import { useEffect, useMemo, useRef } from "react";
+import { Avatar } from "../../shared/ui";
+import type { AgentRecord, EmployeeRecord } from "../../../../shared/domain";
+
+const NODE_W = 208;
+const NODE_H = 64;
+const H_GAP = 24;
+const V_GAP = 44;
+const LEVEL_H = NODE_H + V_GAP;
+
+interface LayoutNode {
+ id: string;
+ name: string;
+ role: string;
+ human: boolean;
+ agentId: string | null;
+ children: LayoutNode[];
+ x: number;
+ y: number;
+ width: number;
+}
+
+interface Edge {
+ px: number;
+ py: number;
+ cx: number;
+ cy: number;
+}
+
+/**
+ * Hierarchical reporting tree with a real layout pass: each subtree's width is
+ * measured bottom-up, children are laid out left-to-right, and every parent is
+ * centered over the span of its reports. Connectors are orthogonal elbows. The
+ * whole chart is sized to its content and scrolls horizontally when wide.
+ */
+export function OrgChartTree({
+ boardChair,
+ agents,
+ onSelectAgent,
+}: {
+ boardChair: EmployeeRecord | null;
+ agents: AgentRecord[];
+ onSelectAgent: (agentId: string) => void;
+}) {
+ const layout = useMemo(() => buildLayout(boardChair, agents), [boardChair, agents]);
+ const scrollRef = useRef(null);
+ const rootX = layout?.root.x ?? 0;
+
+ // Open the chart looking at the top of the company, not a wall of whitespace.
+ useEffect(() => {
+ const el = scrollRef.current;
+ if (el) el.scrollLeft = Math.max(0, rootX - el.clientWidth / 2);
+ }, [rootX]);
+
+ if (!layout) return null;
+ const { root, width, height } = layout;
+ const nodes = flatten(root);
+ const edges = collectEdges(root);
+
+ return (
+
+
+
+ {nodes.map((node) => (
+
+ ))}
+
+
+ );
+}
+
+function buildLayout(
+ boardChair: EmployeeRecord | null,
+ agents: AgentRecord[],
+): { root: LayoutNode; width: number; height: number } | null {
+ const known = new Set(agents.map((agent) => agent.id));
+ const byManager = new Map();
+ for (const agent of agents) {
+ const key = agent.managerAgentId && known.has(agent.managerAgentId) ? agent.managerAgentId : "__root__";
+ const siblings = byManager.get(key) ?? [];
+ siblings.push(agent);
+ byManager.set(key, siblings);
+ }
+
+ const toNode = (agent: AgentRecord): LayoutNode => ({
+ id: agent.id,
+ name: agent.name,
+ role: agent.title || agent.role,
+ human: false,
+ agentId: agent.id,
+ children: (byManager.get(agent.id) ?? []).map(toNode),
+ x: 0,
+ y: 0,
+ width: 0,
+ });
+
+ const roots = (byManager.get("__root__") ?? []).map(toNode);
+ if (!boardChair && roots.length === 0) return null;
+
+ const root: LayoutNode = boardChair
+ ? { id: boardChair.id, name: boardChair.displayName, role: boardChair.role, human: true, agentId: null, children: roots, x: 0, y: 0, width: 0 }
+ : roots.length === 1
+ ? roots[0]
+ : { id: "__org__", name: "Organization", role: "Reporting lines", human: false, agentId: null, children: roots, x: 0, y: 0, width: 0 };
+
+ measure(root);
+ let maxDepth = 0;
+ const place = (node: LayoutNode, left: number, depth: number) => {
+ maxDepth = Math.max(maxDepth, depth);
+ node.y = depth * LEVEL_H;
+ if (node.children.length === 0) {
+ node.x = left + NODE_W / 2;
+ return;
+ }
+ const span = childrenSpan(node.children);
+ let cursor = left + (node.width - span) / 2;
+ for (const child of node.children) {
+ place(child, cursor, depth + 1);
+ cursor += child.width + H_GAP;
+ }
+ node.x = (node.children[0].x + node.children[node.children.length - 1].x) / 2;
+ };
+ place(root, 0, 0);
+
+ return { root, width: root.width, height: (maxDepth + 1) * LEVEL_H - V_GAP };
+}
+
+function measure(node: LayoutNode): number {
+ if (node.children.length === 0) {
+ node.width = NODE_W;
+ return node.width;
+ }
+ const span = node.children.reduce((total, child) => total + measure(child), 0) + (node.children.length - 1) * H_GAP;
+ node.width = Math.max(NODE_W, span);
+ return node.width;
+}
+
+function childrenSpan(children: LayoutNode[]): number {
+ return children.reduce((total, child) => total + child.width, 0) + (children.length - 1) * H_GAP;
+}
+
+function flatten(node: LayoutNode): LayoutNode[] {
+ return [node, ...node.children.flatMap(flatten)];
+}
+
+function collectEdges(node: LayoutNode): Edge[] {
+ return node.children.flatMap((child) => [
+ { px: node.x, py: node.y + NODE_H, cx: child.x, cy: child.y },
+ ...collectEdges(child),
+ ]);
+}
+
+function elbow(edge: Edge): string {
+ const midY = edge.py + V_GAP / 2;
+ return `M ${edge.px} ${edge.py} L ${edge.px} ${midY} L ${edge.cx} ${midY} L ${edge.cx} ${edge.cy}`;
+}
diff --git a/AgentCompany-New/src/renderer/src/styles.css b/AgentCompany-New/src/renderer/src/styles.css
index ccbbd9c7..21b6d928 100644
--- a/AgentCompany-New/src/renderer/src/styles.css
+++ b/AgentCompany-New/src/renderer/src/styles.css
@@ -386,47 +386,39 @@ a { color: inherit; }
min-height: 0;
}
-/* Hierarchical org chart: a real reporting tree with connector lines. */
-.org-tree { display: inline-block; min-width: 100%; text-align: center; margin-top: 18px; }
-.org-tree ul {
- position: relative;
+/* Hierarchical org chart: nodes are absolutely positioned by a layout pass,
+ connectors are drawn in an SVG underlay (see OrgChartTree). */
+.org-scroll { overflow-x: auto; overflow-y: hidden; }
+.org-node-card {
+ position: absolute;
display: flex;
- justify-content: center;
- margin: 0;
- padding: 22px 0 0 0;
- list-style: none;
+ align-items: center;
+ gap: 10px;
+ padding: 0 14px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: var(--panel);
+ box-shadow: var(--shadow-sm);
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
-.org-tree li {
- position: relative;
- padding: 22px 8px 0 8px;
- list-style: none;
+.org-node-card:hover:not(:disabled) { border-color: var(--border-strong); box-shadow: var(--shadow-md); }
+.org-node-card:disabled { cursor: default; }
+.org-node-card.human { border-color: var(--amber); background: var(--amber-soft); }
+.org-node-text { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
+.org-node-name {
+ font-size: 12.5px;
+ font-weight: 560;
+ letter-spacing: -0.01em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.org-tree li::before, .org-tree li::after {
- content: "";
- position: absolute;
- top: 0;
- right: 50%;
- width: 50%;
- height: 22px;
- border-top: 1px solid var(--border-strong);
+.org-node-role {
+ font-size: 11px;
+ color: var(--text-3);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.org-tree li::after {
- right: auto;
- left: 50%;
- border-left: 1px solid var(--border-strong);
-}
-.org-tree li:only-child::before, .org-tree li:only-child::after { display: none; }
-.org-tree li:only-child { padding-top: 22px; }
-.org-tree li:first-child::before, .org-tree li:last-child::after { border: 0 none; }
-.org-tree li:last-child::before { border-right: 1px solid var(--border-strong); }
-.org-tree ul ul::before {
- content: "";
- position: absolute;
- top: 0;
- left: 50%;
- width: 0;
- height: 22px;
- border-left: 1px solid var(--border-strong);
-}
-.org-tree > ul { padding-top: 0; }
-.org-tree > ul > li:only-child { padding-top: 0; }