@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.
Files changed (86) 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 +2 -2
  4. package/dist/analytics/log-build.js +4 -4
  5. package/dist/{chunk-TOMV5YJH.js → chunk-56CD2NH6.js} +1 -1
  6. package/dist/{chunk-M7OVJHPV.js → chunk-DE3QHVJP.js} +1 -1
  7. package/dist/{chunk-Q7FBYUCZ.js → chunk-L2IRFOE7.js} +1 -1
  8. package/dist/{chunk-TQLRG35T.js → chunk-PIYJM642.js} +1 -1
  9. package/dist/{chunk-VA77ENFQ.js → chunk-QBP3BBSV.js} +1 -1
  10. package/dist/{chunk-PL3T5TWF.js → chunk-XMPFHLK4.js} +2 -2
  11. package/dist/constants.cjs +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/eventcatalog.cjs +2 -2
  14. package/dist/eventcatalog.js +6 -6
  15. package/dist/generate.cjs +1 -1
  16. package/dist/generate.js +3 -3
  17. package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
  18. package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
  19. package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
  20. package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
  21. package/dist/runtime-dependencies/manifest.json +8 -0
  22. package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
  23. package/dist/runtime-dependencies/y-protocols.mjs +3 -0
  24. package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
  25. package/dist/runtime-dependencies/yjs.mjs +3 -0
  26. package/dist/utils/cli-logger.cjs +1 -1
  27. package/dist/utils/cli-logger.js +2 -2
  28. package/dist/utils/license-status.cjs +1 -1
  29. package/dist/utils/license-status.js +1 -1
  30. package/eventcatalog/astro.config.mjs +2 -0
  31. package/eventcatalog/integrations/studio-server.mjs +17 -0
  32. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +20 -1
  33. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +39 -0
  34. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
  35. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +269 -0
  36. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +209 -0
  37. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +2 -2
  38. package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
  39. package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
  40. package/eventcatalog/src/features/mcp/mcp-server.ts +17 -4
  41. package/eventcatalog/src/features/studio/agent-choreography.ts +378 -0
  42. package/eventcatalog/src/features/studio/canvas-actions.ts +287 -0
  43. package/eventcatalog/src/features/studio/canvas-doc.ts +280 -0
  44. package/eventcatalog/src/features/studio/catalog-resources.ts +152 -0
  45. package/eventcatalog/src/features/studio/catalog.ts +171 -0
  46. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +153 -0
  47. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +332 -0
  48. package/eventcatalog/src/features/studio/components/Comments.tsx +455 -0
  49. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +217 -0
  50. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
  51. package/eventcatalog/src/features/studio/components/JoinForm.tsx +58 -0
  52. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +218 -0
  53. package/eventcatalog/src/features/studio/components/Presence.tsx +284 -0
  54. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +75 -0
  55. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +760 -0
  56. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +79 -0
  57. package/eventcatalog/src/features/studio/components/icons.ts +48 -0
  58. package/eventcatalog/src/features/studio/components/status.ts +22 -0
  59. package/eventcatalog/src/features/studio/edges.ts +54 -0
  60. package/eventcatalog/src/features/studio/flow-state.ts +92 -0
  61. package/eventcatalog/src/features/studio/grouping.ts +133 -0
  62. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
  63. package/eventcatalog/src/features/studio/hooks/presence-store.ts +115 -0
  64. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +337 -0
  65. package/eventcatalog/src/features/studio/hooks/use-comments.ts +47 -0
  66. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +99 -0
  67. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +423 -0
  68. package/eventcatalog/src/features/studio/layout.ts +39 -0
  69. package/eventcatalog/src/features/studio/levels.ts +216 -0
  70. package/eventcatalog/src/features/studio/node-types.ts +173 -0
  71. package/eventcatalog/src/features/studio/pages/[id].astro +38 -0
  72. package/eventcatalog/src/features/studio/pages/index.astro +8 -0
  73. package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
  74. package/eventcatalog/src/features/studio/placement.ts +197 -0
  75. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +475 -0
  76. package/eventcatalog/src/features/studio/server/runtime.ts +215 -0
  77. package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
  78. package/eventcatalog/src/features/studio/studio.css +89 -0
  79. package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
  80. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +9 -2
  81. package/eventcatalog/src/utils/feature.ts +4 -0
  82. package/eventcatalog/src/utils/trial.ts +1 -1
  83. package/package.json +9 -2
  84. package/eventcatalog/public/studio-bg.png +0 -0
  85. package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
  86. package/eventcatalog/src/pages/studio.astro +0 -316
@@ -0,0 +1,337 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { z } from 'zod';
3
+ import type * as Y from 'yjs';
4
+ import { initializeWebMCPPolyfill } from '@mcp-b/webmcp-polyfill';
5
+ import { replyToThread, setThreadResolved, type Author } from '../canvas-doc';
6
+ import {
7
+ ADD_TO_CANVAS_DESCRIPTION,
8
+ CATALOG_COLLECTIONS,
9
+ describeCanvas,
10
+ edgeSpecsSchema,
11
+ nodeSpecsSchema,
12
+ type CatalogIndex,
13
+ } from '../canvas-actions';
14
+ import type { CatalogResource } from '../catalog-resources';
15
+ import {
16
+ playAddToCanvas,
17
+ playComment,
18
+ playConnectAll,
19
+ playRemove,
20
+ playUpdateNode,
21
+ type AgentPresence,
22
+ type AgentStage,
23
+ } from '../agent-choreography';
24
+ import type { Peer, PresenceStore } from './presence-store';
25
+ import { BROWSER_AGENT_ORIGIN } from './use-studio-flow';
26
+
27
+ // The browser agent shows on the canvas (in this tab) while it works, and fades out a while after
28
+ const AGENT_PRESENCE_TTL_MS = 30_000;
29
+
30
+ /**
31
+ * WebMCP: the canvas page offers its tools to AI agents in the browser (Chrome's built in agent,
32
+ * ChatGPT desktop, the MCP-B extension, or desktop clients through the local relay), so an agent works
33
+ * on the canvas the user has open, live for everyone on it. The same actions as the MCP tools.
34
+ * https://webmachinelearning.github.io/webmcp/
35
+ */
36
+
37
+ type ToolResult = { content: { type: 'text'; text: string }[]; isError?: boolean };
38
+ type ModelContextTool = {
39
+ name: string;
40
+ title?: string;
41
+ description: string;
42
+ inputSchema?: object;
43
+ annotations?: { readOnlyHint?: boolean; consequentialHint?: boolean };
44
+ execute: (input: unknown, options?: { signal?: AbortSignal }) => Promise<ToolResult>;
45
+ };
46
+ type ModelContext = { registerTool: (tool: ModelContextTool, options?: { signal?: AbortSignal }) => Promise<void> | void };
47
+
48
+ // The spec moved modelContext from navigator to document; older runtimes still have the navigator one
49
+ const getModelContext = () =>
50
+ ((document as unknown as { modelContext?: ModelContext }).modelContext ??
51
+ (navigator as unknown as { modelContext?: ModelContext }).modelContext) as ModelContext | undefined;
52
+
53
+ // Survives remounts, so a native WebMCP runtime is never shadowed by the polyfill
54
+ let usingPolyfill = false;
55
+
56
+ type CanvasTool<Input extends z.ZodType> = {
57
+ name: string;
58
+ title: string;
59
+ description: string;
60
+ input: Input;
61
+ annotations?: ModelContextTool['annotations'];
62
+ execute: (input: z.output<Input>) => unknown;
63
+ };
64
+
65
+ const defineTool = <Input extends z.ZodType>(tool: CanvasTool<Input>) => tool as unknown as CanvasTool<z.ZodType>;
66
+
67
+ const result = (value: unknown, isError = false): ToolResult => ({
68
+ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
69
+ ...(isError && { isError }),
70
+ });
71
+
72
+ export type WebMcpStatus = { state: 'off' } | { state: 'on'; source: 'native' | 'polyfill'; tools: number; lastCall?: string };
73
+
74
+ type CanvasWebMcpOptions = {
75
+ doc: Y.Doc | null;
76
+ /** Where the agent shows up while it works (only in this tab: it acts through this tab's connection) */
77
+ presence: PresenceStore | null;
78
+ /** Who's on the canvas, read when an agent asks */
79
+ getPeers: () => Peer[];
80
+ resources: CatalogResource[];
81
+ catalog: CatalogIndex;
82
+ /** Who agents act as in the browser: the user's agent */
83
+ agent: Author;
84
+ select: (nodeIds: string[]) => void;
85
+ fitView: (nodeIds?: string[]) => void;
86
+ /** Lays the canvas out with the visualiser's layout */
87
+ reorder: () => Promise<void>;
88
+ /**
89
+ * Whether to install the WebMCP polyfill when the browser has no WebMCP of its own. It's opt in: it watches
90
+ * every change to the page (for declarative form tools), and a canvas changes on every drag and pan.
91
+ */
92
+ allowPolyfill: boolean;
93
+ };
94
+
95
+ export function useCanvasWebMcp(options: CanvasWebMcpOptions) {
96
+ const [status, setStatus] = useState<WebMcpStatus>({ state: 'off' });
97
+ const agentPointer = useRef<AgentPresence['pointer']>(null);
98
+ const presenceTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
99
+ /** How the agent works on the canvas: played out like a person (see agent-choreography), shown in this tab */
100
+ const stage = (): AgentStage | undefined => {
101
+ const { doc } = latest.current;
102
+ if (!doc) return undefined;
103
+ return {
104
+ // The agent's changes aren't the user's: their undo leaves them alone, as it does other people's and MCP agents'
105
+ change: (fn) => doc.transact(() => fn(doc), BROWSER_AGENT_ORIGIN),
106
+ present: (presence) => {
107
+ const { agent, presence: store } = latest.current;
108
+ agentPointer.current = presence.pointer;
109
+ store?.setLocalAgent({ clientId: -1, name: agent.name, color: agent.color, agent: true, ...presence });
110
+ clearTimeout(presenceTimer.current);
111
+ presenceTimer.current = setTimeout(() => latest.current.presence?.setLocalAgent(null), AGENT_PRESENCE_TTL_MS);
112
+ },
113
+ lastPointer: agentPointer.current,
114
+ };
115
+ };
116
+ const stageRef = useRef(stage);
117
+ stageRef.current = stage;
118
+ // Tools are registered once per canvas and read the latest state when they run
119
+ const latest = useRef(options);
120
+ latest.current = options;
121
+
122
+ useEffect(() => {
123
+ if (!options.doc) return;
124
+ if (!getModelContext()) {
125
+ if (!options.allowPolyfill) return;
126
+ initializeWebMCPPolyfill();
127
+ usingPolyfill = true;
128
+ }
129
+ const modelContext = getModelContext();
130
+ if (!modelContext) return;
131
+
132
+ const controller = new AbortController();
133
+ const tools = createCanvasTools(
134
+ () => latest.current,
135
+ () => stageRef.current()
136
+ );
137
+ const onCall = (name: string) => setStatus((current) => (current.state === 'on' ? { ...current, lastCall: name } : current));
138
+
139
+ void Promise.allSettled(
140
+ tools.map((tool) =>
141
+ modelContext.registerTool(
142
+ {
143
+ name: tool.name,
144
+ title: tool.title,
145
+ description: tool.description,
146
+ inputSchema: z.toJSONSchema(tool.input, { io: 'input', unrepresentable: 'any' }),
147
+ annotations: tool.annotations,
148
+ execute: async (input) => {
149
+ const parsed = tool.input.safeParse(input ?? {});
150
+ if (!parsed.success) return result({ error: z.prettifyError(parsed.error) }, true);
151
+ onCall(tool.name);
152
+ try {
153
+ const value = await tool.execute(parsed.data);
154
+ return result(value, !!value && typeof value === 'object' && 'error' in value);
155
+ } catch (error) {
156
+ return result({ error: String((error as Error)?.message ?? error) }, true);
157
+ }
158
+ },
159
+ },
160
+ { signal: controller.signal }
161
+ )
162
+ )
163
+ ).then((outcomes) => {
164
+ if (controller.signal.aborted) return;
165
+ // A tool that's already registered (e.g. after a hot reload) rejects, but is still there
166
+ setStatus({ state: 'on', source: usingPolyfill ? 'polyfill' : 'native', tools: outcomes.length });
167
+ });
168
+
169
+ return () => {
170
+ controller.abort();
171
+ setStatus({ state: 'off' });
172
+ };
173
+ }, [options.doc, options.allowPolyfill]);
174
+
175
+ useEffect(() => () => clearTimeout(presenceTimer.current), []);
176
+
177
+ return status;
178
+ }
179
+
180
+ const NOT_CONNECTED = { error: 'The canvas is not connected yet' };
181
+
182
+ const createCanvasTools = (get: () => CanvasWebMcpOptions, getStage: () => AgentStage | undefined) => {
183
+ const withDoc = <T>(fn: (doc: Y.Doc) => T) => {
184
+ const { doc } = get();
185
+ return doc ? doc.transact(() => fn(doc), BROWSER_AGENT_ORIGIN) : NOT_CONNECTED;
186
+ };
187
+ /** Show the user what the agent changed */
188
+ const reveal = (nodeIds: string[]) => {
189
+ if (!nodeIds.length) return;
190
+ get().select(nodeIds);
191
+ get().fitView(nodeIds);
192
+ };
193
+
194
+ return [
195
+ defineTool({
196
+ name: 'get_canvas',
197
+ title: 'Read the canvas',
198
+ description:
199
+ 'Reads the collaborative canvas open on this page: its nodes (ids, names, positions, the catalog resource they stand for), connections, comment threads, and who is on it. Call it first, and again to see what people changed.',
200
+ input: z.object({}),
201
+ annotations: { readOnlyHint: true },
202
+ execute: () =>
203
+ withDoc((doc) => ({
204
+ ...describeCanvas(doc),
205
+ people: get()
206
+ .getPeers()
207
+ .map((peer) => `${peer.name}${peer.agent ? ' (agent)' : ''}`),
208
+ })),
209
+ }),
210
+ defineTool({
211
+ name: 'search_catalog',
212
+ title: 'Search the catalog',
213
+ description:
214
+ 'Finds EventCatalog resources (domains, systems, services, events, commands, queries, channels, data stores) to add to the canvas with add_to_canvas.',
215
+ input: z.object({
216
+ query: z.string().optional().describe('Matches names and ids'),
217
+ collection: z.enum(CATALOG_COLLECTIONS).optional(),
218
+ }),
219
+ annotations: { readOnlyHint: true },
220
+ execute: ({ query, collection }) => {
221
+ const term = query?.toLowerCase();
222
+ return get()
223
+ .resources.filter((resource) => !collection || resource.collection === collection)
224
+ .filter((resource) => !term || `${resource.name} ${resource.id}`.toLowerCase().includes(term))
225
+ .slice(0, 50)
226
+ .map(({ collection, id, name, version, summary }) => ({ collection, id, name, version, summary }));
227
+ },
228
+ }),
229
+ defineTool({
230
+ name: 'add_to_canvas',
231
+ title: 'Add to the canvas',
232
+ description: `${ADD_TO_CANVAS_DESCRIPTION} Use search_catalog to find catalog resources first.`,
233
+ input: z.object({ nodes: nodeSpecsSchema, edges: edgeSpecsSchema.optional() }),
234
+ execute: async (input) => {
235
+ const stage = getStage();
236
+ if (!stage) return NOT_CONNECTED;
237
+ const outcome = await playAddToCanvas(stage, input, get().catalog);
238
+ reveal(outcome.created.map((node) => node.nodeId));
239
+ return outcome;
240
+ },
241
+ }),
242
+ defineTool({
243
+ name: 'update_node',
244
+ title: 'Update a node',
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.',
247
+ input: z.object({
248
+ nodeId: z.string(),
249
+ name: z.string().optional(),
250
+ summary: z.string().optional(),
251
+ x: z.number().optional(),
252
+ y: z.number().optional(),
253
+ }),
254
+ execute: async (input) => {
255
+ const stage = getStage();
256
+ return stage ? playUpdateNode(stage, input) : NOT_CONNECTED;
257
+ },
258
+ }),
259
+ defineTool({
260
+ name: 'connect_nodes',
261
+ title: 'Connect nodes',
262
+ description: 'Draws connections between nodes (by id). Labels default to EventCatalog wording, e.g. "publishes event".',
263
+ input: z.object({ edges: edgeSpecsSchema.min(1) }),
264
+ execute: async ({ edges }) => {
265
+ const stage = getStage();
266
+ return stage ? playConnectAll(stage, edges) : NOT_CONNECTED;
267
+ },
268
+ }),
269
+ defineTool({
270
+ name: 'remove_from_canvas',
271
+ title: 'Remove from the canvas',
272
+ description: 'Removes nodes (and their connections) or connections. Only remove what the user asked for.',
273
+ input: z.object({ nodeIds: z.array(z.string()).optional(), connectionIds: z.array(z.string()).optional() }),
274
+ annotations: { consequentialHint: true },
275
+ execute: async ({ nodeIds = [], connectionIds = [] }) => {
276
+ const stage = getStage();
277
+ if (!stage) return NOT_CONNECTED;
278
+ await playRemove(stage, nodeIds, connectionIds);
279
+ return { removedNodes: nodeIds, removedConnections: connectionIds };
280
+ },
281
+ }),
282
+ defineTool({
283
+ name: 'layout_canvas',
284
+ title: 'Tidy the canvas',
285
+ description:
286
+ "Lays the whole canvas out again with EventCatalog's visualiser layout (left to right, connections routed around nodes), for everyone on it. It moves everything, including what people placed themselves, so only use it when the user asks to tidy up: add_to_canvas already places what you add next to what it connects to.",
287
+ input: z.object({}),
288
+ execute: async () => {
289
+ await get().reorder();
290
+ return { laidOut: true };
291
+ },
292
+ }),
293
+ defineTool({
294
+ name: 'add_comment',
295
+ 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.',
297
+ input: z.object({ text: z.string(), nodeId: z.string().optional(), x: z.number().optional(), y: z.number().optional() }),
298
+ execute: async (input) => {
299
+ const stage = getStage();
300
+ return stage ? playComment(stage, input, get().agent) : NOT_CONNECTED;
301
+ },
302
+ }),
303
+ defineTool({
304
+ name: 'reply_to_comment',
305
+ title: 'Reply to a comment',
306
+ description: 'Replies to a comment thread (threadId from get_canvas).',
307
+ input: z.object({ threadId: z.string(), text: z.string() }),
308
+ execute: ({ threadId, text }) =>
309
+ withDoc((doc) =>
310
+ replyToThread(doc, threadId, text, get().agent) ? { threadId } : { error: `No comment thread "${threadId}"` }
311
+ ),
312
+ }),
313
+ defineTool({
314
+ name: 'resolve_comment',
315
+ title: 'Resolve a comment',
316
+ description: 'Resolves (or reopens) a comment thread.',
317
+ input: z.object({ threadId: z.string(), resolved: z.boolean().default(true) }),
318
+ execute: ({ threadId, resolved }) =>
319
+ withDoc((doc) =>
320
+ setThreadResolved(doc, threadId, resolved) ? { threadId, resolved } : { error: `No comment thread "${threadId}"` }
321
+ ),
322
+ }),
323
+ defineTool({
324
+ name: 'show_nodes',
325
+ title: 'Show nodes to the user',
326
+ description:
327
+ 'Selects nodes and moves the view to them, to point the user at something. Leave out nodeIds to show the whole canvas.',
328
+ input: z.object({ nodeIds: z.array(z.string()).optional() }),
329
+ annotations: { readOnlyHint: true },
330
+ execute: ({ nodeIds }) => {
331
+ if (nodeIds?.length) get().select(nodeIds);
332
+ get().fitView(nodeIds);
333
+ return { shown: nodeIds ?? 'everything' };
334
+ },
335
+ }),
336
+ ];
337
+ };
@@ -0,0 +1,47 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import type * as Y from 'yjs';
3
+ import {
4
+ createThread as createThreadInDoc,
5
+ deleteThread as deleteThreadInDoc,
6
+ getCanvasMaps,
7
+ moveThread as moveThreadInDoc,
8
+ readThreads,
9
+ replyToThread,
10
+ setThreadResolved,
11
+ type Author,
12
+ type CommentAnchor,
13
+ type Thread,
14
+ } from '../canvas-doc';
15
+
16
+ export type { Author, CommentAnchor, Message, Thread } from '../canvas-doc';
17
+
18
+ /** Comment threads in the shared canvas document */
19
+ export function useComments(doc: Y.Doc | null, author: Author) {
20
+ const [threads, setThreads] = useState<Thread[]>([]);
21
+
22
+ useEffect(() => {
23
+ if (!doc) return setThreads([]);
24
+ const { threads: yThreads } = getCanvasMaps(doc);
25
+ const sync = () => setThreads(readThreads(doc));
26
+ sync();
27
+ yThreads.observeDeep(sync);
28
+ return () => yThreads.unobserveDeep(sync);
29
+ }, [doc]);
30
+
31
+ const createThread = useCallback(
32
+ (anchor: CommentAnchor, text: string) => (doc ? createThreadInDoc(doc, anchor, text, author) : undefined),
33
+ [doc, author]
34
+ );
35
+ const reply = useCallback((threadId: string, text: string) => doc && replyToThread(doc, threadId, text, author), [doc, author]);
36
+ const setResolved = useCallback(
37
+ (threadId: string, resolved: boolean) => doc && setThreadResolved(doc, threadId, resolved),
38
+ [doc]
39
+ );
40
+ const moveThread = useCallback(
41
+ (threadId: string, anchor: CommentAnchor) => doc && moveThreadInDoc(doc, threadId, anchor),
42
+ [doc]
43
+ );
44
+ const deleteThread = useCallback((threadId: string) => doc && deleteThreadInDoc(doc, threadId), [doc]);
45
+
46
+ return { threads, createThread, reply, setResolved, moveThread, deleteThread };
47
+ }
@@ -0,0 +1,99 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import { getViewportForBounds, useReactFlow, useStore, type Rect } from '@xyflow/react';
3
+ import type { Peer } from './presence-store';
4
+
5
+ /**
6
+ * The camera follows agents as they work (like following someone in Figma): it keeps their pointer and what's
7
+ * on the canvas in view, moving only when something they're doing goes out of view. Anyone panning, zooming or
8
+ * dragging pauses it for a moment, so it never fights them.
9
+ */
10
+
11
+ const FOLLOW_EVERY_MS = 500;
12
+ const MOVE_MS = 600;
13
+ const PAUSE_AFTER_USER_MS = 4000;
14
+ const POINTER_SIZE = 60;
15
+ const PADDING = 0.15;
16
+ const MAX_ZOOM = 1.1;
17
+
18
+ const union = (a: Rect, b: Rect): Rect => {
19
+ const x = Math.min(a.x, b.x);
20
+ const y = Math.min(a.y, b.y);
21
+ return { x, y, width: Math.max(a.x + a.width, b.x + b.width) - x, height: Math.max(a.y + a.height, b.y + b.height) - y };
22
+ };
23
+
24
+ /** Whether a rect is in view, inside a margin around the edges */
25
+ const inView = (rect: Rect, view: Rect, margin: number) =>
26
+ rect.x >= view.x + margin &&
27
+ rect.y >= view.y + margin &&
28
+ rect.x + rect.width <= view.x + view.width - margin &&
29
+ rect.y + rect.height <= view.y + view.height - margin;
30
+
31
+ export function useFollowCamera({
32
+ enabled,
33
+ agentsWorking,
34
+ getAgents,
35
+ }: {
36
+ enabled: boolean;
37
+ /** Whether any agent is working on the canvas (has a pointer on it) */
38
+ agentsWorking: boolean;
39
+ /** The agents working, with where their pointers are now (read when the camera checks, not on every move) */
40
+ getAgents: () => Peer[];
41
+ }) {
42
+ const { getNodes, getNodesBounds, getViewport, setViewport, fitView } = useReactFlow();
43
+ const hasNodes = useStore((state) => state.nodes.length > 0);
44
+ const width = useStore((state) => state.width);
45
+ const height = useStore((state) => state.height);
46
+ const pausedUntil = useRef(0);
47
+ const lastMove = useRef(0);
48
+ const latest = useRef({ enabled, getAgents });
49
+ latest.current = { enabled, getAgents };
50
+
51
+ /** People moving the camera themselves take it over for a moment */
52
+ const pause = useCallback(() => {
53
+ pausedUntil.current = Date.now() + PAUSE_AFTER_USER_MS;
54
+ }, []);
55
+
56
+ // A canvas that opens empty and fills in (it syncs after it opens) fits once its content arrives
57
+ const hasFitContent = useRef(false);
58
+ useEffect(() => {
59
+ if (hasFitContent.current || !hasNodes || !width) return;
60
+ hasFitContent.current = true;
61
+ const frame = requestAnimationFrame(() => void fitView({ padding: PADDING, maxZoom: MAX_ZOOM, duration: 300 }));
62
+ return () => cancelAnimationFrame(frame);
63
+ }, [hasNodes, width]);
64
+
65
+ const follow = useCallback(() => {
66
+ const { enabled, getAgents } = latest.current;
67
+ // Stopped (or a move is still going): leave the camera alone
68
+ if (!enabled || performance.now() - lastMove.current < MOVE_MS) return;
69
+ const agents = getAgents();
70
+ const pointers = agents.flatMap((agent) => (agent.pointer ? [agent.pointer] : []));
71
+ if (!pointers.length || !width || !height || Date.now() < pausedUntil.current) return;
72
+
73
+ const { x, y, zoom } = getViewport();
74
+ const view = { x: -x / zoom, y: -y / zoom, width: width / zoom, height: height / zoom };
75
+ const margin = 40 / zoom;
76
+ const pointerRects = pointers.map((pointer) => ({ x: pointer.x, y: pointer.y, width: POINTER_SIZE, height: POINTER_SIZE }));
77
+ const touched = agents.flatMap((agent) => agent.selection);
78
+ const touchedBounds = touched.length ? getNodesBounds(touched) : undefined;
79
+
80
+ // Only move when what the agent is doing is out of view
81
+ const needed = [...pointerRects, ...(touchedBounds ? [touchedBounds] : [])];
82
+ if (needed.every((rect) => inView(rect, view, margin))) return;
83
+
84
+ const nodes = getNodes();
85
+ const everything = [...(nodes.length ? [getNodesBounds(nodes)] : []), ...needed].reduce(union);
86
+ lastMove.current = performance.now();
87
+ void setViewport(getViewportForBounds(everything, width, height, 0.1, MAX_ZOOM, PADDING), { duration: MOVE_MS });
88
+ }, [width, height]);
89
+
90
+ // While agents work, check every so often whether what they're doing is still in view
91
+ useEffect(() => {
92
+ if (!enabled || !agentsWorking) return;
93
+ follow();
94
+ const timer = setInterval(follow, FOLLOW_EVERY_MS);
95
+ return () => clearInterval(timer);
96
+ }, [enabled, agentsWorking, follow]);
97
+
98
+ return { pause };
99
+ }