diff --git a/src/features/org/OrgCanvas.tsx b/src/features/org/OrgCanvas.tsx index 2e15745a..9dc8052a 100644 --- a/src/features/org/OrgCanvas.tsx +++ b/src/features/org/OrgCanvas.tsx @@ -28,14 +28,18 @@ interface CanvasProps { connecting: boolean; /** True during a background pan-drag — suppresses the edge click that ends it. */ dragMoved: React.MutableRefObject; - /** Synthetic pool nodes (a collapsed swarm) keyed by nodeId — rendered as stacked cards that drill in - * instead of select/drag. Empty inside a pool's own sub-graph. */ + /** Synthetic pool nodes (a collapsed swarm) keyed by nodeId — rendered as stacked cards: a click + * drills in, a drag moves the whole stack (#2439). Empty inside a pool's own sub-graph. */ poolInfo?: Record; onSelectNode: (nodeId: string) => void; onSelectEdge: (relId: string) => void; onMoveNode: (nodeId: string, x: number, y: number) => void; /** Enter a pool's own graph (clicking its stacked card). */ onDrillPool?: (poolNodeId: string) => void; + /** A pool card was DRAGGED by (dx, dy) design-space units (#2439). The pool node is synthetic — + * rendered at its members' centroid — so the owner shifts every member by the delta; the centroid + * then lands where the user dropped the stack. */ + onMovePool?: (poolNodeId: string, dx: number, dy: number) => void; /** Right-click a node/edge → open the canvas context menu (delete, …). #2385. */ onContext?: (sel: Selection, e: React.MouseEvent) => void; } @@ -68,7 +72,7 @@ function AgentFace({ d, isSel }: { d: PositionDisplay; isSel: boolean }) { /** The world-layer content — placed inside GraphCanvas's transformed world box. */ export function OrgCanvas(props: CanvasProps) { const { org, personas, sel, scale, connecting, dragMoved, poolInfo, - onSelectNode, onSelectEdge, onMoveNode, onDrillPool, onContext } = props; + onSelectNode, onSelectEdge, onMoveNode, onDrillPool, onMovePool, onContext } = props; /** Right-click a node/edge → open the context menu at the cursor (delete). */ const context = (s: Selection) => (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); onContext?.(s, e); }; @@ -76,22 +80,22 @@ export function OrgCanvas(props: CanvasProps) { // Live node-drag preview (design-space x/y). Commits to the store on drop. const [drag, setDrag] = useState<{ nodeId: string; x: number; y: number } | null>(null); const gesture = useRef<{ nodeId: string; sx: number; sy: number; bx: number; by: number; moved: boolean } | null>(null); - // A pool card's press is a drill (a click), never a drag — its own tiny gesture tracks click-vs-drag. - const poolGesture = useRef<{ sx: number; sy: number; moved: boolean } | null>(null); - const onPoolDown = (e: React.PointerEvent) => { + // A pool card shares the node drag machinery (#2439) — the live preview moves the stacked card via + // `at()` like any node. Only the drop differs: a drag reports the DELTA (the owner shifts every + // member, since the synthetic node re-renders at the members' centroid); a click drills in. + const onPoolDown = (poolNodeId: string) => (e: React.PointerEvent) => { e.stopPropagation(); - poolGesture.current = { sx: e.clientX, sy: e.clientY, moved: false }; + const b = boxes.get(poolNodeId)!; + gesture.current = { nodeId: poolNodeId, sx: e.clientX, sy: e.clientY, bx: b.x, by: b.y, moved: false }; (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); }; - const onPoolMove = (e: React.PointerEvent) => { - const g = poolGesture.current; - if (g && Math.hypot(e.clientX - g.sx, e.clientY - g.sy) > DRAG_THRESHOLD) g.moved = true; - }; const onPoolUp = (poolNodeId: string) => (e: React.PointerEvent) => { - const g = poolGesture.current; - poolGesture.current = null; + const g = gesture.current; + gesture.current = null; + setDrag(null); (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId); - if (g && !g.moved) onDrillPool?.(poolNodeId); + if (g && g.moved) onMovePool?.(poolNodeId, Math.round((e.clientX - g.sx) / scale), Math.round((e.clientY - g.sy) / scale)); + else onDrillPool?.(poolNodeId); }; /** The x/y to render a node at — its live drag preview if being dragged, else its stored box. */ @@ -203,15 +207,15 @@ export function OrgCanvas(props: CanvasProps) { const dim = focused && !nodeActive(pos.nodeId); const pool = poolInfo?.[pos.nodeId]; - // A pool: a stacked card (offset shadow cards behind + a ×N badge). A press drills into the - // pool's own graph rather than selecting/dragging. + // A pool: a stacked card (offset shadow cards behind + a ×N badge). A click drills into the + // pool's own graph; a drag moves the whole stack (#2439 — the drop shifts every member). if (pool) { return ( // eslint-disable-next-line no-restricted-syntax -- data-node marks it as owning its own press gesture (no background pan)
+ cursor: "pointer", zIndex: isSel ? 6 : 3, opacity: dim ? 0.5 : 1, transition: drag ? "none" : "opacity .15s", touchAction: "none", userSelect: "none" }}> {/* stacked shadow cards behind the face — the "N of them" cue */} @@ -233,7 +237,7 @@ export function OrgCanvas(props: CanvasProps) { onPointerDown={onNodeDown(pos.nodeId)} onPointerMove={onNodeMove} onPointerUp={onNodeUp(pos.nodeId)} onContextMenu={context({ type: "node", id: pos.nodeId })} style={{ position: "absolute", left: xy.x, top: xy.y, width: box.w, height: box.h, - cursor: connecting ? "crosshair" : "grab", zIndex: isSel ? 6 : 3, opacity: dim ? 0.5 : 1, transition: drag ? "none" : "opacity .15s", touchAction: "none" }}> + cursor: connecting ? "crosshair" : "grab", zIndex: isSel ? 6 : 3, opacity: dim ? 0.5 : 1, transition: drag ? "none" : "opacity .15s", touchAction: "none", userSelect: "none" }}> {pos.kind === "agent" && } {pos.kind === "resource" && ( { it("opens with nothing selected — the inspector shows no position (no 'Persona' section)", () => { @@ -35,3 +37,50 @@ describe("OrgPanel initial selection (#2333)", () => { expect(screen.getByText("Persona")).toBeTruthy(); }); }); + +describe("pool card gesture — drag moves the stack, click drills (#2439)", () => { + // Fleet Alpha's two engineers stack (#2436), so the seeded panel renders one pool card. The card + // is the [data-node] wrapper around the drill hint. + const poolCard = (): HTMLElement => { + const hint = screen.getByText(/click to open/i); + return hint.closest("[data-node]") as HTMLElement; + }; + + it("a plain click drills into the pool (unchanged)", () => { + render(); + const card = poolCard(); + fireEvent.pointerDown(card, { clientX: 100, clientY: 100 }); + fireEvent.pointerUp(card, { clientX: 101, clientY: 101 }); // < DRAG_THRESHOLD → click + expect(screen.getByText("← back")).toBeTruthy(); + }); + + it("a drag shifts every member by the same delta and does NOT drill", () => { + render(); + const orgBefore = useAppStore.getState().orgs[0]; + const before = new Map(orgBefore.positions.map((p) => [p.nodeId, nodeBox(p)])); + const members = orgBefore.positions.filter((p) => p.personaId === "persona-worker").map((p) => p.nodeId); + expect(members.length).toBeGreaterThanOrEqual(2); // sanity: the stacked engineers + + const card = poolCard(); + fireEvent.pointerDown(card, { clientX: 100, clientY: 100 }); + fireEvent.pointerMove(card, { clientX: 160, clientY: 140 }); // > DRAG_THRESHOLD → drag + fireEvent.pointerUp(card, { clientX: 160, clientY: 140 }); + + expect(screen.queryByText("← back")).toBeNull(); // a drag never drills + // Every member shifted by ONE shared (dx, dy) — the centroid follows the drop, the pool's + // internal arrangement is preserved for the drill-in. + const after = useAppStore.getState().orgs[0]; + const deltas = members.map((m) => { + const b = before.get(m)!; + const p = after.positions.find((x) => x.nodeId === m)!; + return { dx: (p.x ?? 0) - b.x, dy: (p.y ?? 0) - b.y }; + }); + expect(deltas[0].dx).not.toBe(0); + for (const d of deltas) expect(d).toEqual(deltas[0]); + }); + + it("node cards are not text-selectable (drag never highlights labels)", () => { + render(); + expect(poolCard().style.userSelect).toBe("none"); + }); +}); diff --git a/src/features/org/OrgPanel.tsx b/src/features/org/OrgPanel.tsx index 60f2f717..32accac9 100644 --- a/src/features/org/OrgPanel.tsx +++ b/src/features/org/OrgPanel.tsx @@ -21,7 +21,7 @@ import { OrgCanvas, OrgLegend, type Selection } from "./OrgCanvas"; import { OrgInspector } from "./OrgInspector"; import { OrgContextMenu } from "./OrgContextMenu"; import { RELATIONSHIP_ARCHETYPES } from "./lib/org"; -import { autoLayout, CANVAS_W, CANVAS_H } from "./lib/orgLayout"; +import { autoLayout, nodeBox, CANVAS_W, CANVAS_H } from "./lib/orgLayout"; import { detectPools, collapseOrg, poolSubgraph, type Pool } from "./lib/orgPools"; import { positionDisplay, hueColor } from "./lib/orgView"; import { overlayFile } from "@/shared/lib/core/configOverrides"; @@ -256,6 +256,19 @@ export function OrgPanel() { onSelectNode={onSelectNode} onSelectEdge={(id) => setSel({ type: "edge", id })} onMoveNode={(nodeId, x, y) => updatePosition(org.id, nodeId, { x, y })} onDrillPool={onDrillPool} + onMovePool={(poolNodeId, dx, dy) => { + // The pool node is synthetic (rendered at its members' centroid) — commit a stack drag by + // shifting EVERY member by the delta, so the centroid lands at the drop point and the + // drill-in keeps its relative arrangement (#2439). nodeBox supplies the same default + // position the canvas rendered, so members without a stored x/y shift from where they SHOW. + const members = collapsed.poolInfo[poolNodeId]?.memberNodeIds ?? []; + for (const m of members) { + const pos = org.positions.find((p) => p.nodeId === m); + if (!pos) continue; + const b = nodeBox(pos); + updatePosition(org.id, m, { x: b.x + dx, y: b.y + dy }); + } + }} onContext={(target, e) => { setSel(target); setMenu({ x: e.clientX, y: e.clientY, target }); }} />