@eventcatalog/core 5.0.0-beta.12 → 5.0.0-beta.13

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 (82) 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/catalog-runtime.cjs +1 -1
  6. package/dist/{chunk-FY6MSP6J.js → chunk-BO2HHVZ3.js} +1 -1
  7. package/dist/{chunk-CX2VB7AI.js → chunk-JPT3QC5V.js} +1 -1
  8. package/dist/{chunk-BWC4YKW3.js → chunk-PLTLW45Q.js} +5 -5
  9. package/dist/{chunk-TSY5CC27.js → chunk-T75DOVFP.js} +1 -1
  10. package/dist/{chunk-QB7X34OD.js → chunk-XA5W6DXX.js} +1 -1
  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 +14 -14
  15. package/dist/federation/federate.js +3 -3
  16. package/dist/generate.cjs +1 -1
  17. package/dist/generate.js +3 -3
  18. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
  19. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
  20. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
  21. package/dist/runtime-dependencies/manifest.json +3 -0
  22. package/dist/utils/cli-logger.cjs +1 -1
  23. package/dist/utils/cli-logger.js +2 -2
  24. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +11 -3
  25. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +3 -3
  26. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +3 -1
  27. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +7 -5
  28. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +133 -71
  29. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +66 -66
  30. package/eventcatalog/src/features/studio/agent-choreography.ts +5 -4
  31. package/eventcatalog/src/features/studio/agents.ts +28 -0
  32. package/eventcatalog/src/features/studio/api/canvases-api.ts +1 -1
  33. package/eventcatalog/src/features/studio/canvas-actions.ts +22 -14
  34. package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
  35. package/eventcatalog/src/features/studio/catalog.ts +1 -4
  36. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +246 -115
  37. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +7 -3
  38. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +272 -119
  39. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +1 -2
  40. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +1 -1
  41. package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
  42. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +102 -241
  43. package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
  44. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +1 -1
  45. package/eventcatalog/src/features/studio/components/Presence.tsx +6 -8
  46. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +277 -141
  47. package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
  48. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1005 -354
  49. package/eventcatalog/src/features/studio/document-name.ts +7 -0
  50. package/eventcatalog/src/features/studio/from-visualiser.ts +1 -1
  51. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +19 -2
  52. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +9 -3
  53. package/eventcatalog/src/features/studio/hooks/use-comments.ts +2 -2
  54. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +162 -23
  55. package/eventcatalog/src/features/studio/identity.ts +23 -1
  56. package/eventcatalog/src/features/studio/pages/[id].astro +2 -1
  57. package/eventcatalog/src/features/studio/pages/index.astro +1 -2
  58. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +7 -22
  59. package/eventcatalog/src/features/studio/server/runtime.ts +4 -5
  60. package/package.json +3 -3
  61. package/eventcatalog/src/features/studio/canvas-doc.ts +0 -569
  62. package/eventcatalog/src/features/studio/clipboard.ts +0 -97
  63. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -119
  64. package/eventcatalog/src/features/studio/components/Comments.tsx +0 -777
  65. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +0 -145
  66. package/eventcatalog/src/features/studio/components/DragGhost.tsx +0 -115
  67. package/eventcatalog/src/features/studio/components/Picture.tsx +0 -21
  68. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +0 -212
  69. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +0 -271
  70. package/eventcatalog/src/features/studio/components/icons.ts +0 -53
  71. package/eventcatalog/src/features/studio/components/status.ts +0 -53
  72. package/eventcatalog/src/features/studio/edges.ts +0 -56
  73. package/eventcatalog/src/features/studio/flow-state.ts +0 -121
  74. package/eventcatalog/src/features/studio/grouping.ts +0 -230
  75. package/eventcatalog/src/features/studio/hooks/use-counter-scale.ts +0 -20
  76. package/eventcatalog/src/features/studio/layout.ts +0 -56
  77. package/eventcatalog/src/features/studio/level-layout.ts +0 -145
  78. package/eventcatalog/src/features/studio/levels.ts +0 -337
  79. package/eventcatalog/src/features/studio/node-types.ts +0 -245
  80. package/eventcatalog/src/features/studio/placement.ts +0 -197
  81. package/eventcatalog/src/features/studio/studio.css +0 -273
  82. package/dist/{chunk-KHACCTW2.js → chunk-ZEY2XUJN.js} +4 -4
@@ -14,7 +14,11 @@ import {
14
14
  setMeta,
15
15
  type Author,
16
16
  type CommentAnchor,
17
- } from './canvas-doc';
17
+ getAbsolutePosition,
18
+ sizeOf,
19
+ type LayoutResult,
20
+ getNodeName,
21
+ } from '@eventcatalog/visualiser/studio';
18
22
  import {
19
23
  describeNode,
20
24
  nodeCenter,
@@ -28,9 +32,6 @@ import {
28
32
  type NodeUpdate,
29
33
  } from './canvas-actions';
30
34
  import { getRelatedEdges } from './catalog';
31
- import { getAbsolutePosition, sizeOf } from './grouping';
32
- import type { LayoutResult } from './layout';
33
- import { getNodeName } from './node-types';
34
35
 
35
36
  /**
36
37
  * Agents work on a canvas like a person would: their cursor travels to where they're going, they drop one node,
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Who an agent is, named after its client when it doesn't say, in its brand's colour: on the server (MCP tools) and
3
+ * in a chat's canvas view (which joins as the chat's agent). Their logos are in the visualiser's ClientLogos.
4
+ */
5
+ export type AgentIdentity = { name: string; color: string };
6
+
7
+ const AGENT_COLORS: Record<string, string> = {
8
+ ChatGPT: '#10a37f',
9
+ Codex: '#10a37f',
10
+ Claude: '#d97757',
11
+ 'Claude Code': '#d97757',
12
+ Cursor: '#111827',
13
+ };
14
+
15
+ /** Agents' clients, by what's in their user agent or host name (most specific first) */
16
+ const CLIENTS: [RegExp, string][] = [
17
+ [/claude[-_ ]?code/i, 'Claude Code'],
18
+ [/claude|anthropic/i, 'Claude'],
19
+ [/codex/i, 'Codex'],
20
+ [/openai|chatgpt/i, 'ChatGPT'],
21
+ [/cursor/i, 'Cursor'],
22
+ ];
23
+
24
+ /** Who the agent is, from the name it gives, else its client's user agent (or a chat's host name) */
25
+ export const getAgent = (agentName: string | undefined, client = ''): AgentIdentity => {
26
+ const name = agentName?.trim() || CLIENTS.find(([pattern]) => pattern.test(client))?.[1] || 'AI agent';
27
+ return { name, color: AGENT_COLORS[name] ?? '#8b5cf6' };
28
+ };
@@ -1,7 +1,7 @@
1
1
  import { Hono, type Context } from 'hono';
2
2
  import { z } from 'zod';
3
3
  import { paginate } from '@features/tools/catalog-tools';
4
- import type { CanvasContent, CanvasStatus } from '../canvas-doc';
4
+ import { type CanvasContent, type CanvasStatus } from '@eventcatalog/visualiser/studio';
5
5
  import { MAX_TITLE_LENGTH } from '../limits';
6
6
  import { diagramLevelsSchema, indexCatalog, newCanvasEdgesSchema, nodeSpecsSchema, planCanvas } from '../canvas-actions';
7
7
  import { getCatalogResources } from '../catalog-resources';
@@ -1,20 +1,18 @@
1
1
  import type { Edge, Node, XYPosition } from '@xyflow/react';
2
2
  import type * as Y from 'yjs';
3
3
  import { z } from 'zod';
4
- import { buildNode, getCanvasMaps, getCanvasStatus, readCanvas, updateNodeData, type CanvasContent } from './canvas-doc';
5
- import type { CatalogRelation, CatalogResource } from './catalog-resources';
6
- import {
7
- buildContainer,
8
- canBeContainer,
9
- catalogNodeData,
10
- containerTypeFor,
11
- getCatalogLink,
12
- getRelatedEdges,
13
- indexRelations,
14
- } from './catalog';
15
- import { containerFor, getAbsolutePosition, sizeOf } from './grouping';
16
- import { placeNodes, type Link } from './placement';
17
4
  import {
5
+ buildNode,
6
+ getCanvasMaps,
7
+ getCanvasStatus,
8
+ readCanvas,
9
+ updateNodeData,
10
+ type CanvasContent,
11
+ containerFor,
12
+ getAbsolutePosition,
13
+ sizeOf,
14
+ placeNodes,
15
+ type Link,
18
16
  canGoInContainer,
19
17
  CATALOG_COLLECTIONS,
20
18
  COMPONENT_TYPES,
@@ -28,7 +26,17 @@ import {
28
26
  isVersioned,
29
27
  isWrittenOnCanvas,
30
28
  updateResource,
31
- } from './node-types';
29
+ } from '@eventcatalog/visualiser/studio';
30
+ import type { CatalogRelation, CatalogResource } from './catalog-resources';
31
+ import {
32
+ buildContainer,
33
+ canBeContainer,
34
+ catalogNodeData,
35
+ containerTypeFor,
36
+ getCatalogLink,
37
+ getRelatedEdges,
38
+ indexRelations,
39
+ } from './catalog';
32
40
 
33
41
  /**
34
42
  * What agents can do on a canvas, shared by the MCP tools (on the server) and the WebMCP tools (in the
@@ -1,6 +1,5 @@
1
1
  import type { Edge, Node } from '@xyflow/react';
2
- import { getAbsolutePosition, sizeOf } from './grouping';
3
- import { isGroupType } from './node-types';
2
+ import { getAbsolutePosition, sizeOf, isGroupType } from '@eventcatalog/visualiser/studio';
4
3
 
5
4
  /**
6
5
  * A canvas drawn small, for lists of canvases: each node as a box where it is on the canvas (containers as outlines
@@ -1,9 +1,6 @@
1
1
  import type { Edge, Node } from '@xyflow/react';
2
2
  import type { CatalogRelation, CatalogResource } from './catalog-resources';
3
- import { buildNode } from './canvas-doc';
4
- import { createEdge } from './edges';
5
- import { layoutGraph, withGroupSizes } from './layout';
6
- import { GROUP_TYPES, isGroupType } from './node-types';
3
+ import { buildNode, createEdge, layoutGraph, withGroupSizes, GROUP_TYPES, isGroupType } from '@eventcatalog/visualiser/studio';
7
4
 
8
5
  /**
9
6
  * Catalog resources on a canvas: the node for a resource, its connections to related resources already there,
@@ -1,5 +1,18 @@
1
1
  import { memo, useEffect, useRef, useState, type ReactNode } from 'react';
2
- import { Bot, Link2, MoreVertical, Pencil, Plus, Trash2, UserPlus } from 'lucide-react';
2
+ import {
3
+ CircleHelp,
4
+ Download,
5
+ FolderOpen,
6
+ ImageDown,
7
+ Link2,
8
+ MoreVertical,
9
+ Pencil,
10
+ Plug,
11
+ Plus,
12
+ Share2,
13
+ Trash2,
14
+ Users,
15
+ } from 'lucide-react';
3
16
  import {
4
17
  DIAGRAM_MENU,
5
18
  DIAGRAM_MENU_BUTTON_ICON,
@@ -9,26 +22,37 @@ import {
9
22
  DIAGRAM_MENU_TITLE,
10
23
  } from '@eventcatalog/visualiser';
11
24
  import { usePeople, type Peer, type PresenceStore } from '../hooks/presence-store';
25
+ import { MAX_NAME_LENGTH, PERSON_COLORS } from '../identity';
12
26
  import type { WebMcpStatus } from '../hooks/use-canvas-webmcp';
13
27
  import type { Status, Transport } from '../hooks/use-studio-flow';
14
- import type { CanvasStatus, StatusChange } from '../canvas-doc';
28
+ import { type CanvasStatus, type StatusChange, STATUS, Picture, AgentMark } from '@eventcatalog/visualiser/studio';
15
29
  import CanvasStatusMenu from './CanvasStatusMenu';
16
- import { CONNECTION_DOT, STATUS } from './status';
17
- import Picture from './Picture';
30
+
31
+ /** The dot showing whether the canvas is connected */
32
+ const CONNECTION_DOT: Record<Status, string> = {
33
+ connected: STATUS.success.dot,
34
+ connecting: STATUS.warning.dot,
35
+ disconnected: STATUS.danger.dot,
36
+ };
18
37
 
19
38
  /**
20
- * The canvas's header: two small bars over the canvas. On the left the canvas (its title, and a menu), on the right
21
- * who's here, connecting an agent and sharing. They look like the visualiser's menu at the top left of a diagram
22
- * (its styles, `DIAGRAM_MENU_*`), so a diagram opened in Studio feels the same.
39
+ * The canvas's header, as Studio on the web's top bar: on the left the canvas (its title, its status and a menu), on
40
+ * the right who's here, connecting an agent and sharing, each a small card over the canvas. Its menus look like the
41
+ * visualiser's (`DIAGRAM_MENU_*`).
23
42
  */
24
43
 
25
44
  const barClass =
26
- 'pointer-events-auto flex h-[42px] items-center rounded-md border bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]';
45
+ 'pointer-events-auto flex h-[38px] items-center rounded-[9px] border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] shadow-sm';
27
46
  // The header lets the pointer through to the canvas around its bars, so its menus take it back
28
47
  const popoverClass = 'pointer-events-auto absolute top-full mt-2';
29
48
  const menuClass = `${popoverClass} ${DIAGRAM_MENU}`;
30
49
  const menuItemClass = `${DIAGRAM_MENU_ITEM} w-full text-left`;
31
50
  const MAX_AVATARS = 4;
51
+ const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
52
+ const MOD = isMac ? 'Cmd' : 'Ctrl';
53
+ const itemClass =
54
+ 'flex h-[34px] w-full cursor-pointer items-center gap-3 rounded-[7px] px-2 text-left text-sm outline-none hover:bg-[rgb(var(--ec-content-hover))]';
55
+ const itemIconClass = 'flex flex-none [&_svg]:h-[17px] [&_svg]:w-[17px] [&_svg]:stroke-[1.75]';
32
56
 
33
57
  /** Closes a popover on outside clicks and Escape */
34
58
  function usePopover() {
@@ -56,7 +80,7 @@ function Avatar({ peer, size = 28, children }: { peer: Peer; size?: number; chil
56
80
  className="relative flex items-center justify-center rounded-full text-[11px] font-semibold text-white ring-2 ring-[rgb(var(--ec-card-bg))]"
57
81
  style={{ backgroundColor: peer.color, width: size, height: size }}
58
82
  >
59
- {children ?? (peer.agent ? <Bot size={14} /> : peer.name.slice(0, 2).toUpperCase())}
83
+ {children ?? (peer.agent ? <AgentMark name={peer.name} size={14} /> : peer.name.slice(0, 2).toUpperCase())}
60
84
  {!children && <Picture src={peer.picture} />}
61
85
  </span>
62
86
  );
@@ -69,15 +93,33 @@ function CanvasMenu({
69
93
  onRetitle,
70
94
  shareUrl,
71
95
  newCanvasUrl,
96
+ onConnectAgent,
97
+ webMcp,
98
+ agentsHere,
72
99
  onDelete,
100
+ onOpenFile,
101
+ onSaveToFile,
102
+ onExportImage,
103
+ onLiveCollaboration,
104
+ onHelp,
73
105
  children,
74
106
  }: {
107
+ /** Opens a .eventcatalog file as a new canvas (not offered when not given, e.g. inside a chat) */
108
+ onOpenFile?: () => void;
109
+ onSaveToFile: () => void;
110
+ onExportImage: () => void;
111
+ onLiveCollaboration: () => void;
112
+ onHelp: () => void;
75
113
  title?: string;
76
114
  status: Status;
77
115
  transport: Transport;
78
116
  onRetitle: (title: string) => void;
79
117
  shareUrl?: string;
80
118
  newCanvasUrl?: string;
119
+ onConnectAgent: () => void;
120
+ webMcp: WebMcpStatus;
121
+ /** An AI agent is on the canvas */
122
+ agentsHere: boolean;
81
123
  /** Asks to delete the canvas (not offered when not given, e.g. inside a chat) */
82
124
  onDelete?: () => void;
83
125
  /** Shown after the title (the canvas's status) */
@@ -92,10 +134,29 @@ function CanvasMenu({
92
134
  disconnected: 'Offline, reconnecting…',
93
135
  }[status];
94
136
  const statusColor = CONNECTION_DOT[status];
137
+ /** A menu item, as Studio on the web's: its icon, what it does, and its shortcut (or something else on the right) */
138
+ const item = (icon: ReactNode, label: string, onSelect: () => void, shortcut?: ReactNode, className = '') => (
139
+ <button
140
+ type="button"
141
+ className={`${itemClass} ${className}`}
142
+ onClick={() => {
143
+ menu.setOpen(false);
144
+ onSelect();
145
+ }}
146
+ >
147
+ <span className={itemIconClass}>{icon}</span>
148
+ <span className="min-w-0 flex-1 truncate">{label}</span>
149
+ {typeof shortcut === 'string' ? (
150
+ <span className="flex-none text-xs font-normal text-[rgb(var(--ec-page-text-muted))]">{shortcut}</span>
151
+ ) : (
152
+ shortcut
153
+ )}
154
+ </button>
155
+ );
95
156
 
96
157
  return (
97
158
  <div ref={menu.ref} className="relative">
98
- <div className={`${barClass} gap-1.5 pl-4 pr-1.5`}>
159
+ <div className={`${barClass} gap-1.5 pl-3 pr-1`}>
99
160
  <span title={statusLabel} className={`h-2 w-2 shrink-0 rounded-full ${statusColor}`} />
100
161
  <input
101
162
  ref={titleInput}
@@ -121,48 +182,45 @@ function CanvasMenu({
121
182
  </button>
122
183
  </div>
123
184
  {menu.open && (
124
- <div className={`${menuClass} left-0`}>
125
- <button
126
- className={menuItemClass}
127
- onClick={() => {
128
- menu.setOpen(false);
129
- titleInput.current?.focus();
130
- titleInput.current?.select();
131
- }}
132
- >
133
- <Pencil className={DIAGRAM_MENU_ITEM_ICON} /> Rename canvas
134
- </button>
135
- <button
136
- className={menuItemClass}
137
- onClick={() => {
138
- void navigator.clipboard.writeText(shareUrl ?? window.location.href);
139
- menu.setOpen(false);
140
- }}
141
- >
142
- <Link2 className={DIAGRAM_MENU_ITEM_ICON} /> Copy link
143
- </button>
185
+ <div
186
+ className={`${popoverClass} left-0 z-50 w-72 rounded-xl border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] p-1.5 text-[rgb(var(--ec-page-text))] shadow-xl`}
187
+ >
188
+ {/* As Studio on the web's menu: files and images, people and agents, help */}
189
+ {onOpenFile && item(<FolderOpen />, 'Open…', onOpenFile, `${MOD}+O`)}
190
+ {item(<Download />, 'Save to file…', onSaveToFile, `${MOD}+S`)}
191
+ {item(<ImageDown />, 'Export image…', onExportImage, `${MOD}+Shift+E`)}
192
+ {item(<Users />, 'Live collaboration…', onLiveCollaboration)}
193
+ {item(
194
+ <Plug />,
195
+ agentsHere ? 'AI agent connected' : 'Connect your AI agent…',
196
+ onConnectAgent,
197
+ webMcp.state === 'on' || agentsHere ? (
198
+ <span className={`h-1.5 w-1.5 rounded-full ${STATUS.success.dot}`} />
199
+ ) : undefined
200
+ )}
201
+ {item(<CircleHelp />, 'Help', onHelp, '?')}
202
+ <div className="my-1.5 h-px bg-[rgb(var(--ec-page-border))]" />
203
+ {item(<Pencil />, 'Rename canvas', () => {
204
+ titleInput.current?.focus();
205
+ titleInput.current?.select();
206
+ })}
207
+ {item(<Link2 />, 'Copy link', () => void navigator.clipboard.writeText(shareUrl ?? window.location.href))}
144
208
  {newCanvasUrl && (
145
- <a className={menuItemClass} href={newCanvasUrl}>
146
- <Plus className={DIAGRAM_MENU_ITEM_ICON} /> New canvas
209
+ <a className={itemClass} href={newCanvasUrl}>
210
+ <span className={itemIconClass}>
211
+ <Plus />
212
+ </span>
213
+ <span className="min-w-0 flex-1 truncate">New canvas</span>
147
214
  </a>
148
215
  )}
149
216
  {onDelete && (
150
217
  <>
151
- <div className={DIAGRAM_MENU_SEPARATOR} />
152
- <button
153
- // Laid out like the other items, in the theme's danger colour
154
- className={`flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${STATUS.danger.text} ${STATUS.danger.hover}`}
155
- onClick={() => {
156
- menu.setOpen(false);
157
- onDelete();
158
- }}
159
- >
160
- <Trash2 className="h-3.5 w-3.5 shrink-0" /> Delete canvas
161
- </button>
218
+ <div className="my-1.5 h-px bg-[rgb(var(--ec-page-border))]" />
219
+ {item(<Trash2 />, 'Delete canvas', onDelete, undefined, `${STATUS.danger.text} ${STATUS.danger.hover}`)}
162
220
  </>
163
221
  )}
164
- <div className={DIAGRAM_MENU_SEPARATOR} />
165
- <p className="flex items-center gap-2 px-3 py-1.5 text-[11px] text-[rgb(var(--ec-page-text-muted))]">
222
+ <div className="my-1.5 h-px bg-[rgb(var(--ec-page-border))]" />
223
+ <p className="flex items-center gap-2 px-2 py-1.5 text-[11px] text-[rgb(var(--ec-page-text-muted))]">
166
224
  <span className={`h-1.5 w-1.5 rounded-full ${statusColor}`} />
167
225
  {statusLabel}
168
226
  </p>
@@ -172,50 +230,106 @@ function CanvasMenu({
172
230
  );
173
231
  }
174
232
 
175
- /** You: change the name others see you by (unless you're signed in, when it's the name you signed in with) */
176
- function YouAvatar({ peer, onRename }: { peer: Peer; onRename?: (name: string) => void }) {
233
+ const initialsOf = (name: string) => {
234
+ const words = name.trim().split(/\s+/).filter(Boolean);
235
+ return (words.length > 1 ? words.map((word) => word[0]).join('') : name.trim()).slice(0, 2).toUpperCase();
236
+ };
237
+
238
+ /**
239
+ * Who you are here, top right (as Studio on the web's): your avatar, opening your name and colour to change. They're
240
+ * on your pointer and comments. Signed in, your name and picture are the ones you signed in with.
241
+ */
242
+ function ProfileMenu({
243
+ me,
244
+ onRename,
245
+ onRecolor,
246
+ }: {
247
+ me: { name: string; color: string; picture?: string; agent?: boolean };
248
+ /** Not given when you're signed in */
249
+ onRename?: (name: string) => void;
250
+ onRecolor: (color: string) => void;
251
+ }) {
177
252
  const popover = usePopover();
178
- const [draft, setDraft] = useState(peer.name);
179
- const commit = () => {
180
- const trimmed = draft.trim();
181
- if (trimmed && trimmed !== peer.name) onRename?.(trimmed);
182
- popover.setOpen(false);
253
+ const [draft, setDraft] = useState(me.name);
254
+ useEffect(() => setDraft(me.name), [me.name]);
255
+ const save = () => {
256
+ const next = draft.trim();
257
+ if (!next) return setDraft(me.name);
258
+ if (next !== me.name) onRename?.(next);
183
259
  };
184
-
185
- // Signed in: you're shown in the app's header already
186
- if (!onRename) return null;
260
+ const avatar = (size: string, text: string) => (
261
+ <span
262
+ className={`relative flex ${size} flex-none items-center justify-center rounded-full text-[10px] font-semibold text-white`}
263
+ style={{ background: me.color }}
264
+ >
265
+ {initialsOf(text)}
266
+ <Picture src={me.picture} />
267
+ </span>
268
+ );
269
+ // Here as an AI agent (e.g. a chat's view, as the chat's agent): who you are is its logo, not yours to change
270
+ if (me.agent)
271
+ return (
272
+ <span title={`You're here as ${me.name} (AI agent)`} className={`${barClass} pointer-events-auto w-[38px] justify-center`}>
273
+ <span className="flex h-6 w-6 items-center justify-center rounded-full text-white" style={{ background: me.color }}>
274
+ <AgentMark name={me.name} size={14} />
275
+ </span>
276
+ </span>
277
+ );
187
278
  return (
188
- <div ref={popover.ref} className="relative">
279
+ <div ref={popover.ref} className="pointer-events-auto relative">
189
280
  <button
190
- title={`${peer.name} (you)`}
191
- onClick={() => {
192
- setDraft(peer.name);
193
- popover.setOpen((open) => !open);
194
- }}
195
- className="block rounded-full transition-transform hover:scale-110"
281
+ type="button"
282
+ title={onRename ? `${me.name}: change your name and colour` : `${me.name}: change your colour`}
283
+ onClick={() => popover.setOpen((open) => !open)}
284
+ className={`${barClass} w-[38px] justify-center hover:bg-[rgb(var(--ec-content-hover))]`}
196
285
  >
197
- <Avatar peer={peer} />
286
+ {avatar('h-6 w-6', me.name)}
198
287
  </button>
199
288
  {popover.open && (
200
289
  <div
201
- className={`${popoverClass} right-0 z-50 w-60 space-y-2 rounded-lg border p-3 shadow-xl bg-[rgb(var(--ec-page-bg))] border-[rgb(var(--ec-page-border))]`}
290
+ className={`${popoverClass} right-0 z-[70] w-72 rounded-xl border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] p-4 text-[rgb(var(--ec-page-text))] shadow-xl`}
202
291
  >
203
- <label className="block space-y-1.5 text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))]">
204
- Your name on this canvas
205
- <input
206
- autoFocus
207
- value={draft}
208
- onChange={(e) => setDraft(e.target.value)}
209
- onKeyDown={(e) => e.key === 'Enter' && commit()}
210
- className="w-full rounded-md border px-2 py-1.5 text-xs bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))]"
211
- />
212
- </label>
213
- <button
214
- onClick={commit}
215
- className="w-full rounded-md px-2 py-1.5 text-xs font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))]"
216
- >
217
- Save
218
- </button>
292
+ <div className="flex items-center gap-3">
293
+ {avatar('h-9 w-9 text-xs', draft || me.name)}
294
+ <div className="min-w-0">
295
+ <p className="truncate text-sm font-semibold">{draft || me.name}</p>
296
+ <p className="text-xs text-[rgb(var(--ec-page-text-muted))]">On your pointer and comments</p>
297
+ </div>
298
+ </div>
299
+ {onRename && (
300
+ <label className="mt-4 block text-xs font-medium text-[rgb(var(--ec-page-text-muted))]">
301
+ Your name
302
+ <input
303
+ value={draft}
304
+ maxLength={MAX_NAME_LENGTH}
305
+ onChange={(event) => setDraft(event.target.value)}
306
+ onBlur={save}
307
+ onKeyDown={(event) => event.key === 'Enter' && event.currentTarget.blur()}
308
+ className="mt-1.5 block h-9 w-full rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-page-bg))] px-3 text-sm text-[rgb(var(--ec-page-text))] outline-none focus:border-[rgb(var(--ec-accent))]"
309
+ />
310
+ </label>
311
+ )}
312
+ <div className="mt-3 text-xs font-medium text-[rgb(var(--ec-page-text-muted))]">
313
+ Your colour
314
+ <div role="radiogroup" aria-label="Your colour" className="mt-2 flex gap-2">
315
+ {PERSON_COLORS.map((color) => (
316
+ <button
317
+ key={color}
318
+ type="button"
319
+ role="radio"
320
+ aria-checked={color === me.color}
321
+ aria-label={color}
322
+ onClick={() => onRecolor(color)}
323
+ className={`h-6 w-6 rounded-full transition-transform hover:scale-110 ${
324
+ color === me.color
325
+ ? 'ring-2 ring-[rgb(var(--ec-page-text))] ring-offset-2 ring-offset-[rgb(var(--ec-card-bg))]'
326
+ : ''
327
+ }`}
328
+ style={{ background: color }}
329
+ />
330
+ ))}
331
+ </div>
332
+ </div>
219
333
  </div>
220
334
  )}
221
335
  </div>
@@ -223,20 +337,34 @@ function YouAvatar({ peer, onRename }: { peer: Peer; onRename?: (name: string) =
223
337
  }
224
338
 
225
339
  /** Who's here: click someone to jump to them; everyone past the first few is in a list */
340
+ /**
341
+ * Who else is here. An agent is shown once, however many places it's in (e.g. its MCP tools and its chat's canvas
342
+ * view), and not at all when it's you.
343
+ */
344
+ const othersHere = (peers: Peer[], clientId: number | null, me: { name: string; agent?: boolean }) => {
345
+ const agentsSeen = new Set(me.agent ? [me.name] : []);
346
+ return peers.filter((peer) => {
347
+ if (peer.clientId === clientId) return false;
348
+ if (!peer.agent) return true;
349
+ if (agentsSeen.has(peer.name)) return false;
350
+ agentsSeen.add(peer.name);
351
+ return true;
352
+ });
353
+ };
354
+
226
355
  function People({
227
356
  peers,
228
357
  clientId,
358
+ me,
229
359
  onJumpTo,
230
- onRename,
231
360
  }: {
232
361
  peers: Peer[];
233
362
  clientId: number | null;
363
+ me: { name: string; agent?: boolean };
234
364
  onJumpTo: (peer: Peer) => void;
235
- onRename?: (name: string) => void;
236
365
  }) {
237
366
  const overflow = usePopover();
238
- const you = peers.find((peer) => peer.clientId === clientId);
239
- const others = peers.filter((peer) => peer.clientId !== clientId);
367
+ const others = othersHere(peers, clientId, me);
240
368
  const shown = others.slice(0, MAX_AVATARS);
241
369
  const hidden = others.slice(MAX_AVATARS);
242
370
  const describe = (peer: Peer) => `${peer.name}${peer.agent ? ` (AI agent)${peer.activity ? `: ${peer.activity}` : ''}` : ''}`;
@@ -283,7 +411,6 @@ function People({
283
411
  </div>
284
412
  )}
285
413
  </div>
286
- {you && <YouAvatar peer={you} onRename={onRename} />}
287
414
  </div>
288
415
  );
289
416
  }
@@ -296,12 +423,19 @@ export default memo(function CanvasHeader({
296
423
  presence,
297
424
  clientId,
298
425
  onJumpTo,
426
+ me,
299
427
  onRename,
428
+ onRecolor,
300
429
  onConnectAgent,
301
430
  webMcp,
302
431
  shareUrl,
303
432
  newCanvasUrl,
304
433
  onShare,
434
+ onOpenFile,
435
+ onSaveToFile,
436
+ onExportImage,
437
+ onLiveCollaboration,
438
+ onHelp,
305
439
  canvasStatus,
306
440
  statusHistory,
307
441
  onStatusChange,
@@ -317,24 +451,33 @@ export default memo(function CanvasHeader({
317
451
  presence: PresenceStore | null;
318
452
  clientId: number | null;
319
453
  onJumpTo: (peer: Peer) => void;
454
+ /** You: your name, colour and (signed in) picture */
455
+ me: { name: string; color: string; picture?: string; agent?: boolean };
320
456
  /** Not given when you're signed in */
321
457
  onRename?: (name: string) => void;
458
+ onRecolor: (color: string) => void;
322
459
  onConnectAgent: () => void;
323
460
  webMcp: WebMcpStatus;
324
461
  shareUrl?: string;
325
462
  newCanvasUrl?: string;
326
463
  /** Open the share dialog */
327
464
  onShare: () => void;
465
+ /** Opens a .eventcatalog file as a new canvas (not given where it can't, e.g. inside a chat) */
466
+ onOpenFile?: () => void;
467
+ onSaveToFile: () => void;
468
+ onExportImage: () => void;
469
+ onLiveCollaboration: () => void;
470
+ onHelp: () => void;
328
471
  /** Ask to delete the canvas (not given where it can't be, e.g. inside a chat) */
329
472
  onDelete?: () => void;
330
473
  }) {
331
474
  const peers = usePeople(presence);
332
475
  const agentsHere = peers.some((peer) => peer.agent);
333
- // Nobody else here, and signed in (so not shown as "you" to rename): no list of people
334
- const showPeople = !!onRename || peers.some((peer) => peer.clientId !== clientId);
476
+ // Who else is here (you're your own avatar, next to it)
477
+ const showPeople = othersHere(peers, clientId, me).length > 0;
335
478
 
336
479
  return (
337
- <div className="pointer-events-none absolute left-4 right-4 top-4 z-10 flex items-start justify-between gap-3">
480
+ <div className="pointer-events-none absolute left-3 right-3 top-3 z-10 flex items-start justify-between gap-3">
338
481
  <CanvasMenu
339
482
  title={title}
340
483
  status={status}
@@ -342,43 +485,31 @@ export default memo(function CanvasHeader({
342
485
  onRetitle={onRetitle}
343
486
  shareUrl={shareUrl}
344
487
  newCanvasUrl={newCanvasUrl}
488
+ onConnectAgent={onConnectAgent}
489
+ webMcp={webMcp}
490
+ agentsHere={agentsHere}
345
491
  onDelete={onDelete}
492
+ onOpenFile={onOpenFile}
493
+ onSaveToFile={onSaveToFile}
494
+ onExportImage={onExportImage}
495
+ onLiveCollaboration={onLiveCollaboration}
496
+ onHelp={onHelp}
346
497
  >
347
498
  <CanvasStatusMenu status={canvasStatus} history={statusHistory} onChange={onStatusChange} />
348
499
  </CanvasMenu>
349
500
 
350
- <div className={`${barClass} gap-2 px-1.5`}>
501
+ <div className="flex items-center gap-2">
351
502
  {showPeople && (
352
- <>
353
- <div className="pl-1">
354
- <People peers={peers} clientId={clientId} onJumpTo={onJumpTo} onRename={onRename} />
355
- </div>
356
- <div className="h-5 w-px bg-[rgb(var(--ec-page-border))]" />
357
- </>
503
+ <div className={`${barClass} px-1.5`}>
504
+ <People peers={peers} clientId={clientId} me={me} onJumpTo={onJumpTo} />
505
+ </div>
358
506
  )}
359
- <button
360
- onClick={onConnectAgent}
361
- title={
362
- webMcp.state === 'on'
363
- ? `Connect an AI agent to this canvas. WebMCP is on in this tab${webMcp.lastCall ? ` (last: ${webMcp.lastCall})` : ''}`
364
- : 'Connect an AI agent to this canvas'
365
- }
366
- // A button of its own next to Share (the two ways to bring someone in): people, or an AI agent
367
- className="relative flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-semibold transition-colors border-[rgb(var(--ec-accent)/0.35)] bg-[rgb(var(--ec-accent-subtle))] text-[rgb(var(--ec-accent))] hover:border-[rgb(var(--ec-accent)/0.7)] hover:bg-[rgb(var(--ec-accent)/0.15)]"
368
- >
369
- <Bot size={15} />
370
- {agentsHere ? 'Agent connected' : 'Connect agent'}
371
- {(webMcp.state === 'on' || agentsHere) && (
372
- <span
373
- className={`absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full ring-2 ring-[rgb(var(--ec-card-bg))] ${STATUS.success.dot}`}
374
- />
375
- )}
376
- </button>
507
+ <ProfileMenu me={me} onRename={onRename} onRecolor={onRecolor} />
377
508
  <button
378
509
  onClick={onShare}
379
- className="flex h-7 items-center gap-1.5 rounded-lg px-3 text-xs font-semibold bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))]"
510
+ className="pointer-events-auto inline-flex h-[38px] items-center gap-2 rounded-[9px] bg-[rgb(var(--ec-page-text))] px-4 text-sm font-semibold text-[rgb(var(--ec-page-bg))] shadow-sm transition-colors hover:bg-[rgb(var(--ec-page-text)/0.85)]"
380
511
  >
381
- <UserPlus size={14} />
512
+ <Share2 size={16} aria-hidden />
382
513
  Share
383
514
  </button>
384
515
  </div>