@eventcatalog/core 5.0.0-beta.12 → 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 (82) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +4 -4
  5. package/dist/catalog-runtime.cjs +1 -1
  6. package/dist/{chunk-FY6MSP6J.js → chunk-BO2HHVZ3.js} +1 -1
  7. package/dist/{chunk-CX2VB7AI.js → chunk-JPT3QC5V.js} +1 -1
  8. package/dist/{chunk-BWC4YKW3.js → chunk-PLTLW45Q.js} +5 -5
  9. package/dist/{chunk-TSY5CC27.js → chunk-T75DOVFP.js} +1 -1
  10. package/dist/{chunk-QB7X34OD.js → chunk-XA5W6DXX.js} +1 -1
  11. package/dist/constants.cjs +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/eventcatalog.cjs +2 -2
  14. package/dist/eventcatalog.js +14 -14
  15. package/dist/federation/federate.js +3 -3
  16. package/dist/generate.cjs +1 -1
  17. package/dist/generate.js +3 -3
  18. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
  19. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
  20. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
  21. package/dist/runtime-dependencies/manifest.json +3 -0
  22. package/dist/utils/cli-logger.cjs +1 -1
  23. package/dist/utils/cli-logger.js +2 -2
  24. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +11 -3
  25. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +3 -3
  26. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +3 -1
  27. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +7 -5
  28. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +133 -71
  29. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +66 -66
  30. package/eventcatalog/src/features/studio/agent-choreography.ts +5 -4
  31. package/eventcatalog/src/features/studio/agents.ts +28 -0
  32. package/eventcatalog/src/features/studio/api/canvases-api.ts +1 -1
  33. package/eventcatalog/src/features/studio/canvas-actions.ts +22 -14
  34. package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
  35. package/eventcatalog/src/features/studio/catalog.ts +1 -4
  36. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +246 -115
  37. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +7 -3
  38. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +272 -119
  39. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +1 -2
  40. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +1 -1
  41. package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
  42. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +102 -241
  43. package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
  44. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +1 -1
  45. package/eventcatalog/src/features/studio/components/Presence.tsx +6 -8
  46. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +277 -141
  47. package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
  48. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1005 -354
  49. package/eventcatalog/src/features/studio/document-name.ts +7 -0
  50. package/eventcatalog/src/features/studio/from-visualiser.ts +1 -1
  51. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +19 -2
  52. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +9 -3
  53. package/eventcatalog/src/features/studio/hooks/use-comments.ts +2 -2
  54. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +162 -23
  55. package/eventcatalog/src/features/studio/identity.ts +23 -1
  56. package/eventcatalog/src/features/studio/pages/[id].astro +2 -1
  57. package/eventcatalog/src/features/studio/pages/index.astro +1 -2
  58. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +7 -22
  59. package/eventcatalog/src/features/studio/server/runtime.ts +4 -5
  60. package/package.json +3 -3
  61. package/eventcatalog/src/features/studio/canvas-doc.ts +0 -569
  62. package/eventcatalog/src/features/studio/clipboard.ts +0 -97
  63. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -119
  64. package/eventcatalog/src/features/studio/components/Comments.tsx +0 -777
  65. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +0 -145
  66. package/eventcatalog/src/features/studio/components/DragGhost.tsx +0 -115
  67. package/eventcatalog/src/features/studio/components/Picture.tsx +0 -21
  68. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +0 -212
  69. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +0 -271
  70. package/eventcatalog/src/features/studio/components/icons.ts +0 -53
  71. package/eventcatalog/src/features/studio/components/status.ts +0 -53
  72. package/eventcatalog/src/features/studio/edges.ts +0 -56
  73. package/eventcatalog/src/features/studio/flow-state.ts +0 -121
  74. package/eventcatalog/src/features/studio/grouping.ts +0 -230
  75. package/eventcatalog/src/features/studio/hooks/use-counter-scale.ts +0 -20
  76. package/eventcatalog/src/features/studio/layout.ts +0 -56
  77. package/eventcatalog/src/features/studio/level-layout.ts +0 -145
  78. package/eventcatalog/src/features/studio/levels.ts +0 -337
  79. package/eventcatalog/src/features/studio/node-types.ts +0 -245
  80. package/eventcatalog/src/features/studio/placement.ts +0 -197
  81. package/eventcatalog/src/features/studio/studio.css +0 -273
  82. package/dist/{chunk-KHACCTW2.js → chunk-ZEY2XUJN.js} +4 -4
@@ -0,0 +1,7 @@
1
+ // Canvases' documents on the collaboration server are named `design:<canvasId>` (scripts and peers join by it)
2
+ const DOCUMENT_PREFIX = 'design:';
3
+ export const canvasDocumentName = (canvasId: string) => `${DOCUMENT_PREFIX}${canvasId}`;
4
+ export const canvasIdFromDocumentName = (name: string) =>
5
+ name.startsWith(DOCUMENT_PREFIX) ? name.slice(DOCUMENT_PREFIX.length) : undefined;
6
+ /** Why the collaboration server refuses a canvas that was deleted (told to anyone who still has it open) */
7
+ export const CANVAS_DELETED_REASON = 'canvas-deleted';
@@ -2,7 +2,7 @@ import type { Edge, Node } from '@xyflow/react';
2
2
  import type { VisualiserSnapshot } from '@eventcatalog/visualiser';
3
3
  import { getNodeCatalogResource } from '@utils/node-graphs/node-resource';
4
4
  import type { DiagramLevelsSpec, NewCanvasEdges, NodeSpecs } from './canvas-actions';
5
- import { DEFAULT_NODE_SIZE, isCatalogCollection, isGroupType } from './node-types';
5
+ import { DEFAULT_NODE_SIZE, isCatalogCollection, isGroupType } from '@eventcatalog/visualiser/studio';
6
6
 
7
7
  /**
8
8
  * A canvas started from a diagram (the visualiser's "Open in Studio"): the catalog resources and actors it shows,
@@ -2,6 +2,11 @@ import type { Awareness } from 'y-protocols/awareness';
2
2
 
3
3
  /** About 30 times a second, like Excalidraw and tldraw: smooth for others, without a message on every mousemove */
4
4
  export const PRESENCE_INTERVAL_MS = 33;
5
+ /**
6
+ * Once a second when nobody else is on the canvas (people or agents), as tldraw does: nobody sees it, and the
7
+ * collaboration server sends every one back. Someone arriving gets what's waiting straight away.
8
+ */
9
+ export const ALONE_INTERVAL_MS = 1000;
5
10
 
6
11
  export type PresenceSender = {
7
12
  /** Share these fields of our presence: straight away if nothing was sent lately, otherwise with the next send */
@@ -16,10 +21,15 @@ export type PresenceSender = {
16
21
  * sends the whole state on every change, so fields changed together (the pointer and the node it drags) go in one
17
22
  * message, and the latest value always goes out (the pointer stops where it really stopped).
18
23
  */
19
- export function createPresenceSender(awareness: Awareness, intervalMs = PRESENCE_INTERVAL_MS): PresenceSender {
24
+ export function createPresenceSender(
25
+ awareness: Awareness,
26
+ intervalMs = PRESENCE_INTERVAL_MS,
27
+ aloneIntervalMs = ALONE_INTERVAL_MS
28
+ ): PresenceSender {
20
29
  let pending: Record<string, unknown> | undefined;
21
30
  let timer: ReturnType<typeof setTimeout> | undefined;
22
31
  let lastSent = -Infinity;
32
+ const alone = () => awareness.getStates().size <= 1;
23
33
 
24
34
  const flush = () => {
25
35
  clearTimeout(timer);
@@ -30,17 +40,24 @@ export function createPresenceSender(awareness: Awareness, intervalMs = PRESENCE
30
40
  lastSent = performance.now();
31
41
  awareness.setLocalState({ ...awareness.getLocalState(), ...fields });
32
42
  };
43
+ // Someone arrived: they get what's waiting now, not at the next (slower) send
44
+ const onChange = ({ added }: { added: number[] }) => {
45
+ if (pending && added.some((id) => id !== awareness.clientID)) flush();
46
+ };
47
+ awareness.on('change', onChange);
33
48
 
34
49
  return {
35
50
  send: (fields) => {
36
51
  pending = { ...pending, ...fields };
37
52
  if (timer) return;
38
- timer = setTimeout(flush, Math.max(0, intervalMs - (performance.now() - lastSent)));
53
+ const interval = alone() ? aloneIntervalMs : intervalMs;
54
+ timer = setTimeout(flush, Math.max(0, interval - (performance.now() - lastSent)));
39
55
  },
40
56
  flush,
41
57
  destroy: () => {
42
58
  clearTimeout(timer);
43
59
  pending = undefined;
60
+ awareness.off('change', onChange);
44
61
  },
45
62
  };
46
63
  }
@@ -2,8 +2,14 @@ import { useEffect, useRef, useState } from 'react';
2
2
  import { z } from 'zod';
3
3
  import type * as Y from 'yjs';
4
4
  import { initializeWebMCPPolyfill } from '@mcp-b/webmcp-polyfill';
5
- import { replyToThread, setThreadResolved, type Author, CANVAS_STATUSES, setCanvasStatus } from '../canvas-doc';
6
- import { CATALOG_COLLECTIONS } from '../node-types';
5
+ import {
6
+ replyToThread,
7
+ setThreadResolved,
8
+ type Author,
9
+ CANVAS_STATUSES,
10
+ setCanvasStatus,
11
+ CATALOG_COLLECTIONS,
12
+ } from '@eventcatalog/visualiser/studio';
7
13
  import {
8
14
  ADD_TO_CANVAS_DESCRIPTION,
9
15
  describeCanvas,
@@ -284,7 +290,7 @@ const createCanvasTools = (get: () => CanvasWebMcpOptions, getStage: () => Agent
284
290
  name: 'layout_canvas',
285
291
  title: 'Tidy the canvas',
286
292
  description:
287
- "Lays the whole canvas out again with EventCatalog's visualiser layout (left to right, connections routed around nodes), for everyone on it. It moves everything, including what people placed themselves, so only use it when the user asks to tidy up: add_to_canvas already places what you add next to what it connects to.",
293
+ "Lays the whole canvas out again with EventCatalog's visualiser layout (left to right, connections routed around nodes), for everyone on it. 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 (add_to_canvas 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.",
288
294
  input: z.object({}),
289
295
  execute: async () => {
290
296
  await get().reorder();
@@ -13,9 +13,9 @@ import {
13
13
  type Author,
14
14
  type CommentAnchor,
15
15
  type Thread,
16
- } from '../canvas-doc';
16
+ } from '@eventcatalog/visualiser/studio';
17
17
 
18
- export type { Author, CommentAnchor, Message, Thread } from '../canvas-doc';
18
+ export type { Author, CommentAnchor, Message, Thread } from '@eventcatalog/visualiser/studio';
19
19
 
20
20
  /** Comment threads in the shared canvas document */
21
21
  export function useComments(doc: Y.Doc | null, author: Author) {
@@ -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,
@@ -41,15 +39,36 @@ import {
41
39
  readLevelLayout,
42
40
  setLevelPlaces,
43
41
  type StatusChange,
44
- setNodeParent,
42
+ setNodeParents,
45
43
  type RenderedSizes,
46
44
  updateNodeData as updateNodeDataInDoc,
47
- } 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';
48
71
  import { applyLayout } from '../agent-choreography';
49
- import { keepDragged, LOCAL_EDGE_KEYS, LOCAL_NODE_KEYS, patchShared, withPositions } from '../flow-state';
50
- import { containerFor, toRelativePosition, type NodePreview } from '../grouping';
51
- import type { LayoutResult } from '../layout';
52
- import { canGoInContainer, getNodeDefinition, isGroupType } from '../node-types';
53
72
  import { startToolSync, type ToolSync } from '../tool-sync';
54
73
  import { createPresenceSender, type PresenceSender } from './presence-sender';
55
74
  import { createPresenceStore, type PresenceStore } from './presence-store';
@@ -73,10 +92,14 @@ const LOCAL_ORIGIN = Symbol('studio-local');
73
92
  export const BROWSER_AGENT_ORIGIN = Symbol('studio-browser-agent');
74
93
  /** Status changes: synced, but not undone (undo takes back edits, not decisions about the canvas) */
75
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');
76
97
  /** Where L1 and L2 put what's new on them, kept so it stays there: synced, not undone (nobody did it) */
77
98
  const LEVEL_PLACEMENT_ORIGIN = Symbol('studio-level-placement');
78
99
 
79
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;
80
103
 
81
104
  export type Status = 'connecting' | 'connected' | 'disconnected';
82
105
  export type Transport = 'websocket' | 'tools';
@@ -100,6 +123,7 @@ export function useStudioFlow({
100
123
  name,
101
124
  color,
102
125
  picture,
126
+ agent,
103
127
  syncViaTools,
104
128
  onLayout,
105
129
  onContainerResize,
@@ -111,6 +135,8 @@ export function useStudioFlow({
111
135
  color: string;
112
136
  /** From your sign-in provider, when you're signed in */
113
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;
114
140
  /** Sync through MCP tool calls when the WebSocket can't connect (e.g. inside a chat's sandbox) */
115
141
  syncViaTools?: ToolSync;
116
142
  /** Someone laid the canvas out (us included): called as the new positions arrive, so they can glide there */
@@ -142,8 +168,14 @@ export function useStudioFlow({
142
168
  const [transport, setTransport] = useState<Transport>('websocket');
143
169
  // Someone deleted the canvas: the server closed it for everyone, and won't open it again
144
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);
145
177
  const user = useRef<Author>({ name, color });
146
- user.current = { name, color, ...(picture && { picture }) };
178
+ user.current = { name, color, ...(picture && { picture }), ...(agent && { agent }) };
147
179
  const onLayoutRef = useRef(onLayout);
148
180
  onLayoutRef.current = onLayout;
149
181
  const onContainerResizeRef = useRef(onContainerResize);
@@ -156,7 +188,14 @@ export function useStudioFlow({
156
188
  setEdges([]);
157
189
 
158
190
  const doc = new Y.Doc();
159
- const { nodes: yNodes, edges: yEdges, threads: yThreads, meta: yMeta, levelLayouts: yLevelLayouts } = 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);
160
199
  const syncLevelLayouts = (event: Y.YMapEvent<LevelPlace>) => {
161
200
  const changed = new Set([...event.keysChanged].map((key) => Number(key.split(':')[0]) as 1 | 2));
162
201
  setLevelLayouts((current) => {
@@ -174,8 +213,13 @@ export function useStudioFlow({
174
213
  if (event.keysChanged.has('status') || event.keysChanged.has('statusHistory'))
175
214
  setCanvasStatusState({ status: getCanvasStatus(meta), history: meta.statusHistory ?? [] });
176
215
  if (event.keysChanged.has('layoutAt')) onLayoutRef.current?.();
216
+ if (event.keysChanged.has('lockedBy')) setLockedBy(getLock(doc));
177
217
  };
178
218
  yMeta.observe(syncMeta);
219
+ setLockedBy(null);
220
+ const syncHotspots = () => setHotspots(readHotspots(doc));
221
+ yHotspots.observe(syncHotspots);
222
+ setHotspots([]);
179
223
  setTitle(undefined);
180
224
  setStructureKey('');
181
225
  setDiagramLevels(undefined);
@@ -249,6 +293,7 @@ export function useStudioFlow({
249
293
  // Open isn't connected until the server lets us in (onAuthenticated)
250
294
  if (!stopToolSync) setStatus(status === 'connected' && !letIn ? 'connecting' : (status as Status));
251
295
  },
296
+ onSynced: ({ state }) => setSynced(state),
252
297
  onAuthenticated: () => {
253
298
  socketConnected = letIn = true;
254
299
  if (!stopToolSync) setStatus('connected');
@@ -268,6 +313,7 @@ export function useStudioFlow({
268
313
  });
269
314
  setTransport('websocket');
270
315
  setDeleted(false);
316
+ setSynced(false);
271
317
 
272
318
  // Back online, or back on the tab: reconnect now rather than when the next retry is due
273
319
  const reconnect = () => {
@@ -285,7 +331,7 @@ export function useStudioFlow({
285
331
  // A socket that isn't let in within a few seconds (blocked, or never answered) is given up for tool calls
286
332
  if (syncViaTools) fallback = setTimeout(() => !socketConnected && syncThroughTools(), SOCKET_TIMEOUT_MS);
287
333
  // Undo only takes back your own changes: other people's arrive with the provider as origin, agents' with theirs
288
- const undoManager = new Y.UndoManager([yNodes, yEdges, yThreads, yLevelLayouts], {
334
+ const undoManager = new Y.UndoManager([yNodes, yEdges, yThreads, yLevelLayouts, yHotspots], {
289
335
  captureTimeout: 500,
290
336
  trackedOrigins: new Set([null, LOCAL_ORIGIN]),
291
337
  });
@@ -302,6 +348,13 @@ export function useStudioFlow({
302
348
  // Where others are dragging nodes, from their presence, until they drop them
303
349
  let remoteMoves = new Map<string, XYPosition>();
304
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
+ });
305
358
  const syncMoves = (_: unknown, origin: unknown) => {
306
359
  if (origin === 'local') return;
307
360
  const next = new Map<string, XYPosition>();
@@ -309,10 +362,10 @@ export function useStudioFlow({
309
362
  if (id === doc.clientID || !state.moving) return;
310
363
  Object.entries(state.moving as Record<string, XYPosition>).forEach(([nodeId, position]) => next.set(nodeId, position));
311
364
  });
312
- 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);
313
366
  const dropped = [...remoteMoves.keys()].filter((id) => !next.has(id));
314
367
  remoteMoves = next;
315
- if (moved.size) setNodes((local) => withPositions(local, moved));
368
+ if (moved || dropped.length) glide.set(next);
316
369
  if (dropped.length === 0) return;
317
370
  // The drop is saved in the document. If it doesn't arrive, back to where the document has it.
318
371
  const settle = setTimeout(() => {
@@ -327,6 +380,40 @@ export function useStudioFlow({
327
380
  };
328
381
  awareness.on('change', syncMoves);
329
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
+
330
417
  // Only what changed is replaced, so React Flow re-renders just those nodes
331
418
  const syncNodes = (event: Y.YMapEvent<Node>) => {
332
419
  if (event.transaction.origin === LOCAL_ORIGIN) return;
@@ -362,12 +449,15 @@ export function useStudioFlow({
362
449
  window.removeEventListener('online', reconnect);
363
450
  document.removeEventListener('visibilitychange', onVisibilityChange);
364
451
  awareness.off('change', syncMoves);
452
+ awareness.off('change', syncEdits);
453
+ glide.stop();
365
454
  sender.destroy();
366
455
  stopToolSync?.();
367
456
  yNodes.unobserve(syncNodes);
368
457
  yLevelLayouts.unobserve(syncLevelLayouts);
369
458
  yEdges.unobserve(syncEdges);
370
459
  yMeta.unobserve(syncMeta);
460
+ yHotspots.unobserve(syncHotspots);
371
461
  doc.off('afterTransaction', reopenOnEdit);
372
462
  doc.off('afterTransaction', syncStructure);
373
463
  presenceStore.destroy();
@@ -535,11 +625,11 @@ export function useStudioFlow({
535
625
  []
536
626
  );
537
627
 
538
- /** Add a node of a registry type, centred on a canvas position (in a container, if asked) */
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) */
539
629
  const addNode = useCallback(
540
- (type: string, center: XYPosition, inside?: string) => {
541
- const definition = getNodeDefinition(type);
542
- return definition ? insertNode(type, definition.createData(), center, undefined, inside) : 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;
543
633
  },
544
634
  [insertNode]
545
635
  );
@@ -575,13 +665,12 @@ export function useStudioFlow({
575
665
  []
576
666
  );
577
667
 
578
- /** Puts a node in a container (or takes it out), e.g. after it's dragged there */
579
- const setParent = useCallback(
580
- (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 }[]) =>
581
671
  withDoc((doc) => {
582
- // Where it was dropped goes first, so it's moved into (or out of) the container from there
583
672
  if (dragged.current.size) saveDrop(doc);
584
- return setNodeParent(doc, nodeId, parentId, renderedSizes());
673
+ return setNodeParents(doc, moves, renderedSizes());
585
674
  }),
586
675
  []
587
676
  );
@@ -605,6 +694,47 @@ export function useStudioFlow({
605
694
  sharedRef.current?.sender.send({ viewport });
606
695
  }, []);
607
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
+
608
738
  return {
609
739
  nodes,
610
740
  edges,
@@ -625,7 +755,7 @@ export function useStudioFlow({
625
755
  levelLayouts,
626
756
  setLevelLayout,
627
757
  insertNodes,
628
- setParent,
758
+ setParents,
629
759
  title,
630
760
  diagramLevels,
631
761
  structureKey,
@@ -634,6 +764,15 @@ export function useStudioFlow({
634
764
  changeStatus,
635
765
  transport,
636
766
  deleted,
767
+ hotspots,
768
+ createHotspot,
769
+ updateHotspot,
770
+ deleteHotspot,
771
+ lockedBy,
772
+ setLocked,
773
+ sendEditing,
774
+ synced,
775
+ addContent,
637
776
  undo,
638
777
  redo,
639
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
+ };
@@ -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,10 +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, type CanvasStatus } from '../canvas-doc';
7
+ import { CANVAS_STATUSES, CANVAS_STATUS_LOOK, STATUS, type CanvasStatus } from '@eventcatalog/visualiser/studio';
8
8
  import { MAX_TITLE_LENGTH } from '../limits';
9
9
  import { MAX_NAME_LENGTH } from '../identity';
10
- import { CANVAS_STATUS_LOOK, STATUS } from '../components/status';
11
10
  import { CANVASES_API_PATH } from '../api/canvases-api';
12
11
  import { startStudio, type CanvasSummary } from '../server/runtime';
13
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
  },
@@ -5,17 +5,15 @@ 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
8
  addCanvasContent,
10
- canvasDocumentName,
11
- canvasIdFromDocumentName,
12
9
  getCanvasMaps,
13
10
  readMeta,
14
11
  setMeta,
15
12
  getCanvasStatus,
16
13
  type CanvasContent,
17
14
  type CanvasStatus,
18
- } from '../canvas-doc';
15
+ } from '@eventcatalog/visualiser/studio';
16
+ import { CANVAS_DELETED_REASON, canvasDocumentName, canvasIdFromDocumentName } from '../document-name';
19
17
  import { getCanvasPreview, type CanvasPreview } from '../canvas-preview';
20
18
  import { MAX_TITLE_LENGTH } from '../limits';
21
19
  import { fromBase64, toBase64, type SyncRequest, type SyncResponse } from '../tool-sync';
@@ -33,7 +31,8 @@ const AGENT_PRESENCE_TTL_MS = 25_000;
33
31
  // Application close codes are 4000-4999
34
32
  const CANVAS_DELETED_CLOSE_CODE = 4410;
35
33
 
36
- export type AgentIdentity = { name: string; color: string };
34
+ import type { AgentIdentity } from '../agents';
35
+ export type { AgentIdentity };
37
36
  export type CanvasSummary = {
38
37
  canvasId: string;
39
38
  title?: string;
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "license": "BUSL-1.1",
9
9
  "type": "module",
10
- "version": "5.0.0-beta.12",
10
+ "version": "5.0.0-beta.13",
11
11
  "publishConfig": {
12
12
  "access": "public"
13
13
  },
@@ -141,8 +141,8 @@
141
141
  "yjs": "^13.6.33",
142
142
  "zod": "^4.3.6",
143
143
  "@eventcatalog/linter": "1.2.0-beta.2",
144
- "@eventcatalog/sdk": "2.30.0-beta.1",
145
- "@eventcatalog/visualiser": "^5.0.0-beta.5"
144
+ "@eventcatalog/visualiser": "^5.0.0-beta.6",
145
+ "@eventcatalog/sdk": "2.30.0-beta.1"
146
146
  },
147
147
  "devDependencies": {
148
148
  "@astrojs/check": "^0.9.10",