Files
PaperClipAI/ui/src/pages/OrgChart.tsx
T
DottaandPaperclip adb69e8f09 feat: default new Codex agents to Paperclip Runner on Linux and macOS (#15683)
## Thinking Path

> - Paperclip is the open source control plane for agent companies.
> - People choose a harness when creating an agent.
> - The server should resolve that harness to a qualified runner.
> - Codex is the first harness in the staged default rollout.
> - Saved agents must retain their execution choices.
> - New Codex agents therefore use Paperclip Runner on qualified
macOS/Linux targets, with an explicit Legacy override.

## Linked Issues or Issue Description

Refs #16028, #15555, #15673, #15422, #15631, #15926, #15932, #16149.

The runtime/install prerequisite and release repair are merged. This
slice owns Codex creation, setup, configuration and preservation. Other
harness defaults remain unchanged. It adds no database migration;
incoming master migrations, required maps and regression coverage are
retained.

## What Changed

- Resolve `runner: auto | paperclip | legacy` at the common creation
boundary across onboarding, API/CLI, hires, built-ins, plugins and
imports. Freeze pending hires and export resolved execution choices.
- Preserve existing runners on upgrades, unrelated edits and import
updates, including compatible accounts, workspace settings, Codex effort
and intentional clears.
- Use the underlying harness picker and branding; put the runner
override in Advanced. Use the shared dropdown and update
production-component stories and creation contracts.
- Support qualified Apple Silicon, Intel Mac, Linux x64 and Linux ARM64.
Linux requires GNU libc 2.39+. Disabled, external, unsupported and
unqualified combinations retain their applicable Legacy execution.
- Test the selected runtime/account/model/environment. Ordinary Connect
probes eligible immutable managed targets and refreshes scoped
discovery. Creation/discovery do not provision. Auth, dependency,
incompatible-setting and cleanup failures remain actionable without
changing runners.
- Keep setup probes read-only, preserve local/SSH isolation and resolve
packaged assets before staging credentials. Bound cleanup reads by the
remaining absolute deadline; physically close every owned ingress socket
before endpoint release. Unconfirmed cleanup retains recovery state.
- Preserve validated completed-onboarding callbacks through startup
recovery. First file-producing work belongs to the child task and uses
the existing dependency/block handoff and task-scoped registration.
- Reuse existing E2E fixtures, artifact checks, reporting and controls.
Keep Docker/Cargo/native compilation remote. Paperclip tarballs contain
no Codex payloads; official optional npm platform dependencies remain.

## Verification

Current tested head: `1c03a8815672d82c264a9b08d4ea1984e8fae107`.
**Merge-ready: required CI, fresh review, package qualification, Mac
core acceptance and Test task/follow-up acceptance pass.** The existing
attachment and native-continuity oracles independently verify both Test
downloads, run attribution and unchanged native/provider/workspace
identities. The maintainer authorized a code-owner-review exception for
this PR only. The first Test setup failure and normal Board
completion-review friction are retained below. Actual post-merge
packages and private Cloud distribution remain separate qualification
gates.

- [Current
CI](https://github.com/paperclipai/paperclip/actions/runs/38564241625):
all 48 ordinary jobs and required verify/E2E pass. Attempt 1 retained
six simultaneous host-shutdown cancellations. One failed-only retry
passes those six lanes and two aggregates, preserving 40 successes.
Actual integration checkout `53104c1` combines this head with master
`c80`; it is not relabeled as the branch tree. Fresh exact-head Greptile
5/5, 30 resolved threads, no actionable findings.
- [Exact-source package
qualification](https://github.com/paperclipai/paperclip/actions/runs/38565287239):
nine applicable jobs pass, nine conditional jobs skip. Four remote
native producers; Linux x64 clean install; Intel Mac, Apple Silicon and
Linux ARM64 native startup/physical cleanup controls. All 18
tarballs/four daemon source/hash/manifest bindings and 343 migrations
through 0344 verify. No Codex payload is packed. These hosted tarballs
are not a published npm canary.
- Normal Apple Silicon installation and credential-free native
startup/cleanup pass using the packaged daemon, with no source build or
provider turn. Fresh installed DB initialization passes. Upgrade
651a→1c03 preserves all three complete execution configs, accounts, ten
recorded run profiles, paused budgets, active incidents, cost summaries
and two historical unpriced events. Historical recovery state is
retained.
- Cleanup regressions: four delayed-read baseline failures become 15
passing deadline/authority controls. No-acknowledgement and prior-error
socket ownership regressions become 12 passing controls. Direct
runtime/server types, maps: 35 areas / 176 IDs, diff checks and
independent reviews pass. Permission/auth/reconnect policy and the
10-second setup budget remain intact.
- Existing source coverage includes creation/selection/imports, pending
approvals, recovery, account/lifecycle handling, mounted forms,
dropdowns and E2E support. Full typechecks/tests/build/token and
applicable Storybook checks pass in the recorded current CI; no
duplicate suite or weakened grader.
- Literal 9546 live Mac proof: ordinary Auto Codex onboarding, useful
document, real completed-child callback/reply, seven successful native
runs, manual follow-up and independently downloaded1,101-byte child
file. Existing Legacy title edit/reload preserves execution; two real
Legacy runs produce independently checked15/34-byte files. These remain
historical source records.
- Current literal 1c03 Mac core acceptance passes independent checks:
ordinary Auto native Codex onboarding without runner override; only
timeout 180 seconds/concurrency 1 saved; six successful native runs and
six serialized turns (maximum overlap 1); both same-task continuity
checks and five mechanical callback checks; actual rendered parent reply
and exact child downloads. The useful handoff advances from revision 1
to 2. Both files are 48 bytes, SHA-256
`8b742bd4e8123a7c078f0a8d9f3449fbfa8a320d7d90be641a2818db3c53d92c` and
`f42db9774fa06e804ab7d596fc8b922a2b638921c418b87d0111151d290a6afe`;
exactly one byte changes and old history remains. The agent is paused
with zero active runs/errors. Both artifact work products have
`isPrimary=false`, and descriptive titles fail a stricter title-helper
assumption; these retained results are scoped editorial follow-up, not
new merge gates. Six source-bound screenshots are uploaded and verified
in the private primary QA report.
- [Literal 9546 managed
proof](https://d1p6rlowie26tp.cloudfront.net/runner-e2e/campaigns/gha-38485488621-1/index.html):
original 19 assertions and completed judge pass, four native turns,
child-bound240-byte file and session/workspace continuity. Checkout
reports a verified generated target-lock mutation; full final porcelain
was not retained. Signed execution-image identity remains separate from
Core source.
- Actual 651a Test deployment succeeds on one owned/pinned target. One
ordinary Auto API-key Connect then fails native cleanup before hire.
Controller turn/drain/suspend completion and service-recorded
environment cleanup are observed; they do not certify physical deletion
or native authority retirement. Failure/recovery/unknown holds are
retained. No Legacy switch or unchanged paid retry. The two source
repairs above require actual treatment.
- [Current immutable Core
preview](https://github.com/paperclipai/paperclip/actions/runs/38570365725)
and matching DB/shared artifacts pass. One owned, pinned Test target
serves exact `1c03`; identical migration history correctly skips the
migrator. Ordinary Auto Codex Connect succeeds and hires native; saved
native selection and QA bounds survive reload. The initial child
preparation attempt fails before provider work, with actual stdout
identifying `renewal_deadline`; its trigger remains unknown and the
failure is retained. Normal Board **Try again** and **Resume** recover
the child to Done, producing a browser-verified 44-byte file, SHA-256
`0a8458b1efcc2d7a0d815689458c4caab039a46c7c7a3e5805de6bc8b1a96ecc`. The
actual parent callback reports the completed child document/file. The
existing validator requires a parent-owned attachment, so recovery
enters normal Review; after byte checking, normal **Approve completion**
reaches parent Done. No forced status or duplicate parent file is used.
The validator is unchanged from integrated and current master.
Autonomous initial callback-to-Done is not proven. The same-child
follow-up completes document revision 2 and publishes a second
independently downloaded 44-byte file, SHA-256
`22022257300fae762784632d08d3bbe0bab61e2ce81f91864a14dcd7aa464591`; only
the revision digit changes. The unchanged existing native-continuity and
attachment oracles pass. All ten recorded runs are terminal: nine native
successes and the retained pre-provider setup failure. The QA agent is
paused with zero active runs. The pre-existing parent evidence scope
mismatch is a separate follow-up, with ordinary completion review
exercised rather than a forced status or relaxed validation. This
unsigned Core preview remains distinct from post-merge private Cloud
composition.

## Risks

- Saved agents are not migrated. Explicit runner changes use normal
configuration/session rules; historical runs retain their recorded
execution.
- Native readiness depends on verified assets and server-owned target
qualification. A Legacy probe, HTTP200 or successful account save cannot
prove native readiness.
- Recovery exceptions remain restricted to validated
completed-onboarding reports; process-loss, ownership and terminal-state
safeguards remain active. Unexecuted live journeys stay open.
- Linux ARM paid tasks, Windows, Alpine and 32-bit ARM remain deferred.
Cold Cargo cache misses are recorded; no warm-cache speedup is claimed.
- Historical invoices/physical cleanup remain unconfirmed and held.
After merge, exact published npm canary, signed standard image/migrator,
private composition and serving/live acceptance still require
qualification. Production deployment/default promotion is not
authorized.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, tools, code execution and
parallel agents. Exact serving snapshot and context-window size are not
exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used with available version and
capability details
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have linked public issues or PRs above
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
ticket id
- [x] I have run the stated focused tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation
- [x] I have considered and documented risks
- [x] All Paperclip CI gates are green on the current candidate
- [x] Greptile is 5/5 with no open findings on the current candidate
- [x] I will address all reviewer comments before requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-14 11:33:02 -05:00

674 lines
23 KiB
TypeScript

import { AgentAvatar } from "@/components/AgentAvatar";
import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import { Link, useNavigate } from "@/lib/router";
import { useQuery } from "@tanstack/react-query";
import { agentsApi, type OrgNode } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { agentUrl } from "../lib/utils";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { Download, Maximize2, Minus, Network, Plus, Upload } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
import { useCloudInstance } from "@/hooks/useCloudInstance";
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
// Layout constants
const CARD_W = 200;
const CARD_H = 100;
const GAP_X = 32;
const GAP_Y = 80;
const PADDING = 60;
const MIN_ZOOM = 0.2;
const MAX_ZOOM = 2;
const FIT_PADDING = 40;
const TOUCH_MOVE_THRESHOLD = 6;
// ── Tree layout types ───────────────────────────────────────────────────
interface LayoutNode {
id: string;
name: string;
role: string;
status: string;
x: number;
y: number;
children: LayoutNode[];
}
interface Point {
x: number;
y: number;
}
interface TouchGesture {
mode: "pan" | "pinch" | null;
startPoint: Point;
startPan: Point;
startZoom: number;
startDistance: number;
startCenter: Point;
moved: boolean;
}
// ── Layout algorithm ────────────────────────────────────────────────────
/** Compute the width each subtree needs. */
function subtreeWidth(node: OrgNode): number {
if (node.reports.length === 0) return CARD_W;
const childrenW = node.reports.reduce((sum, c) => sum + subtreeWidth(c), 0);
const gaps = (node.reports.length - 1) * GAP_X;
return Math.max(CARD_W, childrenW + gaps);
}
/** Recursively assign x,y positions. */
function layoutTree(node: OrgNode, x: number, y: number): LayoutNode {
const totalW = subtreeWidth(node);
const layoutChildren: LayoutNode[] = [];
if (node.reports.length > 0) {
const childrenW = node.reports.reduce((sum, c) => sum + subtreeWidth(c), 0);
const gaps = (node.reports.length - 1) * GAP_X;
let cx = x + (totalW - childrenW - gaps) / 2;
for (const child of node.reports) {
const cw = subtreeWidth(child);
layoutChildren.push(layoutTree(child, cx, y + CARD_H + GAP_Y));
cx += cw + GAP_X;
}
}
return {
id: node.id,
name: node.name,
role: node.role,
status: node.status,
x: x + (totalW - CARD_W) / 2,
y,
children: layoutChildren,
};
}
/** Layout all root nodes side by side. */
function layoutForest(roots: OrgNode[]): LayoutNode[] {
if (roots.length === 0) return [];
const totalW = roots.reduce((sum, r) => sum + subtreeWidth(r), 0);
const gaps = (roots.length - 1) * GAP_X;
let x = PADDING;
const y = PADDING;
const result: LayoutNode[] = [];
for (const root of roots) {
const w = subtreeWidth(root);
result.push(layoutTree(root, x, y));
x += w + GAP_X;
}
// Compute bounds and return
return result;
}
/** Flatten layout tree to list of nodes. */
function flattenLayout(nodes: LayoutNode[]): LayoutNode[] {
const result: LayoutNode[] = [];
function walk(n: LayoutNode) {
result.push(n);
n.children.forEach(walk);
}
nodes.forEach(walk);
return result;
}
/** Collect all parent→child edges. */
function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: LayoutNode }> {
const edges: Array<{ parent: LayoutNode; child: LayoutNode }> = [];
function walk(n: LayoutNode) {
for (const c of n.children) {
edges.push({ parent: n, child: c });
walk(c);
}
}
nodes.forEach(walk);
return edges;
}
function clampZoom(value: number): number {
return Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM);
}
function fitChartToViewport(
containerWidth: number,
containerHeight: number,
bounds: { width: number; height: number },
): { zoom: number; pan: Point } | null {
if (containerWidth <= FIT_PADDING || containerHeight <= FIT_PADDING) return null;
const scaleX = (containerWidth - FIT_PADDING) / bounds.width;
const scaleY = (containerHeight - FIT_PADDING) / bounds.height;
const zoom = clampZoom(Math.min(scaleX, scaleY, 1));
const chartWidth = bounds.width * zoom;
const chartHeight = bounds.height * zoom;
return {
zoom,
pan: {
x: (containerWidth - chartWidth) / 2,
y: (containerHeight - chartHeight) / 2,
},
};
}
function touchPoint(touch: React.Touch): Point {
return { x: touch.clientX, y: touch.clientY };
}
function touchDistance(a: React.Touch, b: React.Touch): number {
const dx = a.clientX - b.clientX;
const dy = a.clientY - b.clientY;
return Math.hypot(dx, dy);
}
function touchCenter(a: React.Touch, b: React.Touch, container: HTMLDivElement): Point {
const rect = container.getBoundingClientRect();
return {
x: (a.clientX + b.clientX) / 2 - rect.left,
y: (a.clientY + b.clientY) / 2 - rect.top,
};
}
// ── Status dot colors (raw hex for SVG) ─────────────────────────────────
import { agentDisplayAdapterType, getAdapterLabel } from "../adapters/adapter-display-registry";
const statusDotColor: Record<string, string> = {
running: "var(--hex-22d3ee)",
active: "var(--hex-4ade80)",
paused: "var(--hex-facc15)",
idle: "var(--hex-facc15)",
error: "var(--hex-f87171)",
terminated: "var(--hex-a3a3a3)",
};
const defaultDotColor = "var(--hex-a3a3a3)";
// ── Main component ──────────────────────────────────────────────────────
export interface OrgChartProps {
/** Pre-filtered tree for embedding the chart in another collection page. */
orgTree?: OrgNode[];
/** Agent records paired with a pre-filtered embedded tree. */
agents?: Agent[];
/** Hides page-level actions and breadcrumb ownership. */
embedded?: boolean;
}
export function OrgChart({ orgTree: providedOrgTree, agents: providedAgents, embedded = false }: OrgChartProps = {}) {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
// Import is floored server-side on cloud-managed instances (403 cloud_managed), so the
// button is hidden rather than dead-ending. Export stays available. Both
// buttons also respect the operator-hidden settings registry.
const isCloud = Boolean(useCloudInstance());
const { hidden: hiddenSettings } = useHiddenSettings();
const showImport = !isCloud && !hiddenSettings.has("company.import");
const showExport = !hiddenSettings.has("company.export");
const { data: queriedOrgTree, isLoading } = useQuery({
queryKey: queryKeys.org(selectedCompanyId!),
queryFn: () => agentsApi.org(selectedCompanyId!),
enabled: !!selectedCompanyId && providedOrgTree === undefined,
});
const { data: queriedAgents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && providedAgents === undefined,
});
const orgTree = providedOrgTree ?? queriedOrgTree;
const agents = providedAgents ?? queriedAgents;
const agentMap = useMemo(() => {
const m = new Map<string, Agent>();
for (const a of agents ?? []) m.set(a.id, a);
return m;
}, [agents]);
useEffect(() => {
if (!embedded) setBreadcrumbs([{ label: "Org Chart" }]);
}, [embedded, setBreadcrumbs]);
// Layout computation
const layout = useMemo(() => layoutForest(orgTree ?? []), [orgTree]);
const allNodes = useMemo(() => flattenLayout(layout), [layout]);
const edges = useMemo(() => collectEdges(layout), [layout]);
// Compute SVG bounds
const bounds = useMemo(() => {
if (allNodes.length === 0) return { width: 800, height: 600 };
let maxX = 0, maxY = 0;
for (const n of allNodes) {
maxX = Math.max(maxX, n.x + CARD_W);
maxY = Math.max(maxY, n.y + CARD_H);
}
return { width: maxX + PADDING, height: maxY + PADDING };
}, [allNodes]);
// Pan & zoom state
const containerRef = useRef<HTMLDivElement>(null);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [dragging, setDragging] = useState(false);
const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
const touchGesture = useRef<TouchGesture>({
mode: null,
startPoint: { x: 0, y: 0 },
startPan: { x: 0, y: 0 },
startZoom: 1,
startDistance: 0,
startCenter: { x: 0, y: 0 },
moved: false,
});
const suppressNextCardClick = useRef(false);
const suppressClickTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (suppressClickTimerRef.current !== null) {
window.clearTimeout(suppressClickTimerRef.current);
}
};
}, []);
// Center the chart on first load
const hasInitialized = useRef(false);
useEffect(() => {
hasInitialized.current = false;
}, [orgTree]);
useEffect(() => {
if (hasInitialized.current || allNodes.length === 0 || !containerRef.current) return;
const container = containerRef.current;
const fitted = fitChartToViewport(container.clientWidth, container.clientHeight, bounds);
if (!fitted) return;
hasInitialized.current = true;
setZoom(fitted.zoom);
setPan(fitted.pan);
}, [allNodes, bounds]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
// Don't drag if clicking a card
const target = e.target as HTMLElement;
if (target.closest("[data-org-card]")) return;
setDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
}, [pan]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!dragging) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
setPan({ x: dragStart.current.panX + dx, y: dragStart.current.panY + dy });
}, [dragging]);
const handleMouseUp = useCallback(() => {
setDragging(false);
}, []);
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const factor = e.deltaY < 0 ? 1.1 : 0.9;
const newZoom = clampZoom(zoom * factor);
// Zoom toward mouse position
const scale = newZoom / zoom;
setPan({
x: mouseX - scale * (mouseX - pan.x),
y: mouseY - scale * (mouseY - pan.y),
});
setZoom(newZoom);
}, [zoom, pan]);
const zoomTowardPoint = useCallback((newZoom: number, point: Point) => {
const clampedZoom = clampZoom(newZoom);
const scale = clampedZoom / zoom;
setPan({
x: point.x - scale * (point.x - pan.x),
y: point.y - scale * (point.y - pan.y),
});
setZoom(clampedZoom);
}, [zoom, pan]);
const fitToScreen = useCallback(() => {
if (!containerRef.current) return;
const fitted = fitChartToViewport(
containerRef.current.clientWidth,
containerRef.current.clientHeight,
bounds,
);
if (!fitted) return;
setZoom(fitted.zoom);
setPan(fitted.pan);
}, [bounds]);
const handleTouchStart = useCallback((e: React.TouchEvent<HTMLDivElement>) => {
if (e.touches.length >= 2 && containerRef.current) {
const [first, second] = [e.touches[0]!, e.touches[1]!];
touchGesture.current = {
mode: "pinch",
startPoint: { x: 0, y: 0 },
startPan: pan,
startZoom: zoom,
startDistance: touchDistance(first, second),
startCenter: touchCenter(first, second, containerRef.current),
moved: false,
};
return;
}
const touch = e.touches[0];
if (!touch) return;
touchGesture.current = {
mode: "pan",
startPoint: touchPoint(touch),
startPan: pan,
startZoom: zoom,
startDistance: 0,
startCenter: { x: 0, y: 0 },
moved: false,
};
}, [pan, zoom]);
const handleTouchMove = useCallback((e: React.TouchEvent<HTMLDivElement>) => {
const container = containerRef.current;
if (!container || !touchGesture.current.mode) return;
if (e.touches.length >= 2) {
const [first, second] = [e.touches[0]!, e.touches[1]!];
const distance = touchDistance(first, second);
const center = touchCenter(first, second, container);
if (touchGesture.current.mode !== "pinch" || touchGesture.current.startDistance === 0) {
touchGesture.current = {
mode: "pinch",
startPoint: { x: 0, y: 0 },
startPan: pan,
startZoom: zoom,
startDistance: distance,
startCenter: center,
moved: false,
};
return;
}
const gesture = touchGesture.current;
const nextZoom = clampZoom(gesture.startZoom * (distance / gesture.startDistance));
const scale = nextZoom / gesture.startZoom;
const dx = center.x - gesture.startCenter.x;
const dy = center.y - gesture.startCenter.y;
gesture.moved =
gesture.moved ||
Math.abs(distance - gesture.startDistance) > TOUCH_MOVE_THRESHOLD ||
Math.hypot(dx, dy) > TOUCH_MOVE_THRESHOLD;
setZoom(nextZoom);
setPan({
x: center.x - scale * (gesture.startCenter.x - gesture.startPan.x),
y: center.y - scale * (gesture.startCenter.y - gesture.startPan.y),
});
return;
}
const touch = e.touches[0];
if (!touch || touchGesture.current.mode !== "pan") return;
const dx = touch.clientX - touchGesture.current.startPoint.x;
const dy = touch.clientY - touchGesture.current.startPoint.y;
touchGesture.current.moved = touchGesture.current.moved || Math.hypot(dx, dy) > TOUCH_MOVE_THRESHOLD;
setPan({
x: touchGesture.current.startPan.x + dx,
y: touchGesture.current.startPan.y + dy,
});
}, [pan, zoom]);
const handleTouchEnd = useCallback(() => {
if (touchGesture.current.moved) {
suppressNextCardClick.current = true;
if (suppressClickTimerRef.current !== null) {
window.clearTimeout(suppressClickTimerRef.current);
}
suppressClickTimerRef.current = window.setTimeout(() => {
suppressNextCardClick.current = false;
suppressClickTimerRef.current = null;
}, 400);
}
touchGesture.current = {
mode: null,
startPoint: { x: 0, y: 0 },
startPan: pan,
startZoom: zoom,
startDistance: 0,
startCenter: { x: 0, y: 0 },
moved: false,
};
}, [pan, zoom]);
if (!selectedCompanyId) {
return <EmptyState icon={Network} message="Select an organization to view the org chart." />;
}
if (providedOrgTree === undefined && isLoading) {
return <PageSkeleton variant="org-chart" />;
}
if (orgTree && orgTree.length === 0) {
return <EmptyState icon={Network} message="No organizational hierarchy defined." />;
}
return (
<div
className={embedded
? "flex min-h-(--sz-420px) flex-1 flex-col md:min-h-0"
: "flex h-(--sz-calc-38) min-h-(--sz-420px) flex-col md:h-full md:min-h-0"}
>
{!embedded && (showImport || showExport) ? (
<div className="mb-2 flex shrink-0 flex-wrap items-center justify-start gap-2">
{showImport ? (
<Link to="/company/import">
<Button variant="outline" size="sm">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Import organization
</Button>
</Link>
) : null}
{showExport ? (
<Link to="/company/export">
<Button variant="outline" size="sm">
<Download className="mr-1.5 h-3.5 w-3.5" />
Export organization
</Button>
</Link>
) : null}
</div>
) : null}
<div
ref={containerRef}
data-testid="org-chart-viewport"
className="w-full flex-1 min-h-0 overflow-hidden relative bg-muted/20 border border-border rounded-lg"
style={{
cursor: dragging ? "grabbing" : "grab",
touchAction: "none",
overscrollBehavior: "contain",
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onTouchCancel={handleTouchEnd}
>
{/* Zoom controls */}
<div className="absolute top-3 right-3 z-10 flex flex-col gap-1.5">
<button
className="flex size-9 items-center justify-center rounded border border-border bg-background text-sm transition-colors hover:bg-accent sm:size-7"
onClick={() => {
const container = containerRef.current;
if (container) {
zoomTowardPoint(zoom * 1.2, {
x: container.clientWidth / 2,
y: container.clientHeight / 2,
});
}
}}
title="Zoom in"
aria-label="Zoom in"
>
<Plus className="h-4 w-4 sm:h-3.5 sm:w-3.5" />
</button>
<button
className="flex size-9 items-center justify-center rounded border border-border bg-background text-sm transition-colors hover:bg-accent sm:size-7"
onClick={() => {
const container = containerRef.current;
if (container) {
zoomTowardPoint(zoom * 0.8, {
x: container.clientWidth / 2,
y: container.clientHeight / 2,
});
}
}}
title="Zoom out"
aria-label="Zoom out"
>
<Minus className="h-4 w-4 sm:h-3.5 sm:w-3.5" />
</button>
<button
className="flex size-9 items-center justify-center rounded border border-border bg-background text-(length:--text-nano) transition-colors hover:bg-accent sm:size-7"
onClick={fitToScreen}
title="Fit to screen"
aria-label="Fit chart to screen"
>
<Maximize2 className="h-4 w-4 sm:h-3.5 sm:w-3.5" />
</button>
</div>
{/* SVG layer for edges */}
<svg
className="absolute inset-0 pointer-events-none"
style={{
width: "100%",
height: "100%",
}}
>
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
{edges.map(({ parent, child }) => {
const x1 = parent.x + CARD_W / 2;
const y1 = parent.y + CARD_H;
const x2 = child.x + CARD_W / 2;
const y2 = child.y;
const midY = (y1 + y2) / 2;
return (
<path
key={`${parent.id}-${child.id}`}
d={`M ${x1} ${y1} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${y2}`}
fill="none"
stroke="var(--border)"
strokeWidth={1.5}
/>
);
})}
</g>
</svg>
{/* Card layer */}
<div
data-testid="org-chart-card-layer"
className="absolute inset-0"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: "0 0",
}}
>
{allNodes.map((node) => {
const agent = agentMap.get(node.id);
const dotColor = statusDotColor[node.status] ?? defaultDotColor;
return (
<Card
key={node.id}
data-org-card
className="block absolute py-0 hover:shadow-md hover:border-foreground/20 transition-(--tp-box-shadow-border-color) duration-150 cursor-pointer select-none"
style={{
left: node.x,
top: node.y,
width: CARD_W,
minHeight: CARD_H,
}}
onClick={() => navigate(agent ? agentUrl(agent) : `/agents/${node.id}`)}
onClickCapture={(e) => {
if (!suppressNextCardClick.current) return;
suppressNextCardClick.current = false;
e.preventDefault();
e.stopPropagation();
}}
>
<div className="flex items-center px-4 py-3 gap-3">
{/* Agent icon + status dot */}
<div className="relative shrink-0">
<div className="w-9 h-9 rounded-full bg-muted flex items-center justify-center">
<AgentAvatar agent={agent} size={16} className="h-4.5 w-4.5 text-foreground/70"/>
</div>
<span
className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-card"
style={{ backgroundColor: dotColor }}
/>
</div>
{/* Name + role + adapter type */}
<div className="flex flex-col items-start min-w-0 flex-1">
<span className="text-sm font-semibold text-foreground leading-tight">
{node.name}
</span>
<span className="text-(length:--text-micro) text-muted-foreground leading-tight mt-0.5">
{agent?.title ?? roleLabel(node.role)}
</span>
{agent && (
<span className="text-(length:--text-nano) text-muted-foreground/60 font-mono leading-tight mt-1">
{getAdapterLabel(agentDisplayAdapterType(agent))}
</span>
)}
{agent && agent.capabilities && (
<span className="text-(length:--text-nano) text-muted-foreground/80 leading-tight mt-1 line-clamp-2">
{agent.capabilities}
</span>
)}
</div>
</div>
</Card>
);
})}
</div>
</div>
</div>
);
}
const roleLabels: Record<string, string> = AGENT_ROLE_LABELS;
function roleLabel(role: string): string {
return roleLabels[role] ?? role;
}