Skip to content
Open
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
5 changes: 4 additions & 1 deletion webui/src/features/board/api/list-local-board-contents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ export async function listLocalBoardContents(boardId: string): Promise<BoardCont
// Runtime `data` is `NoteNodeData` (the converter's payload) even though the
// model types it as the leaner `DimNodeData`; read the surface fields off it.
const data = node.data as NoteNodeData | undefined
const kind = data?.styleType as BoardContentKind | undefined
// Prefer the display `styleType`, but fall back to the canonical `node.type`:
// agent-authored surfaces (built via the mutator, not the convert layer) set
// only `node.type`, so without this they'd be missing from the tree/picker.
const kind = (data?.styleType ?? node.type) as BoardContentKind | undefined
if (!kind || !SURFACE_KINDS.has(kind)) continue
// label is `RichText` at runtime; fall back to a plain string defensively.
const label =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ describe("affectsSurfaceTree", () => {
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)
Expand All @@ -30,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)", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,16 @@ const SURFACE_KINDS = new Set<BoardContentKind>(["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") {
// 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<NoteNodeData> } | undefined)?.data
if (data && ("label" in data || "parentId" in data || "properties" in data || "styleType" in data)) {
return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<NoteNodeData>
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<NoteNodeData>
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.
Expand Down Expand Up @@ -177,12 +189,14 @@ export const SheetPanel = memo(function SheetPanel({
// store.updateNode below already keeps it in sync.
if (boardId) {
queryClient.setQueriesData<BoardContentItem[]>(
{ queryKey: ["boardContents", boardId] },
{ queryKey: ["localBoardContents", boardId] },
(old) => applyTitleUpdateToBoardContents(old, nodeId, trimmed || null),
)
}
if (isLocalNote) {
const prevData = (localNode?.data ?? {}) as Record<string, unknown>
// 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<string, unknown>
store.updateNode(nodeId as NodeId, {
data: {
...prevData,
Expand All @@ -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(
Expand Down Expand Up @@ -235,13 +249,13 @@ export const SheetPanel = memo(function SheetPanel({
// expanded folder) gets the matching item updated in place.
if (boardId) {
queryClient.setQueriesData<BoardContentItem[]>(
{ queryKey: ["boardContents", boardId] },
{ queryKey: ["localBoardContents", boardId] },
(old) => applyIconUpdateToBoardContents(old, nodeId, next),
)
}

if (isLocalNote) {
const prevData = (localNode?.data ?? {}) as Record<string, unknown>
const prevData = (store.getNode(nodeId as NodeId)?.data ?? {}) as Record<string, unknown>
const prevProps =
(prevData.properties as Partial<NoteProperties> | undefined) ?? {}
store.updateNode(nodeId as NodeId, {
Expand All @@ -265,7 +279,6 @@ export const SheetPanel = memo(function SheetPanel({
},
[
isLocalNote,
localNode?.data,
nodeId,
store,
fetchedNote,
Expand Down Expand Up @@ -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 (
<div
className={`${PANEL_CLASS} items-center justify-center text-sm text-muted-foreground`}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"
import { asNodeId, type OpBatch } from "@canvas-harness/core"
import { freshStore, resetIdb } from "@/test/canvas"
import { getLocalStores } from "@/features/local-stores"
import { BoardPersistence } from "@/features/board/persist/local/board-persistence"
import { setBoardPersistenceRef } from "@/features/board/persist/local/board-persistence-ref"
import { setBoardSyncRef } from "@/features/board/harness/sync/board-sync-ref"
import type { BoardSyncHandle } from "@/features/board/harness/sync/board-sync"
import { noteToNode } from "../../convert/note-to-node"
import { createDefaultNote } from "@/features/board/types/note"
import { openOffSceneNoteStore } from "./use-off-scene-note"


beforeEach(() => 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, "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
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()
})

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, "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.
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()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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"


/**
* 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.
*
* 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 | 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)
// 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,
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. 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 }
}


/**
* React wrapper over {@link openOffSceneNoteStore}. Loads a note that lives
* 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). `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,
boardId: string | null,
nodeId: string,
enabled: boolean,
): { store: CanvasStore | null; node: Node | null; ready: boolean } {
const [source, setSource] = useState<{ store: CanvasStore | null; 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, boardId, nodeId).then((res) => {
if (cancelled) {
res.dispose()
return
}
// 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)
})
return () => {
cancelled = true
dispose()
}
}, [enabled, boardId, nodeId, liveStore])

return { store: source?.store ?? null, node: source?.node ?? null, ready }
}
Loading
Loading