Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 23 additions & 19 deletions src/features/org/OrgCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,18 @@ interface CanvasProps {
connecting: boolean;
/** True during a background pan-drag — suppresses the edge click that ends it. */
dragMoved: React.MutableRefObject<boolean>;
/** 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<string, Pool>;
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;
}
Expand Down Expand Up @@ -68,30 +72,30 @@ 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); };

const boxes = new Map(org.positions.map((p) => [p.nodeId, nodeBox(p)]));
// 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. */
Expand Down Expand Up @@ -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)
<div key={pos.nodeId} data-node={pos.nodeId}
onPointerDown={onPoolDown} onPointerMove={onPoolMove} onPointerUp={onPoolUp(pos.nodeId)}
onPointerDown={onPoolDown(pos.nodeId)} onPointerMove={onNodeMove} onPointerUp={onPoolUp(pos.nodeId)}
style={{ position: "absolute", left: xy.x, top: xy.y, width: box.w, height: box.h,
cursor: "pointer", zIndex: isSel ? 6 : 3, opacity: dim ? 0.5 : 1, transition: "opacity .15s", touchAction: "none" }}>
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 */}
<Box style={{ position: "absolute", inset: 0, transform: "translate(11px,11px)", borderRadius: 13, background: "var(--bg-elev)", border: "1px solid var(--border)", opacity: 0.45 }} />
<Box style={{ position: "absolute", inset: 0, transform: "translate(6px,6px)", borderRadius: 13, background: "var(--bg-elev)", border: "1px solid var(--border)", opacity: 0.7 }} />
Expand All @@ -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" && <AgentFace d={d} isSel={isSel} />}
{pos.kind === "resource" && (
<Box style={{ width: "100%", height: "100%", boxSizing: "border-box", padding: "11px 13px", display: "flex", flexDirection: "column", justifyContent: "center",
Expand Down
49 changes: 49 additions & 0 deletions src/features/org/OrgPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { OrgPanel } from "./OrgPanel";
import { useAppStore } from "@/store";
import { nodeBox } from "./lib/orgLayout";

describe("OrgPanel initial selection (#2333)", () => {
it("opens with nothing selected — the inspector shows no position (no 'Persona' section)", () => {
Expand Down Expand Up @@ -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(<OrgPanel />);
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(<OrgPanel />);
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(<OrgPanel />);
expect(poolCard().style.userSelect).toBe("none");
});
});
15 changes: 14 additions & 1 deletion src/features/org/OrgPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 }); }}
/>
</Box>
Expand Down
Loading