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
22 changes: 22 additions & 0 deletions webui/src/features/agent/engine/board-mutator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,16 @@ describe("StoreMutator", () => {
expect(edge).toBeTruthy()
expect((edge?.data as { parentId?: string } | undefined)?.parentId).toBe("folder-1")
})

it("createFolder adds a folder node carrying the label + working-folder parentId", async () => {
const store = freshStore("b")
const m = new StoreMutator(store, "parent-1")
const { id } = await m.createFolder("Ideas")
const node = store.getNode(asNodeId(id))
expect(node?.type).toBe("folder")
expect(label(store, id)).toBe("Ideas")
expect((node?.data as DimNodeData).parentId).toBe("parent-1")
})
})


Expand Down Expand Up @@ -329,6 +339,18 @@ describe("HeadlessMutator", () => {
expect((edge?.data as { parentId?: string } | undefined)?.parentId).toBe("folder-1")
})

it("createFolder makes a subfolder off-scene (in the whole-board oplog, not the visible store)", async () => {
const { persistence, scene } = setup()
const m = new HeadlessMutator(scene, "F")
const { id } = await m.createFolder("Sub")
await persistence.flush()
expect(scene.getNode(asNodeId(id))).toBeUndefined()
const whole = await persistence.load()
const node = whole.nodes.find((n) => String(n.id) === id)
expect(node?.type).toBe("folder")
expect(node?.data?.parentId).toBe("F")
})

it("seeds from the existing layer so it can edit a note already in that folder", async () => {
const { persistence, scene } = setup()
// Seed a note into folder-1 via a separate headless session (as a prior turn would).
Expand Down
37 changes: 37 additions & 0 deletions webui/src/features/agent/engine/board-mutator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
} from "@/features/board/harness/theme/color-adapter"
import { getBoardThemeMode } from "@/features/board/harness/theme/theme-mode-ref"
import { createDefaultLinkStyle, createDefaultStyle } from "@/features/board/types/style"
import { DEFAULT_FOLDER_WIDTH } from "@/features/board/types/note"
import { beneathBorderOrigin } from "@/features/board/harness/agent/beneath-border"
import { bumpMeta, freshMeta } from "@/features/board/utils/node-meta"
import { estimateNoteSize } from "./note-size"
Expand Down Expand Up @@ -85,6 +86,8 @@ export interface BoardMutator {
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 }>
/** Create a folder (a nested sub-board) in this layer; returns its id. */
createFolder(label: string): Promise<{ id: string }>
}


Expand Down Expand Up @@ -166,6 +169,10 @@ const COLORABLE_CUSTOM_TYPES = new Set(["sheet"])
const NEAR_GAP = 48


// Agent-created folders use the same square default as the folder tool.
const FOLDER_SIZE = DEFAULT_FOLDER_WIDTH


type Box = { x: number; y: number; w: number; h: number }
type XYWH = { x: number; y: number; w: number; h: number }

Expand Down Expand Up @@ -445,6 +452,32 @@ export class StoreMutator implements BoardMutator {
this.store.batch(() => this.store.updateNode(nid, next))
}

async createFolder(label: string): Promise<{ id: string }> {
// A folder is a first-class node type: entering it sets root_id = folder.id and
// its children carry parentId = folder.id. The view only needs `data.label`;
// dispatch is by node.type. Auto-placed beneath existing content, like a note.
const id = asNodeId(this.store.generateId())
const { x, y } = this.placeNote({ content: "" }, FOLDER_SIZE, FOLDER_SIZE)
this.store.batch(() =>
this.store.addNode({
id,
type: "folder",
x,
y,
w: FOLDER_SIZE,
h: FOLDER_SIZE,
angle: 0,
groups: [],
data: {
label: { markdown: label },
parentId: this.rootId ?? undefined,
meta: freshMeta(),
} satisfies DimNodeData,
}),
)
return { id: String(id) }
}

async createLink(spec: LinkSpec): Promise<{ id: string }> {
const id = asEdgeId(this.store.generateId())
const src = asNodeId(spec.sourceId)
Expand Down Expand Up @@ -556,6 +589,10 @@ export class HeadlessMutator implements BoardMutator {
return (await this.ensure()).createLink(spec)
}

async createFolder(label: string): Promise<{ id: string }> {
return (await this.ensure()).createFolder(label)
}

/** True if a node with `id` lives in the target layer (seeds on first call). */
async hasNode(id: string): Promise<boolean> {
await this.ensure()
Expand Down
97 changes: 97 additions & 0 deletions webui/src/features/agent/engine/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
editNote,
arrangeNotes,
navigate,
createFolder,
searchNotes,
listBoards,
saveMemory,
Expand Down Expand Up @@ -771,6 +772,102 @@ describe("navigate (working folder)", () => {
expect(store.getNode(asNodeId("visible"))!.x).toBe(visibleX)
})

it("create_folder makes a folder in the working folder and returns its id", async () => {
const store = freshStore("c")
const nav: ToolContext = { store, rootId: null, sceneRootId: null, boardNotes: new Map() }
const res = (await createFolder.run({ label: "Project" }, nav)) as { folder_id: string; label: string }
expect(res.label).toBe("Project")
expect(store.getNode(asNodeId(res.folder_id))?.type).toBe("folder")
})

it("create_folder rejects nesting past the max depth", async () => {
const store = freshStore("c")
// Working folder `b` sits at depth 2 (root → a → b); a child would be depth 3.
const a = folder("a", "A")
const b = { ...folder("b", "B"), data: { ...(folder("b", "B").data as object), parentId: "a" } } as unknown as Node
const nav: ToolContext = { store, rootId: "b", sceneRootId: null, boardNotes: new Map([["a", a], ["b", b]]) }
const res = (await createFolder.run({ label: "TooDeep" }, nav)) as { error?: string }
expect(res).toHaveProperty("error")
})

it("create_folder → navigate → author: builds and populates a sub-board without touching the view", async () => {
const store = freshStore("c")
seed(store, "visible", { label: "OnScreen" }) // the user's on-screen note
const nav: ToolContext = { store, rootId: null, sceneRootId: null, boardNotes: new Map() }
// Folder created at the visible root → present in the user's view.
const f = (await createFolder.run({ label: "Project" }, nav)) as { folder_id: string }
expect(store.getNode(asNodeId(f.folder_id))?.type).toBe("folder")
// navigate into the freshly-created folder (resolved from the live store, not
// the pre-turn snapshot), then author inside it — off-scene.
await navigate.run({ target: f.folder_id }, nav)
expect(nav.board).toBeInstanceOf(HeadlessMutator)
const note = (await createNote.run({ title: "N", body: "inside" }, nav)) as { id: string; offScene?: boolean }
expect(note.offScene).toBe(true)
expect(store.getNode(asNodeId(note.id))).toBeUndefined() // authored off-scene, not in the user's view
expect(store.getNode(asNodeId("visible"))).toBeDefined() // the user's note is untouched
})

// A full ctx mirroring the real run — carries the this-turn creation index and
// the per-layer session cache the guards/navigation rely on.
const liveCtx = (store: CanvasStore): ToolContext => ({
store,
rootId: null,
sceneRootId: null,
boardNotes: new Map(),
liveNodes: new Map(),
sessions: new Map(),
})

it("enforces the nesting cap across folders created in the SAME turn", async () => {
const store = freshStore("c")
const nav = liveCtx(store)
const a = (await createFolder.run({ label: "A" }, nav)) as { folder_id: string }
await navigate.run({ target: a.folder_id }, nav) // depth 1
const b = (await createFolder.run({ label: "B" }, nav)) as { folder_id: string; error?: string }
expect(b.error).toBeUndefined()
await navigate.run({ target: b.folder_id }, nav) // depth 2
const c = (await createFolder.run({ label: "C" }, nav)) as { error?: string }
expect(c).toHaveProperty("error") // a 4th level would exceed MAX_BOARD_DEPTH
})

it('navigate "up" from a folder created this turn lands at its true parent', async () => {
const store = freshStore("c")
const nav = liveCtx(store)
const a = (await createFolder.run({ label: "A" }, nav)) as { folder_id: string }
await navigate.run({ target: a.folder_id }, nav)
const b = (await createFolder.run({ label: "B" }, nav)) as { folder_id: string } // B lives in A, off-scene
await navigate.run({ target: b.folder_id }, nav)
const up = (await navigate.run({ target: "up" }, nav)) as { working_folder: string }
expect(up.working_folder).toBe(a.folder_id) // A, not root
})

it("write_note refuses to duplicate an id created this turn in another layer", async () => {
const store = freshStore("c")
const nav = liveCtx(store)
// Create note N at the visible root.
const n = (await writeNote.run({ content: "root note" }, nav)) as { id: string }
// Navigate into a folder, then try to write_note with N's id there.
const f = (await createFolder.run({ label: "F" }, nav)) as { folder_id: string }
await navigate.run({ target: f.folder_id }, nav)
const res = (await writeNote.run({ content: "dup", note_id: n.id }, nav)) as { error?: string }
expect(res).toHaveProperty("error") // refused, not a colliding duplicate
})

it("enforces the per-level folder count cap (universal 10)", async () => {
const store = freshStore("c")
// Seed the root layer at the folder limit.
for (let i = 0; i < 10; i += 1) {
store.addNode({
id: asNodeId(`f${i}`),
type: "folder",
x: 0, y: 0, w: 100, h: 100, angle: 0, groups: [],
data: { label: { markdown: `F${i}` }, meta: { v: 1, createdAt: 0, updatedAt: 0 } } satisfies DimNodeData,
})
}
const res = (await createFolder.run({ label: "overflow" }, liveCtx(store))) as { error?: string }
expect(res).toHaveProperty("error")
})

it("re-entering a folder in the same turn sees notes it wrote before leaving", async () => {
const store = freshStore("c")
const nav: ToolContext = {
Expand Down
Loading
Loading