@eventcatalog/core 5.0.0-beta.4 → 5.0.0-beta.6
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.
- package/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +2 -2
- package/dist/analytics/log-build.js +4 -4
- package/dist/{chunk-TOMV5YJH.js → chunk-56CD2NH6.js} +1 -1
- package/dist/{chunk-M7OVJHPV.js → chunk-DE3QHVJP.js} +1 -1
- package/dist/{chunk-Q7FBYUCZ.js → chunk-L2IRFOE7.js} +1 -1
- package/dist/{chunk-TQLRG35T.js → chunk-PIYJM642.js} +1 -1
- package/dist/{chunk-VA77ENFQ.js → chunk-QBP3BBSV.js} +1 -1
- package/dist/{chunk-PL3T5TWF.js → chunk-XMPFHLK4.js} +2 -2
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +2 -2
- package/dist/eventcatalog.js +6 -6
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
- package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
- package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
- package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
- package/dist/runtime-dependencies/manifest.json +8 -0
- package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
- package/dist/runtime-dependencies/y-protocols.mjs +3 -0
- package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
- package/dist/runtime-dependencies/yjs.mjs +3 -0
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/dist/utils/license-status.cjs +1 -1
- package/dist/utils/license-status.js +1 -1
- package/eventcatalog/astro.config.mjs +2 -0
- package/eventcatalog/integrations/studio-server.mjs +17 -0
- package/eventcatalog/src/features/integrations/eventcatalog-features.ts +20 -1
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +39 -0
- package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +269 -0
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +209 -0
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +2 -2
- package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
- package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
- package/eventcatalog/src/features/mcp/mcp-server.ts +17 -4
- package/eventcatalog/src/features/studio/agent-choreography.ts +378 -0
- package/eventcatalog/src/features/studio/canvas-actions.ts +287 -0
- package/eventcatalog/src/features/studio/canvas-doc.ts +280 -0
- package/eventcatalog/src/features/studio/catalog-resources.ts +152 -0
- package/eventcatalog/src/features/studio/catalog.ts +171 -0
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +153 -0
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +332 -0
- package/eventcatalog/src/features/studio/components/Comments.tsx +455 -0
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +217 -0
- package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +58 -0
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +218 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +284 -0
- package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +75 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +760 -0
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +79 -0
- package/eventcatalog/src/features/studio/components/icons.ts +48 -0
- package/eventcatalog/src/features/studio/components/status.ts +22 -0
- package/eventcatalog/src/features/studio/edges.ts +54 -0
- package/eventcatalog/src/features/studio/flow-state.ts +92 -0
- package/eventcatalog/src/features/studio/grouping.ts +133 -0
- package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
- package/eventcatalog/src/features/studio/hooks/presence-store.ts +115 -0
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +337 -0
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +47 -0
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +99 -0
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +423 -0
- package/eventcatalog/src/features/studio/layout.ts +39 -0
- package/eventcatalog/src/features/studio/levels.ts +216 -0
- package/eventcatalog/src/features/studio/node-types.ts +173 -0
- package/eventcatalog/src/features/studio/pages/[id].astro +38 -0
- package/eventcatalog/src/features/studio/pages/index.astro +8 -0
- package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
- package/eventcatalog/src/features/studio/placement.ts +197 -0
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +475 -0
- package/eventcatalog/src/features/studio/server/runtime.ts +215 -0
- package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
- package/eventcatalog/src/features/studio/studio.css +89 -0
- package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +9 -2
- package/eventcatalog/src/utils/feature.ts +4 -0
- package/eventcatalog/src/utils/trial.ts +1 -1
- package/package.json +9 -2
- package/eventcatalog/public/studio-bg.png +0 -0
- package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
- package/eventcatalog/src/pages/studio.astro +0 -316
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { memo, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { useInternalNode, useReactFlow, useStore, ViewportPortal, type Node, type XYPosition } from '@xyflow/react';
|
|
3
|
+
import { Check, MessageCircle, RotateCcw, Trash2, X } from 'lucide-react';
|
|
4
|
+
import { getAbsolutePosition } from '../grouping';
|
|
5
|
+
import type { Author, CommentAnchor, Thread } from '../hooks/use-comments';
|
|
6
|
+
import { STATUS } from './status';
|
|
7
|
+
|
|
8
|
+
const initials = (name: string) => name.slice(0, 2).toUpperCase();
|
|
9
|
+
|
|
10
|
+
const timeAgo = (timestamp: number) => {
|
|
11
|
+
const seconds = Math.round((Date.now() - timestamp) / 1000);
|
|
12
|
+
if (seconds < 60) return 'just now';
|
|
13
|
+
const minutes = Math.round(seconds / 60);
|
|
14
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
15
|
+
const hours = Math.round(minutes / 60);
|
|
16
|
+
if (hours < 24) return `${hours}h ago`;
|
|
17
|
+
return new Date(timestamp).toLocaleDateString();
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Where a pin sits on the canvas: on its node (so it moves with it), or where it was dropped */
|
|
21
|
+
export const getAnchorPosition = (anchor: CommentAnchor, nodesById: Map<string, Node>): XYPosition => {
|
|
22
|
+
const node = anchor.nodeId ? nodesById.get(anchor.nodeId) : undefined;
|
|
23
|
+
if (!node || !anchor.offset) return anchor.position;
|
|
24
|
+
// Nodes in containers are positioned relative to them
|
|
25
|
+
const position = getAbsolutePosition(node, nodesById);
|
|
26
|
+
return { x: position.x + anchor.offset.x, y: position.y + anchor.offset.y };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Pinned to a node, a comment keeps its offset from the node so it moves with it */
|
|
30
|
+
export const anchorAt = (position: XYPosition, node?: Node, nodesById?: Map<string, Node>): CommentAnchor => {
|
|
31
|
+
if (!node) return { position };
|
|
32
|
+
const nodePosition = getAbsolutePosition(node, nodesById ?? new Map([[node.id, node]]));
|
|
33
|
+
return { position, nodeId: node.id, offset: { x: position.x - nodePosition.x, y: position.y - nodePosition.y } };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const DRAG_THRESHOLD_PX = 4;
|
|
37
|
+
|
|
38
|
+
const panelClass =
|
|
39
|
+
'nowheel nodrag nopan absolute z-40 w-72 rounded-xl border shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A comment pin. Click opens it, dragging moves it: `onDrag` gets the screen point the pin's tip
|
|
43
|
+
* (its bottom left corner) is over, keeping where on the pin it was grabbed.
|
|
44
|
+
*/
|
|
45
|
+
function Pin({
|
|
46
|
+
author,
|
|
47
|
+
active,
|
|
48
|
+
resolved,
|
|
49
|
+
onClick,
|
|
50
|
+
onDrag,
|
|
51
|
+
}: {
|
|
52
|
+
author: Author;
|
|
53
|
+
active?: boolean;
|
|
54
|
+
resolved?: boolean;
|
|
55
|
+
onClick?: () => void;
|
|
56
|
+
onDrag?: (tip: XYPosition, phase: 'move' | 'end') => void;
|
|
57
|
+
}) {
|
|
58
|
+
const drag = useRef<{ start: XYPosition; grab: XYPosition; moved: boolean } | null>(null);
|
|
59
|
+
const tipOf = (event: { clientX: number; clientY: number }) => ({
|
|
60
|
+
x: event.clientX - drag.current!.grab.x,
|
|
61
|
+
y: event.clientY - drag.current!.grab.y,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<button
|
|
66
|
+
onPointerDown={(event) => {
|
|
67
|
+
if (event.button !== 0) return;
|
|
68
|
+
// Keeps pointer events coming while the pointer is outside the pin (throws if the pointer has gone)
|
|
69
|
+
try {
|
|
70
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
71
|
+
} catch {}
|
|
72
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
73
|
+
drag.current = {
|
|
74
|
+
start: { x: event.clientX, y: event.clientY },
|
|
75
|
+
grab: { x: event.clientX - rect.left, y: event.clientY - rect.bottom },
|
|
76
|
+
moved: false,
|
|
77
|
+
};
|
|
78
|
+
}}
|
|
79
|
+
onPointerMove={(event) => {
|
|
80
|
+
const current = drag.current;
|
|
81
|
+
if (!current || !onDrag) return;
|
|
82
|
+
const distance = Math.hypot(event.clientX - current.start.x, event.clientY - current.start.y);
|
|
83
|
+
if (!current.moved && distance < DRAG_THRESHOLD_PX) return;
|
|
84
|
+
current.moved = true;
|
|
85
|
+
onDrag(tipOf(event), 'move');
|
|
86
|
+
}}
|
|
87
|
+
onPointerUp={(event) => {
|
|
88
|
+
const current = drag.current;
|
|
89
|
+
if (!current) return;
|
|
90
|
+
if (current.moved) onDrag?.(tipOf(event), 'end');
|
|
91
|
+
else onClick?.();
|
|
92
|
+
drag.current = null;
|
|
93
|
+
}}
|
|
94
|
+
onPointerCancel={() => (drag.current = null)}
|
|
95
|
+
// Keyboard activation (pointer clicks are handled on pointer up)
|
|
96
|
+
onClick={(event) => event.detail === 0 && onClick?.()}
|
|
97
|
+
className={`flex h-8 w-8 cursor-grab items-center justify-center rounded-full rounded-bl-none border-2 border-[rgb(var(--ec-card-bg))] text-[11px] font-semibold text-white shadow-md transition-transform hover:scale-110 active:cursor-grabbing ${
|
|
98
|
+
active ? 'scale-110 ring-2 ring-[rgb(var(--ec-accent))]' : ''
|
|
99
|
+
}`}
|
|
100
|
+
// Resolved threads are greyed out, in the theme's muted colour
|
|
101
|
+
style={{ backgroundColor: resolved ? 'rgb(var(--ec-page-text-muted))' : author.color }}
|
|
102
|
+
>
|
|
103
|
+
{initials(author.name)}
|
|
104
|
+
</button>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function Composer({
|
|
109
|
+
placeholder,
|
|
110
|
+
onSubmit,
|
|
111
|
+
onCancel,
|
|
112
|
+
}: {
|
|
113
|
+
placeholder: string;
|
|
114
|
+
onSubmit: (text: string) => void;
|
|
115
|
+
onCancel?: () => void;
|
|
116
|
+
}) {
|
|
117
|
+
const [text, setText] = useState('');
|
|
118
|
+
const submit = () => {
|
|
119
|
+
if (!text.trim()) return;
|
|
120
|
+
onSubmit(text.trim());
|
|
121
|
+
setText('');
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className="flex items-end gap-2 p-2">
|
|
126
|
+
<textarea
|
|
127
|
+
autoFocus
|
|
128
|
+
rows={1}
|
|
129
|
+
value={text}
|
|
130
|
+
placeholder={placeholder}
|
|
131
|
+
onChange={(e) => setText(e.target.value)}
|
|
132
|
+
onKeyDown={(e) => {
|
|
133
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
submit();
|
|
136
|
+
}
|
|
137
|
+
if (e.key === 'Escape') onCancel?.();
|
|
138
|
+
}}
|
|
139
|
+
className="max-h-32 min-h-[34px] flex-1 resize-none rounded-md border px-2 py-1.5 text-sm bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))]"
|
|
140
|
+
/>
|
|
141
|
+
<button
|
|
142
|
+
onClick={submit}
|
|
143
|
+
disabled={!text.trim()}
|
|
144
|
+
className="rounded-md px-2.5 py-1.5 text-xs font-medium disabled:opacity-40 bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))]"
|
|
145
|
+
>
|
|
146
|
+
Post
|
|
147
|
+
</button>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function IconButton({ title, onClick, children }: { title: string; onClick: () => void; children: ReactNode }) {
|
|
153
|
+
return (
|
|
154
|
+
<button
|
|
155
|
+
title={title}
|
|
156
|
+
onClick={onClick}
|
|
157
|
+
className="rounded p-1 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)] hover:text-[rgb(var(--ec-icon-hover))]"
|
|
158
|
+
>
|
|
159
|
+
{children}
|
|
160
|
+
</button>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function ThreadPopover({
|
|
165
|
+
thread,
|
|
166
|
+
onReply,
|
|
167
|
+
onResolve,
|
|
168
|
+
onDelete,
|
|
169
|
+
onClose,
|
|
170
|
+
}: {
|
|
171
|
+
thread: Thread;
|
|
172
|
+
onReply: (text: string) => void;
|
|
173
|
+
onResolve: (resolved: boolean) => void;
|
|
174
|
+
onDelete: () => void;
|
|
175
|
+
onClose: () => void;
|
|
176
|
+
}) {
|
|
177
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
|
|
180
|
+
}, [thread.messages.length]);
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<>
|
|
184
|
+
<div className="flex items-center justify-between border-b px-3 py-2 border-[rgb(var(--ec-page-border))]">
|
|
185
|
+
<span className="text-xs font-semibold">{thread.resolved ? 'Resolved thread' : 'Thread'}</span>
|
|
186
|
+
<span className="flex items-center gap-0.5">
|
|
187
|
+
<IconButton title={thread.resolved ? 'Reopen' : 'Resolve'} onClick={() => onResolve(!thread.resolved)}>
|
|
188
|
+
{thread.resolved ? <RotateCcw size={14} /> : <Check size={14} />}
|
|
189
|
+
</IconButton>
|
|
190
|
+
<IconButton title="Delete thread" onClick={onDelete}>
|
|
191
|
+
<Trash2 size={14} />
|
|
192
|
+
</IconButton>
|
|
193
|
+
<IconButton title="Close" onClick={onClose}>
|
|
194
|
+
<X size={14} />
|
|
195
|
+
</IconButton>
|
|
196
|
+
</span>
|
|
197
|
+
</div>
|
|
198
|
+
<div ref={listRef} className="max-h-72 space-y-3 overflow-y-auto px-3 py-3">
|
|
199
|
+
{thread.messages.map((message) => (
|
|
200
|
+
<div key={message.id} className="flex gap-2">
|
|
201
|
+
<span
|
|
202
|
+
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white"
|
|
203
|
+
style={{ backgroundColor: message.author.color }}
|
|
204
|
+
>
|
|
205
|
+
{initials(message.author.name)}
|
|
206
|
+
</span>
|
|
207
|
+
<div className="min-w-0">
|
|
208
|
+
<p className="text-xs">
|
|
209
|
+
<span className="font-semibold">{message.author.name}</span>{' '}
|
|
210
|
+
<span className="text-[rgb(var(--ec-page-text-muted))]">{timeAgo(message.createdAt)}</span>
|
|
211
|
+
</p>
|
|
212
|
+
<p className="whitespace-pre-wrap break-words text-sm">{message.text}</p>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
))}
|
|
216
|
+
</div>
|
|
217
|
+
<div className="border-t border-[rgb(var(--ec-page-border))]">
|
|
218
|
+
<Composer placeholder="Reply" onSubmit={onReply} onCancel={onClose} />
|
|
219
|
+
</div>
|
|
220
|
+
</>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Where a pin goes on the canvas, following only the node it's on (if it's on one), counter-scaled */
|
|
225
|
+
function PinAt({ anchor, zoom, zIndex, children }: { anchor: CommentAnchor; zoom: number; zIndex: number; children: ReactNode }) {
|
|
226
|
+
const node = useInternalNode(anchor.nodeId ?? '');
|
|
227
|
+
const origin = anchor.nodeId && anchor.offset ? node?.internals.positionAbsolute : undefined;
|
|
228
|
+
const { x, y } = origin && anchor.offset ? { x: origin.x + anchor.offset.x, y: origin.y + anchor.offset.y } : anchor.position;
|
|
229
|
+
return (
|
|
230
|
+
<div className="absolute" style={{ left: x, top: y, transform: `scale(${1 / zoom})`, transformOrigin: '0 0', zIndex }}>
|
|
231
|
+
{/* The pin's tip (bottom left corner) sits on the anchor point */}
|
|
232
|
+
<div className="nopan nodrag nowheel pointer-events-auto absolute left-0 -top-8">{children}</div>
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Comment pins (and the open thread) on the canvas, in its own coordinates (React Flow's viewport portal) so
|
|
239
|
+
* they move with it without re-rendering, counter-scaled to stay the same size on screen. Each pin follows
|
|
240
|
+
* only its own node, so other nodes moving don't re-render them.
|
|
241
|
+
*/
|
|
242
|
+
export const CommentLayer = memo(function CommentLayer({
|
|
243
|
+
threads,
|
|
244
|
+
author,
|
|
245
|
+
openThreadId,
|
|
246
|
+
draft,
|
|
247
|
+
showResolved,
|
|
248
|
+
onOpen,
|
|
249
|
+
onCreate,
|
|
250
|
+
onCancelDraft,
|
|
251
|
+
onReply,
|
|
252
|
+
onResolve,
|
|
253
|
+
onDelete,
|
|
254
|
+
onMove,
|
|
255
|
+
onMoveDraft,
|
|
256
|
+
}: {
|
|
257
|
+
threads: Thread[];
|
|
258
|
+
author: Author;
|
|
259
|
+
openThreadId: string | null;
|
|
260
|
+
draft: CommentAnchor | null;
|
|
261
|
+
showResolved: boolean;
|
|
262
|
+
onOpen: (threadId: string | null) => void;
|
|
263
|
+
onCreate: (text: string) => void;
|
|
264
|
+
onCancelDraft: () => void;
|
|
265
|
+
onReply: (threadId: string, text: string) => void;
|
|
266
|
+
onResolve: (threadId: string, resolved: boolean) => void;
|
|
267
|
+
onDelete: (threadId: string) => void;
|
|
268
|
+
onMove: (threadId: string, anchor: CommentAnchor) => void;
|
|
269
|
+
onMoveDraft: (anchor: CommentAnchor) => void;
|
|
270
|
+
}) {
|
|
271
|
+
const zoom = useStore((state) => state.transform[2]);
|
|
272
|
+
const { screenToFlowPosition, getInternalNode } = useReactFlow();
|
|
273
|
+
// A pin being dragged stays here until it's dropped, then moves for everyone
|
|
274
|
+
const [moving, setMoving] = useState<{ threadId: string; anchor: CommentAnchor } | null>(null);
|
|
275
|
+
|
|
276
|
+
// While dragging, a pin follows the pointer. Dropped on a node it attaches to it (and moves with it),
|
|
277
|
+
// anywhere else it stays on the canvas.
|
|
278
|
+
const nodeUnder = (point: XYPosition) => {
|
|
279
|
+
const element = document.elementsFromPoint(point.x, point.y).find((el) => el.closest('.react-flow__node'));
|
|
280
|
+
const id = element?.closest('.react-flow__node')?.getAttribute('data-id');
|
|
281
|
+
return id ? getInternalNode(id) : undefined;
|
|
282
|
+
};
|
|
283
|
+
const anchorFor = (tip: XYPosition, phase: 'move' | 'end'): CommentAnchor => {
|
|
284
|
+
const position = screenToFlowPosition(tip);
|
|
285
|
+
const node = phase === 'end' ? nodeUnder(tip) : undefined;
|
|
286
|
+
if (!node) return { position };
|
|
287
|
+
const origin = node.internals.positionAbsolute;
|
|
288
|
+
return { position, nodeId: node.id, offset: { x: position.x - origin.x, y: position.y - origin.y } };
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const visible = threads.filter((thread) => showResolved || !thread.resolved || thread.id === openThreadId);
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<ViewportPortal>
|
|
295
|
+
{/* Above nodes (selected ones are raised to 1000) and other people's pointers */}
|
|
296
|
+
<div className="absolute left-0 top-0" style={{ zIndex: 2001 }}>
|
|
297
|
+
{visible.map((thread) => {
|
|
298
|
+
const isOpen = thread.id === openThreadId;
|
|
299
|
+
return (
|
|
300
|
+
<PinAt
|
|
301
|
+
key={thread.id}
|
|
302
|
+
anchor={moving?.threadId === thread.id ? moving.anchor : thread}
|
|
303
|
+
zoom={zoom}
|
|
304
|
+
zIndex={isOpen ? 2 : 1}
|
|
305
|
+
>
|
|
306
|
+
<Pin
|
|
307
|
+
author={thread.author}
|
|
308
|
+
active={isOpen}
|
|
309
|
+
resolved={thread.resolved}
|
|
310
|
+
onClick={() => onOpen(isOpen ? null : thread.id)}
|
|
311
|
+
onDrag={(tip, phase) => {
|
|
312
|
+
if (phase === 'move') return setMoving({ threadId: thread.id, anchor: anchorFor(tip, phase) });
|
|
313
|
+
onMove(thread.id, anchorFor(tip, phase));
|
|
314
|
+
setMoving(null);
|
|
315
|
+
}}
|
|
316
|
+
/>
|
|
317
|
+
{isOpen && (
|
|
318
|
+
<div className={panelClass} style={{ left: 40, top: 0 }}>
|
|
319
|
+
<ThreadPopover
|
|
320
|
+
thread={thread}
|
|
321
|
+
onReply={(text) => onReply(thread.id, text)}
|
|
322
|
+
onResolve={(resolved) => onResolve(thread.id, resolved)}
|
|
323
|
+
onDelete={() => onDelete(thread.id)}
|
|
324
|
+
onClose={() => onOpen(null)}
|
|
325
|
+
/>
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
</PinAt>
|
|
329
|
+
);
|
|
330
|
+
})}
|
|
331
|
+
|
|
332
|
+
{draft && (
|
|
333
|
+
<PinAt anchor={draft} zoom={zoom} zIndex={3}>
|
|
334
|
+
<Pin author={author} active onDrag={(tip, phase) => onMoveDraft(anchorFor(tip, phase))} />
|
|
335
|
+
<div className={panelClass} style={{ left: 40, top: 0 }}>
|
|
336
|
+
<Composer placeholder="Add a comment" onSubmit={onCreate} onCancel={onCancelDraft} />
|
|
337
|
+
</div>
|
|
338
|
+
</PinAt>
|
|
339
|
+
)}
|
|
340
|
+
</div>
|
|
341
|
+
</ViewportPortal>
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
export type ContextMenuState = { x: number; y: number; flowPosition: XYPosition; node?: Node } | null;
|
|
346
|
+
|
|
347
|
+
export const CanvasContextMenu = memo(function CanvasContextMenu({
|
|
348
|
+
menu,
|
|
349
|
+
onAddComment,
|
|
350
|
+
onDeleteNode,
|
|
351
|
+
onClose,
|
|
352
|
+
}: {
|
|
353
|
+
menu: ContextMenuState;
|
|
354
|
+
onAddComment: () => void;
|
|
355
|
+
onDeleteNode: (id: string) => void;
|
|
356
|
+
onClose: () => void;
|
|
357
|
+
}) {
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
if (!menu) return;
|
|
360
|
+
const close = (event: Event) => {
|
|
361
|
+
if (event instanceof KeyboardEvent && event.key !== 'Escape') return;
|
|
362
|
+
onClose();
|
|
363
|
+
};
|
|
364
|
+
window.addEventListener('pointerdown', close);
|
|
365
|
+
window.addEventListener('keydown', close);
|
|
366
|
+
window.addEventListener('wheel', close);
|
|
367
|
+
return () => {
|
|
368
|
+
window.removeEventListener('pointerdown', close);
|
|
369
|
+
window.removeEventListener('keydown', close);
|
|
370
|
+
window.removeEventListener('wheel', close);
|
|
371
|
+
};
|
|
372
|
+
}, [menu, onClose]);
|
|
373
|
+
|
|
374
|
+
if (!menu) return null;
|
|
375
|
+
|
|
376
|
+
const itemClass =
|
|
377
|
+
'flex w-full items-center gap-2 rounded px-2.5 py-1.5 text-left text-xs hover:bg-[rgb(var(--ec-page-border)/0.5)]';
|
|
378
|
+
|
|
379
|
+
return (
|
|
380
|
+
<div
|
|
381
|
+
// Keep the window pointerdown listener from closing the menu before a click lands
|
|
382
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
383
|
+
className="fixed z-50 w-44 rounded-lg border p-1 shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]"
|
|
384
|
+
style={{ left: menu.x, top: menu.y }}
|
|
385
|
+
>
|
|
386
|
+
<button className={itemClass} onClick={onAddComment}>
|
|
387
|
+
<MessageCircle size={14} />
|
|
388
|
+
Add comment
|
|
389
|
+
<span className="ml-auto text-[rgb(var(--ec-page-text-muted))]">C</span>
|
|
390
|
+
</button>
|
|
391
|
+
{menu.node && (
|
|
392
|
+
<button className={`${itemClass} ${STATUS.danger.text}`} onClick={() => onDeleteNode(menu.node!.id)}>
|
|
393
|
+
<Trash2 size={14} />
|
|
394
|
+
Delete
|
|
395
|
+
</button>
|
|
396
|
+
)}
|
|
397
|
+
</div>
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
/** All threads, for the left panel. Clicking one opens it on the canvas. */
|
|
402
|
+
export function CommentList({
|
|
403
|
+
threads,
|
|
404
|
+
showResolved,
|
|
405
|
+
onToggleResolved,
|
|
406
|
+
onOpen,
|
|
407
|
+
}: {
|
|
408
|
+
threads: Thread[];
|
|
409
|
+
showResolved: boolean;
|
|
410
|
+
onToggleResolved: () => void;
|
|
411
|
+
onOpen: (thread: Thread) => void;
|
|
412
|
+
}) {
|
|
413
|
+
const shown = threads.filter((thread) => showResolved || !thread.resolved).reverse();
|
|
414
|
+
|
|
415
|
+
return (
|
|
416
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
417
|
+
<label className="mx-3 mb-2 flex items-center gap-2 text-xs text-[rgb(var(--ec-page-text-muted))]">
|
|
418
|
+
<input type="checkbox" checked={showResolved} onChange={onToggleResolved} />
|
|
419
|
+
Show resolved
|
|
420
|
+
</label>
|
|
421
|
+
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto px-3 pb-3">
|
|
422
|
+
{shown.length === 0 && (
|
|
423
|
+
<p className="py-6 text-center text-xs text-[rgb(var(--ec-page-text-muted))]">
|
|
424
|
+
No comments yet. Right click the canvas, or press C, to add one.
|
|
425
|
+
</p>
|
|
426
|
+
)}
|
|
427
|
+
{shown.map((thread) => {
|
|
428
|
+
const [first, ...replies] = thread.messages;
|
|
429
|
+
return (
|
|
430
|
+
<button
|
|
431
|
+
key={thread.id}
|
|
432
|
+
onClick={() => onOpen(thread)}
|
|
433
|
+
className={`w-full space-y-1 rounded-md border px-2.5 py-2 text-left bg-[rgb(var(--ec-page-bg))] border-[rgb(var(--ec-page-border))] hover:border-[rgb(var(--ec-accent))] ${
|
|
434
|
+
thread.resolved ? 'opacity-60' : ''
|
|
435
|
+
}`}
|
|
436
|
+
>
|
|
437
|
+
<p className="flex items-center gap-1.5 text-xs">
|
|
438
|
+
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: thread.author.color }} />
|
|
439
|
+
<span className="font-semibold">{thread.author.name}</span>
|
|
440
|
+
<span className="text-[rgb(var(--ec-page-text-muted))]">{timeAgo(thread.createdAt)}</span>
|
|
441
|
+
{thread.resolved && <Check size={12} className={`ml-auto ${STATUS.success.text}`} />}
|
|
442
|
+
</p>
|
|
443
|
+
<p className="line-clamp-2 text-xs">{first?.text}</p>
|
|
444
|
+
{replies.length > 0 && (
|
|
445
|
+
<p className="text-[11px] text-[rgb(var(--ec-page-text-muted))]">
|
|
446
|
+
{replies.length} {replies.length === 1 ? 'reply' : 'replies'}
|
|
447
|
+
</p>
|
|
448
|
+
)}
|
|
449
|
+
</button>
|
|
450
|
+
);
|
|
451
|
+
})}
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
);
|
|
455
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from 'react';
|
|
2
|
+
import { Bot, Check, Copy, X } from 'lucide-react';
|
|
3
|
+
import type { WebMcpStatus } from '../hooks/use-canvas-webmcp';
|
|
4
|
+
import { STATUS } from './status';
|
|
5
|
+
|
|
6
|
+
type CanvasLink = { canvasId: string; title?: string; canvasUrl: string; mcpUrl: string };
|
|
7
|
+
|
|
8
|
+
export const RELAY_COMMAND = 'npx -y @mcp-b/webmcp-local-relay@5.1.0';
|
|
9
|
+
const RELAY_KEY = 'eventcatalog-webmcp-relay';
|
|
10
|
+
const WEBMCP_KEY = 'eventcatalog-webmcp';
|
|
11
|
+
|
|
12
|
+
const isOn = (key: string) => {
|
|
13
|
+
try {
|
|
14
|
+
return localStorage.getItem(key) === 'true';
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
const turnOn = (key: string) => {
|
|
20
|
+
try {
|
|
21
|
+
localStorage.setItem(key, 'true');
|
|
22
|
+
} catch {}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* WebMCP through the polyfill (for browsers without WebMCP of their own) is opt in, remembered in this browser:
|
|
27
|
+
* the polyfill watches every change to the page, which on a canvas is every drag and pan. The relay needs it.
|
|
28
|
+
*/
|
|
29
|
+
export const isWebMcpEnabled = () => isOn(WEBMCP_KEY) || isOn(RELAY_KEY);
|
|
30
|
+
export const enableWebMcp = () => turnOn(WEBMCP_KEY);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The WebMCP local relay hands this tab's tools to desktop agents. Opt in: its script keeps looking for a
|
|
34
|
+
* relay on localhost, so it only loads once someone turns it on (remembered in this browser).
|
|
35
|
+
*/
|
|
36
|
+
export const isRelayEnabled = () => isOn(RELAY_KEY);
|
|
37
|
+
|
|
38
|
+
export const loadRelay = (scriptPath: string) => {
|
|
39
|
+
if (document.querySelector('script[data-webmcp-relay-embed]')) return;
|
|
40
|
+
const script = document.createElement('script');
|
|
41
|
+
script.src = scriptPath;
|
|
42
|
+
script.dataset.webmcpRelayEmbed = '';
|
|
43
|
+
script.dataset.relayPort = '9333';
|
|
44
|
+
document.body.appendChild(script);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const enableRelay = (scriptPath: string) => {
|
|
48
|
+
turnOn(RELAY_KEY);
|
|
49
|
+
loadRelay(scriptPath);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The prompt people give their agent to join this canvas. It works out how to connect from what the agent
|
|
54
|
+
* can do: the EventCatalog MCP server (and openCanvas in chats that show MCP Apps), WebMCP in the open tab,
|
|
55
|
+
* or the WebMCP local relay for desktop agents.
|
|
56
|
+
*/
|
|
57
|
+
export const buildAgentPrompt = ({ canvasId, title, canvasUrl, mcpUrl }: CanvasLink) =>
|
|
58
|
+
`Join me on an EventCatalog canvas so we can design this together, live.
|
|
59
|
+
|
|
60
|
+
Canvas: ${title ? `"${title}"` : 'Untitled'}
|
|
61
|
+
Canvas id: ${canvasId}
|
|
62
|
+
Link: ${canvasUrl}
|
|
63
|
+
|
|
64
|
+
Connect with the first of these you can use:
|
|
65
|
+
|
|
66
|
+
1. EventCatalog MCP server (Streamable HTTP): ${mcpUrl}
|
|
67
|
+
- If you already have it (tools like getCanvas, addToCanvas), use them with canvasId "${canvasId}".
|
|
68
|
+
- If not and you can add MCP servers, add it. For example, in Claude Code: claude mcp add --transport http eventcatalog ${mcpUrl}
|
|
69
|
+
- If you can show MCP Apps in this chat (e.g. ChatGPT, Claude), call openCanvas with canvasId "${canvasId}" so I can work on the canvas with you right here.
|
|
70
|
+
2. WebMCP: if you can use tools from my browser tab, I have the canvas open there and it offers tools like get_canvas, add_to_canvas and add_comment.
|
|
71
|
+
3. WebMCP local relay: if you run on my computer (e.g. Claude Code, Cursor) and can't reach the MCP server, add the MCP server \`${RELAY_COMMAND}\`. It gives you the tools of my open canvas tab.
|
|
72
|
+
|
|
73
|
+
Once connected:
|
|
74
|
+
- Read the canvas first (getCanvas / get_canvas) and tell me who else is on it.
|
|
75
|
+
- Ask me what we are designing, then work on the canvas as we talk: add catalog resources (find them with getResources / search_catalog), add new components for things that don't exist yet, and connect them. Leave out positions so new nodes go next to what they connect to; only re-lay the whole canvas (layoutCanvas / layout_canvas) when I ask.
|
|
76
|
+
- Leave questions and suggestions as comments on the canvas, and reply to comments people leave for you.
|
|
77
|
+
- Pass agentName with your name on MCP tools, so people on the canvas can see who you are.`;
|
|
78
|
+
|
|
79
|
+
function CopyButton({ text, label = 'Copy' }: { text: string; label?: string }) {
|
|
80
|
+
const [copied, setCopied] = useState(false);
|
|
81
|
+
return (
|
|
82
|
+
<button
|
|
83
|
+
onClick={async () => {
|
|
84
|
+
await navigator.clipboard.writeText(text);
|
|
85
|
+
setCopied(true);
|
|
86
|
+
setTimeout(() => setCopied(false), 1500);
|
|
87
|
+
}}
|
|
88
|
+
className="flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))]"
|
|
89
|
+
>
|
|
90
|
+
{copied ? <Check size={14} /> : <Copy size={14} />}
|
|
91
|
+
{copied ? 'Copied' : label}
|
|
92
|
+
</button>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function Detail({ title, value, children }: { title: string; value?: string; children: ReactNode }) {
|
|
97
|
+
return (
|
|
98
|
+
<div className="space-y-1.5">
|
|
99
|
+
<h3 className="text-xs font-semibold">{title}</h3>
|
|
100
|
+
<p className="text-xs text-[rgb(var(--ec-page-text-muted))]">{children}</p>
|
|
101
|
+
{value && (
|
|
102
|
+
<div className="flex items-center gap-2">
|
|
103
|
+
<code className="min-w-0 flex-1 truncate rounded-md border px-2 py-1.5 text-xs bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))]">
|
|
104
|
+
{value}
|
|
105
|
+
</code>
|
|
106
|
+
<CopyButton text={value} />
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default function ConnectAgentDialog({
|
|
114
|
+
link,
|
|
115
|
+
webMcp,
|
|
116
|
+
relayScriptPath,
|
|
117
|
+
onEnableWebMcp,
|
|
118
|
+
onClose,
|
|
119
|
+
}: {
|
|
120
|
+
link: CanvasLink;
|
|
121
|
+
webMcp: WebMcpStatus;
|
|
122
|
+
/** Turn WebMCP on for this tab (through the polyfill, when the browser has none) */
|
|
123
|
+
onEnableWebMcp: () => void;
|
|
124
|
+
/** Where the relay's script is served (only on EventCatalog's own canvas page) */
|
|
125
|
+
relayScriptPath?: string;
|
|
126
|
+
onClose: () => void;
|
|
127
|
+
}) {
|
|
128
|
+
const prompt = buildAgentPrompt(link);
|
|
129
|
+
const [relayOn, setRelayOn] = useState(isRelayEnabled);
|
|
130
|
+
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const onKeyDown = (event: KeyboardEvent) => event.key === 'Escape' && onClose();
|
|
133
|
+
window.addEventListener('keydown', onKeyDown);
|
|
134
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
135
|
+
}, [onClose]);
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
|
139
|
+
<div
|
|
140
|
+
role="dialog"
|
|
141
|
+
aria-label="Connect your agent"
|
|
142
|
+
onClick={(event) => event.stopPropagation()}
|
|
143
|
+
className="flex max-h-full w-full max-w-2xl flex-col rounded-xl border shadow-2xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]"
|
|
144
|
+
>
|
|
145
|
+
<div className="flex items-start justify-between gap-4 border-b p-5 border-[rgb(var(--ec-page-border))]">
|
|
146
|
+
<div className="space-y-1">
|
|
147
|
+
<h2 className="flex items-center gap-2 text-base font-semibold">
|
|
148
|
+
<Bot size={18} />
|
|
149
|
+
Connect your agent
|
|
150
|
+
</h2>
|
|
151
|
+
<p className="text-sm text-[rgb(var(--ec-page-text-muted))]">
|
|
152
|
+
Give this prompt to your agent (Claude, ChatGPT, Codex, Cursor...). It joins this canvas and works on it with you,
|
|
153
|
+
live, using whatever it can connect with.
|
|
154
|
+
</p>
|
|
155
|
+
</div>
|
|
156
|
+
<button
|
|
157
|
+
onClick={onClose}
|
|
158
|
+
aria-label="Close"
|
|
159
|
+
className="rounded p-1 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)]"
|
|
160
|
+
>
|
|
161
|
+
<X size={16} />
|
|
162
|
+
</button>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div className="min-h-0 space-y-5 overflow-y-auto p-5">
|
|
166
|
+
<div className="space-y-2">
|
|
167
|
+
<div className="flex items-center justify-between">
|
|
168
|
+
<h3 className="text-xs font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">Prompt</h3>
|
|
169
|
+
<CopyButton text={prompt} label="Copy prompt" />
|
|
170
|
+
</div>
|
|
171
|
+
<pre className="max-h-64 overflow-y-auto whitespace-pre-wrap rounded-lg border p-3 font-mono text-xs leading-relaxed bg-[rgb(var(--ec-page-bg))] border-[rgb(var(--ec-page-border))]">
|
|
172
|
+
{prompt}
|
|
173
|
+
</pre>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div className="space-y-4 border-t pt-4 border-[rgb(var(--ec-page-border))]">
|
|
177
|
+
<Detail title="MCP server" value={link.mcpUrl}>
|
|
178
|
+
For agents that can add an MCP server. Chats that show MCP Apps (ChatGPT, Claude) can open the canvas right in the
|
|
179
|
+
chat. Agents running in the cloud need this EventCatalog on a public URL.
|
|
180
|
+
</Detail>
|
|
181
|
+
<Detail title="WebMCP (this tab)">
|
|
182
|
+
{webMcp.state === 'on'
|
|
183
|
+
? `On: this tab offers ${webMcp.tools} canvas tools to agents in your browser (${webMcp.source === 'native' ? "your browser's WebMCP" : 'WebMCP polyfill, e.g. for the MCP-B extension'}).`
|
|
184
|
+
: 'Off: this tab is not offering tools to agents in your browser (e.g. through the MCP-B extension).'}{' '}
|
|
185
|
+
{webMcp.state === 'off' && (
|
|
186
|
+
<button onClick={onEnableWebMcp} className="font-medium text-[rgb(var(--ec-accent))] hover:underline">
|
|
187
|
+
Turn on for this tab
|
|
188
|
+
</button>
|
|
189
|
+
)}
|
|
190
|
+
</Detail>
|
|
191
|
+
{relayScriptPath && (
|
|
192
|
+
<Detail title="WebMCP local relay" value={RELAY_COMMAND}>
|
|
193
|
+
For agents on your computer (Claude Code, Cursor...): add this as an MCP server, and turn the relay on for this
|
|
194
|
+
tab. They get the tools of this tab while it stays open.{' '}
|
|
195
|
+
{relayOn ? (
|
|
196
|
+
<span className={`font-medium ${STATUS.success.text}`}>The relay is on for this tab.</span>
|
|
197
|
+
) : (
|
|
198
|
+
<button
|
|
199
|
+
onClick={() => {
|
|
200
|
+
enableRelay(relayScriptPath);
|
|
201
|
+
setRelayOn(true);
|
|
202
|
+
// The relay hands this tab's WebMCP tools to desktop agents
|
|
203
|
+
onEnableWebMcp();
|
|
204
|
+
}}
|
|
205
|
+
className="font-medium text-[rgb(var(--ec-accent))] hover:underline"
|
|
206
|
+
>
|
|
207
|
+
Turn on for this tab
|
|
208
|
+
</button>
|
|
209
|
+
)}
|
|
210
|
+
</Detail>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}
|