@eventcatalog/core 5.0.0-beta.6 → 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-XMPFHLK4.js → chunk-4W55ZOOS.js} +2 -2
  6. package/dist/{chunk-PIYJM642.js → chunk-AWOO63ME.js} +70 -44
  7. package/dist/{chunk-QBP3BBSV.js → chunk-C6T2AAFR.js} +1 -1
  8. package/dist/{chunk-MDWB5HLO.js → chunk-ENAG6UH7.js} +5 -0
  9. package/dist/{chunk-56CD2NH6.js → chunk-PU25TOOJ.js} +1 -1
  10. package/dist/{chunk-L2IRFOE7.js → chunk-RDIZJ7UT.js} +1 -1
  11. package/dist/{chunk-DE3QHVJP.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
@@ -1,9 +1,10 @@
1
- import { createContext, memo, useCallback, useContext } from 'react';
2
- import { NodeResizer, type NodeProps, type NodeTypes } from '@xyflow/react';
3
- import { DomainCardNode, Note, nodeComponents, SystemGroupNode, type NoteNode as NoteNodeType } from '@eventcatalog/visualiser';
4
- import { GROUP_TYPES, nodeDefinitions } from '../node-types';
1
+ import { createContext, memo, useCallback, useContext, useRef, useState } from 'react';
2
+ import { NodeResizeControl, NodeResizer, ResizeControlVariant, useStoreApi, type NodeProps, type NodeTypes } from '@xyflow/react';
3
+ import { DomainCardNode, nodeComponents, SystemGroupNode } from '@eventcatalog/visualiser';
4
+ import { GROUP_PADDING } from '../grouping';
5
+ import { getNodeName, GROUP_TYPES, nodeDefinitions } from '../node-types';
5
6
 
6
- /** How the canvas draws each kind of node: the visualiser's nodes, editable notes and resizable containers */
7
+ /** How the canvas draws each kind of node: the visualiser's nodes, notes and text written on the canvas, and resizable containers */
7
8
 
8
9
  export type UpdateNodeData = (id: string, updater: (data: Record<string, unknown>) => Record<string, unknown>) => void;
9
10
 
@@ -18,17 +19,152 @@ export const DropTargetContext = createContext<string | null>(null);
18
19
  const visualiserNodes = nodeComponents as unknown as NodeTypes;
19
20
  const SystemGroupComponent: NodeTypes[string] = SystemGroupNode;
20
21
 
21
- const NoteNode = memo(function NoteNode(props: NodeProps<NoteNodeType>) {
22
+ /** The handles that set a node's width, on either side (its height follows what's written in it) */
23
+ function WidthHandles() {
24
+ return (
25
+ <>
26
+ {/* Horizontal only: otherwise React Flow saves the height too, and it stops growing with what's written */}
27
+ <NodeResizeControl
28
+ position="left"
29
+ variant={ResizeControlVariant.Line}
30
+ resizeDirection="horizontal"
31
+ minWidth={80}
32
+ className="studio-width-handle"
33
+ />
34
+ <NodeResizeControl
35
+ position="right"
36
+ variant={ResizeControlVariant.Line}
37
+ resizeDirection="horizontal"
38
+ minWidth={80}
39
+ className="studio-width-handle"
40
+ />
41
+ </>
42
+ );
43
+ }
44
+
45
+ /**
46
+ * What's written on a note or text, and writing it: Enter starts a new line, Escape or ⌘/Ctrl + Enter (or clicking
47
+ * away) finishes, and it's saved for everyone then
48
+ */
49
+ function Writing({
50
+ id,
51
+ text,
52
+ editing,
53
+ onDone,
54
+ placeholder,
55
+ className,
56
+ }: {
57
+ id: string;
58
+ text: string;
59
+ editing: boolean;
60
+ onDone: () => void;
61
+ placeholder: string;
62
+ className: string;
63
+ }) {
22
64
  const updateNodeData = useContext(UpdateNodeDataContext);
23
- const onTextChange = useCallback(
24
- (id: string, text: string) => updateNodeData(id, (data) => ({ ...data, text })),
25
- [updateNodeData]
65
+ // The box grows with what's written (the node's height follows it)
66
+ const fit = (area: HTMLTextAreaElement) => {
67
+ area.style.height = 'auto';
68
+ area.style.height = `${area.scrollHeight}px`;
69
+ };
70
+ if (!editing) return <p className={className}>{text || <span className="opacity-50">{placeholder}</span>}</p>;
71
+ return (
72
+ <textarea
73
+ ref={(area) => {
74
+ if (area) fit(area);
75
+ }}
76
+ autoFocus
77
+ defaultValue={text}
78
+ placeholder={placeholder}
79
+ onFocus={(event) => event.currentTarget.select()}
80
+ onInput={(event) => fit(event.currentTarget)}
81
+ onBlur={(event) => {
82
+ const value = event.currentTarget.value;
83
+ onDone();
84
+ if (value !== text) updateNodeData(id, (current) => ({ ...current, text: value }));
85
+ }}
86
+ onKeyDown={(event) => {
87
+ if (event.key === 'Escape' || (event.key === 'Enter' && (event.metaKey || event.ctrlKey))) event.currentTarget.blur();
88
+ }}
89
+ className={`nodrag nopan nowheel block w-full resize-none overflow-hidden bg-transparent outline-none ${className}`}
90
+ />
26
91
  );
27
- const onColorChange = useCallback(
28
- (id: string, color: string) => updateNodeData(id, (data) => ({ ...data, color })),
29
- [updateNodeData]
92
+ }
93
+
94
+ /** A sticky note's colours: soft paper colours, readable in light and dark themes (the names are the visualiser's) */
95
+ const NOTE_COLORS: Record<string, { fill: string; border: string }> = {
96
+ yellow: { fill: '#fef9c3', border: '#fde047' },
97
+ blue: { fill: '#dbeafe', border: '#93c5fd' },
98
+ green: { fill: '#dcfce7', border: '#86efac' },
99
+ pink: { fill: '#fce7f3', border: '#f9a8d4' },
100
+ purple: { fill: '#ede9fe', border: '#c4b5fd' },
101
+ gray: { fill: '#f3f4f6', border: '#d1d5db' },
102
+ };
103
+
104
+ /**
105
+ * A sticky note: a few lines on paper, about as wide as a card and as tall as what's on it. Double click it to
106
+ * write; when it's selected, its colour can be changed and its width set.
107
+ */
108
+ const StickyNote = memo(function StickyNote({ id, data, selected }: NodeProps) {
109
+ const updateNodeData = useContext(UpdateNodeDataContext);
110
+ const [editing, setEditing] = useState(false);
111
+ const colorName = typeof data.color === 'string' && NOTE_COLORS[data.color] ? data.color : 'yellow';
112
+ const color = NOTE_COLORS[colorName];
113
+ return (
114
+ <div
115
+ className="studio-note"
116
+ style={{ background: color.fill, borderColor: color.border }}
117
+ onDoubleClick={() => setEditing(true)}
118
+ >
119
+ {selected && !editing && (
120
+ <>
121
+ <WidthHandles />
122
+ <div className="nodrag nopan studio-note-colors" role="radiogroup" aria-label="Note colour">
123
+ {Object.entries(NOTE_COLORS).map(([name, { fill, border }]) => (
124
+ <button
125
+ key={name}
126
+ role="radio"
127
+ aria-checked={name === colorName}
128
+ title={name.charAt(0).toUpperCase() + name.slice(1)}
129
+ onClick={() => updateNodeData(id, (current) => ({ ...current, color: name }))}
130
+ className="studio-note-color"
131
+ style={{ background: fill, borderColor: name === colorName ? 'rgb(var(--ec-page-text))' : border }}
132
+ />
133
+ ))}
134
+ </div>
135
+ </>
136
+ )}
137
+ <Writing
138
+ id={id}
139
+ text={String(data.text ?? '')}
140
+ editing={editing}
141
+ onDone={() => setEditing(false)}
142
+ placeholder="Double-click to write a note"
143
+ className="studio-note-body"
144
+ />
145
+ </div>
146
+ );
147
+ });
148
+
149
+ /**
150
+ * Text written straight on the canvas (titles, labels, notes to self): double click it to write. Its width is set
151
+ * with the handles on either side; its height follows what's written.
152
+ */
153
+ const TextNode = memo(function TextNode({ id, data, selected }: NodeProps) {
154
+ const [editing, setEditing] = useState(false);
155
+ return (
156
+ <div className="studio-text" onDoubleClick={() => setEditing(true)}>
157
+ {selected && !editing && <WidthHandles />}
158
+ <Writing
159
+ id={id}
160
+ text={String(data.text ?? '')}
161
+ editing={editing}
162
+ onDone={() => setEditing(false)}
163
+ placeholder="Double-click to write"
164
+ className="studio-text-body"
165
+ />
166
+ </div>
30
167
  );
31
- return <Note {...props} onTextChange={onTextChange} onColorChange={onColorChange} />;
32
168
  });
33
169
 
34
170
  /**
@@ -38,10 +174,51 @@ const NoteNode = memo(function NoteNode(props: NodeProps<NoteNodeType>) {
38
174
  */
39
175
  const GroupNode = memo(function GroupNode(props: NodeProps) {
40
176
  const isDropTarget = useContext(DropTargetContext) === props.id;
177
+ const store = useStoreApi();
178
+ // Where what's inside has to stay (with room around it and for the header), in the container's parent's
179
+ // coordinates: found when a resize starts, so it can't be resized smaller than what's in it
180
+ const contents = useRef<{ left: number; top: number; right: number; bottom: number } | null>(null);
181
+ const onResizeStart = useCallback(() => {
182
+ const { nodeLookup } = store.getState();
183
+ const container = nodeLookup.get(props.id);
184
+ const children = [...nodeLookup.values()].filter((node) => node.parentId === props.id);
185
+ if (!container || children.length === 0) {
186
+ contents.current = null;
187
+ return;
188
+ }
189
+ const { x, y } = container.position;
190
+ const size = (node: (typeof children)[number]) => ({
191
+ width: node.measured.width ?? node.width ?? 0,
192
+ height: node.measured.height ?? node.height ?? 0,
193
+ });
194
+ const own = size(container);
195
+ // A side already closer in than that (e.g. something placed in the padding) can still move out, just not in
196
+ contents.current = {
197
+ left: Math.max(x + Math.min(...children.map((node) => node.position.x)) - GROUP_PADDING.left, x),
198
+ top: Math.max(y + Math.min(...children.map((node) => node.position.y)) - GROUP_PADDING.top, y),
199
+ right: Math.min(
200
+ x + Math.max(...children.map((node) => node.position.x + size(node).width)) + GROUP_PADDING.right,
201
+ x + own.width
202
+ ),
203
+ bottom: Math.min(
204
+ y + Math.max(...children.map((node) => node.position.y + size(node).height)) + GROUP_PADDING.bottom,
205
+ y + own.height
206
+ ),
207
+ };
208
+ }, [store, props.id]);
209
+ const shouldResize = useCallback(
210
+ (_: unknown, { x, y, width, height }: { x: number; y: number; width: number; height: number }) => {
211
+ const bounds = contents.current;
212
+ return !bounds || (x <= bounds.left && y <= bounds.top && x + width >= bounds.right && y + height >= bounds.bottom);
213
+ },
214
+ []
215
+ );
41
216
  return (
42
217
  <div style={{ width: '100%', height: '100%', position: 'relative' }}>
43
218
  <NodeResizer
44
219
  isVisible={props.selected}
220
+ onResizeStart={onResizeStart}
221
+ shouldResize={shouldResize}
45
222
  minWidth={240}
46
223
  minHeight={160}
47
224
  lineClassName="!border-[rgb(var(--ec-accent))]"
@@ -50,14 +227,13 @@ const GroupNode = memo(function GroupNode(props: NodeProps) {
50
227
  <SystemGroupComponent {...props} />
51
228
  {/* The header (and the badge on its border) */}
52
229
  <div className="ec-group-grip absolute inset-x-0 -top-3 h-[60px] cursor-grab" />
53
- {(isDropTarget || props.selected) && (
54
- <div
55
- className={`pointer-events-none absolute inset-0 rounded-[14px] ${
56
- isDropTarget
57
- ? 'ring-4 ring-[rgb(var(--ec-accent)/0.5)] bg-[rgb(var(--ec-accent)/0.05)]'
58
- : 'ring-2 ring-[rgb(var(--ec-accent)/0.7)]'
59
- }`}
60
- />
230
+ {isDropTarget ? (
231
+ // What's dragged over it goes in it when it's dropped
232
+ <div className="ec-drop-target">
233
+ <span className="ec-drop-target-label">Drop to add to {getNodeName(props.type, props.data, 'this container')}</span>
234
+ </div>
235
+ ) : (
236
+ props.selected && <div className="ec-group-selected" />
61
237
  )}
62
238
  </div>
63
239
  );
@@ -67,7 +243,7 @@ export const nodeTypes: NodeTypes = {
67
243
  ...Object.fromEntries(
68
244
  nodeDefinitions.map((definition) => [
69
245
  definition.type,
70
- definition.type === 'note' ? NoteNode : visualiserNodes[definition.type],
246
+ definition.type === 'note' ? StickyNote : definition.type === 'text' ? TextNode : visualiserNodes[definition.type],
71
247
  ])
72
248
  ),
73
249
  // Only dropped in from the catalog
@@ -11,6 +11,7 @@ import {
11
11
  Search,
12
12
  ServerIcon,
13
13
  StickyNote,
14
+ Type,
14
15
  User,
15
16
  Zap,
16
17
  } from 'lucide-react';
@@ -22,17 +23,21 @@ export type NodeIcon = { icon: LucideIcon; className: string };
22
23
  export const NODE_ICONS: Record<string, NodeIcon> = {
23
24
  [GROUP_TYPES.domain]: { icon: Boxes, className: 'text-yellow-500' },
24
25
  [GROUP_TYPES.system]: { icon: Group, className: 'text-violet-600' },
26
+ // Catalog domains and systems added as cards
27
+ 'context-domain': { icon: Boxes, className: 'text-yellow-500' },
28
+ system: { icon: Group, className: 'text-violet-600' },
25
29
  service: { icon: ServerIcon, className: 'text-pink-600' },
26
30
  event: { icon: Zap, className: 'text-orange-600' },
27
31
  command: { icon: Mail, className: 'text-blue-600' },
28
32
  query: { icon: Search, className: 'text-green-600' },
29
33
  channel: { icon: ArrowRightLeft, className: 'text-gray-600' },
30
34
  data: { icon: Database, className: 'text-blue-600' },
31
- view: { icon: MonitorSmartphone, className: 'text-indigo-600' },
35
+ view: { icon: MonitorSmartphone, className: 'text-sky-600' },
32
36
  externalSystem: { icon: Globe, className: 'text-purple-600' },
33
37
  agent: { icon: Bot, className: 'text-violet-600' },
34
38
  actor: { icon: User, className: 'text-sky-600' },
35
39
  note: { icon: StickyNote, className: 'text-yellow-500' },
40
+ text: { icon: Type, className: 'text-[rgb(var(--ec-icon-color))]' },
36
41
  };
37
42
 
38
43
  /** The catalog's collections, in the order the catalog list shows them, with the icon of the node each becomes */
@@ -1,3 +1,4 @@
1
+ import type { CanvasStatus } from '../canvas-doc';
1
2
  import type { Status } from '../hooks/use-studio-flow';
2
3
 
3
4
  /**
@@ -6,7 +7,7 @@ import type { Status } from '../hooks/use-studio-flow';
6
7
  */
7
8
  export const STATUS = {
8
9
  success: { text: 'text-[rgb(var(--ec-badge-color-green-text))]', dot: 'bg-[rgb(var(--ec-badge-color-green-text))]' },
9
- warning: { dot: 'bg-[rgb(var(--ec-badge-color-yellow-text))]' },
10
+ warning: { text: 'text-[rgb(var(--ec-badge-color-yellow-text))]', dot: 'bg-[rgb(var(--ec-badge-color-yellow-text))]' },
10
11
  danger: {
11
12
  text: 'text-[rgb(var(--ec-badge-color-red-text))]',
12
13
  dot: 'bg-[rgb(var(--ec-badge-color-red-text))]',
@@ -20,3 +21,31 @@ export const CONNECTION_DOT: Record<Status, string> = {
20
21
  connecting: STATUS.warning.dot,
21
22
  disconnected: STATUS.danger.dot,
22
23
  };
24
+
25
+ /** How each canvas status looks and what it means (from the theme's badge colours) */
26
+ export const CANVAS_STATUS_LOOK: Record<CanvasStatus, { label: string; description: string; pill: string; dot: string }> = {
27
+ draft: {
28
+ label: 'Draft',
29
+ description: 'Being worked on',
30
+ pill: 'bg-[rgb(var(--ec-badge-color-gray-background))] text-[rgb(var(--ec-badge-color-gray-text))] border-[rgb(var(--ec-badge-color-gray-text)/0.25)]',
31
+ dot: 'bg-[rgb(var(--ec-badge-color-gray-text))]',
32
+ },
33
+ proposed: {
34
+ label: 'Proposed',
35
+ description: 'Ready for review',
36
+ pill: 'bg-[rgb(var(--ec-badge-color-yellow-background))] text-[rgb(var(--ec-badge-color-yellow-text))] border-[rgb(var(--ec-badge-color-yellow-text)/0.3)]',
37
+ dot: 'bg-[rgb(var(--ec-badge-color-yellow-text))]',
38
+ },
39
+ accepted: {
40
+ label: 'Accepted',
41
+ description: 'The agreed design',
42
+ pill: 'bg-[rgb(var(--ec-badge-color-green-background))] text-[rgb(var(--ec-badge-color-green-text))] border-[rgb(var(--ec-badge-color-green-text)/0.3)]',
43
+ dot: 'bg-[rgb(var(--ec-badge-color-green-text))]',
44
+ },
45
+ rejected: {
46
+ label: 'Rejected',
47
+ description: 'Decided against, kept for the record',
48
+ pill: 'bg-[rgb(var(--ec-badge-color-red-background))] text-[rgb(var(--ec-badge-color-red-text))] border-[rgb(var(--ec-badge-color-red-text)/0.3)]',
49
+ dot: 'bg-[rgb(var(--ec-badge-color-red-text))]',
50
+ },
51
+ };
@@ -24,6 +24,8 @@ export const getEdgeLabel = (sourceType = '', targetType = ''): string => {
24
24
  const targetCollection = MESSAGE_COLLECTIONS[targetType];
25
25
 
26
26
  if (PRODUCERS.includes(sourceType) && targetCollection) return PRODUCER_LABELS[targetCollection];
27
+ // One message leading to another (e.g. an event that causes a command)
28
+ if (sourceCollection && targetCollection) return 'triggers';
27
29
  if (sourceCollection && targetType === 'channel') return 'sent to';
28
30
  if (sourceCollection) return MESSAGE_LABELS[sourceCollection];
29
31
  if (sourceType === 'channel') return 'routes to';
@@ -1,5 +1,5 @@
1
1
  import type { Node, XYPosition } from '@xyflow/react';
2
- import { getNodeSize, isGroupType } from './node-types';
2
+ import { canGoInContainer, getNodeSize, isGroupType } from './node-types';
3
3
 
4
4
  /**
5
5
  * Containers: domains and systems shown as boxes that other nodes sit in.
@@ -60,7 +60,8 @@ export const sortByHierarchy = (nodes: Node[]): Node[] => {
60
60
  export const findDropTarget = (nodeId: string, nodes: Node[]): Node | undefined => {
61
61
  const lookup = byId(nodes);
62
62
  const node = lookup.get(nodeId);
63
- if (!node) return undefined;
63
+ // Sticky notes go anywhere, never in a container (one that's in one is taken out when it's next moved)
64
+ if (!node || !canGoInContainer(node.type)) return undefined;
64
65
  const rect = getAbsoluteRect(node, lookup);
65
66
  const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
66
67
  return nodes
@@ -131,3 +132,95 @@ export const fitGroup = (groupId: string, nodes: Node[]) => {
131
132
  height: Math.max(current.height, Math.max(...rects.map((rect) => rect.y + rect.height)) + shift.y + GROUP_PADDING.bottom),
132
133
  };
133
134
  };
135
+
136
+ /** How far a dragged node's middle can go past a container's edge and still be kept in it (growing it) */
137
+ export const KEEP_IN_CONTAINER_MARGIN = 160;
138
+
139
+ type Size = { width: number; height: number };
140
+ /** A node's place and size as shown while something's dragged */
141
+ export type NodePreview = { position: XYPosition; width?: number; height?: number };
142
+
143
+ /**
144
+ * Containers growing to fit what's being dragged in them, on every side, as a preview of where things will be.
145
+ * `containerIds` are the containers around what's dragged, innermost first. `start` is the canvas when the drag
146
+ * started, and `now` the canvas as shown (with the dragged nodes where they are now). A dragged node is kept in a
147
+ * container while its middle is within `KEEP_IN_CONTAINER_MARGIN` of where the container was, and dropping it
148
+ * further away takes it out, so the container goes back to its size. A container growing up or left moves, and
149
+ * what's in it moves the other way, so nothing moves on the canvas.
150
+ *
151
+ * Returns, by id, where containers and the nodes in them that aren't dragged go (positions relative to their
152
+ * containers), and their sizes for containers.
153
+ */
154
+ export const previewContainerGrowth = (
155
+ containerIds: string[],
156
+ draggedIds: ReadonlySet<string>,
157
+ start: ReadonlyMap<string, Node>,
158
+ now: ReadonlyMap<string, Node>
159
+ ): Map<string, NodePreview> => {
160
+ const previews = new Map<string, NodePreview>();
161
+ // Where each container's top left was on the canvas when the drag started (moving a container grown up or
162
+ // left is offset by what's in it moving the other way, so only its own shift changes it)
163
+ const startOrigin = (id: string) => getAbsolutePosition(start.get(id)!, start as Map<string, Node>);
164
+ const shifts = new Map<string, XYPosition>();
165
+
166
+ for (const id of containerIds) {
167
+ const container = start.get(id);
168
+ if (!container) continue;
169
+ const origin = startOrigin(id);
170
+ const startSize = sizeOf(container);
171
+ // Everything in it, in its coordinates when the drag started
172
+ const items: { rect: Rect }[] = [];
173
+ for (const node of start.values()) {
174
+ if (node.parentId !== id) continue;
175
+ if (draggedIds.has(node.id)) {
176
+ const current = now.get(node.id);
177
+ if (!current) continue;
178
+ const at = getAbsoluteRect(current, now as Map<string, Node>);
179
+ const middle = { x: at.x + at.width / 2, y: at.y + at.height / 2 };
180
+ const kept =
181
+ middle.x >= origin.x - KEEP_IN_CONTAINER_MARGIN &&
182
+ middle.x <= origin.x + startSize.width + KEEP_IN_CONTAINER_MARGIN &&
183
+ middle.y >= origin.y - KEEP_IN_CONTAINER_MARGIN &&
184
+ middle.y <= origin.y + startSize.height + KEEP_IN_CONTAINER_MARGIN;
185
+ if (kept) items.push({ rect: { ...at, x: at.x - origin.x, y: at.y - origin.y } });
186
+ continue;
187
+ }
188
+ // A container inside it that's grown: where it's grown to
189
+ const inner = previews.get(node.id);
190
+ const innerShift = shifts.get(node.id) ?? { x: 0, y: 0 };
191
+ items.push({
192
+ rect: {
193
+ x: node.position.x - innerShift.x,
194
+ y: node.position.y - innerShift.y,
195
+ ...(inner?.width && inner.height ? { width: inner.width, height: inner.height } : sizeOf(node)),
196
+ },
197
+ });
198
+ }
199
+
200
+ const shift = { x: 0, y: 0 };
201
+ let width = startSize.width;
202
+ let height = startSize.height;
203
+ if (items.length > 0) {
204
+ shift.x = Math.max(0, GROUP_PADDING.left - Math.min(...items.map(({ rect }) => rect.x)));
205
+ shift.y = Math.max(0, GROUP_PADDING.top - Math.min(...items.map(({ rect }) => rect.y)));
206
+ width =
207
+ Math.max(startSize.width, Math.max(...items.map(({ rect }) => rect.x + rect.width)) + GROUP_PADDING.right) + shift.x;
208
+ height =
209
+ Math.max(startSize.height, Math.max(...items.map(({ rect }) => rect.y + rect.height)) + GROUP_PADDING.bottom) + shift.y;
210
+ }
211
+ shifts.set(id, shift);
212
+ previews.set(id, {
213
+ position: { x: container.position.x - shift.x, y: container.position.y - shift.y },
214
+ width,
215
+ height,
216
+ });
217
+ // What's in it moves the other way. Not what's dragged: React Flow places it against the container as it is.
218
+ for (const node of start.values()) {
219
+ if (node.parentId !== id || draggedIds.has(node.id)) continue;
220
+ const own = previews.get(node.id);
221
+ const base = own?.position ?? node.position;
222
+ previews.set(node.id, { ...own, position: { x: base.x + shift.x, y: base.y + shift.y } });
223
+ }
224
+ }
225
+ return previews;
226
+ };
@@ -6,6 +6,8 @@ export type Peer = {
6
6
  clientId: number;
7
7
  name: string;
8
8
  color: string;
9
+ /** A picture from their sign-in provider (SSO), shown over their initials */
10
+ picture?: string;
9
11
  /** An AI agent (joined over MCP, or working through this tab with WebMCP), rather than a person */
10
12
  agent?: boolean;
11
13
  /** What an agent is doing, e.g. "Adding OrderService" */
@@ -60,7 +62,9 @@ const toPeers = (awareness: Awareness): Peer[] => {
60
62
 
61
63
  // What getPeople() changes on: who people are, what agents are doing, and whether they're on the canvas (a pointer)
62
64
  const identity = (peer: Peer) =>
63
- [peer.clientId, peer.name, peer.color, peer.agent ? 1 : 0, peer.activity ?? '', peer.pointer ? 1 : 0].join('|');
65
+ [peer.clientId, peer.name, peer.color, peer.picture ?? '', peer.agent ? 1 : 0, peer.activity ?? '', peer.pointer ? 1 : 0].join(
66
+ '|'
67
+ );
64
68
 
65
69
  export function createPresenceStore(awareness: Awareness): PresenceStore {
66
70
  const listeners = new Set<Listener>();
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
2
2
  import { z } from 'zod';
3
3
  import type * as Y from 'yjs';
4
4
  import { initializeWebMCPPolyfill } from '@mcp-b/webmcp-polyfill';
5
- import { replyToThread, setThreadResolved, type Author } from '../canvas-doc';
5
+ import { replyToThread, setThreadResolved, type Author, CANVAS_STATUSES, setCanvasStatus } from '../canvas-doc';
6
6
  import {
7
7
  ADD_TO_CANVAS_DESCRIPTION,
8
8
  CATALOG_COLLECTIONS,
@@ -243,11 +243,12 @@ const createCanvasTools = (get: () => CanvasWebMcpOptions, getStage: () => Agent
243
243
  name: 'update_node',
244
244
  title: 'Update a node',
245
245
  description:
246
- 'Renames, re-describes or moves a node (x/y: its new centre). Catalog resources keep their catalog name and summary. For notes, summary is the note text.',
246
+ 'Renames, re-describes, re-versions or moves a node (x/y: its new centre). Catalog resources keep their catalog name, summary and version. For notes, summary is the note text.',
247
247
  input: z.object({
248
248
  nodeId: z.string(),
249
249
  name: z.string().optional(),
250
250
  summary: z.string().optional(),
251
+ version: z.string().optional().describe('e.g. 1.0.0'),
251
252
  x: z.number().optional(),
252
253
  y: z.number().optional(),
253
254
  }),
@@ -290,10 +291,19 @@ const createCanvasTools = (get: () => CanvasWebMcpOptions, getStage: () => Agent
290
291
  return { laidOut: true };
291
292
  },
292
293
  }),
294
+ defineTool({
295
+ name: 'set_canvas_status',
296
+ title: 'Set the canvas status',
297
+ description:
298
+ "Changes the canvas's status: draft (being worked on, where every canvas starts), proposed (ready for review), accepted (the agreed design) or rejected (decided against). Only change it when the user asks you to. Changing the nodes or connections on an accepted or rejected canvas makes it a draft again.",
299
+ input: z.object({ status: z.enum(CANVAS_STATUSES), note: z.string().optional() }),
300
+ execute: ({ status, note }) => withDoc((doc) => ({ status, changed: setCanvasStatus(doc, status, get().agent, note) })),
301
+ }),
293
302
  defineTool({
294
303
  name: 'add_comment',
295
304
  title: 'Comment on the canvas',
296
- description: 'Starts a comment thread pinned to a node (nodeId) or a spot (x, y), to ask questions or explain suggestions.',
305
+ description:
306
+ "Starts a comment thread pinned to a node (nodeId) or a spot (x, y). Only use it when the user asks you to comment (e.g. to review the canvas). Don't use comments to greet people or ask the user questions: talk to them in the conversation.",
297
307
  input: z.object({ text: z.string(), nodeId: z.string().optional(), x: z.number().optional(), y: z.number().optional() }),
298
308
  execute: async (input) => {
299
309
  const stage = getStage();
@@ -3,8 +3,10 @@ import type * as Y from 'yjs';
3
3
  import {
4
4
  createThread as createThreadInDoc,
5
5
  deleteThread as deleteThreadInDoc,
6
+ deleteThreads as deleteThreadsInDoc,
6
7
  getCanvasMaps,
7
8
  moveThread as moveThreadInDoc,
9
+ moveThreads as moveThreadsInDoc,
8
10
  readThreads,
9
11
  replyToThread,
10
12
  setThreadResolved,
@@ -42,6 +44,11 @@ export function useComments(doc: Y.Doc | null, author: Author) {
42
44
  [doc]
43
45
  );
44
46
  const deleteThread = useCallback((threadId: string) => doc && deleteThreadInDoc(doc, threadId), [doc]);
47
+ const deleteThreads = useCallback((threadIds: string[]) => doc && deleteThreadsInDoc(doc, threadIds), [doc]);
48
+ const moveThreads = useCallback(
49
+ (moves: { threadId: string; anchor: CommentAnchor }[]) => doc && moveThreadsInDoc(doc, moves),
50
+ [doc]
51
+ );
45
52
 
46
- return { threads, createThread, reply, setResolved, moveThread, deleteThread };
53
+ return { threads, createThread, reply, setResolved, moveThread, moveThreads, deleteThread, deleteThreads };
47
54
  }
@@ -53,10 +53,15 @@ export function useFollowCamera({
53
53
  pausedUntil.current = Date.now() + PAUSE_AFTER_USER_MS;
54
54
  }, []);
55
55
 
56
- // A canvas that opens empty and fills in (it syncs after it opens) fits once its content arrives
56
+ // A canvas that opens empty and fills in (it syncs after it opens) fits once its content arrives, and so does an
57
+ // empty canvas when something's added to it (rather than keeping however far out it was zoomed)
57
58
  const hasFitContent = useRef(false);
58
59
  useEffect(() => {
59
- if (hasFitContent.current || !hasNodes || !width) return;
60
+ if (!hasNodes) {
61
+ hasFitContent.current = false;
62
+ return;
63
+ }
64
+ if (hasFitContent.current || !width) return;
60
65
  hasFitContent.current = true;
61
66
  const frame = requestAnimationFrame(() => void fitView({ padding: PADDING, maxZoom: MAX_ZOOM, duration: 300 }));
62
67
  return () => cancelAnimationFrame(frame);