Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions webui/src/features/agent/engine/board-mutator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -204,6 +206,50 @@ 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("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")
Expand Down
21 changes: 11 additions & 10 deletions webui/src/features/agent/engine/board-mutator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"


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


/**
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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
Expand All @@ -427,10 +423,15 @@ 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
const next: Partial<Node> = {}
// 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<Node> = { 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))
}

Expand All @@ -456,7 +457,7 @@ export class StoreMutator implements BoardMutator {
data: {
label: spec.label || undefined,
parentId: this.rootId ?? undefined,
meta: meta(),
meta: freshMeta(),
_storedColors: storedColors,
} satisfies DimEdgeData,
})
Expand Down
8 changes: 5 additions & 3 deletions webui/src/features/board/harness/node-types/sheet/view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion webui/src/features/board/harness/views/list-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<NoteNodeData> | 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(() => {
Expand Down
67 changes: 67 additions & 0 deletions webui/src/features/board/utils/node-meta.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
57 changes: 57 additions & 0 deletions webui/src/features/board/utils/node-meta.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading