@eventcatalog/core 5.0.0-beta.3 → 5.0.0-beta.30

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 (277) hide show
  1. package/LICENSE +234 -134
  2. package/README.md +5 -5
  3. package/dist/analytics/analytics.cjs +18 -3
  4. package/dist/analytics/analytics.d.cts +1 -1
  5. package/dist/analytics/analytics.d.ts +1 -1
  6. package/dist/analytics/analytics.js +3 -2
  7. package/dist/analytics/log-build.cjs +160 -77
  8. package/dist/analytics/log-build.d.cts +2 -2
  9. package/dist/analytics/log-build.d.ts +2 -2
  10. package/dist/analytics/log-build.js +7 -5
  11. package/dist/analytics/studio-usage.cjs +90 -0
  12. package/dist/analytics/studio-usage.d.cts +5 -0
  13. package/dist/analytics/studio-usage.d.ts +5 -0
  14. package/dist/analytics/studio-usage.js +10 -0
  15. package/dist/analytics/telemetry.cjs +42 -0
  16. package/dist/analytics/telemetry.d.cts +20 -0
  17. package/dist/analytics/telemetry.d.ts +20 -0
  18. package/dist/analytics/telemetry.js +8 -0
  19. package/dist/catalog-runtime.cjs +1 -1
  20. package/dist/{chunk-RFIXKATC.js → chunk-4G7FSRPN.js} +35 -42
  21. package/dist/{chunk-6OC46WQJ.js → chunk-55QETQZ3.js} +66 -35
  22. package/dist/{chunk-TQLRG35T.js → chunk-AD7CL772.js} +78 -45
  23. package/dist/chunk-CA6WCQ2T.js +54 -0
  24. package/dist/chunk-CPW6G62V.js +17 -0
  25. package/dist/{chunk-423S2KHN.js → chunk-FLAZ2JVN.js} +4 -2
  26. package/dist/{chunk-BH2F5WPN.js → chunk-GQI5UYXZ.js} +1 -1
  27. package/dist/{chunk-GYIVJXOJ.js → chunk-NA7OTBWW.js} +1 -1
  28. package/dist/{chunk-MDWB5HLO.js → chunk-NKNOXQ62.js} +1 -1
  29. package/dist/{chunk-3PKS2IIP.js → chunk-OX6TSVHY.js} +1 -1
  30. package/dist/{chunk-36RYTYT5.js → chunk-PD7KTSYN.js} +1 -1
  31. package/dist/chunk-S5GWAUG5.js +29 -0
  32. package/dist/{chunk-NDJOAITI.js → chunk-T3OROMAU.js} +9 -3
  33. package/dist/constants.cjs +1 -1
  34. package/dist/constants.js +1 -1
  35. package/dist/eventcatalog-config-file-utils.cjs +34 -41
  36. package/dist/eventcatalog-config-file-utils.d.cts +56 -58
  37. package/dist/eventcatalog-config-file-utils.d.ts +56 -58
  38. package/dist/eventcatalog-config-file-utils.js +1 -1
  39. package/dist/eventcatalog.cjs +531 -356
  40. package/dist/eventcatalog.config.d.cts +59 -1
  41. package/dist/eventcatalog.config.d.ts +59 -1
  42. package/dist/eventcatalog.js +63 -25
  43. package/dist/features.cjs +34 -41
  44. package/dist/features.js +2 -2
  45. package/dist/federation/federate.cjs +37 -44
  46. package/dist/federation/federate.js +2 -2
  47. package/dist/generate.cjs +37 -42
  48. package/dist/generate.js +4 -4
  49. package/dist/resolve-catalog-dependencies.cjs +34 -41
  50. package/dist/resolve-catalog-dependencies.js +2 -2
  51. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
  52. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
  53. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
  54. package/dist/runtime-dependencies/@hocuspocus__provider.mjs +3 -0
  55. package/dist/runtime-dependencies/@hocuspocus__server.mjs +3 -0
  56. package/dist/runtime-dependencies/@mcp-b__webmcp-local-relay.mjs +3 -0
  57. package/dist/runtime-dependencies/@mcp-b__webmcp-polyfill.mjs +3 -0
  58. package/dist/runtime-dependencies/htmlparser2.mjs +3 -0
  59. package/dist/runtime-dependencies/manifest.json +13 -1
  60. package/dist/runtime-dependencies/react-hotkeys-hook.mjs +3 -0
  61. package/dist/runtime-dependencies/react-syntax-highlighter__dist__cjs__create-element.js.mjs +3 -0
  62. package/dist/runtime-dependencies/y-protocols.mjs +3 -0
  63. package/dist/runtime-dependencies/y-protocols__awareness.mjs +3 -0
  64. package/dist/runtime-dependencies/yjs.mjs +3 -0
  65. package/dist/utils/child-process-signals.cjs +53 -0
  66. package/dist/utils/child-process-signals.d.cts +19 -0
  67. package/dist/utils/child-process-signals.d.ts +19 -0
  68. package/dist/utils/child-process-signals.js +6 -0
  69. package/dist/utils/cli-logger.cjs +1 -1
  70. package/dist/utils/cli-logger.js +2 -2
  71. package/dist/utils/license-status.cjs +78 -45
  72. package/dist/utils/license-status.d.cts +15 -5
  73. package/dist/utils/license-status.d.ts +15 -5
  74. package/dist/utils/license-status.js +1 -1
  75. package/eventcatalog/astro.config.mjs +23 -1
  76. package/eventcatalog/integrations/config-loader.mjs +56 -58
  77. package/eventcatalog/integrations/skip-startup-reload.mjs +31 -0
  78. package/eventcatalog/integrations/stable-dependency-order.mjs +29 -0
  79. package/eventcatalog/integrations/storage.mjs +20 -0
  80. package/eventcatalog/integrations/studio-server.mjs +32 -0
  81. package/eventcatalog/src/components/CatalogGraph/CatalogForceGraph.tsx +39 -0
  82. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
  83. package/eventcatalog/src/components/Header.astro +3 -3
  84. package/eventcatalog/src/components/Homepage/CatalogDashboard.astro +342 -0
  85. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +60 -4
  86. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
  87. package/eventcatalog/src/components/NavigationProgress.astro +7 -0
  88. package/eventcatalog/src/components/SchemaExplorer/ApiContentViewer.tsx +2 -0
  89. package/eventcatalog/src/components/SchemaExplorer/ExamplesViewer.tsx +2 -0
  90. package/eventcatalog/src/components/SchemaExplorer/SchemaCodeModal.tsx +2 -0
  91. package/eventcatalog/src/components/SchemaExplorer/SchemaContentViewer.tsx +2 -0
  92. package/eventcatalog/src/components/Search/SearchDataLoader.astro +16 -28
  93. package/eventcatalog/src/components/Search/SearchModal.tsx +2 -10
  94. package/eventcatalog/src/components/Search/search-utils.ts +26 -0
  95. package/eventcatalog/src/components/Seo.astro +5 -1
  96. package/eventcatalog/src/components/Settings/AssistantSettingsForm.tsx +53 -48
  97. package/eventcatalog/src/components/Settings/GeneralSettingsForm.tsx +183 -178
  98. package/eventcatalog/src/components/Settings/LicenseSettings.tsx +140 -31
  99. package/eventcatalog/src/components/Settings/LlmAccessSettingsForm.tsx +40 -35
  100. package/eventcatalog/src/components/Settings/LogoUpload.tsx +1 -1
  101. package/eventcatalog/src/components/Settings/McpSettingsForm.tsx +9 -8
  102. package/eventcatalog/src/components/Settings/Row.tsx +7 -4
  103. package/eventcatalog/src/components/Settings/SettingsShared.tsx +3 -3
  104. package/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx +53 -83
  105. package/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts +2 -0
  106. package/eventcatalog/src/components/SideNav/NestedSideBar/utils.ts +131 -0
  107. package/eventcatalog/src/components/SideNav/SideNav.astro +9 -1
  108. package/eventcatalog/src/components/TrialBadge.astro +20 -9
  109. package/eventcatalog/src/content.config.ts +19 -2
  110. package/eventcatalog/src/features/auth/error.astro +4 -2
  111. package/eventcatalog/src/features/auth/login.astro +2 -1
  112. package/eventcatalog/src/features/auth/middleware/middleware-auth.ts +10 -0
  113. package/eventcatalog/src/features/auth/unauthorized.astro +1 -1
  114. package/eventcatalog/src/features/fields/pages/fields.astro +1 -1
  115. package/eventcatalog/src/features/integrations/eventcatalog-features.ts +34 -1
  116. package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
  117. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +47 -0
  118. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +24 -0
  119. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +278 -0
  120. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +272 -0
  121. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +65 -65
  122. package/eventcatalog/src/features/mcp/apps/shared/app-view.tsx +2 -1
  123. package/eventcatalog/src/features/mcp/mcp-apps.ts +6 -3
  124. package/eventcatalog/src/features/mcp/mcp-server.ts +36 -5
  125. package/eventcatalog/src/features/server/start.ts +25 -0
  126. package/eventcatalog/src/features/storage/database.ts +88 -0
  127. package/eventcatalog/src/features/storage/migrations.ts +85 -0
  128. package/eventcatalog/src/features/storage/sql/database.sql +5 -0
  129. package/eventcatalog/src/features/storage/sql/migrations/0001-studio-canvases.sql +12 -0
  130. package/eventcatalog/src/features/storage/sql/migrations.sql +15 -0
  131. package/eventcatalog/src/features/storage/sql.ts +28 -0
  132. package/eventcatalog/src/features/studio/agent-choreography.ts +384 -0
  133. package/eventcatalog/src/features/studio/agents.ts +28 -0
  134. package/eventcatalog/src/features/studio/api/canvases-api.ts +219 -0
  135. package/eventcatalog/src/features/studio/api/canvases.ts +25 -0
  136. package/eventcatalog/src/features/studio/api/client.ts +40 -0
  137. package/eventcatalog/src/features/studio/canvas-actions.ts +540 -0
  138. package/eventcatalog/src/features/studio/canvas-preview.ts +52 -0
  139. package/eventcatalog/src/features/studio/catalog-resources.ts +155 -0
  140. package/eventcatalog/src/features/studio/catalog.ts +173 -0
  141. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +518 -0
  142. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +118 -0
  143. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +434 -0
  144. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +143 -0
  145. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +65 -0
  146. package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
  147. package/eventcatalog/src/features/studio/components/JoinForm.tsx +109 -0
  148. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +206 -0
  149. package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
  150. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
  151. package/eventcatalog/src/features/studio/components/Presence.tsx +293 -0
  152. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +396 -0
  153. package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
  154. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +2091 -0
  155. package/eventcatalog/src/features/studio/components/StudioNotice.astro +24 -0
  156. package/eventcatalog/src/features/studio/document-name.ts +7 -0
  157. package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
  158. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +63 -0
  159. package/eventcatalog/src/features/studio/hooks/presence-store.ts +119 -0
  160. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +353 -0
  161. package/eventcatalog/src/features/studio/hooks/use-comments.ts +54 -0
  162. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +109 -0
  163. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +782 -0
  164. package/eventcatalog/src/features/studio/identity.ts +59 -0
  165. package/eventcatalog/src/features/studio/limits.ts +2 -0
  166. package/eventcatalog/src/features/studio/pages/[id].astro +69 -0
  167. package/eventcatalog/src/features/studio/pages/index.astro +784 -0
  168. package/eventcatalog/src/features/studio/pages/webmcp-relay.ts +27 -0
  169. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +495 -0
  170. package/eventcatalog/src/features/studio/server/runtime.ts +498 -0
  171. package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
  172. package/eventcatalog/src/features/studio/server/sql/storage.sql +18 -0
  173. package/eventcatalog/src/features/studio/server/storage.ts +90 -0
  174. package/eventcatalog/src/features/studio/server/tool-icons.ts +30 -0
  175. package/eventcatalog/src/features/studio/tool-sync.ts +75 -0
  176. package/eventcatalog/src/layouts/BaseLayout.astro +4 -4
  177. package/eventcatalog/src/layouts/SettingsLayout.astro +2 -2
  178. package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +13 -5
  179. package/eventcatalog/src/pages/_index.astro +5 -342
  180. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.spec.ts +2 -3
  181. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts +0 -7
  182. package/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro +3 -1
  183. package/eventcatalog/src/pages/diagrams/[id]/[version]/_index.data.ts +0 -7
  184. package/eventcatalog/src/pages/diagrams/[id]/[version]/embed.astro +1 -0
  185. package/eventcatalog/src/pages/diagrams/[id]/[version]/index.astro +1 -0
  186. package/eventcatalog/src/pages/directory/[type]/_index.data.ts +5 -7
  187. package/eventcatalog/src/pages/directory/[type]/index.astro +3 -1
  188. package/eventcatalog/src/pages/discover/[type]/_index.data.ts +5 -7
  189. package/eventcatalog/src/pages/discover/[type]/index.astro +3 -1
  190. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/[docVersion]/_index.data.ts +0 -7
  191. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/[docVersion]/index.astro +1 -0
  192. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/_index.data.ts +0 -7
  193. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/[docType]/[docId]/index.astro +1 -0
  194. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/_index.data.ts +0 -7
  195. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/asyncapi/[filename].astro +3 -1
  196. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/asyncapi/_[filename].data.ts +0 -7
  197. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/_index.data.ts +0 -7
  198. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/changelog/index.astro +1 -0
  199. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/examples/[...filename].astro +1 -0
  200. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/examples/_[...filename].data.ts +0 -4
  201. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/field-lineage/_index.data.ts +0 -7
  202. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/field-lineage/index.astro +1 -0
  203. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/[filename].astro +3 -1
  204. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/graphql/_[filename].data.ts +0 -7
  205. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/index.astro +1 -0
  206. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.spec.ts +2 -3
  207. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/_index.data.ts +0 -7
  208. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/resources/index.astro +1 -0
  209. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/spec/[filename].astro +1 -0
  210. package/eventcatalog/src/pages/docs/[type]/[id]/[version]/spec/_[filename].data.ts +0 -7
  211. package/eventcatalog/src/pages/docs/[type]/[id]/_index.data.ts +0 -7
  212. package/eventcatalog/src/pages/docs/[type]/[id]/index.astro +2 -1
  213. package/eventcatalog/src/pages/docs/[type]/[id]/language/[dictionaryId]/_index.data.ts +0 -7
  214. package/eventcatalog/src/pages/docs/[type]/[id]/language/[dictionaryId]/index.astro +1 -0
  215. package/eventcatalog/src/pages/docs/[type]/[id]/language/_index.data.ts +0 -7
  216. package/eventcatalog/src/pages/docs/[type]/[id]/language/index.astro +1 -0
  217. package/eventcatalog/src/pages/docs/custom/[...path]/_index.data.ts +0 -7
  218. package/eventcatalog/src/pages/docs/custom/[...path]/index.astro +1 -0
  219. package/eventcatalog/src/pages/docs/services/[id]/[docType]/[docId]/index.astro +1 -1
  220. package/eventcatalog/src/pages/docs/services/[id]/[version]/resources/index.astro +1 -0
  221. package/eventcatalog/src/pages/docs/services/[id]/asyncapi/[filename].astro +1 -1
  222. package/eventcatalog/src/pages/docs/services/[id]/changelog/index.astro +1 -1
  223. package/eventcatalog/src/pages/docs/services/[id]/graphql/[filename].astro +1 -1
  224. package/eventcatalog/src/pages/docs/services/[id]/spec/[filename].astro +1 -1
  225. package/eventcatalog/src/pages/docs/teams/[id]/_index.data.ts +0 -7
  226. package/eventcatalog/src/pages/docs/teams/[id]/index.astro +1 -0
  227. package/eventcatalog/src/pages/docs/users/[id]/_index.data.ts +0 -7
  228. package/eventcatalog/src/pages/docs/users/[id]/index.astro +1 -0
  229. package/eventcatalog/src/pages/index.astro +4 -2
  230. package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/_index.data.ts +0 -7
  231. package/eventcatalog/src/pages/schemas/[type]/[id]/[version]/index.astro +1 -0
  232. package/eventcatalog/src/pages/schemas/explorer/_index.data.ts +0 -7
  233. package/eventcatalog/src/pages/schemas/explorer/index.astro +4 -2
  234. package/eventcatalog/src/pages/settings/license.astro +9 -1
  235. package/eventcatalog/src/pages/triggers/[type]/[id]/[version]/_index.data.ts +0 -7
  236. package/eventcatalog/src/pages/triggers/[type]/[id]/[version]/index.astro +1 -0
  237. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/_index.data.ts +0 -7
  238. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/data/_index.data.ts +0 -7
  239. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/data/index.astro +1 -0
  240. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/entity-map/_index.data.ts +0 -7
  241. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/entity-map/index.astro +1 -0
  242. package/eventcatalog/src/pages/visualiser/[type]/[id]/[version]/index.astro +1 -0
  243. package/eventcatalog/src/pages/visualiser/[type]/[id]/_index.data.ts +38 -0
  244. package/eventcatalog/src/pages/visualiser/[type]/[id]/index.astro +18 -41
  245. package/eventcatalog/src/pages/visualiser/designs/[id]/_index.data.ts +0 -7
  246. package/eventcatalog/src/pages/visualiser/designs/[id]/index.astro +2 -1
  247. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/entity-map/_index.data.ts +0 -7
  248. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/entity-map/index.astro +1 -0
  249. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/index.astro +1 -0
  250. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/systems-context/_index.data.ts +0 -7
  251. package/eventcatalog/src/pages/visualiser/domains/[id]/[version]/systems-context/index.astro +1 -0
  252. package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/context/_index.data.ts +0 -7
  253. package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/context/index.astro +1 -0
  254. package/eventcatalog/src/pages/visualiser/systems/[id]/[version]/index.astro +1 -0
  255. package/eventcatalog/src/types/react-syntax-highlighter.d.ts +10 -0
  256. package/eventcatalog/src/utils/collections/util.ts +8 -4
  257. package/eventcatalog/src/utils/feature.ts +8 -0
  258. package/eventcatalog/src/utils/git.ts +22 -5
  259. package/eventcatalog/src/utils/homepage-dashboard.ts +143 -0
  260. package/eventcatalog/src/utils/license.ts +1 -1
  261. package/eventcatalog/src/utils/link-validation.ts +25 -19
  262. package/eventcatalog/src/utils/node-graphs/domain-entity-map.ts +3 -1
  263. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
  264. package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
  265. package/eventcatalog/src/utils/node-graphs/field-node-graph.ts +4 -2
  266. package/eventcatalog/src/utils/node-graphs/layout-node-graph.ts +20 -1
  267. package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
  268. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
  269. package/eventcatalog/src/utils/page-loaders/hybrid-page.ts +4 -15
  270. package/eventcatalog/src/utils/page-title.ts +15 -0
  271. package/eventcatalog/src/utils/syntax-highlighter-renderer.ts +64 -0
  272. package/eventcatalog/src/utils/trial.ts +14 -1
  273. package/package.json +19 -13
  274. package/dist/runtime-dependencies/shelljs.mjs +0 -3
  275. package/eventcatalog/public/studio-bg.png +0 -0
  276. package/eventcatalog/src/components/Studio/StudioPageModal.tsx +0 -150
  277. package/eventcatalog/src/pages/studio.astro +0 -316
@@ -0,0 +1,353 @@
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 {
6
+ replyToThread,
7
+ setThreadResolved,
8
+ type Author,
9
+ CANVAS_STATUSES,
10
+ setCanvasStatus,
11
+ CATALOG_COLLECTIONS,
12
+ } from '@eventcatalog/visualiser/studio';
13
+ import {
14
+ ADD_TO_CANVAS_DESCRIPTION,
15
+ describeCanvas,
16
+ edgeSpecsSchema,
17
+ nodeSpecsSchema,
18
+ type CatalogIndex,
19
+ } from '../canvas-actions';
20
+ import type { CatalogResource } from '../catalog-resources';
21
+ import {
22
+ playAddToCanvas,
23
+ playComment,
24
+ playConnectAll,
25
+ playRemove,
26
+ playUpdateNode,
27
+ type AgentPresence,
28
+ type AgentStage,
29
+ } from '../agent-choreography';
30
+ import type { Peer, PresenceStore } from './presence-store';
31
+ import { BROWSER_AGENT_ORIGIN } from './use-studio-flow';
32
+
33
+ // The browser agent shows on the canvas (in this tab) while it works, and fades out a while after
34
+ const AGENT_PRESENCE_TTL_MS = 30_000;
35
+
36
+ /**
37
+ * WebMCP: the canvas page offers its tools to AI agents in the browser (Chrome's built in agent,
38
+ * ChatGPT desktop, the MCP-B extension, or desktop clients through the local relay), so an agent works
39
+ * on the canvas the user has open, live for everyone on it. The same actions as the MCP tools.
40
+ * https://webmachinelearning.github.io/webmcp/
41
+ */
42
+
43
+ type ToolResult = { content: { type: 'text'; text: string }[]; isError?: boolean };
44
+ type ModelContextTool = {
45
+ name: string;
46
+ title?: string;
47
+ description: string;
48
+ inputSchema?: object;
49
+ annotations?: { readOnlyHint?: boolean; consequentialHint?: boolean };
50
+ execute: (input: unknown, options?: { signal?: AbortSignal }) => Promise<ToolResult>;
51
+ };
52
+ type ModelContext = { registerTool: (tool: ModelContextTool, options?: { signal?: AbortSignal }) => Promise<void> | void };
53
+
54
+ // The spec moved modelContext from navigator to document; older runtimes still have the navigator one
55
+ const getModelContext = () =>
56
+ ((document as unknown as { modelContext?: ModelContext }).modelContext ??
57
+ (navigator as unknown as { modelContext?: ModelContext }).modelContext) as ModelContext | undefined;
58
+
59
+ // Survives remounts, so a native WebMCP runtime is never shadowed by the polyfill
60
+ let usingPolyfill = false;
61
+
62
+ type CanvasTool<Input extends z.ZodType> = {
63
+ name: string;
64
+ title: string;
65
+ description: string;
66
+ input: Input;
67
+ annotations?: ModelContextTool['annotations'];
68
+ execute: (input: z.output<Input>) => unknown;
69
+ };
70
+
71
+ const defineTool = <Input extends z.ZodType>(tool: CanvasTool<Input>) => tool as unknown as CanvasTool<z.ZodType>;
72
+
73
+ const result = (value: unknown, isError = false): ToolResult => ({
74
+ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
75
+ ...(isError && { isError }),
76
+ });
77
+
78
+ export type WebMcpStatus = { state: 'off' } | { state: 'on'; source: 'native' | 'polyfill'; tools: number; lastCall?: string };
79
+
80
+ type CanvasWebMcpOptions = {
81
+ doc: Y.Doc | null;
82
+ /** Where the agent shows up while it works (only in this tab: it acts through this tab's connection) */
83
+ presence: PresenceStore | null;
84
+ /** Who's on the canvas, read when an agent asks */
85
+ getPeers: () => Peer[];
86
+ resources: CatalogResource[];
87
+ catalog: CatalogIndex;
88
+ /** Who agents act as in the browser: the user's agent */
89
+ agent: Author;
90
+ select: (nodeIds: string[]) => void;
91
+ fitView: (nodeIds?: string[]) => void;
92
+ /** Lays the canvas out with the visualiser's layout */
93
+ reorder: () => Promise<void>;
94
+ /**
95
+ * Whether to install the WebMCP polyfill when the browser has no WebMCP of its own. It's opt in: it watches
96
+ * every change to the page (for declarative form tools), and a canvas changes on every drag and pan.
97
+ */
98
+ allowPolyfill: boolean;
99
+ };
100
+
101
+ export function useCanvasWebMcp(options: CanvasWebMcpOptions) {
102
+ const [status, setStatus] = useState<WebMcpStatus>({ state: 'off' });
103
+ const agentPointer = useRef<AgentPresence['pointer']>(null);
104
+ const presenceTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
105
+ /** How the agent works on the canvas: played out like a person (see agent-choreography), shown in this tab */
106
+ const stage = (): AgentStage | undefined => {
107
+ const { doc } = latest.current;
108
+ if (!doc) return undefined;
109
+ return {
110
+ // The agent's changes aren't the user's: their undo leaves them alone, as it does other people's and MCP agents'
111
+ change: (fn) => doc.transact(() => fn(doc), BROWSER_AGENT_ORIGIN),
112
+ present: (presence) => {
113
+ const { agent, presence: store } = latest.current;
114
+ agentPointer.current = presence.pointer;
115
+ store?.setLocalAgent({ clientId: -1, name: agent.name, color: agent.color, agent: true, ...presence });
116
+ clearTimeout(presenceTimer.current);
117
+ presenceTimer.current = setTimeout(() => latest.current.presence?.setLocalAgent(null), AGENT_PRESENCE_TTL_MS);
118
+ },
119
+ lastPointer: agentPointer.current,
120
+ };
121
+ };
122
+ const stageRef = useRef(stage);
123
+ stageRef.current = stage;
124
+ // Tools are registered once per canvas and read the latest state when they run
125
+ const latest = useRef(options);
126
+ latest.current = options;
127
+
128
+ useEffect(() => {
129
+ if (!options.doc) return;
130
+ if (!getModelContext()) {
131
+ if (!options.allowPolyfill) return;
132
+ initializeWebMCPPolyfill();
133
+ usingPolyfill = true;
134
+ }
135
+ const modelContext = getModelContext();
136
+ if (!modelContext) return;
137
+
138
+ const controller = new AbortController();
139
+ const tools = createCanvasTools(
140
+ () => latest.current,
141
+ () => stageRef.current()
142
+ );
143
+ const onCall = (name: string) => setStatus((current) => (current.state === 'on' ? { ...current, lastCall: name } : current));
144
+
145
+ void Promise.allSettled(
146
+ tools.map((tool) =>
147
+ modelContext.registerTool(
148
+ {
149
+ name: tool.name,
150
+ title: tool.title,
151
+ description: tool.description,
152
+ inputSchema: z.toJSONSchema(tool.input, { io: 'input', unrepresentable: 'any' }),
153
+ annotations: tool.annotations,
154
+ execute: async (input) => {
155
+ const parsed = tool.input.safeParse(input ?? {});
156
+ if (!parsed.success) return result({ error: z.prettifyError(parsed.error) }, true);
157
+ onCall(tool.name);
158
+ try {
159
+ const value = await tool.execute(parsed.data);
160
+ return result(value, !!value && typeof value === 'object' && 'error' in value);
161
+ } catch (error) {
162
+ return result({ error: String((error as Error)?.message ?? error) }, true);
163
+ }
164
+ },
165
+ },
166
+ { signal: controller.signal }
167
+ )
168
+ )
169
+ ).then((outcomes) => {
170
+ if (controller.signal.aborted) return;
171
+ // A tool that's already registered (e.g. after a hot reload) rejects, but is still there
172
+ setStatus({ state: 'on', source: usingPolyfill ? 'polyfill' : 'native', tools: outcomes.length });
173
+ });
174
+
175
+ return () => {
176
+ controller.abort();
177
+ setStatus({ state: 'off' });
178
+ };
179
+ }, [options.doc, options.allowPolyfill]);
180
+
181
+ useEffect(() => () => clearTimeout(presenceTimer.current), []);
182
+
183
+ return status;
184
+ }
185
+
186
+ const NOT_CONNECTED = { error: 'The canvas is not connected yet' };
187
+
188
+ const createCanvasTools = (get: () => CanvasWebMcpOptions, getStage: () => AgentStage | undefined) => {
189
+ const withDoc = <T>(fn: (doc: Y.Doc) => T) => {
190
+ const { doc } = get();
191
+ return doc ? doc.transact(() => fn(doc), BROWSER_AGENT_ORIGIN) : NOT_CONNECTED;
192
+ };
193
+ /** Show the user what the agent changed */
194
+ const reveal = (nodeIds: string[]) => {
195
+ if (!nodeIds.length) return;
196
+ get().select(nodeIds);
197
+ get().fitView(nodeIds);
198
+ };
199
+
200
+ return [
201
+ defineTool({
202
+ name: 'get_canvas',
203
+ title: 'Read the canvas',
204
+ description:
205
+ '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.',
206
+ input: z.object({}),
207
+ annotations: { readOnlyHint: true },
208
+ execute: () =>
209
+ withDoc((doc) => ({
210
+ ...describeCanvas(doc),
211
+ people: get()
212
+ .getPeers()
213
+ .map((peer) => `${peer.name}${peer.agent ? ' (agent)' : ''}`),
214
+ })),
215
+ }),
216
+ defineTool({
217
+ name: 'search_catalog',
218
+ title: 'Search the catalog',
219
+ description:
220
+ 'Finds EventCatalog resources (domains, systems, services, events, commands, queries, channels, data stores) to add to the canvas with add_to_canvas.',
221
+ input: z.object({
222
+ query: z.string().optional().describe('Matches names and ids'),
223
+ collection: z.enum(CATALOG_COLLECTIONS).optional(),
224
+ }),
225
+ annotations: { readOnlyHint: true },
226
+ execute: ({ query, collection }) => {
227
+ const term = query?.toLowerCase();
228
+ return get()
229
+ .resources.filter((resource) => !collection || resource.collection === collection)
230
+ .filter((resource) => !term || `${resource.name} ${resource.id}`.toLowerCase().includes(term))
231
+ .slice(0, 50)
232
+ .map(({ collection, id, name, version, summary }) => ({ collection, id, name, version, summary }));
233
+ },
234
+ }),
235
+ defineTool({
236
+ name: 'add_to_canvas',
237
+ title: 'Add to the canvas',
238
+ description: `${ADD_TO_CANVAS_DESCRIPTION} Use search_catalog to find catalog resources first.`,
239
+ input: z.object({ nodes: nodeSpecsSchema, edges: edgeSpecsSchema.optional() }),
240
+ execute: async (input) => {
241
+ const stage = getStage();
242
+ if (!stage) return NOT_CONNECTED;
243
+ const outcome = await playAddToCanvas(stage, input, get().catalog);
244
+ reveal(outcome.created.map((node) => node.nodeId));
245
+ return outcome;
246
+ },
247
+ }),
248
+ defineTool({
249
+ name: 'update_node',
250
+ title: 'Update a node',
251
+ description:
252
+ 'Renames, re-describes, re-versions or moves a node (x/y: its new centre). Catalog resources keep their catalog name, summary and version. For notes, summary is the note text.',
253
+ input: z.object({
254
+ nodeId: z.string(),
255
+ name: z.string().optional(),
256
+ summary: z.string().optional(),
257
+ version: z.string().optional().describe('e.g. 1.0.0'),
258
+ x: z.number().optional(),
259
+ y: z.number().optional(),
260
+ }),
261
+ execute: async (input) => {
262
+ const stage = getStage();
263
+ return stage ? playUpdateNode(stage, input) : NOT_CONNECTED;
264
+ },
265
+ }),
266
+ defineTool({
267
+ name: 'connect_nodes',
268
+ title: 'Connect nodes',
269
+ description: 'Draws connections between nodes (by id). Labels default to EventCatalog wording, e.g. "publishes event".',
270
+ input: z.object({ edges: edgeSpecsSchema.min(1) }),
271
+ execute: async ({ edges }) => {
272
+ const stage = getStage();
273
+ return stage ? playConnectAll(stage, edges) : NOT_CONNECTED;
274
+ },
275
+ }),
276
+ defineTool({
277
+ name: 'remove_from_canvas',
278
+ title: 'Remove from the canvas',
279
+ description: 'Removes nodes (and their connections) or connections. Only remove what the user asked for.',
280
+ input: z.object({ nodeIds: z.array(z.string()).optional(), connectionIds: z.array(z.string()).optional() }),
281
+ annotations: { consequentialHint: true },
282
+ execute: async ({ nodeIds = [], connectionIds = [] }) => {
283
+ const stage = getStage();
284
+ if (!stage) return NOT_CONNECTED;
285
+ await playRemove(stage, nodeIds, connectionIds);
286
+ return { removedNodes: nodeIds, removedConnections: connectionIds };
287
+ },
288
+ }),
289
+ defineTool({
290
+ name: 'layout_canvas',
291
+ title: 'Tidy the canvas',
292
+ description:
293
+ "Lays the whole canvas out again with EventCatalog's visualiser layout (left to right, connections routed around nodes), for everyone on it. Call it once when you finish a round of changes, before you hand back to the user, so the canvas is tidy for everyone, and whenever the user asks to tidy up. Don't call it after every change (add_to_canvas already places what you add next to what it connects to while you work), and skip it if the user asked to keep the layout as it is: it moves everything, including what people placed themselves.",
294
+ input: z.object({}),
295
+ execute: async () => {
296
+ await get().reorder();
297
+ return { laidOut: true };
298
+ },
299
+ }),
300
+ defineTool({
301
+ name: 'set_canvas_status',
302
+ title: 'Set the canvas status',
303
+ description:
304
+ "Changes the canvas's status: draft (being worked on, where every canvas starts), proposed (ready for review), accepted (the agreed design) or rejected (decided against). Only change it when the user asks you to. Changing the nodes or connections on an accepted or rejected canvas makes it a draft again.",
305
+ input: z.object({ status: z.enum(CANVAS_STATUSES), note: z.string().optional() }),
306
+ execute: ({ status, note }) => withDoc((doc) => ({ status, changed: setCanvasStatus(doc, status, get().agent, note) })),
307
+ }),
308
+ defineTool({
309
+ name: 'add_comment',
310
+ title: 'Comment on the canvas',
311
+ description:
312
+ "Starts a comment thread pinned to a node (nodeId) or a spot (x, y). Only use it when the user asks you to comment (e.g. to review the canvas). Don't use comments to greet people or ask the user questions: talk to them in the conversation.",
313
+ input: z.object({ text: z.string(), nodeId: z.string().optional(), x: z.number().optional(), y: z.number().optional() }),
314
+ execute: async (input) => {
315
+ const stage = getStage();
316
+ return stage ? playComment(stage, input, get().agent) : NOT_CONNECTED;
317
+ },
318
+ }),
319
+ defineTool({
320
+ name: 'reply_to_comment',
321
+ title: 'Reply to a comment',
322
+ description: 'Replies to a comment thread (threadId from get_canvas).',
323
+ input: z.object({ threadId: z.string(), text: z.string() }),
324
+ execute: ({ threadId, text }) =>
325
+ withDoc((doc) =>
326
+ replyToThread(doc, threadId, text, get().agent) ? { threadId } : { error: `No comment thread "${threadId}"` }
327
+ ),
328
+ }),
329
+ defineTool({
330
+ name: 'resolve_comment',
331
+ title: 'Resolve a comment',
332
+ description: 'Resolves (or reopens) a comment thread.',
333
+ input: z.object({ threadId: z.string(), resolved: z.boolean().default(true) }),
334
+ execute: ({ threadId, resolved }) =>
335
+ withDoc((doc) =>
336
+ setThreadResolved(doc, threadId, resolved) ? { threadId, resolved } : { error: `No comment thread "${threadId}"` }
337
+ ),
338
+ }),
339
+ defineTool({
340
+ name: 'show_nodes',
341
+ title: 'Show nodes to the user',
342
+ description:
343
+ 'Selects nodes and moves the view to them, to point the user at something. Leave out nodeIds to show the whole canvas.',
344
+ input: z.object({ nodeIds: z.array(z.string()).optional() }),
345
+ annotations: { readOnlyHint: true },
346
+ execute: ({ nodeIds }) => {
347
+ if (nodeIds?.length) get().select(nodeIds);
348
+ get().fitView(nodeIds);
349
+ return { shown: nodeIds ?? 'everything' };
350
+ },
351
+ }),
352
+ ];
353
+ };
@@ -0,0 +1,54 @@
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
+ deleteThreads as deleteThreadsInDoc,
7
+ getCanvasMaps,
8
+ moveThread as moveThreadInDoc,
9
+ moveThreads as moveThreadsInDoc,
10
+ readThreads,
11
+ replyToThread,
12
+ setThreadResolved,
13
+ type Author,
14
+ type CommentAnchor,
15
+ type Thread,
16
+ } from '@eventcatalog/visualiser/studio';
17
+
18
+ export type { Author, CommentAnchor, Message, Thread } from '@eventcatalog/visualiser/studio';
19
+
20
+ /** Comment threads in the shared canvas document */
21
+ export function useComments(doc: Y.Doc | null, author: Author) {
22
+ const [threads, setThreads] = useState<Thread[]>([]);
23
+
24
+ useEffect(() => {
25
+ if (!doc) return setThreads([]);
26
+ const { threads: yThreads } = getCanvasMaps(doc);
27
+ const sync = () => setThreads(readThreads(doc));
28
+ sync();
29
+ yThreads.observeDeep(sync);
30
+ return () => yThreads.unobserveDeep(sync);
31
+ }, [doc]);
32
+
33
+ const createThread = useCallback(
34
+ (anchor: CommentAnchor, text: string) => (doc ? createThreadInDoc(doc, anchor, text, author) : undefined),
35
+ [doc, author]
36
+ );
37
+ const reply = useCallback((threadId: string, text: string) => doc && replyToThread(doc, threadId, text, author), [doc, author]);
38
+ const setResolved = useCallback(
39
+ (threadId: string, resolved: boolean) => doc && setThreadResolved(doc, threadId, resolved),
40
+ [doc]
41
+ );
42
+ const moveThread = useCallback(
43
+ (threadId: string, anchor: CommentAnchor) => doc && moveThreadInDoc(doc, threadId, anchor),
44
+ [doc]
45
+ );
46
+ const deleteThread = useCallback((threadId: string) => doc && deleteThreadInDoc(doc, threadId), [doc]);
47
+ const deleteThreads = useCallback((threadIds: string[]) => doc && deleteThreadsInDoc(doc, threadIds), [doc]);
48
+ const moveThreads = useCallback(
49
+ (moves: { threadId: string; anchor: CommentAnchor }[]) => doc && moveThreadsInDoc(doc, moves),
50
+ [doc]
51
+ );
52
+
53
+ return { threads, createThread, reply, setResolved, moveThread, moveThreads, deleteThread, deleteThreads };
54
+ }
@@ -0,0 +1,109 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import { getViewportForBounds, useReactFlow, useStore, type Rect } from '@xyflow/react';
3
+ import { DIAGRAM_FIT_VIEW_OPTIONS } from '@eventcatalog/visualiser';
4
+ import type { Peer } from './presence-store';
5
+
6
+ /**
7
+ * The camera follows agents as they work (like following someone in Figma): it keeps their pointer and what's
8
+ * on the canvas in view, moving only when something they're doing goes out of view. Anyone panning, zooming or
9
+ * dragging pauses it for a moment, so it never fights them.
10
+ */
11
+
12
+ const FOLLOW_EVERY_MS = 500;
13
+ const MOVE_MS = 600;
14
+ const PAUSE_AFTER_USER_MS = 4000;
15
+ const POINTER_SIZE = 60;
16
+ const PADDING = 0.15;
17
+ const MAX_ZOOM = 1.1;
18
+
19
+ const union = (a: Rect, b: Rect): Rect => {
20
+ const x = Math.min(a.x, b.x);
21
+ const y = Math.min(a.y, b.y);
22
+ 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 };
23
+ };
24
+
25
+ /** Whether a rect is in view, inside a margin around the edges */
26
+ const inView = (rect: Rect, view: Rect, margin: number) =>
27
+ rect.x >= view.x + margin &&
28
+ rect.y >= view.y + margin &&
29
+ rect.x + rect.width <= view.x + view.width - margin &&
30
+ rect.y + rect.height <= view.y + view.height - margin;
31
+
32
+ export function useFollowCamera({
33
+ enabled,
34
+ fitsContent,
35
+ agentsWorking,
36
+ getAgents,
37
+ }: {
38
+ enabled: boolean;
39
+ /** Whether it fits the canvas's content when it arrives (the canvas people edit, not a level, fitted on its own) */
40
+ fitsContent: boolean;
41
+ /** Whether any agent is working on the canvas (has a pointer on it) */
42
+ agentsWorking: boolean;
43
+ /** The agents working, with where their pointers are now (read when the camera checks, not on every move) */
44
+ getAgents: () => Peer[];
45
+ }) {
46
+ const { getNodes, getNodesBounds, getViewport, setViewport, fitView } = useReactFlow();
47
+ const hasNodes = useStore((state) => state.nodes.length > 0);
48
+ const width = useStore((state) => state.width);
49
+ const height = useStore((state) => state.height);
50
+ const pausedUntil = useRef(0);
51
+ const lastMove = useRef(0);
52
+ const latest = useRef({ enabled, getAgents });
53
+ latest.current = { enabled, getAgents };
54
+
55
+ /** People moving the camera themselves take it over for a moment */
56
+ const pause = useCallback(() => {
57
+ pausedUntil.current = Date.now() + PAUSE_AFTER_USER_MS;
58
+ }, []);
59
+
60
+ // A canvas that opens empty and fills in (it syncs after it opens) fits once its content arrives, and so does an
61
+ // empty canvas when something's added to it (rather than keeping however far out it was zoomed)
62
+ const hasFitContent = useRef(false);
63
+ useEffect(() => {
64
+ if (!hasNodes) {
65
+ hasFitContent.current = false;
66
+ return;
67
+ }
68
+ if (hasFitContent.current || !width || !fitsContent) return;
69
+ hasFitContent.current = true;
70
+ // Fitted like the visualiser fits a diagram
71
+ const frame = requestAnimationFrame(() => void fitView({ ...DIAGRAM_FIT_VIEW_OPTIONS, duration: 300 }));
72
+ return () => cancelAnimationFrame(frame);
73
+ }, [hasNodes, width, fitsContent]);
74
+
75
+ const follow = useCallback(() => {
76
+ const { enabled, getAgents } = latest.current;
77
+ // Stopped (or a move is still going): leave the camera alone
78
+ if (!enabled || performance.now() - lastMove.current < MOVE_MS) return;
79
+ const agents = getAgents();
80
+ const pointers = agents.flatMap((agent) => (agent.pointer ? [agent.pointer] : []));
81
+ if (!pointers.length || !width || !height || Date.now() < pausedUntil.current) return;
82
+
83
+ const { x, y, zoom } = getViewport();
84
+ const view = { x: -x / zoom, y: -y / zoom, width: width / zoom, height: height / zoom };
85
+ const margin = 40 / zoom;
86
+ const pointerRects = pointers.map((pointer) => ({ x: pointer.x, y: pointer.y, width: POINTER_SIZE, height: POINTER_SIZE }));
87
+ const touched = agents.flatMap((agent) => agent.selection);
88
+ const touchedBounds = touched.length ? getNodesBounds(touched) : undefined;
89
+
90
+ // Only move when what the agent is doing is out of view
91
+ const needed = [...pointerRects, ...(touchedBounds ? [touchedBounds] : [])];
92
+ if (needed.every((rect) => inView(rect, view, margin))) return;
93
+
94
+ const nodes = getNodes();
95
+ const everything = [...(nodes.length ? [getNodesBounds(nodes)] : []), ...needed].reduce(union);
96
+ lastMove.current = performance.now();
97
+ void setViewport(getViewportForBounds(everything, width, height, 0.1, MAX_ZOOM, PADDING), { duration: MOVE_MS });
98
+ }, [width, height]);
99
+
100
+ // While agents work, check every so often whether what they're doing is still in view
101
+ useEffect(() => {
102
+ if (!enabled || !agentsWorking) return;
103
+ follow();
104
+ const timer = setInterval(follow, FOLLOW_EVERY_MS);
105
+ return () => clearInterval(timer);
106
+ }, [enabled, agentsWorking, follow]);
107
+
108
+ return { pause };
109
+ }