@eventcatalog/core 5.0.0-beta.5 → 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 (84) hide show
  1. package/dist/analytics/analytics.cjs +1 -1
  2. package/dist/analytics/analytics.js +2 -2
  3. package/dist/analytics/log-build.cjs +1 -1
  4. package/dist/analytics/log-build.js +4 -4
  5. package/dist/{chunk-43AEF3JE.js → chunk-56CD2NH6.js} +1 -1
  6. package/dist/{chunk-YYMBYOQR.js → chunk-DE3QHVJP.js} +1 -1
  7. package/dist/{chunk-XQXMSQA5.js → chunk-L2IRFOE7.js} +1 -1
  8. package/dist/{chunk-SHHO3JJI.js → chunk-QBP3BBSV.js} +1 -1
  9. package/dist/{chunk-EBDTVZDW.js → chunk-XMPFHLK4.js} +4 -4
  10. package/dist/constants.cjs +1 -1
  11. package/dist/constants.js +1 -1
  12. package/dist/eventcatalog.cjs +1 -1
  13. package/dist/eventcatalog.js +11 -11
  14. package/dist/federation/federate.js +4 -4
  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/eventcatalog/astro.config.mjs +2 -0
  29. package/eventcatalog/integrations/studio-server.mjs +17 -0
  30. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +20 -1
  31. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +39 -0
  32. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +22 -0
  33. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +269 -0
  34. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +209 -0
  35. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +2 -2
  36. package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
  37. package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
  38. package/eventcatalog/src/features/mcp/mcp-server.ts +17 -4
  39. package/eventcatalog/src/features/studio/agent-choreography.ts +378 -0
  40. package/eventcatalog/src/features/studio/canvas-actions.ts +287 -0
  41. package/eventcatalog/src/features/studio/canvas-doc.ts +280 -0
  42. package/eventcatalog/src/features/studio/catalog-resources.ts +152 -0
  43. package/eventcatalog/src/features/studio/catalog.ts +171 -0
  44. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +153 -0
  45. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +332 -0
  46. package/eventcatalog/src/features/studio/components/Comments.tsx +455 -0
  47. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +217 -0
  48. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +145 -0
  49. package/eventcatalog/src/features/studio/components/JoinForm.tsx +58 -0
  50. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +218 -0
  51. package/eventcatalog/src/features/studio/components/Presence.tsx +284 -0
  52. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +75 -0
  53. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +760 -0
  54. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +79 -0
  55. package/eventcatalog/src/features/studio/components/icons.ts +48 -0
  56. package/eventcatalog/src/features/studio/components/status.ts +22 -0
  57. package/eventcatalog/src/features/studio/edges.ts +54 -0
  58. package/eventcatalog/src/features/studio/flow-state.ts +92 -0
  59. package/eventcatalog/src/features/studio/grouping.ts +133 -0
  60. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +46 -0
  61. package/eventcatalog/src/features/studio/hooks/presence-store.ts +115 -0
  62. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +337 -0
  63. package/eventcatalog/src/features/studio/hooks/use-comments.ts +47 -0
  64. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +99 -0
  65. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +423 -0
  66. package/eventcatalog/src/features/studio/layout.ts +39 -0
  67. package/eventcatalog/src/features/studio/levels.ts +216 -0
  68. package/eventcatalog/src/features/studio/node-types.ts +173 -0
  69. package/eventcatalog/src/features/studio/pages/[id].astro +38 -0
  70. package/eventcatalog/src/features/studio/pages/index.astro +8 -0
  71. package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
  72. package/eventcatalog/src/features/studio/placement.ts +197 -0
  73. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +475 -0
  74. package/eventcatalog/src/features/studio/server/runtime.ts +215 -0
  75. package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
  76. package/eventcatalog/src/features/studio/studio.css +89 -0
  77. package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
  78. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +9 -2
  79. package/eventcatalog/src/utils/feature.ts +4 -0
  80. package/package.json +9 -2
  81. package/eventcatalog/public/studio-bg.png +0 -0
  82. package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
  83. package/eventcatalog/src/pages/studio.astro +0 -316
  84. package/dist/{chunk-ZEY2XUJN.js → chunk-AYNZXUB5.js} +7 -7
@@ -84,7 +84,8 @@ export function useMcpAppView<Payload>(options: McpAppViewOptions<Payload>) {
84
84
  setHostContext(context);
85
85
  }, [app]);
86
86
 
87
- return { app, payload, error: connectionError?.message ?? error, hostContext };
87
+ // setPayload: views that switch what they show themselves (e.g. picking a canvas) without a new tool result
88
+ return { app, payload, setPayload, error: connectionError?.message ?? error, hostContext };
88
89
  }
89
90
 
90
91
  /** Adds the view's styles and renders it */
@@ -12,7 +12,7 @@ const mcpAppViews = import.meta.glob<string>('./apps/generated/*.html', { query:
12
12
  export const getMcpAppViewLoader = (name: string) => mcpAppViews[`./apps/generated/${name}.html`];
13
13
 
14
14
  /** Tool `_meta` telling MCP hosts that support MCP Apps to show the tool's result in a view */
15
- const appViewMeta = (resourceUri: string, visibility?: Array<'model' | 'app'>) => ({
15
+ export const appViewMeta = (resourceUri: string, visibility?: Array<'model' | 'app'>) => ({
16
16
  ui: { resourceUri, ...(visibility && { visibility }) },
17
17
  [RESOURCE_URI_META_KEY]: resourceUri,
18
18
  });
@@ -38,6 +38,8 @@ type ToolDefinition<Shape extends z.ZodRawShape> = {
38
38
  description: string;
39
39
  /** The tool's arguments, as a Zod shape */
40
40
  input: Shape;
41
+ /** More `_meta` for the tool, e.g. host extensions such as ChatGPT's `openai/ui` entrypoints */
42
+ meta?: Record<string, unknown>;
41
43
  };
42
44
 
43
45
  export type McpAppDefinition<Shape extends z.ZodRawShape, ViewShape extends z.ZodRawShape, Payload> = {
@@ -47,7 +49,8 @@ export type McpAppDefinition<Shape extends z.ZodRawShape, ViewShape extends z.Zo
47
49
  name: string;
48
50
  uri: string;
49
51
  description: string;
50
- csp?: { resourceDomains: string[] };
52
+ /** Origins the view may load from (resourceDomains) and connect to with fetch or WebSockets (connectDomains) */
53
+ csp?: { resourceDomains?: string[]; connectDomains?: string[] };
51
54
  /** How hosts open the view: ChatGPT reads these before loading it, so it can open straight in that mode */
52
55
  displayModes?: { preferred: McpUiDisplayMode; available: McpUiDisplayMode[] };
53
56
  };
@@ -110,7 +113,7 @@ export function registerMcpApp<Shape extends z.ZodRawShape, ViewShape extends z.
110
113
  description: app.tool.description,
111
114
  inputSchema: toolInput,
112
115
  annotations: app.annotations,
113
- _meta: loadView ? appViewMeta(app.resource.uri) : undefined,
116
+ _meta: loadView ? { ...appViewMeta(app.resource.uri), ...app.tool.meta } : undefined,
114
117
  },
115
118
  showInView
116
119
  );
@@ -22,6 +22,9 @@ import { RESOURCE_VIEWS, VIEWER_META_KEY, VIEWER_RESOURCE_URI, VIEWER_VIEW_TOOL,
22
22
  import { toSchemaViewerSchemas } from './schema-viewer';
23
23
  import { getMcpAppViewLoader, mcpAppNote, registerMcpApp } from './mcp-apps';
24
24
  import { buildUrl } from '@utils/url-builder';
25
+ import { isCanvasEnabled } from '@utils/feature';
26
+ import { CANVAS_TOOL_NAMES, registerCanvasTools } from '@features/studio/server/canvas-mcp';
27
+ import { findStudioRuntime } from '@features/studio/server/runtime';
25
28
 
26
29
  const loadViewerView = getMcpAppViewLoader('viewer');
27
30
 
@@ -36,6 +39,8 @@ const SHOW_RESOURCE_DESCRIPTION = [
36
39
  type McpServerOptions = {
37
40
  /** Where this EventCatalog is served, for links from MCP App views back to it */
38
41
  catalogUrl: string;
42
+ /** The MCP client's user agent, to name agents that join a canvas */
43
+ userAgent?: string;
39
44
  };
40
45
 
41
46
  const catalogDirectory = process.env.PROJECT_DIR || process.cwd();
@@ -115,7 +120,7 @@ function getServerInstructions(scope?: McpScope) {
115
120
  }
116
121
 
117
122
  // Create MCP Server with tools that access Astro collections
118
- function createMcpServer(scope: McpScope | undefined, { catalogUrl }: McpServerOptions) {
123
+ function createMcpServer(scope: McpScope | undefined, { catalogUrl, userAgent }: McpServerOptions) {
119
124
  const server = new McpServer(
120
125
  {
121
126
  name: scope ? `EventCatalog MCP Server — ${scope.name} ${scope.ref.kind}` : 'EventCatalog MCP Server',
@@ -831,6 +836,9 @@ function createMcpServer(scope: McpScope | undefined, { catalogUrl }: McpServerO
831
836
  );
832
837
  }
833
838
 
839
+ // Collaborative canvases (when the collaboration server runs in this process)
840
+ if (!scope && isCanvasEnabled()) registerCanvasTools(server, { catalogUrl, userAgent });
841
+
834
842
  return server;
835
843
  }
836
844
 
@@ -964,7 +972,9 @@ const handleGetRequest = async (c: Context, kind?: McpScopeKind) => {
964
972
  version: MCP_SERVER_VERSION,
965
973
  status: 'running',
966
974
  ...(scope && { scope: scope.ref }),
967
- tools: scope ? getScopedBuiltInTools(scope) : [...globalBuiltInTools, ...extendedToolNames],
975
+ tools: scope
976
+ ? getScopedBuiltInTools(scope)
977
+ : [...globalBuiltInTools, ...(isCanvasEnabled() && findStudioRuntime() ? CANVAS_TOOL_NAMES : []), ...extendedToolNames],
968
978
  extendedTools: !scope && extendedToolNames.length > 0 ? extendedToolNames : undefined,
969
979
  resources: getMcpResourceUris(scope),
970
980
  });
@@ -983,7 +993,10 @@ const handleMcpRequest = async (c: Context, kind?: McpScopeKind) => {
983
993
  }
984
994
 
985
995
  const scope = await resolveRequestScope(c, kind);
986
- const server = createMcpServer(scope, { catalogUrl: new URL(c.req.url).origin });
996
+ const server = createMcpServer(scope, {
997
+ catalogUrl: new URL(c.req.url).origin,
998
+ userAgent: c.req.header('user-agent'),
999
+ });
987
1000
  const transport = new WebStandardStreamableHTTPServerTransport({
988
1001
  sessionIdGenerator: undefined,
989
1002
  });
@@ -1023,7 +1036,7 @@ app.post('/', (c: Context) => handleMcpRequest(c));
1023
1036
 
1024
1037
  // Astro API route handler - delegates all requests to Hono
1025
1038
  // Note: SSR checks are handled at build time by the integration
1026
- // This route is only injected when isEventCatalogMCPEnabled() returns true
1039
+ // This route is only injected when isEventCatalogMCPEnabled() or isCanvasEnabled() returns true
1027
1040
  export const ALL: APIRoute = async ({ request }) => {
1028
1041
  return app.fetch(request);
1029
1042
  };
@@ -0,0 +1,378 @@
1
+ import type { Edge, Node, XYPosition } from '@xyflow/react';
2
+ import type * as Y from 'yjs';
3
+ import {
4
+ addNodes,
5
+ connectNodes,
6
+ createThread,
7
+ deleteEdges,
8
+ deleteNodes,
9
+ fitGroupToChildren,
10
+ getCanvasMaps,
11
+ moveNode,
12
+ resizeNode,
13
+ setMeta,
14
+ type Author,
15
+ type CommentAnchor,
16
+ } from './canvas-doc';
17
+ import {
18
+ describeNode,
19
+ nodeCenter,
20
+ planNodes,
21
+ resolveEdgeRefs,
22
+ topLeftFor,
23
+ updateCanvasNode,
24
+ type CatalogIndex,
25
+ type EdgeSpecs,
26
+ type NodeSpecs,
27
+ } from './canvas-actions';
28
+ import { getRelatedEdges } from './catalog';
29
+ import { getAbsolutePosition, sizeOf } from './grouping';
30
+ import type { LayoutResult } from './layout';
31
+ import { getNodeName } from './node-types';
32
+
33
+ /**
34
+ * Agents work on a canvas like a person would: their cursor travels to where they're going, they drop one node,
35
+ * drag a connection from it to the next, and so on, rather than everything appearing at once. Used by the MCP
36
+ * tools (on the server) and the WebMCP tools (in the browser), with a stage that makes the changes and shows
37
+ * the agent's presence there.
38
+ */
39
+
40
+ /** What everyone sees of an agent at work */
41
+ export type AgentPresence = {
42
+ pointer: XYPosition | null;
43
+ selection: string[];
44
+ activity?: string;
45
+ /** Where a connection being dragged starts: drawn from there to the pointer */
46
+ connecting?: XYPosition | null;
47
+ /** Nodes being moved, by id, and where they are (relative to their containers), until they're dropped */
48
+ moving?: Record<string, XYPosition> | null;
49
+ };
50
+
51
+ export type AgentStage = {
52
+ /** Change the canvas (and read it) */
53
+ change: <T>(fn: (doc: Y.Doc) => T) => Promise<T> | T;
54
+ /** Show the agent on the canvas */
55
+ present: (presence: AgentPresence) => void;
56
+ /** Where the agent's pointer was left last time, if it's been here */
57
+ lastPointer?: XYPosition | null;
58
+ /**
59
+ * Everyone sees the agent's presence, so nodes it moves are shown moving with it and only saved where they
60
+ * end up, like a person's drag. Otherwise each step of a move is saved, so others see it move.
61
+ */
62
+ sharesMoves?: boolean;
63
+ };
64
+
65
+ const FRAME_MS = 40;
66
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
67
+ const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
68
+ const distance = (a: XYPosition, b: XYPosition) => Math.hypot(a.x - b.x, a.y - b.y);
69
+
70
+ type Lookup = Map<string, Node>;
71
+ /** Where a node is on the canvas (nodes in containers are positioned relative to them) */
72
+ const positionOf = (node: Node, nodes: Lookup) => getAbsolutePosition(node, nodes);
73
+ /** Where connections leave a node (its right side) and arrive (its left side) */
74
+ const sourceHandle = (node: Node, nodes: Lookup) => {
75
+ const position = positionOf(node, nodes);
76
+ return { x: position.x + sizeOf(node).width, y: position.y + sizeOf(node).height / 2 };
77
+ };
78
+ const targetHandle = (node: Node, nodes: Lookup) => {
79
+ const position = positionOf(node, nodes);
80
+ return { x: position.x, y: position.y + sizeOf(node).height / 2 };
81
+ };
82
+ const nameOf = (node: Node) => getNodeName(node.type, node.data);
83
+
84
+ /**
85
+ * The agent's hand: moves its pointer smoothly (in frames, so it glides for everyone) and shows what it's doing.
86
+ * `pace` slows down or speeds up everything (big changes go faster, so a tool call doesn't take too long).
87
+ */
88
+ export function createAgentHand(stage: AgentStage, { pace = 1 }: { pace?: number } = {}) {
89
+ const presence: AgentPresence = { pointer: stage.lastPointer ?? null, selection: [] };
90
+ const show = () => stage.present({ ...presence });
91
+
92
+ const hand = {
93
+ get pointer() {
94
+ return presence.pointer;
95
+ },
96
+ async pause(ms: number) {
97
+ await sleep(ms * pace);
98
+ },
99
+ say(activity: string) {
100
+ presence.activity = activity;
101
+ show();
102
+ },
103
+ select(ids: string[]) {
104
+ presence.selection = ids;
105
+ show();
106
+ },
107
+ /**
108
+ * Glide the pointer to a point, e.g. dragging a connection from `connecting`, or carrying a node held at
109
+ * `grab` from its position
110
+ */
111
+ async moveTo(
112
+ target: XYPosition,
113
+ { connecting, carry }: { connecting?: XYPosition; carry?: { nodeId: string; grab: XYPosition } } = {}
114
+ ) {
115
+ // The first time, come in from a little way off rather than appearing on the spot
116
+ const from = presence.pointer ?? { x: target.x - 160, y: target.y - 120 };
117
+ const duration = Math.min(900, Math.max(250, 200 + distance(from, target) * 0.6)) * pace;
118
+ const frames = Math.max(1, Math.round(duration / FRAME_MS));
119
+ presence.connecting = connecting ?? null;
120
+ for (let frame = 1; frame <= frames; frame++) {
121
+ const t = easeInOut(frame / frames);
122
+ presence.pointer = { x: from.x + (target.x - from.x) * t, y: from.y + (target.y - from.y) * t };
123
+ if (carry)
124
+ presence.moving = {
125
+ [carry.nodeId]: { x: presence.pointer.x - carry.grab.x, y: presence.pointer.y - carry.grab.y },
126
+ };
127
+ show();
128
+ await sleep(FRAME_MS);
129
+ }
130
+ },
131
+ /** Let go of a connection being dragged, or nodes being carried (once where they're dropped is saved) */
132
+ release() {
133
+ presence.connecting = null;
134
+ presence.moving = null;
135
+ show();
136
+ },
137
+ };
138
+ return hand;
139
+ }
140
+
141
+ /** Faster for bigger changes, so a tool call stays well within client timeouts */
142
+ const paceFor = (steps: number) => Math.max(0.25, Math.min(1, 10 / Math.max(steps, 1)));
143
+
144
+ const readNodes = (doc: Y.Doc) => new Map<string, Node>(getCanvasMaps(doc).nodes.entries());
145
+
146
+ /** Drag a connection from one node to another, then make it */
147
+ async function playConnection(
148
+ stage: AgentStage,
149
+ hand: ReturnType<typeof createAgentHand>,
150
+ connect: (doc: Y.Doc) => Edge | { error: string },
151
+ sourceId: string,
152
+ targetId: string
153
+ ) {
154
+ const nodes = await stage.change(readNodes);
155
+ const source = nodes.get(sourceId);
156
+ const target = nodes.get(targetId);
157
+ if (!source || !target) return stage.change(connect);
158
+ hand.say(`Connecting ${nameOf(source)} to ${nameOf(target)}`);
159
+ const start = sourceHandle(source, nodes);
160
+ await hand.moveTo(start);
161
+ await hand.pause(120);
162
+ await hand.moveTo(targetHandle(target, nodes), { connecting: start });
163
+ const outcome = await stage.change(connect);
164
+ hand.release();
165
+ await hand.pause(180);
166
+ return outcome;
167
+ }
168
+
169
+ /** addToCanvas, played out: each node dropped in turn, connected (to related resources and as asked) as soon as it can be */
170
+ export async function playAddToCanvas(
171
+ stage: AgentStage,
172
+ { nodes: specs, edges = [] }: { nodes: NodeSpecs; edges?: EdgeSpecs },
173
+ catalog: CatalogIndex
174
+ ) {
175
+ const existing = await stage.change((doc) => [...readNodes(doc).values()]);
176
+ const { planned, errors } = planNodes(existing, specs, catalog, edges);
177
+ const pending = resolveEdgeRefs(edges, planned);
178
+ const hand = createAgentHand(stage, { pace: paceFor(planned.length * 2 + pending.length) });
179
+ const connections: string[] = [];
180
+
181
+ for (const { node: plannedNode, canvasPosition, name, resource } of planned) {
182
+ hand.say(`Adding ${name}`);
183
+ const { width, height } = sizeOf(plannedNode);
184
+ await hand.moveTo({ x: canvasPosition.x + width / 2, y: canvasPosition.y + height / 2 });
185
+ await hand.pause(150);
186
+ const { node, related } = await stage.change((doc) => {
187
+ const onCanvas = readNodes(doc);
188
+ // Where it goes on the canvas, relative to its container as it is now (containers move as they grow)
189
+ const parent = plannedNode.parentId ? onCanvas.get(plannedNode.parentId) : undefined;
190
+ const origin = parent ? getAbsolutePosition(parent, onCanvas) : { x: 0, y: 0 };
191
+ const node = { ...plannedNode, position: { x: canvasPosition.x - origin.x, y: canvasPosition.y - origin.y } };
192
+ addNodes(doc, [node]);
193
+ // The containers it's in grow to fit it
194
+ for (let id = node.parentId; id; id = readNodes(doc).get(id)?.parentId) fitGroupToChildren(doc, id);
195
+ const related = resource
196
+ ? getRelatedEdges(resource, node.id, [...onCanvas.values()], catalog.relationsByKey, node.type)
197
+ : [];
198
+ return { node, related };
199
+ });
200
+ hand.select([node.id]);
201
+ await hand.pause(300);
202
+
203
+ // Its connections to what's already there: related catalog resources, then the ones asked for
204
+ for (const edge of related) {
205
+ await playConnection(stage, hand, (doc) => (addNodes(doc, [], [edge]), edge), edge.source, edge.target);
206
+ connections.push(edge.id);
207
+ }
208
+ const onCanvas = await stage.change((doc) => new Set(readNodes(doc).keys()));
209
+ for (const edge of pending.filter((edge) => onCanvas.has(edge.from) && onCanvas.has(edge.to))) {
210
+ pending.splice(pending.indexOf(edge), 1);
211
+ const outcome = await playConnection(
212
+ stage,
213
+ hand,
214
+ (doc) => connectNodes(doc, { source: edge.from, target: edge.to }, edge.label),
215
+ edge.from,
216
+ edge.to
217
+ );
218
+ if ('error' in outcome) errors.push(outcome.error);
219
+ else connections.push(outcome.id);
220
+ }
221
+ }
222
+
223
+ // Connections to nodes that aren't on the canvas
224
+ for (const edge of pending) {
225
+ const outcome = await stage.change((doc) => connectNodes(doc, { source: edge.from, target: edge.to }, edge.label));
226
+ if ('error' in outcome) errors.push(outcome.error);
227
+ }
228
+
229
+ hand.say('Done');
230
+ return { created: planned.map(({ ref, node, name }) => ({ ref, nodeId: node.id, name })), connections, errors };
231
+ }
232
+
233
+ /** connectCanvasNodes, played out: each connection dragged from one node to the other */
234
+ export async function playConnectAll(stage: AgentStage, edges: EdgeSpecs) {
235
+ const hand = createAgentHand(stage, { pace: paceFor(edges.length) });
236
+ const connections: string[] = [];
237
+ const errors: string[] = [];
238
+ for (const edge of edges) {
239
+ const outcome = await playConnection(
240
+ stage,
241
+ hand,
242
+ (doc) => connectNodes(doc, { source: edge.from, target: edge.to }, edge.label),
243
+ edge.from,
244
+ edge.to
245
+ );
246
+ if ('error' in outcome) errors.push(outcome.error);
247
+ else connections.push(outcome.id);
248
+ }
249
+ return { connections, errors };
250
+ }
251
+
252
+ /** Moving a node, played out: grab it and drag it there */
253
+ export async function playMoveNode(stage: AgentStage, nodeId: string, to: XYPosition, hand = createAgentHand(stage)) {
254
+ const nodes = await stage.change(readNodes);
255
+ const node = nodes.get(nodeId);
256
+ if (!node) return false;
257
+ // Positions are relative to the node's container, the pointer is on the canvas
258
+ const parent = node.parentId ? nodes.get(node.parentId) : undefined;
259
+ const origin = parent ? positionOf(parent, nodes) : { x: 0, y: 0 };
260
+ const grab = { x: origin.x + sizeOf(node).width / 2, y: origin.y + 24 };
261
+ hand.say(`Moving ${nameOf(node)}`);
262
+ await hand.moveTo({ x: node.position.x + grab.x, y: node.position.y + grab.y });
263
+ hand.select([nodeId]);
264
+ await hand.pause(120);
265
+ if (stage.sharesMoves) {
266
+ await hand.moveTo({ x: to.x + grab.x, y: to.y + grab.y }, { carry: { nodeId, grab } });
267
+ await stage.change((doc) => moveNode(doc, nodeId, to));
268
+ hand.release();
269
+ return true;
270
+ }
271
+ const from = node.position;
272
+ const frames = 12;
273
+ for (let frame = 1; frame <= frames; frame++) {
274
+ const t = easeInOut(frame / frames);
275
+ const position = { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t };
276
+ await stage.change((doc) => moveNode(doc, nodeId, position));
277
+ await hand.moveTo({ x: position.x + grab.x, y: position.y + grab.y });
278
+ }
279
+ return true;
280
+ }
281
+
282
+ /**
283
+ * Laying the canvas out: everything moves in one change, and everyone's canvas glides the nodes into place
284
+ * (the layout time in the canvas's meta tells them to)
285
+ */
286
+ export async function playLayout(stage: AgentStage, layout: LayoutResult) {
287
+ const hand = createAgentHand(stage);
288
+ hand.say('Tidying the layout');
289
+ await stage.change((doc) => applyLayout(doc, layout));
290
+ await hand.pause(500);
291
+ hand.say('Done');
292
+ }
293
+
294
+ /** Moves nodes and resizes containers from a layout, in one change, and marks when it happened */
295
+ export const applyLayout = (doc: Y.Doc, layout: LayoutResult) =>
296
+ doc.transact(() => {
297
+ layout.forEach(({ position, size }, id) => {
298
+ moveNode(doc, id, position);
299
+ if (size) resizeNode(doc, id, size);
300
+ });
301
+ setMeta(doc, { layoutAt: Date.now() });
302
+ });
303
+
304
+ /** Removing things, played out: point at each, then remove it */
305
+ export async function playRemove(stage: AgentStage, nodeIds: string[], connectionIds: string[]) {
306
+ const hand = createAgentHand(stage, { pace: paceFor(nodeIds.length) });
307
+ if (connectionIds.length) await stage.change((doc) => deleteEdges(doc, connectionIds));
308
+ for (const id of nodeIds) {
309
+ const nodes = await stage.change(readNodes);
310
+ const node = nodes.get(id);
311
+ if (!node) continue;
312
+ hand.say(`Removing ${nameOf(node)}`);
313
+ await hand.moveTo(nodeCenter(node, nodes));
314
+ hand.select([id]);
315
+ await hand.pause(250);
316
+ await stage.change((doc) => deleteNodes(doc, [id]));
317
+ hand.select([]);
318
+ }
319
+ }
320
+
321
+ /** Commenting, played out: go to where the comment goes, then leave it */
322
+ export async function playComment(
323
+ stage: AgentStage,
324
+ { text, nodeId, x, y }: { text: string; nodeId?: string; x?: number; y?: number },
325
+ author: Author
326
+ ) {
327
+ const nodes = await stage.change(readNodes);
328
+ const node = nodeId ? nodes.get(nodeId) : undefined;
329
+ if (nodeId && !node) return { error: `No node "${nodeId}" on the canvas` };
330
+ const offset = node ? { x: sizeOf(node).width - 8, y: 8 } : undefined;
331
+ const position = node && positionOf(node, nodes);
332
+ const anchor: CommentAnchor =
333
+ node && offset && position
334
+ ? { position: { x: position.x + offset.x, y: position.y + offset.y }, nodeId: node.id, offset }
335
+ : { position: { x: x ?? 0, y: y ?? 0 } };
336
+ const hand = createAgentHand(stage);
337
+ hand.say('Commenting');
338
+ await hand.moveTo(anchor.position);
339
+ await hand.pause(400);
340
+ const threadId = await stage.change((doc) => createThread(doc, anchor, text, author));
341
+ return { threadId };
342
+ }
343
+
344
+ /** Pointing at a node while changing it (e.g. a rename) */
345
+ export async function playOnNode<T>(stage: AgentStage, nodeId: string, activity: string, change: (doc: Y.Doc) => T) {
346
+ const nodes = await stage.change(readNodes);
347
+ const node = nodes.get(nodeId);
348
+ const hand = createAgentHand(stage);
349
+ if (node) {
350
+ hand.say(activity);
351
+ await hand.moveTo(nodeCenter(node, nodes));
352
+ hand.select([nodeId]);
353
+ await hand.pause(250);
354
+ }
355
+ return stage.change(change);
356
+ }
357
+
358
+ /** Updating a node, played out: point at it to rename or re-describe it, then drag it to its new centre (x/y) */
359
+ export async function playUpdateNode(
360
+ stage: AgentStage,
361
+ { nodeId, name, summary, x, y }: { nodeId: string; name?: string; summary?: string; x?: number; y?: number }
362
+ ) {
363
+ const missing = { error: `No node "${nodeId}" on the canvas` };
364
+ if (name !== undefined || summary !== undefined) {
365
+ const outcome = await playOnNode(stage, nodeId, 'Editing', (doc) => updateCanvasNode(doc, { nodeId, name, summary }));
366
+ if ('error' in outcome) return outcome;
367
+ }
368
+ if (x !== undefined || y !== undefined) {
369
+ const nodes = await stage.change(readNodes);
370
+ const node = nodes.get(nodeId);
371
+ if (!node) return missing;
372
+ const center = nodeCenter(node, nodes);
373
+ await playMoveNode(stage, nodeId, topLeftFor(node, { x: x ?? center.x, y: y ?? center.y }, nodes));
374
+ }
375
+ const nodes = await stage.change(readNodes);
376
+ const node = nodes.get(nodeId);
377
+ return node ? describeNode(node, nodes) : missing;
378
+ }