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.
Files changed (36) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/Readme.md +25 -11
  3. package/dist/canvas/global.css +123 -40
  4. package/dist/canvas/index.js +84 -84
  5. package/dist/json-render/index.js +55 -55
  6. package/dist/types/client/canvas/ChoiceMenu.d.ts +13 -0
  7. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  8. package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -0
  9. package/dist/types/client/state/canvas-store.d.ts +2 -0
  10. package/dist/types/client/state/intent-bridge.d.ts +1 -1
  11. package/dist/types/json-render/renderer/index.d.ts +2 -1
  12. package/dist/types/json-render/server.d.ts +2 -0
  13. package/dist/types/shared/ax-surface-protocol.d.ts +2 -0
  14. package/docs/ax-host-adapter-contract.md +13 -2
  15. package/package.json +1 -1
  16. package/skills/pmx-canvas/SKILL.md +23 -11
  17. package/skills/pmx-canvas/evals/evals.json +69 -0
  18. package/skills/pmx-canvas/references/full-reference.md +3 -3
  19. package/skills/pmx-canvas/references/installing-pmx-canvas.md +76 -4
  20. package/skills/pmx-canvas-testing/SKILL.md +49 -0
  21. package/src/client/canvas/CanvasNode.tsx +200 -195
  22. package/src/client/canvas/CanvasViewport.tsx +6 -0
  23. package/src/client/canvas/ChoiceMenu.tsx +122 -0
  24. package/src/client/canvas/CommandBar.tsx +25 -23
  25. package/src/client/canvas/FeedbackDialog.tsx +10 -8
  26. package/src/client/canvas/ToolRail.tsx +46 -99
  27. package/src/client/nodes/McpAppNode.tsx +16 -0
  28. package/src/client/nodes/use-ax-surface-bridge.ts +4 -2
  29. package/src/client/nodes/viewer-runtime-state.ts +78 -0
  30. package/src/client/state/canvas-store.ts +3 -0
  31. package/src/client/state/intent-bridge.ts +14 -2
  32. package/src/client/theme/global.css +123 -40
  33. package/src/json-render/renderer/index.tsx +58 -38
  34. package/src/json-render/server.ts +8 -1
  35. package/src/server/server.ts +3 -1
  36. 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
- <label>
44
- Feedback type
45
- <select class="text-prompt-input" value={kind} onChange={(e) => setKind(e.currentTarget.value)}>
46
- <option>Bug report</option>
47
- <option>Feature request</option>
48
- <option>General feedback</option>
49
- </select>
50
- </label>
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' | 'annotate'>(null);
181
- const [menuAnchor, setMenuAnchor] = useState<{ top: number; bottom: number; right: number } | null>(null);
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 documentInput = useRef<HTMLInputElement>(null);
180
+ const fileInput = useRef<HTMLInputElement>(null);
184
181
 
185
- const toggleMenu = (menu: 'theme' | 'annotate') => (e: MouseEvent) => {
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({ top: rect.top, bottom: rect.bottom, right: rect.right });
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 annotate menu top-aligns with its trigger, the
198
- // theme menu (bottom cluster) bottom-aligns so it grows upward.
199
- const sideMenuStyle = (alignBottom: boolean) =>
200
- menuAnchor
201
- ? alignBottom
202
- ? {
203
- position: 'fixed' as const,
204
- left: `${menuAnchor.right + 8}px`,
205
- bottom: `${window.innerHeight - menuAnchor.bottom}px`,
206
- top: 'auto',
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 label="Image" shortcut="I" onClick={() => promptedCreate('image')}>
335
- <IconNodeImage size={15} />
336
- </RailButton>
337
- <RailButton label="File" shortcut="Shift+F" onClick={() => promptedCreate('file')}>
338
- <IconNodeFile size={15} />
339
- </RailButton>
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={documentInput}
336
+ ref={fileInput}
345
337
  type="file"
346
338
  hidden
347
339
  multiple
348
- accept=".pdf,.ppt,.pptx,.xls,.xlsx,.doc,.docx,.odt,.ods,.odp"
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
- <span class="toolbar-menu-anchor">
387
- <RailButton
388
- label="Annotate"
389
- shortcut="A"
390
- detail="Draw · Text note · Eraser"
391
- active={annotationTool !== null}
392
- menuOpen={openMenu === 'annotate'}
393
- onClick={toggleMenu('annotate')}
394
- >
395
- {annotationTool === 'eraser' ? (
396
- <IconEraser />
397
- ) : annotationTool === 'text' ? (
398
- <IconTextAnnotation />
399
- ) : (
400
- <IconPen />
401
- )}
402
- </RailButton>
403
- {openMenu === 'annotate' && (
404
- <div class="toolbar-menu" style={sideMenuStyle(false)} role="menu" aria-label="Annotate">
405
- <button
406
- type="button"
407
- class={`toolbar-menu-item${annotationTool === 'pen' ? ' active' : ''}`}
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(true)} role="menu" aria-label="Theme">
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 { useEffect } from 'preact/hooks';
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
- useEffect(() => {
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
- /** Create a canvas edge via the server. */
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
- return requestJson(
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. */