From 7e98229ea6c879ca2ecc44089aa345b0178908ad Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Wed, 26 Aug 2026 14:55:25 +0200 Subject: [PATCH 1/2] fix(board): unify node freshness stamp so agent notes show Created/Edited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-created notes only carry the canonical `meta` (ms numbers), but the sheet card and list view read the legacy top-level `createdAt`/`updatedAt` ISO strings, so agent notes showed no stamp. The mutator also reset `createdAt` on every rewrite, so an edited note never read as "Edited". Add `node-meta` helpers: `freshMeta` (create), `bumpMeta` (edit — preserve createdAt, advance updatedAt, bump version), and `nodeStamp` (read meta first, fall back to the legacy strings). Route the mutator's create/rewrite/ patch/link through them and switch the sheet + list readers to `nodeStamp`. --- .../agent/engine/board-mutator.test.ts | 35 ++++++++++ .../features/agent/engine/board-mutator.ts | 18 +++-- .../board/harness/node-types/sheet/view.tsx | 8 ++- .../board/harness/views/list-view.tsx | 5 +- .../features/board/utils/node-meta.test.ts | 67 +++++++++++++++++++ webui/src/features/board/utils/node-meta.ts | 57 ++++++++++++++++ 6 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 webui/src/features/board/utils/node-meta.test.ts create mode 100644 webui/src/features/board/utils/node-meta.ts diff --git a/webui/src/features/agent/engine/board-mutator.test.ts b/webui/src/features/agent/engine/board-mutator.test.ts index eb2258b9..87e0e04f 100644 --- a/webui/src/features/agent/engine/board-mutator.test.ts +++ b/webui/src/features/agent/engine/board-mutator.test.ts @@ -16,6 +16,8 @@ const label = (store: CanvasStore, id: string): string => const body = (store: CanvasStore, id: string): string => store.getNode(asNodeId(id))?.content ?? "" const stored = (store: CanvasStore, id: string) => (store.getNode(asNodeId(id))?.data as DimNodeData | undefined)?._storedColors +const nodeMeta = (store: CanvasStore, id: string) => + (store.getNode(asNodeId(id))?.data as DimNodeData | undefined)?.meta describe("StoreMutator", () => { @@ -204,6 +206,39 @@ describe("StoreMutator", () => { expect(stored(store, id)?.textColor).toBe("#ffffff") }) + it("createNote stamps fresh meta (v:1, createdAt == updatedAt) so the note shows a 'Created' stamp", async () => { + const store = freshStore("b") + const m = new StoreMutator(store, null) + const { id } = await m.createNote({ content: "x" }) + const meta = nodeMeta(store, id) + expect(meta?.v).toBe(1) + expect(meta?.createdAt).toBeTypeOf("number") + expect(meta?.updatedAt).toBe(meta?.createdAt) // brand new → same instant + }) + + it("rewriteNote preserves createdAt and advances the version so the note reads as 'Edited'", async () => { + const store = freshStore("b") + const m = new StoreMutator(store, null) + const { id } = await m.createNote({ content: "old" }) + const before = nodeMeta(store, id) + await m.rewriteNote(id, { content: "new" }) + const after = nodeMeta(store, id) + expect(after?.createdAt).toBe(before?.createdAt) // original creation time kept + expect(after?.v).toBe((before?.v ?? 0) + 1) + expect(after?.updatedAt).toBeGreaterThanOrEqual(before?.updatedAt ?? 0) + }) + + it("patchNote bumps meta (createdAt preserved) on a content-only edit", async () => { + const store = freshStore("b") + const m = new StoreMutator(store, null) + const { id } = await m.createNote({ content: "old", label: "Keep" }) + const before = nodeMeta(store, id) + await m.patchNote(id, { content: "changed" }) + const after = nodeMeta(store, id) + expect(after?.createdAt).toBe(before?.createdAt) + expect(after?.v).toBe((before?.v ?? 0) + 1) + }) + it("createLink attaches an edge at node centers with the parent layer", async () => { const store = freshStore("b") const m = new StoreMutator(store, "folder-1") diff --git a/webui/src/features/agent/engine/board-mutator.ts b/webui/src/features/agent/engine/board-mutator.ts index 04b0b009..7d39dc2d 100644 --- a/webui/src/features/agent/engine/board-mutator.ts +++ b/webui/src/features/agent/engine/board-mutator.ts @@ -29,6 +29,7 @@ import { import { getBoardThemeMode } from "@/features/board/harness/theme/theme-mode-ref" import { createDefaultLinkStyle, createDefaultStyle } from "@/features/board/types/style" import { beneathBorderOrigin } from "@/features/board/harness/agent/beneath-border" +import { bumpMeta, freshMeta } from "@/features/board/utils/node-meta" import { estimateNoteSize } from "./note-size" @@ -243,11 +244,6 @@ const noteGeometry = (nodeType: string, content: string): { w: number; h: number } -/** Fresh SyncMeta stamp for a created/updated entity. */ -const meta = (): DimNodeData["meta"] => { - const t = Date.now() - return { v: 1, createdAt: t, updatedAt: t } -} /** @@ -332,7 +328,7 @@ export class StoreMutator implements BoardMutator { data: { label: { markdown: spec.label ?? "" }, parentId: this.rootId ?? undefined, - meta: meta(), + meta: freshMeta(), _storedColors: storedColors, } satisfies DimNodeData, }) @@ -400,7 +396,7 @@ export class StoreMutator implements BoardMutator { const data: DimNodeData = { ...prev, label: spec.label ? { markdown: spec.label } : (prev?.label ?? { markdown: "" }), - meta: meta(), + meta: bumpMeta(prev?.meta), // rewrite = edit → preserve createdAt, advance updatedAt } as DimNodeData // Default: keep the existing style (+ autoFit for custom types). Recolor only // the channels the caller named (merge, don't randomize the rest), and only @@ -428,9 +424,11 @@ export class StoreMutator implements BoardMutator { const node = this.store.getNode(nid) if (!node) return const prev = node.data as DimNodeData | undefined - const next: Partial = {} + // Any edit advances the freshness stamp (preserving createdAt), so the note + // reads as "Edited" — whether the caller changed the content, the label, or both. + const next: Partial = { data: { ...prev, meta: bumpMeta(prev?.meta) } as DimNodeData } if (patch.content !== undefined) next.content = patch.content - if (patch.label !== undefined) next.data = { ...prev, label: { markdown: patch.label }, meta: meta() } + if (patch.label !== undefined) next.data = { ...(next.data as DimNodeData), label: { markdown: patch.label } } this.store.batch(() => this.store.updateNode(nid, next)) } @@ -456,7 +454,7 @@ export class StoreMutator implements BoardMutator { data: { label: spec.label || undefined, parentId: this.rootId ?? undefined, - meta: meta(), + meta: freshMeta(), _storedColors: storedColors, } satisfies DimEdgeData, }) diff --git a/webui/src/features/board/harness/node-types/sheet/view.tsx b/webui/src/features/board/harness/node-types/sheet/view.tsx index 8f6b4ce7..0e8242ae 100644 --- a/webui/src/features/board/harness/node-types/sheet/view.tsx +++ b/webui/src/features/board/harness/node-types/sheet/view.tsx @@ -8,6 +8,7 @@ import { IconPropertyView } from "@/components/icons/icon-property-view" import { useTheme } from "@/components/theme-provider" import { createBoardPageProvider } from "@/features/board/providers/board-page-provider" import { findFamilyShadeFromHex, toBaseHex } from "@/features/board/lib/colors/tailwind" +import { nodeStamp } from "@/features/board/utils/node-meta" import { cn } from "@/lib/utils" import type { NoteNodeData } from "../../convert/note-to-node" import { computeNodeColorUpdate } from "../../theme/apply-node-colors" @@ -138,9 +139,10 @@ export function SheetView({ id }: SheetViewProps) { const label = data.label?.markdown const body = node.content?.trim() ?? "" const iconValue = data.properties?.iconData?.icon ?? null - // Last-modified, falling back to created. Prefix tells which one it is. - const stampIso = data.updatedAt ?? data.createdAt - const stampPrefix = data.updatedAt ? "Edited" : "Created" + // Last-modified, falling back to created. Reads canonical `meta` (agent notes) + // or legacy strings; `edited` picks the prefix. + const { iso: stampIso, edited } = nodeStamp(data) + const stampPrefix = edited ? "Edited" : "Created" const enterEdit = () => { if (canEdit) setEditing(true) diff --git a/webui/src/features/board/harness/views/list-view.tsx b/webui/src/features/board/harness/views/list-view.tsx index 45fc8feb..6d1f4b5b 100644 --- a/webui/src/features/board/harness/views/list-view.tsx +++ b/webui/src/features/board/harness/views/list-view.tsx @@ -14,6 +14,7 @@ import { } from "@/components/icons" import { IconPropertyView } from "@/components/icons/icon-property-view" import { formatDistanceToNow } from "@/features/board/utils/date" +import { nodeStamp } from "@/features/board/utils/node-meta" import type { IconProperty } from "@/features/newsfeed/types/properties" import { useDocumentLikeNodes } from "../canvas/use-document-like-nodes" import type { NoteNodeData } from "../convert/note-to-node" @@ -83,8 +84,10 @@ const ListRow = memo(function ListRow({ node, index, isLast }: RowProps) { const meta = metaOf(node) const Icon = meta.icon const data = node.data as Partial | undefined + // Canonical `meta` (agent notes) or legacy top-level strings — same stamp the + // sheet card shows. const { text: timeAgo, tooltip: fullDate } = formatDistanceToNow( - data?.updatedAt ?? data?.createdAt, + nodeStamp(data).iso, ) const handleOpen = useCallback(() => { diff --git a/webui/src/features/board/utils/node-meta.test.ts b/webui/src/features/board/utils/node-meta.test.ts new file mode 100644 index 00000000..618c87c9 --- /dev/null +++ b/webui/src/features/board/utils/node-meta.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest" +import { bumpMeta, freshMeta, nodeStamp } from "./node-meta" + + +describe("freshMeta", () => { + it("stamps createdAt == updatedAt at the given instant, v:1", () => { + const m = freshMeta(1000) + expect(m).toEqual({ v: 1, createdAt: 1000, updatedAt: 1000 }) + }) +}) + + +describe("bumpMeta", () => { + it("preserves createdAt, advances updatedAt, increments the version", () => { + const prev = freshMeta(1000) + const next = bumpMeta(prev, 2000) + expect(next).toEqual({ v: 2, createdAt: 1000, updatedAt: 2000 }) + }) + + it("treats a missing prev as a fresh stamp (v:1, createdAt == now)", () => { + expect(bumpMeta(undefined, 2000)).toEqual({ v: 1, createdAt: 2000, updatedAt: 2000 }) + }) +}) + + +describe("nodeStamp", () => { + it("reads canonical meta (numbers) and flags an edited node", () => { + const { iso, edited } = nodeStamp({ meta: { createdAt: 1000, updatedAt: 2000 } }) + expect(iso).toBe(new Date(2000).toISOString()) // last-touched = updatedAt + expect(edited).toBe(true) + }) + + it("is not 'edited' when createdAt == updatedAt", () => { + expect(nodeStamp({ meta: { createdAt: 1000, updatedAt: 1000 } }).edited).toBe(false) + }) + + it("falls back to legacy top-level ISO strings when meta is absent", () => { + const created = new Date(1000).toISOString() + const updated = new Date(2000).toISOString() + const { iso, edited } = nodeStamp({ createdAt: created, updatedAt: updated }) + expect(iso).toBe(updated) + expect(edited).toBe(true) + }) + + it("prefers meta over the legacy strings when both are present", () => { + const { iso } = nodeStamp({ + createdAt: new Date(5000).toISOString(), + meta: { createdAt: 1000, updatedAt: 1000 }, + }) + expect(iso).toBe(new Date(1000).toISOString()) + }) + + it("returns iso:null and edited:false when nothing is known", () => { + expect(nodeStamp(undefined)).toEqual({ iso: null, edited: false }) + expect(nodeStamp({})).toEqual({ iso: null, edited: false }) + }) + + it("ignores an unparseable legacy string", () => { + expect(nodeStamp({ createdAt: "not-a-date" })).toEqual({ iso: null, edited: false }) + }) + + it("uses createdAt when only createdAt is present (created, not edited)", () => { + const { iso, edited } = nodeStamp({ meta: { createdAt: 1000 } }) + expect(iso).toBe(new Date(1000).toISOString()) + expect(edited).toBe(false) + }) +}) diff --git a/webui/src/features/board/utils/node-meta.ts b/webui/src/features/board/utils/node-meta.ts new file mode 100644 index 00000000..921fb0fc --- /dev/null +++ b/webui/src/features/board/utils/node-meta.ts @@ -0,0 +1,57 @@ +/** + * Node freshness metadata helpers — one place for creating/advancing a node's + * `SyncMeta` and for deriving its display "Created/Edited" stamp. + * + * There are two historical representations of a node's timestamps: + * - canonical `data.meta` (`SyncMeta`, ms numbers) — what the agent/mutator write; + * - legacy top-level `data.createdAt`/`updatedAt` (ISO strings) — older local + * boards + the REST convert layer. + * `nodeStamp` reads whichever is present (meta first), so the display works for + * both, and agent-created notes (which only carry `meta`) finally show a stamp. + */ +import type { SyncMeta } from "@/features/board/model" + + +/** Fresh meta for a newly created entity — createdAt = updatedAt = now. */ +export const freshMeta = (now = Date.now()): SyncMeta => ({ v: 1, createdAt: now, updatedAt: now }) + + +/** + * Meta for an EDIT: preserve the original `createdAt`, advance `updatedAt`, bump + * the version. Unlike a fresh stamp, this lets the display distinguish an edited + * note ("Edited …") from a newly created one ("Created …"). + */ +export const bumpMeta = (prev: SyncMeta | undefined, now = Date.now()): SyncMeta => ({ + v: (prev?.v ?? 0) + 1, + createdAt: prev?.createdAt ?? now, + updatedAt: now, +}) + + +type StampData = { + createdAt?: string // legacy display strings (NoteNodeData / converted Note) + updatedAt?: string + meta?: { createdAt?: number; updatedAt?: number } // canonical (DimNodeData) +} + + +const toMs = (s?: string): number | undefined => { + if (!s) return undefined + const t = Date.parse(s) + return Number.isFinite(t) ? t : undefined +} + + +/** + * The display freshness stamp for a node, from the canonical `meta` (numbers) or + * the legacy top-level strings. `iso` is the last-touched time (updated, else + * created) as an ISO string, or null when unknown; `edited` is true only when the + * note has been updated after creation. + */ +export const nodeStamp = (data: StampData | undefined): { iso: string | null; edited: boolean } => { + const created = data?.meta?.createdAt ?? toMs(data?.createdAt) + const updated = data?.meta?.updatedAt ?? toMs(data?.updatedAt) + const ms = updated ?? created + const edited = created != null && updated != null && updated > created + return { iso: ms != null ? new Date(ms).toISOString() : null, edited } +} From 87d11435c984c3b76cc85a968950d54f6e4f0b40 Mon Sep 17 00:00:00 2001 From: Le Ha Quang Date: Wed, 26 Aug 2026 15:00:28 +0200 Subject: [PATCH 2/2] fix(board): make patchNote a no-op on an empty patch update_note with neither content nor label was flipping the note to 'Edited' and pushing a needless store/sync op. Skip the write when there is nothing to change. --- webui/src/features/agent/engine/board-mutator.test.ts | 11 +++++++++++ webui/src/features/agent/engine/board-mutator.ts | 5 ++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/webui/src/features/agent/engine/board-mutator.test.ts b/webui/src/features/agent/engine/board-mutator.test.ts index 87e0e04f..4a749afb 100644 --- a/webui/src/features/agent/engine/board-mutator.test.ts +++ b/webui/src/features/agent/engine/board-mutator.test.ts @@ -239,6 +239,17 @@ describe("StoreMutator", () => { expect(after?.v).toBe((before?.v ?? 0) + 1) }) + it("patchNote with an empty patch is a no-op (no spurious 'Edited' bump)", async () => { + const store = freshStore("b") + const m = new StoreMutator(store, null) + const { id } = await m.createNote({ content: "keep" }) + const before = nodeMeta(store, id) + await m.patchNote(id, {}) // neither content nor label + const after = nodeMeta(store, id) + expect(after?.v).toBe(before?.v) // unchanged → still reads as 'Created' + expect(after?.updatedAt).toBe(before?.updatedAt) + }) + it("createLink attaches an edge at node centers with the parent layer", async () => { const store = freshStore("b") const m = new StoreMutator(store, "folder-1") diff --git a/webui/src/features/agent/engine/board-mutator.ts b/webui/src/features/agent/engine/board-mutator.ts index 7d39dc2d..e9adf4b6 100644 --- a/webui/src/features/agent/engine/board-mutator.ts +++ b/webui/src/features/agent/engine/board-mutator.ts @@ -423,8 +423,11 @@ export class StoreMutator implements BoardMutator { const nid = asNodeId(id) const node = this.store.getNode(nid) if (!node) return + // Nothing to change → no-op. Avoids a spurious "Edited" stamp and a needless + // store/sync op when update_note is called with neither content nor label. + if (patch.content === undefined && patch.label === undefined) return const prev = node.data as DimNodeData | undefined - // Any edit advances the freshness stamp (preserving createdAt), so the note + // A real edit advances the freshness stamp (preserving createdAt), so the note // reads as "Edited" — whether the caller changed the content, the label, or both. const next: Partial = { data: { ...prev, meta: bumpMeta(prev?.meta) } as DimNodeData } if (patch.content !== undefined) next.content = patch.content