@eventcatalog/core 5.0.0-beta.7 → 5.0.0-beta.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +227 -128
- package/dist/analytics/analytics.cjs +18 -3
- package/dist/analytics/analytics.d.cts +1 -1
- package/dist/analytics/analytics.d.ts +1 -1
- package/dist/analytics/analytics.js +3 -2
- package/dist/analytics/log-build.cjs +70 -34
- package/dist/analytics/log-build.js +5 -4
- package/dist/analytics/telemetry.cjs +42 -0
- package/dist/analytics/telemetry.d.cts +20 -0
- package/dist/analytics/telemetry.d.ts +20 -0
- package/dist/analytics/telemetry.js +8 -0
- package/dist/{chunk-3VBB2ZMP.js → chunk-4CFZUHGG.js} +9 -3
- package/dist/{chunk-PIYJM642.js → chunk-AWOO63ME.js} +70 -44
- package/dist/chunk-CPW6G62V.js +17 -0
- package/dist/{chunk-MDWB5HLO.js → chunk-ENAG6UH7.js} +5 -0
- package/dist/{chunk-FARBIZ7Y.js → chunk-EUNQJO3J.js} +1 -1
- package/dist/{chunk-S7ODHVII.js → chunk-GAHKCOQM.js} +1 -1
- package/dist/{chunk-BOKAG5XC.js → chunk-JQMYHZWI.js} +58 -35
- package/dist/{chunk-SJJKWZYZ.js → chunk-TZVDMWQX.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +207 -123
- package/dist/eventcatalog.config.d.cts +55 -0
- package/dist/eventcatalog.config.d.ts +55 -0
- package/dist/eventcatalog.js +39 -19
- package/dist/features.cjs +8 -2
- package/dist/features.d.cts +3 -1
- package/dist/features.d.ts +3 -1
- package/dist/features.js +5 -3
- package/dist/federation/federate.js +4 -4
- package/dist/generate.cjs +1 -1
- package/dist/generate.js +3 -3
- package/dist/utils/cli-logger.cjs +1 -1
- package/dist/utils/cli-logger.js +2 -2
- package/dist/utils/license-status.cjs +70 -44
- package/dist/utils/license-status.d.cts +14 -5
- package/dist/utils/license-status.d.ts +14 -5
- package/dist/utils/license-status.js +1 -1
- package/eventcatalog/integrations/studio-server.mjs +15 -3
- package/eventcatalog/src/components/Header.astro +3 -3
- package/eventcatalog/src/features/integrations/eventcatalog-features.ts +10 -2
- package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +9 -1
- package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +9 -2
- package/eventcatalog/src/features/mcp/apps/generated/canvas.html +70 -69
- package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
- package/eventcatalog/src/features/mcp/mcp-server.ts +5 -3
- package/eventcatalog/src/features/studio/agent-choreography.ts +8 -5
- package/eventcatalog/src/features/studio/canvas-actions.ts +148 -21
- package/eventcatalog/src/features/studio/canvas-doc.ts +138 -9
- package/eventcatalog/src/features/studio/canvas-preview.ts +53 -0
- package/eventcatalog/src/features/studio/catalog-resources.ts +5 -2
- package/eventcatalog/src/features/studio/clipboard.ts +97 -0
- package/eventcatalog/src/features/studio/components/CanvasControls.tsx +4 -3
- package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +39 -22
- package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +117 -0
- package/eventcatalog/src/features/studio/components/Comments.tsx +345 -30
- package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +140 -76
- package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +144 -0
- package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +1 -1
- package/eventcatalog/src/features/studio/components/DragGhost.tsx +115 -0
- package/eventcatalog/src/features/studio/components/JoinForm.tsx +0 -24
- package/eventcatalog/src/features/studio/components/LeftPanel.tsx +131 -35
- package/eventcatalog/src/features/studio/components/Picture.tsx +21 -0
- package/eventcatalog/src/features/studio/components/Presence.tsx +2 -0
- package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +187 -50
- package/eventcatalog/src/features/studio/components/ShareDialog.tsx +260 -0
- package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +442 -40
- package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +198 -22
- package/eventcatalog/src/features/studio/components/icons.ts +6 -1
- package/eventcatalog/src/features/studio/components/status.ts +30 -1
- package/eventcatalog/src/features/studio/edges.ts +2 -0
- package/eventcatalog/src/features/studio/grouping.ts +95 -2
- package/eventcatalog/src/features/studio/hooks/presence-store.ts +5 -1
- package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +13 -3
- package/eventcatalog/src/features/studio/hooks/use-comments.ts +8 -1
- package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +7 -2
- package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +174 -27
- package/eventcatalog/src/features/studio/identity.ts +35 -0
- package/eventcatalog/src/features/studio/layout.ts +7 -4
- package/eventcatalog/src/features/studio/levels.ts +138 -72
- package/eventcatalog/src/features/studio/node-types.ts +35 -3
- package/eventcatalog/src/features/studio/pages/[id].astro +7 -2
- package/eventcatalog/src/features/studio/pages/index.astro +555 -2
- package/eventcatalog/src/features/studio/pages/new.astro +24 -0
- package/eventcatalog/src/features/studio/pages/wake.ts +15 -0
- package/eventcatalog/src/features/studio/server/canvas-mcp.ts +48 -13
- package/eventcatalog/src/features/studio/server/runtime.ts +193 -14
- package/eventcatalog/src/features/studio/server/sign-in.ts +18 -0
- package/eventcatalog/src/features/studio/server/storage.ts +110 -0
- package/eventcatalog/src/features/studio/studio.css +184 -0
- package/eventcatalog/src/layouts/VerticalSideBarLayout.astro +1 -1
- package/eventcatalog/src/utils/feature.ts +7 -3
- package/eventcatalog/src/utils/node-graphs/export-mermaid.ts +0 -2
- package/package.json +3 -3
- package/dist/{chunk-AYNZXUB5.js → chunk-ZEY2XUJN.js} +7 -7
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Edge, Node, XYPosition } from '@xyflow/react';
|
|
2
|
+
import { shortId, toSharedNode } from './canvas-doc';
|
|
3
|
+
import { findGroupAtPoint, getAbsolutePosition, sizeOf, sortByHierarchy, withDescendants } from './grouping';
|
|
4
|
+
import { canGoInContainer } from './node-types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Copying and pasting nodes, within a canvas or between canvases (through the system clipboard). What's copied is
|
|
8
|
+
* the selected nodes, everything inside selected containers, and the connections between them. Comments aren't.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The clipboard type Studio reads first (other apps get a plain text list of what was copied) */
|
|
12
|
+
export const CLIPBOARD_TYPE = 'application/x-eventcatalog-studio+json';
|
|
13
|
+
const MARKER = 'eventcatalog-studio/nodes@1';
|
|
14
|
+
|
|
15
|
+
export type ClipboardContent = { marker: typeof MARKER; nodes: Node[]; edges: Edge[] };
|
|
16
|
+
|
|
17
|
+
/** What's copied when these nodes are selected, or null if there's nothing to copy */
|
|
18
|
+
export const copyNodes = (nodes: Node[], edges: Edge[], selectedIds: string[]): ClipboardContent | null => {
|
|
19
|
+
if (selectedIds.length === 0) return null;
|
|
20
|
+
const lookup = new Map(nodes.map((node) => [node.id, node]));
|
|
21
|
+
const ids = withDescendants(selectedIds, nodes);
|
|
22
|
+
const copied = sortByHierarchy(nodes.filter((node) => ids.has(node.id))).map((node) => {
|
|
23
|
+
const shared = toSharedNode(node);
|
|
24
|
+
// Copied without its container: where it is on the canvas
|
|
25
|
+
if (shared.parentId && !ids.has(shared.parentId)) {
|
|
26
|
+
const { parentId: _, ...rest } = shared;
|
|
27
|
+
return { ...rest, position: getAbsolutePosition(node, lookup) };
|
|
28
|
+
}
|
|
29
|
+
return shared;
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
marker: MARKER,
|
|
33
|
+
nodes: copied,
|
|
34
|
+
edges: edges.filter((edge) => ids.has(edge.source) && ids.has(edge.target)).map(({ selected: _, ...edge }) => edge),
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Copied content read back from the clipboard, if it's Studio's */
|
|
39
|
+
export const readClipboard = (text: string | undefined): ClipboardContent | null => {
|
|
40
|
+
if (!text) return null;
|
|
41
|
+
try {
|
|
42
|
+
const content = JSON.parse(text) as Partial<ClipboardContent>;
|
|
43
|
+
return content.marker === MARKER && Array.isArray(content.nodes) && Array.isArray(content.edges)
|
|
44
|
+
? (content as ClipboardContent)
|
|
45
|
+
: null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Copies of the content to add to the canvas, with new ids: centred on a point (e.g. the pointer), or moved by an
|
|
53
|
+
* offset from where they were copied. Copies that land in a container on the canvas go inside it.
|
|
54
|
+
*/
|
|
55
|
+
export const pasteNodes = (
|
|
56
|
+
content: ClipboardContent,
|
|
57
|
+
existing: Node[],
|
|
58
|
+
place: { at: XYPosition } | { offset: XYPosition }
|
|
59
|
+
): { nodes: Node[]; edges: Edge[] } => {
|
|
60
|
+
const ids = new Map(content.nodes.map((node) => [node.id, `${node.type ?? 'node'}-${shortId()}`]));
|
|
61
|
+
const roots = content.nodes.filter((node) => !node.parentId);
|
|
62
|
+
const offset = 'offset' in place ? place.offset : offsetToCentre(roots, place.at);
|
|
63
|
+
|
|
64
|
+
const lookup = new Map(existing.map((node) => [node.id, node]));
|
|
65
|
+
const nodes = content.nodes.map((node): Node => {
|
|
66
|
+
const id = ids.get(node.id)!;
|
|
67
|
+
if (node.parentId) return { ...node, id, parentId: ids.get(node.parentId) };
|
|
68
|
+
const position = { x: node.position.x + offset.x, y: node.position.y + offset.y };
|
|
69
|
+
const { width, height } = sizeOf(node);
|
|
70
|
+
const container = canGoInContainer(node.type)
|
|
71
|
+
? findGroupAtPoint({ x: position.x + width / 2, y: position.y + height / 2 }, existing)
|
|
72
|
+
: undefined;
|
|
73
|
+
if (!container) return { ...node, id, position };
|
|
74
|
+
const origin = getAbsolutePosition(container, lookup);
|
|
75
|
+
return { ...node, id, parentId: container.id, position: { x: position.x - origin.x, y: position.y - origin.y } };
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
nodes,
|
|
80
|
+
edges: content.edges.map((edge) => ({
|
|
81
|
+
...edge,
|
|
82
|
+
id: `edge-${shortId()}`,
|
|
83
|
+
source: ids.get(edge.source)!,
|
|
84
|
+
target: ids.get(edge.target)!,
|
|
85
|
+
})),
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** How far to move nodes so the middle of them is at a point */
|
|
90
|
+
const offsetToCentre = (roots: Node[], at: XYPosition): XYPosition => {
|
|
91
|
+
if (roots.length === 0) return { x: 0, y: 0 };
|
|
92
|
+
const left = Math.min(...roots.map((node) => node.position.x));
|
|
93
|
+
const top = Math.min(...roots.map((node) => node.position.y));
|
|
94
|
+
const right = Math.max(...roots.map((node) => node.position.x + sizeOf(node).width));
|
|
95
|
+
const bottom = Math.max(...roots.map((node) => node.position.y + sizeOf(node).height));
|
|
96
|
+
return { x: at.x - (left + right) / 2, y: at.y - (top + bottom) / 2 };
|
|
97
|
+
};
|
|
@@ -134,17 +134,18 @@ export default memo(function CanvasControls({
|
|
|
134
134
|
<Divider />
|
|
135
135
|
<LevelSwitch level={level} onChange={onLevelChange} unavailable={unavailableLevels} />
|
|
136
136
|
<Divider />
|
|
137
|
-
<ControlButton label="Undo (⌘Z)" onClick={onUndo} disabled={!
|
|
137
|
+
<ControlButton label="Undo (⌘Z)" onClick={onUndo} disabled={!canUndo}>
|
|
138
138
|
<Undo className="h-4 w-4" />
|
|
139
139
|
</ControlButton>
|
|
140
|
-
<ControlButton label="Redo (⇧⌘Z)" onClick={onRedo} disabled={!
|
|
140
|
+
<ControlButton label="Redo (⇧⌘Z)" onClick={onRedo} disabled={!canRedo}>
|
|
141
141
|
<Redo className="h-4 w-4" />
|
|
142
142
|
</ControlButton>
|
|
143
143
|
<Divider />
|
|
144
144
|
<ControlButton label="Comment (C)" onClick={onToggleCommentMode} active={commentMode} disabled={!editable}>
|
|
145
145
|
<MessageCircle className="h-4 w-4" />
|
|
146
146
|
</ControlButton>
|
|
147
|
-
|
|
147
|
+
{/* Notes can be added on every level, and are shown on the level they're added on */}
|
|
148
|
+
<ControlButton label={editable ? 'Add sticky note' : `Add sticky note to L${level}`} onClick={onAddNote}>
|
|
148
149
|
<StickyNote className="h-4 w-4 text-yellow-500" />
|
|
149
150
|
</ControlButton>
|
|
150
151
|
</div>
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { memo, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
-
import { Bot,
|
|
2
|
+
import { Bot, ChevronDown, Link2, Pencil, Plus, UserPlus } from 'lucide-react';
|
|
3
3
|
import { usePeople, type Peer, type PresenceStore } from '../hooks/presence-store';
|
|
4
4
|
import type { WebMcpStatus } from '../hooks/use-canvas-webmcp';
|
|
5
5
|
import type { Status, Transport } from '../hooks/use-studio-flow';
|
|
6
|
+
import type { CanvasStatus, StatusChange } from '../canvas-doc';
|
|
7
|
+
import CanvasStatusMenu from './CanvasStatusMenu';
|
|
6
8
|
import { CONNECTION_DOT, STATUS } from './status';
|
|
9
|
+
import Picture from './Picture';
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* The canvas's header, like Figma or Miro: two small floating bars. On the left the canvas (its title, and a
|
|
@@ -12,8 +15,9 @@ import { CONNECTION_DOT, STATUS } from './status';
|
|
|
12
15
|
|
|
13
16
|
const barClass =
|
|
14
17
|
'pointer-events-auto flex h-10 items-center rounded-xl border shadow-md bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]';
|
|
18
|
+
// The header lets the pointer through to the canvas around its bars, so its menus take it back
|
|
15
19
|
const menuClass =
|
|
16
|
-
'absolute top-full z-50 mt-2 min-w-52 rounded-xl border p-1 shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]';
|
|
20
|
+
'pointer-events-auto absolute top-full z-50 mt-2 min-w-52 rounded-xl border p-1 shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]';
|
|
17
21
|
const menuItemClass =
|
|
18
22
|
'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs hover:bg-[rgb(var(--ec-page-border)/0.5)]';
|
|
19
23
|
const MAX_AVATARS = 4;
|
|
@@ -41,10 +45,11 @@ function usePopover() {
|
|
|
41
45
|
function Avatar({ peer, size = 28, children }: { peer: Peer; size?: number; children?: ReactNode }) {
|
|
42
46
|
return (
|
|
43
47
|
<span
|
|
44
|
-
className="flex items-center justify-center rounded-full text-[11px] font-semibold text-white ring-2 ring-[rgb(var(--ec-card-bg))]"
|
|
48
|
+
className="relative flex items-center justify-center rounded-full text-[11px] font-semibold text-white ring-2 ring-[rgb(var(--ec-card-bg))]"
|
|
45
49
|
style={{ backgroundColor: peer.color, width: size, height: size }}
|
|
46
50
|
>
|
|
47
51
|
{children ?? (peer.agent ? <Bot size={14} /> : peer.name.slice(0, 2).toUpperCase())}
|
|
52
|
+
{!children && <Picture src={peer.picture} />}
|
|
48
53
|
</span>
|
|
49
54
|
);
|
|
50
55
|
}
|
|
@@ -56,6 +61,7 @@ function CanvasMenu({
|
|
|
56
61
|
onRetitle,
|
|
57
62
|
shareUrl,
|
|
58
63
|
newCanvasUrl,
|
|
64
|
+
children,
|
|
59
65
|
}: {
|
|
60
66
|
title?: string;
|
|
61
67
|
status: Status;
|
|
@@ -63,6 +69,8 @@ function CanvasMenu({
|
|
|
63
69
|
onRetitle: (title: string) => void;
|
|
64
70
|
shareUrl?: string;
|
|
65
71
|
newCanvasUrl?: string;
|
|
72
|
+
/** Shown after the title (the canvas's status) */
|
|
73
|
+
children?: ReactNode;
|
|
66
74
|
}) {
|
|
67
75
|
const menu = usePopover();
|
|
68
76
|
const titleInput = useRef<HTMLInputElement>(null);
|
|
@@ -92,6 +100,7 @@ function CanvasMenu({
|
|
|
92
100
|
size={Math.max(12, (draft ?? title ?? 'Untitled canvas').length)}
|
|
93
101
|
className="max-w-64 truncate rounded-md bg-transparent px-1.5 py-1 text-sm font-semibold placeholder:font-semibold placeholder:text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-page-border)/0.4)] focus:bg-[rgb(var(--ec-input-bg))] focus:outline-none"
|
|
94
102
|
/>
|
|
103
|
+
{children}
|
|
95
104
|
<button
|
|
96
105
|
aria-label="Canvas menu"
|
|
97
106
|
onClick={() => menu.setOpen((open) => !open)}
|
|
@@ -137,16 +146,18 @@ function CanvasMenu({
|
|
|
137
146
|
);
|
|
138
147
|
}
|
|
139
148
|
|
|
140
|
-
/** You: change the name others see you by */
|
|
141
|
-
function YouAvatar({ peer, onRename }: { peer: Peer; onRename
|
|
149
|
+
/** You: change the name others see you by (unless you're signed in, when it's the name you signed in with) */
|
|
150
|
+
function YouAvatar({ peer, onRename }: { peer: Peer; onRename?: (name: string) => void }) {
|
|
142
151
|
const popover = usePopover();
|
|
143
152
|
const [draft, setDraft] = useState(peer.name);
|
|
144
153
|
const commit = () => {
|
|
145
154
|
const trimmed = draft.trim();
|
|
146
|
-
if (trimmed && trimmed !== peer.name) onRename(trimmed);
|
|
155
|
+
if (trimmed && trimmed !== peer.name) onRename?.(trimmed);
|
|
147
156
|
popover.setOpen(false);
|
|
148
157
|
};
|
|
149
158
|
|
|
159
|
+
// Signed in: you're shown in the app's header already
|
|
160
|
+
if (!onRename) return null;
|
|
150
161
|
return (
|
|
151
162
|
<div ref={popover.ref} className="relative">
|
|
152
163
|
<button
|
|
@@ -193,7 +204,7 @@ function People({
|
|
|
193
204
|
peers: Peer[];
|
|
194
205
|
clientId: number | null;
|
|
195
206
|
onJumpTo: (peer: Peer) => void;
|
|
196
|
-
onRename
|
|
207
|
+
onRename?: (name: string) => void;
|
|
197
208
|
}) {
|
|
198
209
|
const overflow = usePopover();
|
|
199
210
|
const you = peers.find((peer) => peer.clientId === clientId);
|
|
@@ -262,30 +273,33 @@ export default memo(function CanvasHeader({
|
|
|
262
273
|
webMcp,
|
|
263
274
|
shareUrl,
|
|
264
275
|
newCanvasUrl,
|
|
276
|
+
onShare,
|
|
277
|
+
canvasStatus,
|
|
278
|
+
statusHistory,
|
|
279
|
+
onStatusChange,
|
|
265
280
|
}: {
|
|
266
281
|
title?: string;
|
|
267
282
|
onRetitle: (title: string) => void;
|
|
283
|
+
canvasStatus: CanvasStatus;
|
|
284
|
+
statusHistory: StatusChange[];
|
|
285
|
+
onStatusChange: (status: CanvasStatus, note?: string) => void;
|
|
268
286
|
status: Status;
|
|
269
287
|
transport: Transport;
|
|
270
288
|
presence: PresenceStore | null;
|
|
271
289
|
clientId: number | null;
|
|
272
290
|
onJumpTo: (peer: Peer) => void;
|
|
273
|
-
|
|
291
|
+
/** Not given when you're signed in */
|
|
292
|
+
onRename?: (name: string) => void;
|
|
274
293
|
onConnectAgent: () => void;
|
|
275
294
|
webMcp: WebMcpStatus;
|
|
276
295
|
shareUrl?: string;
|
|
277
296
|
newCanvasUrl?: string;
|
|
297
|
+
/** Open the share dialog */
|
|
298
|
+
onShare: () => void;
|
|
278
299
|
}) {
|
|
279
|
-
const [copied, setCopied] = useState(false);
|
|
280
300
|
const peers = usePeople(presence);
|
|
281
301
|
const agentsHere = peers.some((peer) => peer.agent);
|
|
282
302
|
|
|
283
|
-
const share = async () => {
|
|
284
|
-
await navigator.clipboard.writeText(shareUrl ?? window.location.href);
|
|
285
|
-
setCopied(true);
|
|
286
|
-
setTimeout(() => setCopied(false), 1500);
|
|
287
|
-
};
|
|
288
|
-
|
|
289
303
|
return (
|
|
290
304
|
<div className="pointer-events-none absolute left-3 right-3 top-3 z-10 flex items-start justify-between gap-3">
|
|
291
305
|
<CanvasMenu
|
|
@@ -295,7 +309,9 @@ export default memo(function CanvasHeader({
|
|
|
295
309
|
onRetitle={onRetitle}
|
|
296
310
|
shareUrl={shareUrl}
|
|
297
311
|
newCanvasUrl={newCanvasUrl}
|
|
298
|
-
|
|
312
|
+
>
|
|
313
|
+
<CanvasStatusMenu status={canvasStatus} history={statusHistory} onChange={onStatusChange} />
|
|
314
|
+
</CanvasMenu>
|
|
299
315
|
|
|
300
316
|
<div className={`${barClass} gap-2 px-1.5`}>
|
|
301
317
|
<div className="pl-1">
|
|
@@ -309,22 +325,23 @@ export default memo(function CanvasHeader({
|
|
|
309
325
|
? `Connect an AI agent to this canvas. WebMCP is on in this tab${webMcp.lastCall ? ` (last: ${webMcp.lastCall})` : ''}`
|
|
310
326
|
: 'Connect an AI agent to this canvas'
|
|
311
327
|
}
|
|
312
|
-
|
|
328
|
+
// A button of its own next to Share (the two ways to bring someone in): people, or an AI agent
|
|
329
|
+
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)]"
|
|
313
330
|
>
|
|
314
331
|
<Bot size={15} />
|
|
315
|
-
Connect agent
|
|
332
|
+
{agentsHere ? 'Agent connected' : 'Connect agent'}
|
|
316
333
|
{(webMcp.state === 'on' || agentsHere) && (
|
|
317
334
|
<span
|
|
318
|
-
className={`absolute
|
|
335
|
+
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}`}
|
|
319
336
|
/>
|
|
320
337
|
)}
|
|
321
338
|
</button>
|
|
322
339
|
<button
|
|
323
|
-
onClick={
|
|
340
|
+
onClick={onShare}
|
|
324
341
|
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))]"
|
|
325
342
|
>
|
|
326
|
-
|
|
327
|
-
|
|
343
|
+
<UserPlus size={14} />
|
|
344
|
+
Share
|
|
328
345
|
</button>
|
|
329
346
|
</div>
|
|
330
347
|
</div>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { memo, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { Check, ChevronDown } from 'lucide-react';
|
|
3
|
+
import { CANVAS_STATUSES, type CanvasStatus, type StatusChange } from '../canvas-doc';
|
|
4
|
+
import { timeAgo } from './Comments';
|
|
5
|
+
import { CANVAS_STATUS_LOOK } from './status';
|
|
6
|
+
|
|
7
|
+
/** Status changes shown in the menu, newest first */
|
|
8
|
+
const HISTORY_SHOWN = 8;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The canvas's status (draft, proposed, accepted, rejected) as a pill beside its title. It opens a menu to change it,
|
|
12
|
+
* with a note on why, and who changed it before.
|
|
13
|
+
*/
|
|
14
|
+
export default memo(function CanvasStatusMenu({
|
|
15
|
+
status,
|
|
16
|
+
history,
|
|
17
|
+
onChange,
|
|
18
|
+
}: {
|
|
19
|
+
status: CanvasStatus;
|
|
20
|
+
history: StatusChange[];
|
|
21
|
+
onChange: (status: CanvasStatus, note?: string) => void;
|
|
22
|
+
}) {
|
|
23
|
+
const [open, setOpen] = useState(false);
|
|
24
|
+
const [note, setNote] = useState('');
|
|
25
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
26
|
+
const look = CANVAS_STATUS_LOOK[status];
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!open) return;
|
|
30
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
31
|
+
if (!ref.current?.contains(event.target as globalThis.Node)) setOpen(false);
|
|
32
|
+
};
|
|
33
|
+
const onKeyDown = (event: KeyboardEvent) => event.key === 'Escape' && setOpen(false);
|
|
34
|
+
window.addEventListener('pointerdown', onPointerDown);
|
|
35
|
+
window.addEventListener('keydown', onKeyDown);
|
|
36
|
+
return () => {
|
|
37
|
+
window.removeEventListener('pointerdown', onPointerDown);
|
|
38
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
39
|
+
};
|
|
40
|
+
}, [open]);
|
|
41
|
+
|
|
42
|
+
const choose = (next: CanvasStatus) => {
|
|
43
|
+
if (next !== status) onChange(next, note);
|
|
44
|
+
setNote('');
|
|
45
|
+
setOpen(false);
|
|
46
|
+
};
|
|
47
|
+
const recent = history.slice(-HISTORY_SHOWN).reverse();
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div ref={ref} className="relative">
|
|
51
|
+
<button
|
|
52
|
+
onClick={() => setOpen((current) => !current)}
|
|
53
|
+
aria-label={`Status: ${look.label}`}
|
|
54
|
+
aria-expanded={open}
|
|
55
|
+
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${look.pill}`}
|
|
56
|
+
>
|
|
57
|
+
{look.label}
|
|
58
|
+
<ChevronDown size={12} />
|
|
59
|
+
</button>
|
|
60
|
+
{open && (
|
|
61
|
+
<div className="absolute left-0 top-full z-50 mt-2 w-72 rounded-xl border p-1 shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]">
|
|
62
|
+
<p className="px-2.5 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">
|
|
63
|
+
Status
|
|
64
|
+
</p>
|
|
65
|
+
{CANVAS_STATUSES.map((value) => {
|
|
66
|
+
const option = CANVAS_STATUS_LOOK[value];
|
|
67
|
+
return (
|
|
68
|
+
<button
|
|
69
|
+
key={value}
|
|
70
|
+
onClick={() => choose(value)}
|
|
71
|
+
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left hover:bg-[rgb(var(--ec-page-border)/0.5)]"
|
|
72
|
+
>
|
|
73
|
+
<span className={`h-2 w-2 shrink-0 rounded-full ${option.dot}`} />
|
|
74
|
+
<span className="min-w-0 flex-1">
|
|
75
|
+
<span className="block text-xs font-medium">{option.label}</span>
|
|
76
|
+
<span className="block text-[11px] text-[rgb(var(--ec-page-text-muted))]">{option.description}</span>
|
|
77
|
+
</span>
|
|
78
|
+
{value === status && <Check size={14} className="shrink-0 text-[rgb(var(--ec-accent))]" />}
|
|
79
|
+
</button>
|
|
80
|
+
);
|
|
81
|
+
})}
|
|
82
|
+
<div className="px-2.5 py-2">
|
|
83
|
+
<input
|
|
84
|
+
value={note}
|
|
85
|
+
onChange={(event) => setNote(event.target.value)}
|
|
86
|
+
placeholder="Add a note (optional), then pick a status"
|
|
87
|
+
aria-label="Note for the status change"
|
|
88
|
+
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))] placeholder:text-[rgb(var(--ec-page-text-muted))] focus:outline-none focus:border-[rgb(var(--ec-accent))]"
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
{recent.length > 0 && (
|
|
92
|
+
<>
|
|
93
|
+
<div className="my-1 h-px bg-[rgb(var(--ec-page-border))]" />
|
|
94
|
+
<p className="px-2.5 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">
|
|
95
|
+
History
|
|
96
|
+
</p>
|
|
97
|
+
<ul className="max-h-48 space-y-1.5 overflow-y-auto px-2.5 pb-2">
|
|
98
|
+
{recent.map((change) => (
|
|
99
|
+
<li key={`${change.at}-${change.status}`} className="text-[11px]">
|
|
100
|
+
<span className="flex items-center gap-1.5">
|
|
101
|
+
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${CANVAS_STATUS_LOOK[change.status].dot}`} />
|
|
102
|
+
<span className="font-medium">{CANVAS_STATUS_LOOK[change.status].label}</span>
|
|
103
|
+
<span className="text-[rgb(var(--ec-page-text-muted))]">
|
|
104
|
+
by {change.by.name} · {timeAgo(change.at)}
|
|
105
|
+
</span>
|
|
106
|
+
</span>
|
|
107
|
+
{change.note && <span className="block pl-3 text-[rgb(var(--ec-page-text-muted))]">{change.note}</span>}
|
|
108
|
+
</li>
|
|
109
|
+
))}
|
|
110
|
+
</ul>
|
|
111
|
+
</>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
});
|