mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
[codex] Improve mobile org chart navigation (#4127)
## Thinking Path > - Paperclip models companies as teams of human and AI operators > - The org chart is the primary visual map of that company structure > - Mobile users need to pan and inspect the chart without awkward gestures or layout jumps > - The roadmap also needed to reflect that the multiple-human-users work is complete > - This pull request improves mobile org chart gestures and updates the roadmap references > - The benefit is a smoother company navigation experience and docs that match shipped multi-user support ## What Changed - Added one-finger mobile pan handling for the org chart. - Expanded org chart test coverage for touch gesture behavior. - Updated README, ROADMAP, and CLI README references to mark multiple-human-users work as complete. ## Verification - `pnpm install --frozen-lockfile --ignore-scripts` - `pnpm exec vitest run ui/src/pages/OrgChart.test.tsx` - Result: 4 tests passed. ## Risks - Low-medium risk: org chart pointer/touch handling changed, but the behavior is scoped to the org chart page and covered by targeted tests. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent based on GPT-5, tool-enabled local shell and GitHub workflow, exact runtime context window not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots, or documented why targeted interaction tests are sufficient here - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -256,7 +256,7 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide.
|
||||
- ✅ Scheduled Routines
|
||||
- ✅ Better Budgeting
|
||||
- ✅ Agent Reviews and Approvals
|
||||
- ⚪ Multiple Human Users
|
||||
- ✅ Multiple Human Users
|
||||
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
||||
- ⚪ Artifacts & Work Products
|
||||
- ⚪ Memory / Knowledge
|
||||
|
||||
@@ -44,7 +44,7 @@ Budgets are a core control-plane feature, not an afterthought. Better budgeting
|
||||
|
||||
Paperclip should support explicit review and approval stages as first-class workflow steps, not just ad hoc comments. That means reviewer routing, approval gates, change requests, and durable audit trails that fit the same task model as the rest of the control plane.
|
||||
|
||||
### ⚪ Multiple Human Users
|
||||
### ✅ Multiple Human Users
|
||||
|
||||
Paperclip needs a clearer path from solo operator to real human teams. That means shared board access, safer collaboration, and a better model for several humans supervising the same autonomous company.
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc
|
||||
- ⚪ Artifacts & Deployments
|
||||
- ⚪ CEO Chat
|
||||
- ⚪ MAXIMIZER MODE
|
||||
- ⚪ Multiple Human Users
|
||||
- ✅ Multiple Human Users
|
||||
- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents)
|
||||
- ⚪ Cloud deployments
|
||||
- ⚪ Desktop App
|
||||
|
||||
266
ui/src/pages/OrgChart.test.tsx
Normal file
266
ui/src/pages/OrgChart.test.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { OrgChart } from "./OrgChart";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
const orgMock = vi.fn();
|
||||
const listMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
|
||||
useNavigate: () => navigateMock,
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({ selectedCompanyId: "company-1" }),
|
||||
}));
|
||||
|
||||
vi.mock("../context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../api/agents", () => ({
|
||||
agentsApi: {
|
||||
org: () => orgMock(),
|
||||
list: () => listMock(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../components/AgentIconPicker", () => ({
|
||||
AgentIcon: () => <span data-testid="agent-icon" />,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const orgTree = [
|
||||
{
|
||||
id: "agent-1",
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
status: "active",
|
||||
reports: [
|
||||
{
|
||||
id: "agent-2",
|
||||
name: "Engineer",
|
||||
role: "engineer",
|
||||
status: "active",
|
||||
reports: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const agents = [
|
||||
{
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "CEO",
|
||||
role: "ceo",
|
||||
title: null,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
contextMode: "thin",
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
icon: "briefcase",
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
|
||||
urlKey: "ceo",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: null,
|
||||
},
|
||||
{
|
||||
id: "agent-2",
|
||||
companyId: "company-1",
|
||||
name: "Engineer",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
status: "active",
|
||||
reportsTo: "agent-1",
|
||||
capabilities: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
contextMode: "thin",
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
icon: "code",
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-04-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
|
||||
urlKey: "engineer",
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: null,
|
||||
},
|
||||
];
|
||||
|
||||
function createTouchEvent(type: string, touches: Array<{ clientX: number; clientY: number }>) {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "touches", {
|
||||
value: touches,
|
||||
});
|
||||
Object.defineProperty(event, "changedTouches", {
|
||||
value: touches,
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe("OrgChart mobile gestures", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
orgMock.mockResolvedValue(orgTree);
|
||||
listMock.mockResolvedValue(agents);
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.getAttribute("data-testid") === "org-chart-viewport" ? 360 : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.getAttribute("data-testid") === "org-chart-viewport" ? 520 : 0;
|
||||
},
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function getRect(this: HTMLElement) {
|
||||
if (this.getAttribute("data-testid") === "org-chart-viewport") {
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 360,
|
||||
bottom: 520,
|
||||
width: 360,
|
||||
height: 520,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderOrgChart() {
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OrgChart />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
return {
|
||||
viewport: container.querySelector('[data-testid="org-chart-viewport"]') as HTMLDivElement,
|
||||
layer: container.querySelector('[data-testid="org-chart-card-layer"]') as HTMLDivElement,
|
||||
};
|
||||
}
|
||||
|
||||
it("pans the chart with one-finger touch drag", async () => {
|
||||
const { viewport, layer } = await renderOrgChart();
|
||||
|
||||
await act(async () => {
|
||||
viewport.dispatchEvent(createTouchEvent("touchstart", [{ clientX: 100, clientY: 100 }]));
|
||||
viewport.dispatchEvent(createTouchEvent("touchmove", [{ clientX: 130, clientY: 145 }]));
|
||||
viewport.dispatchEvent(createTouchEvent("touchend", []));
|
||||
});
|
||||
|
||||
expect(layer.style.transform).toBe("translate(50px, 105px) scale(1)");
|
||||
});
|
||||
|
||||
it("suppresses card navigation after a touch pan", async () => {
|
||||
const { viewport } = await renderOrgChart();
|
||||
const card = container.querySelector("[data-org-card]") as HTMLDivElement;
|
||||
|
||||
await act(async () => {
|
||||
viewport.dispatchEvent(createTouchEvent("touchstart", [{ clientX: 100, clientY: 100 }]));
|
||||
viewport.dispatchEvent(createTouchEvent("touchmove", [{ clientX: 130, clientY: 145 }]));
|
||||
viewport.dispatchEvent(createTouchEvent("touchend", []));
|
||||
card.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows card navigation after a touch tap without movement", async () => {
|
||||
const { viewport } = await renderOrgChart();
|
||||
const card = container.querySelector("[data-org-card]") as HTMLDivElement;
|
||||
|
||||
await act(async () => {
|
||||
viewport.dispatchEvent(createTouchEvent("touchstart", [{ clientX: 100, clientY: 100 }]));
|
||||
viewport.dispatchEvent(createTouchEvent("touchend", []));
|
||||
card.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith("/agents/ceo");
|
||||
});
|
||||
|
||||
it("pinch-zooms toward the touch center", async () => {
|
||||
const { viewport, layer } = await renderOrgChart();
|
||||
|
||||
await act(async () => {
|
||||
viewport.dispatchEvent(createTouchEvent("touchstart", [
|
||||
{ clientX: 100, clientY: 100 },
|
||||
{ clientX: 200, clientY: 100 },
|
||||
]));
|
||||
viewport.dispatchEvent(createTouchEvent("touchmove", [
|
||||
{ clientX: 75, clientY: 100 },
|
||||
{ clientX: 225, clientY: 100 },
|
||||
]));
|
||||
viewport.dispatchEvent(createTouchEvent("touchend", []));
|
||||
});
|
||||
|
||||
expect(layer.style.transform).toBe("translate(-45px, 40px) scale(1.5)");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import { Download, Network, Upload } from "lucide-react";
|
||||
import { Download, Maximize2, Minus, Network, Plus, Upload } from "lucide-react";
|
||||
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
|
||||
|
||||
// Layout constants
|
||||
@@ -19,6 +19,9 @@ 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 TOUCH_MOVE_THRESHOLD = 6;
|
||||
|
||||
// ── Tree layout types ───────────────────────────────────────────────────
|
||||
|
||||
@@ -32,6 +35,21 @@ interface LayoutNode {
|
||||
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. */
|
||||
@@ -114,6 +132,28 @@ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: L
|
||||
return edges;
|
||||
}
|
||||
|
||||
function clampZoom(value: number): number {
|
||||
return Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM);
|
||||
}
|
||||
|
||||
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 { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
@@ -179,6 +219,25 @@ export function OrgChart() {
|
||||
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);
|
||||
@@ -235,7 +294,7 @@ export function OrgChart() {
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const factor = e.deltaY < 0 ? 1.1 : 0.9;
|
||||
const newZoom = Math.min(Math.max(zoom * factor, 0.2), 2);
|
||||
const newZoom = clampZoom(zoom * factor);
|
||||
|
||||
// Zoom toward mouse position
|
||||
const scale = newZoom / zoom;
|
||||
@@ -246,6 +305,129 @@ export function OrgChart() {
|
||||
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 cW = containerRef.current.clientWidth;
|
||||
const cH = containerRef.current.clientHeight;
|
||||
const scaleX = (cW - 40) / bounds.width;
|
||||
const scaleY = (cH - 40) / bounds.height;
|
||||
const fitZoom = Math.min(scaleX, scaleY, 1);
|
||||
const chartW = bounds.width * fitZoom;
|
||||
const chartH = bounds.height * fitZoom;
|
||||
setZoom(fitZoom);
|
||||
setPan({ x: (cW - chartW) / 2, y: (cH - chartH) / 2 });
|
||||
}, [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 a company to view the org chart." />;
|
||||
}
|
||||
@@ -259,179 +441,182 @@ export function OrgChart() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="mb-2 flex items-center justify-start gap-2 shrink-0">
|
||||
<Link to="/company/import">
|
||||
<Button variant="outline" size="sm">
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
Import company
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/company/export">
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export company
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full flex-1 min-h-0 overflow-hidden relative bg-muted/20 border border-border rounded-lg"
|
||||
style={{ cursor: dragging ? "grabbing" : "grab" }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{/* Zoom controls */}
|
||||
<div className="absolute top-3 right-3 z-10 flex flex-col gap-1">
|
||||
<button
|
||||
className="w-7 h-7 flex items-center justify-center bg-background border border-border rounded text-sm hover:bg-accent transition-colors"
|
||||
onClick={() => {
|
||||
const newZoom = Math.min(zoom * 1.2, 2);
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
const cx = container.clientWidth / 2;
|
||||
const cy = container.clientHeight / 2;
|
||||
const scale = newZoom / zoom;
|
||||
setPan({ x: cx - scale * (cx - pan.x), y: cy - scale * (cy - pan.y) });
|
||||
}
|
||||
setZoom(newZoom);
|
||||
}}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
className="w-7 h-7 flex items-center justify-center bg-background border border-border rounded text-sm hover:bg-accent transition-colors"
|
||||
onClick={() => {
|
||||
const newZoom = Math.max(zoom * 0.8, 0.2);
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
const cx = container.clientWidth / 2;
|
||||
const cy = container.clientHeight / 2;
|
||||
const scale = newZoom / zoom;
|
||||
setPan({ x: cx - scale * (cx - pan.x), y: cy - scale * (cy - pan.y) });
|
||||
}
|
||||
setZoom(newZoom);
|
||||
}}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
className="w-7 h-7 flex items-center justify-center bg-background border border-border rounded text-[10px] hover:bg-accent transition-colors"
|
||||
onClick={() => {
|
||||
if (!containerRef.current) return;
|
||||
const cW = containerRef.current.clientWidth;
|
||||
const cH = containerRef.current.clientHeight;
|
||||
const scaleX = (cW - 40) / bounds.width;
|
||||
const scaleY = (cH - 40) / bounds.height;
|
||||
const fitZoom = Math.min(scaleX, scaleY, 1);
|
||||
const chartW = bounds.width * fitZoom;
|
||||
const chartH = bounds.height * fitZoom;
|
||||
setZoom(fitZoom);
|
||||
setPan({ x: (cW - chartW) / 2, y: (cH - chartH) / 2 });
|
||||
}}
|
||||
title="Fit to screen"
|
||||
aria-label="Fit chart to screen"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
<div className="flex h-[calc(100dvh-9rem)] min-h-[420px] flex-col md:h-full md:min-h-0">
|
||||
<div className="mb-2 flex shrink-0 flex-wrap items-center justify-start gap-2">
|
||||
<Link to="/company/import">
|
||||
<Button variant="outline" size="sm">
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
Import company
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/company/export">
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export company
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* SVG layer for edges */}
|
||||
<svg
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
<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={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
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}
|
||||
>
|
||||
<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;
|
||||
{/* 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-[10px] 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 (
|
||||
<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
|
||||
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 (
|
||||
<div
|
||||
key={node.id}
|
||||
data-org-card
|
||||
className="absolute bg-card border border-border rounded-lg shadow-sm hover:shadow-md hover:border-foreground/20 transition-[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}`)}
|
||||
>
|
||||
<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">
|
||||
<AgentIcon icon={agent?.icon} className="h-4.5 w-4.5 text-foreground/70" />
|
||||
<div
|
||||
key={node.id}
|
||||
data-org-card
|
||||
className="absolute bg-card border border-border rounded-lg shadow-sm hover:shadow-md hover:border-foreground/20 transition-[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">
|
||||
<AgentIcon icon={agent?.icon} 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>
|
||||
<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-[11px] text-muted-foreground leading-tight mt-0.5">
|
||||
{agent?.title ?? roleLabel(node.role)}
|
||||
</span>
|
||||
{agent && (
|
||||
<span className="text-[10px] text-muted-foreground/60 font-mono leading-tight mt-1">
|
||||
{getAdapterLabel(agent.adapterType)}
|
||||
{/* 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>
|
||||
)}
|
||||
{agent && agent.capabilities && (
|
||||
<span className="text-[10px] text-muted-foreground/80 leading-tight mt-1 line-clamp-2">
|
||||
{agent.capabilities}
|
||||
<span className="text-[11px] text-muted-foreground leading-tight mt-0.5">
|
||||
{agent?.title ?? roleLabel(node.role)}
|
||||
</span>
|
||||
)}
|
||||
{agent && (
|
||||
<span className="text-[10px] text-muted-foreground/60 font-mono leading-tight mt-1">
|
||||
{getAdapterLabel(agent.adapterType)}
|
||||
</span>
|
||||
)}
|
||||
{agent && agent.capabilities && (
|
||||
<span className="text-[10px] text-muted-foreground/80 leading-tight mt-1 line-clamp-2">
|
||||
{agent.capabilities}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user