From b7c1bd35df5b76176310ecf35dab23f0cd813639 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Sat, 29 Aug 2026 16:20:57 +0200 Subject: [PATCH 1/6] fix(board): back sheet @-mentions + subpages with the on-device store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sheet editor's PageProvider still called the REST board API (listBoardContents / getNote / addNotes), but boards went offline-first — those routes have no local-board counterpart, so apiFetch threw. Result: @ autocomplete returned nothing, existing @ chips couldn't resolve, and /subpage created nothing. (The sidebar migrated to the on-device store in #190/#191; the PageProvider was missed.) Migrate all three methods to the local replica: - list -> listLocalBoardContents (sheet-kind nodes) - get -> live store, else the whole-board persistence replica - create -> build a real sheet (createDefaultNote + noteToNode, sets styleType so it lists) and write sync-correctly: same layer as the view -> the store; a subpage's child layer -> the S7 headless intake (record + submitLocalBatch scene:false), so the user's view never moves. No call-site changes (both providers just pass boardId/parentNoteId/onNavigate). --- .../providers/board-page-provider.test.ts | 112 +++++++++++++++++- .../board/providers/board-page-provider.ts | 91 +++++++++++--- 2 files changed, 185 insertions(+), 18 deletions(-) diff --git a/webui/src/features/board/providers/board-page-provider.test.ts b/webui/src/features/board/providers/board-page-provider.test.ts index 685db4c0..e46c4305 100644 --- a/webui/src/features/board/providers/board-page-provider.test.ts +++ b/webui/src/features/board/providers/board-page-provider.test.ts @@ -1,7 +1,17 @@ -import { describe, expect, it } from "vitest" +import { afterEach, beforeEach, describe, expect, it } from "vitest" +import { asNodeId } from "@canvas-harness/core" import type { IconProperty } from "@/features/newsfeed/types/properties" +import { addNode, freshStore, resetIdb } from "@/test/canvas" +import { getLocalStores } from "@/features/local-stores" +import { BoardPersistence } from "../persist/local/board-persistence" +import { setBoardPersistenceRef } from "../persist/local/board-persistence-ref" +import { setBoardSyncRef } from "../harness/sync/board-sync-ref" +import { setCanvasStoreRef } from "../harness/canvas-store-ref" +import { useBoardAppStore } from "../harness/store/board-app-store" +import { noteToNode } from "../harness/convert/note-to-node" +import { listLocalBoardContents } from "../api/list-local-board-contents" import { createDefaultNote, type Note } from "../types/note" -import { noteToPage } from "./board-page-provider" +import { createBoardPageProvider, noteToPage } from "./board-page-provider" type IconValue = NonNullable @@ -49,3 +59,101 @@ describe("noteToPage", () => { expect(page.parentId).toBe("parent-1") }) }) + + +describe("createBoardPageProvider (on-device store)", () => { + // Wire a live board over the shared local engine, so the provider's local + // read/write paths run end-to-end (no REST). + const setup = async (boardId = "b", currentLayer: string | null = null) => { + const { engine } = await getLocalStores() + const persistence = new BoardPersistence(boardId, { engine }) + const store = freshStore("live") + persistence.attach(store) + setBoardPersistenceRef(persistence) + setCanvasStoreRef(store) + setBoardSyncRef(null) // no relay — persistence-only is sync-correct locally + useBoardAppStore.setState({ rootId: currentLayer }) + return { persistence, store } + } + + const sheetNote = (boardId: string, id: string, label: string): Note => { + const n = createDefaultNote({ boardId, nodeType: "sheet" }) + n.id = id + n.label = { markdown: label } + n.content = { markdown: "" } + return n + } + + beforeEach(() => resetIdb()) + afterEach(() => { + setBoardPersistenceRef(null) + setCanvasStoreRef(null) + setBoardSyncRef(null) + useBoardAppStore.setState({ rootId: null }) + }) + + it("list() returns the board's sheets and excludes non-sheet nodes", async () => { + const { persistence, store } = await setup() + store.addNode(noteToNode(sheetNote("b", "s1", "Alpha"))) + store.addNode(noteToNode(sheetNote("b", "s2", "Beta"))) + addNode(store, "r1", "a rectangle") // not a sheet + await persistence.flush() + + const pages = await createBoardPageProvider({ boardId: "b" }).list() + expect(pages.map((p) => p.title).sort()).toEqual(["Alpha", "Beta"]) + }) + + it("list(query) filters sheets by title", async () => { + const { persistence, store } = await setup() + store.addNode(noteToNode(sheetNote("b", "s1", "Photosynthesis"))) + store.addNode(noteToNode(sheetNote("b", "s2", "Mitosis"))) + await persistence.flush() + + const pages = await createBoardPageProvider({ boardId: "b" }).list("photo") + expect(pages.map((p) => p.title)).toEqual(["Photosynthesis"]) + }) + + it("get() resolves a page (title + snippet) from the whole-board replica", async () => { + const { engine } = await getLocalStores() + const persistence = new BoardPersistence("b", { engine }) + const seed = freshStore("seed") + const unsub = persistence.attach(seed) + const n = createDefaultNote({ boardId: "b", nodeType: "sheet" }) + n.id = "s1" + n.label = { markdown: "Deep Page" } + n.content = { markdown: "# Title\n\nsome body text" } + seed.addNode(noteToNode(n)) + await persistence.flush() + unsub() + // No live store → the whole-board replica path must resolve it. + setBoardPersistenceRef(null) + setCanvasStoreRef(null) + + const page = await createBoardPageProvider({ boardId: "b" }).get("s1") + expect(page?.title).toBe("Deep Page") + expect(page?.snippet).toContain("some body text") + expect(await createBoardPageProvider({ boardId: "b" }).get("nope")).toBeNull() + }) + + it("create() adds a top-level sheet in the current layer and lists it", async () => { + const { persistence, store } = await setup("b", null) // viewing root + const provider = createBoardPageProvider({ boardId: "b" }) + const page = await provider.create({ title: "New Page" }) + // In-scene: rendered in the visible store, empty body (not seeded from title). + expect(store.getNode(asNodeId(page.id))?.type).toBe("sheet") + expect(store.getNode(asNodeId(page.id))?.content).toBe("") + await persistence.flush() + expect((await provider.list()).map((p) => p.title)).toContain("New Page") + }) + + it("create() with a parent writes the subpage off-scene (not in the current view)", async () => { + const { persistence, store } = await setup("b", null) // viewing root + const provider = createBoardPageProvider({ boardId: "b" }) + const page = await provider.create({ title: "Child", parentId: "parent-note" }) + // Off-scene: a child layer, so it must NOT land in the visible store. + expect(store.getNode(asNodeId(page.id))).toBeUndefined() + await persistence.flush() + const items = await listLocalBoardContents("b") + expect(items.find((it) => it.id === page.id)?.parentId).toBe("parent-note") + }) +}) diff --git a/webui/src/features/board/providers/board-page-provider.ts b/webui/src/features/board/providers/board-page-provider.ts index 92618623..277dfe5e 100644 --- a/webui/src/features/board/providers/board-page-provider.ts +++ b/webui/src/features/board/providers/board-page-provider.ts @@ -1,8 +1,15 @@ +import { asNodeId, type Node, type Op } from "@canvas-harness/core" import type { Page, PageProvider } from "@/components/editor/tiptap/page/types" -import { listBoardContents } from "../api/list-board-contents" -import { getNote } from "../api/get-note" -import { addNotes } from "../api/add-notes" -import { invalidateBoardContents } from "../api/invalidate-board-contents" +import { queryClient } from "@/query-client" +import { getLocalStores } from "@/features/local-stores" +import { BoardPersistence } from "@/features/board/persist/local/board-persistence" +import { getBoardPersistenceRef } from "@/features/board/persist/local/board-persistence-ref" +import { getBoardSyncRef } from "@/features/board/harness/sync/board-sync-ref" +import { getCanvasStoreRef } from "@/features/board/harness/canvas-store-ref" +import { useBoardAppStore } from "@/features/board/harness/store/board-app-store" +import { makeBatch } from "@/features/board/harness/make-batch" +import { noteToNode, type NoteNodeData } from "../harness/convert/note-to-node" +import { listLocalBoardContents } from "../api/list-local-board-contents" import { createDefaultNote, type Note } from "../types/note" @@ -53,10 +60,55 @@ export function noteToPage(note: Note): Page { /** - * Build a `PageProvider` backed by the existing board API helpers. - * Filters listings to sheet-kind notes (treating sheets as pages); titles - * are matched case-insensitively client-side since the backend's contents - * endpoint doesn't support search yet. + * Map a live canvas-harness `Node` (runtime `NoteNodeData` + string `content`) + * onto a `Page`. The local analog of {@link noteToPage} for the on-device store, + * where the body rides on `node.content` (a string), not `note.content.markdown`. + */ +function nodeToPage(node: Node): Page { + const data = node.data as NoteNodeData | undefined + const label = typeof data?.label === "string" ? data.label : data?.label?.markdown + return { + id: node.id as unknown as string, + title: label?.trim() || "Untitled", + icon: data?.properties?.iconData?.icon ?? null, + parentId: data?.parentId ?? undefined, + snippet: snippetFromMarkdown(node.content ?? undefined), + } +} + + +/** + * Write a freshly-built sheet node into the board, sync-correctly, without + * disturbing the user's view: + * - target layer == the visible layer → through the live store (renders + the + * store's change pipeline persists + syncs it, exactly like a manual create); + * - otherwise (a /subpage's child layer, or a top-level page created while the + * user is inside a folder) → off-scene: record to the oplog + enter the sync + * intake with `scene: false` (the headless path, ADR-SYNC-001), so it never + * lands in the current scene. + */ +function writeSheetNode(node: Node, layer: string | null): void { + const store = getCanvasStoreRef() + if (!store) return // no live board mounted — nothing to write into + const currentLayer = useBoardAppStore.getState().rootId ?? null + if (layer === currentLayer) { + store.addNode(node) + return + } + const persistence = getBoardPersistenceRef() + if (!persistence) return + const batch = makeBatch(store, "local", [{ type: "node.add", node } as Op]) + persistence.record(batch) + getBoardSyncRef()?.submitLocalBatch(batch, { scene: false }) +} + + +/** + * Build a `PageProvider` backed by the on-device store (offline-first): pages are + * the board's sheet-kind notes. `list` reads the local surface index, `get` + * resolves a note from the live store or the whole-board replica, and `create` + * writes a new sheet sync-correctly. (Formerly REST-backed — that broke once + * boards became local/synced, since those routes have no local-board counterpart.) */ export function createBoardPageProvider( config: BoardPageProviderConfig, @@ -65,10 +117,10 @@ export function createBoardPageProvider( return { async list(query?: string) { - const items = await listBoardContents(boardId) + const items = await listLocalBoardContents(boardId) const sheets: Page[] = items .filter((it) => it.kind === "sheet") - .map((it) => ({ id: it.id, title: it.label?.trim() || "Untitled" })) + .map((it) => ({ id: it.id, title: it.label?.trim() || "Untitled", icon: it.iconData ?? null })) const q = query?.trim().toLowerCase() if (!q) return sheets @@ -76,9 +128,15 @@ export function createBoardPageProvider( }, async get(id: string) { + // The live store holds the current layer — freshest, and the common case. + const live = getCanvasStoreRef()?.getNode(asNodeId(id)) + if (live) return nodeToPage(live) + // Otherwise resolve from the whole-board replica (a page in another layer). try { - const note = await getNote(boardId, id) - return noteToPage(note) + const { engine } = await getLocalStores() + const content = await new BoardPersistence(boardId, { engine }).load() + const node = content.nodes.find((n) => (n.id as unknown as string) === id) + return node ? nodeToPage(node) : null } catch (err) { console.warn("[boardPageProvider] get failed", id, err) return null @@ -88,11 +146,12 @@ export function createBoardPageProvider( async create(opts: { title: string; parentId?: string }) { const note = createDefaultNote({ boardId, nodeType: "sheet" }) note.label = { markdown: opts.title || "Untitled" } + // Empty body — without this, noteToNode seeds the body from the label. + note.content = { markdown: "" } if (opts.parentId) note.parentId = opts.parentId - await addNotes(boardId, [note]) - // The new sheet is now visible in the parent's contents listing — - // refresh any sidebar / picker query that's looking at the board. - invalidateBoardContents(boardId) + writeSheetNode(noteToNode(note), note.parentId ?? null) + // Refresh the on-device contents index (sidebar tree / page picker). + void queryClient.invalidateQueries({ queryKey: ["localBoardContents", boardId] }) return { id: note.id, title: opts.title || "Untitled", From 48e4f98bbe5c50754810afe1d145c41255408b8f Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Sat, 29 Aug 2026 18:06:47 +0200 Subject: [PATCH 2/6] fix(board): harden page provider (flush-guard, sheet filter, cache, no dangling chip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address code-review findings on the on-device page provider: - create() awaits persistence.flush() before invalidating, so an off-scene subpage (no store 'change' to flush-chain) is durable before the contents index reloads — otherwise the picker/sidebar stayed stale until an unrelated edit. - writeSheetNode returns a boolean; create() throws when there's no live board to write into, so the editor never inserts a chip for a non-existent page. - get() filters to sheet-kind on both the live-store and replica paths (a page is a sheet), matching list(). - list()/get() share a short (1.5s) per-instance whole-board cache so typeahead and hover cards don't replay the snapshot+oplog on every call. Tests: off-scene create is durable + listable without a manual flush; get() on a non-sheet returns null; create() throws with no live board. --- .../providers/board-page-provider.test.ts | 21 +++++- .../board/providers/board-page-provider.ts | 64 ++++++++++++++----- 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/webui/src/features/board/providers/board-page-provider.test.ts b/webui/src/features/board/providers/board-page-provider.test.ts index e46c4305..a4963ada 100644 --- a/webui/src/features/board/providers/board-page-provider.test.ts +++ b/webui/src/features/board/providers/board-page-provider.test.ts @@ -146,14 +146,29 @@ describe("createBoardPageProvider (on-device store)", () => { expect((await provider.list()).map((p) => p.title)).toContain("New Page") }) - it("create() with a parent writes the subpage off-scene (not in the current view)", async () => { - const { persistence, store } = await setup("b", null) // viewing root + it("create() with a parent writes the subpage off-scene, durable + listable without a manual flush", async () => { + const { store } = await setup("b", null) // viewing root const provider = createBoardPageProvider({ boardId: "b" }) const page = await provider.create({ title: "Child", parentId: "parent-note" }) // Off-scene: a child layer, so it must NOT land in the visible store. expect(store.getNode(asNodeId(page.id))).toBeUndefined() - await persistence.flush() + // create() flushed before returning, so the page is already durable + listable + // (no store 'change' fired for the off-scene write — the flush is the guard). + expect((await provider.list()).map((p) => p.title)).toContain("Child") const items = await listLocalBoardContents("b") expect(items.find((it) => it.id === page.id)?.parentId).toBe("parent-note") }) + + it("get() returns null for a non-sheet node (a page is a sheet)", async () => { + const { store } = await setup("b", null) + addNode(store, "rect1", "a rectangle") // not a sheet, in the live store + const page = await createBoardPageProvider({ boardId: "b" }).get("rect1") + expect(page).toBeNull() + }) + + it("create() throws (no dangling chip) when there is no live board to write into", async () => { + await setup("b", null) + setCanvasStoreRef(null) // simulate no mounted board + await expect(createBoardPageProvider({ boardId: "b" }).create({ title: "X" })).rejects.toThrow() + }) }) diff --git a/webui/src/features/board/providers/board-page-provider.ts b/webui/src/features/board/providers/board-page-provider.ts index 277dfe5e..f1d41836 100644 --- a/webui/src/features/board/providers/board-page-provider.ts +++ b/webui/src/features/board/providers/board-page-provider.ts @@ -1,5 +1,6 @@ import { asNodeId, type Node, type Op } from "@canvas-harness/core" import type { Page, PageProvider } from "@/components/editor/tiptap/page/types" +import type { BoardContent } from "@/features/board/model" import { queryClient } from "@/query-client" import { getLocalStores } from "@/features/local-stores" import { BoardPersistence } from "@/features/board/persist/local/board-persistence" @@ -9,7 +10,6 @@ import { getCanvasStoreRef } from "@/features/board/harness/canvas-store-ref" import { useBoardAppStore } from "@/features/board/harness/store/board-app-store" import { makeBatch } from "@/features/board/harness/make-batch" import { noteToNode, type NoteNodeData } from "../harness/convert/note-to-node" -import { listLocalBoardContents } from "../api/list-local-board-contents" import { createDefaultNote, type Note } from "../types/note" @@ -42,6 +42,12 @@ function snippetFromMarkdown(markdown: string | undefined): string | undefined { } +/** A page is a sheet-kind note — dispatch type or the persisted `styleType`. */ +function isSheet(node: Node): boolean { + return node.type === "sheet" || (node.data as NoteNodeData | undefined)?.styleType === "sheet" +} + + /** * Map a board Note onto the editor's `Page` shape: title (falling back to * "Untitled"), the user's custom icon (`iconData.icon`, else null so the @@ -86,20 +92,24 @@ function nodeToPage(node: Node): Page { * user is inside a folder) → off-scene: record to the oplog + enter the sync * intake with `scene: false` (the headless path, ADR-SYNC-001), so it never * lands in the current scene. + * + * Returns false when there's no live board mounted to write into — the caller + * MUST NOT report the page as created in that case. */ -function writeSheetNode(node: Node, layer: string | null): void { +function writeSheetNode(node: Node, layer: string | null): boolean { const store = getCanvasStoreRef() - if (!store) return // no live board mounted — nothing to write into + if (!store) return false // no live board mounted — nothing to write into const currentLayer = useBoardAppStore.getState().rootId ?? null if (layer === currentLayer) { store.addNode(node) - return + return true } const persistence = getBoardPersistenceRef() - if (!persistence) return + if (!persistence) return false const batch = makeBatch(store, "local", [{ type: "node.add", node } as Op]) persistence.record(batch) getBoardSyncRef()?.submitLocalBatch(batch, { scene: false }) + return true } @@ -115,12 +125,31 @@ export function createBoardPageProvider( ): PageProvider { const { boardId, onNavigate } = config + // A short-lived whole-board cache: `list` runs per keystroke and `get` misses + // hit the replica, and BoardPersistence.load() replays the snapshot+oplog each + // call — without this, typeahead would replay the whole board on every key. + // Bounded so it self-heals; cleared on create so a new page shows immediately. + const CACHE_MS = 1500 + let cache: { at: number; content: BoardContent } | null = null + const loadBoard = async (): Promise => { + const now = Date.now() + if (cache && now - cache.at < CACHE_MS) return cache.content + const { engine } = await getLocalStores() + const content = await new BoardPersistence(boardId, { engine }).load() + cache = { at: now, content } + return content + } + return { async list(query?: string) { - const items = await listLocalBoardContents(boardId) - const sheets: Page[] = items - .filter((it) => it.kind === "sheet") - .map((it) => ({ id: it.id, title: it.label?.trim() || "Untitled", icon: it.iconData ?? null })) + const { nodes } = await loadBoard() + const sheets: Page[] = nodes + .filter(isSheet) + .map((n) => { + const data = n.data as NoteNodeData | undefined + const label = typeof data?.label === "string" ? data.label : data?.label?.markdown + return { id: n.id as unknown as string, title: label?.trim() || "Untitled", icon: data?.properties?.iconData?.icon ?? null } + }) const q = query?.trim().toLowerCase() if (!q) return sheets @@ -130,13 +159,11 @@ export function createBoardPageProvider( async get(id: string) { // The live store holds the current layer — freshest, and the common case. const live = getCanvasStoreRef()?.getNode(asNodeId(id)) - if (live) return nodeToPage(live) + if (live) return isSheet(live) ? nodeToPage(live) : null // Otherwise resolve from the whole-board replica (a page in another layer). try { - const { engine } = await getLocalStores() - const content = await new BoardPersistence(boardId, { engine }).load() - const node = content.nodes.find((n) => (n.id as unknown as string) === id) - return node ? nodeToPage(node) : null + const node = (await loadBoard()).nodes.find((n) => (n.id as unknown as string) === id) + return node && isSheet(node) ? nodeToPage(node) : null } catch (err) { console.warn("[boardPageProvider] get failed", id, err) return null @@ -149,8 +176,13 @@ export function createBoardPageProvider( // Empty body — without this, noteToNode seeds the body from the label. note.content = { markdown: "" } if (opts.parentId) note.parentId = opts.parentId - writeSheetNode(noteToNode(note), note.parentId ?? null) - // Refresh the on-device contents index (sidebar tree / page picker). + const ok = writeSheetNode(noteToNode(note), note.parentId ?? null) + if (!ok) throw new Error("createBoardPageProvider: no live board to write the page into") + // Make the write durable BEFORE refreshing readers: an off-scene write emits + // no store 'change', so nothing else flush-chains the invalidate, and the + // contents index (fresh snapshot+oplog load) would miss an unflushed batch. + await getBoardPersistenceRef()?.flush() + cache = null // a new page must show in the next list() void queryClient.invalidateQueries({ queryKey: ["localBoardContents", boardId] }) return { id: note.id, From dcf4c3dc4878fa8c38c3ebb48d7214874720d037 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Sat, 29 Aug 2026 18:46:15 +0200 Subject: [PATCH 3/6] fix(board): index agent-authored surfaces via node.type fallback The contents index keyed surface detection purely on the display `styleType`, which only the convert layer (createDefaultNote -> noteToNode) sets. Agent surfaces built through the mutator carry the canonical `node.type` but no `styleType`, so an agent-created sheet/folder never appeared in the sidebar tree or the @ page picker (though it rendered fine on the canvas). Fall back to `node.type` when `styleType` is absent, in both surface readers: - listLocalBoardContents (sidebar tree + page picker source) - affectsSurfaceTree (live sidebar refresh on create/remove) styleType stays a NoteNodeData (display) field, not canonical DimNodeData, so the fix is a tolerant reader, not stamping an off-model field on agent writes. Tests: an agent-style sheet (node.type only) is indexed by listLocalBoardContents + the provider list; affectsSurfaceTree fires for it. --- .../board/api/list-local-board-contents.ts | 5 ++++- .../canvas/use-sidebar-contents-sync.test.ts | 6 ++++++ .../harness/canvas/use-sidebar-contents-sync.ts | 5 ++++- .../board/providers/board-page-provider.test.ts | 17 +++++++++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/webui/src/features/board/api/list-local-board-contents.ts b/webui/src/features/board/api/list-local-board-contents.ts index 61341ee1..3da7bb69 100644 --- a/webui/src/features/board/api/list-local-board-contents.ts +++ b/webui/src/features/board/api/list-local-board-contents.ts @@ -33,7 +33,10 @@ export async function listLocalBoardContents(boardId: string): Promise { expect(affectsSurfaceTree(batch([{ type: "node.add", node: { id: "n", data: {} } }]))).toBe(false) }) + it("falls back to node.type for an agent surface (canonical type, no styleType)", () => { + // Agent-authored sheets set node.type but not the display styleType. + expect(affectsSurfaceTree(batch([{ type: "node.add", node: { id: "n", type: "sheet", data: { meta: {} } } }]))).toBe(true) + expect(affectsSurfaceTree(batch([{ type: "node.add", node: { id: "n", type: "rect", data: {} } }]))).toBe(false) + }) + it("is true when a surface node is removed, false for a non-surface removal", () => { // The remove op carries the full node, so its kind is checkable (like add). expect(affectsSurfaceTree(batch([{ type: "node.remove", node: { id: "n", data: { styleType: "sheet" } } }]))).toBe(true) diff --git a/webui/src/features/board/harness/canvas/use-sidebar-contents-sync.ts b/webui/src/features/board/harness/canvas/use-sidebar-contents-sync.ts index b72aeb92..08763ce6 100644 --- a/webui/src/features/board/harness/canvas/use-sidebar-contents-sync.ts +++ b/webui/src/features/board/harness/canvas/use-sidebar-contents-sync.ts @@ -20,7 +20,10 @@ const SURFACE_KINDS = new Set(["sheet", "folder", "code-sandbo export const affectsSurfaceTree = (batch: OpBatch): boolean => { for (const op of batch.ops) { if (op.type === "node.add" || op.type === "node.remove") { - const kind = (op.node.data as NoteNodeData | undefined)?.styleType as BoardContentKind | undefined + // Fall back to `node.type`: agent-authored surfaces set only the canonical + // type, not the display `styleType`, so keying off styleType alone would + // skip refreshing the tree when the agent adds/removes a sheet or folder. + const kind = ((op.node.data as NoteNodeData | undefined)?.styleType ?? op.node.type) as BoardContentKind | undefined if (kind && SURFACE_KINDS.has(kind)) return true } else if (op.type === "node.update") { const data = (op.patch as { data?: Partial } | undefined)?.data diff --git a/webui/src/features/board/providers/board-page-provider.test.ts b/webui/src/features/board/providers/board-page-provider.test.ts index a4963ada..57064d50 100644 --- a/webui/src/features/board/providers/board-page-provider.test.ts +++ b/webui/src/features/board/providers/board-page-provider.test.ts @@ -159,6 +159,23 @@ describe("createBoardPageProvider (on-device store)", () => { expect(items.find((it) => it.id === page.id)?.parentId).toBe("parent-note") }) + it("indexes an agent-authored sheet (node.type only, no styleType) in list + contents", async () => { + const { persistence, store } = await setup() + // An agent sheet: built by the mutator (node.type="sheet"), no display styleType. + store.addNode({ + id: asNodeId("agent-s"), + type: "sheet", + x: 0, y: 0, w: 100, h: 50, angle: 0, groups: [], + content: "", + data: { label: { markdown: "Agent Sheet" }, meta: { v: 1, createdAt: 0, updatedAt: 0 } }, + }) + await persistence.flush() + // Sidebar source (listLocalBoardContents) picks it up via the node.type fallback. + expect((await listLocalBoardContents("b")).find((it) => it.id === "agent-s")?.kind).toBe("sheet") + // The @ page picker (provider.list) surfaces it too. + expect((await createBoardPageProvider({ boardId: "b" }).list()).map((p) => p.title)).toContain("Agent Sheet") + }) + it("get() returns null for a non-sheet node (a page is a sheet)", async () => { const { store } = await setup("b", null) addNode(store, "rect1", "a rectangle") // not a sheet, in the live store From a12ced7df90d4aed163f016c3446463e8d447349 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Sat, 29 Aug 2026 20:14:16 +0200 Subject: [PATCH 4/6] fix(board): address second-pass review on the page provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - get() live-store lookup now runs inside the try/catch, so a malformed id can't reject the promise — honors the 'return null on missing' contract. - extract titleFromData(); nodeToPage + list() share it (no duplicated RichText-vs-string decoding). - affectsSurfaceTree: node.update that patches the node-level `type` (a kind change) now counts as tree-affecting, symmetric with the add/remove node.type fallback. Skipped by design: re-adding the ['boardContents'] invalidation (dead cache — useBoardContents has no callers; the sidebar reads ['localBoardContents']), and the REST get/list fallback (offline-first narrowing; self-heals on materialize, and re-adding it would break local boards). --- .../canvas/use-sidebar-contents-sync.test.ts | 2 ++ .../canvas/use-sidebar-contents-sync.ts | 4 ++++ .../board/providers/board-page-provider.ts | 23 ++++++++++++------- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/webui/src/features/board/harness/canvas/use-sidebar-contents-sync.test.ts b/webui/src/features/board/harness/canvas/use-sidebar-contents-sync.test.ts index e7df3419..a9d1b888 100644 --- a/webui/src/features/board/harness/canvas/use-sidebar-contents-sync.test.ts +++ b/webui/src/features/board/harness/canvas/use-sidebar-contents-sync.test.ts @@ -36,6 +36,8 @@ describe("affectsSurfaceTree", () => { expect(affectsSurfaceTree(batch([{ type: "node.update", id: "n", patch: { data: { parentId: "f" } } }]))).toBe(true) expect(affectsSurfaceTree(batch([{ type: "node.update", id: "n", patch: { data: { properties: { iconData: { icon: "x" } } } } }]))).toBe(true) expect(affectsSurfaceTree(batch([{ type: "node.update", id: "n", patch: { data: { styleType: "folder" } } }]))).toBe(true) + // A kind change via the node-level `type` (agent surfaces carry no styleType). + expect(affectsSurfaceTree(batch([{ type: "node.update", id: "n", patch: { type: "sheet" } }]))).toBe(true) }) it("is false for a pure position/style update (drag/resize)", () => { diff --git a/webui/src/features/board/harness/canvas/use-sidebar-contents-sync.ts b/webui/src/features/board/harness/canvas/use-sidebar-contents-sync.ts index 08763ce6..a01a2711 100644 --- a/webui/src/features/board/harness/canvas/use-sidebar-contents-sync.ts +++ b/webui/src/features/board/harness/canvas/use-sidebar-contents-sync.ts @@ -26,6 +26,10 @@ export const affectsSurfaceTree = (batch: OpBatch): boolean => { const kind = ((op.node.data as NoteNodeData | undefined)?.styleType ?? op.node.type) as BoardContentKind | undefined if (kind && SURFACE_KINDS.has(kind)) return true } else if (op.type === "node.update") { + // A kind change rides on the node-level `type` (agent surfaces set only the + // canonical type), so treat that as tree-affecting too — symmetric with the + // add/remove node.type fallback above. + if ("type" in op.patch) return true const data = (op.patch as { data?: Partial } | undefined)?.data if (data && ("label" in data || "parentId" in data || "properties" in data || "styleType" in data)) { return true diff --git a/webui/src/features/board/providers/board-page-provider.ts b/webui/src/features/board/providers/board-page-provider.ts index f1d41836..4c61ac7a 100644 --- a/webui/src/features/board/providers/board-page-provider.ts +++ b/webui/src/features/board/providers/board-page-provider.ts @@ -48,6 +48,13 @@ function isSheet(node: Node): boolean { } +/** Title from node data — RichText label or a legacy bare string; "Untitled" if blank. */ +function titleFromData(data: NoteNodeData | undefined): string { + const label = typeof data?.label === "string" ? data.label : data?.label?.markdown + return label?.trim() || "Untitled" +} + + /** * Map a board Note onto the editor's `Page` shape: title (falling back to * "Untitled"), the user's custom icon (`iconData.icon`, else null so the @@ -72,10 +79,9 @@ export function noteToPage(note: Note): Page { */ function nodeToPage(node: Node): Page { const data = node.data as NoteNodeData | undefined - const label = typeof data?.label === "string" ? data.label : data?.label?.markdown return { id: node.id as unknown as string, - title: label?.trim() || "Untitled", + title: titleFromData(data), icon: data?.properties?.iconData?.icon ?? null, parentId: data?.parentId ?? undefined, snippet: snippetFromMarkdown(node.content ?? undefined), @@ -147,8 +153,7 @@ export function createBoardPageProvider( .filter(isSheet) .map((n) => { const data = n.data as NoteNodeData | undefined - const label = typeof data?.label === "string" ? data.label : data?.label?.markdown - return { id: n.id as unknown as string, title: label?.trim() || "Untitled", icon: data?.properties?.iconData?.icon ?? null } + return { id: n.id as unknown as string, title: titleFromData(data), icon: data?.properties?.iconData?.icon ?? null } }) const q = query?.trim().toLowerCase() @@ -157,11 +162,13 @@ export function createBoardPageProvider( }, async get(id: string) { - // The live store holds the current layer — freshest, and the common case. - const live = getCanvasStoreRef()?.getNode(asNodeId(id)) - if (live) return isSheet(live) ? nodeToPage(live) : null - // Otherwise resolve from the whole-board replica (a page in another layer). + // Contract: return null (never throw) on missing / no access — so a + // malformed id (asNodeId/getNode) can't reject the promise either. try { + // The live store holds the current layer — freshest, and the common case. + const live = getCanvasStoreRef()?.getNode(asNodeId(id)) + if (live) return isSheet(live) ? nodeToPage(live) : null + // Otherwise resolve from the whole-board replica (a page in another layer). const node = (await loadBoard()).nodes.find((n) => (n.id as unknown as string) === id) return node && isSheet(node) ? nodeToPage(node) : null } catch (err) { From c2f354a834faf536542c855b97903bacd5f931e3 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Sun, 30 Aug 2026 19:19:14 +0200 Subject: [PATCH 5/6] fix(board): open + edit off-scene sub-pages in the sheet surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes local-board sub-pages: creating one worked (#265), but OPENING it showed 'This sheet no longer exists'. The surface host resolved a note only via the live store (current layer) or REST — a /subpage lives off-scene in a sub-layer, so it was in neither (not on the canvas, not on the server for a local board), and even a successful load would have lost edits (save was store-or-REST too). Add useOffSceneNote (+ testable openOffSceneNoteStore core): seed the note's layer from the whole-board replica into a throwaway store and forward its edits to the sync intake (record + submitLocalBatch scene:false) — the surface-host analog of the agent's HeadlessMutator. sheet-panel now resolves the note as live-store -> off-scene replica -> REST, and points `store` at whichever holds it, so the existing store.updateNode save path works off-scene unchanged. REST is gated to fire only after the off-scene load settles (synced-not-materialized). Save callbacks read prevData from the live store node so multi-edit merges don't clobber a prior off-scene edit. Tests: off-scene sub-page loads into an editable store; an edit records + enters the sync intake scene:false and lands in the oplog. --- .../chrome/node-surface-host/sheet-panel.tsx | 35 ++++--- .../use-off-scene-note.test.ts | 76 +++++++++++++++ .../node-surface-host/use-off-scene-note.ts | 94 +++++++++++++++++++ 3 files changed, 194 insertions(+), 11 deletions(-) create mode 100644 webui/src/features/board/harness/chrome/node-surface-host/use-off-scene-note.test.ts create mode 100644 webui/src/features/board/harness/chrome/node-surface-host/use-off-scene-note.ts diff --git a/webui/src/features/board/harness/chrome/node-surface-host/sheet-panel.tsx b/webui/src/features/board/harness/chrome/node-surface-host/sheet-panel.tsx index 659985cc..acefd85d 100644 --- a/webui/src/features/board/harness/chrome/node-surface-host/sheet-panel.tsx +++ b/webui/src/features/board/harness/chrome/node-surface-host/sheet-panel.tsx @@ -23,6 +23,7 @@ import type { Note, NoteProperties } from "@/features/board/types/note" import type { IconProperty } from "@/features/newsfeed/types/properties" import type { NoteNodeData } from "../../convert/note-to-node" import { useBoardAppStore } from "../../store/board-app-store" +import { useOffSceneNote } from "./use-off-scene-note" export type SheetPanelProps = { @@ -46,22 +47,33 @@ export const SheetPanel = memo(function SheetPanel({ nodeId, onClose, }: SheetPanelProps) { - const store = useCanvasStore() + const liveStore = useCanvasStore() const navigate = useNavigate() const queryClient = useQueryClient() - const localNode = useNode(nodeId as NodeId) - const localData = (localNode?.data ?? {}) as Partial + const liveNode = useNode(nodeId as NodeId) const activeBoardId = useBoardAppStore((s) => s.boardId) const openNodeSurface = useBoardAppStore((s) => s.openNodeSurface) + // A sub-page lives in a layer that isn't on the canvas, so it's absent from the + // live store. Load it OFF-SCENE from the local replica — read + sync-correct + // edit — instead of the REST path, which 404s for a local (unsynced) board. + const off = useOffSceneNote(liveStore, activeBoardId ?? null, nodeId, !liveNode) + const localNode = liveNode ?? off.node + // The store that actually holds the note: the live canvas store for an on-canvas + // sheet, else the off-scene store (whose edits sync via its own change wiring). + const store = liveNode ? liveStore : off.store ?? liveStore + const localData = (localNode?.data ?? {}) as Partial const isLocalNote = !!localNode + // While the off-scene load is in flight we don't yet know if the note exists — + // don't flash "no longer exists". + const offSceneLoading = !liveNode && !!activeBoardId && !off.ready - // REST fallback for sheets not present on the current canvas scope - // (sub-pages reached via the editor's `/subpage` slash command). + // REST fallback ONLY for a synced note not yet materialized locally: the + // off-scene load has settled (`off.ready`) without finding it in the replica. const { data: fetchedNote, isLoading: isFetchingNote } = useGetNote({ boardId: activeBoardId ?? undefined, noteId: nodeId, - enabled: !isLocalNote && !!activeBoardId, + enabled: !isLocalNote && off.ready && !!activeBoardId, }) // Resolved view of the note — prefer local store, fall back to fetch. @@ -182,7 +194,9 @@ export const SheetPanel = memo(function SheetPanel({ ) } if (isLocalNote) { - const prevData = (localNode?.data ?? {}) as Record + // Read the freshest data from the target store (not the render-time + // snapshot) so a prior off-scene edit isn't clobbered by a stale merge. + const prevData = (store.getNode(nodeId as NodeId)?.data ?? {}) as Record store.updateNode(nodeId as NodeId, { data: { ...prevData, @@ -193,7 +207,7 @@ export const SheetPanel = memo(function SheetPanel({ persistRemote({ label: trimmed ? { markdown: trimmed } : undefined }) } }, - [isLocalNote, localNode?.data, nodeId, noteLabel, persistRemote, store, boardId, queryClient], + [isLocalNote, nodeId, noteLabel, persistRemote, store, boardId, queryClient], ) const stopTitleEdit = useCallback( @@ -241,7 +255,7 @@ export const SheetPanel = memo(function SheetPanel({ } if (isLocalNote) { - const prevData = (localNode?.data ?? {}) as Record + const prevData = (store.getNode(nodeId as NodeId)?.data ?? {}) as Record const prevProps = (prevData.properties as Partial | undefined) ?? {} store.updateNode(nodeId as NodeId, { @@ -265,7 +279,6 @@ export const SheetPanel = memo(function SheetPanel({ }, [ isLocalNote, - localNode?.data, nodeId, store, fetchedNote, @@ -300,7 +313,7 @@ export const SheetPanel = memo(function SheetPanel({ // REST fetch has settled (otherwise sub-pages flash that message // before their data arrives). if (!exists) { - if (isFetchingNote) { + if (offSceneLoading || isFetchingNote) { return (
resetIdb()) +afterEach(() => { + setBoardPersistenceRef(null) + setBoardSyncRef(null) +}) + + +// Seed a sub-page (sheet with parentId = a parent note) into the whole-board +// replica, as `/subpage` off-scene creation does. +const seedSubpage = async (boardId: string, id: string, parentId: string, opts: { label?: string; content?: string } = {}) => { + const { engine } = await getLocalStores() + const persistence = new BoardPersistence(boardId, { engine }) + const store = freshStore("seed") + const unsub = persistence.attach(store) + const note = createDefaultNote({ boardId, nodeType: "sheet" }) + note.id = id + note.parentId = parentId + note.label = { markdown: opts.label ?? "Sub" } + note.content = { markdown: opts.content ?? "body" } + store.addNode(noteToNode(note)) + await persistence.flush() + unsub() + return persistence +} + + +describe("openOffSceneNoteStore", () => { + it("loads an off-scene sub-page from the replica into an editable store", async () => { + const persistence = await seedSubpage("b", "sub1", "parent1", { label: "Deep", content: "hello" }) + setBoardPersistenceRef(persistence) + setBoardSyncRef(null) + + const live = freshStore("live") // the visible canvas (does NOT contain the sub-page) + const { store, node, dispose } = await openOffSceneNoteStore(live, "sub1") + + expect(live.getNode(asNodeId("sub1"))).toBeUndefined() // not on the visible canvas + expect(node?.type).toBe("sheet") + expect(node?.content).toBe("hello") + expect(store.getNode(asNodeId("sub1"))).toBeTruthy() // present in the off-scene store + dispose() + }) + + it("forwards an edit to the sync intake off-scene (record + submitLocalBatch scene:false)", async () => { + const persistence = await seedSubpage("b", "sub1", "parent1", { content: "old" }) + setBoardPersistenceRef(persistence) + const submitted: { scene?: boolean }[] = [] + setBoardSyncRef({ + submitLocalBatch: (_b: OpBatch, o?: { scene?: boolean }) => submitted.push({ scene: o?.scene }), + } as unknown as BoardSyncHandle) + + const live = freshStore("live") + const { store, dispose } = await openOffSceneNoteStore(live, "sub1") + store.updateNode(asNodeId("sub1"), { content: "edited" }) + await persistence.flush() + + // Edit entered the sync intake off-scene, and landed in the whole-board oplog. + expect(submitted.length).toBeGreaterThan(0) + expect(submitted.every((s) => s.scene === false)).toBe(true) + const whole = await persistence.load() + expect(whole.nodes.find((n) => String(n.id) === "sub1")?.content).toBe("edited") + dispose() + }) +}) diff --git a/webui/src/features/board/harness/chrome/node-surface-host/use-off-scene-note.ts b/webui/src/features/board/harness/chrome/node-surface-host/use-off-scene-note.ts new file mode 100644 index 00000000..24dcd979 --- /dev/null +++ b/webui/src/features/board/harness/chrome/node-surface-host/use-off-scene-note.ts @@ -0,0 +1,94 @@ +import { useEffect, useState } from "react" +import { asNodeId, createCanvasStore } from "@canvas-harness/core" +import type { CanvasStore, Node } from "@canvas-harness/core" +import { generateUuid } from "@/lib/common" +import { contentToScene, emptyContent } from "@/features/board/persist/local/codec" +import { filterContentByLayer } from "@/features/board/model/layer" +import { getBoardPersistenceRef } from "@/features/board/persist/local/board-persistence-ref" +import { getBoardSyncRef } from "@/features/board/harness/sync/board-sync-ref" + + +/** + * Open a throwaway, off-scene store holding the note `nodeId` (and its layer), + * seeded from the whole-board replica. The surface reads from it and edits + * through it (`store.updateNode`); every edit is forwarded to the sync-correct + * intake (`record` + `submitLocalBatch({scene:false})`), so an off-scene edit + * persists + syncs exactly like an on-canvas one, without moving the user's view. + * The surface-host analog of the agent's `HeadlessMutator`. Pure (non-React) so + * the load + sync wiring is unit-testable. + * + * `dispose()` detaches the change subscription — call it when the surface closes. + */ +export async function openOffSceneNoteStore( + liveStore: CanvasStore, + nodeId: string, +): Promise<{ store: CanvasStore; node: Node | null; dispose: () => void }> { + const persistence = getBoardPersistenceRef() + // Flush first so a re-open reflects this session's own (debounced) off-scene + // edits, rather than re-seeding from a stale oplog tail. + await persistence?.flush() + const content = persistence ? await persistence.load() : emptyContent() + const target = content.nodes.find((n) => (n.id as unknown as string) === nodeId) + const layer = (target?.data as { parentId?: string | null } | undefined)?.parentId ?? null + const store = createCanvasStore({ + clientId: liveStore.clientId, + idGenerator: generateUuid, + initial: contentToScene(filterContentByLayer(content, layer)), + }) + // Every off-scene edit records to the oplog + enters the sync intake as an + // off-scene batch (never the in-scene rebase set), mirroring the scene store's + // persistence.attach + attachSync — minus the render. + const dispose = store.subscribe("change", (batch) => { + persistence?.record(batch) + getBoardSyncRef()?.submitLocalBatch(batch, { scene: false }) + }) + return { store, node: store.getNode(asNodeId(nodeId)) ?? null, dispose } +} + + +/** + * React wrapper over {@link openOffSceneNoteStore}. Loads a note that lives + * OFF-SCENE — a sub-page in a layer that isn't on the canvas, so it's absent from + * the live store — into a store the surface can read AND edit. + * + * `enabled` should be true only when the note ISN'T in the live store (a normal + * on-canvas sheet edits through the live store, unchanged). `ready` flips true + * once the async load settles, so the caller can tell "still loading" apart from + * "not found" (a synced note not yet materialized locally then falls back to REST). + */ +export function useOffSceneNote( + liveStore: CanvasStore, + boardId: string | null, + nodeId: string, + enabled: boolean, +): { store: CanvasStore | null; node: Node | null; ready: boolean } { + const [source, setSource] = useState<{ store: CanvasStore; node: Node | null } | null>(null) + const [ready, setReady] = useState(false) + + useEffect(() => { + if (!enabled || !boardId) { + setSource(null) + setReady(false) + return + } + let cancelled = false + let dispose: () => void = () => {} + setReady(false) + setSource(null) + void openOffSceneNoteStore(liveStore, nodeId).then((res) => { + if (cancelled) { + res.dispose() + return + } + dispose = res.dispose + setSource({ store: res.store, node: res.node }) + setReady(true) + }) + return () => { + cancelled = true + dispose() + } + }, [enabled, boardId, nodeId, liveStore]) + + return { store: source?.store ?? null, node: source?.node ?? null, ready } +} From 1510a931e2a95267450afd6084ba7efd63723040 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Sun, 30 Aug 2026 19:29:47 +0200 Subject: [PATCH 6/6] fix(board): address review on off-scene surface (reactive node, sidebar, caches) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Off-scene node is now LIVE: it re-reads on the off-scene store's changes, so a sub-page rename / icon edit shows in the panel instead of reverting to the load-time snapshot (edits were saved but looked reverted). - Surface-relevant off-scene edits invalidate the sidebar's localBoardContents cache (the sidebar sync only watches the live store, so it never saw them). - Optimistic rename/icon patch now targets localBoardContents (what the sidebar reads) instead of the dead boardContents key. - openOffSceneNoteStore returns a null store when the note isn't in the replica, so a synced-not-materialized note falls back to REST without building a doomed store (no wasted whole-board replay + subscription). - Page provider get() trusts the live store only when it IS this provider's board (guards a sub-graph id collision). Skipped: the 1500ms @-picker loadBoard cache staleness — an accepted trade-off against per-keystroke oplog replay. Tests: not-in-replica returns a null store; existing off-scene load + edit-sync tests updated for the boardId param. --- .../chrome/node-surface-host/sheet-panel.tsx | 4 +- .../use-off-scene-note.test.ts | 19 +++++-- .../node-surface-host/use-off-scene-note.ts | 49 ++++++++++++++----- .../board/providers/board-page-provider.ts | 7 ++- 4 files changed, 60 insertions(+), 19 deletions(-) diff --git a/webui/src/features/board/harness/chrome/node-surface-host/sheet-panel.tsx b/webui/src/features/board/harness/chrome/node-surface-host/sheet-panel.tsx index acefd85d..36496385 100644 --- a/webui/src/features/board/harness/chrome/node-surface-host/sheet-panel.tsx +++ b/webui/src/features/board/harness/chrome/node-surface-host/sheet-panel.tsx @@ -189,7 +189,7 @@ export const SheetPanel = memo(function SheetPanel({ // store.updateNode below already keeps it in sync. if (boardId) { queryClient.setQueriesData( - { queryKey: ["boardContents", boardId] }, + { queryKey: ["localBoardContents", boardId] }, (old) => applyTitleUpdateToBoardContents(old, nodeId, trimmed || null), ) } @@ -249,7 +249,7 @@ export const SheetPanel = memo(function SheetPanel({ // expanded folder) gets the matching item updated in place. if (boardId) { queryClient.setQueriesData( - { queryKey: ["boardContents", boardId] }, + { queryKey: ["localBoardContents", boardId] }, (old) => applyIconUpdateToBoardContents(old, nodeId, next), ) } diff --git a/webui/src/features/board/harness/chrome/node-surface-host/use-off-scene-note.test.ts b/webui/src/features/board/harness/chrome/node-surface-host/use-off-scene-note.test.ts index 2c7ef533..d6234a9f 100644 --- a/webui/src/features/board/harness/chrome/node-surface-host/use-off-scene-note.test.ts +++ b/webui/src/features/board/harness/chrome/node-surface-host/use-off-scene-note.test.ts @@ -44,12 +44,23 @@ describe("openOffSceneNoteStore", () => { setBoardSyncRef(null) const live = freshStore("live") // the visible canvas (does NOT contain the sub-page) - const { store, node, dispose } = await openOffSceneNoteStore(live, "sub1") + const { store, node, dispose } = await openOffSceneNoteStore(live, "b", "sub1") expect(live.getNode(asNodeId("sub1"))).toBeUndefined() // not on the visible canvas expect(node?.type).toBe("sheet") expect(node?.content).toBe("hello") - expect(store.getNode(asNodeId("sub1"))).toBeTruthy() // present in the off-scene store + expect(store?.getNode(asNodeId("sub1"))).toBeTruthy() // present in the off-scene store + dispose() + }) + + it("returns a null store when the note isn't in the replica (falls back to REST)", async () => { + const persistence = await seedSubpage("b", "sub1", "parent1") + setBoardPersistenceRef(persistence) + setBoardSyncRef(null) + + const { store, node, dispose } = await openOffSceneNoteStore(freshStore("live"), "b", "ghost") + expect(store).toBeNull() // no doomed store built for a not-in-replica note + expect(node).toBeNull() dispose() }) @@ -62,8 +73,8 @@ describe("openOffSceneNoteStore", () => { } as unknown as BoardSyncHandle) const live = freshStore("live") - const { store, dispose } = await openOffSceneNoteStore(live, "sub1") - store.updateNode(asNodeId("sub1"), { content: "edited" }) + const { store, dispose } = await openOffSceneNoteStore(live, "b", "sub1") + store!.updateNode(asNodeId("sub1"), { content: "edited" }) await persistence.flush() // Edit entered the sync intake off-scene, and landed in the whole-board oplog. diff --git a/webui/src/features/board/harness/chrome/node-surface-host/use-off-scene-note.ts b/webui/src/features/board/harness/chrome/node-surface-host/use-off-scene-note.ts index 24dcd979..ea25174a 100644 --- a/webui/src/features/board/harness/chrome/node-surface-host/use-off-scene-note.ts +++ b/webui/src/features/board/harness/chrome/node-surface-host/use-off-scene-note.ts @@ -2,10 +2,12 @@ import { useEffect, useState } from "react" import { asNodeId, createCanvasStore } from "@canvas-harness/core" import type { CanvasStore, Node } from "@canvas-harness/core" import { generateUuid } from "@/lib/common" +import { queryClient } from "@/query-client" import { contentToScene, emptyContent } from "@/features/board/persist/local/codec" import { filterContentByLayer } from "@/features/board/model/layer" import { getBoardPersistenceRef } from "@/features/board/persist/local/board-persistence-ref" import { getBoardSyncRef } from "@/features/board/harness/sync/board-sync-ref" +import { affectsSurfaceTree } from "@/features/board/harness/canvas/use-sidebar-contents-sync" /** @@ -17,19 +19,28 @@ import { getBoardSyncRef } from "@/features/board/harness/sync/board-sync-ref" * The surface-host analog of the agent's `HeadlessMutator`. Pure (non-React) so * the load + sync wiring is unit-testable. * - * `dispose()` detaches the change subscription — call it when the surface closes. + * Returns `store: null` when the note isn't in the local replica (a synced note + * not yet materialized) — the caller then falls back to REST, and we skip + * building a doomed store. Surface-relevant off-scene edits (rename / re-icon / + * move) invalidate the sidebar's `localBoardContents` cache, since the sidebar + * sync only listens to the live store. `dispose()` detaches the subscription. */ export async function openOffSceneNoteStore( liveStore: CanvasStore, + boardId: string | null, nodeId: string, -): Promise<{ store: CanvasStore; node: Node | null; dispose: () => void }> { +): Promise<{ store: CanvasStore | null; node: Node | null; dispose: () => void }> { const persistence = getBoardPersistenceRef() // Flush first so a re-open reflects this session's own (debounced) off-scene // edits, rather than re-seeding from a stale oplog tail. await persistence?.flush() const content = persistence ? await persistence.load() : emptyContent() const target = content.nodes.find((n) => (n.id as unknown as string) === nodeId) - const layer = (target?.data as { parentId?: string | null } | undefined)?.parentId ?? null + // Not in the replica → let the caller's REST path handle it; don't build a + // store (avoids a wasted seed + subscription for a synced-not-local note). + if (!target) return { store: null, node: null, dispose: () => {} } + + const layer = (target.data as { parentId?: string | null } | undefined)?.parentId ?? null const store = createCanvasStore({ clientId: liveStore.clientId, idGenerator: generateUuid, @@ -37,10 +48,16 @@ export async function openOffSceneNoteStore( }) // Every off-scene edit records to the oplog + enters the sync intake as an // off-scene batch (never the in-scene rebase set), mirroring the scene store's - // persistence.attach + attachSync — minus the render. + // persistence.attach + attachSync — minus the render. A surface-relevant edit + // also refreshes the sidebar tree (the sidebar sync can't see this store). const dispose = store.subscribe("change", (batch) => { persistence?.record(batch) getBoardSyncRef()?.submitLocalBatch(batch, { scene: false }) + if (boardId && affectsSurfaceTree(batch)) { + void Promise.resolve(persistence?.flush()).then(() => + queryClient.invalidateQueries({ queryKey: ["localBoardContents", boardId] }), + ) + } }) return { store, node: store.getNode(asNodeId(nodeId)) ?? null, dispose } } @@ -48,13 +65,14 @@ export async function openOffSceneNoteStore( /** * React wrapper over {@link openOffSceneNoteStore}. Loads a note that lives - * OFF-SCENE — a sub-page in a layer that isn't on the canvas, so it's absent from - * the live store — into a store the surface can read AND edit. + * OFF-SCENE — a sub-page in a layer that isn't on the canvas — into a store the + * surface can read AND edit. * * `enabled` should be true only when the note ISN'T in the live store (a normal - * on-canvas sheet edits through the live store, unchanged). `ready` flips true - * once the async load settles, so the caller can tell "still loading" apart from - * "not found" (a synced note not yet materialized locally then falls back to REST). + * on-canvas sheet edits through the live store, unchanged). `node` stays live — + * it re-reads on the off-scene store's changes so a rename / icon edit shows in + * the panel (not just persists). `ready` flips true once the async load settles, + * so the caller can tell "still loading" from "not found" (→ REST fallback). */ export function useOffSceneNote( liveStore: CanvasStore, @@ -62,7 +80,7 @@ export function useOffSceneNote( nodeId: string, enabled: boolean, ): { store: CanvasStore | null; node: Node | null; ready: boolean } { - const [source, setSource] = useState<{ store: CanvasStore; node: Node | null } | null>(null) + const [source, setSource] = useState<{ store: CanvasStore | null; node: Node | null } | null>(null) const [ready, setReady] = useState(false) useEffect(() => { @@ -75,12 +93,19 @@ export function useOffSceneNote( let dispose: () => void = () => {} setReady(false) setSource(null) - void openOffSceneNoteStore(liveStore, nodeId).then((res) => { + void openOffSceneNoteStore(liveStore, boardId, nodeId).then((res) => { if (cancelled) { res.dispose() return } - dispose = res.dispose + // Keep `node` live so panel-visible fields (title, icon) reflect edits. + const reRead = (): void => + setSource({ store: res.store, node: res.store?.getNode(asNodeId(nodeId)) ?? null }) + const unsubReactive = res.store?.subscribe("change", reRead) ?? (() => {}) + dispose = () => { + unsubReactive() + res.dispose() + } setSource({ store: res.store, node: res.node }) setReady(true) }) diff --git a/webui/src/features/board/providers/board-page-provider.ts b/webui/src/features/board/providers/board-page-provider.ts index 4c61ac7a..9465576b 100644 --- a/webui/src/features/board/providers/board-page-provider.ts +++ b/webui/src/features/board/providers/board-page-provider.ts @@ -166,7 +166,12 @@ export function createBoardPageProvider( // malformed id (asNodeId/getNode) can't reject the promise either. try { // The live store holds the current layer — freshest, and the common case. - const live = getCanvasStoreRef()?.getNode(asNodeId(id)) + // Only trust it when it IS this provider's board (a sub-graph provider must + // not resolve an id against a different mounted board). + const live = + boardId === useBoardAppStore.getState().boardId + ? getCanvasStoreRef()?.getNode(asNodeId(id)) + : undefined if (live) return isSheet(live) ? nodeToPage(live) : null // Otherwise resolve from the whole-board replica (a page in another layer). const node = (await loadBoard()).nodes.find((n) => (n.id as unknown as string) === id)