diff --git a/README.md b/README.md index 5b1d2a77..bf84acb0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 532d301e..d4036c3d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/cli/README.md b/cli/README.md index be390d19..98dffcad 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 diff --git a/ui/src/pages/OrgChart.test.tsx b/ui/src/pages/OrgChart.test.tsx new file mode 100644 index 00000000..ee4b9f36 --- /dev/null +++ b/ui/src/pages/OrgChart.test.tsx @@ -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 }) => {children}, + 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: () => , +})); + +// 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; + 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( + + + , + ); + }); + 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)"); + }); +}); diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx index fdfd6b90..c5ea70e5 100644 --- a/ui/src/pages/OrgChart.tsx +++ b/ui/src/pages/OrgChart.tsx @@ -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({ + 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(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) => { + 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) => { + 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 ; } @@ -259,179 +441,182 @@ export function OrgChart() { } return ( -
-
- - - - - - -
-
- {/* Zoom controls */} -
- - - +
+
+ + + + + +
- - {/* SVG layer for edges */} - - - {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 */} +
+ + + +
+ + {/* SVG layer for edges */} + + + {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 ( + + ); + })} + + + + {/* Card layer */} +
+ {allNodes.map((node) => { + const agent = agentMap.get(node.id); + const dotColor = statusDotColor[node.status] ?? defaultDotColor; return ( - - ); - })} - - - - {/* Card layer */} -
- {allNodes.map((node) => { - const agent = agentMap.get(node.id); - const dotColor = statusDotColor[node.status] ?? defaultDotColor; - - return ( -
navigate(agent ? agentUrl(agent) : `/agents/${node.id}`)} - > -
- {/* Agent icon + status dot */} -
-
- +
navigate(agent ? agentUrl(agent) : `/agents/${node.id}`)} + onClickCapture={(e) => { + if (!suppressNextCardClick.current) return; + suppressNextCardClick.current = false; + e.preventDefault(); + e.stopPropagation(); + }} + > +
+ {/* Agent icon + status dot */} +
+
+ +
+
- -
- {/* Name + role + adapter type */} -
- - {node.name} - - - {agent?.title ?? roleLabel(node.role)} - - {agent && ( - - {getAdapterLabel(agent.adapterType)} + {/* Name + role + adapter type */} +
+ + {node.name} - )} - {agent && agent.capabilities && ( - - {agent.capabilities} + + {agent?.title ?? roleLabel(node.role)} - )} + {agent && ( + + {getAdapterLabel(agent.adapterType)} + + )} + {agent && agent.capabilities && ( + + {agent.capabilities} + + )} +
-
- ); - })} + ); + })} +
-
); }