From 4d0c4b81acbd7de2ed9a081567a4789ac492ccb5 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Tue, 25 Aug 2026 12:13:39 +0200 Subject: [PATCH 1/2] feat(agent): add an arrange_notes tool for on-demand layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the layout engine as a tool: arrange_notes(note_ids?) tidies the given notes — or the whole current layer when omitted — into a clean mindmap/dagre layout. Unlike the post-turn create-arrange (which drops a new cluster beneath existing content), it reorganizes IN PLACE, keeping the cluster centered where it already sits, so re-tidying doesn't relocate the board. Factors the shared layout step (layoutNodes) out of arrangeCreatedNodes and adds arrangeNodesInPlace. Wires the tool into agentBuildTools, the stream ToolName/label/icon maps, a readable 'Arranged N notes' step output, and the system prompt's tool list. S6 of the board-authoring plan. --- webui/src/features/agent/engine/tools.test.ts | 19 +++++ webui/src/features/agent/engine/tools.ts | 18 ++++- .../agent/local/agent-event-to-step.test.ts | 12 +++ .../agent/local/agent-event-to-step.ts | 4 + .../src/features/agent/prompts/plan-system.md | 1 + webui/src/features/agent/types/stream.ts | 4 + .../agent/arrange-created-nodes.test.ts | 45 ++++++++++- .../harness/agent/arrange-created-nodes.ts | 81 +++++++++++++++---- 8 files changed, 165 insertions(+), 19 deletions(-) diff --git a/webui/src/features/agent/engine/tools.test.ts b/webui/src/features/agent/engine/tools.test.ts index 5c5933d4..7d4fbc53 100644 --- a/webui/src/features/agent/engine/tools.test.ts +++ b/webui/src/features/agent/engine/tools.test.ts @@ -17,6 +17,7 @@ import { writeNote, getNote, editNote, + arrangeNotes, searchNotes, listBoards, saveMemory, @@ -344,6 +345,24 @@ describe("writeNote", () => { }) +describe("arrangeNotes", () => { + it("arranges the given note_ids and reports the count", async () => { + seed(store, "a", { content: "a" }) + seed(store, "b", { content: "b" }) + seed(store, "c", { content: "c" }) + const res = (await arrangeNotes.run({ note_ids: ["a", "b", "c"] }, ctx)) as { arranged: number } + expect(res.arranged).toBe(3) + }) + + it("arranges the whole current view when note_ids is omitted", async () => { + seed(store, "a", { content: "a" }) + seed(store, "b", { content: "b" }) + const res = (await arrangeNotes.run({}, ctx)) as { arranged: number } + expect(res.arranged).toBe(2) + }) +}) + + describe("getNote", () => { it("reads label, content, and type", async () => { seed(store, "n1", { label: "T", content: "body" }) diff --git a/webui/src/features/agent/engine/tools.ts b/webui/src/features/agent/engine/tools.ts index eb1296c6..b44c8922 100644 --- a/webui/src/features/agent/engine/tools.ts +++ b/webui/src/features/agent/engine/tools.ts @@ -17,6 +17,7 @@ import { validateMiniAppSource } from "@/features/mini-app/validate" import { defineTool } from "./types" import type { Tool, ToolContext } from "./types" import { StoreMutator, type BoardMutator } from "./board-mutator" +import { arrangeNodesInPlace } from "@/features/board/harness/agent/arrange-created-nodes" import type { MemoryKind, MemoryScope } from "@/features/board/persist/local/idb" @@ -151,6 +152,21 @@ export const writeNote = defineTool({ }) +export const arrangeNotes = defineTool({ + name: "arrange_notes", + description: + "Tidy notes into a clean auto-arranged layout. Pass note_ids to arrange just those (kept centered where they are); omit to tidy the whole current board/folder. Use after creating or editing notes that ended up cluttered or overlapping.", + parameters: z.object({ + note_ids: z.array(z.string()).optional().describe("Ids of notes to arrange; omit to arrange all notes in the current view."), + }), + run: async ({ note_ids }, ctx) => { + const ids = note_ids && note_ids.length > 0 ? note_ids : ctx.store.getAllNodes().map((n) => String(n.id)) + const arranged = await arrangeNodesInPlace(ctx.store, ids) + return { arranged } + }, +}) + + export const getNote = defineTool({ name: "get_note", description: "Read an existing note's label, content, and type.", @@ -405,4 +421,4 @@ export const localTools: Tool[] = [createNote, updateNote, linkNotes, searchNote /** The note-building tools the chat agent uses (matches the system prompt's vocabulary). */ -export const agentBuildTools: Tool[] = [writeNote, editNote, getNote, linkNotes] +export const agentBuildTools: Tool[] = [writeNote, editNote, getNote, linkNotes, arrangeNotes] diff --git a/webui/src/features/agent/local/agent-event-to-step.test.ts b/webui/src/features/agent/local/agent-event-to-step.test.ts index 703a1890..4f3bea8c 100644 --- a/webui/src/features/agent/local/agent-event-to-step.test.ts +++ b/webui/src/features/agent/local/agent-event-to-step.test.ts @@ -333,6 +333,18 @@ describe("stepsFromEvents", () => { }) + it("renders arrange_notes as a readable 'Arranged N notes' step", () => { + const [step] = stepsFromEvents( + [ + { type: "tool_start", toolName: "arrange_notes", args: {} }, + { type: "tool_result", toolName: "arrange_notes", result: { arranged: 6 } }, + ], + "b", + ) + expect(step.type === "tool_call" && step.output).toBe("Arranged 6 notes") + }) + + it("latestAssistantText ignores reasoning (answer body only)", () => { expect( latestAssistantText([ diff --git a/webui/src/features/agent/local/agent-event-to-step.ts b/webui/src/features/agent/local/agent-event-to-step.ts index fd2c1cac..b2499b55 100644 --- a/webui/src/features/agent/local/agent-event-to-step.ts +++ b/webui/src/features/agent/local/agent-event-to-step.ts @@ -123,6 +123,10 @@ const toOutput = (name: string, args: unknown, result: unknown, boardId: string) .filter((r) => r.noteId !== "") return { type: "note_search", references } } + if (name === "arrange_notes") { + const n = field(result, "arranged") + return `Arranged ${typeof n === "number" ? n : 0} notes` + } if (name.startsWith("learn_generate")) { return `Loaded ${name.replace("learn_generate_", "").replace(/_/g, " ")} guidance` } diff --git a/webui/src/features/agent/prompts/plan-system.md b/webui/src/features/agent/prompts/plan-system.md index 324ffb8b..a4ba4c52 100644 --- a/webui/src/features/agent/prompts/plan-system.md +++ b/webui/src/features/agent/prompts/plan-system.md @@ -50,6 +50,7 @@ Use only these tools: - `edit_note(note_id, field, old, new, replace_all?)`: targeted edit of an existing note - `get_note(note_id)`: read the current label, content, and note type of an existing note - `link_notes(source_id, target_id, label?)`: draw a directed arrow between two existing notes in the current board +- `arrange_notes(note_ids?)`: tidy notes into a clean auto-layout in place; omit `note_ids` to arrange the whole current board. Use when notes end up cluttered or overlapping. - `search_notes(query)`: full-text search existing notes on the board; returns each match's id, title, and a content snippet - `save_memory(scope, kind, title, summary, body)`: remember a durable fact (scope `board` = about this board, `global` = about the user across boards) - `update_memory(id, …)` / `delete_memory(id)`: revise or drop a saved fact by its id (ids appear in the `## MEMORY` block) diff --git a/webui/src/features/agent/types/stream.ts b/webui/src/features/agent/types/stream.ts index 60499d80..32a4b7a7 100644 --- a/webui/src/features/agent/types/stream.ts +++ b/webui/src/features/agent/types/stream.ts @@ -14,6 +14,7 @@ import { ScrollIcon, StockWidgetIcon, ToolCodeIcon, + TreeMapIcon, WeatherWidgetIcon, WebCollectorIcon, WriteNoteToolIcon, @@ -111,6 +112,7 @@ export type ToolName = | "edit_note" | "get_note" | "link_notes" + | "arrange_notes" | "outline_generator" | "web_collector" | "synthesizer" @@ -138,6 +140,7 @@ export const ToolNameDescription: Record = { edit_note: "Edit note", get_note: "Read note", link_notes: "Link notes", + arrange_notes: "Arrange notes", outline_generator: "Generate outline", web_collector: "Collect web content", synthesizer: "Synthesize response", @@ -174,6 +177,7 @@ export const ToolNameIcon: Record = { edit_note: EditNoteIcon, get_note: ReadNoteIcon, link_notes: LinkIcon, + arrange_notes: TreeMapIcon, image_description: ImageGenerationIcon, topic_illustrator: ImageGenerationIcon, image_generation: ImageGenerationIcon, diff --git a/webui/src/features/board/harness/agent/arrange-created-nodes.test.ts b/webui/src/features/board/harness/agent/arrange-created-nodes.test.ts index dfeb8407..65c02e32 100644 --- a/webui/src/features/board/harness/agent/arrange-created-nodes.test.ts +++ b/webui/src/features/board/harness/agent/arrange-created-nodes.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest" import { asNodeId } from "@canvas-harness/core" import { addEdge, addNode, freshStore, resetIdb } from "@/test/canvas" -import { arrangeCreatedNodes } from "./arrange-created-nodes" +import { arrangeCreatedNodes, arrangeNodesInPlace } from "./arrange-created-nodes" beforeEach(() => resetIdb()) @@ -13,6 +13,15 @@ const at = (store: ReturnType, id: string) => { } +// Bounding-box center over a set of node ids. +const bboxCenterOf = (store: ReturnType, ids: string[]) => { + const ns = ids.map((id) => store.getNode(asNodeId(id))!) + const minX = Math.min(...ns.map((n) => n.x)), minY = Math.min(...ns.map((n) => n.y)) + const maxX = Math.max(...ns.map((n) => n.x + n.w)), maxY = Math.max(...ns.map((n) => n.y + n.h)) + return { x: (minX + maxX) / 2, y: (minY + maxY) / 2 } +} + + describe("arrangeCreatedNodes", () => { it("spreads linked nodes that all start at the origin", async () => { const store = freshStore("c") @@ -65,3 +74,37 @@ describe("arrangeCreatedNodes", () => { expect(minNewY).toBeGreaterThanOrEqual(220) // below old (100+120) }) }) + + +describe("arrangeNodesInPlace", () => { + it("tidies the nodes while keeping the cluster centered where it was", async () => { + const store = freshStore("c") + for (const id of ["a", "b", "c"]) addNode(store, id, id) + // Move the cluster off-origin to a known region. + store.updateNode(asNodeId("a"), { x: 1000, y: 1000 }) + store.updateNode(asNodeId("b"), { x: 1300, y: 1000 }) + store.updateNode(asNodeId("c"), { x: 1150, y: 1200 }) + addEdge(store, "e1", "a", "b") + addEdge(store, "e2", "a", "c") + const before = bboxCenterOf(store, ["a", "b", "c"]) + + const n = await arrangeNodesInPlace(store, ["a", "b", "c"]) + expect(n).toBe(3) + + // Re-laid-out (not all coincident)... + const positions = ["a", "b", "c"].map((id) => at(store, id)) + expect(new Set(positions.map((p) => `${Math.round(p.x)},${Math.round(p.y)}`)).size).toBeGreaterThan(1) + // ...but the cluster stays centered where it was (in place, NOT shoved beneath). + const after = bboxCenterOf(store, ["a", "b", "c"]) + expect(Math.abs(after.x - before.x)).toBeLessThan(2) + expect(Math.abs(after.y - before.y)).toBeLessThan(2) + }) + + it("returns 0 and moves nothing for fewer than two nodes", async () => { + const store = freshStore("c") + addNode(store, "solo", "solo") + store.updateNode(asNodeId("solo"), { x: 500, y: 500 }) + expect(await arrangeNodesInPlace(store, ["solo"])).toBe(0) + expect(at(store, "solo")).toEqual({ x: 500, y: 500 }) + }) +}) 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 58899021..e04a5263 100644 --- a/webui/src/features/board/harness/agent/arrange-created-nodes.ts +++ b/webui/src/features/board/harness/agent/arrange-created-nodes.ts @@ -171,38 +171,59 @@ const layoutBidirectional = async ( /** - * Arrange the nodes an agent turn just created — the frontend analog of the - * backend's post-turn `rearrange_created_notes`. Without this, every - * `write_note` lands at (0,0) and a mindmap collapses to a single point. - * - * Tries a centered bidirectional mindmap layout; falls back to flat Dagre LR for - * non-tree graphs. The result is translated to sit just below existing board - * content and written back in one local batch, so it snaps into place after the - * turn (as the system prompt promises). Single-node turns are left untouched. + * Lay out a subset of nodes (mindmap-first, Dagre-LR fallback), returning + * top-left positions keyed by id. Empty map for < 2 present nodes. Edges are the + * links whose BOTH ends are in the set. Shared by the create-arrange and the + * on-demand `arrange` tool. */ -export const arrangeCreatedNodes = async (store: CanvasStore, createdIds: string[]): Promise => { - const ids = new Set(createdIds) - const present = createdIds.filter((id) => store.getNode(asNodeId(id))) - if (present.length < 2) return - +const layoutNodes = async (store: CanvasStore, ids: string[]): Promise> => { + const present = ids.filter((id) => store.getNode(asNodeId(id))) + if (present.length < 2) return new Map() + const idSet = new Set(present) const sizeOf: SizeOf = (id) => { const n = store.getNode(asNodeId(id)) return n ? { w: n.w, h: n.h } : { w: 0, h: 0 } } - const edges: SimpleEdge[] = [] for (const e of store.getAllEdges()) { const s = endNodeId(e.source) const t = endNodeId(e.target) - if (s && t && ids.has(s) && ids.has(t)) edges.push({ source: s, target: t }) + if (s && t && idSet.has(s) && idSet.has(t)) edges.push({ source: s, target: t }) + } + return (await layoutBidirectional(present, edges, sizeOf)) ?? (await runDagre(present, edges, sizeOf, "LR")) +} + + +/** Center of the union AABB of the given boxes (top-left + size). */ +const bboxCenter = (boxes: ReadonlyArray<{ x: number; y: number; w: number; h: number }>): XY => { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const b of boxes) { + if (b.x < minX) minX = b.x + if (b.y < minY) minY = b.y + if (b.x + b.w > maxX) maxX = b.x + b.w + if (b.y + b.h > maxY) maxY = b.y + b.h } + return { x: (minX + maxX) / 2, y: (minY + maxY) / 2 } +} + - const positions = - (await layoutBidirectional(present, edges, sizeOf)) ?? (await runDagre(present, edges, sizeOf, "LR")) +/** + * Arrange the nodes an agent turn just created — the frontend analog of the + * backend's post-turn `rearrange_created_notes`. Without this, every + * `write_note` lands at (0,0) and a mindmap collapses to a single point. + * + * Tries a centered bidirectional mindmap layout; falls back to flat Dagre LR for + * non-tree graphs. The result is translated to sit just below existing board + * content and written back in one local batch, so it snaps into place after the + * turn (as the system prompt promises). Single-node turns are left untouched. + */ +export const arrangeCreatedNodes = async (store: CanvasStore, createdIds: string[]): Promise => { + const positions = await layoutNodes(store, createdIds) if (positions.size === 0) return // Translate the laid-out cluster below existing (non-created) content — the // same placement rule the mindmap-apply drain uses (originBeneath). + const ids = new Set(createdIds) const others = store.getAllNodes().filter((n) => !ids.has(String(n.id))) const { x: dx, y: dy } = offsetToOrigin([...positions.values()], originBeneath(others)) @@ -212,3 +233,29 @@ export const arrangeCreatedNodes = async (store: CanvasStore, createdIds: string } }) } + + +/** + * On-demand tidy of EXISTING nodes (the `arrange` tool): re-lays-out `ids` and + * keeps the tidied cluster centered where it currently sits — it reorganizes in + * place rather than relocating the cluster beneath other content. Returns how + * many nodes were moved (0 for < 2 present). Same layout engine as create-arrange. + */ +export const arrangeNodesInPlace = async (store: CanvasStore, ids: string[]): Promise => { + const positions = await layoutNodes(store, ids) + if (positions.size === 0) return 0 + + const laid = [...positions.entries()].map(([id, p]) => { + const n = store.getNode(asNodeId(id))! + return { id, x: p.x, y: p.y, w: n.w, h: n.h, curX: n.x, curY: n.y } + }) + const laidCenter = bboxCenter(laid) + const curCenter = bboxCenter(laid.map((n) => ({ x: n.curX, y: n.curY, w: n.w, h: n.h }))) + const dx = curCenter.x - laidCenter.x + const dy = curCenter.y - laidCenter.y + + store.batch(() => { + for (const n of laid) store.updateNode(asNodeId(n.id), { x: n.x + dx, y: n.y + dy }) + }) + return laid.length +} From 2eee322b5ab2ccb325b7a2f70bf3ef4bf439a18f Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Tue, 25 Aug 2026 20:23:11 +0200 Subject: [PATCH 2/2] fix(agent): harden arrange + placement paths (review) - Revert the near-anchor arrange-exclusion: pinning a same-turn auto anchor fragmented a mindmap rooted at it (root dropped from the layout, edges stranded). Arrange now runs on all auto nodes; `near` is for stable/existing anchors (anchoring to a same-turn auto node is the deferred pinned-aware case). - arrangeNodesInPlace re-reads nodes after the async layout and drops any removed concurrently, so the write-back can't throw on a missing node. - link_notes errors (no dangling edge at 0,0) when an endpoint doesn't exist in the current layer. - arrange_notes caps a whole-view arrange (MAX_ARRANGE) so a huge board isn't mass-relocated / re-laid-out in one shot; steer toward passing note_ids. - offsetToOrigin uses a reduce, not Math.min spread, over a possibly large cluster (call-argument-limit safety). --- .../agent/engine/board-mutator.test.ts | 9 -------- .../features/agent/engine/board-mutator.ts | 16 +++++++------- webui/src/features/agent/engine/tools.test.ts | 15 +++++++++++++ webui/src/features/agent/engine/tools.ts | 21 ++++++++++++++++--- .../agent/local/use-local-submit-prompt.ts | 13 +++++------- .../harness/agent/arrange-created-nodes.ts | 9 +++++--- .../board/harness/agent/beneath-border.ts | 10 +++++++-- 7 files changed, 59 insertions(+), 34 deletions(-) diff --git a/webui/src/features/agent/engine/board-mutator.test.ts b/webui/src/features/agent/engine/board-mutator.test.ts index b1cc1d71..eb2258b9 100644 --- a/webui/src/features/agent/engine/board-mutator.test.ts +++ b/webui/src/features/agent/engine/board-mutator.test.ts @@ -78,15 +78,6 @@ describe("StoreMutator", () => { const m = new StoreMutator(store, null) const res = await m.createNote({ content: "x", near: { nodeId: "ghost", dir: "right" } }) expect(res.placed).toBe(false) - expect(res.anchorId).toBeUndefined() // no anchor resolved → nothing to keep put - }) - - it("near reports the anchorId so the turn can keep the anchor put", async () => { - const store = freshStore("b") - const m = new StoreMutator(store, null) - await m.createNote({ id: "a", content: "A", x: 0, y: 0 }) - const res = await m.createNote({ content: "B", near: { nodeId: "a", dir: "below" } }) - expect(res.anchorId).toBe("a") }) it("rewriteNote replaces content + label of an existing note (created:false)", async () => { diff --git a/webui/src/features/agent/engine/board-mutator.ts b/webui/src/features/agent/engine/board-mutator.ts index 484d23cf..04b0b009 100644 --- a/webui/src/features/agent/engine/board-mutator.ts +++ b/webui/src/features/agent/engine/board-mutator.ts @@ -69,10 +69,8 @@ export type LinkSpec = { * the signature; the impl decides how the write reaches persistence + sync. */ export interface BoardMutator { - /** `placed` = pinned at an explicit/relational position (exclude from arrange). - * `anchorId` = the `near` anchor, if any — the turn keeps it put too, so a note - * pinned beside a same-turn auto node doesn't detach when that node arranges. */ - createNote(spec: NoteSpec): Promise<{ id: string; created: true; placed: boolean; anchorId?: string }> + /** `placed` = pinned at an explicit/relational position (excluded from arrange). */ + createNote(spec: NoteSpec): Promise<{ id: string; created: true; placed: boolean }> rewriteNote(id: string, spec: NoteSpec): Promise<{ id: string; created: boolean }> patchNote(id: string, patch: { content?: string; label?: string }): Promise createLink(spec: LinkSpec): Promise<{ id: string }> @@ -302,12 +300,12 @@ export class StoreMutator implements BoardMutator { this.rootId = rootId } - async createNote(spec: NoteSpec): Promise<{ id: string; created: true; placed: boolean; anchorId?: string }> { + async createNote(spec: NoteSpec): Promise<{ id: string; created: true; placed: boolean }> { const nodeType = toNodeType(spec.type ?? "") const autoFitStyle = AUTOFIT_DISABLED_TYPES.has(nodeType) ? { autoFit: false } : undefined const storedColors = resolveNoteColors(spec.colors, nodeType) const { w, h } = noteGeometry(nodeType, spec.content) - const { x, y, placed, anchorId } = this.placeNote(spec, w, h) + const { x, y, placed } = this.placeNote(spec, w, h) // Plain rectangles are painted by the lib from `style`. A colorable custom // type (sheet) whose FILL was explicitly set gets it projected onto `style` // so its own view honors it; other custom types paint via their own view and @@ -339,7 +337,7 @@ export class StoreMutator implements BoardMutator { } satisfies DimNodeData, }) }) - return { id: String(id), created: true, placed, ...(anchorId ? { anchorId } : {}) } + return { id: String(id), created: true, placed } } /** @@ -350,10 +348,10 @@ export class StoreMutator implements BoardMutator { * - explicit `x`+`y` → verbatim, no collision avoidance. * - neither → auto: beneath the current board border (arranged after the turn). */ - private placeNote(spec: NoteSpec, w: number, h: number): { x: number; y: number; placed: boolean; anchorId?: string } { + private placeNote(spec: NoteSpec, w: number, h: number): { x: number; y: number; placed: boolean } { if (spec.near) { const p = this.nearPosition(spec.near, w, h) - if (p) return { ...p, placed: true, anchorId: spec.near.nodeId } + if (p) return { ...p, placed: true } } if (spec.x !== undefined && spec.y !== undefined) return { x: spec.x, y: spec.y, placed: true } const origin = beneathBorderOrigin(this.store) diff --git a/webui/src/features/agent/engine/tools.test.ts b/webui/src/features/agent/engine/tools.test.ts index 7d4fbc53..f897de31 100644 --- a/webui/src/features/agent/engine/tools.test.ts +++ b/webui/src/features/agent/engine/tools.test.ts @@ -196,6 +196,14 @@ describe("linkNotes", () => { expect((store.getAllEdges()[0].data as { parentId?: string }).parentId).toBe("folder-1") }) + it("errors (no dangling edge) when an endpoint note doesn't exist", async () => { + seed(store, "a") + const before = store.getAllEdges().length + const res = (await linkNotes.run({ sourceId: "a", targetId: "ghost" }, ctx)) as { error?: string } + expect(res.error).toMatch(/not found/) + expect(store.getAllEdges().length).toBe(before) // nothing created + }) + it("stamps a canonical edge style so the live edge matches the reloaded one", async () => { // Regression: a live edge with no `style` fell back to the lib defaults and // looked rougher until reload. The convert layer sets a filled arrowhead. @@ -360,6 +368,13 @@ describe("arrangeNotes", () => { const res = (await arrangeNotes.run({}, ctx)) as { arranged: number } expect(res.arranged).toBe(2) }) + + it("refuses to arrange too many notes at once (whole-board cap)", async () => { + for (let i = 0; i < 61; i += 1) seed(store, `n${i}`, { content: `${i}` }) + const res = (await arrangeNotes.run({}, ctx)) as { error?: string; arranged?: number } + expect(res.error).toMatch(/Too many notes/) + expect(res.arranged).toBeUndefined() + }) }) diff --git a/webui/src/features/agent/engine/tools.ts b/webui/src/features/agent/engine/tools.ts index b44c8922..dc203f5b 100644 --- a/webui/src/features/agent/engine/tools.ts +++ b/webui/src/features/agent/engine/tools.ts @@ -106,7 +106,14 @@ export const linkNotes = defineTool({ targetId: z.string().describe("Exact id of the note the arrow points to."), label: z.string().optional().describe("Optional short label on the edge, e.g. 'yes', 'no', 'then', 'reads', 'causes'."), }), - run: async ({ sourceId, targetId, label }, ctx) => mutatorFor(ctx).createLink({ sourceId, targetId, label }), + run: async ({ sourceId, targetId, label }, ctx) => { + // Both endpoints must exist in the current layer (edges are layer-scoped) — + // otherwise the edge would dangle at (0,0) on a phantom node. Fail clearly. + if (!ctx.store.getNode(asNodeId(sourceId)) || !ctx.store.getNode(asNodeId(targetId))) { + return { error: "link_notes: source or target note not found in the current board." } + } + return mutatorFor(ctx).createLink({ sourceId, targetId, label }) + }, }) @@ -152,15 +159,23 @@ export const writeNote = defineTool({ }) +// Cap the whole-view arrange so a huge board isn't mass-relocated / re-laid-out on +// the hot agent path in one shot. Prefer targeting a specific cluster via note_ids. +const MAX_ARRANGE = 60 + + export const arrangeNotes = defineTool({ name: "arrange_notes", description: - "Tidy notes into a clean auto-arranged layout. Pass note_ids to arrange just those (kept centered where they are); omit to tidy the whole current board/folder. Use after creating or editing notes that ended up cluttered or overlapping.", + "Tidy notes into a clean auto-arranged layout, kept centered where they already sit. Prefer passing note_ids to arrange a specific cluster; omitting them re-lays-out the whole current board/folder (capped). Use after creating or editing notes that ended up cluttered or overlapping.", parameters: z.object({ - note_ids: z.array(z.string()).optional().describe("Ids of notes to arrange; omit to arrange all notes in the current view."), + note_ids: z.array(z.string()).optional().describe("Ids of the notes to arrange (a cluster). Omit to arrange all notes in the current view."), }), run: async ({ note_ids }, ctx) => { const ids = note_ids && note_ids.length > 0 ? note_ids : ctx.store.getAllNodes().map((n) => String(n.id)) + if (ids.length > MAX_ARRANGE) { + return { error: `Too many notes to arrange at once (${ids.length}). Pass a note_ids set for the specific cluster to tidy.` } + } const arranged = await arrangeNodesInPlace(ctx.store, ids) return { arranged } }, diff --git a/webui/src/features/agent/local/use-local-submit-prompt.ts b/webui/src/features/agent/local/use-local-submit-prompt.ts index fc6df442..5b64e5d1 100644 --- a/webui/src/features/agent/local/use-local-submit-prompt.ts +++ b/webui/src/features/agent/local/use-local-submit-prompt.ts @@ -275,10 +275,10 @@ export function useLocalSubmitPrompt(boardId: string, syncTranscript = false) { const createdNodeIds: string[] = [] // Subset of createdNodeIds to auto-arrange: excludes notes the agent PINNED // at an explicit/relational position (result.placed), so `near`/x-y survive. + // (Anchors are NOT excluded: pinning a same-turn auto anchor would fragment a + // mindmap rooted at it. `near` is meant for stable/existing anchors; anchoring + // to a note created the same turn is the deferred pinned-aware-layout case.) const arrangeNodeIds: string[] = [] - // Anchors a pinned note was placed relative to — kept put too, so a note - // pinned beside a same-turn auto node doesn't detach when that node arranges. - const pinnedAnchorIds = new Set() // Coalesce token-delta repaints to ~10fps (shared with the backend-agent // stream builder). Structural events (tool start/result) force an // immediate repaint; the final frame below always flushes. @@ -392,8 +392,6 @@ export function useLocalSubmitPrompt(boardId: string, syncTranscript = false) { const id = String((ev.result as { id: unknown }).id) createdNodeIds.push(id) if ((ev.result as { placed?: unknown }).placed !== true) arrangeNodeIds.push(id) - const anchorId = (ev.result as { anchorId?: unknown }).anchorId - if (typeof anchorId === "string") pinnedAnchorIds.add(anchorId) } const now = Date.now() // Token streams (assistant_text AND reasoning) ride the ~10fps throttle; @@ -424,9 +422,8 @@ export function useLocalSubmitPrompt(boardId: string, syncTranscript = false) { } render(false) // Post-turn arrange (frontend analog of backend rearrange_created_notes). - // Only auto-placed notes — pinned (near/explicit) ones keep their spot, and - // so do the anchors they were placed beside (else the anchor would move away). - await arrangeCreatedNodes(store, arrangeNodeIds.filter((id) => !pinnedAnchorIds.has(id))) + // Only auto-placed notes — pinned (near/explicit) ones keep their spot. + await arrangeCreatedNodes(store, arrangeNodeIds) // Recenter the canvas on the freshly created nodes — parity with the // online path's `?center=` navigation, which useCenterFromUrl reads to // fit the union rect (zoom-capped) and select them. 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 e04a5263..c07570f5 100644 --- a/webui/src/features/board/harness/agent/arrange-created-nodes.ts +++ b/webui/src/features/board/harness/agent/arrange-created-nodes.ts @@ -245,10 +245,13 @@ export const arrangeNodesInPlace = async (store: CanvasStore, ids: string[]): Pr const positions = await layoutNodes(store, ids) if (positions.size === 0) return 0 - const laid = [...positions.entries()].map(([id, p]) => { - const n = store.getNode(asNodeId(id))! - return { id, x: p.x, y: p.y, w: n.w, h: n.h, curX: n.x, curY: n.y } + // Re-read nodes after the await; drop any removed concurrently (sync/user delete) + // so the write-back can't throw on a missing node. + const laid = [...positions.entries()].flatMap(([id, p]) => { + const n = store.getNode(asNodeId(id)) + return n ? [{ id, x: p.x, y: p.y, w: n.w, h: n.h, curX: n.x, curY: n.y }] : [] }) + if (laid.length === 0) return 0 const laidCenter = bboxCenter(laid) const curCenter = bboxCenter(laid.map((n) => ({ x: n.curX, y: n.curY, w: n.w, h: n.h }))) const dx = curCenter.x - laidCenter.x diff --git a/webui/src/features/board/harness/agent/beneath-border.ts b/webui/src/features/board/harness/agent/beneath-border.ts index b9ab1a3c..58a66988 100644 --- a/webui/src/features/board/harness/agent/beneath-border.ts +++ b/webui/src/features/board/harness/agent/beneath-border.ts @@ -38,8 +38,14 @@ export const originBeneath = (nodes: ReadonlyArray): XY => { */ 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)) + // Reduce (not Math.min spread) — a cluster can be large (whole-board arrange), + // and spreading thousands of args risks a call-argument-limit RangeError. + let minX = Infinity + let minY = Infinity + for (const n of cluster) { + if (n.x < minX) minX = n.x + if (n.y < minY) minY = n.y + } return { x: origin.x - minX, y: origin.y - minY } }