@eventcatalog/core 5.0.0-beta.11 → 5.0.0-beta.13

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 (92) hide show
  1. package/README.md +1 -1
  2. package/dist/analytics/analytics.cjs +1 -1
  3. package/dist/analytics/analytics.js +2 -2
  4. package/dist/analytics/log-build.cjs +1 -1
  5. package/dist/analytics/log-build.js +4 -4
  6. package/dist/catalog-runtime.cjs +1 -1
  7. package/dist/{chunk-MFEMKQZ6.js → chunk-BO2HHVZ3.js} +1 -1
  8. package/dist/{chunk-4YFM63DS.js → chunk-JPT3QC5V.js} +1 -1
  9. package/dist/{chunk-76DAAJLV.js → chunk-PLTLW45Q.js} +5 -5
  10. package/dist/{chunk-WM4QYMIA.js → chunk-T75DOVFP.js} +1 -1
  11. package/dist/{chunk-LQZ7IG7B.js → chunk-XA5W6DXX.js} +1 -1
  12. package/dist/constants.cjs +1 -1
  13. package/dist/constants.js +1 -1
  14. package/dist/eventcatalog.cjs +2 -2
  15. package/dist/eventcatalog.js +14 -14
  16. package/dist/federation/federate.js +3 -3
  17. package/dist/generate.cjs +1 -1
  18. package/dist/generate.js +3 -3
  19. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
  20. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
  21. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
  22. package/dist/runtime-dependencies/manifest.json +3 -0
  23. package/dist/utils/cli-logger.cjs +1 -1
  24. package/dist/utils/cli-logger.js +2 -2
  25. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
  26. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +42 -3
  27. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
  28. package/eventcatalog/src/components/NavigationProgress.astro +7 -0
  29. package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
  30. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +3 -3
  31. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +3 -1
  32. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +7 -5
  33. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +132 -70
  34. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +71 -71
  35. package/eventcatalog/src/features/studio/agent-choreography.ts +13 -10
  36. package/eventcatalog/src/features/studio/agents.ts +28 -0
  37. package/eventcatalog/src/features/studio/api/canvases-api.ts +64 -15
  38. package/eventcatalog/src/features/studio/api/client.ts +6 -1
  39. package/eventcatalog/src/features/studio/canvas-actions.ts +160 -34
  40. package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
  41. package/eventcatalog/src/features/studio/catalog.ts +16 -14
  42. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +263 -115
  43. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +15 -14
  44. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +272 -119
  45. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +1 -2
  46. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +28 -44
  47. package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
  48. package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
  49. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +119 -230
  50. package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
  51. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
  52. package/eventcatalog/src/features/studio/components/Presence.tsx +52 -45
  53. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +277 -141
  54. package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
  55. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1286 -411
  56. package/eventcatalog/src/features/studio/document-name.ts +7 -0
  57. package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
  58. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +19 -2
  59. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +9 -3
  60. package/eventcatalog/src/features/studio/hooks/use-comments.ts +2 -2
  61. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
  62. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +223 -30
  63. package/eventcatalog/src/features/studio/identity.ts +23 -1
  64. package/eventcatalog/src/features/studio/limits.ts +2 -0
  65. package/eventcatalog/src/features/studio/pages/[id].astro +2 -1
  66. package/eventcatalog/src/features/studio/pages/index.astro +2 -2
  67. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +8 -23
  68. package/eventcatalog/src/features/studio/server/runtime.ts +16 -9
  69. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
  70. package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
  71. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
  72. package/package.json +3 -3
  73. package/eventcatalog/src/features/studio/canvas-doc.ts +0 -414
  74. package/eventcatalog/src/features/studio/clipboard.ts +0 -97
  75. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -154
  76. package/eventcatalog/src/features/studio/components/Comments.tsx +0 -770
  77. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +0 -145
  78. package/eventcatalog/src/features/studio/components/DragGhost.tsx +0 -115
  79. package/eventcatalog/src/features/studio/components/Picture.tsx +0 -21
  80. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +0 -212
  81. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +0 -255
  82. package/eventcatalog/src/features/studio/components/icons.ts +0 -53
  83. package/eventcatalog/src/features/studio/components/status.ts +0 -53
  84. package/eventcatalog/src/features/studio/edges.ts +0 -56
  85. package/eventcatalog/src/features/studio/flow-state.ts +0 -92
  86. package/eventcatalog/src/features/studio/grouping.ts +0 -226
  87. package/eventcatalog/src/features/studio/layout.ts +0 -42
  88. package/eventcatalog/src/features/studio/levels.ts +0 -282
  89. package/eventcatalog/src/features/studio/node-types.ts +0 -205
  90. package/eventcatalog/src/features/studio/placement.ts +0 -197
  91. package/eventcatalog/src/features/studio/studio.css +0 -273
  92. package/dist/{chunk-KHACCTW2.js → chunk-ZEY2XUJN.js} +4 -4
@@ -15,8 +15,6 @@ import {
15
15
  import {
16
16
  addNodes,
17
17
  buildNode,
18
- CANVAS_DELETED_REASON,
19
- canvasDocumentName,
20
18
  connectNodes,
21
19
  deleteEdges,
22
20
  deleteNodes as deleteNodesInDoc,
@@ -25,7 +23,9 @@ import {
25
23
  fitGroupToChildren,
26
24
  getCanvasMaps,
27
25
  moveNode,
26
+ placeNode,
28
27
  changesNodesOrEdges,
28
+ getStructureKey,
29
29
  getCanvasStatus,
30
30
  readMeta,
31
31
  reopenIfDecided,
@@ -34,16 +34,41 @@ import {
34
34
  setMeta,
35
35
  type Author,
36
36
  type CanvasStatus,
37
+ type DiagramLevels,
38
+ type LevelPlace,
39
+ readLevelLayout,
40
+ setLevelPlaces,
37
41
  type StatusChange,
38
- setNodeParent,
42
+ setNodeParents,
39
43
  type RenderedSizes,
40
44
  updateNodeData as updateNodeDataInDoc,
41
- } from '../canvas-doc';
45
+ keepDragged,
46
+ LOCAL_EDGE_KEYS,
47
+ LOCAL_NODE_KEYS,
48
+ patchShared,
49
+ withPositions,
50
+ createSmoother,
51
+ containerFor,
52
+ toRelativePosition,
53
+ type NodePreview,
54
+ type LayoutResult,
55
+ canGoInContainer,
56
+ getNodeDefinition,
57
+ isGroupType,
58
+ createHotspot as createHotspotInDoc,
59
+ updateHotspot as updateHotspotInDoc,
60
+ deleteHotspot as deleteHotspotInDoc,
61
+ readHotspots,
62
+ getLock,
63
+ setLock,
64
+ type Hotspot,
65
+ type Editing,
66
+ addThreads,
67
+ addHotspots,
68
+ type ModelCanvasContent,
69
+ } from '@eventcatalog/visualiser/studio';
70
+ import { CANVAS_DELETED_REASON, canvasDocumentName } from '../document-name';
42
71
  import { applyLayout } from '../agent-choreography';
43
- import { keepDragged, LOCAL_EDGE_KEYS, LOCAL_NODE_KEYS, patchShared, withPositions } from '../flow-state';
44
- import { findGroupAtPoint, toRelativePosition, type NodePreview } from '../grouping';
45
- import type { LayoutResult } from '../layout';
46
- import { canGoInContainer, getNodeDefinition, isGroupType } from '../node-types';
47
72
  import { startToolSync, type ToolSync } from '../tool-sync';
48
73
  import { createPresenceSender, type PresenceSender } from './presence-sender';
49
74
  import { createPresenceStore, type PresenceStore } from './presence-store';
@@ -67,6 +92,14 @@ const LOCAL_ORIGIN = Symbol('studio-local');
67
92
  export const BROWSER_AGENT_ORIGIN = Symbol('studio-browser-agent');
68
93
  /** Status changes: synced, but not undone (undo takes back edits, not decisions about the canvas) */
69
94
  const STATUS_ORIGIN = Symbol('studio-status');
95
+ /** A diagram file opened into a new canvas: what it starts with, synced, not undone (nothing to go back to) */
96
+ const OPEN_ORIGIN = Symbol('studio-open');
97
+ /** Where L1 and L2 put what's new on them, kept so it stays there: synced, not undone (nobody did it) */
98
+ const LEVEL_PLACEMENT_ORIGIN = Symbol('studio-level-placement');
99
+
100
+ const NO_LEVEL_LAYOUTS = { 1: new Map(), 2: new Map() } as const;
101
+ /** What someone changing a note or text shows the others as they change it (see `editing` in their presence) */
102
+ const EDITED_FIELDS = ['text', 'fontSize', 'angle'] as const;
70
103
 
71
104
  export type Status = 'connecting' | 'connected' | 'disconnected';
72
105
  export type Transport = 'websocket' | 'tools';
@@ -90,6 +123,7 @@ export function useStudioFlow({
90
123
  name,
91
124
  color,
92
125
  picture,
126
+ agent,
93
127
  syncViaTools,
94
128
  onLayout,
95
129
  onContainerResize,
@@ -101,6 +135,8 @@ export function useStudioFlow({
101
135
  color: string;
102
136
  /** From your sign-in provider, when you're signed in */
103
137
  picture?: string;
138
+ /** You're an AI agent here (e.g. a chat's canvas view, which joins as the chat's agent) */
139
+ agent?: boolean;
104
140
  /** Sync through MCP tool calls when the WebSocket can't connect (e.g. inside a chat's sandbox) */
105
141
  syncViaTools?: ToolSync;
106
142
  /** Someone laid the canvas out (us included): called as the new positions arrive, so they can glide there */
@@ -121,12 +157,25 @@ export function useStudioFlow({
121
157
  const [doc, setDoc] = useState<Y.Doc | null>(null);
122
158
  const [history, setHistory] = useState({ canUndo: false, canRedo: false });
123
159
  const [title, setTitle] = useState<string | undefined>();
160
+ // L1 and L2 of the diagram the canvas was opened from, if it was
161
+ const [diagramLevels, setDiagramLevels] = useState<DiagramLevels | undefined>();
162
+ // What's on the canvas and how it's connected (getStructureKey): worked out when the document's nodes or edges
163
+ // change, which drags don't do (they're in presence until the drop)
164
+ const [structureKey, setStructureKey] = useState('');
165
+ // Where things are on L1 and L2, as people arranged them
166
+ const [levelLayouts, setLevelLayouts] = useState<Record<1 | 2, ReadonlyMap<string, LevelPlace>>>(NO_LEVEL_LAYOUTS);
124
167
  const [canvasStatus, setCanvasStatusState] = useState<{ status: CanvasStatus; history: StatusChange[] }>(NEW_CANVAS_STATUS);
125
168
  const [transport, setTransport] = useState<Transport>('websocket');
126
169
  // Someone deleted the canvas: the server closed it for everyone, and won't open it again
127
170
  const [deleted, setDeleted] = useState(false);
171
+ // What's flagged on the canvas (risks, problems, the key parts), on nodes or at places
172
+ const [hotspots, setHotspots] = useState<Hotspot[]>([]);
173
+ // Who locked the canvas (for everyone on it), if it's locked
174
+ const [lockedBy, setLockedBy] = useState<string | null>(null);
175
+ // The server's copy of the canvas has arrived (what's on it is known, e.g. that it's empty)
176
+ const [synced, setSynced] = useState(false);
128
177
  const user = useRef<Author>({ name, color });
129
- user.current = { name, color, ...(picture && { picture }) };
178
+ user.current = { name, color, ...(picture && { picture }), ...(agent && { agent }) };
130
179
  const onLayoutRef = useRef(onLayout);
131
180
  onLayoutRef.current = onLayout;
132
181
  const onContainerResizeRef = useRef(onContainerResize);
@@ -139,16 +188,41 @@ export function useStudioFlow({
139
188
  setEdges([]);
140
189
 
141
190
  const doc = new Y.Doc();
142
- const { nodes: yNodes, edges: yEdges, threads: yThreads, meta: yMeta } = getCanvasMaps(doc);
191
+ const {
192
+ nodes: yNodes,
193
+ edges: yEdges,
194
+ threads: yThreads,
195
+ meta: yMeta,
196
+ levelLayouts: yLevelLayouts,
197
+ hotspots: yHotspots,
198
+ } = getCanvasMaps(doc);
199
+ const syncLevelLayouts = (event: Y.YMapEvent<LevelPlace>) => {
200
+ const changed = new Set([...event.keysChanged].map((key) => Number(key.split(':')[0]) as 1 | 2));
201
+ setLevelLayouts((current) => {
202
+ const next = { ...current };
203
+ changed.forEach((level) => (next[level] = readLevelLayout(doc, level)));
204
+ return next;
205
+ });
206
+ };
207
+ yLevelLayouts.observe(syncLevelLayouts);
208
+ setLevelLayouts(NO_LEVEL_LAYOUTS);
143
209
  const syncMeta = (event: Y.YMapEvent<unknown>) => {
144
210
  const meta = readMeta(doc);
145
211
  setTitle(meta.title);
212
+ if (event.keysChanged.has('diagramLevels')) setDiagramLevels(meta.diagramLevels);
146
213
  if (event.keysChanged.has('status') || event.keysChanged.has('statusHistory'))
147
214
  setCanvasStatusState({ status: getCanvasStatus(meta), history: meta.statusHistory ?? [] });
148
215
  if (event.keysChanged.has('layoutAt')) onLayoutRef.current?.();
216
+ if (event.keysChanged.has('lockedBy')) setLockedBy(getLock(doc));
149
217
  };
150
218
  yMeta.observe(syncMeta);
219
+ setLockedBy(null);
220
+ const syncHotspots = () => setHotspots(readHotspots(doc));
221
+ yHotspots.observe(syncHotspots);
222
+ setHotspots([]);
151
223
  setTitle(undefined);
224
+ setStructureKey('');
225
+ setDiagramLevels(undefined);
152
226
  setCanvasStatusState(NEW_CANVAS_STATUS);
153
227
  // Changing an accepted (or rejected) canvas here makes it a draft again, for everyone. Changes from others arrive
154
228
  // already reopened by whoever made them.
@@ -169,6 +243,12 @@ export function useStudioFlow({
169
243
  );
170
244
  };
171
245
  doc.on('afterTransaction', reopenOnEdit);
246
+ const syncStructure = (transaction: Y.Transaction) => {
247
+ // Drops and resizes here only move and size things (putting something in a container isn't LOCAL_ORIGIN)
248
+ if (transaction.origin !== LOCAL_ORIGIN && changesNodesOrEdges(transaction))
249
+ setStructureKey(getStructureKey(Array.from(yNodes.values()), Array.from(yEdges.values())));
250
+ };
251
+ doc.on('afterTransaction', syncStructure);
172
252
  // One presence for whichever connection is used
173
253
  const awareness = new Awareness(doc);
174
254
  awareness.setLocalStateField('user', user.current);
@@ -213,6 +293,7 @@ export function useStudioFlow({
213
293
  // Open isn't connected until the server lets us in (onAuthenticated)
214
294
  if (!stopToolSync) setStatus(status === 'connected' && !letIn ? 'connecting' : (status as Status));
215
295
  },
296
+ onSynced: ({ state }) => setSynced(state),
216
297
  onAuthenticated: () => {
217
298
  socketConnected = letIn = true;
218
299
  if (!stopToolSync) setStatus('connected');
@@ -232,6 +313,7 @@ export function useStudioFlow({
232
313
  });
233
314
  setTransport('websocket');
234
315
  setDeleted(false);
316
+ setSynced(false);
235
317
 
236
318
  // Back online, or back on the tab: reconnect now rather than when the next retry is due
237
319
  const reconnect = () => {
@@ -249,7 +331,7 @@ export function useStudioFlow({
249
331
  // A socket that isn't let in within a few seconds (blocked, or never answered) is given up for tool calls
250
332
  if (syncViaTools) fallback = setTimeout(() => !socketConnected && syncThroughTools(), SOCKET_TIMEOUT_MS);
251
333
  // Undo only takes back your own changes: other people's arrive with the provider as origin, agents' with theirs
252
- const undoManager = new Y.UndoManager([yNodes, yEdges, yThreads], {
334
+ const undoManager = new Y.UndoManager([yNodes, yEdges, yThreads, yLevelLayouts, yHotspots], {
253
335
  captureTimeout: 500,
254
336
  trackedOrigins: new Set([null, LOCAL_ORIGIN]),
255
337
  });
@@ -266,6 +348,13 @@ export function useStudioFlow({
266
348
  // Where others are dragging nodes, from their presence, until they drop them
267
349
  let remoteMoves = new Map<string, XYPosition>();
268
350
  const settling = new Set<ReturnType<typeof setTimeout>>();
351
+ // Shown gliding, a step a frame: at most one update a frame however many people drag (each presence arriving
352
+ // was an update of its own: ~5 a frame with 7 people dragging), and smooth between their ~30 updates a second.
353
+ // Not what's been dropped since (its drop is in the document, see syncNodes).
354
+ const glide = createSmoother((shown) => {
355
+ const moved = new Map([...shown].filter(([id]) => remoteMoves.has(id)));
356
+ if (moved.size) setNodes((local) => withPositions(local, moved));
357
+ });
269
358
  const syncMoves = (_: unknown, origin: unknown) => {
270
359
  if (origin === 'local') return;
271
360
  const next = new Map<string, XYPosition>();
@@ -273,10 +362,10 @@ export function useStudioFlow({
273
362
  if (id === doc.clientID || !state.moving) return;
274
363
  Object.entries(state.moving as Record<string, XYPosition>).forEach(([nodeId, position]) => next.set(nodeId, position));
275
364
  });
276
- const moved = new Map([...next].filter(([id, { x, y }]) => remoteMoves.get(id)?.x !== x || remoteMoves.get(id)?.y !== y));
365
+ const moved = [...next].some(([id, { x, y }]) => remoteMoves.get(id)?.x !== x || remoteMoves.get(id)?.y !== y);
277
366
  const dropped = [...remoteMoves.keys()].filter((id) => !next.has(id));
278
367
  remoteMoves = next;
279
- if (moved.size) setNodes((local) => withPositions(local, moved));
368
+ if (moved || dropped.length) glide.set(next);
280
369
  if (dropped.length === 0) return;
281
370
  // The drop is saved in the document. If it doesn't arrive, back to where the document has it.
282
371
  const settle = setTimeout(() => {
@@ -291,6 +380,40 @@ export function useStudioFlow({
291
380
  };
292
381
  awareness.on('change', syncMoves);
293
382
 
383
+ // What others are writing on notes and text, and how they're scaling and turning text, as they do it (from their
384
+ // presence): shown on just those nodes, and back to what the document has when they're done (their result is
385
+ // written to the document before their presence is cleared, so it doesn't go back in between)
386
+ let remoteEdits = '';
387
+ let remoteEdited = new Set<string>();
388
+ const syncEdits = (_: unknown, origin: unknown) => {
389
+ if (origin === 'local') return;
390
+ const edits = new Map<string, Record<string, unknown>>();
391
+ awareness.getStates().forEach((state, id) => {
392
+ const editing = state.editing as Editing | null | undefined;
393
+ if (id === doc.clientID || !editing?.id) return;
394
+ const { id: nodeId, ...fields } = editing;
395
+ edits.set(nodeId, fields);
396
+ });
397
+ const key = JSON.stringify([...edits]);
398
+ if (key === remoteEdits) return;
399
+ remoteEdits = key;
400
+ const before = remoteEdited;
401
+ remoteEdited = new Set(edits.keys());
402
+ setNodes((local) => {
403
+ let next: Node[] | undefined;
404
+ local.forEach((node, index) => {
405
+ const edit = edits.get(node.id);
406
+ const savedData = before.has(node.id) ? yNodes.get(node.id)?.data : undefined;
407
+ const changes = edit ?? (savedData && Object.fromEntries(EDITED_FIELDS.map((field) => [field, savedData[field]])));
408
+ if (!changes || Object.entries(changes).every(([field, value]) => node.data[field] === value)) return;
409
+ next ??= [...local];
410
+ next[index] = { ...node, data: { ...node.data, ...changes } };
411
+ });
412
+ return next ?? local;
413
+ });
414
+ };
415
+ awareness.on('change', syncEdits);
416
+
294
417
  // Only what changed is replaced, so React Flow re-renders just those nodes
295
418
  const syncNodes = (event: Y.YMapEvent<Node>) => {
296
419
  if (event.transaction.origin === LOCAL_ORIGIN) return;
@@ -326,12 +449,17 @@ export function useStudioFlow({
326
449
  window.removeEventListener('online', reconnect);
327
450
  document.removeEventListener('visibilitychange', onVisibilityChange);
328
451
  awareness.off('change', syncMoves);
452
+ awareness.off('change', syncEdits);
453
+ glide.stop();
329
454
  sender.destroy();
330
455
  stopToolSync?.();
331
456
  yNodes.unobserve(syncNodes);
457
+ yLevelLayouts.unobserve(syncLevelLayouts);
332
458
  yEdges.unobserve(syncEdges);
333
459
  yMeta.unobserve(syncMeta);
460
+ yHotspots.unobserve(syncHotspots);
334
461
  doc.off('afterTransaction', reopenOnEdit);
462
+ doc.off('afterTransaction', syncStructure);
335
463
  presenceStore.destroy();
336
464
  undoManager.destroy();
337
465
  provider.destroy();
@@ -384,10 +512,10 @@ export function useStudioFlow({
384
512
  dragged.current.forEach((position, id) => moveNode(doc, id, position));
385
513
  // Containers as they grew around what was dragged, and what else is in them (moved the other way if they grew up
386
514
  // or left). Dragged nodes are where React Flow dropped them, already in their containers' grown coordinates.
387
- dragPreview.current.forEach(({ position, width, height }, id) => {
388
- const node = getCanvasMaps(doc).nodes.get(id);
389
- if (node) getCanvasMaps(doc).nodes.set(id, { ...node, position, ...(width !== undefined && { width, height }) });
390
- });
515
+ // Only what actually moved or grew is written (the preview has everything in those containers)
516
+ dragPreview.current.forEach(({ position, width, height }, id) =>
517
+ placeNode(doc, id, position, width !== undefined && height !== undefined ? { width, height } : undefined)
518
+ );
391
519
  dragPreview.current = new Map();
392
520
  dragged.current.clear();
393
521
  const shared = sharedRef.current;
@@ -474,12 +602,18 @@ export function useStudioFlow({
474
602
  * on the canvas, and returns edges to add with it (e.g. a catalog resource's relationships).
475
603
  */
476
604
  const insertNode = useCallback(
477
- (type: string, data: Record<string, unknown>, center: XYPosition, connect?: (id: string, existing: Node[]) => Edge[]) => {
605
+ (
606
+ type: string,
607
+ data: Record<string, unknown>,
608
+ center: XYPosition,
609
+ connect?: (id: string, existing: Node[]) => Edge[],
610
+ inside?: string
611
+ ) => {
478
612
  const built = buildNode(type, data, center);
479
613
  withDoc((doc) => {
480
614
  const existing = Array.from(getCanvasMaps(doc).nodes.values());
481
- // Dropped on a container: it goes in it
482
- const group = canGoInContainer(type) ? findGroupAtPoint(center, existing) : undefined;
615
+ // In the container asked for, else the one it's dropped on
616
+ const group = canGoInContainer(type) ? containerFor(center, existing, inside) : undefined;
483
617
  const node = group
484
618
  ? { ...built, parentId: group.id, position: toRelativePosition(built.position, group.id, existing) }
485
619
  : built;
@@ -491,11 +625,11 @@ export function useStudioFlow({
491
625
  []
492
626
  );
493
627
 
494
- /** Add a node of a registry type, centred on a canvas position */
628
+ /** Add a node of a component (its type, or its key in the components list), centred on a canvas position (in a container, if asked) */
495
629
  const addNode = useCallback(
496
- (type: string, center: XYPosition) => {
497
- const definition = getNodeDefinition(type);
498
- return definition ? insertNode(type, definition.createData(), center) : undefined;
630
+ (component: string, center: XYPosition, inside?: string) => {
631
+ const definition = getNodeDefinition(component);
632
+ return definition ? insertNode(definition.type, definition.createData(), center, undefined, inside) : undefined;
499
633
  },
500
634
  [insertNode]
501
635
  );
@@ -510,6 +644,12 @@ export function useStudioFlow({
510
644
 
511
645
  /** Moves nodes (and resizes containers) from a layout in one change, so it undoes in one go */
512
646
  const applyPositions = useCallback((layout: LayoutResult) => withDoc((doc) => applyLayout(doc, layout)), []);
647
+ /** Puts things where they go on L1 or L2: moved there by someone (undone like any edit), or placed by the level */
648
+ const setLevelLayout = useCallback(
649
+ (level: 1 | 2, places: ReadonlyMap<string, LevelPlace>, by: 'person' | 'level' = 'person') =>
650
+ withDoc((doc) => setLevelPlaces(doc, level, places), by === 'level' ? LEVEL_PLACEMENT_ORIGIN : null),
651
+ []
652
+ );
513
653
 
514
654
  /** Adds ready-made nodes and edges (e.g. a container with what's in it) in one change */
515
655
  const insertNodes = useCallback(
@@ -525,13 +665,12 @@ export function useStudioFlow({
525
665
  []
526
666
  );
527
667
 
528
- /** Puts a node in a container (or takes it out), e.g. after it's dragged there */
529
- const setParent = useCallback(
530
- (nodeId: string, parentId: string | undefined) =>
668
+ /** Puts nodes in containers (or takes them out) together, e.g. a selection dropped in one, as one change */
669
+ const setParents = useCallback(
670
+ (moves: { id: string; parentId: string | undefined; at?: XYPosition }[]) =>
531
671
  withDoc((doc) => {
532
- // Where it was dropped goes first, so it's moved into (or out of) the container from there
533
672
  if (dragged.current.size) saveDrop(doc);
534
- return setNodeParent(doc, nodeId, parentId, renderedSizes());
673
+ return setNodeParents(doc, moves, renderedSizes());
535
674
  }),
536
675
  []
537
676
  );
@@ -555,6 +694,47 @@ export function useStudioFlow({
555
694
  sharedRef.current?.sender.send({ viewport });
556
695
  }, []);
557
696
 
697
+ /**
698
+ * The note or text being changed here, as it changes (what's written, a text's size and angle), for the others to
699
+ * see as it happens; `null` when done (after the result is saved), sent straight away
700
+ */
701
+ const sendEditing = useCallback((editing: Editing | null) => {
702
+ const sender = sharedRef.current?.sender;
703
+ if (!sender) return;
704
+ sender.send({ editing: editing && { ...editing, ...(editing.text !== undefined && { text: editing.text.slice(0, 2000) }) } });
705
+ if (!editing) sender.flush();
706
+ }, []);
707
+
708
+ /**
709
+ * What a diagram file opened into this (new) canvas starts with: its nodes and connections, comments, hotspots and
710
+ * where things are on L1 and L2, in one change that isn't undone
711
+ */
712
+ const addContent = useCallback(
713
+ (content: ModelCanvasContent) =>
714
+ withDoc((doc) => {
715
+ addNodes(doc, content.nodes, content.edges);
716
+ if (content.threads?.length) addThreads(doc, content.threads);
717
+ if (content.hotspots?.length) addHotspots(doc, content.hotspots);
718
+ setLevelPlaces(doc, 1, content.levelLayouts[1]);
719
+ setLevelPlaces(doc, 2, content.levelLayouts[2]);
720
+ }, OPEN_ORIGIN),
721
+ []
722
+ );
723
+
724
+ // ---- Hotspots and the lock ----
725
+
726
+ const createHotspot = useCallback(
727
+ (hotspot: Omit<Hotspot, 'id' | 'createdAt'>) => withDoc((doc) => createHotspotInDoc(doc, hotspot)),
728
+ []
729
+ );
730
+ const updateHotspot = useCallback(
731
+ (id: string, fields: Parameters<typeof updateHotspotInDoc>[2]) => withDoc((doc) => updateHotspotInDoc(doc, id, fields)),
732
+ []
733
+ );
734
+ const deleteHotspot = useCallback((id: string) => withDoc((doc) => deleteHotspotInDoc(doc, id)), []);
735
+ /** Locks the canvas for everyone (by a name), or unlocks it (null): looked at, not changed */
736
+ const setLocked = useCallback((by: string | null) => withDoc((doc) => setLock(doc, by), STATUS_ORIGIN), []);
737
+
558
738
  return {
559
739
  nodes,
560
740
  edges,
@@ -572,14 +752,27 @@ export function useStudioFlow({
572
752
  updateNodeData,
573
753
  deleteNodes,
574
754
  applyPositions,
755
+ levelLayouts,
756
+ setLevelLayout,
575
757
  insertNodes,
576
- setParent,
758
+ setParents,
577
759
  title,
760
+ diagramLevels,
761
+ structureKey,
578
762
  rename,
579
763
  canvasStatus,
580
764
  changeStatus,
581
765
  transport,
582
766
  deleted,
767
+ hotspots,
768
+ createHotspot,
769
+ updateHotspot,
770
+ deleteHotspot,
771
+ lockedBy,
772
+ setLocked,
773
+ sendEditing,
774
+ synced,
775
+ addContent,
583
776
  undo,
584
777
  redo,
585
778
  ...history,
@@ -1,6 +1,9 @@
1
1
  // Who you are in Studio: the name you give on your first visit, kept in this browser and used on every canvas
2
- const COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899'];
2
+ /** The colours people can go by (as Studio on the web's): on their pointer, avatar and comments */
3
+ export const PERSON_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899'];
4
+ const COLORS = PERSON_COLORS;
3
5
  const NAME_KEY = 'eventcatalog-studio-name';
6
+ const COLOR_KEY = 'eventcatalog-studio-color';
4
7
  /** Names people give are at most this long */
5
8
  export const MAX_NAME_LENGTH = 60;
6
9
 
@@ -35,3 +38,22 @@ export const colorForName = (name: string) => {
35
38
  for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
36
39
  return COLORS[Math.abs(hash) % COLORS.length];
37
40
  };
41
+
42
+ /** The colour you picked, kept in this browser (else it follows from your name) */
43
+ export const getStoredColor = () => {
44
+ try {
45
+ const color = localStorage.getItem(COLOR_KEY);
46
+ return color && PERSON_COLORS.includes(color) ? color : null;
47
+ } catch {
48
+ return null;
49
+ }
50
+ };
51
+
52
+ export const storeColor = (color: string) => {
53
+ try {
54
+ localStorage.setItem(COLOR_KEY, color);
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ };
@@ -0,0 +1,2 @@
1
+ /** Canvas titles are at most this long (no imports, so the browser can check it without loading a canvas) */
2
+ export const MAX_TITLE_LENGTH = 200;
@@ -1,7 +1,8 @@
1
1
  ---
2
2
  // Visualiser styles ship in the page head so ClientRouter navigations keep them (see NodeGraph.astro).
3
3
  import '@eventcatalog/visualiser/styles-core.css';
4
- import '../studio.css';
4
+ import '@eventcatalog/visualiser/studio.css';
5
+ import '@eventcatalog/visualiser/studio-fonts.css';
5
6
  import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro';
6
7
  import { buildUrl } from '@utils/url-builder';
7
8
  import StudioDesigner from '../components/StudioDesigner';
@@ -4,9 +4,9 @@ import { ChevronDown, ChevronLeft, ChevronRight, Copy, EllipsisVertical, Pencil,
4
4
  import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro';
5
5
  import StudioNotice from '../components/StudioNotice.astro';
6
6
  import { buildUrl } from '@utils/url-builder';
7
- import { CANVAS_STATUSES, MAX_TITLE_LENGTH, type CanvasStatus } from '../canvas-doc';
7
+ import { CANVAS_STATUSES, CANVAS_STATUS_LOOK, STATUS, type CanvasStatus } from '@eventcatalog/visualiser/studio';
8
+ import { MAX_TITLE_LENGTH } from '../limits';
8
9
  import { MAX_NAME_LENGTH } from '../identity';
9
- import { CANVAS_STATUS_LOOK, STATUS } from '../components/status';
10
10
  import { CANVASES_API_PATH } from '../api/canvases-api';
11
11
  import { startStudio, type CanvasSummary } from '../server/runtime';
12
12
  import { getSignedInUser, isSignedIn } from '../server/sign-in';
@@ -21,10 +21,10 @@ import {
21
21
  setCanvasStatus,
22
22
  setThreadResolved,
23
23
  type Author,
24
- } from '../canvas-doc';
24
+ getLayoutPositions,
25
+ } from '@eventcatalog/visualiser/studio';
25
26
  import { ADD_TO_CANVAS_DESCRIPTION, describeCanvas, edgeSpecsSchema, indexCatalog, nodeSpecsSchema } from '../canvas-actions';
26
27
  import { getCatalogResources } from '../catalog-resources';
27
- import { getLayoutPositions } from '../layout';
28
28
  import {
29
29
  playAddToCanvas,
30
30
  playComment,
@@ -35,7 +35,8 @@ import {
35
35
  playUpdateNode,
36
36
  type AgentStage,
37
37
  } from '../agent-choreography';
38
- import { STUDIO_SOCKET_PATH, findStudioRuntime, type AgentIdentity } from './runtime';
38
+ import { STUDIO_SOCKET_PATH, findStudioRuntime } from './runtime';
39
+ import { getAgent } from '../agents';
39
40
  import { addToolIcons } from './tool-icons';
40
41
 
41
42
  /**
@@ -46,21 +47,11 @@ import { addToolIcons } from './tool-icons';
46
47
 
47
48
  const loadCanvasView = getMcpAppViewLoader('canvas');
48
49
 
49
- const AGENT_COLORS: Record<string, string> = { ChatGPT: '#10a37f', Claude: '#d97757' };
50
-
51
50
  // The canvases entrypoint's icon (OpenAI MCP extensions: a 20x20, monochrome SVG in currentColor, 1.33px strokes)
52
51
  const CANVAS_ICON_SVG =
53
52
  '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.33" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="3.5" width="5.5" height="4.5" rx="1"/><rect x="12" y="3.5" width="5.5" height="4.5" rx="1"/><rect x="12" y="12" width="5.5" height="4.5" rx="1"/><path d="M8 5.75h4M14.75 8v4"/></svg>';
54
53
  const CANVAS_ICON = { src: `data:image/svg+xml;base64,${btoa(CANVAS_ICON_SVG)}`, mimeType: 'image/svg+xml', sizes: ['20x20'] };
55
54
 
56
- /** Who the agent is, from what it tells us or its client's user agent */
57
- const getAgent = (agentName: string | undefined, userAgent = ''): AgentIdentity => {
58
- const name =
59
- agentName?.trim() ||
60
- (/openai|chatgpt/i.test(userAgent) ? 'ChatGPT' : /claude|anthropic/i.test(userAgent) ? 'Claude' : 'AI agent');
61
- return { name, color: AGENT_COLORS[name] ?? '#8b5cf6' };
62
- };
63
-
64
55
  const text = (value: unknown, isError = false) => ({
65
56
  content: [{ type: 'text' as const, text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }],
66
57
  ...(isError && { isError }),
@@ -151,12 +142,6 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
151
142
  input: {
152
143
  canvasId: canvasId.optional(),
153
144
  title: z.string().optional().describe('For a new canvas: what it is for'),
154
- userName: z
155
- .string()
156
- .optional()
157
- .describe(
158
- 'The name of the person you are working with, if you know it: others on the canvas see them by this name. Leave it out if you do not know it.'
159
- ),
160
145
  agentName,
161
146
  },
162
147
  },
@@ -170,7 +155,7 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
170
155
  annotations: writes,
171
156
  errorMessage: 'Failed to open the canvas',
172
157
  load: async (params) => {
173
- const { title: newTitle, agentName: name, userName } = params as { title?: string; agentName?: string; userName?: string };
158
+ const { title: newTitle, agentName: name } = params as { title?: string; agentName?: string };
174
159
 
175
160
  // Opened without a canvas (e.g. from an entrypoint): the canvases to pick from, or start one
176
161
  if (!params.canvasId && !newTitle) {
@@ -207,7 +192,7 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
207
192
  socketUrl,
208
193
  canvasUrl: canvasUrl(id),
209
194
  mcpUrl: `${catalogUrl}${buildUrl('/docs/mcp', true)}`,
210
- userName,
195
+ agent: getAgent(name, userAgent),
211
196
  };
212
197
  return {
213
198
  forModel: {
@@ -418,7 +403,7 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
418
403
  {
419
404
  title: 'Tidy a canvas',
420
405
  description:
421
- "Lays the whole canvas out again with EventCatalog's visualiser layout (left to right, connections routed around nodes); everyone on it sees the nodes glide into place. It moves everything, including what people placed themselves, so only use it when the user asks to tidy up: addToCanvas already places what you add next to what it connects to.",
406
+ "Lays the whole canvas out again with EventCatalog's visualiser layout (left to right, connections routed around nodes); everyone on it sees the nodes glide into place. Call it once when you finish a round of changes, before you hand back to the user, so the canvas is tidy for everyone, and whenever the user asks to tidy up. Don't call it after every change (addToCanvas already places what you add next to what it connects to while you work), and skip it if the user asked to keep the layout as it is: it moves everything, including what people placed themselves.",
422
407
  inputSchema: z.object({ canvasId, agentName }),
423
408
  annotations: writes,
424
409
  },
@@ -427,7 +412,7 @@ export function registerCanvasTools(server: McpServer, { catalogUrl, userAgent }
427
412
  const { nodes, edges } = await runtime.withCanvas(id, readCanvas);
428
413
  const positions = await getLayoutPositions(nodes, edges);
429
414
  await playLayout(asAgent(id, name).stage, positions);
430
- return text({ laidOut: positions.size });
415
+ return text({ laidOut: positions.nodes.size });
431
416
  }
432
417
  );
433
418
 
@@ -5,17 +5,17 @@ import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, removeAwareness
5
5
  import type { XYPosition } from '@xyflow/react';
6
6
  import type { AgentPresence } from '../agent-choreography';
7
7
  import {
8
- CANVAS_DELETED_REASON,
9
- MAX_TITLE_LENGTH,
10
- canvasDocumentName,
11
- canvasIdFromDocumentName,
8
+ addCanvasContent,
12
9
  getCanvasMaps,
13
10
  readMeta,
14
11
  setMeta,
15
12
  getCanvasStatus,
13
+ type CanvasContent,
16
14
  type CanvasStatus,
17
- } from '../canvas-doc';
15
+ } from '@eventcatalog/visualiser/studio';
16
+ import { CANVAS_DELETED_REASON, canvasDocumentName, canvasIdFromDocumentName } from '../document-name';
18
17
  import { getCanvasPreview, type CanvasPreview } from '../canvas-preview';
18
+ import { MAX_TITLE_LENGTH } from '../limits';
19
19
  import { fromBase64, toBase64, type SyncRequest, type SyncResponse } from '../tool-sync';
20
20
  import config from '../../../utils/eventcatalog-config/source';
21
21
  import { isAuthEnabled } from '../../../utils/feature';
@@ -31,7 +31,8 @@ const AGENT_PRESENCE_TTL_MS = 25_000;
31
31
  // Application close codes are 4000-4999
32
32
  const CANVAS_DELETED_CLOSE_CODE = 4410;
33
33
 
34
- export type AgentIdentity = { name: string; color: string };
34
+ import type { AgentIdentity } from '../agents';
35
+ export type { AgentIdentity };
35
36
  export type CanvasSummary = {
36
37
  canvasId: string;
37
38
  title?: string;
@@ -299,10 +300,16 @@ export class StudioRuntime {
299
300
  return true;
300
301
  }
301
302
 
302
- /** Starts a canvas with a title, recording when and by whom (a person, or an agent). Returns its id. */
303
- async createCanvas(started: { title?: string; createdBy?: string }) {
303
+ /**
304
+ * Starts a canvas with a title, recording when and by whom (a person, or an agent), and what's on it to begin
305
+ * with, if anything. Returns its id.
306
+ */
307
+ async createCanvas(started: { title?: string; createdBy?: string }, content?: CanvasContent) {
304
308
  const canvasId = crypto.randomUUID();
305
- await this.withCanvas(canvasId, (doc) => setStarted(doc, started));
309
+ await this.withCanvas(canvasId, (doc) => {
310
+ setStarted(doc, started);
311
+ if (content) addCanvasContent(doc, content);
312
+ });
306
313
  return canvasId;
307
314
  }
308
315