pmx-canvas 0.1.30 → 0.1.32

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 (43) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/dist/canvas/global.css +56 -59
  3. package/dist/canvas/index.js +59 -59
  4. package/dist/json-render/index.js +97 -97
  5. package/dist/types/client/nodes/surface-url.d.ts +7 -0
  6. package/dist/types/client/state/canvas-store.d.ts +1 -0
  7. package/dist/types/client/state/intent-bridge.d.ts +7 -0
  8. package/dist/types/json-render/renderer/index.d.ts +1 -0
  9. package/dist/types/json-render/server.d.ts +1 -0
  10. package/dist/types/server/ax-context.d.ts +24 -1
  11. package/dist/types/server/canvas-state.d.ts +7 -0
  12. package/dist/types/server/html-surface.d.ts +29 -0
  13. package/dist/types/server/index.d.ts +19 -3
  14. package/dist/types/server/server.d.ts +12 -0
  15. package/docs/sdk.md +3 -1
  16. package/package.json +1 -1
  17. package/skills/pmx-canvas/SKILL.md +96 -1
  18. package/src/cli/agent.ts +18 -1
  19. package/src/cli/index.ts +8 -1
  20. package/src/client/App.tsx +3 -3
  21. package/src/client/canvas/CanvasNode.tsx +16 -1
  22. package/src/client/canvas/DockedNode.tsx +38 -38
  23. package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -1
  24. package/src/client/nodes/ContextNode.tsx +1 -1
  25. package/src/client/nodes/HtmlNode.tsx +26 -1
  26. package/src/client/nodes/McpAppNode.tsx +35 -8
  27. package/src/client/nodes/StatusNode.tsx +0 -20
  28. package/src/client/nodes/surface-url.ts +12 -0
  29. package/src/client/state/canvas-store.ts +4 -0
  30. package/src/client/state/intent-bridge.ts +19 -0
  31. package/src/client/state/sse-bridge.ts +17 -0
  32. package/src/client/theme/global.css +56 -59
  33. package/src/json-render/renderer/index.tsx +31 -2
  34. package/src/json-render/server.ts +3 -0
  35. package/src/mcp/canvas-access.ts +6 -1
  36. package/src/mcp/server.ts +23 -1
  37. package/src/server/ax-context.ts +49 -1
  38. package/src/server/ax-interaction.ts +3 -0
  39. package/src/server/ax-state.ts +3 -1
  40. package/src/server/canvas-state.ts +30 -11
  41. package/src/server/html-surface.ts +70 -13
  42. package/src/server/index.ts +32 -7
  43. package/src/server/server.ts +117 -4
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useRef } from 'preact/hooks';
2
- import { canvasTheme } from '../state/canvas-store';
2
+ import { axSurfaceState, canvasTheme } from '../state/canvas-store';
3
3
  import { submitAxInteractionFromClient } from '../state/intent-bridge';
4
4
  import { showToast } from '../state/attention-bridge';
5
5
  import type { CanvasNodeState } from '../types';
@@ -82,6 +82,23 @@ export function HtmlNode({
82
82
  if (autoFocus) iframeRef.current?.focus();
83
83
  }, [theme, themeToken]);
84
84
 
85
+ // Read-side AX bridge: push live AX state into the surface so an AX-enabled
86
+ // board reflects the work queue / focus. Validated by the surface against axToken.
87
+ // Gate matches the server's bridge-injection gate (enabled && allowed not empty)
88
+ // so we never push state to a surface the server left without the bridge.
89
+ const axCaps = node.data.axCapabilities as { enabled?: boolean; allowed?: unknown } | undefined;
90
+ const axEnabled = axCaps?.enabled === true && (!Array.isArray(axCaps.allowed) || axCaps.allowed.length > 0);
91
+ const axStateValue = axSurfaceState.value;
92
+ useEffect(() => {
93
+ if (!axEnabled || axStateValue == null) return;
94
+ iframeRef.current?.contentWindow?.postMessage({
95
+ source: 'pmx-canvas-html-node',
96
+ type: 'ax-update',
97
+ token: axToken,
98
+ state: axStateValue,
99
+ }, '*');
100
+ }, [axEnabled, axStateValue, axToken]);
101
+
85
102
  useEffect(() => {
86
103
  if (!autoFocus) return;
87
104
  const id = window.setTimeout(() => iframeRef.current?.focus(), 0);
@@ -95,6 +112,14 @@ export function HtmlNode({
95
112
  token: themeToken,
96
113
  theme,
97
114
  }, '*');
115
+ if (axEnabled && axSurfaceState.value != null) {
116
+ iframeRef.current?.contentWindow?.postMessage({
117
+ source: 'pmx-canvas-html-node',
118
+ type: 'ax-update',
119
+ token: axToken,
120
+ state: axSurfaceState.value,
121
+ }, '*');
122
+ }
98
123
  if (autoFocus) iframeRef.current?.focus();
99
124
  };
100
125
 
@@ -1,11 +1,11 @@
1
1
  import { useEffect, useMemo, useRef } from 'preact/hooks';
2
2
  import type { CanvasNodeState } from '../types';
3
- import { canvasTheme } from '../state/canvas-store';
3
+ import { axSurfaceState, canvasTheme } from '../state/canvas-store';
4
4
  import { submitAxInteractionFromClient } from '../state/intent-bridge';
5
5
  import { showToast } from '../state/attention-bridge';
6
6
  import { ExtAppFrame } from './ExtAppFrame';
7
7
 
8
- function withViewerParams(url: string, expanded: boolean, specVersion?: number, axToken?: string): string {
8
+ function withViewerParams(url: string, expanded: boolean, specVersion?: number, axToken?: string, axNodeId?: string): string {
9
9
  if (!url) return url;
10
10
  try {
11
11
  const resolved = new URL(url, window.location.origin);
@@ -14,8 +14,11 @@ function withViewerParams(url: string, expanded: boolean, specVersion?: number,
14
14
  // Streaming json-render nodes bump specVersion as patches accumulate; including
15
15
  // it in the src reloads the iframe so it re-reads the latest accumulated spec.
16
16
  if (typeof specVersion === 'number') resolved.searchParams.set('v', String(specVersion));
17
- // AX bridge nonce for json-render/graph viewer nodes (Phase 6 follow-up).
17
+ // AX bridge nonce for json-render/graph + web-artifact viewer nodes.
18
18
  if (axToken) resolved.searchParams.set('axToken', axToken);
19
+ // The /artifact route needs the node id to inject the AX bridge (the json-render
20
+ // view route already gets nodeId from its own query param).
21
+ if (axNodeId) resolved.searchParams.set('axNodeId', axNodeId);
19
22
  return resolved.toString();
20
23
  } catch {
21
24
  return url;
@@ -43,9 +46,18 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
43
46
 
44
47
  function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boolean }) {
45
48
  const iframeRef = useRef<HTMLIFrameElement>(null);
46
- // json-render / graph viewers run the json-render bundle, which can forward
47
- // spec actions named ax.* to us. Other viewers (web-artifact, hosted URLs) do not.
48
- const isAxViewer = node.type === 'json-render' || node.type === 'graph';
49
+ // json-render / graph viewers run the json-render bundle, which forwards spec
50
+ // actions named ax.* to us. AX-enabled web-artifacts get the same emit+read
51
+ // bridge injected at the /artifact route. Hosted URL viewers do not.
52
+ const isWebArtifact = node.type === 'mcp-app' && node.data.viewerType === 'web-artifact';
53
+ const isJsonViewer = node.type === 'json-render' || node.type === 'graph';
54
+ const axFlag = (node.data.axCapabilities as { enabled?: boolean } | undefined)?.enabled;
55
+ // json-render/graph are AX-enabled by default (opt OUT with enabled:false, matching
56
+ // the server seed gate); web-artifacts opt IN. So an opted-out viewer is not treated
57
+ // as an AX viewer — no token, no emit, no read-state push.
58
+ const axOn = isWebArtifact ? axFlag === true : axFlag !== false;
59
+ const isAxViewer = (isJsonViewer || isWebArtifact) && axOn;
60
+ const axSurface: 'json-render' | 'mcp-app' = isWebArtifact ? 'mcp-app' : 'json-render';
49
61
  const axToken = useMemo(() => (isAxViewer ? `ax-${crypto.randomUUID()}` : ''), [isAxViewer]);
50
62
 
51
63
  // Receive AX emits forwarded by the json-render viewer; validate (bound to this
@@ -65,7 +77,7 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
65
77
  void submitAxInteractionFromClient({
66
78
  type: interaction.type,
67
79
  sourceNodeId: node.id,
68
- sourceSurface: 'json-render',
80
+ sourceSurface: axSurface,
69
81
  ...(interaction.payload && typeof interaction.payload === 'object'
70
82
  ? { payload: interaction.payload as Record<string, unknown> }
71
83
  : {}),
@@ -78,8 +90,22 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
78
90
  return () => window.removeEventListener('message', onAxMessage);
79
91
  }, [isAxViewer, axToken, node.id]);
80
92
 
93
+ // Read-side: push live AX state into the json-render viewer so a spec bound to
94
+ // /ax reflects the work queue. Validated by the viewer against axToken.
95
+ const axStateValue = axSurfaceState.value;
96
+ const pushAxState = () => {
97
+ if (!isAxViewer || !axToken || axStateValue == null) return;
98
+ iframeRef.current?.contentWindow?.postMessage({
99
+ source: 'pmx-canvas-html-node',
100
+ type: 'ax-update',
101
+ token: axToken,
102
+ state: axStateValue,
103
+ }, '*');
104
+ };
105
+ useEffect(pushAxState, [isAxViewer, axToken, axStateValue]);
106
+
81
107
  const specVersion = typeof node.data.specVersion === 'number' ? node.data.specVersion : undefined;
82
- const url = withViewerParams((node.data.url as string) || '', expanded, specVersion, axToken || undefined);
108
+ const url = withViewerParams((node.data.url as string) || '', expanded, specVersion, axToken || undefined, isAxViewer ? node.id : undefined);
83
109
  const sourceServer = (node.data.sourceServer as string) || '';
84
110
  const hostMode = (node.data.hostMode as string) || 'hosted';
85
111
  const fallbackReason = node.data.fallbackReason as string | undefined;
@@ -146,6 +172,7 @@ function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boo
146
172
  sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
147
173
  allow="clipboard-read; clipboard-write"
148
174
  loading="lazy"
175
+ onLoad={pushAxState}
149
176
  style={{ flex: 1, minHeight: 0, width: '100%' }}
150
177
  title={`MCP App: ${sourceServer}`}
151
178
  />
@@ -1,6 +1,5 @@
1
1
  import { PHASE_COLORS } from '../theme/tokens';
2
2
  import type { CanvasNodeState } from '../types';
3
- import { axNodeActionButtonStyle, runNodeAxInteraction } from './ax-node-actions';
4
3
 
5
4
  export function getStatusDisplayPhase(node: CanvasNodeState): string {
6
5
  const phase = typeof node.data.phase === 'string' && node.data.phase.trim().length > 0
@@ -95,25 +94,6 @@ export function StatusNode({ node }: { node: CanvasNodeState }) {
95
94
  {message}
96
95
  </div>
97
96
  )}
98
-
99
- {/* AX: turn this status into a tracked work item */}
100
- <button
101
- type="button"
102
- class="ax-node-action"
103
- title="Create an AX work item tied to this node"
104
- style={axNodeActionButtonStyle}
105
- onClick={(e) => {
106
- e.stopPropagation();
107
- void runNodeAxInteraction(
108
- node,
109
- 'ax.work.create',
110
- { title: (node.data.title as string) || message || phase || 'Status update' },
111
- 'Tracked as work',
112
- );
113
- }}
114
- >
115
- Track as work
116
- </button>
117
97
  </div>
118
98
  );
119
99
  }
@@ -1,4 +1,5 @@
1
1
  import { canvasTheme } from '../state/canvas-store';
2
+ import { openNodeInSystemBrowserRequest } from '../state/intent-bridge';
2
3
  import { canOpenNodeAsSurface } from '../../shared/surface.js';
3
4
  import type { CanvasNodeState } from '../types';
4
5
 
@@ -46,3 +47,14 @@ export function canOpenAsSite(node: CanvasNodeState): boolean {
46
47
  export function openNodeAsSite(node: CanvasNodeState): void {
47
48
  window.open(nodeSurfaceUrl(node.id), '_blank', 'noopener');
48
49
  }
50
+
51
+ /**
52
+ * Open the node's surface in the user's real SYSTEM browser via the server's OS
53
+ * launcher — for hosts (e.g. Codex) whose embedded browser makes a normal
54
+ * `_blank` tab feel in-place. Falls back to a normal new-tab open when the server
55
+ * can't launch (headless / PMX_CANVAS_DISABLE_BROWSER_OPEN).
56
+ */
57
+ export async function openNodeInSystemBrowser(node: CanvasNodeState): Promise<void> {
58
+ const res = await openNodeInSystemBrowserRequest(node.id);
59
+ if (!res.opened) openNodeAsSite(node);
60
+ }
@@ -18,6 +18,10 @@ export const sessionId = signal<string>('');
18
18
  export const traceEnabled = signal<boolean>(false);
19
19
  export const canvasTheme = signal<string>('dark');
20
20
  export const hasInitialServerLayout = signal<boolean>(false);
21
+ // Compact AX state snapshot (work items, focus, …) mirrored from the server and
22
+ // pushed into AX-enabled surfaces so authored boards can render the live queue.
23
+ // Refreshed by the SSE bridge on ax-state-changed / ax-event-created.
24
+ export const axSurfaceState = signal<unknown>(null);
21
25
 
22
26
  // ── Expanded (focus) node ─────────────────────────────────────
23
27
  // Only one node at a time can be in expanded/focus mode. When expanded, the
@@ -255,6 +255,25 @@ export interface AxInteractionResponse {
255
255
  error?: string;
256
256
  }
257
257
 
258
+ /** Fetch the compact AX state snapshot pushed into AX-enabled surfaces. */
259
+ export async function fetchAxSurfaceState(): Promise<unknown> {
260
+ return requestJson<unknown>('fetchAxSurfaceState', '/api/canvas/ax/surface-snapshot', null);
261
+ }
262
+
263
+ /** Ask the server to open a node's surface in the system browser. */
264
+ export async function openNodeInSystemBrowserRequest(nodeId: string): Promise<{ ok: boolean; opened: boolean }> {
265
+ return requestJson<{ ok: boolean; opened: boolean }>(
266
+ 'openNodeInSystemBrowserRequest',
267
+ '/api/canvas/open-external',
268
+ { ok: false, opened: false },
269
+ {
270
+ method: 'POST',
271
+ headers: { 'Content-Type': 'application/json' },
272
+ body: JSON.stringify({ nodeId }),
273
+ },
274
+ );
275
+ }
276
+
258
277
  /** Submit a capability-gated AX interaction from a native node control. */
259
278
  export async function submitAxInteractionFromClient(input: AxInteractionRequest): Promise<AxInteractionResponse> {
260
279
  return requestJson<AxInteractionResponse>(
@@ -6,6 +6,7 @@ import {
6
6
  addEdge,
7
7
  addNode,
8
8
  applyServerCanvasLayout,
9
+ axSurfaceState,
9
10
  bringToFront,
10
11
  cancelViewportAnimation,
11
12
  canvasTheme,
@@ -24,6 +25,7 @@ import {
24
25
  updateNode,
25
26
  updateNodeData,
26
27
  } from './canvas-store';
28
+ import { fetchAxSurfaceState } from './intent-bridge';
27
29
  import { invalidateTokenCache } from '../theme/tokens';
28
30
  import { resetAttentionBridge, syncAttentionFromSse } from './attention-bridge';
29
31
 
@@ -926,6 +928,19 @@ function handleContextPinsChanged(data: Record<string, unknown>): void {
926
928
  syncAttentionFromSse({ event: 'context-pins-changed', data });
927
929
  }
928
930
 
931
+ // AX state changes arrive as per-primitive deltas; rather than reduce them, treat
932
+ // the event as a "something changed" signal and re-fetch the full compact snapshot
933
+ // (debounced). The snapshot feeds AX-enabled surfaces (HtmlNode/McpAppNode push it
934
+ // into their iframes), so authored boards reflect the live work queue / focus.
935
+ let axRefreshTimer: ReturnType<typeof setTimeout> | null = null;
936
+ function handleAxStateChanged(): void {
937
+ if (axRefreshTimer) clearTimeout(axRefreshTimer);
938
+ axRefreshTimer = setTimeout(() => {
939
+ axRefreshTimer = null;
940
+ void fetchAxSurfaceState().then((state) => { axSurfaceState.value = state; });
941
+ }, 150);
942
+ }
943
+
929
944
  // ── SSE connection ────────────────────────────────────────────
930
945
  /** @internal — exported for testing */
931
946
  export const EVENT_HANDLERS: Record<string, (data: Record<string, unknown>) => void> = {
@@ -959,6 +974,8 @@ export const EVENT_HANDLERS: Record<string, (data: Record<string, unknown>) => v
959
974
  'canvas-response-start': handleCanvasResponseStart,
960
975
  'canvas-response-delta': handleCanvasResponseDelta,
961
976
  'canvas-response-complete': handleCanvasResponseComplete,
977
+ 'ax-state-changed': handleAxStateChanged,
978
+ 'ax-event-created': handleAxStateChanged,
962
979
  };
963
980
 
964
981
  export function connectSSE(): () => void {
@@ -56,6 +56,10 @@
56
56
  --mono: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;
57
57
  --radius: 10px;
58
58
  --radius-sm: 6px;
59
+ /* Shared height for the top HUD row so the toolbar and the collapsed docked
60
+ status/context widgets that flank it line up to the same height. Matches the
61
+ toolbar's natural content height (icon buttons at 6px padding). */
62
+ --hud-bar-height: 44px;
59
63
  }
60
64
 
61
65
  :root[data-theme="light"] {
@@ -453,13 +457,15 @@ body,
453
457
  font-weight: 600;
454
458
  }
455
459
 
456
- /* HUD layer — fixed row: [left-dock] [toolbar] [right-dock] */
460
+ /* HUD layer — [left-dock] [toolbar] [right-dock]. Wraps onto multiple rows in a
461
+ narrow embedding panel (e.g. the Copilot side panel) instead of clipping. */
457
462
  .hud-layer {
458
463
  position: fixed;
459
464
  top: 12px;
460
465
  left: 12px;
461
466
  right: 12px;
462
467
  display: flex;
468
+ flex-wrap: wrap;
463
469
  align-items: flex-start;
464
470
  justify-content: center;
465
471
  gap: 8px;
@@ -472,20 +478,24 @@ body,
472
478
  .hud-left,
473
479
  .hud-right {
474
480
  display: flex;
481
+ flex-wrap: wrap;
475
482
  gap: 8px;
476
483
  }
477
484
 
478
485
  /* Toolbar */
479
486
  .canvas-toolbar {
480
487
  display: flex;
488
+ flex-wrap: wrap;
481
489
  align-items: center;
482
490
  gap: 6px;
483
491
  padding: 6px 10px;
492
+ min-height: var(--hud-bar-height);
493
+ max-width: 100%;
494
+ box-sizing: border-box;
484
495
  background: var(--c-panel-glass);
485
496
  backdrop-filter: blur(12px);
486
497
  border: 1px solid var(--c-line);
487
498
  border-radius: var(--radius);
488
- flex-shrink: 0;
489
499
  }
490
500
 
491
501
  .toolbar-tooltip-anchor {
@@ -660,9 +670,11 @@ body,
660
670
 
661
671
  .toolbar-group {
662
672
  display: flex;
673
+ flex-wrap: wrap;
663
674
  align-items: center;
664
675
  gap: 6px;
665
- flex-shrink: 0;
676
+ min-width: 0;
677
+ max-width: 100%;
666
678
  }
667
679
 
668
680
  .canvas-toolbar button svg {
@@ -686,6 +698,15 @@ body,
686
698
  }
687
699
  }
688
700
 
701
+ /* Narrow embedding panels: drop low-value text from the HUD so the icon controls
702
+ fit in fewer rows. Buttons keep their aria-labels + tooltips, so nothing is
703
+ lost for a11y or discovery. */
704
+ @media (max-width: 720px) {
705
+ .hud-collapsible-text {
706
+ display: none;
707
+ }
708
+ }
709
+
689
710
  /* Raw markdown source editor */
690
711
  .md-editor-split {
691
712
  display: flex;
@@ -1409,6 +1430,38 @@ html.is-node-resizing .ext-app-preview-catcher {
1409
1430
  max-width: 320px;
1410
1431
  }
1411
1432
 
1433
+ /* Collapsed docked widget = a single menu-height pill that flanks the toolbar.
1434
+ Pinned to the same height as .canvas-toolbar so the top HUD row reads as one
1435
+ continuous bar (status on the left, context on the right). */
1436
+ .docked-node--collapsed {
1437
+ height: var(--hud-bar-height);
1438
+ box-sizing: border-box;
1439
+ justify-content: center;
1440
+ width: auto;
1441
+ /* Reset the base .docked-node min-width so the collapsed pill hugs its content
1442
+ (badge + count + controls) instead of stretching to a 200px bar. */
1443
+ min-width: 0;
1444
+ }
1445
+ .docked-node--collapsed .docked-node-header {
1446
+ height: 100%;
1447
+ padding: 0 10px;
1448
+ border-bottom: none;
1449
+ }
1450
+ .docked-node-count {
1451
+ min-width: 18px;
1452
+ height: 18px;
1453
+ padding: 0 5px;
1454
+ display: inline-flex;
1455
+ align-items: center;
1456
+ justify-content: center;
1457
+ border-radius: 9px;
1458
+ background: var(--c-accent);
1459
+ color: var(--c-contrast-fg);
1460
+ font-size: 10px;
1461
+ font-weight: 700;
1462
+ flex-shrink: 0;
1463
+ }
1464
+
1412
1465
  .docked-node-header {
1413
1466
  display: flex;
1414
1467
  align-items: center;
@@ -1948,62 +2001,6 @@ html.is-node-resizing .ext-app-preview-catcher {
1948
2001
  max-width: 200px;
1949
2002
  }
1950
2003
 
1951
- /* Context dock — collapsed pill mirrors Updates pill, sits above it */
1952
- .context-dock-tab {
1953
- position: fixed;
1954
- top: 92px;
1955
- right: 0;
1956
- display: flex;
1957
- align-items: center;
1958
- gap: 8px;
1959
- padding: 8px 12px 8px 14px;
1960
- background: color-mix(in srgb, var(--c-panel-glass) 96%, transparent);
1961
- backdrop-filter: blur(16px);
1962
- border: 1px solid color-mix(in srgb, var(--c-line) 82%, var(--c-accent) 18%);
1963
- border-right: 0;
1964
- border-radius: 14px 0 0 14px;
1965
- box-shadow: 0 12px 36px var(--c-shadow);
1966
- color: var(--c-text);
1967
- cursor: pointer;
1968
- font: inherit;
1969
- font-size: 11px;
1970
- font-weight: 600;
1971
- letter-spacing: 0.08em;
1972
- text-transform: uppercase;
1973
- z-index: 60;
1974
- }
1975
-
1976
- .context-dock-tab:hover {
1977
- border-color: color-mix(in srgb, var(--c-accent) 40%, var(--c-line) 60%);
1978
- color: var(--c-accent);
1979
- }
1980
-
1981
- .context-dock-tab svg {
1982
- display: block;
1983
- color: var(--c-accent);
1984
- flex-shrink: 0;
1985
- }
1986
-
1987
- .context-dock-tab-label {
1988
- white-space: nowrap;
1989
- }
1990
-
1991
- .context-dock-tab-badge {
1992
- min-width: 18px;
1993
- height: 18px;
1994
- padding: 0 5px;
1995
- display: inline-flex;
1996
- align-items: center;
1997
- justify-content: center;
1998
- border-radius: 9px;
1999
- background: var(--c-accent);
2000
- color: var(--c-contrast-fg);
2001
- font-size: 10px;
2002
- font-weight: 700;
2003
- letter-spacing: 0;
2004
- text-transform: none;
2005
- }
2006
-
2007
2004
  /* Context dock — expanded panel anchored top-right edge.
2008
2005
  Mutually exclusive with the Updates panel (see DockedNode.tsx and
2009
2006
  AttentionHistory.tsx) — opening one collapses the other, so they can both
@@ -8,8 +8,9 @@
8
8
  */
9
9
 
10
10
  import type { Spec } from '@json-render/core';
11
+ import { useEffect } from 'react';
11
12
  import { createRoot } from 'react-dom/client';
12
- import { defineRegistry, JSONUIProvider, Renderer } from '@json-render/react';
13
+ import { defineRegistry, JSONUIProvider, Renderer, useStateBinding } from '@json-render/react';
13
14
  import { shadcnComponents } from '@json-render/shadcn';
14
15
  import { catalog } from '../catalog';
15
16
  import { chartComponents } from '../charts/components';
@@ -81,9 +82,30 @@ declare global {
81
82
  __PMX_CANVAS_JSON_RENDER_DEVTOOLS__?: boolean;
82
83
  __PMX_CANVAS_JSON_RENDER_NODE_ID__?: string;
83
84
  __PMX_CANVAS_AX_TOKEN__?: string;
85
+ __PMX_CANVAS_AX_STATE__?: unknown;
84
86
  }
85
87
  }
86
88
 
89
+ // Read-side AX bridge for json-render: keeps the spec-bound `/ax` state live as
90
+ // the parent canvas pushes nonce-validated `ax-update` messages, so a declarative
91
+ // board ({ "$state": "/ax/workItems" }) reflects the work queue in real time.
92
+ function AxStateSync() {
93
+ const [, setAx] = useStateBinding<unknown>('ax');
94
+ useEffect(() => {
95
+ const token = window.__PMX_CANVAS_AX_TOKEN__;
96
+ if (!token) return undefined;
97
+ function onMessage(event: MessageEvent) {
98
+ const m = event.data as { source?: string; type?: string; token?: string; state?: unknown } | null;
99
+ if (!m || m.source !== 'pmx-canvas-html-node' || m.type !== 'ax-update' || m.token !== token) return;
100
+ window.__PMX_CANVAS_AX_STATE__ = m.state;
101
+ setAx(m.state);
102
+ }
103
+ window.addEventListener('message', onMessage);
104
+ return () => window.removeEventListener('message', onMessage);
105
+ }, [setAx]);
106
+ return null;
107
+ }
108
+
87
109
  // AX interaction types a json-render spec can bind actions to. When an action
88
110
  // named like one of these fires, we forward it to the parent canvas (which
89
111
  // validates + submits through the capability-gated endpoint). Convention-based
@@ -149,14 +171,21 @@ function App() {
149
171
  );
150
172
  }
151
173
 
174
+ // Seed AX state under a reserved `/ax` key so specs can bind { "$state": "/ax/workItems" }.
175
+ const axState = window.__PMX_CANVAS_AX_STATE__;
176
+ const initialState = axState !== undefined && axState !== null
177
+ ? { ...(spec.state ?? {}), ax: axState }
178
+ : spec.state ?? undefined;
179
+
152
180
  return (
153
181
  <div style={{ minHeight: '100vh', padding: 16, boxSizing: 'border-box' }}>
154
182
  <JSONUIProvider
155
183
  registry={registry}
156
- initialState={spec.state ?? undefined}
184
+ initialState={initialState}
157
185
  directives={pmxCanvasDirectives}
158
186
  handlers={buildAxHandlers()}
159
187
  >
188
+ <AxStateSync />
160
189
  <Renderer spec={spec} registry={registry} loading={false} />
161
190
  {window.__PMX_CANVAS_JSON_RENDER_DEVTOOLS__ ? (
162
191
  <JsonRenderDevtools position="right" />
@@ -943,6 +943,7 @@ export async function buildJsonRenderViewerHtml(options: {
943
943
  devtools?: boolean;
944
944
  nodeId?: string;
945
945
  axToken?: string;
946
+ axState?: unknown;
946
947
  }): Promise<string> {
947
948
  const sanitizeAxValue = (v?: string): string => (typeof v === 'string' ? v.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80) : '');
948
949
  try {
@@ -962,6 +963,8 @@ export async function buildJsonRenderViewerHtml(options: {
962
963
  ...(options.nodeId && options.axToken ? [
963
964
  `window.__PMX_CANVAS_JSON_RENDER_NODE_ID__ = ${JSON.stringify(sanitizeAxValue(options.nodeId))};`,
964
965
  `window.__PMX_CANVAS_AX_TOKEN__ = ${JSON.stringify(sanitizeAxValue(options.axToken))};`,
966
+ // Read-side AX state: seed for initial render + bound under /ax for specs.
967
+ `window.__PMX_CANVAS_AX_STATE__ = ${JSON.stringify(options.axState ?? null).replace(/</g, '\\u003c')};`,
965
968
  ] : []),
966
969
  jsBundle,
967
970
  ].join('\n');
@@ -271,7 +271,9 @@ class LocalCanvasAccess implements CanvasAccess {
271
271
  }
272
272
 
273
273
  async addHtmlNode(input: AddHtmlNodeInput): Promise<string> {
274
- return this.canvas.addHtmlNode(input);
274
+ // PmxCanvas.addHtmlNode returns the created node; the CanvasAccess contract
275
+ // is a bare id string, so extract it (mirrors addNode above).
276
+ return this.canvas.addHtmlNode(input).id;
275
277
  }
276
278
 
277
279
  async addHtmlPrimitive(input: AddHtmlPrimitiveInput): Promise<AddHtmlPrimitiveResult> {
@@ -683,6 +685,7 @@ class RemoteCanvasAccess implements CanvasAccess {
683
685
  slideTitles,
684
686
  embeddedNodeIds,
685
687
  embeddedUrls,
688
+ axCapabilities,
686
689
  ...rest
687
690
  } = input as AddHtmlNodeInput & {
688
691
  summary?: string;
@@ -692,6 +695,7 @@ class RemoteCanvasAccess implements CanvasAccess {
692
695
  slideTitles?: string[];
693
696
  embeddedNodeIds?: string[];
694
697
  embeddedUrls?: string[];
698
+ axCapabilities?: { enabled?: boolean; allowed?: string[] };
695
699
  };
696
700
  return await this.requestNodeId('POST', '/api/canvas/node', {
697
701
  type: 'html',
@@ -704,6 +708,7 @@ class RemoteCanvasAccess implements CanvasAccess {
704
708
  ...(Array.isArray(slideTitles) ? { slideTitles } : {}),
705
709
  ...(Array.isArray(embeddedNodeIds) ? { embeddedNodeIds } : {}),
706
710
  ...(Array.isArray(embeddedUrls) ? { embeddedUrls } : {}),
711
+ ...(axCapabilities ? { axCapabilities } : {}),
707
712
  },
708
713
  });
709
714
  }
package/src/mcp/server.ts CHANGED
@@ -447,6 +447,10 @@ export async function startMcpServer(): Promise<void> {
447
447
  width: z.number().optional().describe('Width in pixels (default: 720).'),
448
448
  height: z.number().optional().describe('Height in pixels (default: 640).'),
449
449
  strictSize: z.boolean().optional().describe('Keep explicit width/height fixed; iframe scrolls overflow internally.'),
450
+ axCapabilities: z.object({
451
+ enabled: z.boolean().optional(),
452
+ allowed: z.array(z.string()).optional().describe('AX interaction types this node may emit (e.g. ax.work.create, ax.work.update, ax.steer, ax.focus.set, ax.evidence.add, ax.event.record). Clamped to the html capability ceiling server-side; cannot escalate.'),
453
+ }).optional().describe('Opt this html node into AX interactions so its sandboxed UI can emit ax.* via window.PMX_AX.emit(type, payload) (and reflect live AX state). html nodes are AX-disabled by default; set { enabled: true, allowed: [...] } to turn the bridge on. Build interactive boards (work queues, review boards, inboxes) this way.'),
450
454
  full: z.boolean().optional().describe('Return the full created node payload. Default false returns compact metadata.'),
451
455
  verbose: z.boolean().optional().describe('Alias for full:true.'),
452
456
  },
@@ -455,6 +459,7 @@ export async function startMcpServer(): Promise<void> {
455
459
  const id = await c.addHtmlNode({
456
460
  html: input.html,
457
461
  ...(typeof input.title === 'string' ? { title: input.title } : {}),
462
+ ...(input.axCapabilities ? { axCapabilities: input.axCapabilities } : {}),
458
463
  ...(typeof input.summary === 'string' ? { summary: input.summary } : {}),
459
464
  ...(typeof input.agentSummary === 'string' ? { agentSummary: input.agentSummary } : {}),
460
465
  ...(typeof input.description === 'string' ? { description: input.description } : {}),
@@ -1079,11 +1084,15 @@ export async function startMcpServer(): Promise<void> {
1079
1084
  dockPosition: z.enum(['left', 'right']).nullable().optional().describe('Dock the node to the left/right HUD column, or pass null to return it to the canvas'),
1080
1085
  pinned: z.boolean().optional().describe('Pin or unpin the node to exclude it from auto-arrange'),
1081
1086
  arrangeLocked: z.boolean().optional().describe('Prevent auto-arrange from moving this node. Pinned nodes are also skipped.'),
1087
+ axCapabilities: z.object({
1088
+ enabled: z.boolean().optional(),
1089
+ allowed: z.array(z.string()).optional(),
1090
+ }).optional().describe('Enable/disable AX interactions on an existing node (e.g. flip an html node on with { enabled: true, allowed: ["ax.work.create"] }). Merged into the node data; clamped to the node-type ceiling server-side.'),
1082
1091
  full: z.boolean().optional().describe('Return the full updated node payload. Default false returns compact metadata.'),
1083
1092
  verbose: z.boolean().optional().describe('Alias for full:true.'),
1084
1093
  },
1085
1094
  async (input) => {
1086
- const { id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, dockPosition, pinned, arrangeLocked, toolName, category, status, duration, resultSummary, error } = input;
1095
+ const { id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, dockPosition, pinned, arrangeLocked, axCapabilities, toolName, category, status, duration, resultSummary, error } = input;
1087
1096
  const c = await ensureCanvas();
1088
1097
  const node = await c.getNode(id);
1089
1098
  if (!node) {
@@ -1125,6 +1134,19 @@ export async function startMcpServer(): Promise<void> {
1125
1134
  if (arrangeLocked !== undefined) {
1126
1135
  patch.arrangeLocked = arrangeLocked;
1127
1136
  }
1137
+ if (axCapabilities !== undefined) {
1138
+ // A graph dataset update (`data` array) and an axCapabilities toggle collide
1139
+ // on patch.data (array vs object) — reject rather than silently dropping the
1140
+ // dataset. Otherwise merge into existing node data so enabling AX doesn't
1141
+ // clobber html/spec/etc. The server re-clamps axCapabilities to the ceiling.
1142
+ if (Array.isArray(patch.data)) {
1143
+ return {
1144
+ content: [{ type: 'text', text: 'Update the graph dataset and axCapabilities in separate canvas_update_node calls.' }],
1145
+ isError: true,
1146
+ };
1147
+ }
1148
+ patch.data = { ...(node.data as Record<string, unknown>), axCapabilities };
1149
+ }
1128
1150
  await c.updateNode(id, patch);
1129
1151
  const updated = await c.getNode(id);
1130
1152
  return {