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

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 (172) hide show
  1. package/LICENSE +227 -128
  2. package/README.md +4 -4
  3. package/dist/analytics/analytics.cjs +18 -3
  4. package/dist/analytics/analytics.d.cts +1 -1
  5. package/dist/analytics/analytics.d.ts +1 -1
  6. package/dist/analytics/analytics.js +3 -2
  7. package/dist/analytics/log-build.cjs +71 -35
  8. package/dist/analytics/log-build.js +5 -4
  9. package/dist/analytics/telemetry.cjs +42 -0
  10. package/dist/analytics/telemetry.d.cts +20 -0
  11. package/dist/analytics/telemetry.d.ts +20 -0
  12. package/dist/analytics/telemetry.js +8 -0
  13. package/dist/{chunk-KYJZJLBD.js → chunk-4YFM63DS.js} +9 -3
  14. package/dist/{chunk-UDS3SYLZ.js → chunk-76DAAJLV.js} +56 -33
  15. package/dist/{chunk-TQLRG35T.js → chunk-AWOO63ME.js} +71 -45
  16. package/dist/chunk-CPW6G62V.js +17 -0
  17. package/dist/{chunk-K2ACDO7Y.js → chunk-LQZ7IG7B.js} +1 -1
  18. package/dist/{chunk-S2QGT7ZF.js → chunk-MFEMKQZ6.js} +1 -1
  19. package/dist/{chunk-Q6SD5SNQ.js → chunk-WM4QYMIA.js} +1 -1
  20. package/dist/constants.cjs +1 -1
  21. package/dist/constants.js +1 -1
  22. package/dist/eventcatalog.cjs +204 -124
  23. package/dist/eventcatalog.config.d.cts +59 -1
  24. package/dist/eventcatalog.config.d.ts +59 -1
  25. package/dist/eventcatalog.js +30 -11
  26. package/dist/federation/federate.js +2 -2
  27. package/dist/generate.cjs +1 -1
  28. package/dist/generate.js +3 -3
  29. package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
  30. package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
  31. package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
  32. package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
  33. package/dist/runtime-dependencies/manifest.json +8 -1
  34. package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
  35. package/dist/runtime-dependencies/y-protocols.mjs +3 -0
  36. package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
  37. package/dist/runtime-dependencies/yjs.mjs +3 -0
  38. package/dist/utils/cli-logger.cjs +1 -1
  39. package/dist/utils/cli-logger.js +2 -2
  40. package/dist/utils/license-status.cjs +71 -45
  41. package/dist/utils/license-status.d.cts +14 -5
  42. package/dist/utils/license-status.d.ts +14 -5
  43. package/dist/utils/license-status.js +1 -1
  44. package/eventcatalog/astro.config.mjs +5 -0
  45. package/eventcatalog/integrations/storage.mjs +20 -0
  46. package/eventcatalog/integrations/studio-server.mjs +32 -0
  47. package/eventcatalog/src/components/Grids/utils.tsx +0 -23
  48. package/eventcatalog/src/components/Header.astro +3 -3
  49. package/eventcatalog/src/components/MDX/CodeGroup/CodeGroup.tsx +6 -1
  50. package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +12 -4
  51. package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +16 -7
  52. package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +10 -0
  53. package/eventcatalog/src/components/Tables/Discover/DiscoverTable.tsx +4 -62
  54. package/eventcatalog/src/components/Tables/Discover/columns.tsx +17 -150
  55. package/eventcatalog/src/components/Tables/Resources/ResourcesTable.tsx +425 -0
  56. package/eventcatalog/src/components/Tables/RowActionsMenu.tsx +157 -0
  57. package/eventcatalog/src/components/Tables/TablePagination.tsx +82 -0
  58. package/eventcatalog/src/features/ai/chat-api.ts +3 -3
  59. package/eventcatalog/src/features/auth/middleware/middleware-auth.ts +10 -0
  60. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +34 -1
  61. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +47 -0
  62. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
  63. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +276 -0
  64. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +210 -0
  65. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +75 -74
  66. package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
  67. package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
  68. package/eventcatalog/src/features/mcp/mcp-server.ts +36 -5
  69. package/eventcatalog/src/features/server/start.ts +25 -0
  70. package/eventcatalog/src/features/storage/database.ts +88 -0
  71. package/eventcatalog/src/features/storage/migrations.ts +85 -0
  72. package/eventcatalog/src/features/storage/sql/database.sql +5 -0
  73. package/eventcatalog/src/features/storage/sql/migrations/0001-studio-canvases.sql +12 -0
  74. package/eventcatalog/src/features/storage/sql/migrations.sql +15 -0
  75. package/eventcatalog/src/features/storage/sql.ts +28 -0
  76. package/eventcatalog/src/features/studio/agent-choreography.ts +381 -0
  77. package/eventcatalog/src/features/studio/api/canvases-api.ts +170 -0
  78. package/eventcatalog/src/features/studio/api/canvases.ts +25 -0
  79. package/eventcatalog/src/features/studio/api/client.ts +35 -0
  80. package/eventcatalog/src/features/studio/canvas-actions.ts +414 -0
  81. package/eventcatalog/src/features/studio/canvas-doc.ts +414 -0
  82. package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
  83. package/eventcatalog/src/features/studio/catalog-resources.ts +155 -0
  84. package/eventcatalog/src/features/studio/catalog.ts +171 -0
  85. package/eventcatalog/src/features/studio/clipboard.ts +97 -0
  86. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +154 -0
  87. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +370 -0
  88. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
  89. package/eventcatalog/src/features/studio/components/Comments.tsx +770 -0
  90. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +281 -0
  91. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
  92. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
  93. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +81 -0
  94. package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
  95. package/eventcatalog/src/features/studio/components/JoinForm.tsx +34 -0
  96. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +314 -0
  97. package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
  98. package/eventcatalog/src/features/studio/components/Presence.tsx +286 -0
  99. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +212 -0
  100. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
  101. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1216 -0
  102. package/eventcatalog/src/features/studio/components/StudioNotice.astro +24 -0
  103. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +255 -0
  104. package/eventcatalog/src/features/studio/components/icons.ts +53 -0
  105. package/eventcatalog/src/features/studio/components/status.ts +53 -0
  106. package/eventcatalog/src/features/studio/edges.ts +56 -0
  107. package/eventcatalog/src/features/studio/flow-state.ts +92 -0
  108. package/eventcatalog/src/features/studio/grouping.ts +226 -0
  109. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
  110. package/eventcatalog/src/features/studio/hooks/presence-store.ts +119 -0
  111. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +347 -0
  112. package/eventcatalog/src/features/studio/hooks/use-comments.ts +54 -0
  113. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +104 -0
  114. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +589 -0
  115. package/eventcatalog/src/features/studio/identity.ts +37 -0
  116. package/eventcatalog/src/features/studio/layout.ts +42 -0
  117. package/eventcatalog/src/features/studio/levels.ts +282 -0
  118. package/eventcatalog/src/features/studio/node-types.ts +205 -0
  119. package/eventcatalog/src/features/studio/pages/[id].astro +68 -0
  120. package/eventcatalog/src/features/studio/pages/index.astro +784 -0
  121. package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
  122. package/eventcatalog/src/features/studio/placement.ts +197 -0
  123. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +510 -0
  124. package/eventcatalog/src/features/studio/server/runtime.ts +491 -0
  125. package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
  126. package/eventcatalog/src/features/studio/server/sql/storage.sql +18 -0
  127. package/eventcatalog/src/features/studio/server/storage.ts +90 -0
  128. package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
  129. package/eventcatalog/src/features/studio/studio.css +273 -0
  130. package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
  131. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +10 -5
  132. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.spec.ts +52 -63
  133. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +34 -46
  134. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +11 -29
  135. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_ResourcesPage.astro +79 -0
  136. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.spec.ts +32 -14
  137. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.ts +63 -58
  138. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +2 -62
  139. package/eventcatalog/src/pages/docs/services/[id]/[version]/resources/index.astro +13 -0
  140. package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +24 -42
  141. package/eventcatalog/src/stores/sidebar-store/builders/flow.ts +2 -0
  142. package/eventcatalog/src/stores/sidebar-store/builders/message.ts +2 -0
  143. package/eventcatalog/src/stores/sidebar-store/builders/service.ts +23 -23
  144. package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +13 -0
  145. package/eventcatalog/src/stores/sidebar-store/builders/system.ts +18 -27
  146. package/eventcatalog/src/stores/sidebar-store/state.ts +5 -0
  147. package/eventcatalog/src/styles/tailwind.css +2 -2
  148. package/eventcatalog/src/utils/collections/channels.ts +56 -1
  149. package/eventcatalog/src/utils/collections/flows.ts +50 -1
  150. package/eventcatalog/src/utils/collections/message-connections.ts +60 -0
  151. package/eventcatalog/src/utils/collections/message-triggers.ts +2 -1
  152. package/eventcatalog/src/utils/collections/resource-owners.ts +193 -0
  153. package/eventcatalog/src/utils/collections/resources.ts +310 -0
  154. package/eventcatalog/src/utils/collections/util.ts +31 -0
  155. package/eventcatalog/src/utils/feature.ts +8 -0
  156. package/eventcatalog/src/utils/git.ts +11 -5
  157. package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
  158. package/eventcatalog/src/utils/trial.ts +1 -1
  159. package/package.json +17 -12
  160. package/dist/runtime-dependencies/shelljs.mjs +0 -3
  161. package/eventcatalog/public/studio-bg.png +0 -0
  162. package/eventcatalog/src/components/Grids/DomainGrid.tsx +0 -838
  163. package/eventcatalog/src/components/Grids/MessageGrid.tsx +0 -344
  164. package/eventcatalog/src/components/Grids/SystemGrid.tsx +0 -185
  165. package/eventcatalog/src/components/Grids/components.tsx +0 -170
  166. package/eventcatalog/src/components/Grids/message-link.ts +0 -38
  167. package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
  168. package/eventcatalog/src/components/Tables/SystemResources/SystemResourcesTable.tsx +0 -294
  169. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.spec.ts +0 -65
  170. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.ts +0 -46
  171. package/eventcatalog/src/pages/studio.astro +0 -316
  172. package/dist/{chunk-AYNZXUB5.js → chunk-KHACCTW2.js} +3 -3
@@ -0,0 +1,1216 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent as ReactMouseEvent } from 'react';
2
+ import {
3
+ Background,
4
+ ConnectionLineType,
5
+ ReactFlow,
6
+ ReactFlowProvider,
7
+ useReactFlow,
8
+ type Edge,
9
+ type Node,
10
+ type NodeChange,
11
+ type XYPosition,
12
+ } from '@xyflow/react';
13
+ import { edgeTypes } from '@eventcatalog/visualiser';
14
+ import { useHotkeys } from 'react-hotkeys-hook';
15
+ import { getCatalogConnections, indexCatalog, planWithConnections, type ConnectionGroup } from '../canvas-actions';
16
+ import { isDecided } from '../canvas-doc';
17
+ import { CLIPBOARD_TYPE, copyNodes, pasteNodes, readClipboard, type ClipboardContent } from '../clipboard';
18
+ import type { CatalogRelation, CatalogResource } from '../catalog-resources';
19
+ import {
20
+ buildContainer,
21
+ buildContainerWithContents,
22
+ canBeContainer,
23
+ catalogNodeData,
24
+ getCatalogLink,
25
+ getContents,
26
+ getRelatedEdges,
27
+ } from '../catalog';
28
+ import {
29
+ findDropTarget,
30
+ findGroupAtPoint,
31
+ getAbsolutePosition,
32
+ previewContainerGrowth,
33
+ sizeOf,
34
+ sortByHierarchy,
35
+ toRelativePosition,
36
+ } from '../grouping';
37
+ import type { Peer } from '../hooks/presence-store';
38
+ import { useCanvasWebMcp } from '../hooks/use-canvas-webmcp';
39
+ import { useStudioFlow } from '../hooks/use-studio-flow';
40
+ import { useComments, type CommentAnchor, type Thread } from '../hooks/use-comments';
41
+ import { getLayoutPositions, layoutGraph } from '../layout';
42
+ import { getLevelGraph, getLevelUnavailableReason, LEVELS, type Level } from '../levels';
43
+ import { getNodeDefinition, getNodeLabel, getNodeName, getNoteLevel, isOnLevel, isWrittenOnCanvas } from '../node-types';
44
+ import type { ToolSync } from '../tool-sync';
45
+ import CanvasControls, { FIT_VIEW_OPTIONS } from './CanvasControls';
46
+ import CanvasHeader from './CanvasHeader';
47
+ import { DropTargetContext, nodeTypes, UpdateNodeDataContext } from './canvas-nodes';
48
+ import { anchorAt, CanvasContextMenu, CommentLayer, getAnchorPosition, timeAgo, type ContextMenuState } from './Comments';
49
+ import { CANVAS_STATUS_LOOK } from './status';
50
+ import ConnectAgentDialog, { enableWebMcp, isRelayEnabled, isWebMcpEnabled, loadRelay } from './ConnectAgentDialog';
51
+ import ContainerChooser, { type ContainerChoice } from './ContainerChooser';
52
+ import ConnectionsChooser, { type ConnectionsChoice } from './ConnectionsChooser';
53
+ import JoinForm from './JoinForm';
54
+ import { colorForName, getStoredName, onStoredNameChange, storeName } from '../identity';
55
+ import LeftPanel, { CATALOG_DRAG_TYPE, COMPONENT_DRAG_TYPE } from './LeftPanel';
56
+ import { AgentActivity, RemotePresence, ViewportSharer } from './Presence';
57
+ import PropertiesPanel from './PropertiesPanel';
58
+ import ShareDialog from './ShareDialog';
59
+ import DeleteCanvasDialog from './DeleteCanvasDialog';
60
+ import { deleteCanvasThroughApi } from '../api/client';
61
+
62
+ type CanvasProps = {
63
+ canvasId: string;
64
+ /** Path of the collaboration socket on this site, or `socketUrl` when embedded elsewhere (e.g. an MCP App) */
65
+ socketPath?: string;
66
+ socketUrl?: string;
67
+ /** Where "New canvas" goes (hidden when not given, e.g. inside a chat) */
68
+ newCanvasUrl?: string;
69
+ /** The Studio page (where you go after deleting the canvas, or finding it deleted) */
70
+ studioUrl?: string;
71
+ /** The Studio API's canvases on this site: deleting the canvas is offered when given (not inside a chat) */
72
+ canvasesApiUrl?: string;
73
+ /** The canvas's link, for Share (defaults to this page) */
74
+ shareUrl?: string;
75
+ /** The EventCatalog MCP server agents connect to (a path on this site, or a URL) */
76
+ mcpUrl?: string;
77
+ /** The WebMCP local relay's script, when this page can offer it (not inside a chat) */
78
+ relayScriptPath?: string;
79
+ /** Canvases are only kept in memory, until the server restarts (`storage` is "memory") */
80
+ keptInMemory?: boolean;
81
+ /** Sync through MCP tool calls when the WebSocket can't connect (e.g. inside a chat's sandbox) */
82
+ syncViaTools?: ToolSync;
83
+ /** Follow agents' changes with the camera from the start (e.g. inside a chat, where you watch an agent work) */
84
+ followChanges?: boolean;
85
+ /** Told what's selected (e.g. a chat attaches it to the conversation, so you can ask about it) */
86
+ onSelectionChange?: (selected: SelectedNode[]) => void;
87
+ /** Join with this name instead of asking for one (e.g. inside a chat, where the agent or host knows who it is) */
88
+ defaultName?: string;
89
+ /** Signed in with the catalog's sign-in (SSO): you're shown by this name and picture, and can't change them */
90
+ signedInAs?: { name: string; picture?: string };
91
+ resources: CatalogResource[];
92
+ relations: CatalogRelation[];
93
+ };
94
+ /** A selected node, as told to whoever embeds the canvas */
95
+ export type SelectedNode = { id: string; name: string; type: string; catalogResource?: string };
96
+
97
+ /** How long a level (L1, L2) waits for changes to settle before it's laid out again */
98
+ const LEVEL_RELAYOUT_MS = 300;
99
+ const DELETE_KEYS = ['Backspace', 'Delete'];
100
+ const LEVEL_FIT_VIEW_OPTIONS = { ...FIT_VIEW_OPTIONS, maxZoom: 1 };
101
+ const NO_THREADS: ReadonlySet<string> = new Set();
102
+ // React Flow's warnings, except that a handle has no node: the left panel's drag previews draw nodes off the canvas
103
+ const onFlowError = (code: string, message: string) => {
104
+ if (code !== '010') console.warn(message);
105
+ };
106
+ const MULTI_SELECTION_KEYS = ['Meta', 'Shift'];
107
+ /**
108
+ * Like design tools: dragging on the canvas draws a box to select what's inside it, and the canvas pans with
109
+ * space + drag, the middle mouse button, or scrolling (two fingers on a trackpad). Pinch, or cmd / ctrl + scroll,
110
+ * zooms. The right button stays for the context menu.
111
+ */
112
+ const PAN_BUTTONS = [1];
113
+ /** How close to a connection's end you grab it to move it */
114
+ const RECONNECT_RADIUS = 14;
115
+ const ZOOM_KEYS = ['Meta', 'Control'];
116
+ const DEFAULT_SOCKET_PATH = '/_eventcatalog/studio';
117
+
118
+ /** A function that keeps its identity across renders and always calls the latest version (for React Flow's props) */
119
+ const useStableCallback = <Args extends unknown[], Result>(fn: (...args: Args) => Result) => {
120
+ const latest = useRef(fn);
121
+ latest.current = fn;
122
+ return useCallback((...args: Args) => latest.current(...args), []);
123
+ };
124
+
125
+ // A message edge drawn as a plain labelled edge (kept per edge, so React Flow sees the same edge until it changes)
126
+ const staticEdges = new WeakMap<Edge, Edge>();
127
+ const withoutEnvelope = (edge: Edge) => {
128
+ let drawn = staticEdges.get(edge);
129
+ if (!drawn) staticEdges.set(edge, (drawn = { ...edge, type: 'smoothstep' }));
130
+ return drawn;
131
+ };
132
+
133
+ /** Ids, types and containers: what changes when nodes are added, removed or moved in or out of containers */
134
+ // Notes on L1 and L2 can be moved and selected there, unlike the rest of the level (made once per note, so a note
135
+ // keeps its identity until it changes)
136
+ const levelNotes = new WeakMap<Node, Node>();
137
+ const asLevelNote = (node: Node) => {
138
+ let note = levelNotes.get(node);
139
+ if (!note) levelNotes.set(node, (note = { ...node, draggable: true, selectable: true }));
140
+ return note;
141
+ };
142
+
143
+ const structureOf = (nodes: Node[]) => nodes.map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}`).join('|');
144
+
145
+ export default function StudioDesigner({ defaultName, signedInAs, ...props }: CanvasProps) {
146
+ const [name, setName] = useState(() => getStoredName() ?? defaultName ?? null);
147
+
148
+ const saveName = useCallback((next: string) => {
149
+ setName(next);
150
+ storeName(next);
151
+ }, []);
152
+ // Renamed on the Studio page or another canvas in this browser
153
+ useEffect(() => onStoredNameChange((next) => next && setName(next)), []);
154
+
155
+ if (signedInAs) {
156
+ return (
157
+ <ReactFlowProvider>
158
+ <Canvas {...props} name={signedInAs.name} picture={signedInAs.picture} />
159
+ </ReactFlowProvider>
160
+ );
161
+ }
162
+ if (!name) return <JoinForm onJoin={saveName} />;
163
+
164
+ return (
165
+ <ReactFlowProvider>
166
+ <Canvas {...props} name={name} onRename={saveName} />
167
+ </ReactFlowProvider>
168
+ );
169
+ }
170
+
171
+ const toSocketUrl = (socketPath = DEFAULT_SOCKET_PATH) =>
172
+ `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}${socketPath}`;
173
+
174
+ function Canvas({
175
+ canvasId,
176
+ socketPath,
177
+ socketUrl,
178
+ newCanvasUrl,
179
+ studioUrl,
180
+ canvasesApiUrl,
181
+ shareUrl,
182
+ mcpUrl = '/docs/mcp',
183
+ relayScriptPath,
184
+ keptInMemory = false,
185
+ syncViaTools,
186
+ followChanges = false,
187
+ onSelectionChange,
188
+ resources,
189
+ relations,
190
+ name,
191
+ picture,
192
+ onRename,
193
+ }: Omit<CanvasProps, 'defaultName' | 'signedInAs'> & {
194
+ name: string;
195
+ picture?: string;
196
+ /** Not given when you're signed in (your name is the one you signed in with) */
197
+ onRename?: (name: string) => void;
198
+ }) {
199
+ const color = colorForName(name);
200
+ const canvasRef = useRef<HTMLDivElement>(null);
201
+
202
+ // Nodes glide to their new place after a layout (anyone's) or a level change (not while editing: it would make dragging lag)
203
+ const [transitioning, setTransitioning] = useState(false);
204
+ const transitionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
205
+ const glide = useCallback(() => {
206
+ setTransitioning(true);
207
+ clearTimeout(transitionTimer.current);
208
+ transitionTimer.current = setTimeout(() => setTransitioning(false), 600);
209
+ }, []);
210
+
211
+ // Containers grow smoothly when they're fitted to what's in them (or someone else resizes them). Set on the
212
+ // element rather than in state, so it doesn't re-render the canvas.
213
+ const resizeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
214
+ const animateContainers = useCallback(() => {
215
+ canvasRef.current?.classList.add('is-container-resize');
216
+ clearTimeout(resizeTimer.current);
217
+ resizeTimer.current = setTimeout(() => canvasRef.current?.classList.remove('is-container-resize'), 400);
218
+ }, []);
219
+
220
+ const flow = useStudioFlow({
221
+ canvasId,
222
+ socketUrl: socketUrl ?? toSocketUrl(socketPath),
223
+ name,
224
+ color,
225
+ picture,
226
+ syncViaTools,
227
+ onLayout: glide,
228
+ onContainerResize: animateContainers,
229
+ });
230
+ const author = useMemo(() => ({ name, color, ...(picture && { picture }) }), [name, color, picture]);
231
+ const comments = useComments(flow.doc, author);
232
+ const { screenToFlowPosition, flowToScreenPosition, setCenter, getZoom, fitView } = useReactFlow();
233
+
234
+ const catalog = useMemo(() => indexCatalog(resources, relations), [resources, relations]);
235
+
236
+ const nodesRef = useRef(flow.nodes);
237
+ nodesRef.current = flow.nodes;
238
+ const edgesRef = useRef(flow.edges);
239
+ edgesRef.current = flow.edges;
240
+ const lookupNodes = () => new Map(nodesRef.current.map((node) => [node.id, node]));
241
+
242
+ // Agents in the browser (WebMCP) work on this canvas through the page, as the user's agent. Browsers without
243
+ // WebMCP of their own get it through the polyfill once someone turns it on (Connect agent).
244
+ const [webMcpEnabled, setWebMcpEnabled] = useState(isWebMcpEnabled);
245
+ const turnOnWebMcp = useCallback(() => {
246
+ enableWebMcp();
247
+ setWebMcpEnabled(true);
248
+ }, []);
249
+ const webMcp = useCanvasWebMcp({
250
+ allowPolyfill: webMcpEnabled,
251
+ doc: flow.doc,
252
+ presence: flow.presence,
253
+ getPeers: () => flow.presence?.getPeers() ?? [],
254
+ resources,
255
+ catalog,
256
+ agent: { name: `${name}'s agent`, color, agent: true },
257
+ select: (ids) =>
258
+ flow.onNodesChange(nodesRef.current.map((node) => ({ type: 'select', id: node.id, selected: ids.includes(node.id) }))),
259
+ fitView: (ids) => void fitView({ nodes: ids?.map((id) => ({ id })), duration: 400, padding: 0.3, maxZoom: 1.2 }),
260
+ reorder: () => reorder(),
261
+ });
262
+
263
+ // Recomputed only when nodes are added, removed or change container, not as they move
264
+ const structure = structureOf(flow.nodes);
265
+ const catalogKeys = flow.nodes
266
+ .map((node) => getCatalogLink(node)?.key)
267
+ .filter(Boolean)
268
+ .join('\n');
269
+ const keysOnCanvas = useMemo(() => new Set(catalogKeys ? catalogKeys.split('\n') : []), [catalogKeys]);
270
+
271
+ /** Middle of the visible canvas, spread out so repeated adds don't land on top of each other */
272
+ const canvasCenter = () => {
273
+ const rect = canvasRef.current!.getBoundingClientRect();
274
+ const step = nodesRef.current.length % 6;
275
+ const offset = { x: ((step % 3) - 1) * 240, y: (Math.floor(step / 3) - 0.5) * 160 };
276
+ return screenToFlowPosition({ x: rect.left + rect.width / 2 + offset.x, y: rect.top + rect.height / 2 + offset.y });
277
+ };
278
+
279
+ // ---- Adding things ----
280
+
281
+ // A domain or system dropped on the canvas: asked whether it's a card or a container first
282
+ const [chooser, setChooser] = useState<{ resource: CatalogResource; center: XYPosition } | null>(null);
283
+ // A service or message that connects to things not on the canvas yet: asked whether to bring them too
284
+ const [connectionsChooser, setConnectionsChooser] = useState<{
285
+ resource: CatalogResource;
286
+ center: XYPosition;
287
+ groups: ConnectionGroup[];
288
+ } | null>(null);
289
+
290
+ // A catalog resource is added with edges to the related resources already on the canvas
291
+ const addResource = useStableCallback((key: string, center: XYPosition = canvasCenter()) => {
292
+ const resource = catalog.resourcesByKey.get(key);
293
+ if (!resource) return;
294
+ if (canBeContainer(resource)) return setChooser({ resource, center });
295
+ const groups = getCatalogConnections(resource, catalog, keysOnCanvas);
296
+ if (groups.length > 0) return setConnectionsChooser({ resource, center, groups });
297
+ addCard(resource, center);
298
+ });
299
+
300
+ const addCard = (resource: CatalogResource, center: XYPosition) =>
301
+ flow.insertNode(resource.node.type, catalogNodeData(resource), center, (id, existing) =>
302
+ getRelatedEdges(resource, id, existing, catalog.relationsByKey)
303
+ );
304
+
305
+ // Nodes waiting to be shown before the canvas is laid out around them
306
+ const reorderOnceShown = useRef<string[] | null>(null);
307
+ useEffect(() => {
308
+ const waiting = reorderOnceShown.current;
309
+ if (!waiting) return;
310
+ const shown = new Set(flow.nodes.map((node) => node.id));
311
+ if (!waiting.every((id) => shown.has(id))) return;
312
+ reorderOnceShown.current = null;
313
+ // A frame later, so they've been measured
314
+ requestAnimationFrame(() => void reorder());
315
+ }, [flow.nodes]);
316
+
317
+ /** A domain or system as a container, with everything it holds in the catalog inside if asked */
318
+ const addContainer = async (resource: CatalogResource, center: XYPosition, withContents: boolean) => {
319
+ const existing = nodesRef.current;
320
+ const built = withContents
321
+ ? await buildContainerWithContents(resource, center, catalog, existing)
322
+ : { nodes: [buildContainer(resource, center)], edges: [] };
323
+ // Dropped in another container (e.g. a system in its domain): it goes in it
324
+ const outer = findGroupAtPoint(center, existing);
325
+ const nodes = outer
326
+ ? built.nodes.map((node) =>
327
+ node.parentId ? node : { ...node, parentId: outer.id, position: toRelativePosition(node.position, outer.id, existing) }
328
+ )
329
+ : built.nodes;
330
+ // With its contents, the canvas is laid out again to make room for it all
331
+ if (withContents && existing.length > 0) reorderOnceShown.current = nodes.map((node) => node.id);
332
+ flow.insertNodes(nodes, built.edges);
333
+ };
334
+
335
+ const choose = useStableCallback((choice: ContainerChoice) => {
336
+ if (!chooser) return;
337
+ if (choice.as === 'card') addCard(chooser.resource, chooser.center);
338
+ else void addContainer(chooser.resource, chooser.center, choice.withContents);
339
+ setChooser(null);
340
+ });
341
+ const cancelChooser = useCallback(() => setChooser(null), []);
342
+
343
+ const chooseConnections = useStableCallback((choice: ConnectionsChoice) => {
344
+ if (!connectionsChooser) return;
345
+ const { resource, center } = connectionsChooser;
346
+ if (choice.as === 'card') addCard(resource, center);
347
+ else {
348
+ const { nodes, edges } = planWithConnections(nodesRef.current, resource, center, choice.include, catalog);
349
+ flow.insertNodes(nodes, edges);
350
+ }
351
+ setConnectionsChooser(null);
352
+ });
353
+ const cancelConnectionsChooser = useCallback(() => setConnectionsChooser(null), []);
354
+
355
+ // What a domain or system dropped as a container would bring with it
356
+ const chooserContents = useMemo(() => {
357
+ if (!chooser) return { systems: 0, services: 0 };
358
+ const inside = (resource: CatalogResource): CatalogResource[] =>
359
+ getContents(resource, catalog).flatMap((child) => [child, ...(canBeContainer(child) ? inside(child) : [])]);
360
+ const missing = inside(chooser.resource).filter((child) => !keysOnCanvas.has(child.key));
361
+ return {
362
+ systems: missing.filter((child) => child.collection === 'systems').length,
363
+ services: missing.filter((child) => child.collection === 'services').length,
364
+ };
365
+ }, [chooser, catalog, keysOnCanvas]);
366
+
367
+ /** A sticky note on the level shown (it's only shown on that level) */
368
+ const addNoteAt = (center: XYPosition) => {
369
+ const data = getNodeDefinition('note')!.createData();
370
+ flow.insertNode('note', levelRef.current === 3 ? data : { ...data, level: levelRef.current }, center);
371
+ };
372
+ /**
373
+ * Where something added while L1 or L2 is shown goes: L1 and L2 are views of the canvas people edit (L3), so it's
374
+ * added there (shown on L3), below what's on it
375
+ */
376
+ const centerOnCanvas = (center: XYPosition) => {
377
+ if (levelRef.current === 3) return center;
378
+ changeLevel(3);
379
+ const roots = nodesRef.current.filter((node) => !node.parentId && isOnLevel(node, 3));
380
+ if (roots.length === 0) return { x: 0, y: 0 };
381
+ const left = Math.min(...roots.map((node) => node.position.x));
382
+ const right = Math.max(...roots.map((node) => node.position.x + sizeOf(node).width));
383
+ const bottom = Math.max(...roots.map((node) => node.position.y + sizeOf(node).height));
384
+ return { x: (left + right) / 2, y: bottom + 160 };
385
+ };
386
+ const addComponentAt = (type: string, center: XYPosition) =>
387
+ type === 'note' ? addNoteAt(center) : flow.addNode(type, centerOnCanvas(center));
388
+
389
+ const addComponent = useStableCallback((type: string) => addComponentAt(type, canvasCenter()));
390
+ const addNote = useStableCallback(() => addNoteAt(canvasCenter()));
391
+ const addResourceFromPanel = useStableCallback((key: string) => addResource(key, centerOnCanvas(canvasCenter())));
392
+
393
+ const onDrop = useStableCallback((event: DragEvent) => {
394
+ event.preventDefault();
395
+ const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
396
+ const type = event.dataTransfer.getData(COMPONENT_DRAG_TYPE);
397
+ const resourceKey = event.dataTransfer.getData(CATALOG_DRAG_TYPE);
398
+ if (type) addComponentAt(type, position);
399
+ if (resourceKey) addResource(resourceKey, centerOnCanvas(position));
400
+ });
401
+
402
+ // ---- Pointer, panning and dragging ----
403
+
404
+ // React Flow's node drag and panning (d3) stop mousemove from propagating, so follow the
405
+ // pointer with pointer events, and with dragover while something is dragged in from the palette
406
+ // Where the pointer is on the canvas (while it's over it), for pasting there
407
+ const pointerAt = useRef<XYPosition | null>(null);
408
+ const trackPointer = useStableCallback((event: { clientX: number; clientY: number }) => {
409
+ pointerAt.current = screenToFlowPosition({ x: event.clientX, y: event.clientY });
410
+ flow.setPointer(pointerAt.current);
411
+ });
412
+ const clearPointer = useCallback(() => {
413
+ pointerAt.current = null;
414
+ flow.setPointer(null);
415
+ }, [flow.setPointer]);
416
+
417
+ // ---- Copy and paste ----
418
+
419
+ // Pasting again without moving the pointer off the canvas cascades the copies, like design tools do
420
+ const pastes = useRef(0);
421
+ const selectedIds = () => nodesRef.current.filter((node) => node.selected).map((node) => node.id);
422
+ // What was last copied here (for "Paste here" in the context menu, which can't read the clipboard without asking)
423
+ const [lastCopied, setLastCopied] = useState<ClipboardContent | null>(null);
424
+ // Nodes to copy instead of the selection (copying from the context menu)
425
+ const copyOnly = useRef<string[] | null>(null);
426
+
427
+ /** Adds copies (as one change, so one undo takes them back) and selects them instead of what was selected */
428
+ const paste = useStableCallback((content: ClipboardContent, place: { at: XYPosition } | { offset: XYPosition }) => {
429
+ const { nodes, edges } = pasteNodes(content, nodesRef.current, place);
430
+ flow.onNodesChange(selectedIds().map((id) => ({ type: 'select', id, selected: false })));
431
+ flow.insertNodes(nodes, edges);
432
+ flow.onNodesChange(nodes.map((node) => ({ type: 'select', id: node.id, selected: true })));
433
+ });
434
+
435
+ useEffect(() => {
436
+ // Copying, cutting and pasting text in a field (or a note being edited) works as usual
437
+ const isTyping = () => {
438
+ const active = document.activeElement;
439
+ return (
440
+ active instanceof HTMLElement && (active.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(active.tagName))
441
+ );
442
+ };
443
+ // Text selected on the page (e.g. in a panel) is copied as text, like anywhere else
444
+ const hasTextSelected = () => !!window.getSelection()?.toString();
445
+ const copy = (event: ClipboardEvent) => {
446
+ const fromMenu = copyOnly.current !== null;
447
+ const ids = copyOnly.current ?? selectedIds();
448
+ copyOnly.current = null;
449
+ if (levelRef.current !== 3 || isTyping() || !event.clipboardData || (!fromMenu && hasTextSelected())) return;
450
+ const content = copyNodes(nodesRef.current, edgesRef.current, ids);
451
+ if (!content) return;
452
+ event.preventDefault();
453
+ event.clipboardData.setData(CLIPBOARD_TYPE, JSON.stringify(content));
454
+ // Other apps get a list of what was copied
455
+ event.clipboardData.setData('text/plain', content.nodes.map((node) => getNodeName(node.type, node.data)).join('\n'));
456
+ pastes.current = 0;
457
+ setLastCopied(content);
458
+ if (event.type === 'cut') flow.deleteNodes(ids);
459
+ };
460
+ const onPaste = (event: ClipboardEvent) => {
461
+ if (levelRef.current !== 3 || isTyping()) return;
462
+ const content =
463
+ readClipboard(event.clipboardData?.getData(CLIPBOARD_TYPE)) ?? readClipboard(event.clipboardData?.getData('text/plain'));
464
+ if (!content) return;
465
+ event.preventDefault();
466
+ pastes.current += 1;
467
+ const step = 40 * pastes.current;
468
+ paste(content, pointerAt.current ? { at: pointerAt.current } : { offset: { x: step, y: step } });
469
+ };
470
+ document.addEventListener('copy', copy);
471
+ document.addEventListener('cut', copy);
472
+ document.addEventListener('paste', onPaste);
473
+ return () => {
474
+ document.removeEventListener('copy', copy);
475
+ document.removeEventListener('cut', copy);
476
+ document.removeEventListener('paste', onPaste);
477
+ };
478
+ }, [paste, flow.deleteNodes]);
479
+
480
+ const onDragOver = useStableCallback((event: DragEvent) => {
481
+ event.preventDefault();
482
+ event.dataTransfer.dropEffect = 'move';
483
+ trackPointer(event);
484
+ });
485
+
486
+ // While the canvas is panned, zoomed or dragged, message edges stop animating (a class, not state, so nothing re-renders)
487
+ const interactions = useRef({ moving: false, dragging: false });
488
+ const setInteraction = useCallback((kind: 'moving' | 'dragging', on: boolean) => {
489
+ interactions.current[kind] = on;
490
+ canvasRef.current?.classList.toggle('ec-interaction-active', interactions.current.moving || interactions.current.dragging);
491
+ }, []);
492
+
493
+ // The camera follows agents unless people move it themselves (set by AgentActivity)
494
+ const pauseCamera = useRef<() => void>(() => {});
495
+ const onMoveStart = useCallback(
496
+ (event: MouseEvent | TouchEvent | null) => {
497
+ if (event) pauseCamera.current();
498
+ setInteraction('moving', true);
499
+ },
500
+ [setInteraction]
501
+ );
502
+ const onMoveEnd = useCallback(() => setInteraction('moving', false), [setInteraction]);
503
+
504
+ // Dragging something into a container puts it in it, out of one takes it out
505
+ const [dropTargetId, setDropTargetId] = useState<string | null>(null);
506
+ const dragRoots = (dragged: Node[]) => {
507
+ const ids = new Set(dragged.map((node) => node.id));
508
+ return dragged.filter((node) => !node.parentId || !ids.has(node.parentId));
509
+ };
510
+ // While something's dragged, the containers around it grow to fit it (see previewContainerGrowth): the canvas
511
+ // when the drag started, and those containers, innermost first
512
+ const dragStart = useRef<{ nodes: Map<string, Node>; containers: string[] } | null>(null);
513
+ const onNodeDragStart = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
514
+ pauseCamera.current();
515
+ setInteraction('dragging', true);
516
+ const lookup = lookupNodes();
517
+ const containers: string[] = [];
518
+ for (const node of dragged) {
519
+ for (let id = lookup.get(node.id)?.parentId; id; id = lookup.get(id)?.parentId) {
520
+ if (!containers.includes(id)) containers.push(id);
521
+ }
522
+ }
523
+ // Innermost first: deeper containers before the ones they're in
524
+ const depth = (id: string) => {
525
+ let level = 0;
526
+ for (let parent = lookup.get(id)?.parentId; parent; parent = lookup.get(parent)?.parentId) level++;
527
+ return level;
528
+ };
529
+ containers.sort((a, b) => depth(b) - depth(a));
530
+ dragStart.current = containers.length ? { nodes: lookup, containers } : null;
531
+ threadsDrag.current = selectedThreads.size && dragged[0] ? { id: dragged[0].id, from: canvasPositionOf(dragged[0]) } : null;
532
+ });
533
+ // Where a dragged node is on the canvas (its position is relative to its container)
534
+ const canvasPositionOf = (node: Node) => {
535
+ const lookup = lookupNodes();
536
+ lookup.set(node.id, { ...lookup.get(node.id)!, position: node.position });
537
+ return getAbsolutePosition(lookup.get(node.id)!, lookup);
538
+ };
539
+ // Selected comments move with what's dragged: how far it's moved from where it was
540
+ const threadsDrag = useRef<{ id: string; from: XYPosition } | null>(null);
541
+ const moveThreadsWith = (dragged: Node[]) => {
542
+ const drag = threadsDrag.current;
543
+ const node = drag && dragged.find((candidate) => candidate.id === drag.id);
544
+ if (!drag || !node) return;
545
+ const at = canvasPositionOf(node);
546
+ setThreadsOffset({ x: at.x - drag.from.x, y: at.y - drag.from.y });
547
+ };
548
+ const growContainers = (dragged: Node[]) => {
549
+ const started = dragStart.current;
550
+ if (!started) return;
551
+ // Where the dragged nodes are now (the canvas's own nodes can be a frame behind)
552
+ const now = lookupNodes();
553
+ dragged.forEach((node) => now.set(node.id, { ...now.get(node.id)!, position: node.position }));
554
+ flow.showDragPreview(previewContainerGrowth(started.containers, new Set(dragged.map((node) => node.id)), started.nodes, now));
555
+ };
556
+ const onNodeDrag = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
557
+ growContainers(dragged);
558
+ moveThreadsWith(dragged);
559
+ const [root] = dragRoots(dragged);
560
+ const current = root && nodesRef.current.find((node) => node.id === root.id);
561
+ const target = root ? findDropTarget(root.id, nodesRef.current) : undefined;
562
+ setDropTargetId(target && target.id !== current?.parentId ? target.id : null);
563
+ });
564
+ const onNodeDragStop = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
565
+ setInteraction('dragging', false);
566
+ setDropTargetId(null);
567
+ dragStart.current = null;
568
+ // Selected comments not on a node are saved where they moved to
569
+ if (threadsDrag.current && threadsOffset) {
570
+ const offset = threadsOffset;
571
+ comments.moveThreads(
572
+ comments.threads
573
+ .filter((thread) => selectedThreads.has(thread.id) && !thread.nodeId)
574
+ .map((thread) => ({
575
+ threadId: thread.id,
576
+ anchor: { position: { x: thread.position.x + offset.x, y: thread.position.y + offset.y } },
577
+ }))
578
+ );
579
+ }
580
+ threadsDrag.current = null;
581
+ setThreadsOffset(null);
582
+ // The containers it grew are saved with the drop; dropped in another container (or out of one), it moves there
583
+ for (const root of dragRoots(dragged)) {
584
+ const current = nodesRef.current.find((node) => node.id === root.id);
585
+ const target = findDropTarget(root.id, nodesRef.current);
586
+ if (current && target?.id !== current.parentId) flow.setParent(root.id, target?.id);
587
+ }
588
+ });
589
+
590
+ // ---- Levels of detail ----
591
+
592
+ // Like EventCatalog's diagrams: L3 is the canvas people edit, L1 and L2 read only views of it
593
+ const [level, setLevel] = useState<Level>(3);
594
+ const levelRef = useRef(level);
595
+ levelRef.current = level;
596
+ const editable = level === 3;
597
+ const [levelGraph, setLevelGraph] = useState<{ level: Level; nodes: Node[]; edges: Edge[] } | null>(null);
598
+
599
+ // What a level shows depends on what's on the canvas and how it's connected, and names (not positions). Notes
600
+ // aren't laid out with it: adding, moving or writing one doesn't lay the level out again.
601
+ const levelKey =
602
+ level === 3
603
+ ? ''
604
+ : `${flow.nodes
605
+ .filter((node) => node.type !== 'note')
606
+ .map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}:${getNodeName(node.type, node.data)}`)
607
+ .join('|')}#${flow.edges.map((edge) => `${edge.source}>${edge.target}:${String(edge.label ?? '')}`).join('|')}`;
608
+ const shownLevelRef = useRef<Level | undefined>(undefined);
609
+ shownLevelRef.current = levelGraph?.level;
610
+ useEffect(() => {
611
+ if (level === 3) return setLevelGraph(null);
612
+ let cancelled = false;
613
+ // Straight away when switching levels; after changes settle while one is shown
614
+ const timer = setTimeout(
615
+ () => {
616
+ const collapsed = getLevelGraph(nodesRef.current, edgesRef.current, level);
617
+ void layoutGraph(
618
+ collapsed.nodes.map((node) => ({ ...node, selected: false })),
619
+ collapsed.edges
620
+ ).then((laidOut) => !cancelled && setLevelGraph({ level, ...laidOut }));
621
+ },
622
+ shownLevelRef.current === level ? LEVEL_RELAYOUT_MS : 0
623
+ );
624
+ return () => {
625
+ cancelled = true;
626
+ clearTimeout(timer);
627
+ };
628
+ }, [level, levelKey]);
629
+
630
+ // Comments: right click > Add comment, or C for comment mode then click (like Figma)
631
+ const [commentMode, setCommentMode] = useState(false);
632
+ const [draft, setDraft] = useState<CommentAnchor | null>(null);
633
+ const [openThreadId, setOpenThreadId] = useState<string | null>(null);
634
+ const [showResolved, setShowResolved] = useState(false);
635
+ // Comments that are part of the selection (selecting everything selects them too): only here, like what nodes
636
+ // are selected. While what's selected is dragged, the ones not on a node move with it by this much.
637
+ const [selectedThreads, setSelectedThreads] = useState<ReadonlySet<string>>(NO_THREADS);
638
+ const clearSelectedThreads = useCallback(() => setSelectedThreads((current) => (current.size ? NO_THREADS : current)), []);
639
+ const [threadsOffset, setThreadsOffset] = useState<XYPosition | null>(null);
640
+ const threadsShown = () => comments.threads.filter((thread) => showResolved || !thread.resolved);
641
+ const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
642
+
643
+ const changeLevel = useStableCallback((next: Level) => {
644
+ if (next === level) return;
645
+ glide();
646
+ setCommentMode(false);
647
+ setDraft(null);
648
+ setOpenThreadId(null);
649
+ // What's selected may not be on the next level (e.g. a note added on this one)
650
+ flow.onNodesChange(selectedIds().map((id) => ({ type: 'select', id, selected: false })));
651
+ setLevel(next);
652
+ });
653
+ const editInL3 = useCallback(() => changeLevel(3), [changeLevel]);
654
+
655
+ // Fit the camera to a level once it's laid out (and to the canvas when going back to L3)
656
+ const shownLevel = level === 3 ? 3 : levelGraph?.level;
657
+ useEffect(() => {
658
+ if (shownLevel === undefined) return;
659
+ // Like the visualiser, a level isn't zoomed in past its real size
660
+ const options = shownLevel === 3 ? FIT_VIEW_OPTIONS : LEVEL_FIT_VIEW_OPTIONS;
661
+ const frame = requestAnimationFrame(() => void fitView({ ...options, duration: 450 }));
662
+ return () => cancelAnimationFrame(frame);
663
+ }, [shownLevel]);
664
+
665
+ /** Lays the canvas out with the visualiser's layout, for everyone on it (everyone's nodes glide there) */
666
+ const reorder = useStableCallback(async () => {
667
+ if (levelRef.current !== 3) setLevel(3);
668
+ const positions = await getLayoutPositions(nodesRef.current, edgesRef.current);
669
+ flow.applyPositions(positions);
670
+ setTimeout(() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 450 }), 50);
671
+ });
672
+ const onReorder = useCallback(() => void reorder(), [reorder]);
673
+
674
+ const selected = flow.nodes.filter((node) => node.selected);
675
+ const selectedKey = selected.map((node) => node.id).join(',');
676
+
677
+ // React Flow needs containers before what's in them: only re-sorted when the structure changes
678
+ const hierarchyOrder = useMemo(() => {
679
+ const sorted = sortByHierarchy(nodesRef.current);
680
+ return sorted.every((node, index) => node === nodesRef.current[index]) ? null : sorted.map((node) => node.id);
681
+ }, [structure]);
682
+ const sortedNodes = useMemo(() => {
683
+ if (!hierarchyOrder) return flow.nodes;
684
+ const byId = new Map(flow.nodes.map((node) => [node.id, node]));
685
+ return hierarchyOrder.flatMap((id) => byId.get(id) ?? []);
686
+ }, [flow.nodes, hierarchyOrder]);
687
+ const levelShown = level !== 3 && levelGraph?.level === level ? levelGraph : null;
688
+ // Sticky notes are shown on the level they were added on: on L1 and L2, over the level's layout
689
+ const shownNodes = useMemo(() => {
690
+ if (!levelShown)
691
+ return sortedNodes.every((node) => isOnLevel(node, 3)) ? sortedNodes : sortedNodes.filter((node) => isOnLevel(node, 3));
692
+ const notes = sortedNodes.filter((node) => getNoteLevel(node) === levelShown.level);
693
+ return notes.length ? [...levelShown.nodes, ...notes.map(asLevelNote)] : levelShown.nodes;
694
+ }, [levelShown, sortedNodes]);
695
+ /** On L1 and L2, only changes to the level's notes go to the canvas (its other nodes are drawn from it) */
696
+ const onLevelNodesChange = useStableCallback((changes: NodeChange[]) => {
697
+ const notes = new Set(nodesRef.current.filter((node) => getNoteLevel(node) === levelRef.current).map((node) => node.id));
698
+ const ofNotes = changes.filter((change) => change.type !== 'add' && notes.has(change.id));
699
+ if (ofNotes.length) flow.onNodesChange(ofNotes);
700
+ });
701
+ // Message edges' envelopes only move on what's selected (the edge, or a node at either end): any moving
702
+ // envelope at all costs a style recalc and layout every frame, which an editing canvas can't afford at idle
703
+ const shownEdges = useMemo(() => {
704
+ const edges = levelShown ? levelShown.edges : flow.edges;
705
+ const focused = new Set(selectedKey ? selectedKey.split(',') : []);
706
+ return edges.map((edge) =>
707
+ edge.type === 'animated' && !edge.selected && !focused.has(edge.source) && !focused.has(edge.target)
708
+ ? withoutEnvelope(edge)
709
+ : edge
710
+ );
711
+ }, [levelShown, flow.edges, selectedKey]);
712
+ const unavailableLevels = useMemo(
713
+ () => ({ 1: getLevelUnavailableReason(nodesRef.current, 1), 2: getLevelUnavailableReason(nodesRef.current, 2) }),
714
+ [structure]
715
+ );
716
+
717
+ // ---- People and agents ----
718
+
719
+ /** Jump to where someone is: their view (and zoom), or for agents, what they last worked on */
720
+ const jumpTo = useStableCallback((person: Peer) => {
721
+ const peer = flow.presence?.getPeers().find((candidate) => candidate.clientId === person.clientId) ?? person;
722
+ if (levelRef.current !== 3) changeLevel(3);
723
+ if (peer.viewport) {
724
+ void setCenter(peer.viewport.x, peer.viewport.y, { zoom: peer.viewport.zoom, duration: 500 });
725
+ } else if (peer.pointer) {
726
+ void setCenter(peer.pointer.x, peer.pointer.y, { zoom: Math.max(getZoom(), 1), duration: 500 });
727
+ }
728
+ });
729
+
730
+ const [following, setFollowing] = useState(followChanges);
731
+ const toggleFollowing = useCallback(() => setFollowing((on) => !on), []);
732
+
733
+ const [shareOpen, setShareOpen] = useState(false);
734
+ const openShare = useCallback(() => setShareOpen(true), []);
735
+ const closeShare = useCallback(() => setShareOpen(false), []);
736
+ const [deleteOpen, setDeleteOpen] = useState(false);
737
+ const openDelete = useCallback(() => setDeleteOpen(true), []);
738
+ const closeDelete = useCallback(() => setDeleteOpen(false), []);
739
+ // Deleted here: on the way to Studio. Set before asking, since the server closes the canvas before it answers
740
+ // (the canvas closing as it's deleted isn't news)
741
+ const [leaving, setLeaving] = useState(false);
742
+ const deleteCanvas = useStableCallback(async () => {
743
+ if (!canvasesApiUrl) return;
744
+ setLeaving(true);
745
+ try {
746
+ await deleteCanvasThroughApi(canvasesApiUrl, canvasId);
747
+ } catch (error) {
748
+ setLeaving(false);
749
+ throw error;
750
+ }
751
+ window.location.assign(studioUrl ?? '/');
752
+ });
753
+ const [connectOpen, setConnectOpen] = useState(false);
754
+ const openConnect = useCallback(() => setConnectOpen(true), []);
755
+ const closeConnect = useCallback(() => setConnectOpen(false), []);
756
+ useEffect(() => {
757
+ if (relayScriptPath && isRelayEnabled()) loadRelay(relayScriptPath);
758
+ }, [relayScriptPath]);
759
+ const agentLink = {
760
+ canvasId,
761
+ title: flow.title,
762
+ canvasUrl: shareUrl ?? window.location.href,
763
+ mcpUrl: new URL(mcpUrl, window.location.origin).href,
764
+ };
765
+
766
+ // ---- Comments ----
767
+
768
+ // Keyboard shortcuts (react-hotkeys-hook: "mod" is ⌘ on a Mac and Ctrl elsewhere; ignored while typing in a field)
769
+ const onCanvas = () => levelRef.current === 3;
770
+ useHotkeys(
771
+ 'mod+a',
772
+ () => {
773
+ if (!onCanvas()) return;
774
+ flow.onNodesChange(
775
+ nodesRef.current.filter((node) => isOnLevel(node, 3)).map((node) => ({ type: 'select', id: node.id, selected: true }))
776
+ );
777
+ flow.onEdgesChange(edgesRef.current.map((edge) => ({ type: 'select', id: edge.id, selected: true })));
778
+ // ...and the comments shown on it
779
+ setSelectedThreads(new Set(threadsShown().map((thread) => thread.id)));
780
+ },
781
+ { preventDefault: true }
782
+ );
783
+ useHotkeys(
784
+ 'mod+d',
785
+ () => {
786
+ const content = onCanvas() && copyNodes(nodesRef.current, edgesRef.current, selectedIds());
787
+ if (content) paste(content, { offset: { x: 40, y: 40 } });
788
+ },
789
+ { preventDefault: true }
790
+ );
791
+ // On every level: notes are changed on L1 and L2 too (and the levels show what's undone on the canvas)
792
+ useHotkeys('mod+z', () => flow.undo(), { preventDefault: true });
793
+ useHotkeys(['mod+shift+z', 'mod+y'], () => flow.redo(), { preventDefault: true });
794
+ useHotkeys('c', () => onCanvas() && setCommentMode((on) => !on));
795
+ useHotkeys('escape', () => {
796
+ setCommentMode(false);
797
+ setDraft(null);
798
+ setOpenThreadId(null);
799
+ clearSelectedThreads();
800
+ setEditingId(null);
801
+ flow.onNodesChange(
802
+ nodesRef.current.filter((node) => node.selected).map((node) => ({ type: 'select', id: node.id, selected: false }))
803
+ );
804
+ flow.onEdgesChange(
805
+ edgesRef.current.filter((edge) => edge.selected).map((edge) => ({ type: 'select', id: edge.id, selected: false }))
806
+ );
807
+ });
808
+ // Selected comments are deleted with what's selected (React Flow deletes the nodes and edges)
809
+ useHotkeys(DELETE_KEYS, () => {
810
+ if (!onCanvas() || selectedThreads.size === 0) return;
811
+ comments.deleteThreads([...selectedThreads]);
812
+ clearSelectedThreads();
813
+ });
814
+ const toggleCommentMode = useCallback(() => setCommentMode((on) => !on), []);
815
+
816
+ const startComment = (anchor: CommentAnchor) => {
817
+ setOpenThreadId(null);
818
+ setDraft(anchor);
819
+ setCommentMode(false);
820
+ };
821
+
822
+ const flowPositionOf = (event: { clientX: number; clientY: number }) =>
823
+ screenToFlowPosition({ x: event.clientX, y: event.clientY });
824
+
825
+ const onPaneClick = useStableCallback((event: ReactMouseEvent) => {
826
+ clearSelectedThreads();
827
+ if (commentMode && editable) return startComment(anchorAt(flowPositionOf(event)));
828
+ // A click inside a container (not on something in it) selects it: its body lets the pointer through so you can
829
+ // drag a selection box in it, so the click lands on the canvas
830
+ // (after React Flow clears the selection, which it does once this returns)
831
+ const container = editable ? findGroupAtPoint(flowPositionOf(event), nodesRef.current) : undefined;
832
+ if (container) queueMicrotask(() => flow.onNodesChange([{ type: 'select', id: container.id, selected: true }]));
833
+ setDraft(null);
834
+ setOpenThreadId(null);
835
+ });
836
+
837
+ // Double clicking a node edits its details (notes are edited on the canvas, by double clicking them too)
838
+ const onNodeDoubleClick = useStableCallback((_: ReactMouseEvent, node: Node) => {
839
+ if (editable && !isWrittenOnCanvas(node.type)) setEditingId(node.id);
840
+ });
841
+ // ...and inside a container (not on something in it): its body lets the pointer through, so it's on the canvas
842
+ const onCanvasDoubleClick = useStableCallback((event: ReactMouseEvent) => {
843
+ if (!editable || !(event.target as Element).classList?.contains('react-flow__pane')) return;
844
+ const container = findGroupAtPoint(flowPositionOf(event), nodesRef.current);
845
+ if (container) setEditingId(container.id);
846
+ });
847
+
848
+ const onNodeClick = useStableCallback((event: ReactMouseEvent, node: Node) => {
849
+ // Clicking one node selects just it (with shift or ⌘, it's added to what's selected)
850
+ if (!event.shiftKey && !event.metaKey) clearSelectedThreads();
851
+ if (commentMode && editable) startComment(anchorAt(flowPositionOf(event), node, lookupNodes()));
852
+ });
853
+
854
+ const openContextMenu = useStableCallback((event: ReactMouseEvent | MouseEvent, node?: Node) => {
855
+ event.preventDefault();
856
+ if (!editable) return;
857
+ setContextMenu({ x: event.clientX, y: event.clientY, flowPosition: flowPositionOf(event), node });
858
+ });
859
+ const onPaneContextMenu = useCallback((event: ReactMouseEvent | MouseEvent) => openContextMenu(event), [openContextMenu]);
860
+ const onNodeContextMenu = useCallback((event: ReactMouseEvent, node: Node) => openContextMenu(event, node), [openContextMenu]);
861
+ const closeContextMenu = useCallback(() => setContextMenu(null), []);
862
+ const commentFromMenu = useStableCallback(() => {
863
+ if (contextMenu) startComment(anchorAt(contextMenu.flowPosition, contextMenu.node, lookupNodes()));
864
+ setContextMenu(null);
865
+ });
866
+ /** The node right clicked, or the whole selection if it's part of it */
867
+ const menuTargets = () => {
868
+ const node = contextMenu?.node;
869
+ if (!node) return [];
870
+ const selected = selectedIds();
871
+ return selected.includes(node.id) ? selected : [node.id];
872
+ };
873
+ const editFromMenu = useStableCallback(() => {
874
+ if (contextMenu?.node) setEditingId(contextMenu.node.id);
875
+ setContextMenu(null);
876
+ });
877
+ const copyFromMenu = useStableCallback(() => {
878
+ // Through the copy event, so it's copied like ⌘C (to the system clipboard too)
879
+ copyOnly.current = menuTargets();
880
+ document.execCommand('copy');
881
+ copyOnly.current = null;
882
+ setContextMenu(null);
883
+ });
884
+ const duplicateFromMenu = useStableCallback(() => {
885
+ const content = copyNodes(nodesRef.current, edgesRef.current, menuTargets());
886
+ if (content) paste(content, { offset: { x: 40, y: 40 } });
887
+ setContextMenu(null);
888
+ });
889
+ const pasteFromMenu = useStableCallback(() => {
890
+ if (lastCopied && contextMenu) paste(lastCopied, { at: contextMenu.flowPosition });
891
+ setContextMenu(null);
892
+ });
893
+ const deleteFromMenu = useStableCallback(() => {
894
+ flow.deleteNodes(menuTargets());
895
+ setContextMenu(null);
896
+ });
897
+
898
+ const openThread = useStableCallback((thread: Thread) => {
899
+ const { x, y } = getAnchorPosition(thread, lookupNodes());
900
+ // Leave room for the thread, which opens to the right of its pin
901
+ void setCenter(x + 150, y, { zoom: getZoom(), duration: 300 });
902
+ setDraft(null);
903
+ setOpenThreadId(thread.id);
904
+ });
905
+ const toggleResolved = useCallback(() => setShowResolved((show) => !show), []);
906
+ const openThreadById = useCallback((id: string | null) => {
907
+ setDraft(null);
908
+ setOpenThreadId(id);
909
+ }, []);
910
+ const createThread = useStableCallback((text: string) => {
911
+ if (!draft) return;
912
+ // Posted, it's a pin like the others (not opened): click it to see or reply to it
913
+ comments.createThread(draft, text);
914
+ setDraft(null);
915
+ });
916
+ const cancelDraft = useCallback(() => setDraft(null), []);
917
+ const deleteThread = useStableCallback((id: string) => {
918
+ comments.deleteThread(id);
919
+ setOpenThreadId(null);
920
+ });
921
+
922
+ // ---- Selection ----
923
+
924
+ useEffect(() => {
925
+ onSelectionChange?.(
926
+ selected.map((node) => {
927
+ const link = getCatalogLink(node);
928
+ return {
929
+ id: node.id,
930
+ name: getNodeName(node.type, node.data, node.id),
931
+ type: getNodeLabel(node.type),
932
+ ...(link && { catalogResource: `${link.key.replace(':', '/')} v${link.version}` }),
933
+ };
934
+ })
935
+ );
936
+ }, [selectedKey]);
937
+ // The node whose details are being edited in the panel: opened by double clicking it, or "Edit details" in its
938
+ // menu (selecting a node doesn't open it). Notes are edited on the canvas.
939
+ const [editingId, setEditingId] = useState<string | null>(null);
940
+ const editing =
941
+ editable && editingId ? flow.nodes.find((node) => node.id === editingId && !isWrittenOnCanvas(node.type)) : undefined;
942
+ // What the panel shows (not the node's position, so dragging it doesn't re-render the panel)
943
+ const inspected = useMemo(
944
+ () => (editing ? { id: editing.id, type: editing.type, data: editing.data } : null),
945
+ [editing?.id, editing?.type, editing?.data]
946
+ );
947
+ const closePanel = useCallback(() => setEditingId(null), []);
948
+ // An accepted or rejected canvas, and when it was decided
949
+ const { status: canvasStatus, history: statusHistory } = flow.canvasStatus;
950
+ const decided = isDecided(canvasStatus)
951
+ ? { status: canvasStatus, change: statusHistory.findLast((change) => change.status === canvasStatus) }
952
+ : null;
953
+
954
+ return (
955
+ <UpdateNodeDataContext.Provider value={flow.updateNodeData}>
956
+ <DropTargetContext.Provider value={dropTargetId}>
957
+ <div className="flex h-full min-h-0 text-[rgb(var(--ec-page-text))]">
958
+ <LeftPanel
959
+ resources={resources}
960
+ keysOnCanvas={keysOnCanvas}
961
+ onAddComponent={addComponent}
962
+ onAddResource={addResourceFromPanel}
963
+ threads={comments.threads}
964
+ showResolved={showResolved}
965
+ onToggleResolved={toggleResolved}
966
+ onOpenThread={openThread}
967
+ />
968
+
969
+ <div
970
+ ref={canvasRef}
971
+ className={`eventcatalog-visualizer studio-canvas relative flex-1 min-w-0 ${commentMode ? 'comment-mode' : ''} ${
972
+ transitioning ? 'is-layout-transition' : ''
973
+ }`}
974
+ onPointerMove={trackPointer}
975
+ onPointerLeave={clearPointer}
976
+ onDoubleClick={onCanvasDoubleClick}
977
+ >
978
+ <ReactFlow
979
+ nodes={shownNodes}
980
+ edges={shownEdges}
981
+ nodeTypes={nodeTypes}
982
+ edgeTypes={edgeTypes}
983
+ nodesDraggable={editable}
984
+ nodesConnectable={editable}
985
+ elementsSelectable={editable}
986
+ onNodesChange={editable ? flow.onNodesChange : onLevelNodesChange}
987
+ onEdgesChange={editable ? flow.onEdgesChange : undefined}
988
+ onConnect={flow.onConnect}
989
+ // Drag either end of a connection to another node (dropped anywhere else, it stays where it was)
990
+ edgesReconnectable={editable}
991
+ onReconnect={editable ? flow.onReconnect : undefined}
992
+ reconnectRadius={RECONNECT_RADIUS}
993
+ onDragOver={onDragOver}
994
+ onMoveStart={onMoveStart}
995
+ onMoveEnd={onMoveEnd}
996
+ onNodeDragStart={onNodeDragStart}
997
+ onNodeDrag={onNodeDrag}
998
+ onNodeDragStop={onNodeDragStop}
999
+ onDrop={onDrop}
1000
+ onPaneClick={onPaneClick}
1001
+ onNodeClick={onNodeClick}
1002
+ onNodeDoubleClick={onNodeDoubleClick}
1003
+ onError={onFlowError}
1004
+ onSelectionStart={clearSelectedThreads}
1005
+ onPaneContextMenu={onPaneContextMenu}
1006
+ onNodeContextMenu={onNodeContextMenu}
1007
+ connectionLineType={ConnectionLineType.SmoothStep}
1008
+ connectionRadius={40}
1009
+ deleteKeyCode={DELETE_KEYS}
1010
+ multiSelectionKeyCode={MULTI_SELECTION_KEYS}
1011
+ selectionOnDrag={editable}
1012
+ // Read only levels can't select: dragging pans them
1013
+ panOnDrag={editable ? PAN_BUTTONS : true}
1014
+ panOnScroll
1015
+ zoomActivationKeyCode={ZOOM_KEYS}
1016
+ zoomOnDoubleClick={false}
1017
+ minZoom={0.1}
1018
+ maxZoom={2}
1019
+ >
1020
+ <Background gap={10} bgColor="rgb(var(--ec-page-bg))" color="rgb(var(--ec-page-border))" />
1021
+ <ViewportSharer onChange={flow.setViewport} />
1022
+ {/* Pointers, selections and comments are on the canvas people edit (L3) */}
1023
+ {editable && <RemotePresence store={flow.presence} clientId={flow.clientId} />}
1024
+ {editable && (
1025
+ <CommentLayer
1026
+ threads={comments.threads}
1027
+ author={author}
1028
+ openThreadId={openThreadId}
1029
+ draft={draft}
1030
+ showResolved={showResolved}
1031
+ onOpen={openThreadById}
1032
+ onCreate={createThread}
1033
+ onCancelDraft={cancelDraft}
1034
+ onReply={comments.reply}
1035
+ onResolve={comments.setResolved}
1036
+ onMove={comments.moveThread}
1037
+ onMoveDraft={setDraft}
1038
+ onDelete={deleteThread}
1039
+ selectedIds={selectedThreads}
1040
+ selectionOffset={threadsOffset}
1041
+ />
1042
+ )}
1043
+ </ReactFlow>
1044
+
1045
+ <AgentActivity
1046
+ store={flow.presence}
1047
+ clientId={flow.clientId}
1048
+ enabled={editable}
1049
+ following={following}
1050
+ onToggleFollowing={toggleFollowing}
1051
+ pauseRef={pauseCamera}
1052
+ />
1053
+
1054
+ {!editable && (
1055
+ <div className="absolute left-1/2 top-16 z-10 flex -translate-x-1/2 items-center gap-3 rounded-full border px-4 py-1.5 text-xs shadow-sm bg-[rgb(var(--ec-accent-subtle))] border-[rgb(var(--ec-accent)/0.3)] text-[rgb(var(--ec-page-text))]">
1056
+ <span>
1057
+ <span className="font-semibold">L{level}</span> · {LEVELS.find((entry) => entry.level === level)?.description} ·
1058
+ read only, sticky notes can be added
1059
+ </span>
1060
+ <button onClick={editInL3} className="font-semibold text-[rgb(var(--ec-accent))] hover:underline">
1061
+ Edit in L3
1062
+ </button>
1063
+ </div>
1064
+ )}
1065
+
1066
+ {/* Accepted or rejected: still editable, but changing it makes it a draft again (for everyone) */}
1067
+ {editable && decided && (
1068
+ <div
1069
+ className={`absolute left-1/2 top-16 z-10 flex max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-2 rounded-full border px-4 py-1.5 text-xs shadow-sm ${CANVAS_STATUS_LOOK[decided.status].pill}`}
1070
+ >
1071
+ <span className="truncate">
1072
+ <span className="font-semibold">{CANVAS_STATUS_LOOK[decided.status].label}</span>
1073
+ {decided.change && ` by ${decided.change.by.name} · ${timeAgo(decided.change.at)}`}
1074
+ {decided.change?.note && ` · “${decided.change.note}”`} · changing it moves it back to Draft
1075
+ </span>
1076
+ </div>
1077
+ )}
1078
+
1079
+ <CanvasControls
1080
+ level={level}
1081
+ onLevelChange={changeLevel}
1082
+ unavailableLevels={unavailableLevels}
1083
+ editable={editable}
1084
+ onReorder={onReorder}
1085
+ canUndo={flow.canUndo}
1086
+ canRedo={flow.canRedo}
1087
+ onUndo={flow.undo}
1088
+ onRedo={flow.redo}
1089
+ commentMode={commentMode}
1090
+ onToggleCommentMode={toggleCommentMode}
1091
+ onAddNote={addNote}
1092
+ />
1093
+
1094
+ <CanvasContextMenu
1095
+ menu={contextMenu}
1096
+ onAddComment={commentFromMenu}
1097
+ onEditDetails={editFromMenu}
1098
+ onCopy={copyFromMenu}
1099
+ onDuplicate={duplicateFromMenu}
1100
+ onPaste={lastCopied ? pasteFromMenu : undefined}
1101
+ onDeleteNode={deleteFromMenu}
1102
+ onClose={closeContextMenu}
1103
+ />
1104
+
1105
+ <CanvasHeader
1106
+ title={flow.title}
1107
+ onRetitle={flow.rename}
1108
+ status={flow.status}
1109
+ transport={flow.transport}
1110
+ presence={flow.presence}
1111
+ clientId={flow.clientId}
1112
+ onJumpTo={jumpTo}
1113
+ onRename={onRename}
1114
+ onConnectAgent={openConnect}
1115
+ onShare={openShare}
1116
+ webMcp={webMcp}
1117
+ shareUrl={shareUrl}
1118
+ newCanvasUrl={newCanvasUrl}
1119
+ canvasStatus={flow.canvasStatus.status}
1120
+ statusHistory={flow.canvasStatus.history}
1121
+ onStatusChange={flow.changeStatus}
1122
+ onDelete={canvasesApiUrl ? openDelete : undefined}
1123
+ />
1124
+
1125
+ {flow.deleted && !leaving && (
1126
+ <div className="absolute inset-0 z-40 flex items-center justify-center bg-[rgb(var(--ec-page-bg)/0.85)] p-4">
1127
+ <div className="max-w-sm space-y-2 rounded-xl border p-5 text-center shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]">
1128
+ <h2 className="text-base font-semibold">This canvas was deleted</h2>
1129
+ <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
1130
+ Someone deleted it, so it can't be opened or changed any more.
1131
+ </p>
1132
+ {studioUrl && (
1133
+ <a
1134
+ href={studioUrl}
1135
+ className="mt-2 inline-flex rounded-lg px-4 py-2 text-sm font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))]"
1136
+ >
1137
+ Back to Studio
1138
+ </a>
1139
+ )}
1140
+ </div>
1141
+ </div>
1142
+ )}
1143
+
1144
+ {/* Only on an empty canvas (not over comments people have left on it) */}
1145
+ {flow.status === 'connected' && flow.nodes.length === 0 && comments.threads.length === 0 && !draft && (
1146
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
1147
+ <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
1148
+ Drag components or catalog resources from the left onto the canvas, then share the link to work on it together.
1149
+ </p>
1150
+ </div>
1151
+ )}
1152
+ </div>
1153
+
1154
+ {chooser && (
1155
+ <ContainerChooser
1156
+ resource={chooser.resource}
1157
+ contents={chooserContents}
1158
+ position={flowToScreenPosition(chooser.center)}
1159
+ onChoose={choose}
1160
+ onCancel={cancelChooser}
1161
+ />
1162
+ )}
1163
+ {connectionsChooser && (
1164
+ <ConnectionsChooser
1165
+ resource={connectionsChooser.resource}
1166
+ groups={connectionsChooser.groups}
1167
+ position={flowToScreenPosition(connectionsChooser.center)}
1168
+ onChoose={chooseConnections}
1169
+ onCancel={cancelConnectionsChooser}
1170
+ />
1171
+ )}
1172
+ {shareOpen && (
1173
+ <ShareDialog
1174
+ keptInMemory={keptInMemory}
1175
+ url={agentLink.canvasUrl}
1176
+ title={flow.title}
1177
+ presence={flow.presence}
1178
+ clientId={flow.clientId}
1179
+ onRename={onRename}
1180
+ onJumpTo={jumpTo}
1181
+ onConnectAgent={openConnect}
1182
+ onClose={closeShare}
1183
+ />
1184
+ )}
1185
+ {deleteOpen && (
1186
+ <DeleteCanvasDialog
1187
+ title={flow.title}
1188
+ presence={flow.presence}
1189
+ clientId={flow.clientId}
1190
+ onDelete={deleteCanvas}
1191
+ onClose={closeDelete}
1192
+ />
1193
+ )}
1194
+ {connectOpen && (
1195
+ <ConnectAgentDialog
1196
+ link={agentLink}
1197
+ webMcp={webMcp}
1198
+ relayScriptPath={relayScriptPath}
1199
+ onEnableWebMcp={turnOnWebMcp}
1200
+ onClose={closeConnect}
1201
+ />
1202
+ )}
1203
+ {inspected && (
1204
+ <PropertiesPanel
1205
+ key={inspected.id}
1206
+ node={inspected}
1207
+ onChange={flow.updateNodeData}
1208
+ onDelete={flow.deleteNodes}
1209
+ onClose={closePanel}
1210
+ />
1211
+ )}
1212
+ </div>
1213
+ </DropTargetContext.Provider>
1214
+ </UpdateNodeDataContext.Provider>
1215
+ );
1216
+ }