pmx-canvas 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/Readme.md +108 -1058
  3. package/dist/canvas/global.css +141 -0
  4. package/dist/canvas/index.js +124 -74
  5. package/dist/json-render/index.css +1 -1
  6. package/dist/types/client/nodes/ContextNode.d.ts +11 -2
  7. package/dist/types/client/nodes/HtmlNode.d.ts +5 -0
  8. package/dist/types/client/nodes/StatusNode.d.ts +1 -0
  9. package/dist/types/client/state/canvas-store.d.ts +11 -3
  10. package/dist/types/client/state/intent-bridge.d.ts +5 -1
  11. package/dist/types/client/types.d.ts +2 -2
  12. package/dist/types/json-render/catalog.d.ts +1 -1
  13. package/dist/types/mcp/canvas-access.d.ts +7 -1
  14. package/dist/types/server/agent-context.d.ts +1 -0
  15. package/dist/types/server/canvas-operations.d.ts +4 -2
  16. package/dist/types/server/canvas-provenance.d.ts +1 -1
  17. package/dist/types/server/canvas-serialization.d.ts +3 -0
  18. package/dist/types/server/canvas-state.d.ts +51 -4
  19. package/dist/types/server/demo.d.ts +5 -0
  20. package/dist/types/server/index.d.ts +13 -3
  21. package/dist/types/server/web-artifacts.d.ts +18 -0
  22. package/dist/types/shared/canvas-node-kind.d.ts +5 -0
  23. package/package.json +1 -1
  24. package/skills/pmx-canvas/SKILL.md +43 -0
  25. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  26. package/src/cli/agent.ts +52 -5
  27. package/src/cli/index.ts +2 -23
  28. package/src/client/canvas/AttentionHistory.tsx +14 -1
  29. package/src/client/canvas/CanvasNode.tsx +1 -1
  30. package/src/client/canvas/CanvasViewport.tsx +3 -0
  31. package/src/client/canvas/ContextPinBar.tsx +2 -1
  32. package/src/client/canvas/DockedNode.tsx +112 -13
  33. package/src/client/canvas/ExpandedNodeOverlay.tsx +5 -0
  34. package/src/client/canvas/Minimap.tsx +1 -0
  35. package/src/client/icons.tsx +1 -0
  36. package/src/client/nodes/ContextNode.tsx +128 -6
  37. package/src/client/nodes/HtmlNode.tsx +151 -0
  38. package/src/client/nodes/StatusNode.tsx +16 -1
  39. package/src/client/nodes/StatusSummary.tsx +2 -1
  40. package/src/client/state/canvas-store.ts +37 -7
  41. package/src/client/state/intent-bridge.ts +9 -4
  42. package/src/client/state/sse-bridge.ts +2 -1
  43. package/src/client/theme/global.css +141 -0
  44. package/src/client/types.ts +3 -0
  45. package/src/mcp/canvas-access.ts +34 -7
  46. package/src/mcp/server.ts +178 -25
  47. package/src/server/agent-context.ts +50 -3
  48. package/src/server/canvas-operations.ts +20 -3
  49. package/src/server/canvas-provenance.ts +2 -1
  50. package/src/server/canvas-serialization.ts +38 -13
  51. package/src/server/canvas-state.ts +305 -34
  52. package/src/server/demo.ts +792 -0
  53. package/src/server/index.ts +33 -3
  54. package/src/server/server.ts +98 -14
  55. package/src/server/web-artifacts.ts +116 -3
  56. package/src/shared/canvas-node-kind.ts +14 -0
@@ -9,8 +9,10 @@
9
9
  * workspace root on every mutation (debounced). Auto-loads on `loadFromDisk()`.
10
10
  */
11
11
 
12
+ import { createHash } from 'node:crypto';
12
13
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, renameSync, unlinkSync } from 'node:fs';
13
- import { join, dirname } from 'node:path';
14
+ import { isAbsolute, join, dirname, relative } from 'node:path';
15
+ import { gzipSync, gunzipSync } from 'node:zlib';
14
16
  import { normalizeCanvasNodeData } from './canvas-provenance.js';
15
17
  import {
16
18
  type CanvasPlacementRect,
@@ -25,12 +27,37 @@ function logCanvasStateWarning(action: string, error: unknown, details?: Record<
25
27
  console.warn(`[canvas-state] ${action}`, { error, ...(details ?? {}) });
26
28
  }
27
29
 
30
+ function normalizePositiveInteger(value: number | undefined): number | undefined {
31
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined;
32
+ return Math.floor(value);
33
+ }
34
+
28
35
  export const PMX_CANVAS_DIR = '.pmx-canvas';
29
36
  const STATE_FILENAME = 'state.json';
30
37
  const SNAPSHOTS_SUBDIR = 'snapshots';
38
+ const BLOBS_SUBDIR = 'blobs';
31
39
  const LEGACY_STATE_FILENAME = '.pmx-canvas.json';
32
40
  const LEGACY_SNAPSHOTS_DIR = '.pmx-canvas-snapshots';
33
41
  const SAVE_DEBOUNCE_MS = 500;
42
+ const BLOB_JSON_THRESHOLD_BYTES = Number(process.env.PMX_CANVAS_BLOB_THRESHOLD_BYTES ?? '2048');
43
+ const BLOB_DATA_FIELDS = new Set([
44
+ 'html',
45
+ 'toolInput',
46
+ 'toolResult',
47
+ 'toolDefinition',
48
+ 'resourceMeta',
49
+ 'appModelContext',
50
+ 'appCheckpoint',
51
+ ]);
52
+
53
+ export interface PersistedBlobRef {
54
+ __pmxCanvasBlob: 'v1';
55
+ path: string;
56
+ sha256: string;
57
+ encoding: 'json+gzip';
58
+ bytes: number;
59
+ jsonBytes: number;
60
+ }
34
61
 
35
62
  interface PersistedCanvasState {
36
63
  version: number;
@@ -58,6 +85,24 @@ export interface CanvasSnapshot {
58
85
  edgeCount: number;
59
86
  }
60
87
 
88
+ export interface CanvasSnapshotListOptions {
89
+ limit?: number;
90
+ query?: string;
91
+ all?: boolean;
92
+ }
93
+
94
+ export interface CanvasSnapshotGcOptions {
95
+ keep?: number;
96
+ dryRun?: boolean;
97
+ }
98
+
99
+ export interface CanvasSnapshotGcResult {
100
+ ok: boolean;
101
+ kept: number;
102
+ deleted: CanvasSnapshot[];
103
+ dryRun: boolean;
104
+ }
105
+
61
106
  export interface CanvasNodeState {
62
107
  id: string;
63
108
  type:
@@ -74,6 +119,7 @@ export interface CanvasNodeState {
74
119
  | 'trace'
75
120
  | 'file'
76
121
  | 'image'
122
+ | 'html'
77
123
  | 'group';
78
124
  position: { x: number; y: number };
79
125
  size: { width: number; height: number };
@@ -152,6 +198,20 @@ function formatBatchUpdateDescription(updates: CanvasNodeUpdate[]): string {
152
198
  return parts.length > 0 ? `${prefix} (${parts.join(', ')})` : prefix;
153
199
  }
154
200
 
201
+ function isRecord(value: unknown): value is Record<string, unknown> {
202
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
203
+ }
204
+
205
+ function isPersistedBlobRef(value: unknown): value is PersistedBlobRef {
206
+ return isRecord(value) &&
207
+ value.__pmxCanvasBlob === 'v1' &&
208
+ typeof value.path === 'string' &&
209
+ typeof value.sha256 === 'string' &&
210
+ value.encoding === 'json+gzip' &&
211
+ typeof value.bytes === 'number' &&
212
+ typeof value.jsonBytes === 'number';
213
+ }
214
+
155
215
  class CanvasStateManager {
156
216
  private nodes = new Map<string, CanvasNodeState>();
157
217
  private edges = new Map<string, CanvasEdge>();
@@ -179,7 +239,7 @@ class CanvasStateManager {
179
239
 
180
240
  // ── Mutation recorder (for undo/redo history) ─────────────
181
241
  private _mutationRecorder: ((info: MutationRecordInfo) => void) | null = null;
182
- private _suppressRecording = false;
242
+ private _suppressRecordingDepth = 0;
183
243
 
184
244
  /** Register a mutation recorder. Used by mutation-history to capture undo/redo closures. */
185
245
  onMutation(cb: (info: MutationRecordInfo) => void): void {
@@ -188,8 +248,8 @@ class CanvasStateManager {
188
248
 
189
249
  /** Run a function with mutation recording suppressed (for undo/redo replay and computed edges). */
190
250
  withSuppressedRecording(fn: () => void): void {
191
- this._suppressRecording = true;
192
- try { fn(); } finally { this._suppressRecording = false; }
251
+ this._suppressRecordingDepth++;
252
+ try { fn(); } finally { this._suppressRecordingDepth--; }
193
253
  }
194
254
 
195
255
  /** Create a closure that runs with recording suppressed. */
@@ -198,7 +258,7 @@ class CanvasStateManager {
198
258
  }
199
259
 
200
260
  private recordMutation(info: MutationRecordInfo): void {
201
- if (this._suppressRecording || !this._mutationRecorder) return;
261
+ if (this._suppressRecordingDepth > 0 || !this._mutationRecorder) return;
202
262
  try {
203
263
  this._mutationRecorder(info);
204
264
  } catch (error) {
@@ -258,10 +318,18 @@ class CanvasStateManager {
258
318
  }
259
319
 
260
320
  private normalizeNode(node: CanvasNodeState): CanvasNodeState {
261
- return {
321
+ const normalized: CanvasNodeState = {
262
322
  ...node,
263
323
  data: normalizeCanvasNodeData(node.type, node.data),
264
324
  };
325
+ // Context nodes are always docked to the right side as a pill/panel widget
326
+ // (see DockedNode.tsx). They start collapsed so the user sees the slim
327
+ // pill first; expanding reveals the full context overview panel.
328
+ if (normalized.type === 'context' && normalized.dockPosition !== 'right') {
329
+ normalized.dockPosition = 'right';
330
+ normalized.collapsed = true;
331
+ }
332
+ return normalized;
265
333
  }
266
334
 
267
335
  private reflowAllGroups(): void {
@@ -285,12 +353,13 @@ class CanvasStateManager {
285
353
  }
286
354
  }
287
355
 
288
- private translateGroupChildren(groupId: string, deltaX: number, deltaY: number): void {
356
+ private translateGroupChildren(groupId: string, deltaX: number, deltaY: number, skipIds: ReadonlySet<string> = new Set()): void {
289
357
  if (deltaX === 0 && deltaY === 0) return;
290
358
  const snapshot = this.getGroupSnapshot(groupId);
291
359
  if (!snapshot) return;
292
360
 
293
361
  for (const child of snapshot.children) {
362
+ if (skipIds.has(child.id)) continue;
294
363
  this.nodes.set(child.id, {
295
364
  ...child,
296
365
  position: {
@@ -390,6 +459,116 @@ class CanvasStateManager {
390
459
  this._stateFilePath = override || join(workspaceRoot, PMX_CANVAS_DIR, STATE_FILENAME);
391
460
  }
392
461
 
462
+ private get blobsDir(): string | null {
463
+ if (!this._workspaceRoot) return null;
464
+ return join(this._workspaceRoot, PMX_CANVAS_DIR, BLOBS_SUBDIR);
465
+ }
466
+
467
+ private relativeBlobPath(filePath: string): string {
468
+ const base = join(this._workspaceRoot, PMX_CANVAS_DIR);
469
+ const rel = relative(base, filePath);
470
+ return rel || filePath;
471
+ }
472
+
473
+ private resolveBlobPath(ref: PersistedBlobRef): string | null {
474
+ if (isAbsolute(ref.path)) return null;
475
+ const base = join(this._workspaceRoot, PMX_CANVAS_DIR);
476
+ const resolved = join(base, ref.path);
477
+ const rel = relative(base, resolved);
478
+ if (rel === '' || rel.startsWith('..') || rel === '..' || isAbsolute(rel)) return null;
479
+ return resolved;
480
+ }
481
+
482
+ private writeBlobValue(value: unknown): PersistedBlobRef | null {
483
+ const dir = this.blobsDir;
484
+ if (!dir) return null;
485
+ const json = JSON.stringify(value);
486
+ if (typeof json !== 'string') return null;
487
+ const jsonBytes = Buffer.byteLength(json);
488
+ if (jsonBytes < BLOB_JSON_THRESHOLD_BYTES) return null;
489
+ const sha256 = createHash('sha256').update(json).digest('hex');
490
+ const prefix = sha256.slice(0, 2);
491
+ const filePath = join(dir, prefix, `${sha256}.json.gz`);
492
+ try {
493
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
494
+ if (!existsSync(dirname(filePath))) mkdirSync(dirname(filePath), { recursive: true });
495
+ const compressed = gzipSync(json);
496
+ if (!existsSync(filePath)) writeFileSync(filePath, compressed);
497
+ return {
498
+ __pmxCanvasBlob: 'v1',
499
+ path: this.relativeBlobPath(filePath),
500
+ sha256,
501
+ encoding: 'json+gzip',
502
+ bytes: compressed.byteLength,
503
+ jsonBytes,
504
+ };
505
+ } catch (error) {
506
+ logCanvasStateWarning('write blob failed', error, { filePath });
507
+ return null;
508
+ }
509
+ }
510
+
511
+ private readBlobValue(ref: PersistedBlobRef): unknown {
512
+ const filePath = this.resolveBlobPath(ref);
513
+ if (!filePath) return ref;
514
+ try {
515
+ const compressed = readFileSync(filePath);
516
+ const json = gunzipSync(compressed).toString('utf-8');
517
+ const sha256 = createHash('sha256').update(json).digest('hex');
518
+ if (sha256 !== ref.sha256) {
519
+ logCanvasStateWarning('blob checksum mismatch', 'checksum mismatch', { filePath });
520
+ return ref;
521
+ }
522
+ return JSON.parse(json) as unknown;
523
+ } catch (error) {
524
+ logCanvasStateWarning('read blob failed', error, { filePath });
525
+ return ref;
526
+ }
527
+ }
528
+
529
+ private externalizeNodeDataBlobs(node: CanvasNodeState): CanvasNodeState {
530
+ if (node.type !== 'mcp-app') return node;
531
+ let changed = false;
532
+ const data = { ...node.data };
533
+ for (const [key, value] of Object.entries(data)) {
534
+ if (!BLOB_DATA_FIELDS.has(key) || isPersistedBlobRef(value)) continue;
535
+ const ref = this.writeBlobValue(value);
536
+ if (!ref) continue;
537
+ data[key] = ref;
538
+ changed = true;
539
+ }
540
+ return changed ? { ...node, data } : node;
541
+ }
542
+
543
+ private resolveNodeDataBlobs(node: CanvasNodeState): CanvasNodeState {
544
+ if (node.type !== 'mcp-app') return node;
545
+ let changed = false;
546
+ const data = { ...node.data };
547
+ for (const [key, value] of Object.entries(data)) {
548
+ if (!BLOB_DATA_FIELDS.has(key) || !isPersistedBlobRef(value)) continue;
549
+ data[key] = this.readBlobValue(value);
550
+ changed = true;
551
+ }
552
+ return changed ? { ...node, data } : node;
553
+ }
554
+
555
+ isBlobReference(value: unknown): value is PersistedBlobRef {
556
+ return isPersistedBlobRef(value);
557
+ }
558
+
559
+ resolveBlobReference(value: unknown): unknown {
560
+ return isPersistedBlobRef(value) ? this.readBlobValue(value) : value;
561
+ }
562
+
563
+ private externalizePersistedStateBlobs<T extends PersistedCanvasState>(state: T): T {
564
+ return {
565
+ ...state,
566
+ nodes: Array.isArray(state.nodes)
567
+ ? state.nodes.map((node) => this.externalizeNodeDataBlobs(node))
568
+ : [],
569
+ };
570
+ }
571
+
393
572
  /**
394
573
  * One-time migration: rename files from the pre-consolidation layout
395
574
  * (`.pmx-canvas.json` + `.pmx-canvas-snapshots/`) into `.pmx-canvas/`.
@@ -479,13 +658,13 @@ class CanvasStateManager {
479
658
  const dir = dirname(this._stateFilePath);
480
659
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
481
660
 
482
- const payload: PersistedCanvasState = {
661
+ const payload = this.externalizePersistedStateBlobs({
483
662
  version: 1,
484
663
  viewport: this._viewport,
485
664
  nodes: Array.from(this.nodes.values()),
486
665
  edges: Array.from(this.edges.values()),
487
666
  contextPins: Array.from(this._contextPinnedNodeIds),
488
- };
667
+ });
489
668
  writeFileSync(this._stateFilePath, JSON.stringify(payload, null, 2), 'utf-8');
490
669
  } catch (error) {
491
670
  logCanvasStateWarning('save state to disk failed', error, {
@@ -552,15 +731,16 @@ class CanvasStateManager {
552
731
  }
553
732
 
554
733
  try {
555
- const matches: Array<{ snapshot: CanvasSnapshot; state: PersistedCanvasState }> = [];
734
+ const matches: Array<{ snapshot: CanvasSnapshot; path: string }> = [];
556
735
  const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
557
736
  for (const file of files) {
558
737
  try {
559
- const raw = readFileSync(join(dir, file), 'utf-8');
738
+ const snapshotPath = join(dir, file);
739
+ const raw = readFileSync(snapshotPath, 'utf-8');
560
740
  const parsed = JSON.parse(raw) as PersistedCanvasState & { snapshot?: CanvasSnapshot };
561
741
  if (!parsed.snapshot) continue;
562
742
  if (parsed.snapshot.name === idOrName || parsed.snapshot.id === idOrName) {
563
- matches.push({ snapshot: parsed.snapshot, state: parsed });
743
+ matches.push({ snapshot: parsed.snapshot, path: snapshotPath });
564
744
  }
565
745
  } catch (error) {
566
746
  logCanvasStateWarning('skip unreadable snapshot while searching by name', error, {
@@ -570,13 +750,31 @@ class CanvasStateManager {
570
750
  }
571
751
  }
572
752
  matches.sort((a, b) => b.snapshot.createdAt.localeCompare(a.snapshot.createdAt));
573
- return matches[0] ?? null;
753
+ const match = matches[0];
754
+ if (!match) return null;
755
+ try {
756
+ const raw = readFileSync(match.path, 'utf-8');
757
+ const parsed = JSON.parse(raw) as PersistedCanvasState & { snapshot?: CanvasSnapshot };
758
+ if (parsed.snapshot) return { snapshot: parsed.snapshot, state: parsed };
759
+ } catch (error) {
760
+ logCanvasStateWarning('read matched snapshot by name failed', error, { idOrName, path: match.path });
761
+ }
762
+ return null;
574
763
  } catch (error) {
575
764
  logCanvasStateWarning('search snapshots by name failed', error, { idOrName, dir });
576
765
  return null;
577
766
  }
578
767
  }
579
768
 
769
+ getSnapshotDataForPersistence(idOrName: string): { snapshot: CanvasSnapshot; state: PersistedCanvasState } | null {
770
+ const resolved = this.readResolvedSnapshot(idOrName);
771
+ if (!resolved) return null;
772
+ return {
773
+ snapshot: resolved.snapshot,
774
+ state: structuredClone(resolved.state),
775
+ };
776
+ }
777
+
580
778
  /** Save current canvas state as a named snapshot. */
581
779
  saveSnapshot(name: string): CanvasSnapshot | null {
582
780
  const dir = this.snapshotsDir;
@@ -594,14 +792,14 @@ class CanvasStateManager {
594
792
  try {
595
793
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
596
794
 
597
- const payload: PersistedCanvasState & { snapshot: CanvasSnapshot } = {
795
+ const payload = this.externalizePersistedStateBlobs({
598
796
  version: 1,
599
797
  snapshot,
600
798
  viewport: this._viewport,
601
799
  nodes: Array.from(this.nodes.values()),
602
800
  edges: Array.from(this.edges.values()),
603
801
  contextPins: Array.from(this._contextPinnedNodeIds),
604
- };
802
+ });
605
803
  writeFileSync(join(dir, `${id}.json`), JSON.stringify(payload, null, 2), 'utf-8');
606
804
  return snapshot;
607
805
  } catch (error) {
@@ -610,8 +808,8 @@ class CanvasStateManager {
610
808
  }
611
809
  }
612
810
 
613
- /** List all saved snapshots. */
614
- listSnapshots(): CanvasSnapshot[] {
811
+ /** List saved snapshots, newest first. */
812
+ listSnapshots(options: CanvasSnapshotListOptions = {}): CanvasSnapshot[] {
615
813
  const dir = this.snapshotsDir;
616
814
  if (!dir || !existsSync(dir)) return [];
617
815
 
@@ -627,25 +825,53 @@ class CanvasStateManager {
627
825
  logCanvasStateWarning('skip corrupt snapshot file', error, { file });
628
826
  }
629
827
  }
630
- return snapshots.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
828
+ const query = options.query?.trim().toLowerCase();
829
+ const filtered = query
830
+ ? snapshots.filter((snapshot) =>
831
+ snapshot.id.toLowerCase().includes(query) || snapshot.name.toLowerCase().includes(query),
832
+ )
833
+ : snapshots;
834
+ const sorted = filtered.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
835
+ const limit = options.all ? undefined : (normalizePositiveInteger(options.limit) ?? 20);
836
+ return limit === undefined ? sorted : sorted.slice(0, limit);
631
837
  } catch (error) {
632
838
  logCanvasStateWarning('list snapshots failed', error, { dir });
633
839
  return [];
634
840
  }
635
841
  }
636
842
 
843
+ gcSnapshots(options: CanvasSnapshotGcOptions = {}): CanvasSnapshotGcResult {
844
+ const keep = normalizePositiveInteger(options.keep) ?? 20;
845
+ const dryRun = options.dryRun ?? false;
846
+ const snapshots = this.listSnapshots({ all: true });
847
+ const deleted = snapshots.slice(keep);
848
+
849
+ if (!dryRun) {
850
+ for (const snapshot of deleted) {
851
+ this.deleteSnapshot(snapshot.id);
852
+ }
853
+ }
854
+
855
+ return {
856
+ ok: true,
857
+ kept: Math.min(keep, snapshots.length),
858
+ deleted,
859
+ dryRun,
860
+ };
861
+ }
862
+
637
863
  /** Restore canvas state from a snapshot. */
638
864
  restoreSnapshot(idOrName: string): boolean {
639
865
  const resolved = this.readResolvedSnapshot(idOrName);
640
866
  if (!resolved || resolved.state.version !== 1) return false;
641
867
 
642
- const previousState: PersistedCanvasState = {
868
+ const previousState: PersistedCanvasState = this.externalizePersistedStateBlobs({
643
869
  version: 1,
644
870
  viewport: structuredClone(this._viewport),
645
871
  nodes: Array.from(this.nodes.values(), (node) => structuredClone(node)),
646
872
  edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
647
873
  contextPins: Array.from(this._contextPinnedNodeIds),
648
- };
874
+ });
649
875
  const nextState: PersistedCanvasState = {
650
876
  version: 1,
651
877
  viewport: structuredClone(resolved.state.viewport),
@@ -690,10 +916,16 @@ class CanvasStateManager {
690
916
  getSnapshotData(idOrName: string): { name: string; nodes: CanvasNodeState[]; edges: CanvasEdge[] } | null {
691
917
  const resolved = this.readResolvedSnapshot(idOrName);
692
918
  if (!resolved) return null;
919
+ const state = {
920
+ ...resolved.state,
921
+ nodes: Array.isArray(resolved.state.nodes)
922
+ ? resolved.state.nodes.map((node) => this.resolveNodeDataBlobs(node))
923
+ : [],
924
+ };
693
925
  return {
694
926
  name: resolved.snapshot.name,
695
- nodes: Array.isArray(resolved.state.nodes) ? resolved.state.nodes.map((node) => structuredClone(node)) : [],
696
- edges: Array.isArray(resolved.state.edges) ? resolved.state.edges.map((edge) => structuredClone(edge)) : [],
927
+ nodes: Array.isArray(state.nodes) ? state.nodes.map((node) => structuredClone(node)) : [],
928
+ edges: Array.isArray(state.edges) ? state.edges.map((edge) => structuredClone(edge)) : [],
697
929
  };
698
930
  }
699
931
 
@@ -821,7 +1053,12 @@ class CanvasStateManager {
821
1053
 
822
1054
  getNode(id: string): CanvasNodeState | undefined {
823
1055
  const node = this.nodes.get(id);
824
- return node ? structuredClone(node) : undefined;
1056
+ return node ? structuredClone(this.resolveNodeDataBlobs(node)) : undefined;
1057
+ }
1058
+
1059
+ getNodeForPersistence(id: string): CanvasNodeState | undefined {
1060
+ const node = this.nodes.get(id);
1061
+ return node ? structuredClone(this.externalizeNodeDataBlobs(node)) : undefined;
825
1062
  }
826
1063
 
827
1064
  // ── Edge CRUD ──────────────────────────────────────────────
@@ -889,12 +1126,25 @@ class CanvasStateManager {
889
1126
  };
890
1127
  }
891
1128
 
1129
+ getLayoutForPersistence(): CanvasLayout {
1130
+ return {
1131
+ viewport: structuredClone(this._viewport),
1132
+ nodes: Array.from(this.nodes.values(), (node) => structuredClone(this.externalizeNodeDataBlobs(node))),
1133
+ edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
1134
+ };
1135
+ }
1136
+
892
1137
  applyUpdates(updates: CanvasNodeUpdate[]): { applied: number; skipped: number } {
893
1138
  let applied = 0;
894
1139
  let skipped = 0;
895
1140
  const touchedParentGroups = new Map<string, { compact: boolean }>();
896
1141
  const oldSnapshots = new Map<string, CanvasNodeState>();
897
1142
  const appliedUpdates: CanvasNodeUpdate[] = [];
1143
+ const explicitPositionUpdateIds = new Set(
1144
+ updates
1145
+ .filter((update) => update.position !== undefined)
1146
+ .map((update) => update.id),
1147
+ );
898
1148
 
899
1149
  for (const update of updates) {
900
1150
  const existing = this.nodes.get(update.id);
@@ -902,26 +1152,47 @@ class CanvasStateManager {
902
1152
  skipped++;
903
1153
  continue;
904
1154
  }
1155
+ const nextPatch: Partial<CanvasNodeState> = {};
1156
+ if (
1157
+ update.position &&
1158
+ (update.position.x !== existing.position.x || update.position.y !== existing.position.y)
1159
+ ) {
1160
+ nextPatch.position = update.position;
1161
+ }
1162
+ if (
1163
+ update.size &&
1164
+ (update.size.width !== existing.size.width || update.size.height !== existing.size.height)
1165
+ ) {
1166
+ nextPatch.size = update.size;
1167
+ }
1168
+ if (update.collapsed !== undefined && update.collapsed !== existing.collapsed) {
1169
+ nextPatch.collapsed = update.collapsed;
1170
+ }
1171
+ if (update.dockPosition !== undefined && update.dockPosition !== existing.dockPosition) {
1172
+ nextPatch.dockPosition = update.dockPosition;
1173
+ }
1174
+ if (Object.keys(nextPatch).length === 0) {
1175
+ skipped++;
1176
+ continue;
1177
+ }
905
1178
  oldSnapshots.set(update.id, structuredClone(existing));
906
- appliedUpdates.push(structuredClone(update));
907
- if (existing.type === 'group' && update.position) {
1179
+ appliedUpdates.push({ id: update.id, ...structuredClone(nextPatch) });
1180
+ if (existing.type === 'group' && nextPatch.position) {
908
1181
  this.translateGroupChildren(
909
1182
  update.id,
910
- update.position.x - existing.position.x,
911
- update.position.y - existing.position.y,
1183
+ nextPatch.position.x - existing.position.x,
1184
+ nextPatch.position.y - existing.position.y,
1185
+ explicitPositionUpdateIds,
912
1186
  );
913
1187
  }
914
- this.nodes.set(update.id, {
1188
+ this.nodes.set(update.id, this.normalizeNode({
915
1189
  ...existing,
916
- ...(update.position && { position: update.position }),
917
- ...(update.size && { size: update.size }),
918
- ...(update.collapsed !== undefined && { collapsed: update.collapsed }),
919
- ...(update.dockPosition !== undefined && { dockPosition: update.dockPosition }),
920
- });
1190
+ ...nextPatch,
1191
+ }));
921
1192
  const parentGroupId = existing.data.parentGroup as string | undefined;
922
1193
  if (parentGroupId) {
923
1194
  const entry = touchedParentGroups.get(parentGroupId) ?? { compact: false };
924
- entry.compact = entry.compact || update.size !== undefined;
1195
+ entry.compact = entry.compact || nextPatch.size !== undefined;
925
1196
  touchedParentGroups.set(parentGroupId, entry);
926
1197
  }
927
1198
  applied++;