@eventcatalog/core 5.0.0-beta.7 → 5.0.0-beta.8

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 (85) 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 +3 -1
  4. package/dist/analytics/log-build.js +4 -4
  5. package/dist/{chunk-BOKAG5XC.js → chunk-4W55ZOOS.js} +2 -2
  6. package/dist/{chunk-PIYJM642.js → chunk-AWOO63ME.js} +70 -44
  7. package/dist/{chunk-3VBB2ZMP.js → chunk-C6T2AAFR.js} +1 -1
  8. package/dist/{chunk-MDWB5HLO.js → chunk-ENAG6UH7.js} +5 -0
  9. package/dist/{chunk-S7ODHVII.js → chunk-PU25TOOJ.js} +1 -1
  10. package/dist/{chunk-FARBIZ7Y.js → chunk-RDIZJ7UT.js} +1 -1
  11. package/dist/{chunk-SJJKWZYZ.js → chunk-T4G3VVZ4.js} +1 -1
  12. package/dist/constants.cjs +1 -1
  13. package/dist/constants.js +1 -1
  14. package/dist/eventcatalog.cjs +138 -90
  15. package/dist/eventcatalog.config.d.cts +47 -0
  16. package/dist/eventcatalog.config.d.ts +47 -0
  17. package/dist/eventcatalog.js +30 -11
  18. package/dist/features.cjs +8 -2
  19. package/dist/features.d.cts +3 -1
  20. package/dist/features.d.ts +3 -1
  21. package/dist/features.js +5 -3
  22. package/dist/generate.cjs +1 -1
  23. package/dist/generate.js +3 -3
  24. package/dist/utils/cli-logger.cjs +1 -1
  25. package/dist/utils/cli-logger.js +2 -2
  26. package/dist/utils/license-status.cjs +70 -44
  27. package/dist/utils/license-status.d.cts +14 -5
  28. package/dist/utils/license-status.d.ts +14 -5
  29. package/dist/utils/license-status.js +1 -1
  30. package/eventcatalog/integrations/studio-server.mjs +15 -3
  31. package/eventcatalog/src/components/Header.astro +3 -3
  32. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +10 -2
  33. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +9 -1
  34. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +9 -2
  35. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +70 -69
  36. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
  37. package/eventcatalog/src/features/mcp/mcp-server.ts +5 -3
  38. package/eventcatalog/src/features/studio/agent-choreography.ts +8 -5
  39. package/eventcatalog/src/features/studio/canvas-actions.ts +148 -21
  40. package/eventcatalog/src/features/studio/canvas-doc.ts +138 -9
  41. package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
  42. package/eventcatalog/src/features/studio/catalog-resources.ts +5 -2
  43. package/eventcatalog/src/features/studio/clipboard.ts +97 -0
  44. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +4 -3
  45. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +39 -22
  46. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
  47. package/eventcatalog/src/features/studio/components/Comments.tsx +345 -30
  48. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +140 -76
  49. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
  50. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +1 -1
  51. package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
  52. package/eventcatalog/src/features/studio/components/JoinForm.tsx +0 -24
  53. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +131 -35
  54. package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
  55. package/eventcatalog/src/features/studio/components/Presence.tsx +2 -0
  56. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +187 -50
  57. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
  58. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +442 -40
  59. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +198 -22
  60. package/eventcatalog/src/features/studio/components/icons.ts +6 -1
  61. package/eventcatalog/src/features/studio/components/status.ts +30 -1
  62. package/eventcatalog/src/features/studio/edges.ts +2 -0
  63. package/eventcatalog/src/features/studio/grouping.ts +95 -2
  64. package/eventcatalog/src/features/studio/hooks/presence-store.ts +5 -1
  65. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +13 -3
  66. package/eventcatalog/src/features/studio/hooks/use-comments.ts +8 -1
  67. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +7 -2
  68. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +174 -27
  69. package/eventcatalog/src/features/studio/identity.ts +35 -0
  70. package/eventcatalog/src/features/studio/layout.ts +7 -4
  71. package/eventcatalog/src/features/studio/levels.ts +138 -72
  72. package/eventcatalog/src/features/studio/node-types.ts +35 -3
  73. package/eventcatalog/src/features/studio/pages/[id].astro +7 -2
  74. package/eventcatalog/src/features/studio/pages/index.astro +555 -2
  75. package/eventcatalog/src/features/studio/pages/new.astro +24 -0
  76. package/eventcatalog/src/features/studio/pages/wake.ts +15 -0
  77. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +48 -13
  78. package/eventcatalog/src/features/studio/server/runtime.ts +193 -14
  79. package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
  80. package/eventcatalog/src/features/studio/server/storage.ts +110 -0
  81. package/eventcatalog/src/features/studio/studio.css +184 -0
  82. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +1 -1
  83. package/eventcatalog/src/utils/feature.ts +7 -3
  84. package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
  85. package/package.json +4 -4
@@ -7,11 +7,14 @@ import {
7
7
  useReactFlow,
8
8
  type Edge,
9
9
  type Node,
10
+ type NodeChange,
10
11
  type XYPosition,
11
12
  } from '@xyflow/react';
12
13
  import { edgeTypes } from '@eventcatalog/visualiser';
13
14
  import { useHotkeys } from 'react-hotkeys-hook';
14
- import { indexCatalog } from '../canvas-actions';
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';
15
18
  import type { CatalogRelation, CatalogResource } from '../catalog-resources';
16
19
  import {
17
20
  buildContainer,
@@ -22,25 +25,37 @@ import {
22
25
  getContents,
23
26
  getRelatedEdges,
24
27
  } from '../catalog';
25
- import { findDropTarget, findGroupAtPoint, sortByHierarchy, toRelativePosition } from '../grouping';
28
+ import {
29
+ findDropTarget,
30
+ findGroupAtPoint,
31
+ getAbsolutePosition,
32
+ previewContainerGrowth,
33
+ sizeOf,
34
+ sortByHierarchy,
35
+ toRelativePosition,
36
+ } from '../grouping';
26
37
  import type { Peer } from '../hooks/presence-store';
27
38
  import { useCanvasWebMcp } from '../hooks/use-canvas-webmcp';
28
39
  import { useStudioFlow } from '../hooks/use-studio-flow';
29
40
  import { useComments, type CommentAnchor, type Thread } from '../hooks/use-comments';
30
41
  import { getLayoutPositions, layoutGraph } from '../layout';
31
42
  import { getLevelGraph, getLevelUnavailableReason, LEVELS, type Level } from '../levels';
32
- import { getNodeLabel, getNodeName } from '../node-types';
43
+ import { getNodeDefinition, getNodeLabel, getNodeName, getNoteLevel, isOnLevel, isWrittenOnCanvas } from '../node-types';
33
44
  import type { ToolSync } from '../tool-sync';
34
45
  import CanvasControls, { FIT_VIEW_OPTIONS } from './CanvasControls';
35
46
  import CanvasHeader from './CanvasHeader';
36
47
  import { DropTargetContext, nodeTypes, UpdateNodeDataContext } from './canvas-nodes';
37
- import { anchorAt, CanvasContextMenu, CommentLayer, getAnchorPosition, type ContextMenuState } from './Comments';
48
+ import { anchorAt, CanvasContextMenu, CommentLayer, getAnchorPosition, timeAgo, type ContextMenuState } from './Comments';
49
+ import { CANVAS_STATUS_LOOK } from './status';
38
50
  import ConnectAgentDialog, { enableWebMcp, isRelayEnabled, isWebMcpEnabled, loadRelay } from './ConnectAgentDialog';
39
51
  import ContainerChooser, { type ContainerChoice } from './ContainerChooser';
40
- import JoinForm, { colorForName, getStoredName, storeName } from './JoinForm';
52
+ import ConnectionsChooser, { type ConnectionsChoice } from './ConnectionsChooser';
53
+ import JoinForm from './JoinForm';
54
+ import { colorForName, getStoredName, onStoredNameChange, storeName } from '../identity';
41
55
  import LeftPanel, { CATALOG_DRAG_TYPE, COMPONENT_DRAG_TYPE } from './LeftPanel';
42
56
  import { AgentActivity, RemotePresence, ViewportSharer } from './Presence';
43
57
  import PropertiesPanel from './PropertiesPanel';
58
+ import ShareDialog from './ShareDialog';
44
59
 
45
60
  type CanvasProps = {
46
61
  canvasId: string;
@@ -55,6 +70,8 @@ type CanvasProps = {
55
70
  mcpUrl?: string;
56
71
  /** The WebMCP local relay's script, when this page can offer it (not inside a chat) */
57
72
  relayScriptPath?: string;
73
+ /** Canvases are only kept in memory, until the server restarts (`studio.storage` is "memory") */
74
+ keptInMemory?: boolean;
58
75
  /** Sync through MCP tool calls when the WebSocket can't connect (e.g. inside a chat's sandbox) */
59
76
  syncViaTools?: ToolSync;
60
77
  /** Follow agents' changes with the camera from the start (e.g. inside a chat, where you watch an agent work) */
@@ -63,6 +80,8 @@ type CanvasProps = {
63
80
  onSelectionChange?: (selected: SelectedNode[]) => void;
64
81
  /** Join with this name instead of asking for one (e.g. inside a chat, where the agent or host knows who it is) */
65
82
  defaultName?: string;
83
+ /** Signed in with the catalog's sign-in (SSO): you're shown by this name and picture, and can't change them */
84
+ signedInAs?: { name: string; picture?: string };
66
85
  resources: CatalogResource[];
67
86
  relations: CatalogRelation[];
68
87
  };
@@ -72,6 +91,12 @@ export type SelectedNode = { id: string; name: string; type: string; catalogReso
72
91
  /** How long a level (L1, L2) waits for changes to settle before it's laid out again */
73
92
  const LEVEL_RELAYOUT_MS = 300;
74
93
  const DELETE_KEYS = ['Backspace', 'Delete'];
94
+ const LEVEL_FIT_VIEW_OPTIONS = { ...FIT_VIEW_OPTIONS, maxZoom: 1 };
95
+ const NO_THREADS: ReadonlySet<string> = new Set();
96
+ // React Flow's warnings, except that a handle has no node: the left panel's drag previews draw nodes off the canvas
97
+ const onFlowError = (code: string, message: string) => {
98
+ if (code !== '010') console.warn(message);
99
+ };
75
100
  const MULTI_SELECTION_KEYS = ['Meta', 'Shift'];
76
101
  /**
77
102
  * Like design tools: dragging on the canvas draws a box to select what's inside it, and the canvas pans with
@@ -79,6 +104,8 @@ const MULTI_SELECTION_KEYS = ['Meta', 'Shift'];
79
104
  * zooms. The right button stays for the context menu.
80
105
  */
81
106
  const PAN_BUTTONS = [1];
107
+ /** How close to a connection's end you grab it to move it */
108
+ const RECONNECT_RADIUS = 14;
82
109
  const ZOOM_KEYS = ['Meta', 'Control'];
83
110
  const DEFAULT_SOCKET_PATH = '/_eventcatalog/studio';
84
111
 
@@ -98,16 +125,34 @@ const withoutEnvelope = (edge: Edge) => {
98
125
  };
99
126
 
100
127
  /** Ids, types and containers: what changes when nodes are added, removed or moved in or out of containers */
128
+ // Notes on L1 and L2 can be moved and selected there, unlike the rest of the level (made once per note, so a note
129
+ // keeps its identity until it changes)
130
+ const levelNotes = new WeakMap<Node, Node>();
131
+ const asLevelNote = (node: Node) => {
132
+ let note = levelNotes.get(node);
133
+ if (!note) levelNotes.set(node, (note = { ...node, draggable: true, selectable: true }));
134
+ return note;
135
+ };
136
+
101
137
  const structureOf = (nodes: Node[]) => nodes.map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}`).join('|');
102
138
 
103
- export default function StudioDesigner({ defaultName, ...props }: CanvasProps) {
139
+ export default function StudioDesigner({ defaultName, signedInAs, ...props }: CanvasProps) {
104
140
  const [name, setName] = useState(() => getStoredName() ?? defaultName ?? null);
105
141
 
106
142
  const saveName = useCallback((next: string) => {
107
143
  setName(next);
108
144
  storeName(next);
109
145
  }, []);
110
-
146
+ // Renamed on the Studio page or another canvas in this browser
147
+ useEffect(() => onStoredNameChange((next) => next && setName(next)), []);
148
+
149
+ if (signedInAs) {
150
+ return (
151
+ <ReactFlowProvider>
152
+ <Canvas {...props} name={signedInAs.name} picture={signedInAs.picture} />
153
+ </ReactFlowProvider>
154
+ );
155
+ }
111
156
  if (!name) return <JoinForm onJoin={saveName} />;
112
157
 
113
158
  return (
@@ -128,14 +173,21 @@ function Canvas({
128
173
  shareUrl,
129
174
  mcpUrl = '/docs/mcp',
130
175
  relayScriptPath,
176
+ keptInMemory = false,
131
177
  syncViaTools,
132
178
  followChanges = false,
133
179
  onSelectionChange,
134
180
  resources,
135
181
  relations,
136
182
  name,
183
+ picture,
137
184
  onRename,
138
- }: Omit<CanvasProps, 'defaultName'> & { name: string; onRename: (name: string) => void }) {
185
+ }: Omit<CanvasProps, 'defaultName' | 'signedInAs'> & {
186
+ name: string;
187
+ picture?: string;
188
+ /** Not given when you're signed in (your name is the one you signed in with) */
189
+ onRename?: (name: string) => void;
190
+ }) {
139
191
  const color = colorForName(name);
140
192
  const canvasRef = useRef<HTMLDivElement>(null);
141
193
 
@@ -148,15 +200,26 @@ function Canvas({
148
200
  transitionTimer.current = setTimeout(() => setTransitioning(false), 600);
149
201
  }, []);
150
202
 
203
+ // Containers grow smoothly when they're fitted to what's in them (or someone else resizes them). Set on the
204
+ // element rather than in state, so it doesn't re-render the canvas.
205
+ const resizeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
206
+ const animateContainers = useCallback(() => {
207
+ canvasRef.current?.classList.add('is-container-resize');
208
+ clearTimeout(resizeTimer.current);
209
+ resizeTimer.current = setTimeout(() => canvasRef.current?.classList.remove('is-container-resize'), 400);
210
+ }, []);
211
+
151
212
  const flow = useStudioFlow({
152
213
  canvasId,
153
214
  socketUrl: socketUrl ?? toSocketUrl(socketPath),
154
215
  name,
155
216
  color,
217
+ picture,
156
218
  syncViaTools,
157
219
  onLayout: glide,
220
+ onContainerResize: animateContainers,
158
221
  });
159
- const author = useMemo(() => ({ name, color }), [name, color]);
222
+ const author = useMemo(() => ({ name, color, ...(picture && { picture }) }), [name, color, picture]);
160
223
  const comments = useComments(flow.doc, author);
161
224
  const { screenToFlowPosition, flowToScreenPosition, setCenter, getZoom, fitView } = useReactFlow();
162
225
 
@@ -209,12 +272,20 @@ function Canvas({
209
272
 
210
273
  // A domain or system dropped on the canvas: asked whether it's a card or a container first
211
274
  const [chooser, setChooser] = useState<{ resource: CatalogResource; center: XYPosition } | null>(null);
275
+ // A service or message that connects to things not on the canvas yet: asked whether to bring them too
276
+ const [connectionsChooser, setConnectionsChooser] = useState<{
277
+ resource: CatalogResource;
278
+ center: XYPosition;
279
+ groups: ConnectionGroup[];
280
+ } | null>(null);
212
281
 
213
282
  // A catalog resource is added with edges to the related resources already on the canvas
214
283
  const addResource = useStableCallback((key: string, center: XYPosition = canvasCenter()) => {
215
284
  const resource = catalog.resourcesByKey.get(key);
216
285
  if (!resource) return;
217
286
  if (canBeContainer(resource)) return setChooser({ resource, center });
287
+ const groups = getCatalogConnections(resource, catalog, keysOnCanvas);
288
+ if (groups.length > 0) return setConnectionsChooser({ resource, center, groups });
218
289
  addCard(resource, center);
219
290
  });
220
291
 
@@ -261,6 +332,18 @@ function Canvas({
261
332
  });
262
333
  const cancelChooser = useCallback(() => setChooser(null), []);
263
334
 
335
+ const chooseConnections = useStableCallback((choice: ConnectionsChoice) => {
336
+ if (!connectionsChooser) return;
337
+ const { resource, center } = connectionsChooser;
338
+ if (choice.as === 'card') addCard(resource, center);
339
+ else {
340
+ const { nodes, edges } = planWithConnections(nodesRef.current, resource, center, choice.include, catalog);
341
+ flow.insertNodes(nodes, edges);
342
+ }
343
+ setConnectionsChooser(null);
344
+ });
345
+ const cancelConnectionsChooser = useCallback(() => setConnectionsChooser(null), []);
346
+
264
347
  // What a domain or system dropped as a container would bring with it
265
348
  const chooserContents = useMemo(() => {
266
349
  if (!chooser) return { systems: 0, services: 0 };
@@ -273,26 +356,118 @@ function Canvas({
273
356
  };
274
357
  }, [chooser, catalog, keysOnCanvas]);
275
358
 
276
- const addComponent = useStableCallback((type: string) => flow.addNode(type, canvasCenter()));
277
- const addNote = useStableCallback(() => flow.addNode('note', canvasCenter()));
359
+ /** A sticky note on the level shown (it's only shown on that level) */
360
+ const addNoteAt = (center: XYPosition) => {
361
+ const data = getNodeDefinition('note')!.createData();
362
+ flow.insertNode('note', levelRef.current === 3 ? data : { ...data, level: levelRef.current }, center);
363
+ };
364
+ /**
365
+ * Where something added while L1 or L2 is shown goes: L1 and L2 are views of the canvas people edit (L3), so it's
366
+ * added there (shown on L3), below what's on it
367
+ */
368
+ const centerOnCanvas = (center: XYPosition) => {
369
+ if (levelRef.current === 3) return center;
370
+ changeLevel(3);
371
+ const roots = nodesRef.current.filter((node) => !node.parentId && isOnLevel(node, 3));
372
+ if (roots.length === 0) return { x: 0, y: 0 };
373
+ const left = Math.min(...roots.map((node) => node.position.x));
374
+ const right = Math.max(...roots.map((node) => node.position.x + sizeOf(node).width));
375
+ const bottom = Math.max(...roots.map((node) => node.position.y + sizeOf(node).height));
376
+ return { x: (left + right) / 2, y: bottom + 160 };
377
+ };
378
+ const addComponentAt = (type: string, center: XYPosition) =>
379
+ type === 'note' ? addNoteAt(center) : flow.addNode(type, centerOnCanvas(center));
380
+
381
+ const addComponent = useStableCallback((type: string) => addComponentAt(type, canvasCenter()));
382
+ const addNote = useStableCallback(() => addNoteAt(canvasCenter()));
383
+ const addResourceFromPanel = useStableCallback((key: string) => addResource(key, centerOnCanvas(canvasCenter())));
278
384
 
279
385
  const onDrop = useStableCallback((event: DragEvent) => {
280
386
  event.preventDefault();
281
387
  const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
282
388
  const type = event.dataTransfer.getData(COMPONENT_DRAG_TYPE);
283
389
  const resourceKey = event.dataTransfer.getData(CATALOG_DRAG_TYPE);
284
- if (type) flow.addNode(type, position);
285
- if (resourceKey) addResource(resourceKey, position);
390
+ if (type) addComponentAt(type, position);
391
+ if (resourceKey) addResource(resourceKey, centerOnCanvas(position));
286
392
  });
287
393
 
288
394
  // ---- Pointer, panning and dragging ----
289
395
 
290
396
  // React Flow's node drag and panning (d3) stop mousemove from propagating, so follow the
291
397
  // pointer with pointer events, and with dragover while something is dragged in from the palette
398
+ // Where the pointer is on the canvas (while it's over it), for pasting there
399
+ const pointerAt = useRef<XYPosition | null>(null);
292
400
  const trackPointer = useStableCallback((event: { clientX: number; clientY: number }) => {
293
- flow.setPointer(screenToFlowPosition({ x: event.clientX, y: event.clientY }));
401
+ pointerAt.current = screenToFlowPosition({ x: event.clientX, y: event.clientY });
402
+ flow.setPointer(pointerAt.current);
294
403
  });
295
- const clearPointer = useCallback(() => flow.setPointer(null), [flow.setPointer]);
404
+ const clearPointer = useCallback(() => {
405
+ pointerAt.current = null;
406
+ flow.setPointer(null);
407
+ }, [flow.setPointer]);
408
+
409
+ // ---- Copy and paste ----
410
+
411
+ // Pasting again without moving the pointer off the canvas cascades the copies, like design tools do
412
+ const pastes = useRef(0);
413
+ const selectedIds = () => nodesRef.current.filter((node) => node.selected).map((node) => node.id);
414
+ // What was last copied here (for "Paste here" in the context menu, which can't read the clipboard without asking)
415
+ const [lastCopied, setLastCopied] = useState<ClipboardContent | null>(null);
416
+ // Nodes to copy instead of the selection (copying from the context menu)
417
+ const copyOnly = useRef<string[] | null>(null);
418
+
419
+ /** Adds copies (as one change, so one undo takes them back) and selects them instead of what was selected */
420
+ const paste = useStableCallback((content: ClipboardContent, place: { at: XYPosition } | { offset: XYPosition }) => {
421
+ const { nodes, edges } = pasteNodes(content, nodesRef.current, place);
422
+ flow.onNodesChange(selectedIds().map((id) => ({ type: 'select', id, selected: false })));
423
+ flow.insertNodes(nodes, edges);
424
+ flow.onNodesChange(nodes.map((node) => ({ type: 'select', id: node.id, selected: true })));
425
+ });
426
+
427
+ useEffect(() => {
428
+ // Copying, cutting and pasting text in a field (or a note being edited) works as usual
429
+ const isTyping = () => {
430
+ const active = document.activeElement;
431
+ return (
432
+ active instanceof HTMLElement && (active.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(active.tagName))
433
+ );
434
+ };
435
+ // Text selected on the page (e.g. in a panel) is copied as text, like anywhere else
436
+ const hasTextSelected = () => !!window.getSelection()?.toString();
437
+ const copy = (event: ClipboardEvent) => {
438
+ const fromMenu = copyOnly.current !== null;
439
+ const ids = copyOnly.current ?? selectedIds();
440
+ copyOnly.current = null;
441
+ if (levelRef.current !== 3 || isTyping() || !event.clipboardData || (!fromMenu && hasTextSelected())) return;
442
+ const content = copyNodes(nodesRef.current, edgesRef.current, ids);
443
+ if (!content) return;
444
+ event.preventDefault();
445
+ event.clipboardData.setData(CLIPBOARD_TYPE, JSON.stringify(content));
446
+ // Other apps get a list of what was copied
447
+ event.clipboardData.setData('text/plain', content.nodes.map((node) => getNodeName(node.type, node.data)).join('\n'));
448
+ pastes.current = 0;
449
+ setLastCopied(content);
450
+ if (event.type === 'cut') flow.deleteNodes(ids);
451
+ };
452
+ const onPaste = (event: ClipboardEvent) => {
453
+ if (levelRef.current !== 3 || isTyping()) return;
454
+ const content =
455
+ readClipboard(event.clipboardData?.getData(CLIPBOARD_TYPE)) ?? readClipboard(event.clipboardData?.getData('text/plain'));
456
+ if (!content) return;
457
+ event.preventDefault();
458
+ pastes.current += 1;
459
+ const step = 40 * pastes.current;
460
+ paste(content, pointerAt.current ? { at: pointerAt.current } : { offset: { x: step, y: step } });
461
+ };
462
+ document.addEventListener('copy', copy);
463
+ document.addEventListener('cut', copy);
464
+ document.addEventListener('paste', onPaste);
465
+ return () => {
466
+ document.removeEventListener('copy', copy);
467
+ document.removeEventListener('cut', copy);
468
+ document.removeEventListener('paste', onPaste);
469
+ };
470
+ }, [paste, flow.deleteNodes]);
296
471
 
297
472
  const onDragOver = useStableCallback((event: DragEvent) => {
298
473
  event.preventDefault();
@@ -324,11 +499,55 @@ function Canvas({
324
499
  const ids = new Set(dragged.map((node) => node.id));
325
500
  return dragged.filter((node) => !node.parentId || !ids.has(node.parentId));
326
501
  };
327
- const onNodeDragStart = useCallback(() => {
502
+ // While something's dragged, the containers around it grow to fit it (see previewContainerGrowth): the canvas
503
+ // when the drag started, and those containers, innermost first
504
+ const dragStart = useRef<{ nodes: Map<string, Node>; containers: string[] } | null>(null);
505
+ const onNodeDragStart = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
328
506
  pauseCamera.current();
329
507
  setInteraction('dragging', true);
330
- }, [setInteraction]);
508
+ const lookup = lookupNodes();
509
+ const containers: string[] = [];
510
+ for (const node of dragged) {
511
+ for (let id = lookup.get(node.id)?.parentId; id; id = lookup.get(id)?.parentId) {
512
+ if (!containers.includes(id)) containers.push(id);
513
+ }
514
+ }
515
+ // Innermost first: deeper containers before the ones they're in
516
+ const depth = (id: string) => {
517
+ let level = 0;
518
+ for (let parent = lookup.get(id)?.parentId; parent; parent = lookup.get(parent)?.parentId) level++;
519
+ return level;
520
+ };
521
+ containers.sort((a, b) => depth(b) - depth(a));
522
+ dragStart.current = containers.length ? { nodes: lookup, containers } : null;
523
+ threadsDrag.current = selectedThreads.size && dragged[0] ? { id: dragged[0].id, from: canvasPositionOf(dragged[0]) } : null;
524
+ });
525
+ // Where a dragged node is on the canvas (its position is relative to its container)
526
+ const canvasPositionOf = (node: Node) => {
527
+ const lookup = lookupNodes();
528
+ lookup.set(node.id, { ...lookup.get(node.id)!, position: node.position });
529
+ return getAbsolutePosition(lookup.get(node.id)!, lookup);
530
+ };
531
+ // Selected comments move with what's dragged: how far it's moved from where it was
532
+ const threadsDrag = useRef<{ id: string; from: XYPosition } | null>(null);
533
+ const moveThreadsWith = (dragged: Node[]) => {
534
+ const drag = threadsDrag.current;
535
+ const node = drag && dragged.find((candidate) => candidate.id === drag.id);
536
+ if (!drag || !node) return;
537
+ const at = canvasPositionOf(node);
538
+ setThreadsOffset({ x: at.x - drag.from.x, y: at.y - drag.from.y });
539
+ };
540
+ const growContainers = (dragged: Node[]) => {
541
+ const started = dragStart.current;
542
+ if (!started) return;
543
+ // Where the dragged nodes are now (the canvas's own nodes can be a frame behind)
544
+ const now = lookupNodes();
545
+ dragged.forEach((node) => now.set(node.id, { ...now.get(node.id)!, position: node.position }));
546
+ flow.showDragPreview(previewContainerGrowth(started.containers, new Set(dragged.map((node) => node.id)), started.nodes, now));
547
+ };
331
548
  const onNodeDrag = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
549
+ growContainers(dragged);
550
+ moveThreadsWith(dragged);
332
551
  const [root] = dragRoots(dragged);
333
552
  const current = root && nodesRef.current.find((node) => node.id === root.id);
334
553
  const target = root ? findDropTarget(root.id, nodesRef.current) : undefined;
@@ -337,6 +556,22 @@ function Canvas({
337
556
  const onNodeDragStop = useStableCallback((_: unknown, __: Node, dragged: Node[]) => {
338
557
  setInteraction('dragging', false);
339
558
  setDropTargetId(null);
559
+ dragStart.current = null;
560
+ // Selected comments not on a node are saved where they moved to
561
+ if (threadsDrag.current && threadsOffset) {
562
+ const offset = threadsOffset;
563
+ comments.moveThreads(
564
+ comments.threads
565
+ .filter((thread) => selectedThreads.has(thread.id) && !thread.nodeId)
566
+ .map((thread) => ({
567
+ threadId: thread.id,
568
+ anchor: { position: { x: thread.position.x + offset.x, y: thread.position.y + offset.y } },
569
+ }))
570
+ );
571
+ }
572
+ threadsDrag.current = null;
573
+ setThreadsOffset(null);
574
+ // The containers it grew are saved with the drop; dropped in another container (or out of one), it moves there
340
575
  for (const root of dragRoots(dragged)) {
341
576
  const current = nodesRef.current.find((node) => node.id === root.id);
342
577
  const target = findDropTarget(root.id, nodesRef.current);
@@ -353,13 +588,15 @@ function Canvas({
353
588
  const editable = level === 3;
354
589
  const [levelGraph, setLevelGraph] = useState<{ level: Level; nodes: Node[]; edges: Edge[] } | null>(null);
355
590
 
356
- // What a level shows depends on what's on the canvas and how it's connected, and names (not positions)
591
+ // What a level shows depends on what's on the canvas and how it's connected, and names (not positions). Notes
592
+ // aren't laid out with it: adding, moving or writing one doesn't lay the level out again.
357
593
  const levelKey =
358
594
  level === 3
359
595
  ? ''
360
- : `${flow.nodes.map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}:${getNodeName(node.type, node.data)}`).join('|')}#${flow.edges
361
- .map((edge) => `${edge.source}>${edge.target}:${String(edge.label ?? '')}`)
362
- .join('|')}`;
596
+ : `${flow.nodes
597
+ .filter((node) => node.type !== 'note')
598
+ .map((node) => `${node.id}:${node.type}:${node.parentId ?? ''}:${getNodeName(node.type, node.data)}`)
599
+ .join('|')}#${flow.edges.map((edge) => `${edge.source}>${edge.target}:${String(edge.label ?? '')}`).join('|')}`;
363
600
  const shownLevelRef = useRef<Level | undefined>(undefined);
364
601
  shownLevelRef.current = levelGraph?.level;
365
602
  useEffect(() => {
@@ -387,6 +624,12 @@ function Canvas({
387
624
  const [draft, setDraft] = useState<CommentAnchor | null>(null);
388
625
  const [openThreadId, setOpenThreadId] = useState<string | null>(null);
389
626
  const [showResolved, setShowResolved] = useState(false);
627
+ // Comments that are part of the selection (selecting everything selects them too): only here, like what nodes
628
+ // are selected. While what's selected is dragged, the ones not on a node move with it by this much.
629
+ const [selectedThreads, setSelectedThreads] = useState<ReadonlySet<string>>(NO_THREADS);
630
+ const clearSelectedThreads = useCallback(() => setSelectedThreads((current) => (current.size ? NO_THREADS : current)), []);
631
+ const [threadsOffset, setThreadsOffset] = useState<XYPosition | null>(null);
632
+ const threadsShown = () => comments.threads.filter((thread) => showResolved || !thread.resolved);
390
633
  const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
391
634
 
392
635
  const changeLevel = useStableCallback((next: Level) => {
@@ -395,6 +638,8 @@ function Canvas({
395
638
  setCommentMode(false);
396
639
  setDraft(null);
397
640
  setOpenThreadId(null);
641
+ // What's selected may not be on the next level (e.g. a note added on this one)
642
+ flow.onNodesChange(selectedIds().map((id) => ({ type: 'select', id, selected: false })));
398
643
  setLevel(next);
399
644
  });
400
645
  const editInL3 = useCallback(() => changeLevel(3), [changeLevel]);
@@ -403,7 +648,9 @@ function Canvas({
403
648
  const shownLevel = level === 3 ? 3 : levelGraph?.level;
404
649
  useEffect(() => {
405
650
  if (shownLevel === undefined) return;
406
- const frame = requestAnimationFrame(() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 450 }));
651
+ // Like the visualiser, a level isn't zoomed in past its real size
652
+ const options = shownLevel === 3 ? FIT_VIEW_OPTIONS : LEVEL_FIT_VIEW_OPTIONS;
653
+ const frame = requestAnimationFrame(() => void fitView({ ...options, duration: 450 }));
407
654
  return () => cancelAnimationFrame(frame);
408
655
  }, [shownLevel]);
409
656
 
@@ -430,7 +677,19 @@ function Canvas({
430
677
  return hierarchyOrder.flatMap((id) => byId.get(id) ?? []);
431
678
  }, [flow.nodes, hierarchyOrder]);
432
679
  const levelShown = level !== 3 && levelGraph?.level === level ? levelGraph : null;
433
- const shownNodes = levelShown ? levelShown.nodes : sortedNodes;
680
+ // Sticky notes are shown on the level they were added on: on L1 and L2, over the level's layout
681
+ const shownNodes = useMemo(() => {
682
+ if (!levelShown)
683
+ return sortedNodes.every((node) => isOnLevel(node, 3)) ? sortedNodes : sortedNodes.filter((node) => isOnLevel(node, 3));
684
+ const notes = sortedNodes.filter((node) => getNoteLevel(node) === levelShown.level);
685
+ return notes.length ? [...levelShown.nodes, ...notes.map(asLevelNote)] : levelShown.nodes;
686
+ }, [levelShown, sortedNodes]);
687
+ /** On L1 and L2, only changes to the level's notes go to the canvas (its other nodes are drawn from it) */
688
+ const onLevelNodesChange = useStableCallback((changes: NodeChange[]) => {
689
+ const notes = new Set(nodesRef.current.filter((node) => getNoteLevel(node) === levelRef.current).map((node) => node.id));
690
+ const ofNotes = changes.filter((change) => change.type !== 'add' && notes.has(change.id));
691
+ if (ofNotes.length) flow.onNodesChange(ofNotes);
692
+ });
434
693
  // Message edges' envelopes only move on what's selected (the edge, or a node at either end): any moving
435
694
  // envelope at all costs a style recalc and layout every frame, which an editing canvas can't afford at idle
436
695
  const shownEdges = useMemo(() => {
@@ -463,6 +722,9 @@ function Canvas({
463
722
  const [following, setFollowing] = useState(followChanges);
464
723
  const toggleFollowing = useCallback(() => setFollowing((on) => !on), []);
465
724
 
725
+ const [shareOpen, setShareOpen] = useState(false);
726
+ const openShare = useCallback(() => setShareOpen(true), []);
727
+ const closeShare = useCallback(() => setShareOpen(false), []);
466
728
  const [connectOpen, setConnectOpen] = useState(false);
467
729
  const openConnect = useCallback(() => setConnectOpen(true), []);
468
730
  const closeConnect = useCallback(() => setConnectOpen(false), []);
@@ -484,19 +746,33 @@ function Canvas({
484
746
  'mod+a',
485
747
  () => {
486
748
  if (!onCanvas()) return;
487
- flow.onNodesChange(nodesRef.current.map((node) => ({ type: 'select', id: node.id, selected: true })));
749
+ flow.onNodesChange(
750
+ nodesRef.current.filter((node) => isOnLevel(node, 3)).map((node) => ({ type: 'select', id: node.id, selected: true }))
751
+ );
488
752
  flow.onEdgesChange(edgesRef.current.map((edge) => ({ type: 'select', id: edge.id, selected: true })));
753
+ // ...and the comments shown on it
754
+ setSelectedThreads(new Set(threadsShown().map((thread) => thread.id)));
755
+ },
756
+ { preventDefault: true }
757
+ );
758
+ useHotkeys(
759
+ 'mod+d',
760
+ () => {
761
+ const content = onCanvas() && copyNodes(nodesRef.current, edgesRef.current, selectedIds());
762
+ if (content) paste(content, { offset: { x: 40, y: 40 } });
489
763
  },
490
764
  { preventDefault: true }
491
765
  );
492
- useHotkeys('mod+z', () => onCanvas() && flow.undo(), { preventDefault: true });
493
- useHotkeys(['mod+shift+z', 'mod+y'], () => onCanvas() && flow.redo(), { preventDefault: true });
766
+ // On every level: notes are changed on L1 and L2 too (and the levels show what's undone on the canvas)
767
+ useHotkeys('mod+z', () => flow.undo(), { preventDefault: true });
768
+ useHotkeys(['mod+shift+z', 'mod+y'], () => flow.redo(), { preventDefault: true });
494
769
  useHotkeys('c', () => onCanvas() && setCommentMode((on) => !on));
495
770
  useHotkeys('escape', () => {
496
771
  setCommentMode(false);
497
772
  setDraft(null);
498
773
  setOpenThreadId(null);
499
- if (!onCanvas()) return;
774
+ clearSelectedThreads();
775
+ setEditingId(null);
500
776
  flow.onNodesChange(
501
777
  nodesRef.current.filter((node) => node.selected).map((node) => ({ type: 'select', id: node.id, selected: false }))
502
778
  );
@@ -504,6 +780,12 @@ function Canvas({
504
780
  edgesRef.current.filter((edge) => edge.selected).map((edge) => ({ type: 'select', id: edge.id, selected: false }))
505
781
  );
506
782
  });
783
+ // Selected comments are deleted with what's selected (React Flow deletes the nodes and edges)
784
+ useHotkeys(DELETE_KEYS, () => {
785
+ if (!onCanvas() || selectedThreads.size === 0) return;
786
+ comments.deleteThreads([...selectedThreads]);
787
+ clearSelectedThreads();
788
+ });
507
789
  const toggleCommentMode = useCallback(() => setCommentMode((on) => !on), []);
508
790
 
509
791
  const startComment = (anchor: CommentAnchor) => {
@@ -516,12 +798,31 @@ function Canvas({
516
798
  screenToFlowPosition({ x: event.clientX, y: event.clientY });
517
799
 
518
800
  const onPaneClick = useStableCallback((event: ReactMouseEvent) => {
801
+ clearSelectedThreads();
519
802
  if (commentMode && editable) return startComment(anchorAt(flowPositionOf(event)));
803
+ // A click inside a container (not on something in it) selects it: its body lets the pointer through so you can
804
+ // drag a selection box in it, so the click lands on the canvas
805
+ // (after React Flow clears the selection, which it does once this returns)
806
+ const container = editable ? findGroupAtPoint(flowPositionOf(event), nodesRef.current) : undefined;
807
+ if (container) queueMicrotask(() => flow.onNodesChange([{ type: 'select', id: container.id, selected: true }]));
520
808
  setDraft(null);
521
809
  setOpenThreadId(null);
522
810
  });
523
811
 
812
+ // Double clicking a node edits its details (notes are edited on the canvas, by double clicking them too)
813
+ const onNodeDoubleClick = useStableCallback((_: ReactMouseEvent, node: Node) => {
814
+ if (editable && !isWrittenOnCanvas(node.type)) setEditingId(node.id);
815
+ });
816
+ // ...and inside a container (not on something in it): its body lets the pointer through, so it's on the canvas
817
+ const onCanvasDoubleClick = useStableCallback((event: ReactMouseEvent) => {
818
+ if (!editable || !(event.target as Element).classList?.contains('react-flow__pane')) return;
819
+ const container = findGroupAtPoint(flowPositionOf(event), nodesRef.current);
820
+ if (container) setEditingId(container.id);
821
+ });
822
+
524
823
  const onNodeClick = useStableCallback((event: ReactMouseEvent, node: Node) => {
824
+ // Clicking one node selects just it (with shift or ⌘, it's added to what's selected)
825
+ if (!event.shiftKey && !event.metaKey) clearSelectedThreads();
525
826
  if (commentMode && editable) startComment(anchorAt(flowPositionOf(event), node, lookupNodes()));
526
827
  });
527
828
 
@@ -537,8 +838,35 @@ function Canvas({
537
838
  if (contextMenu) startComment(anchorAt(contextMenu.flowPosition, contextMenu.node, lookupNodes()));
538
839
  setContextMenu(null);
539
840
  });
540
- const deleteFromMenu = useStableCallback((id: string) => {
541
- flow.deleteNodes([id]);
841
+ /** The node right clicked, or the whole selection if it's part of it */
842
+ const menuTargets = () => {
843
+ const node = contextMenu?.node;
844
+ if (!node) return [];
845
+ const selected = selectedIds();
846
+ return selected.includes(node.id) ? selected : [node.id];
847
+ };
848
+ const editFromMenu = useStableCallback(() => {
849
+ if (contextMenu?.node) setEditingId(contextMenu.node.id);
850
+ setContextMenu(null);
851
+ });
852
+ const copyFromMenu = useStableCallback(() => {
853
+ // Through the copy event, so it's copied like ⌘C (to the system clipboard too)
854
+ copyOnly.current = menuTargets();
855
+ document.execCommand('copy');
856
+ copyOnly.current = null;
857
+ setContextMenu(null);
858
+ });
859
+ const duplicateFromMenu = useStableCallback(() => {
860
+ const content = copyNodes(nodesRef.current, edgesRef.current, menuTargets());
861
+ if (content) paste(content, { offset: { x: 40, y: 40 } });
862
+ setContextMenu(null);
863
+ });
864
+ const pasteFromMenu = useStableCallback(() => {
865
+ if (lastCopied && contextMenu) paste(lastCopied, { at: contextMenu.flowPosition });
866
+ setContextMenu(null);
867
+ });
868
+ const deleteFromMenu = useStableCallback(() => {
869
+ flow.deleteNodes(menuTargets());
542
870
  setContextMenu(null);
543
871
  });
544
872
 
@@ -556,7 +884,8 @@ function Canvas({
556
884
  }, []);
557
885
  const createThread = useStableCallback((text: string) => {
558
886
  if (!draft) return;
559
- setOpenThreadId(comments.createThread(draft, text) ?? null);
887
+ // Posted, it's a pin like the others (not opened): click it to see or reply to it
888
+ comments.createThread(draft, text);
560
889
  setDraft(null);
561
890
  });
562
891
  const cancelDraft = useCallback(() => setDraft(null), []);
@@ -580,11 +909,22 @@ function Canvas({
580
909
  })
581
910
  );
582
911
  }, [selectedKey]);
583
- const single = editable && selected.length === 1 && selected[0].type !== 'note' ? selected[0] : null;
912
+ // The node whose details are being edited in the panel: opened by double clicking it, or "Edit details" in its
913
+ // menu (selecting a node doesn't open it). Notes are edited on the canvas.
914
+ const [editingId, setEditingId] = useState<string | null>(null);
915
+ const editing =
916
+ editable && editingId ? flow.nodes.find((node) => node.id === editingId && !isWrittenOnCanvas(node.type)) : undefined;
917
+ // What the panel shows (not the node's position, so dragging it doesn't re-render the panel)
584
918
  const inspected = useMemo(
585
- () => (single ? { id: single.id, type: single.type, data: single.data } : null),
586
- [single?.id, single?.type, single?.data]
919
+ () => (editing ? { id: editing.id, type: editing.type, data: editing.data } : null),
920
+ [editing?.id, editing?.type, editing?.data]
587
921
  );
922
+ const closePanel = useCallback(() => setEditingId(null), []);
923
+ // An accepted or rejected canvas, and when it was decided
924
+ const { status: canvasStatus, history: statusHistory } = flow.canvasStatus;
925
+ const decided = isDecided(canvasStatus)
926
+ ? { status: canvasStatus, change: statusHistory.findLast((change) => change.status === canvasStatus) }
927
+ : null;
588
928
 
589
929
  return (
590
930
  <UpdateNodeDataContext.Provider value={flow.updateNodeData}>
@@ -594,7 +934,7 @@ function Canvas({
594
934
  resources={resources}
595
935
  keysOnCanvas={keysOnCanvas}
596
936
  onAddComponent={addComponent}
597
- onAddResource={addResource}
937
+ onAddResource={addResourceFromPanel}
598
938
  threads={comments.threads}
599
939
  showResolved={showResolved}
600
940
  onToggleResolved={toggleResolved}
@@ -608,6 +948,7 @@ function Canvas({
608
948
  }`}
609
949
  onPointerMove={trackPointer}
610
950
  onPointerLeave={clearPointer}
951
+ onDoubleClick={onCanvasDoubleClick}
611
952
  >
612
953
  <ReactFlow
613
954
  nodes={shownNodes}
@@ -617,18 +958,25 @@ function Canvas({
617
958
  nodesDraggable={editable}
618
959
  nodesConnectable={editable}
619
960
  elementsSelectable={editable}
620
- onNodesChange={editable ? flow.onNodesChange : undefined}
961
+ onNodesChange={editable ? flow.onNodesChange : onLevelNodesChange}
621
962
  onEdgesChange={editable ? flow.onEdgesChange : undefined}
622
963
  onConnect={flow.onConnect}
964
+ // Drag either end of a connection to another node (dropped anywhere else, it stays where it was)
965
+ edgesReconnectable={editable}
966
+ onReconnect={editable ? flow.onReconnect : undefined}
967
+ reconnectRadius={RECONNECT_RADIUS}
623
968
  onDragOver={onDragOver}
624
969
  onMoveStart={onMoveStart}
625
970
  onMoveEnd={onMoveEnd}
626
971
  onNodeDragStart={onNodeDragStart}
627
- onNodeDrag={editable ? onNodeDrag : undefined}
628
- onNodeDragStop={editable ? onNodeDragStop : undefined}
972
+ onNodeDrag={onNodeDrag}
973
+ onNodeDragStop={onNodeDragStop}
629
974
  onDrop={onDrop}
630
975
  onPaneClick={onPaneClick}
631
976
  onNodeClick={onNodeClick}
977
+ onNodeDoubleClick={onNodeDoubleClick}
978
+ onError={onFlowError}
979
+ onSelectionStart={clearSelectedThreads}
632
980
  onPaneContextMenu={onPaneContextMenu}
633
981
  onNodeContextMenu={onNodeContextMenu}
634
982
  connectionLineType={ConnectionLineType.SmoothStep}
@@ -663,6 +1011,8 @@ function Canvas({
663
1011
  onMove={comments.moveThread}
664
1012
  onMoveDraft={setDraft}
665
1013
  onDelete={deleteThread}
1014
+ selectedIds={selectedThreads}
1015
+ selectionOffset={threadsOffset}
666
1016
  />
667
1017
  )}
668
1018
  </ReactFlow>
@@ -680,7 +1030,7 @@ function Canvas({
680
1030
  <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))]">
681
1031
  <span>
682
1032
  <span className="font-semibold">L{level}</span> · {LEVELS.find((entry) => entry.level === level)?.description} ·
683
- read only
1033
+ read only, sticky notes can be added
684
1034
  </span>
685
1035
  <button onClick={editInL3} className="font-semibold text-[rgb(var(--ec-accent))] hover:underline">
686
1036
  Edit in L3
@@ -688,6 +1038,19 @@ function Canvas({
688
1038
  </div>
689
1039
  )}
690
1040
 
1041
+ {/* Accepted or rejected: still editable, but changing it makes it a draft again (for everyone) */}
1042
+ {editable && decided && (
1043
+ <div
1044
+ 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}`}
1045
+ >
1046
+ <span className="truncate">
1047
+ <span className="font-semibold">{CANVAS_STATUS_LOOK[decided.status].label}</span>
1048
+ {decided.change && ` by ${decided.change.by.name} · ${timeAgo(decided.change.at)}`}
1049
+ {decided.change?.note && ` · “${decided.change.note}”`} · changing it moves it back to Draft
1050
+ </span>
1051
+ </div>
1052
+ )}
1053
+
691
1054
  <CanvasControls
692
1055
  level={level}
693
1056
  onLevelChange={changeLevel}
@@ -706,6 +1069,10 @@ function Canvas({
706
1069
  <CanvasContextMenu
707
1070
  menu={contextMenu}
708
1071
  onAddComment={commentFromMenu}
1072
+ onEditDetails={editFromMenu}
1073
+ onCopy={copyFromMenu}
1074
+ onDuplicate={duplicateFromMenu}
1075
+ onPaste={lastCopied ? pasteFromMenu : undefined}
709
1076
  onDeleteNode={deleteFromMenu}
710
1077
  onClose={closeContextMenu}
711
1078
  />
@@ -720,12 +1087,17 @@ function Canvas({
720
1087
  onJumpTo={jumpTo}
721
1088
  onRename={onRename}
722
1089
  onConnectAgent={openConnect}
1090
+ onShare={openShare}
723
1091
  webMcp={webMcp}
724
1092
  shareUrl={shareUrl}
725
1093
  newCanvasUrl={newCanvasUrl}
1094
+ canvasStatus={flow.canvasStatus.status}
1095
+ statusHistory={flow.canvasStatus.history}
1096
+ onStatusChange={flow.changeStatus}
726
1097
  />
727
1098
 
728
- {flow.status === 'connected' && flow.nodes.length === 0 && (
1099
+ {/* Only on an empty canvas (not over comments people have left on it) */}
1100
+ {flow.status === 'connected' && flow.nodes.length === 0 && comments.threads.length === 0 && !draft && (
729
1101
  <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
730
1102
  <p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
731
1103
  Drag components or catalog resources from the left onto the canvas, then share the link to work on it together.
@@ -743,6 +1115,28 @@ function Canvas({
743
1115
  onCancel={cancelChooser}
744
1116
  />
745
1117
  )}
1118
+ {connectionsChooser && (
1119
+ <ConnectionsChooser
1120
+ resource={connectionsChooser.resource}
1121
+ groups={connectionsChooser.groups}
1122
+ position={flowToScreenPosition(connectionsChooser.center)}
1123
+ onChoose={chooseConnections}
1124
+ onCancel={cancelConnectionsChooser}
1125
+ />
1126
+ )}
1127
+ {shareOpen && (
1128
+ <ShareDialog
1129
+ keptInMemory={keptInMemory}
1130
+ url={agentLink.canvasUrl}
1131
+ title={flow.title}
1132
+ presence={flow.presence}
1133
+ clientId={flow.clientId}
1134
+ onRename={onRename}
1135
+ onJumpTo={jumpTo}
1136
+ onConnectAgent={openConnect}
1137
+ onClose={closeShare}
1138
+ />
1139
+ )}
746
1140
  {connectOpen && (
747
1141
  <ConnectAgentDialog
748
1142
  link={agentLink}
@@ -752,7 +1146,15 @@ function Canvas({
752
1146
  onClose={closeConnect}
753
1147
  />
754
1148
  )}
755
- {inspected && <PropertiesPanel node={inspected} onChange={flow.updateNodeData} onDelete={flow.deleteNodes} />}
1149
+ {inspected && (
1150
+ <PropertiesPanel
1151
+ key={inspected.id}
1152
+ node={inspected}
1153
+ onChange={flow.updateNodeData}
1154
+ onDelete={flow.deleteNodes}
1155
+ onClose={closePanel}
1156
+ />
1157
+ )}
756
1158
  </div>
757
1159
  </DropTargetContext.Provider>
758
1160
  </UpdateNodeDataContext.Provider>