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
9 changes: 0 additions & 9 deletions webui/src/features/agent/engine/board-mutator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
16 changes: 7 additions & 9 deletions webui/src/features/agent/engine/board-mutator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>
createLink(spec: LinkSpec): Promise<{ id: string }>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
}

/**
Expand All @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions webui/src/features/agent/engine/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
writeNote,
getNote,
editNote,
arrangeNotes,
searchNotes,
listBoards,
saveMemory,
Expand Down Expand Up @@ -195,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.
Expand Down Expand Up @@ -344,6 +353,31 @@ 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)
})

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()
})
})


describe("getNote", () => {
it("reads label, content, and type", async () => {
seed(store, "n1", { label: "T", content: "body" })
Expand Down
35 changes: 33 additions & 2 deletions webui/src/features/agent/engine/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down Expand Up @@ -105,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 })
},
})


Expand Down Expand Up @@ -151,6 +159,29 @@ 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, 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 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 }
},
})


export const getNote = defineTool({
name: "get_note",
description: "Read an existing note's label, content, and type.",
Expand Down Expand Up @@ -405,4 +436,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]
12 changes: 12 additions & 0 deletions webui/src/features/agent/local/agent-event-to-step.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
4 changes: 4 additions & 0 deletions webui/src/features/agent/local/agent-event-to-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
}
Expand Down
13 changes: 5 additions & 8 deletions webui/src/features/agent/local/use-local-submit-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()
// 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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions webui/src/features/agent/prompts/plan-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions webui/src/features/agent/types/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
ScrollIcon,
StockWidgetIcon,
ToolCodeIcon,
TreeMapIcon,
WeatherWidgetIcon,
WebCollectorIcon,
WriteNoteToolIcon,
Expand Down Expand Up @@ -111,6 +112,7 @@ export type ToolName =
| "edit_note"
| "get_note"
| "link_notes"
| "arrange_notes"
| "outline_generator"
| "web_collector"
| "synthesizer"
Expand Down Expand Up @@ -138,6 +140,7 @@ export const ToolNameDescription: Record<ToolName, string> = {
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",
Expand Down Expand Up @@ -174,6 +177,7 @@ export const ToolNameIcon: Record<ToolName, AppIconComponent> = {
edit_note: EditNoteIcon,
get_note: ReadNoteIcon,
link_notes: LinkIcon,
arrange_notes: TreeMapIcon,
image_description: ImageGenerationIcon,
topic_illustrator: ImageGenerationIcon,
image_generation: ImageGenerationIcon,
Expand Down
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 { addEdge, addNode, freshStore, resetIdb } from "@/test/canvas"
import { arrangeCreatedNodes } from "./arrange-created-nodes"
import { arrangeCreatedNodes, arrangeNodesInPlace } from "./arrange-created-nodes"


beforeEach(() => resetIdb())
Expand All @@ -13,6 +13,15 @@ const at = (store: ReturnType<typeof freshStore>, id: string) => {
}


// Bounding-box center over a set of node ids.
const bboxCenterOf = (store: ReturnType<typeof freshStore>, 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")
Expand Down Expand Up @@ -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 })
})
})
Loading
Loading