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
12 changes: 4 additions & 8 deletions webui/src/features/board/harness/agent/arrange-created-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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) {
Expand Down
44 changes: 43 additions & 1 deletion webui/src/features/board/harness/agent/beneath-border.test.ts
Original file line number Diff line number Diff line change
@@ -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())
Expand All @@ -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)
})
})
45 changes: 36 additions & 9 deletions webui/src/features/board/harness/agent/beneath-border.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Box>): 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<XY>, 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())
Original file line number Diff line number Diff line change
Expand Up @@ -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"


/**
Expand All @@ -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
Expand All @@ -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]),
)
Expand Down Expand Up @@ -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])
}


Expand Down
2 changes: 1 addition & 1 deletion webui/src/features/board/harness/canvas/harness-canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading