From 0acdabfb5a96781d4a41779fba991fa462423b11 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Mon, 24 Aug 2026 11:37:17 +0200 Subject: [PATCH 1/2] fix(board): place applied mindmaps beneath existing board content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mindmap-apply drain (drawify, and mapify/schemify/etc. on synced boards) wrote autoLayout's origin-anchored coordinates straight onto the canvas, so new clusters landed on top of existing nodes. autoLayout only sees the new nodes, so it can't account for what's already on the board. Unify both placement paths on a shared rule (originBeneath + offsetToOrigin in beneath-border): the drain now translates each staged cluster to sit below existing content (and below any cluster already placed this drain), left-aligned — matching write_note + arrangeCreatedNodes. arrangeCreatedNodes is refactored onto the same helpers. --- .../harness/agent/arrange-created-nodes.ts | 12 ++--- .../harness/agent/beneath-border.test.ts | 44 ++++++++++++++++++- .../board/harness/agent/beneath-border.ts | 32 +++++++++++--- .../agent/use-harness-apply-mindmap.ts | 17 ++++++- 4 files changed, 89 insertions(+), 16 deletions(-) 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..9a03a2d7 100644 --- a/webui/src/features/board/harness/agent/beneath-border.ts +++ b/webui/src/features/board/harness/agent/beneath-border.ts @@ -8,18 +8,38 @@ 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 } } + + +/** + * 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..74cff0c4 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" /** @@ -44,9 +45,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]), ) From 96812e392ef8d620c3872edc321364cebb9fb12b Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Mon, 24 Aug 2026 11:45:35 +0200 Subject: [PATCH 2/2] fix(board): gate mindmap drain on hydration + harden origin scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review findings on the placement fix: - Gate useHarnessApplyMindMap's drain on the board's `ready` flag. A drain racing hydration would compute originBeneath over an empty store ({0,0}) and place the cluster at the origin, only to be overlapped by nodes that hydrate a moment later. When ready flips true the effect re-runs and drains anything staged meanwhile. - Compute originBeneath's min-x / max-bottom with a reduce instead of Math.min/max argument spread — it now runs over the whole board (the shared placement path), which could exceed the engine's argument limit when spread on a very large board. --- .../features/board/harness/agent/beneath-border.ts | 13 ++++++++++--- .../harness/agent/use-harness-apply-mindmap.ts | 9 +++++++-- .../board/harness/canvas/harness-canvas.tsx | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/webui/src/features/board/harness/agent/beneath-border.ts b/webui/src/features/board/harness/agent/beneath-border.ts index 9a03a2d7..b9ab1a3c 100644 --- a/webui/src/features/board/harness/agent/beneath-border.ts +++ b/webui/src/features/board/harness/agent/beneath-border.ts @@ -18,9 +18,16 @@ type Box = { x: number; y: number; h: number } */ 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 } } 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 74cff0c4..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 @@ -30,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 @@ -92,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