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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/LICENSE +227 -128
  2. package/README.md +4 -4
  3. package/dist/analytics/analytics.cjs +18 -3
  4. package/dist/analytics/analytics.d.cts +1 -1
  5. package/dist/analytics/analytics.d.ts +1 -1
  6. package/dist/analytics/analytics.js +3 -2
  7. package/dist/analytics/log-build.cjs +71 -35
  8. package/dist/analytics/log-build.js +5 -4
  9. package/dist/analytics/telemetry.cjs +42 -0
  10. package/dist/analytics/telemetry.d.cts +20 -0
  11. package/dist/analytics/telemetry.d.ts +20 -0
  12. package/dist/analytics/telemetry.js +8 -0
  13. package/dist/{chunk-KYJZJLBD.js → chunk-4YFM63DS.js} +9 -3
  14. package/dist/{chunk-UDS3SYLZ.js → chunk-76DAAJLV.js} +56 -33
  15. package/dist/{chunk-TQLRG35T.js → chunk-AWOO63ME.js} +71 -45
  16. package/dist/chunk-CPW6G62V.js +17 -0
  17. package/dist/{chunk-K2ACDO7Y.js → chunk-LQZ7IG7B.js} +1 -1
  18. package/dist/{chunk-S2QGT7ZF.js → chunk-MFEMKQZ6.js} +1 -1
  19. package/dist/{chunk-Q6SD5SNQ.js → chunk-WM4QYMIA.js} +1 -1
  20. package/dist/constants.cjs +1 -1
  21. package/dist/constants.js +1 -1
  22. package/dist/eventcatalog.cjs +204 -124
  23. package/dist/eventcatalog.config.d.cts +59 -1
  24. package/dist/eventcatalog.config.d.ts +59 -1
  25. package/dist/eventcatalog.js +30 -11
  26. package/dist/federation/federate.js +2 -2
  27. package/dist/generate.cjs +1 -1
  28. package/dist/generate.js +3 -3
  29. package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
  30. package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
  31. package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
  32. package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
  33. package/dist/runtime-dependencies/manifest.json +8 -1
  34. package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
  35. package/dist/runtime-dependencies/y-protocols.mjs +3 -0
  36. package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
  37. package/dist/runtime-dependencies/yjs.mjs +3 -0
  38. package/dist/utils/cli-logger.cjs +1 -1
  39. package/dist/utils/cli-logger.js +2 -2
  40. package/dist/utils/license-status.cjs +71 -45
  41. package/dist/utils/license-status.d.cts +14 -5
  42. package/dist/utils/license-status.d.ts +14 -5
  43. package/dist/utils/license-status.js +1 -1
  44. package/eventcatalog/astro.config.mjs +5 -0
  45. package/eventcatalog/integrations/storage.mjs +20 -0
  46. package/eventcatalog/integrations/studio-server.mjs +32 -0
  47. package/eventcatalog/src/components/Grids/utils.tsx +0 -23
  48. package/eventcatalog/src/components/Header.astro +3 -3
  49. package/eventcatalog/src/components/MDX/CodeGroup/CodeGroup.tsx +6 -1
  50. package/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +12 -4
  51. package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +16 -7
  52. package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +10 -0
  53. package/eventcatalog/src/components/Tables/Discover/DiscoverTable.tsx +4 -62
  54. package/eventcatalog/src/components/Tables/Discover/columns.tsx +17 -150
  55. package/eventcatalog/src/components/Tables/Resources/ResourcesTable.tsx +425 -0
  56. package/eventcatalog/src/components/Tables/RowActionsMenu.tsx +157 -0
  57. package/eventcatalog/src/components/Tables/TablePagination.tsx +82 -0
  58. package/eventcatalog/src/features/ai/chat-api.ts +3 -3
  59. package/eventcatalog/src/features/auth/middleware/middleware-auth.ts +10 -0
  60. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +34 -1
  61. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +47 -0
  62. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
  63. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +276 -0
  64. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +210 -0
  65. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +75 -74
  66. package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
  67. package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
  68. package/eventcatalog/src/features/mcp/mcp-server.ts +36 -5
  69. package/eventcatalog/src/features/server/start.ts +25 -0
  70. package/eventcatalog/src/features/storage/database.ts +88 -0
  71. package/eventcatalog/src/features/storage/migrations.ts +85 -0
  72. package/eventcatalog/src/features/storage/sql/database.sql +5 -0
  73. package/eventcatalog/src/features/storage/sql/migrations/0001-studio-canvases.sql +12 -0
  74. package/eventcatalog/src/features/storage/sql/migrations.sql +15 -0
  75. package/eventcatalog/src/features/storage/sql.ts +28 -0
  76. package/eventcatalog/src/features/studio/agent-choreography.ts +381 -0
  77. package/eventcatalog/src/features/studio/api/canvases-api.ts +170 -0
  78. package/eventcatalog/src/features/studio/api/canvases.ts +25 -0
  79. package/eventcatalog/src/features/studio/api/client.ts +35 -0
  80. package/eventcatalog/src/features/studio/canvas-actions.ts +414 -0
  81. package/eventcatalog/src/features/studio/canvas-doc.ts +414 -0
  82. package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
  83. package/eventcatalog/src/features/studio/catalog-resources.ts +155 -0
  84. package/eventcatalog/src/features/studio/catalog.ts +171 -0
  85. package/eventcatalog/src/features/studio/clipboard.ts +97 -0
  86. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +154 -0
  87. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +370 -0
  88. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
  89. package/eventcatalog/src/features/studio/components/Comments.tsx +770 -0
  90. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +281 -0
  91. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
  92. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
  93. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +81 -0
  94. package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
  95. package/eventcatalog/src/features/studio/components/JoinForm.tsx +34 -0
  96. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +314 -0
  97. package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
  98. package/eventcatalog/src/features/studio/components/Presence.tsx +286 -0
  99. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +212 -0
  100. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
  101. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1216 -0
  102. package/eventcatalog/src/features/studio/components/StudioNotice.astro +24 -0
  103. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +255 -0
  104. package/eventcatalog/src/features/studio/components/icons.ts +53 -0
  105. package/eventcatalog/src/features/studio/components/status.ts +53 -0
  106. package/eventcatalog/src/features/studio/edges.ts +56 -0
  107. package/eventcatalog/src/features/studio/flow-state.ts +92 -0
  108. package/eventcatalog/src/features/studio/grouping.ts +226 -0
  109. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
  110. package/eventcatalog/src/features/studio/hooks/presence-store.ts +119 -0
  111. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +347 -0
  112. package/eventcatalog/src/features/studio/hooks/use-comments.ts +54 -0
  113. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +104 -0
  114. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +589 -0
  115. package/eventcatalog/src/features/studio/identity.ts +37 -0
  116. package/eventcatalog/src/features/studio/layout.ts +42 -0
  117. package/eventcatalog/src/features/studio/levels.ts +282 -0
  118. package/eventcatalog/src/features/studio/node-types.ts +205 -0
  119. package/eventcatalog/src/features/studio/pages/[id].astro +68 -0
  120. package/eventcatalog/src/features/studio/pages/index.astro +784 -0
  121. package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
  122. package/eventcatalog/src/features/studio/placement.ts +197 -0
  123. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +510 -0
  124. package/eventcatalog/src/features/studio/server/runtime.ts +491 -0
  125. package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
  126. package/eventcatalog/src/features/studio/server/sql/storage.sql +18 -0
  127. package/eventcatalog/src/features/studio/server/storage.ts +90 -0
  128. package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
  129. package/eventcatalog/src/features/studio/studio.css +273 -0
  130. package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
  131. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +10 -5
  132. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.spec.ts +52 -63
  133. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +34 -46
  134. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +11 -29
  135. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_ResourcesPage.astro +79 -0
  136. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.spec.ts +32 -14
  137. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.ts +63 -58
  138. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +2 -62
  139. package/eventcatalog/src/pages/docs/services/[id]/[version]/resources/index.astro +13 -0
  140. package/eventcatalog/src/stores/sidebar-store/builders/domain.ts +24 -42
  141. package/eventcatalog/src/stores/sidebar-store/builders/flow.ts +2 -0
  142. package/eventcatalog/src/stores/sidebar-store/builders/message.ts +2 -0
  143. package/eventcatalog/src/stores/sidebar-store/builders/service.ts +23 -23
  144. package/eventcatalog/src/stores/sidebar-store/builders/shared.ts +13 -0
  145. package/eventcatalog/src/stores/sidebar-store/builders/system.ts +18 -27
  146. package/eventcatalog/src/stores/sidebar-store/state.ts +5 -0
  147. package/eventcatalog/src/styles/tailwind.css +2 -2
  148. package/eventcatalog/src/utils/collections/channels.ts +56 -1
  149. package/eventcatalog/src/utils/collections/flows.ts +50 -1
  150. package/eventcatalog/src/utils/collections/message-connections.ts +60 -0
  151. package/eventcatalog/src/utils/collections/message-triggers.ts +2 -1
  152. package/eventcatalog/src/utils/collections/resource-owners.ts +193 -0
  153. package/eventcatalog/src/utils/collections/resources.ts +310 -0
  154. package/eventcatalog/src/utils/collections/util.ts +31 -0
  155. package/eventcatalog/src/utils/feature.ts +8 -0
  156. package/eventcatalog/src/utils/git.ts +11 -5
  157. package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
  158. package/eventcatalog/src/utils/trial.ts +1 -1
  159. package/package.json +17 -12
  160. package/dist/runtime-dependencies/shelljs.mjs +0 -3
  161. package/eventcatalog/public/studio-bg.png +0 -0
  162. package/eventcatalog/src/components/Grids/DomainGrid.tsx +0 -838
  163. package/eventcatalog/src/components/Grids/MessageGrid.tsx +0 -344
  164. package/eventcatalog/src/components/Grids/SystemGrid.tsx +0 -185
  165. package/eventcatalog/src/components/Grids/components.tsx +0 -170
  166. package/eventcatalog/src/components/Grids/message-link.ts +0 -38
  167. package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
  168. package/eventcatalog/src/components/Tables/SystemResources/SystemResourcesTable.tsx +0 -294
  169. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.spec.ts +0 -65
  170. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_resources.ts +0 -46
  171. package/eventcatalog/src/pages/studio.astro +0 -316
  172. package/dist/{chunk-AYNZXUB5.js → chunk-KHACCTW2.js} +3 -3
@@ -0,0 +1,286 @@
1
+ import { Fragment, memo, useEffect, useLayoutEffect, useRef, useSyncExternalStore, type MutableRefObject } from 'react';
2
+ import { useInternalNode, useStore, useStoreApi, ViewportPortal, type XYPosition } from '@xyflow/react';
3
+ import { Bot, Eye, EyeOff } from 'lucide-react';
4
+ import { sizeOf } from '../grouping';
5
+ import { usePeople, type Peer, type PresenceStore } from '../hooks/presence-store';
6
+ import { useFollowCamera } from '../hooks/use-follow-camera';
7
+ import Picture from './Picture';
8
+
9
+ /**
10
+ * Other people on the canvas: their pointers, selections and the connections they're dragging. Drawn in the
11
+ * canvas's own coordinates (React Flow's viewport portal), so panning and zooming move them without
12
+ * re-rendering, and counter-scaled so they stay the same size on screen. Each person's pointer and marks follow
13
+ * them on their own, so one pointer moving doesn't re-render anyone else's.
14
+ */
15
+ export const RemotePresence = memo(function RemotePresence({
16
+ store,
17
+ clientId,
18
+ }: {
19
+ store: PresenceStore | null;
20
+ clientId: number | null;
21
+ }) {
22
+ const people = usePeople(store);
23
+ const zoom = useStore((state) => state.transform[2]);
24
+ const scale = 1 / zoom;
25
+ const others = store ? people.filter((peer) => peer.clientId !== clientId) : [];
26
+
27
+ return (
28
+ <ViewportPortal>
29
+ {/* Above nodes (selected ones are raised to 1000) */}
30
+ <div className="pointer-events-none absolute left-0 top-0" style={{ zIndex: 2000 }}>
31
+ {others.map((peer) => (
32
+ <Fragment key={peer.clientId}>
33
+ <PeerMarks store={store!} clientId={peer.clientId} scale={scale} />
34
+ {peer.pointer && <PeerPointer store={store!} peer={peer} scale={scale} />}
35
+ </Fragment>
36
+ ))}
37
+ </div>
38
+ </ViewportPortal>
39
+ );
40
+ });
41
+
42
+ const findPeer = (store: PresenceStore, clientId: number) => store.getPeers().find((peer) => peer.clientId === clientId);
43
+
44
+ /** What a peer's marks show: re-rendered only when it changes */
45
+ const marksKey = (peer: Peer | undefined) =>
46
+ peer
47
+ ? [
48
+ peer.selection.join(','),
49
+ peer.connecting && peer.pointer ? [peer.connecting.x, peer.connecting.y, peer.pointer.x, peer.pointer.y].join(',') : '',
50
+ ].join('|')
51
+ : '';
52
+
53
+ /** Someone's selection, and the connection they're dragging (a dashed line from where it starts to their pointer) */
54
+ const PeerMarks = memo(function PeerMarks({ store, clientId, scale }: { store: PresenceStore; clientId: number; scale: number }) {
55
+ useSyncExternalStore(store.subscribe, () => marksKey(findPeer(store, clientId)));
56
+ const peer = findPeer(store, clientId);
57
+ if (!peer) return null;
58
+ return (
59
+ <>
60
+ {peer.selection.map((id) => (
61
+ <SelectionOutline key={id} nodeId={id} name={peer.name} color={peer.color} scale={scale} />
62
+ ))}
63
+ {peer.pointer && peer.connecting && (
64
+ <svg className="absolute left-0 top-0 overflow-visible" width={1} height={1}>
65
+ <line
66
+ x1={peer.connecting.x}
67
+ y1={peer.connecting.y}
68
+ x2={peer.pointer.x}
69
+ y2={peer.pointer.y}
70
+ stroke={peer.color}
71
+ strokeWidth={2 * scale}
72
+ strokeDasharray={`${6 * scale} ${4 * scale}`}
73
+ />
74
+ <circle cx={peer.connecting.x} cy={peer.connecting.y} r={4 * scale} fill={peer.color} />
75
+ </svg>
76
+ )}
77
+ </>
78
+ );
79
+ });
80
+
81
+ /** Longest a pointer takes to glide to where it was last seen (updates come about every 33 ms) */
82
+ const MAX_GLIDE_MS = 120;
83
+
84
+ /**
85
+ * Someone's pointer, gliding from where it was to each new position over the time since the last one (so a
86
+ * steady stream of updates looks like steady movement). Moved with a transform outside React: no re-renders.
87
+ */
88
+ const PeerPointer = memo(function PeerPointer({ store, peer, scale }: { store: PresenceStore; peer: Peer; scale: number }) {
89
+ const ref = useRef<HTMLDivElement>(null);
90
+ const shown = useRef<XYPosition | null>(null);
91
+ const scaleRef = useRef(scale);
92
+ scaleRef.current = scale;
93
+
94
+ const draw = () => {
95
+ if (ref.current && shown.current)
96
+ ref.current.style.transform = `translate(${shown.current.x}px, ${shown.current.y}px) scale(${scaleRef.current})`;
97
+ };
98
+
99
+ useLayoutEffect(draw, [scale]);
100
+
101
+ useLayoutEffect(() => {
102
+ let target: XYPosition | null = null;
103
+ let from: XYPosition = { x: 0, y: 0 };
104
+ let start = 0;
105
+ let duration = 0;
106
+ let lastSeen = 0;
107
+ let frame = 0;
108
+
109
+ const step = (now: number) => {
110
+ const t = Math.min(1, (now - start) / duration);
111
+ if (target) shown.current = { x: from.x + (target.x - from.x) * t, y: from.y + (target.y - from.y) * t };
112
+ draw();
113
+ frame = t < 1 ? requestAnimationFrame(step) : 0;
114
+ };
115
+
116
+ const follow = () => {
117
+ const pointer = findPeer(store, peer.clientId)?.pointer;
118
+ if (!pointer || (target && pointer.x === target.x && pointer.y === target.y)) return;
119
+ const now = performance.now();
120
+ target = pointer;
121
+ if (!shown.current) {
122
+ shown.current = pointer;
123
+ lastSeen = now;
124
+ return draw();
125
+ }
126
+ from = shown.current;
127
+ start = now;
128
+ duration = Math.min(MAX_GLIDE_MS, Math.max(16, now - lastSeen));
129
+ lastSeen = now;
130
+ if (!frame) frame = requestAnimationFrame(step);
131
+ };
132
+
133
+ follow();
134
+ const unsubscribe = store.subscribe(follow);
135
+ return () => {
136
+ unsubscribe();
137
+ cancelAnimationFrame(frame);
138
+ };
139
+ }, [store, peer.clientId]);
140
+
141
+ return (
142
+ <div ref={ref} className="absolute left-0 top-0 will-change-transform" style={{ transformOrigin: '0 0' }}>
143
+ {/* The pointer's outline is a light border on any background */}
144
+ <svg width="18" height="18" viewBox="0 0 24 24" fill={peer.color} stroke="white" strokeWidth="1.5">
145
+ <path d="M4 2l16 9-7 2-3 7z" />
146
+ </svg>
147
+ <span
148
+ className="absolute left-4 top-4 flex items-center gap-1 whitespace-nowrap rounded px-1.5 py-0.5 text-[11px] font-medium text-white"
149
+ style={{ backgroundColor: peer.color }}
150
+ >
151
+ {peer.agent && <Bot size={12} />}
152
+ <Picture src={peer.picture} className="-ml-0.5 h-4 w-4 shrink-0" />
153
+ {peer.name}
154
+ {peer.activity && <span className="font-normal opacity-90">· {peer.activity}</span>}
155
+ </span>
156
+ </div>
157
+ );
158
+ });
159
+
160
+ /** Someone's selection around a node: follows that node only, so other nodes moving don't re-render it */
161
+ const SelectionOutline = memo(function SelectionOutline({
162
+ nodeId,
163
+ name,
164
+ color,
165
+ scale,
166
+ }: {
167
+ nodeId: string;
168
+ name: string;
169
+ color: string;
170
+ scale: number;
171
+ }) {
172
+ const node = useInternalNode(nodeId);
173
+ if (!node) return null;
174
+ const { width, height } = sizeOf(node);
175
+ const { x, y } = node.internals.positionAbsolute;
176
+ return (
177
+ <div
178
+ className="absolute"
179
+ style={{
180
+ left: x - 4 * scale,
181
+ top: y - 4 * scale,
182
+ width: width + 8 * scale,
183
+ height: height + 8 * scale,
184
+ border: `${2 * scale}px solid ${color}`,
185
+ borderRadius: 8 * scale,
186
+ }}
187
+ >
188
+ <span
189
+ className="absolute right-0 top-0 whitespace-nowrap rounded px-1.5 text-[10px] leading-4 text-white"
190
+ style={{ backgroundColor: color, transform: `translateY(-100%) scale(${scale})`, transformOrigin: 'bottom right' }}
191
+ >
192
+ {name}
193
+ </span>
194
+ </div>
195
+ );
196
+ });
197
+
198
+ /**
199
+ * Shares what we're looking at, so others can jump to it: the canvas position in the middle of the screen,
200
+ * and the zoom. Follows React Flow's store (people's moves and code's, e.g. fit view) without rendering.
201
+ */
202
+ export const ViewportSharer = memo(function ViewportSharer({
203
+ onChange,
204
+ }: {
205
+ onChange: (viewport: { x: number; y: number; zoom: number }) => void;
206
+ }) {
207
+ const store = useStoreApi();
208
+ useEffect(() => {
209
+ let timer: ReturnType<typeof setTimeout> | undefined;
210
+ const share = () => {
211
+ const {
212
+ transform: [x, y, zoom],
213
+ width,
214
+ height,
215
+ } = store.getState();
216
+ onChange({ x: (width / 2 - x) / zoom, y: (height / 2 - y) / zoom, zoom });
217
+ };
218
+ const unsubscribe = store.subscribe((state, previous) => {
219
+ if (state.transform === previous.transform && state.width === previous.width && state.height === previous.height) return;
220
+ clearTimeout(timer);
221
+ timer = setTimeout(share, 150);
222
+ });
223
+ share();
224
+ return () => {
225
+ unsubscribe();
226
+ clearTimeout(timer);
227
+ };
228
+ }, [store, onChange]);
229
+ return null;
230
+ });
231
+
232
+ /**
233
+ * Agents at work: who's working (with a button to follow them or stop), and the camera following them while
234
+ * they work (on by default inside a chat). `pause` is set for whoever moves the camera themselves.
235
+ */
236
+ export const AgentActivity = memo(function AgentActivity({
237
+ store,
238
+ clientId,
239
+ enabled,
240
+ following,
241
+ onToggleFollowing,
242
+ pauseRef,
243
+ }: {
244
+ store: PresenceStore | null;
245
+ clientId: number | null;
246
+ /** Off in the read only levels */
247
+ enabled: boolean;
248
+ following: boolean;
249
+ onToggleFollowing: () => void;
250
+ pauseRef: MutableRefObject<() => void>;
251
+ }) {
252
+ const people = usePeople(store);
253
+ const working = people.filter((peer) => peer.clientId !== clientId && peer.agent && peer.pointer);
254
+ const camera = useFollowCamera({
255
+ enabled: following && enabled,
256
+ agentsWorking: working.length > 0,
257
+ getAgents: () => (store?.getPeers() ?? []).filter((peer: Peer) => peer.clientId !== clientId && peer.agent && peer.pointer),
258
+ });
259
+ pauseRef.current = camera.pause;
260
+
261
+ if (!enabled || working.length === 0) return null;
262
+ return (
263
+ <div className="absolute left-1/2 top-16 z-10 flex -translate-x-1/2 items-center gap-2 rounded-full border px-3 py-1 text-xs shadow-sm bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]">
264
+ <span className="flex -space-x-1">
265
+ {working.map((agent) => (
266
+ <span
267
+ key={agent.clientId}
268
+ className="h-2.5 w-2.5 rounded-full ring-2 ring-[rgb(var(--ec-card-bg))]"
269
+ style={{ backgroundColor: agent.color }}
270
+ />
271
+ ))}
272
+ </span>
273
+ <span>
274
+ {following ? 'Following' : ''} <span className="font-semibold">{working.map((agent) => agent.name).join(', ')}</span>
275
+ {following ? '' : ` ${working.length === 1 ? 'is' : 'are'} working`}
276
+ </span>
277
+ <button
278
+ onClick={onToggleFollowing}
279
+ className="flex items-center gap-1 rounded-full px-2 py-0.5 font-medium text-[rgb(var(--ec-accent))] hover:bg-[rgb(var(--ec-accent-subtle))]"
280
+ >
281
+ {following ? <EyeOff size={12} /> : <Eye size={12} />}
282
+ {following ? 'Stop' : 'Follow'}
283
+ </button>
284
+ </div>
285
+ );
286
+ });
@@ -0,0 +1,212 @@
1
+ import { memo, useState, type FormEvent, type ReactNode } from 'react';
2
+ import type { Node } from '@xyflow/react';
3
+ import { Box, ExternalLink, Library, Trash2, X } from 'lucide-react';
4
+ import { getCatalogLink } from '../catalog';
5
+ import { getNodeLabel, getResource, isVersioned, updateResource } from '../node-types';
6
+ import type { UpdateNodeData } from './canvas-nodes';
7
+ import { NODE_ICONS } from './icons';
8
+ import { STATUS } from './status';
9
+
10
+ const inputClass =
11
+ 'w-full rounded-lg border px-3 py-2 text-sm shadow-sm bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))] placeholder:text-[rgb(var(--ec-page-text-muted))] focus:outline-none focus:border-[rgb(var(--ec-accent))] focus:ring-2 focus:ring-[rgb(var(--ec-accent)/0.25)] read-only:cursor-default read-only:opacity-70 read-only:shadow-none read-only:focus:ring-0 read-only:focus:border-[rgb(var(--ec-input-border))]';
12
+ const buttonClass = 'rounded-lg px-4 py-2 text-sm font-medium transition-colors';
13
+
14
+ // Versions are usually semantic versions (1.0.0), but any text is allowed
15
+ const LOOKS_LIKE_A_VERSION = /^v?\d+(\.\d+){0,2}([-+][\w.-]+)?$/;
16
+
17
+ /** How to name each type, shown under the name */
18
+ const NAME_HINTS: Record<string, string> = {
19
+ event: 'The name of the event. Use a verb-noun format in the past tense (e.g., OrderPlaced).',
20
+ command: 'The name of the command. Use an instruction (e.g., PlaceOrder).',
21
+ query: 'The name of the query. Ask for something (e.g., GetOrder).',
22
+ service: 'The name of the service (e.g., Orders Service).',
23
+ channel: 'The name of the channel (e.g., orders.events).',
24
+ };
25
+
26
+ type Field = 'name' | 'version' | 'summary';
27
+
28
+ /**
29
+ * The details of the node being edited: its name, version and summary, saved together with Save changes (one
30
+ * change for everyone, and one step to undo). Catalog resources are read only: they're changed in the catalog.
31
+ * Notes and text are edited on the canvas.
32
+ */
33
+ export default memo(function PropertiesPanel({
34
+ node,
35
+ onChange,
36
+ onDelete,
37
+ onClose,
38
+ }: {
39
+ /** What's shown of the node (not its position, so dragging it doesn't re-render the panel) */
40
+ node: Pick<Node, 'id' | 'type' | 'data'>;
41
+ onChange: UpdateNodeData;
42
+ onDelete: (ids: string[]) => void;
43
+ onClose: () => void;
44
+ }) {
45
+ const resource = getResource(node.type, node.data);
46
+ const link = getCatalogLink(node);
47
+ const label = getNodeLabel(node.type);
48
+ const noun = label.toLowerCase();
49
+ const { icon: Icon, className: iconClassName } = NODE_ICONS[node.type ?? ''] ?? { icon: Box, className: '' };
50
+ const versioned = isVersioned(node.type);
51
+
52
+ // What's been changed here and not saved. Fields not changed show the node as it is, so changes others make while
53
+ // it's open show up.
54
+ const [draft, setDraft] = useState<Partial<Record<Field, string>>>({});
55
+ const saved: Record<Field, string> = {
56
+ name: String(resource.name ?? ''),
57
+ version: String(link?.version ?? resource.version ?? ''),
58
+ summary: String(resource.summary ?? ''),
59
+ };
60
+ const value = (field: Field) => draft[field] ?? saved[field];
61
+ const edit = (field: Field) => (event: { target: { value: string } }) =>
62
+ setDraft((current) => ({ ...current, [field]: event.target.value }));
63
+
64
+ const changes = Object.entries(draft).filter(([field, text]) => text !== saved[field as Field]);
65
+ const missing = !value('name').trim() || (versioned && !value('version').trim());
66
+ const canSave = !link && changes.length > 0 && !missing;
67
+ const version = value('version');
68
+
69
+ const save = (event?: FormEvent) => {
70
+ event?.preventDefault();
71
+ if (!canSave) return;
72
+ onChange(node.id, (data) => updateResource(node.type, data, Object.fromEntries(changes)));
73
+ setDraft({});
74
+ onClose();
75
+ };
76
+
77
+ return (
78
+ <aside className="flex w-[22rem] shrink-0 flex-col border-l bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]">
79
+ <header className="flex items-center gap-3 border-b px-5 py-4 border-[rgb(var(--ec-page-border))]">
80
+ <Icon size={22} className={`shrink-0 ${iconClassName}`} />
81
+ <h2 className="min-w-0 flex-1 truncate text-lg font-semibold">{label}</h2>
82
+ <button
83
+ type="button"
84
+ onClick={onClose}
85
+ title="Close"
86
+ aria-label="Close"
87
+ className="shrink-0 rounded-md p-1 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)] hover:text-[rgb(var(--ec-icon-hover))]"
88
+ >
89
+ <X size={18} />
90
+ </button>
91
+ </header>
92
+
93
+ <form
94
+ onSubmit={save}
95
+ // ⌘/Ctrl + Enter saves from any field (Enter alone adds a line to the summary)
96
+ onKeyDown={(event) => (event.metaKey || event.ctrlKey) && event.key === 'Enter' && save(event)}
97
+ className="flex min-h-0 flex-1 flex-col"
98
+ >
99
+ <div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-5">
100
+ {link && (
101
+ <div className="space-y-2 rounded-lg border p-3 text-xs bg-[rgb(var(--ec-accent-subtle)/0.5)] border-[rgb(var(--ec-accent)/0.25)]">
102
+ <p className="flex items-center gap-1.5 font-semibold text-[rgb(var(--ec-page-text))]">
103
+ <Library size={14} className="text-[rgb(var(--ec-accent))]" />
104
+ From your catalog
105
+ </p>
106
+ <p className="text-[rgb(var(--ec-page-text-muted))]">
107
+ Its name, version and summary come from the catalog: change them there and they're kept in sync here.
108
+ </p>
109
+ <a
110
+ href={link.url}
111
+ target="_blank"
112
+ rel="noreferrer"
113
+ className="inline-flex items-center gap-1 font-medium text-[rgb(var(--ec-accent))] hover:underline"
114
+ >
115
+ Open in catalog <ExternalLink size={12} />
116
+ </a>
117
+ </div>
118
+ )}
119
+
120
+ <FormField label="Name" hint={NAME_HINTS[node.type ?? ''] ?? `The name of the ${noun}.`} required={!link}>
121
+ <input
122
+ autoFocus={!link}
123
+ value={value('name')}
124
+ readOnly={!!link}
125
+ onChange={edit('name')}
126
+ placeholder={`Name this ${noun}`}
127
+ className={inputClass}
128
+ />
129
+ </FormField>
130
+ {versioned && (
131
+ <FormField label="Version" hint="The version number (e.g., 1.0.0)." required={!link}>
132
+ <input
133
+ value={version}
134
+ readOnly={!!link}
135
+ onChange={edit('version')}
136
+ placeholder="0.0.1"
137
+ spellCheck={false}
138
+ className={inputClass}
139
+ />
140
+ {!link && version && !LOOKS_LIKE_A_VERSION.test(version) && (
141
+ <p className={`text-xs ${STATUS.warning.text}`}>Versions are usually like 1.0.0</p>
142
+ )}
143
+ </FormField>
144
+ )}
145
+ <FormField label="Summary" hint={`A brief summary of the ${noun}.`}>
146
+ <textarea
147
+ rows={5}
148
+ value={value('summary')}
149
+ readOnly={!!link}
150
+ onChange={edit('summary')}
151
+ placeholder={`What does this ${noun} do?`}
152
+ className={`${inputClass} resize-y`}
153
+ />
154
+ </FormField>
155
+ </div>
156
+
157
+ <footer className="flex items-center gap-2 border-t px-5 py-4 border-[rgb(var(--ec-page-border))]">
158
+ <button
159
+ type="button"
160
+ onClick={() => onDelete([node.id])}
161
+ title={`Delete ${noun}`}
162
+ aria-label={`Delete ${noun}`}
163
+ className={`mr-auto rounded-lg p-2 ${STATUS.danger.text} ${STATUS.danger.hover}`}
164
+ >
165
+ <Trash2 size={16} />
166
+ </button>
167
+ <button
168
+ type="button"
169
+ onClick={onClose}
170
+ className={`${buttonClass} border border-[rgb(var(--ec-page-border))] hover:bg-[rgb(var(--ec-page-border)/0.4)]`}
171
+ >
172
+ {link ? 'Close' : 'Cancel'}
173
+ </button>
174
+ {!link && (
175
+ <button
176
+ type="submit"
177
+ disabled={!canSave}
178
+ className={`${buttonClass} bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))] disabled:cursor-not-allowed disabled:opacity-50`}
179
+ >
180
+ Save changes
181
+ </button>
182
+ )}
183
+ </footer>
184
+ </form>
185
+ </aside>
186
+ );
187
+ });
188
+
189
+ function FormField({
190
+ label,
191
+ hint,
192
+ required = false,
193
+ children,
194
+ }: {
195
+ label: string;
196
+ hint: string;
197
+ required?: boolean;
198
+ children: ReactNode;
199
+ }) {
200
+ return (
201
+ <label className="block space-y-2">
202
+ <span className="block">
203
+ <span className="block text-sm font-semibold text-[rgb(var(--ec-page-text))]">
204
+ {label}
205
+ {required && <span className={`ml-0.5 ${STATUS.danger.text}`}>*</span>}
206
+ </span>
207
+ <span className="mt-0.5 block text-xs text-[rgb(var(--ec-page-text-muted))]">{hint}</span>
208
+ </span>
209
+ {children}
210
+ </label>
211
+ );
212
+ }