From 56b31209715cf1b2f52b23cbadebcc407cf6d90a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 20 Apr 2026 10:35:33 -0500 Subject: [PATCH] [codex] Improve mobile org chart navigation (#4127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- README.md | 2 +- ROADMAP.md | 2 +- cli/README.md | 2 +- ui/src/pages/OrgChart.test.tsx | 266 +++++++++++++++++ ui/src/pages/OrgChart.tsx | 509 ++++++++++++++++++++++----------- 5 files changed, 616 insertions(+), 165 deletions(-) create mode 100644 ui/src/pages/OrgChart.test.tsx 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} + + )} +
-
- ); - })} + ); + })} +
-
); }