@eventcatalog/core 5.6.1 → 5.7.1

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 (43) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +3 -3
  5. package/dist/{chunk-LVOGLB6C.js → chunk-36XVRD36.js} +1 -1
  6. package/dist/{chunk-N7LDFUEB.js → chunk-AXWTR2FV.js} +1 -1
  7. package/dist/{chunk-QJXVAM76.js → chunk-CPF5WE3R.js} +1 -1
  8. package/dist/{chunk-2NALBBWH.js → chunk-GRC67WHC.js} +1 -1
  9. package/dist/{chunk-L63ED43X.js → chunk-HZ3XZKUT.js} +1 -1
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +1 -1
  13. package/dist/eventcatalog.js +5 -5
  14. package/dist/generate.cjs +1 -1
  15. package/dist/generate.js +3 -3
  16. package/dist/utils/cli-logger.cjs +1 -1
  17. package/dist/utils/cli-logger.js +2 -2
  18. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +596 -122
  19. package/eventcatalog/src/components/ChatPanel/ChatPanelButton.tsx +3 -2
  20. package/eventcatalog/src/components/ChatPanel/assistant-events.ts +37 -0
  21. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +21 -1
  22. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +2 -1
  23. package/eventcatalog/src/components/MDX/ResourceRef/ResourceRef.astro +2 -34
  24. package/eventcatalog/src/features/ai/chat-api.ts +24 -5
  25. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +75 -74
  26. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +73 -73
  27. package/eventcatalog/src/features/studio/agent-choreography.ts +84 -16
  28. package/eventcatalog/src/features/studio/api/canvases-api.ts +3 -0
  29. package/eventcatalog/src/features/studio/canvas-actions.ts +330 -15
  30. package/eventcatalog/src/features/studio/catalog.ts +5 -3
  31. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +14 -0
  32. package/eventcatalog/src/features/studio/components/NodeDetails.tsx +4 -0
  33. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +165 -18
  34. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +9 -4
  35. package/eventcatalog/src/features/studio/pages/[id].astro +2 -0
  36. package/eventcatalog/src/features/studio/server/assistant-tools.ts +207 -0
  37. package/eventcatalog/src/features/studio/server/canvas-agent.ts +27 -0
  38. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +2 -14
  39. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +4 -0
  40. package/eventcatalog/src/utils/node-graphs/compact-visualiser-graph.ts +14 -0
  41. package/eventcatalog/src/utils/node-graphs/node-resource.ts +26 -0
  42. package/eventcatalog/src/utils/resource-reference-icons.ts +37 -0
  43. package/package.json +7 -7
@@ -97,15 +97,17 @@ export const buildContainer = (resource: CatalogResource, center: { x: number; y
97
97
  * A domain or system as a container with everything it holds in the catalog: its systems (as containers too)
98
98
  * and services, and the messages and data stores of those services, so every level of detail (L1 to L3) has
99
99
  * something to show. Laid out with the visualiser's layout and centred on a point, with the connections between
100
- * them and to related resources already on the canvas. Resources already on the canvas aren't added again.
100
+ * them and to related resources already on the canvas. Resources already on the canvas aren't added again, nor
101
+ * any in `alsoAdding` (by catalog key: added some other way at the same time, e.g. by an agent in the same call).
101
102
  */
102
103
  export const buildContainerWithContents = async (
103
104
  resource: CatalogResource,
104
105
  center: { x: number; y: number },
105
106
  catalog: { resourcesByKey: Map<string, CatalogResource>; relationsByKey: Map<string, CatalogRelation[]> },
106
- existing: Node[]
107
+ existing: Node[],
108
+ alsoAdding: ReadonlySet<string> = new Set()
107
109
  ): Promise<{ nodes: Node[]; edges: Edge[] }> => {
108
- const onCanvas = new Set(existing.map((node) => getCatalogLink(node)?.key).filter(Boolean));
110
+ const onCanvas = new Set([...existing.map((node) => getCatalogLink(node)?.key).filter(Boolean), ...alsoAdding]);
109
111
  const root = buildContainer(resource, { x: 0, y: 0 });
110
112
  const nodes: Node[] = [root];
111
113
  const added = (key: string) => onCanvas.has(key) || nodes.some((node) => getCatalogLink(node)?.key === key);
@@ -10,6 +10,7 @@ import {
10
10
  Plug,
11
11
  Plus,
12
12
  Share2,
13
+ Sparkles,
13
14
  Trash2,
14
15
  Users,
15
16
  } from 'lucide-react';
@@ -440,6 +441,7 @@ export default memo(function CanvasHeader({
440
441
  statusHistory,
441
442
  onStatusChange,
442
443
  onDelete,
444
+ onDrawWithAi,
443
445
  }: {
444
446
  title?: string;
445
447
  onRetitle: (title: string) => void;
@@ -470,6 +472,8 @@ export default memo(function CanvasHeader({
470
472
  onHelp: () => void;
471
473
  /** Ask to delete the canvas (not given where it can't be, e.g. inside a chat) */
472
474
  onDelete?: () => void;
475
+ /** Opens the assistant to draw on the canvas (not given where there isn't one, e.g. inside a chat) */
476
+ onDrawWithAi?: () => void;
473
477
  }) {
474
478
  const peers = usePeople(presence);
475
479
  const agentsHere = peers.some((peer) => peer.agent);
@@ -505,6 +509,16 @@ export default memo(function CanvasHeader({
505
509
  </div>
506
510
  )}
507
511
  <ProfileMenu me={me} onRename={onRename} onRecolor={onRecolor} />
512
+ {onDrawWithAi && (
513
+ <button
514
+ onClick={onDrawWithAi}
515
+ title="Describe what to diagram and the assistant draws it here"
516
+ className={`${barClass} gap-2 px-3 text-sm font-medium text-[rgb(var(--ec-page-text))] transition-colors hover:bg-[rgb(var(--ec-accent-subtle))]`}
517
+ >
518
+ <Sparkles size={16} className="text-[rgb(var(--ec-accent))]" aria-hidden />
519
+ Draw with AI
520
+ </button>
521
+ )}
508
522
  <button
509
523
  onClick={onShare}
510
524
  className="pointer-events-auto inline-flex h-[38px] items-center gap-2 rounded-[9px] bg-[rgb(var(--ec-page-text))] px-4 text-sm font-semibold text-[rgb(var(--ec-page-bg))] shadow-sm transition-colors hover:bg-[rgb(var(--ec-page-text)/0.85)]"
@@ -30,6 +30,7 @@ export default memo(function NodeDetails({
30
30
  connections,
31
31
  onOpenNode,
32
32
  containerRef,
33
+ nameEmpty,
33
34
  locked,
34
35
  onChange,
35
36
  onClose,
@@ -43,6 +44,8 @@ export default memo(function NodeDetails({
43
44
  onOpenNode: (id: string) => void;
44
45
  /** The canvas, to put the card next to the node */
45
46
  containerRef: RefObject<HTMLElement | null>;
47
+ /** Its name starts empty, to type it straight in (a node just made: it keeps its name if nothing's typed) */
48
+ nameEmpty?: boolean;
46
49
  locked: boolean;
47
50
  onChange: UpdateNodeData;
48
51
  onClose: () => void;
@@ -112,6 +115,7 @@ export default memo(function NodeDetails({
112
115
  onSaved={showSaved}
113
116
  placeholder={`Name this ${label.toLowerCase()}`}
114
117
  className="ec-editable-title"
118
+ startEmpty={nameEmpty}
115
119
  required
116
120
  />
117
121
  </Field>
@@ -98,6 +98,12 @@ import {
98
98
  type HotspotSeverity,
99
99
  type HotspotSpot,
100
100
  RemeasureHandles,
101
+ ConnectChooser,
102
+ isCardType,
103
+ newCardData,
104
+ notPlaceholder,
105
+ useConnectNew,
106
+ type NewLink,
101
107
  } from '@eventcatalog/visualiser/studio';
102
108
  import { X } from 'lucide-react';
103
109
  import type { CatalogRelation, CatalogResource } from '../catalog-resources';
@@ -127,6 +133,12 @@ import ShareDialog from './ShareDialog';
127
133
  import DeleteCanvasDialog from './DeleteCanvasDialog';
128
134
  import { createCanvasThroughApi, deleteCanvasThroughApi } from '../api/client';
129
135
  import ShortcutsDialog from './ShortcutsDialog';
136
+ import {
137
+ ASSISTANT_DRAWING_EVENT,
138
+ CANVAS_FOCUS_EVENT,
139
+ CANVAS_HOTSPOT_EVENT,
140
+ OPEN_CHAT_EVENT,
141
+ } from '../../../components/ChatPanel/assistant-events';
130
142
 
131
143
  type CanvasProps = {
132
144
  canvasId: string;
@@ -159,6 +171,8 @@ type CanvasProps = {
159
171
  asAgent?: { name: string; color: string };
160
172
  /** Signed in with the catalog's sign-in (SSO): you're shown by this name and picture, and can't change them */
161
173
  signedInAs?: { name: string; picture?: string };
174
+ /** The catalog's assistant (Ask AI) is on the page: "Draw with AI" opens it to draw on the canvas */
175
+ assistant?: boolean;
162
176
  resources: CatalogResource[];
163
177
  relations: CatalogRelation[];
164
178
  };
@@ -192,6 +206,7 @@ type Landing = {
192
206
  };
193
207
  const DELETE_KEYS = ['Backspace', 'Delete'];
194
208
  const NO_THREADS: ReadonlySet<string> = new Set();
209
+ const NO_HOTSPOTS: ReadonlySet<string> = new Set();
195
210
  // React Flow's warnings, except that a handle has no node: the left panel's drag previews draw nodes off the canvas
196
211
  const onFlowError = (code: string, message: string) => {
197
212
  if (code !== '010') console.warn(message);
@@ -333,6 +348,9 @@ export default function StudioDesigner({ asAgent, signedInAs, ...props }: Canvas
333
348
  );
334
349
  }
335
350
 
351
+ // The assistant's panel (ChatPanelButton, in the page header) opens on this; on a canvas it draws on it
352
+ const openAssistant = () => window.dispatchEvent(new Event(OPEN_CHAT_EVENT));
353
+
336
354
  const toSocketUrl = (socketPath = DEFAULT_SOCKET_PATH) =>
337
355
  `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}${socketPath}`;
338
356
 
@@ -347,6 +365,7 @@ function Canvas({
347
365
  mcpUrl = '/docs/mcp',
348
366
  relayScriptPath,
349
367
  keptInMemory = false,
368
+ assistant = false,
350
369
  syncViaTools,
351
370
  followChanges = false,
352
371
  paletteCollapsed,
@@ -625,11 +644,23 @@ function Canvas({
625
644
  centring.current.set(id, { level, size });
626
645
  flow.setLevelLayout(level, new Map([[id, centredOn(center, size)]]));
627
646
  };
628
- /** A component (its type, or its key in the components list: the kinds of data store share a type) */
629
- const addComponentAt = (component: string, point: XYPosition) => {
630
- if (component === 'note') return addNoteAt(point);
647
+ /**
648
+ * A component (its type, or its key in the components list: the kinds of data store share a type), connected to
649
+ * the node a connection was dragged from when there is one
650
+ */
651
+ const addComponentAt = (component: string, point: XYPosition, link?: NewLink) => {
652
+ if (component === 'note') return void addNoteAt(point);
631
653
  const at = landingAt(point, getNodeDefinition(component)?.type ?? component);
632
- keepOnLevel(at, flow.addNode(component, at.center, at.inside));
654
+ const id = flow.addNode(component, at.center, at.inside, link);
655
+ keepOnLevel(at, id);
656
+ return id;
657
+ };
658
+ /** A new domain or system as a card (the visualiser's, as Studio on the web makes them), connected to `link`'s node */
659
+ const addCardAt = (type: 'context-domain' | 'system', point: XYPosition, link: NewLink) => {
660
+ const at = landingAt(point, type);
661
+ const id = flow.insertNode(type, newCardData(type), at.center, undefined, at.inside, link);
662
+ keepOnLevel(at, id);
663
+ return id;
633
664
  };
634
665
 
635
666
  const addComponent = useStableCallback((type: string) => addComponentAt(type, canvasCenter()));
@@ -728,6 +759,21 @@ function Canvas({
728
759
  trackPointer(event);
729
760
  });
730
761
 
762
+ // ---- Connecting to something new: a connection let go of on empty canvas ----
763
+
764
+ // A placeholder where it was let go of, connected to the node it came from, until what it is is chosen beside it
765
+ const connect = useConnectNew({
766
+ canvasRef,
767
+ getNode: (id) => nodesRef.current.find((node) => node.id === id),
768
+ getLevel: () => levelRef.current,
769
+ locked,
770
+ onMake: (option, center, link) => {
771
+ const id = isCardType(option.type) ? addCardAt(option.type, center, link) : addComponentAt(option.key, center, link);
772
+ // Selected, and its details open to name it, once it's on the canvas
773
+ if (id) editOnceShown.current = id;
774
+ },
775
+ });
776
+
731
777
  // While the canvas is panned, zoomed or dragged, message edges stop animating (a class, not state, so nothing re-renders)
732
778
  const interactions = useRef({ moving: false, dragging: false });
733
779
  const setInteraction = useCallback((kind: 'moving' | 'dragging', on: boolean) => {
@@ -940,7 +986,13 @@ function Canvas({
940
986
  // Comments that are part of the selection (selecting everything selects them too): only here, like what nodes
941
987
  // are selected. While what's selected is dragged, the ones not on a node move with it by this much.
942
988
  const [selectedThreads, setSelectedThreads] = useState<ReadonlySet<string>>(NO_THREADS);
943
- const clearSelectedThreads = useCallback(() => setSelectedThreads((current) => (current.size ? NO_THREADS : current)), []);
989
+ // Hotspots too (selecting everything selects them, and deleting it deletes them)
990
+ const [selectedHotspots, setSelectedHotspots] = useState<ReadonlySet<string>>(NO_HOTSPOTS);
991
+ /** Comments and hotspots stop being part of the selection */
992
+ const clearSelectedPins = useCallback(() => {
993
+ setSelectedThreads((current) => (current.size ? NO_THREADS : current));
994
+ setSelectedHotspots((current) => (current.size ? NO_HOTSPOTS : current));
995
+ }, []);
944
996
  const [threadsOffset, setThreadsOffset] = useState<XYPosition | null>(null);
945
997
  const threadsShown = () => comments.threads.filter((thread) => showResolved || !thread.resolved);
946
998
  const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
@@ -972,6 +1024,7 @@ function Canvas({
972
1024
  setTool('select');
973
1025
  setDraft(null);
974
1026
  setOpenThreadId(null);
1027
+ connect.cancel();
975
1028
  // What's selected may not be on the next level (e.g. a note added on this one)
976
1029
  flow.onNodesChange(selectedIds().map((id) => ({ type: 'select', id, selected: false })));
977
1030
  setLevel(next);
@@ -1193,7 +1246,8 @@ function Canvas({
1193
1246
  * removes it from the canvas (the level is drawn from it); and moving or resizing something arranges the level,
1194
1247
  * kept when it's dropped.
1195
1248
  */
1196
- const onLevelNodesChange = useStableCallback((changes: NodeChange[]) => {
1249
+ const onLevelNodesChange = useStableCallback((allChanges: NodeChange[]) => {
1250
+ const changes = allChanges.filter(notPlaceholder);
1197
1251
  const level = levelRef.current;
1198
1252
  const current = levelGraphRef.current;
1199
1253
  if (level === 3 || !current) return;
@@ -1232,6 +1286,11 @@ function Canvas({
1232
1286
  });
1233
1287
  if (measured.length) flow.setLevelLayout(level, new Map(measured));
1234
1288
  });
1289
+ /** Changes on the canvas (L3), apart from the placeholder's */
1290
+ const onCanvasNodesChange = useStableCallback((allChanges: NodeChange[]) => {
1291
+ const changes = allChanges.filter(notPlaceholder);
1292
+ if (changes.length) flow.onNodesChange(changes);
1293
+ });
1235
1294
  /** Connecting two things on L1 or L2 connects them on the canvas (e.g. a relationship between two systems) */
1236
1295
  const onLevelConnect = useStableCallback((connection: Connection) => {
1237
1296
  const onCanvas = new Set(nodesRef.current.map((node) => node.id));
@@ -1254,6 +1313,13 @@ function Canvas({
1254
1313
  : edge
1255
1314
  );
1256
1315
  }, [levelEdges, flow.edges, hovered]);
1316
+ // With the placeholder, while a connection let go of on empty canvas is waiting to be told what it connects to
1317
+ const { placeholder } = connect;
1318
+ const flowNodes = useMemo(
1319
+ () => (placeholder ? [...visibleNodes, placeholder.node] : visibleNodes),
1320
+ [visibleNodes, placeholder]
1321
+ );
1322
+ const flowEdges = useMemo(() => (placeholder ? [...shownEdges, placeholder.edge] : shownEdges), [shownEdges, placeholder]);
1257
1323
  // A canvas opened from a diagram has the levels the diagram has (until what's on it changes)
1258
1324
  const unavailableLevels = useMemo(() => {
1259
1325
  const reason = (level: 1 | 2) => {
@@ -1281,6 +1347,57 @@ function Canvas({
1281
1347
 
1282
1348
  const [following, setFollowing] = useState(followChanges);
1283
1349
  const toggleFollowing = useCallback(() => setFollowing((on) => !on), []);
1350
+ // Asking the assistant (Ask AI) to draw on this canvas follows it while it works
1351
+ useEffect(() => {
1352
+ const followAssistant = (event: Event) => {
1353
+ if ((event as CustomEvent<{ canvasId?: string }>).detail?.canvasId === canvasId) setFollowing(true);
1354
+ };
1355
+ window.addEventListener(ASSISTANT_DRAWING_EVENT, followAssistant);
1356
+ return () => window.removeEventListener(ASSISTANT_DRAWING_EVENT, followAssistant);
1357
+ }, [canvasId]);
1358
+
1359
+ // The assistant taking you to part of the canvas (showOnCanvas, a walkthrough's steps): zoomed to, and selected. On
1360
+ // the canvas people edit (L3), where everything is.
1361
+ const focusOn = useStableCallback((ids: string[]) => {
1362
+ const nodes = lookupNodes();
1363
+ const found = ids.filter((id) => nodes.has(id));
1364
+ if (!found.length) return;
1365
+ const changingLevel = levelRef.current !== 3;
1366
+ if (changingLevel) changeLevel(3);
1367
+ // You're looking around now: following the agent would take the camera back to its pointer a moment later
1368
+ setFollowing(false);
1369
+ // Once the level's switched (and drawn), if it was
1370
+ setTimeout(
1371
+ () => {
1372
+ select(new Set(found));
1373
+ void fitView({ nodes: found.map((id) => ({ id })), duration: 600, padding: 0.35, maxZoom: 1.2 });
1374
+ },
1375
+ changingLevel ? 400 : 0
1376
+ );
1377
+ });
1378
+ useEffect(() => {
1379
+ const focus = (event: Event) => {
1380
+ const { canvasId: on, nodeIds } = (event as CustomEvent<{ canvasId?: string; nodeIds?: string[] }>).detail ?? {};
1381
+ if (on === canvasId && nodeIds?.length) focusOn(nodeIds);
1382
+ };
1383
+ window.addEventListener(CANVAS_FOCUS_EVENT, focus);
1384
+ return () => window.removeEventListener(CANVAS_FOCUS_EVENT, focus);
1385
+ }, [canvasId, focusOn]);
1386
+ // ...and to a hotspot it flagged, opened
1387
+ const showHotspot = useStableCallback((hotspotId: string) => {
1388
+ const hotspot = flow.hotspots.find((candidate) => candidate.id === hotspotId);
1389
+ if (!hotspot) return;
1390
+ setFollowing(false);
1391
+ goToHotspot(hotspot);
1392
+ });
1393
+ useEffect(() => {
1394
+ const show = (event: Event) => {
1395
+ const { canvasId: on, hotspotId } = (event as CustomEvent<{ canvasId?: string; hotspotId?: string }>).detail ?? {};
1396
+ if (on === canvasId && hotspotId) showHotspot(hotspotId);
1397
+ };
1398
+ window.addEventListener(CANVAS_HOTSPOT_EVENT, show);
1399
+ return () => window.removeEventListener(CANVAS_HOTSPOT_EVENT, show);
1400
+ }, [canvasId, showHotspot]);
1284
1401
 
1285
1402
  const [shareOpen, setShareOpen] = useState<false | 'choose' | 'invite'>(false);
1286
1403
  const openShare = useCallback(() => setShareOpen('choose'), []);
@@ -1411,6 +1528,8 @@ function Canvas({
1411
1528
  flow.onEdgesChange(edgesRef.current.map((edge) => ({ type: 'select', id: edge.id, selected: true })));
1412
1529
  // ...and the comments shown on it
1413
1530
  setSelectedThreads(new Set(threadsShown().map((thread) => thread.id)));
1531
+ // ...and its hotspots (the ones on L1 and L2's own places are on those levels)
1532
+ setSelectedHotspots(new Set(flow.hotspots.filter((hotspot) => !hotspot.level).map((hotspot) => hotspot.id)));
1414
1533
  },
1415
1534
  { preventDefault: true }
1416
1535
  );
@@ -1445,7 +1564,7 @@ function Canvas({
1445
1564
  setTool('select');
1446
1565
  setDraft(null);
1447
1566
  setOpenThreadId(null);
1448
- clearSelectedThreads();
1567
+ clearSelectedPins();
1449
1568
  setEditingId(null);
1450
1569
  flow.onNodesChange(
1451
1570
  nodesRef.current.filter((node) => node.selected).map((node) => ({ type: 'select', id: node.id, selected: false }))
@@ -1454,11 +1573,16 @@ function Canvas({
1454
1573
  edgesRef.current.filter((edge) => edge.selected).map((edge) => ({ type: 'select', id: edge.id, selected: false }))
1455
1574
  );
1456
1575
  });
1457
- // Selected comments are deleted with what's selected (React Flow deletes the nodes and edges)
1576
+ // Selected comments and hotspots are deleted with what's selected (React Flow deletes the nodes and edges)
1458
1577
  useHotkeys(DELETE_KEYS, () => {
1459
- if (!onCanvas() || selectedThreads.size === 0) return;
1460
- comments.deleteThreads([...selectedThreads]);
1461
- clearSelectedThreads();
1578
+ if (!onCanvas() || (selectedThreads.size === 0 && selectedHotspots.size === 0)) return;
1579
+ if (selectedThreads.size) comments.deleteThreads([...selectedThreads]);
1580
+ // (hotspots, like nodes, stay on a locked canvas)
1581
+ if (!locked) {
1582
+ selectedHotspots.forEach((id) => flow.deleteHotspot(id));
1583
+ if (selectedHotspots.size) setOpenHotspot(null);
1584
+ }
1585
+ clearSelectedPins();
1462
1586
  });
1463
1587
  /** A tool that puts something down: picked, or put back to selecting if it was already */
1464
1588
  const pickTool = useStableCallback((next: 'text' | 'note' | 'comment' | 'hotspot') => {
@@ -1491,7 +1615,9 @@ function Canvas({
1491
1615
  // A place on L1 or L2 is on that level (they're laid out on their own): what's put there says so
1492
1616
  const placeLevel = () => (levelRef.current === 3 ? {} : { level: levelRef.current as 1 | 2 });
1493
1617
  const onPaneClick = useStableCallback((event: ReactMouseEvent) => {
1494
- clearSelectedThreads();
1618
+ clearSelectedPins();
1619
+ // An open hotspot closes on a click anywhere else on the canvas
1620
+ setOpenHotspot(null);
1495
1621
  if (tool === 'hand') return;
1496
1622
  if (commentMode && editable) return startComment(anchorAt(flowPositionOf(event)));
1497
1623
  // Flagging: a hotspot where it's clicked
@@ -1527,7 +1653,8 @@ function Canvas({
1527
1653
 
1528
1654
  const onNodeClick = useStableCallback((event: ReactMouseEvent, node: Node) => {
1529
1655
  // Clicking one node selects just it (with shift or ⌘, it's added to what's selected)
1530
- if (!event.shiftKey && !event.metaKey) clearSelectedThreads();
1656
+ if (!event.shiftKey && !event.metaKey) clearSelectedPins();
1657
+ setOpenHotspot(null);
1531
1658
  // Not on notes and text: they're written on, not what's being flagged or commented on
1532
1659
  if (isWrittenOnCanvas(node.type)) return;
1533
1660
  // A hotspot on it (it shows on the node, wherever it goes, on every level it's on)
@@ -1654,6 +1781,20 @@ function Canvas({
1654
1781
  // The node whose details are being edited in its card: opened with the edit button over it, by double clicking it,
1655
1782
  // or "Edit details" in its menu (selecting a node doesn't open it). Notes are edited on the canvas.
1656
1783
  const [editingId, setEditingId] = useState<string | null>(null);
1784
+ // Made at the end of a connection: selected and its details opened once it's on the canvas (looked for as what's on
1785
+ // it changes, not as things move), its name empty to type it straight in. NodeDetails is keyed by the node, so its
1786
+ // name starts empty when its card opens, and only then (the next card opened, even its own again, doesn't).
1787
+ const editOnceShown = useRef<string | null>(null);
1788
+ const [justMadeId, setJustMadeId] = useState<string | null>(null);
1789
+ useEffect(() => {
1790
+ const id = editOnceShown.current;
1791
+ if (!id || !nodesRef.current.some((node) => node.id === id)) return;
1792
+ editOnceShown.current = null;
1793
+ select(new Set([id]));
1794
+ setEditingId(id);
1795
+ setJustMadeId(id);
1796
+ }, [flow.structureKey]);
1797
+ useEffect(() => setJustMadeId((id) => (id === editingId ? id : null)), [editingId]);
1657
1798
  const editing = editingId ? flow.nodes.find((node) => node.id === editingId && !isWrittenOnCanvas(node.type)) : undefined;
1658
1799
  // What the card shows (not the node's position, so dragging it doesn't re-render the card)
1659
1800
  const inspected = useMemo(
@@ -1706,8 +1847,8 @@ function Canvas({
1706
1847
  onDoubleClick={onCanvasDoubleClick}
1707
1848
  >
1708
1849
  <ReactFlow
1709
- nodes={visibleNodes}
1710
- edges={shownEdges}
1850
+ nodes={flowNodes}
1851
+ edges={flowEdges}
1711
1852
  onNodeMouseEnter={onNodeMouseEnter}
1712
1853
  onNodeMouseLeave={onHoverEnd}
1713
1854
  onEdgeMouseEnter={onEdgeMouseEnter}
@@ -1717,12 +1858,15 @@ function Canvas({
1717
1858
  nodesDraggable={!locked && tool !== 'hand'}
1718
1859
  nodesConnectable={!locked && tool !== 'hand'}
1719
1860
  elementsSelectable={tool !== 'hand'}
1720
- onNodesChange={editable ? flow.onNodesChange : onLevelNodesChange}
1861
+ onNodesChange={editable ? onCanvasNodesChange : onLevelNodesChange}
1721
1862
  onEdgesChange={editable ? flow.onEdgesChange : undefined}
1722
1863
  onConnect={editable ? flow.onConnect : onLevelConnect}
1864
+ onConnectEnd={connect.onConnectEnd}
1723
1865
  // Drag either end of a connection to another node (dropped anywhere else, it stays where it was)
1724
1866
  edgesReconnectable={editable && !locked}
1725
1867
  onReconnect={editable ? flow.onReconnect : undefined}
1868
+ onReconnectStart={connect.onReconnectStart}
1869
+ onReconnectEnd={connect.onReconnectEnd}
1726
1870
  reconnectRadius={RECONNECT_RADIUS}
1727
1871
  onDragOver={locked ? undefined : onDragOver}
1728
1872
  onMoveStart={onMoveStart}
@@ -1735,7 +1879,7 @@ function Canvas({
1735
1879
  onNodeClick={onNodeClick}
1736
1880
  onNodeDoubleClick={onNodeDoubleClick}
1737
1881
  onError={onFlowError}
1738
- onSelectionStart={clearSelectedThreads}
1882
+ onSelectionStart={clearSelectedPins}
1739
1883
  onPaneContextMenu={onPaneContextMenu}
1740
1884
  onNodeContextMenu={onNodeContextMenu}
1741
1885
  connectionLineType={ConnectionLineType.SmoothStep}
@@ -1785,7 +1929,7 @@ function Canvas({
1785
1929
  draft={hotspotDraft}
1786
1930
  locked={locked}
1787
1931
  movable={!locked}
1788
- selectedIds={null}
1932
+ selectedIds={selectedHotspots.size ? selectedHotspots : null}
1789
1933
  onMove={moveHotspots}
1790
1934
  onDropTarget={setHotspotDropTarget}
1791
1935
  onOpen={setOpenHotspot}
@@ -1969,6 +2113,7 @@ function Canvas({
1969
2113
  statusHistory={flow.canvasStatus.history}
1970
2114
  onStatusChange={flow.changeStatus}
1971
2115
  onDelete={canvasesApiUrl ? openDelete : undefined}
2116
+ onDrawWithAi={assistant ? openAssistant : undefined}
1972
2117
  />
1973
2118
 
1974
2119
  {flow.deleted && !leaving && (
@@ -2016,6 +2161,7 @@ function Canvas({
2016
2161
  connections={inspectedConnections}
2017
2162
  onOpenNode={openConnected}
2018
2163
  containerRef={canvasRef}
2164
+ nameEmpty={inspected.id === justMadeId}
2019
2165
  locked={locked}
2020
2166
  onChange={flow.updateNodeData}
2021
2167
  onClose={closePanel}
@@ -2023,6 +2169,7 @@ function Canvas({
2023
2169
  )}
2024
2170
  </div>
2025
2171
 
2172
+ {connect.chooser && <ConnectChooser {...connect.chooser} />}
2026
2173
  {chooser && (
2027
2174
  <ContainerChooser
2028
2175
  kind={chooser.resource.collection === 'domains' ? 'domain' : 'system'}
@@ -55,6 +55,8 @@ import {
55
55
  canGoInContainer,
56
56
  getNodeDefinition,
57
57
  isGroupType,
58
+ linkConnection,
59
+ type NewLink,
58
60
  createHotspot as createHotspotInDoc,
59
61
  updateHotspot as updateHotspotInDoc,
60
62
  deleteHotspot as deleteHotspotInDoc,
@@ -599,7 +601,8 @@ export function useStudioFlow({
599
601
 
600
602
  /**
601
603
  * Add a node centred on a canvas position. `connect` gets the new node's id and the nodes already
602
- * on the canvas, and returns edges to add with it (e.g. a catalog resource's relationships).
604
+ * on the canvas, and returns edges to add with it (e.g. a catalog resource's relationships). `link` connects it to
605
+ * the node a connection was dragged from (in the same change, so one undo takes both back).
603
606
  */
604
607
  const insertNode = useCallback(
605
608
  (
@@ -607,7 +610,8 @@ export function useStudioFlow({
607
610
  data: Record<string, unknown>,
608
611
  center: XYPosition,
609
612
  connect?: (id: string, existing: Node[]) => Edge[],
610
- inside?: string
613
+ inside?: string,
614
+ link?: NewLink
611
615
  ) => {
612
616
  const built = buildNode(type, data, center);
613
617
  withDoc((doc) => {
@@ -618,6 +622,7 @@ export function useStudioFlow({
618
622
  ? { ...built, parentId: group.id, position: toRelativePosition(built.position, group.id, existing) }
619
623
  : built;
620
624
  addNodes(doc, [node], connect?.(node.id, existing) ?? []);
625
+ if (link) connectNodes(doc, linkConnection(node.id, link));
621
626
  if (group) fitGroupToChildren(doc, group.id, renderedSizes());
622
627
  });
623
628
  return built.id;
@@ -627,9 +632,9 @@ export function useStudioFlow({
627
632
 
628
633
  /** Add a node of a component (its type, or its key in the components list), centred on a canvas position (in a container, if asked) */
629
634
  const addNode = useCallback(
630
- (component: string, center: XYPosition, inside?: string) => {
635
+ (component: string, center: XYPosition, inside?: string, link?: NewLink) => {
631
636
  const definition = getNodeDefinition(component);
632
- return definition ? insertNode(definition.type, definition.createData(), center, undefined, inside) : undefined;
637
+ return definition ? insertNode(definition.type, definition.createData(), center, undefined, inside, link) : undefined;
633
638
  },
634
639
  [insertNode]
635
640
  );
@@ -11,6 +11,7 @@ import { getCatalogResources } from '../catalog-resources';
11
11
  import { startStudio, STUDIO_SOCKET_PATH } from '../server/runtime';
12
12
  import { getSignedInUser, isSignedIn } from '../server/sign-in';
13
13
  import { CANVASES_API_PATH } from '../api/canvases-api';
14
+ import { isEventCatalogChatVisible } from '@utils/feature';
14
15
 
15
16
  export const prerender = false;
16
17
 
@@ -59,6 +60,7 @@ const signedInAs = getSignedInUser(Astro.locals);
59
60
  mcpUrl={buildUrl('/docs/mcp', true)}
60
61
  relayScriptPath={buildUrl('/webmcp-relay/embed.js', true)}
61
62
  keptInMemory={keptInMemory}
63
+ assistant={isEventCatalogChatVisible()}
62
64
  signedInAs={signedInAs}
63
65
  resources={resources}
64
66
  relations={relations}