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"}

-
+
Org
- +
Roster
@@ -164,80 +165,6 @@ export function TeamPage({ snapshot, actions }: PageProps) { ); } -function OrgCard({ name, role, human = false }: { name: string; role: string; human?: boolean }) { - return ( -
-
- -
-
{name}
-
{role}
-
-
-
- {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; }