diff --git a/webui/src/features/board/harness/agent/arrange-created-nodes.ts b/webui/src/features/board/harness/agent/arrange-created-nodes.ts index 7794e794..58899021 100644 --- a/webui/src/features/board/harness/agent/arrange-created-nodes.ts +++ b/webui/src/features/board/harness/agent/arrange-created-nodes.ts @@ -2,7 +2,7 @@ import { asNodeId, type CanvasStore, type Edge } from "@canvas-harness/core" import type { LinkEdge, NoteNode } from "@/features/board/types/flow" import { autoLayout } from "@/features/board/lib/graph/auto-layout" import { defaultLayoutOptions, type Direction } from "@/features/board/lib/graph/settings" -import { NOTE_TAIL_GAP } from "./beneath-border" +import { offsetToOrigin, originBeneath } from "./beneath-border" type XY = { x: number; y: number } @@ -201,14 +201,10 @@ export const arrangeCreatedNodes = async (store: CanvasStore, createdIds: string (await layoutBidirectional(present, edges, sizeOf)) ?? (await runDagre(present, edges, sizeOf, "LR")) if (positions.size === 0) return - // Translate the laid-out cluster below existing (non-created) content. + // Translate the laid-out cluster below existing (non-created) content — the + // same placement rule the mindmap-apply drain uses (originBeneath). const others = store.getAllNodes().filter((n) => !ids.has(String(n.id))) - const originX = others.length ? Math.min(...others.map((n) => n.x)) : 0 - const originY = others.length ? Math.max(...others.map((n) => n.y + n.h)) + NOTE_TAIL_GAP : 0 - const xs = [...positions.values()].map((p) => p.x) - const ys = [...positions.values()].map((p) => p.y) - const dx = originX - Math.min(...xs) - const dy = originY - Math.min(...ys) + const { x: dx, y: dy } = offsetToOrigin([...positions.values()], originBeneath(others)) store.batch(() => { for (const [id, p] of positions) { diff --git a/webui/src/features/board/harness/agent/beneath-border.test.ts b/webui/src/features/board/harness/agent/beneath-border.test.ts index a0dc22c1..ab0845e4 100644 --- a/webui/src/features/board/harness/agent/beneath-border.test.ts +++ b/webui/src/features/board/harness/agent/beneath-border.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest" import { asNodeId } from "@canvas-harness/core" import { addNode, freshStore, resetIdb } from "@/test/canvas" -import { NOTE_TAIL_GAP, beneathBorderOrigin } from "./beneath-border" +import { NOTE_TAIL_GAP, beneathBorderOrigin, offsetToOrigin, originBeneath } from "./beneath-border" beforeEach(() => resetIdb()) @@ -21,3 +21,45 @@ describe("beneathBorderOrigin", () => { expect(beneathBorderOrigin(store)).toEqual({ x: 40, y: 240 + NOTE_TAIL_GAP }) }) }) + + +describe("originBeneath", () => { + it("returns (0,0) for no nodes", () => { + expect(originBeneath([])).toEqual({ x: 0, y: 0 }) + }) + + it("takes min x and max bottom + gap across the given nodes", () => { + const nodes = [ + { x: 40, y: 0, h: 100 }, // bottom 100, leftmost + { x: 300, y: 60, h: 180 }, // bottom 240 + ] + expect(originBeneath(nodes)).toEqual({ x: 40, y: 240 + NOTE_TAIL_GAP }) + }) +}) + + +describe("offsetToOrigin", () => { + it("no move for an empty cluster", () => { + expect(offsetToOrigin([], { x: 100, y: 500 })).toEqual({ x: 0, y: 0 }) + }) + + it("shifts the cluster's top-left corner onto the origin", () => { + // Cluster min corner is (10, 20); moving it to (100, 500) is (+90, +480). + const cluster = [ + { x: 10, y: 40 }, + { x: 60, y: 20 }, + ] + expect(offsetToOrigin(cluster, { x: 100, y: 500 })).toEqual({ x: 90, y: 480 }) + }) + + it("places a cluster beneath existing content when composed with originBeneath", () => { + // Existing content bottoms out at y=240; a new cluster anchored near the + // origin must shift below it, not overlap. + const existing = [{ x: 40, y: 0, h: 100 }, { x: 300, y: 60, h: 180 }] + const cluster = [{ x: 0, y: 0 }, { x: 0, y: 120 }] + const shift = offsetToOrigin(cluster, originBeneath(existing)) + expect(shift).toEqual({ x: 40, y: 240 + NOTE_TAIL_GAP }) + // Applied: the cluster's top now sits one gap below the lowest existing bottom. + expect(Math.min(...cluster.map((n) => n.y + shift.y))).toBe(240 + NOTE_TAIL_GAP) + }) +}) diff --git a/webui/src/features/board/harness/agent/beneath-border.ts b/webui/src/features/board/harness/agent/beneath-border.ts index 38514240..b9ab1a3c 100644 --- a/webui/src/features/board/harness/agent/beneath-border.ts +++ b/webui/src/features/board/harness/agent/beneath-border.ts @@ -8,18 +8,45 @@ export const NOTE_TAIL_GAP = 80 type XY = { x: number; y: number } +type Box = { x: number; y: number; h: number } /** - * Top-left origin just beneath the current graph's border: left-aligned to the - * leftmost node, one gap below the lowest bottom edge. `(0, 0)` on an empty - * board. Frontend analog of the backend's `compute_note_position` - * (min existing x, max existing bottom + gap). + * Top-left origin just beneath a set of nodes' border: left-aligned to the + * leftmost node, one gap below the lowest bottom edge. `(0, 0)` when empty. + * The shared placement rule for freshly-added content (min x, max bottom + gap). */ -export const beneathBorderOrigin = (store: CanvasStore): XY => { - const nodes = store.getAllNodes() +export const originBeneath = (nodes: ReadonlyArray): XY => { if (nodes.length === 0) return { x: 0, y: 0 } - const x = Math.min(...nodes.map((n) => n.x)) - const y = Math.max(...nodes.map((n) => n.y + n.h)) + NOTE_TAIL_GAP - return { x, y } + // Reduce (not Math.min/max spread) — this runs over the whole board, which can + // be large enough to exceed the engine's call-argument limit when spread. + let minX = Infinity + let maxBottom = -Infinity + for (const n of nodes) { + if (n.x < minX) minX = n.x + const bottom = n.y + n.h + if (bottom > maxBottom) maxBottom = bottom + } + return { x: minX, y: maxBottom + NOTE_TAIL_GAP } } + + +/** + * The (dx, dy) that moves a freshly-laid-out cluster so its top-left corner + * lands on `origin` (left edge → origin.x, top → origin.y). No move for an + * empty cluster. Apply the result to every node in the cluster. + */ +export const offsetToOrigin = (cluster: ReadonlyArray, origin: XY): XY => { + if (cluster.length === 0) return { x: 0, y: 0 } + const minX = Math.min(...cluster.map((n) => n.x)) + const minY = Math.min(...cluster.map((n) => n.y)) + return { x: origin.x - minX, y: origin.y - minY } +} + + +/** + * Top-left origin just beneath the current graph's border. Frontend analog of + * the backend's `compute_note_position` (min existing x, max existing bottom + + * gap). `(0, 0)` on an empty board. + */ +export const beneathBorderOrigin = (store: CanvasStore): XY => originBeneath(store.getAllNodes()) diff --git a/webui/src/features/board/harness/agent/use-harness-apply-mindmap.ts b/webui/src/features/board/harness/agent/use-harness-apply-mindmap.ts index 3e12ccc5..da7baa54 100644 --- a/webui/src/features/board/harness/agent/use-harness-apply-mindmap.ts +++ b/webui/src/features/board/harness/agent/use-harness-apply-mindmap.ts @@ -7,6 +7,7 @@ import type { Link } from "@/features/board/types/link" import type { Note } from "@/features/board/types/note" import { linkToEdge } from "../convert/link-to-edge" import { noteToNode } from "../convert/note-to-node" +import { NOTE_TAIL_GAP, offsetToOrigin, originBeneath } from "./beneath-border" /** @@ -29,11 +30,16 @@ export const useHarnessApplyMindMap = ( store: CanvasStore, boardId: string | null, rootId: string | null, + ready: boolean, ): void => { const drainingRef = useRef(false) useEffect(() => { - if (!boardId) return + // Wait for hydration: draining into an empty (not-yet-loaded) store would + // place the cluster at {0,0} (originBeneath of nothing) and then be overlapped + // by the existing nodes that hydrate a moment later. When `ready` flips true + // this effect re-runs and drains anything staged in the meantime. + if (!boardId || !ready) return const drain = (): void => { if (drainingRef.current) return @@ -44,9 +50,23 @@ export const useHarnessApplyMindMap = ( drainingRef.current = true try { const ops: Op[] = [] + // Place each staged cluster beneath existing board content (and beneath + // any cluster already placed in this drain), left-aligned — the same + // rule write_note + arrangeCreatedNodes follow. autoLayout anchors the + // staged nodes near the origin with no knowledge of what's on the board, + // so without this translate they land on top of existing nodes. + const start = originBeneath(store.getAllNodes()) + const originX = start.x + let frontierY = start.y for (const mindmap of pending) { const { nodes, edges } = mindmap - const harnessNodes = convertStagedNodes(nodes, rootId, boardId) + const staged = convertStagedNodes(nodes, rootId, boardId) + const shift = offsetToOrigin(staged, { x: originX, y: frontierY }) + const harnessNodes = staged.map((n) => ({ ...n, x: n.x + shift.x, y: n.y + shift.y })) + if (harnessNodes.length > 0) { + // Next cluster stacks below this one. + frontierY = Math.max(...harnessNodes.map((n) => n.y + n.h)) + NOTE_TAIL_GAP + } const nodeMap = new Map( harnessNodes.map((n) => [n.id as unknown as string, n]), ) @@ -77,7 +97,7 @@ export const useHarnessApplyMindMap = ( const b = next.mindmaps.get(boardId)?.length ?? 0 if (b > a) drain() }) - }, [store, boardId, rootId]) + }, [store, boardId, rootId, ready]) } diff --git a/webui/src/features/board/harness/canvas/harness-canvas.tsx b/webui/src/features/board/harness/canvas/harness-canvas.tsx index 9da4b12d..1acb790b 100644 --- a/webui/src/features/board/harness/canvas/harness-canvas.tsx +++ b/webui/src/features/board/harness/canvas/harness-canvas.tsx @@ -183,7 +183,7 @@ export function HarnessCanvas({ local = false }: { local?: boolean } = {}) { useLocalDocIndex(boardId ?? "", agentLocalIndexes) useDocNodeCascade(store, boardId ?? "", agentLocalIndexes) useBlockFolderCopy(store) - useHarnessApplyMindMap(store, boardId, rootId) + useHarnessApplyMindMap(store, boardId, rootId, ready) useHydrateIconNodes(store, boardId, rootId, ready) useThemeColorProjection(store, ready) // Local boards store the thumbnail in IndexedDB; capture only at the root