pmx-canvas 0.7.2 → 0.7.3
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/CHANGELOG.md +13 -0
- package/Readme.md +25 -11
- package/dist/canvas/global.css +123 -40
- package/dist/canvas/index.js +84 -84
- package/dist/json-render/index.js +55 -55
- package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
- package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
- package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -0
- package/dist/types/client/state/intent-bridge.d.ts +1 -1
- package/dist/types/json-render/renderer/index.d.ts +2 -1
- package/dist/types/json-render/server.d.ts +2 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
- package/docs/ax-host-adapter-contract.md +13 -2
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +23 -11
- package/skills/pmx-canvas/evals/evals.json +69 -0
- package/skills/pmx-canvas/references/full-reference.md +3 -3
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
- package/skills/pmx-canvas-testing/SKILL.md +49 -0
- package/src/client/canvas/CanvasNode.tsx +200 -195
- package/src/client/canvas/CanvasViewport.tsx +6 -0
- package/src/client/canvas/ChoiceMenu.tsx +122 -0
- package/src/client/canvas/CommandBar.tsx +25 -23
- package/src/client/canvas/FeedbackDialog.tsx +10 -8
- package/src/client/canvas/ToolRail.tsx +46 -99
- package/src/client/nodes/McpAppNode.tsx +16 -0
- package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
- package/src/client/nodes/viewer-runtime-state.ts +78 -0
- package/src/client/state/canvas-store.ts +3 -0
- package/src/client/state/intent-bridge.ts +14 -2
- package/src/client/theme/global.css +123 -40
- package/src/json-render/renderer/index.tsx +58 -38
- package/src/json-render/server.ts +8 -1
- package/src/server/server.ts +3 -1
- package/src/shared/ax-surface-protocol.ts +3 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { useFocusTrap } from './use-focus-trap';
|
|
3
3
|
import { IconExternalLink } from '../icons';
|
|
4
|
+
import { ChoiceMenu } from './ChoiceMenu';
|
|
4
5
|
|
|
5
6
|
/** Draft only: GitHub owns authentication and the final public submission. */
|
|
6
7
|
export function FeedbackDialog({ onClose }: { onClose: () => void }) {
|
|
@@ -40,14 +41,15 @@ export function FeedbackDialog({ onClose }: { onClose: () => void }) {
|
|
|
40
41
|
public. Only what you enter below is included; no board contents, workspace paths, screenshots, or logs are
|
|
41
42
|
attached.
|
|
42
43
|
</p>
|
|
43
|
-
<
|
|
44
|
-
Feedback type
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
<div class="feedback-field">
|
|
45
|
+
<span>Feedback type</span>
|
|
46
|
+
<ChoiceMenu
|
|
47
|
+
label="Feedback type"
|
|
48
|
+
value={kind}
|
|
49
|
+
onChange={setKind}
|
|
50
|
+
options={['Bug report', 'Feature request', 'General feedback'].map((value) => ({ value, label: value }))}
|
|
51
|
+
/>
|
|
52
|
+
</div>
|
|
51
53
|
<label>
|
|
52
54
|
Title
|
|
53
55
|
<input
|
|
@@ -8,10 +8,7 @@ import {
|
|
|
8
8
|
IconLogo,
|
|
9
9
|
IconMinimap,
|
|
10
10
|
IconMoon,
|
|
11
|
-
IconNodeFile,
|
|
12
11
|
IconNodeGroup,
|
|
13
|
-
IconNodeHtml,
|
|
14
|
-
IconNodeImage,
|
|
15
12
|
IconNodeMarkdown,
|
|
16
13
|
IconNodeWebpage,
|
|
17
14
|
IconPen,
|
|
@@ -177,37 +174,33 @@ export function ToolRail({
|
|
|
177
174
|
const traceNodeCount = Array.from(nodes.value.values()).filter((n) => n.type === 'trace').length;
|
|
178
175
|
const edgeCount = edges.value.size;
|
|
179
176
|
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
|
180
|
-
const [openMenu, setOpenMenu] = useState<null | 'theme'
|
|
181
|
-
const [menuAnchor, setMenuAnchor] = useState<{
|
|
177
|
+
const [openMenu, setOpenMenu] = useState<null | 'theme'>(null);
|
|
178
|
+
const [menuAnchor, setMenuAnchor] = useState<{ bottom: number; right: number } | null>(null);
|
|
182
179
|
const railRef = useRef<HTMLDivElement>(null);
|
|
183
|
-
const
|
|
180
|
+
const fileInput = useRef<HTMLInputElement>(null);
|
|
184
181
|
|
|
185
|
-
const toggleMenu = (menu: 'theme'
|
|
182
|
+
const toggleMenu = (menu: 'theme') => (e: MouseEvent) => {
|
|
186
183
|
if (openMenu === menu) {
|
|
187
184
|
setOpenMenu(null);
|
|
188
185
|
return;
|
|
189
186
|
}
|
|
190
187
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
191
|
-
setMenuAnchor({
|
|
188
|
+
setMenuAnchor({ bottom: rect.bottom, right: rect.right });
|
|
192
189
|
setOpenMenu(menu);
|
|
193
190
|
};
|
|
194
191
|
|
|
195
192
|
// The rail scrolls (overflow-y:auto), so an absolutely-positioned popover
|
|
196
193
|
// would be clipped by it. Fixed positioning from the trigger's rect escapes
|
|
197
|
-
// the scroll container; the
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
right: 'auto',
|
|
208
|
-
}
|
|
209
|
-
: { position: 'fixed' as const, left: `${menuAnchor.right + 8}px`, top: `${menuAnchor.top}px`, right: 'auto' }
|
|
210
|
-
: undefined;
|
|
194
|
+
// the scroll container; the theme menu bottom-aligns so it grows upward.
|
|
195
|
+
const sideMenuStyle = menuAnchor
|
|
196
|
+
? {
|
|
197
|
+
position: 'fixed' as const,
|
|
198
|
+
left: `${menuAnchor.right + 8}px`,
|
|
199
|
+
bottom: `${window.innerHeight - menuAnchor.bottom}px`,
|
|
200
|
+
top: 'auto',
|
|
201
|
+
right: 'auto',
|
|
202
|
+
}
|
|
203
|
+
: undefined;
|
|
211
204
|
|
|
212
205
|
useEffect(() => {
|
|
213
206
|
if (!openMenu) return;
|
|
@@ -331,22 +324,20 @@ export function ToolRail({
|
|
|
331
324
|
>
|
|
332
325
|
<IconNodeMarkdown size={15} />
|
|
333
326
|
</RailButton>
|
|
334
|
-
<RailButton
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
<RailButton label="Attach document" onClick={() => documentInput.current?.click()}>
|
|
327
|
+
<RailButton
|
|
328
|
+
label="Attach files"
|
|
329
|
+
ariaLabel="Attach files"
|
|
330
|
+
detail="Images (PNG, JPEG, SVG…), Markdown and text/code files. PDF, Word, Excel, PowerPoint and OpenDocument use agent import with review."
|
|
331
|
+
onClick={() => fileInput.current?.click()}
|
|
332
|
+
>
|
|
341
333
|
<IconUpload size={15} />
|
|
342
334
|
</RailButton>
|
|
343
335
|
<input
|
|
344
|
-
ref={
|
|
336
|
+
ref={fileInput}
|
|
345
337
|
type="file"
|
|
346
338
|
hidden
|
|
347
339
|
multiple
|
|
348
|
-
|
|
349
|
-
aria-label="Attach documents"
|
|
340
|
+
aria-label="Attach files"
|
|
350
341
|
onChange={(event) => {
|
|
351
342
|
const area = canvasArea();
|
|
352
343
|
const view = viewport.value;
|
|
@@ -361,17 +352,6 @@ export function ToolRail({
|
|
|
361
352
|
<RailButton label="Webpage" shortcut="W" onClick={() => promptedCreate('webpage')}>
|
|
362
353
|
<IconNodeWebpage size={15} />
|
|
363
354
|
</RailButton>
|
|
364
|
-
<RailButton
|
|
365
|
-
label="HTML surface"
|
|
366
|
-
shortcut="H"
|
|
367
|
-
onClick={() =>
|
|
368
|
-
void createNodeInView({ type: 'html', title: 'HTML surface' }).catch((error) =>
|
|
369
|
-
logRailError('create html', error),
|
|
370
|
-
)
|
|
371
|
-
}
|
|
372
|
-
>
|
|
373
|
-
<IconNodeHtml />
|
|
374
|
-
</RailButton>
|
|
375
355
|
<RailButton
|
|
376
356
|
label="Group"
|
|
377
357
|
shortcut="G"
|
|
@@ -383,61 +363,28 @@ export function ToolRail({
|
|
|
383
363
|
>
|
|
384
364
|
<IconNodeGroup size={15} />
|
|
385
365
|
</RailButton>
|
|
386
|
-
<
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
{
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
onClick={() => {
|
|
409
|
-
onSetAnnotationTool(annotationTool === 'pen' ? null : 'pen');
|
|
410
|
-
setOpenMenu(null);
|
|
411
|
-
}}
|
|
412
|
-
>
|
|
413
|
-
<IconPen />
|
|
414
|
-
<span>{annotationTool === 'pen' ? 'Stop annotating' : 'Draw (A)'}</span>
|
|
415
|
-
</button>
|
|
416
|
-
<button
|
|
417
|
-
type="button"
|
|
418
|
-
class={`toolbar-menu-item${annotationTool === 'text' ? ' active' : ''}`}
|
|
419
|
-
onClick={() => {
|
|
420
|
-
onSetAnnotationTool(annotationTool === 'text' ? null : 'text');
|
|
421
|
-
setOpenMenu(null);
|
|
422
|
-
}}
|
|
423
|
-
>
|
|
424
|
-
<IconTextAnnotation />
|
|
425
|
-
<span>{annotationTool === 'text' ? 'Stop text notes' : 'Text note'}</span>
|
|
426
|
-
</button>
|
|
427
|
-
<button
|
|
428
|
-
type="button"
|
|
429
|
-
class={`toolbar-menu-item${annotationTool === 'eraser' ? ' active' : ''}`}
|
|
430
|
-
onClick={() => {
|
|
431
|
-
onSetAnnotationTool(annotationTool === 'eraser' ? null : 'eraser');
|
|
432
|
-
setOpenMenu(null);
|
|
433
|
-
}}
|
|
434
|
-
>
|
|
435
|
-
<IconEraser />
|
|
436
|
-
<span>{annotationTool === 'eraser' ? 'Stop erasing' : 'Eraser'}</span>
|
|
437
|
-
</button>
|
|
438
|
-
</div>
|
|
439
|
-
)}
|
|
440
|
-
</span>
|
|
366
|
+
<RailButton
|
|
367
|
+
label="Draw"
|
|
368
|
+
shortcut="A"
|
|
369
|
+
active={annotationTool === 'pen'}
|
|
370
|
+
onClick={() => onSetAnnotationTool(annotationTool === 'pen' ? null : 'pen')}
|
|
371
|
+
>
|
|
372
|
+
<IconPen />
|
|
373
|
+
</RailButton>
|
|
374
|
+
<RailButton
|
|
375
|
+
label="Text note"
|
|
376
|
+
active={annotationTool === 'text'}
|
|
377
|
+
onClick={() => onSetAnnotationTool(annotationTool === 'text' ? null : 'text')}
|
|
378
|
+
>
|
|
379
|
+
<IconTextAnnotation />
|
|
380
|
+
</RailButton>
|
|
381
|
+
<RailButton
|
|
382
|
+
label="Eraser"
|
|
383
|
+
active={annotationTool === 'eraser'}
|
|
384
|
+
onClick={() => onSetAnnotationTool(annotationTool === 'eraser' ? null : 'eraser')}
|
|
385
|
+
>
|
|
386
|
+
<IconEraser />
|
|
387
|
+
</RailButton>
|
|
441
388
|
|
|
442
389
|
<div class="rail-spacer" />
|
|
443
390
|
|
|
@@ -485,7 +432,7 @@ export function ToolRail({
|
|
|
485
432
|
{canvasThemeScheme(activeTheme) === 'dark' ? <IconSun /> : <IconMoon />}
|
|
486
433
|
</RailButton>
|
|
487
434
|
{openMenu === 'theme' && (
|
|
488
|
-
<div class="toolbar-menu" style={sideMenuStyle
|
|
435
|
+
<div class="toolbar-menu" style={sideMenuStyle} role="menu" aria-label="Theme">
|
|
489
436
|
{CANVAS_THEMES.map((name) => (
|
|
490
437
|
<button
|
|
491
438
|
key={name}
|
|
@@ -10,6 +10,7 @@ import { ExtAppFrame } from './ExtAppFrame';
|
|
|
10
10
|
import { useAxSurfaceBridge } from './use-ax-surface-bridge';
|
|
11
11
|
import { useIframeContentHeight } from './use-iframe-content-height';
|
|
12
12
|
import { useSurfaceFrame } from './use-surface-frame';
|
|
13
|
+
import { restoreViewerRuntimeState } from './viewer-runtime-state';
|
|
13
14
|
|
|
14
15
|
function withViewerParams(
|
|
15
16
|
url: string,
|
|
@@ -19,6 +20,7 @@ function withViewerParams(
|
|
|
19
20
|
axNodeId?: string,
|
|
20
21
|
frameToken?: string,
|
|
21
22
|
fitContent?: boolean,
|
|
23
|
+
stateToken?: string,
|
|
22
24
|
): string {
|
|
23
25
|
if (!url) return url;
|
|
24
26
|
try {
|
|
@@ -38,6 +40,7 @@ function withViewerParams(
|
|
|
38
40
|
// Content-fit: report natural height (charts render intrinsic) so the node grows.
|
|
39
41
|
if (frameToken) resolved.searchParams.set('frameToken', frameToken);
|
|
40
42
|
if (fitContent) resolved.searchParams.set('fit', 'content');
|
|
43
|
+
if (stateToken) resolved.searchParams.set('stateToken', stateToken);
|
|
41
44
|
return resolved.toString();
|
|
42
45
|
} catch {
|
|
43
46
|
return url;
|
|
@@ -68,6 +71,7 @@ export function RefreshingViewerFrame({
|
|
|
68
71
|
source,
|
|
69
72
|
iframeRef,
|
|
70
73
|
onLoad,
|
|
74
|
+
onDocumentLoad,
|
|
71
75
|
title,
|
|
72
76
|
className = 'mcp-app-frame',
|
|
73
77
|
sandbox = 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox',
|
|
@@ -77,6 +81,7 @@ export function RefreshingViewerFrame({
|
|
|
77
81
|
source: ViewerFrameSource;
|
|
78
82
|
iframeRef: RefObject<HTMLIFrameElement>;
|
|
79
83
|
onLoad: () => void;
|
|
84
|
+
onDocumentLoad?: (frame: HTMLIFrameElement) => void;
|
|
80
85
|
title: string;
|
|
81
86
|
className?: string;
|
|
82
87
|
sandbox?: string;
|
|
@@ -119,6 +124,9 @@ export function RefreshingViewerFrame({
|
|
|
119
124
|
tabIndex={tabIndex}
|
|
120
125
|
loading={iframeMode.value === 'srcdoc' ? undefined : 'lazy'}
|
|
121
126
|
onLoad={(event) => {
|
|
127
|
+
// Seed runtime state while a replacement is still behind the painted
|
|
128
|
+
// frame, rather than mounting an empty viewer after promotion.
|
|
129
|
+
onDocumentLoad?.(event.currentTarget);
|
|
122
130
|
if (!isPending) {
|
|
123
131
|
onLoad();
|
|
124
132
|
return;
|
|
@@ -214,6 +222,10 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
|
|
|
214
222
|
useEffect(pushAxState, [isAxViewer, axToken, axStateValue]);
|
|
215
223
|
|
|
216
224
|
const specVersion = typeof node.data.specVersion === 'number' ? node.data.specVersion : undefined;
|
|
225
|
+
const stateToken = useMemo(
|
|
226
|
+
() => (isJsonViewer ? `ui-${crypto.randomUUID()}` : ''),
|
|
227
|
+
[isJsonViewer, specVersion, node.data.url],
|
|
228
|
+
);
|
|
217
229
|
const url = withViewerParams(
|
|
218
230
|
(node.data.url as string) || '',
|
|
219
231
|
expanded,
|
|
@@ -222,6 +234,7 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
|
|
|
222
234
|
isAxViewer ? node.id : undefined,
|
|
223
235
|
frameToken || undefined,
|
|
224
236
|
contentFit,
|
|
237
|
+
stateToken || undefined,
|
|
225
238
|
);
|
|
226
239
|
// src vs fetch()+srcdoc, decided by the boot-wide embed probe (Amp portals).
|
|
227
240
|
// External viewer URLs always stay src — the hook passes them through.
|
|
@@ -284,6 +297,9 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
|
|
|
284
297
|
<RefreshingViewerFrame
|
|
285
298
|
source={surfaceFrame}
|
|
286
299
|
iframeRef={iframeRef}
|
|
300
|
+
onDocumentLoad={(frame) => {
|
|
301
|
+
if (stateToken && frame.contentWindow) restoreViewerRuntimeState(node.id, stateToken, frame.contentWindow);
|
|
302
|
+
}}
|
|
287
303
|
onLoad={pushAxState}
|
|
288
304
|
title={`MCP App: ${sourceServer}`}
|
|
289
305
|
/>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RefObject } from 'preact';
|
|
2
|
-
import {
|
|
2
|
+
import { useLayoutEffect } from 'preact/hooks';
|
|
3
3
|
import { AX_SURFACE_ACK_SOURCE, AX_SURFACE_EMIT_SOURCE } from '../../shared/ax-surface-protocol.js';
|
|
4
4
|
import { showToast } from '../state/attention-bridge';
|
|
5
5
|
import { submitAxInteractionFromClient } from '../state/intent-bridge';
|
|
@@ -25,7 +25,9 @@ export function useAxSurfaceBridge(options: {
|
|
|
25
25
|
iframeRef: RefObject<HTMLIFrameElement>;
|
|
26
26
|
}): void {
|
|
27
27
|
const { enabled, token, nodeId, sourceSurface, iframeRef } = options;
|
|
28
|
-
|
|
28
|
+
// Register before paint: a fast iframe can accept input before passive effects
|
|
29
|
+
// run, and its first emit must not be lost in that gap.
|
|
30
|
+
useLayoutEffect(() => {
|
|
29
31
|
if (!enabled || !token) return;
|
|
30
32
|
function onAxMessage(event: MessageEvent) {
|
|
31
33
|
// Bind to THIS node's own iframe; the nonce + nodeId are a second gate,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { effect } from '@preact/signals';
|
|
2
|
+
import { JSON_VIEWER_STATE_SOURCE } from '../../shared/ax-surface-protocol.js';
|
|
3
|
+
import { canvasInteractionEpoch, nodes } from '../state/canvas-store';
|
|
4
|
+
|
|
5
|
+
type Snapshot = {
|
|
6
|
+
epoch: number;
|
|
7
|
+
version: unknown;
|
|
8
|
+
documentUrl: unknown;
|
|
9
|
+
state: Record<string, unknown> | null;
|
|
10
|
+
owners: Array<{ token: string; source: Window }>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Tab-local UI state only: never persist form drafts in canvas data or AX. Keep
|
|
14
|
+
// the receiver alive across the inline/expanded unmount so queued input messages
|
|
15
|
+
// cannot fall into a gap between component effects. Two owners cover a handover;
|
|
16
|
+
// removal, board navigation and authored spec changes discard the entire entry.
|
|
17
|
+
const snapshots = new Map<string, Snapshot>();
|
|
18
|
+
let listening = false;
|
|
19
|
+
|
|
20
|
+
export function restoreViewerRuntimeState(nodeId: string, token: string, source: Window): void {
|
|
21
|
+
if (!listening) {
|
|
22
|
+
listening = true;
|
|
23
|
+
effect(() => {
|
|
24
|
+
const current = nodes.value;
|
|
25
|
+
const epoch = canvasInteractionEpoch.value;
|
|
26
|
+
for (const [id, snapshot] of snapshots) {
|
|
27
|
+
const node = current.get(id);
|
|
28
|
+
if (
|
|
29
|
+
!node ||
|
|
30
|
+
snapshot.epoch !== epoch ||
|
|
31
|
+
snapshot.version !== node.data.specVersion ||
|
|
32
|
+
snapshot.documentUrl !== node.data.url
|
|
33
|
+
)
|
|
34
|
+
snapshots.delete(id);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
window.addEventListener('message', (event: MessageEvent) => {
|
|
38
|
+
const message = event.data as {
|
|
39
|
+
source?: string;
|
|
40
|
+
type?: string;
|
|
41
|
+
token?: string;
|
|
42
|
+
nodeId?: string;
|
|
43
|
+
state?: unknown;
|
|
44
|
+
} | null;
|
|
45
|
+
if (
|
|
46
|
+
!message ||
|
|
47
|
+
message.source !== JSON_VIEWER_STATE_SOURCE ||
|
|
48
|
+
message.type !== 'snapshot' ||
|
|
49
|
+
typeof message.nodeId !== 'string'
|
|
50
|
+
)
|
|
51
|
+
return;
|
|
52
|
+
const snapshot = snapshots.get(message.nodeId);
|
|
53
|
+
const ownerIndex =
|
|
54
|
+
snapshot?.owners.findIndex((owner) => owner.token === message.token && owner.source === event.source) ?? -1;
|
|
55
|
+
if (!snapshot || ownerIndex < 0) return;
|
|
56
|
+
if (!message.state || typeof message.state !== 'object' || Array.isArray(message.state)) return;
|
|
57
|
+
const { ax: _ax, ...state } = message.state as Record<string, unknown>;
|
|
58
|
+
snapshot.state = state;
|
|
59
|
+
// Once the replacement emits, the retired document cannot overwrite it.
|
|
60
|
+
snapshot.owners = snapshot.owners.slice(ownerIndex);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const node = nodes.value.get(nodeId);
|
|
64
|
+
if (!node) return;
|
|
65
|
+
let snapshot = snapshots.get(nodeId);
|
|
66
|
+
if (!snapshot) {
|
|
67
|
+
snapshot = {
|
|
68
|
+
epoch: canvasInteractionEpoch.value,
|
|
69
|
+
version: node.data.specVersion,
|
|
70
|
+
documentUrl: node.data.url,
|
|
71
|
+
state: null,
|
|
72
|
+
owners: [],
|
|
73
|
+
};
|
|
74
|
+
snapshots.set(nodeId, snapshot);
|
|
75
|
+
}
|
|
76
|
+
snapshot.owners = [...snapshot.owners.filter((owner) => owner.source !== source).slice(-1), { token, source }];
|
|
77
|
+
source.postMessage({ source: JSON_VIEWER_STATE_SOURCE, type: 'restore', token, state: snapshot.state }, '*');
|
|
78
|
+
}
|
|
@@ -58,6 +58,8 @@ const EXCALIDRAW_CLOSE_MAX_WAIT_MS = 2500;
|
|
|
58
58
|
|
|
59
59
|
// ── Pending edge connection (for context menu "Connect from") ─
|
|
60
60
|
export const pendingConnection = signal<{ from: string } | null>(null);
|
|
61
|
+
/** Invalidates in-flight interaction replies when board navigation resets the canvas. */
|
|
62
|
+
export const canvasInteractionEpoch = signal(0);
|
|
61
63
|
|
|
62
64
|
// ── Drag-to-connect (live edge preview) ─────────────────────
|
|
63
65
|
export const draggingEdge = signal<{
|
|
@@ -151,6 +153,7 @@ export function resetCanvasInteractionState(): void {
|
|
|
151
153
|
expandedCloseTimer = null;
|
|
152
154
|
}
|
|
153
155
|
batch(() => {
|
|
156
|
+
canvasInteractionEpoch.value += 1;
|
|
154
157
|
activeNodeId.value = null;
|
|
155
158
|
expandedNodeId.value = null;
|
|
156
159
|
pendingExpandedNodeCloseId.value = null;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { canvasArea } from '../canvas/canvas-area';
|
|
2
2
|
import { showToast } from './attention-bridge';
|
|
3
3
|
import type { AgentPresenceSnapshot } from '../../shared/agent-presence.js';
|
|
4
|
+
import type { CanvasEdge } from '../types';
|
|
5
|
+
import { addEdge, canvasInteractionEpoch, nodes } from './canvas-store';
|
|
4
6
|
function logRequestError(action: string, error: unknown): void {
|
|
5
7
|
console.error(`[intent-bridge] ${action} failed`, error);
|
|
6
8
|
}
|
|
@@ -261,14 +263,15 @@ export async function pushCanvasUpdate(
|
|
|
261
263
|
});
|
|
262
264
|
}
|
|
263
265
|
|
|
264
|
-
/**
|
|
266
|
+
/** Render the server-confirmed edge immediately, without waiting for SSE/polling. */
|
|
265
267
|
export async function createEdgeFromClient(
|
|
266
268
|
from: string,
|
|
267
269
|
to: string,
|
|
268
270
|
type: string,
|
|
269
271
|
label?: string,
|
|
270
272
|
): Promise<{ ok: boolean; id?: string }> {
|
|
271
|
-
|
|
273
|
+
const epoch = canvasInteractionEpoch.value;
|
|
274
|
+
const result = await requestJson<({ ok: true } & CanvasEdge) | { ok: false; error?: string }>(
|
|
272
275
|
'createEdgeFromClient',
|
|
273
276
|
'/api/canvas/edge',
|
|
274
277
|
{ ok: false },
|
|
@@ -278,6 +281,15 @@ export async function createEdgeFromClient(
|
|
|
278
281
|
body: JSON.stringify({ from, to, type, label }),
|
|
279
282
|
},
|
|
280
283
|
);
|
|
284
|
+
if (epoch === canvasInteractionEpoch.value && nodes.value.has(from) && nodes.value.has(to)) {
|
|
285
|
+
if (result.ok) {
|
|
286
|
+
const { ok, ...edge } = result;
|
|
287
|
+
addEdge(edge);
|
|
288
|
+
} else if (!result.error) {
|
|
289
|
+
showToast('remove', 'Connection not added', 'Could not reach the server. Try again.');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return result;
|
|
281
293
|
}
|
|
282
294
|
|
|
283
295
|
/** Create a canvas node via the server. Returns the new node ID. */
|