pmx-canvas 0.1.29 → 0.1.31
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 +219 -0
- package/Readme.md +20 -10
- package/dist/canvas/global.css +51 -56
- package/dist/canvas/index.js +80 -163
- package/dist/canvas/surface-theme.css +142 -0
- package/dist/json-render/index.js +103 -103
- package/dist/types/client/nodes/HtmlNode.d.ts +0 -7
- package/dist/types/client/nodes/ax-node-actions.d.ts +18 -0
- package/dist/types/client/nodes/surface-url.d.ts +22 -0
- package/dist/types/client/state/attention-bridge.d.ts +3 -0
- package/dist/types/client/state/intent-bridge.d.ts +17 -0
- package/dist/types/json-render/renderer/index.d.ts +2 -0
- package/dist/types/json-render/schema.d.ts +2 -0
- package/dist/types/json-render/server.d.ts +2 -0
- package/dist/types/mcp/canvas-access.d.ts +47 -0
- package/dist/types/server/ax-interaction.d.ts +210 -0
- package/dist/types/server/ax-state.d.ts +67 -1
- package/dist/types/server/canvas-db.d.ts +4 -0
- package/dist/types/server/canvas-serialization.d.ts +2 -0
- package/dist/types/server/canvas-state.d.ts +54 -2
- package/dist/types/server/html-surface.d.ts +46 -0
- package/dist/types/server/index.d.ts +63 -5
- package/dist/types/server/mutation-history.d.ts +1 -1
- package/dist/types/server/placement.d.ts +1 -1
- package/dist/types/server/server.d.ts +12 -0
- package/dist/types/shared/surface.d.ts +19 -0
- package/docs/cli.md +30 -0
- package/docs/http-api.md +55 -0
- package/docs/mcp.md +40 -2
- package/docs/node-types.md +26 -0
- package/docs/plans/plan-004-pmx-ax-primitives.md +623 -394
- package/docs/sdk.md +23 -1
- package/package.json +2 -2
- package/skills/pmx-canvas/SKILL.md +107 -9
- package/src/cli/agent.ts +177 -0
- package/src/cli/index.ts +8 -1
- package/src/client/canvas/CanvasNode.tsx +8 -4
- package/src/client/canvas/DockedNode.tsx +38 -38
- package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -0
- package/src/client/nodes/ContextNode.tsx +17 -0
- package/src/client/nodes/ExtAppFrame.tsx +40 -3
- package/src/client/nodes/FileNode.tsx +26 -0
- package/src/client/nodes/HtmlNode.tsx +60 -188
- package/src/client/nodes/McpAppNode.tsx +47 -2
- package/src/client/nodes/StatusNode.tsx +20 -0
- package/src/client/nodes/ax-node-actions.ts +39 -0
- package/src/client/nodes/surface-url.ts +48 -0
- package/src/client/state/attention-bridge.ts +5 -0
- package/src/client/state/intent-bridge.ts +33 -0
- package/src/client/theme/global.css +51 -56
- package/src/client/theme/surface-theme.css +142 -0
- package/src/json-render/renderer/index.tsx +31 -0
- package/src/json-render/schema.ts +4 -0
- package/src/json-render/server.ts +13 -0
- package/src/mcp/canvas-access.ts +198 -1
- package/src/mcp/server.ts +232 -2
- package/src/server/ax-context.ts +3 -0
- package/src/server/ax-interaction.ts +549 -0
- package/src/server/ax-state.ts +188 -2
- package/src/server/canvas-db.ts +20 -0
- package/src/server/canvas-operations.ts +11 -0
- package/src/server/canvas-serialization.ts +9 -0
- package/src/server/canvas-state.ts +201 -26
- package/src/server/html-surface.ts +190 -0
- package/src/server/index.ts +122 -7
- package/src/server/mutation-history.ts +5 -0
- package/src/server/placement.ts +5 -1
- package/src/server/server.ts +360 -0
- package/src/shared/surface.ts +38 -0
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { CallToolResult, ListToolsResult, RequestId, Tool } from '@modelcontextprotocol/sdk/types.js';
|
|
2
2
|
import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
3
3
|
import { AppBridge, PostMessageTransport, buildAllowAttribute } from '@modelcontextprotocol/ext-apps/app-bridge';
|
|
4
|
-
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
4
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
5
5
|
import { extAppToolResultsMatch } from '../../shared/ext-app-tool-result.js';
|
|
6
|
+
import { DEFAULT_EXT_APP_SANDBOX } from '../../shared/surface.js';
|
|
7
|
+
import { submitAxInteractionFromClient } from '../state/intent-bridge';
|
|
8
|
+
import { showToast } from '../state/attention-bridge';
|
|
6
9
|
import {
|
|
7
10
|
canvasTheme,
|
|
8
11
|
collapseExpandedNode,
|
|
@@ -20,7 +23,6 @@ type McpUiTheme = 'light' | 'dark';
|
|
|
20
23
|
|
|
21
24
|
type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
|
|
22
25
|
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
|
|
23
|
-
const DEFAULT_EXT_APP_SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
|
|
24
26
|
|
|
25
27
|
interface ExtAppHostDimensionsTarget {
|
|
26
28
|
clientWidth?: number;
|
|
@@ -156,7 +158,42 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
156
158
|
const nodeId = node.id;
|
|
157
159
|
const frameKey = getExtAppBridgeInitKey(node, retryKey);
|
|
158
160
|
const iframeSandbox = resolveExtAppSandbox(null);
|
|
159
|
-
|
|
161
|
+
// Phase 6 — opt-in ext-app AX bridge. When the node sets data.axCapabilities.enabled,
|
|
162
|
+
// inject window.PMX_AX into the app HTML and accept emits below (server re-validates).
|
|
163
|
+
const axCaps = node.data.axCapabilities as { enabled?: boolean } | undefined;
|
|
164
|
+
const axEnabled = axCaps?.enabled === true && typeof html === 'string' && html.length > 0;
|
|
165
|
+
const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
|
|
166
|
+
const axBridgeScript = axEnabled
|
|
167
|
+
? `<script data-pmx-canvas-ax-bridge>window.PMX_AX={emit:function(t,p){window.parent.postMessage({source:'pmx-canvas-ax',token:${JSON.stringify(axToken)},nodeId:${JSON.stringify(nodeId)},interaction:{type:String(t),payload:p&&typeof p==='object'?p:{}}},'*');}};</script>`
|
|
168
|
+
: '';
|
|
169
|
+
const iframeDocument = useIframeDocument((html ?? '') + axBridgeScript, iframeSandbox);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (!axEnabled) return;
|
|
173
|
+
function onAxMessage(event: MessageEvent) {
|
|
174
|
+
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
175
|
+
const data = event.data as {
|
|
176
|
+
source?: string; token?: string; nodeId?: string;
|
|
177
|
+
interaction?: { type?: unknown; payload?: unknown };
|
|
178
|
+
} | null;
|
|
179
|
+
if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== nodeId) return;
|
|
180
|
+
const interaction = data.interaction;
|
|
181
|
+
if (!interaction || typeof interaction.type !== 'string') return;
|
|
182
|
+
void submitAxInteractionFromClient({
|
|
183
|
+
type: interaction.type,
|
|
184
|
+
sourceNodeId: nodeId,
|
|
185
|
+
sourceSurface: 'mcp-app',
|
|
186
|
+
...(interaction.payload && typeof interaction.payload === 'object'
|
|
187
|
+
? { payload: interaction.payload as Record<string, unknown> }
|
|
188
|
+
: {}),
|
|
189
|
+
}).then((res) => {
|
|
190
|
+
if (res.ok) showToast('context', 'AX interaction', interaction.type as string, [nodeId]);
|
|
191
|
+
else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [nodeId]);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
window.addEventListener('message', onAxMessage);
|
|
195
|
+
return () => window.removeEventListener('message', onAxMessage);
|
|
196
|
+
}, [axEnabled, axToken, nodeId]);
|
|
160
197
|
const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
|
|
161
198
|
const isExpanded = expanded || expandedNodeId.value === nodeId;
|
|
162
199
|
|
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'preact/hooks';
|
|
|
2
2
|
import { updateNodeData } from '../state/canvas-store';
|
|
3
3
|
import { fetchFile, updateNodeFromClient } from '../state/intent-bridge';
|
|
4
4
|
import type { CanvasNodeState } from '../types';
|
|
5
|
+
import { runNodeAxInteraction } from './ax-node-actions';
|
|
5
6
|
|
|
6
7
|
/** Guess a language label from a file extension for display. */
|
|
7
8
|
function langFromPath(path: string): string {
|
|
@@ -170,6 +171,31 @@ export function FileNode({
|
|
|
170
171
|
{new Date(updatedAt).toLocaleTimeString()}
|
|
171
172
|
</span>
|
|
172
173
|
)}
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
class="ax-node-action"
|
|
177
|
+
title="Mark this file as AX evidence"
|
|
178
|
+
onClick={(e) => {
|
|
179
|
+
e.stopPropagation();
|
|
180
|
+
void runNodeAxInteraction(
|
|
181
|
+
node,
|
|
182
|
+
'ax.evidence.add',
|
|
183
|
+
{ kind: 'file', title: filePath.split('/').pop() || filePath, ref: filePath },
|
|
184
|
+
'Marked as evidence',
|
|
185
|
+
);
|
|
186
|
+
}}
|
|
187
|
+
style={{
|
|
188
|
+
background: 'none',
|
|
189
|
+
border: 'none',
|
|
190
|
+
color: 'var(--c-muted)',
|
|
191
|
+
cursor: 'pointer',
|
|
192
|
+
padding: '2px 4px',
|
|
193
|
+
fontSize: '12px',
|
|
194
|
+
flexShrink: 0,
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
⊕
|
|
198
|
+
</button>
|
|
173
199
|
<button
|
|
174
200
|
type="button"
|
|
175
201
|
onClick={handleReload}
|
|
@@ -1,181 +1,9 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef } from 'preact/hooks';
|
|
2
2
|
import { canvasTheme } from '../state/canvas-store';
|
|
3
|
-
import {
|
|
3
|
+
import { submitAxInteractionFromClient } from '../state/intent-bridge';
|
|
4
|
+
import { showToast } from '../state/attention-bridge';
|
|
4
5
|
import type { CanvasNodeState } from '../types';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Strip characters that could break out of a CSS custom-property value context
|
|
9
|
-
* before interpolating into a `<style>` block. The expected token shape is a
|
|
10
|
-
* CSS color (`#abc`, `rgb(...)`) or font-family list, neither of which needs
|
|
11
|
-
* `<`, `>`, `{`, `}`, `;`, or backticks. Defense-in-depth against a future
|
|
12
|
-
* scenario where theme tokens become runtime-editable.
|
|
13
|
-
*/
|
|
14
|
-
function sanitizeCssTokenValue(value: string): string {
|
|
15
|
-
return value.replace(/[<>{};`\\]/g, '').trim();
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Build a `<style>` block that exposes canvas theme tokens to the iframe under
|
|
20
|
-
* both the canonical `--c-*` names and common `--color-*` aliases. Also sets
|
|
21
|
-
* sensible body defaults (font, bg, color) so authored HTML inherits the look.
|
|
22
|
-
*/
|
|
23
|
-
function buildThemeStyleBlock(): string {
|
|
24
|
-
const raw = getCanvasTokens();
|
|
25
|
-
const t = {
|
|
26
|
-
bg: sanitizeCssTokenValue(raw.bg),
|
|
27
|
-
panel: sanitizeCssTokenValue(raw.panel),
|
|
28
|
-
panelSoft: sanitizeCssTokenValue(raw.panelSoft),
|
|
29
|
-
line: sanitizeCssTokenValue(raw.line),
|
|
30
|
-
text: sanitizeCssTokenValue(raw.text),
|
|
31
|
-
textSoft: sanitizeCssTokenValue(raw.textSoft),
|
|
32
|
-
muted: sanitizeCssTokenValue(raw.muted),
|
|
33
|
-
dim: sanitizeCssTokenValue(raw.dim),
|
|
34
|
-
accent: sanitizeCssTokenValue(raw.accent),
|
|
35
|
-
ok: sanitizeCssTokenValue(raw.ok),
|
|
36
|
-
warn: sanitizeCssTokenValue(raw.warn),
|
|
37
|
-
warnAlt: sanitizeCssTokenValue(raw.warnAlt),
|
|
38
|
-
danger: sanitizeCssTokenValue(raw.danger),
|
|
39
|
-
purple: sanitizeCssTokenValue(raw.purple),
|
|
40
|
-
font: sanitizeCssTokenValue(raw.font),
|
|
41
|
-
mono: sanitizeCssTokenValue(raw.mono),
|
|
42
|
-
};
|
|
43
|
-
return `
|
|
44
|
-
:root {
|
|
45
|
-
--c-bg: ${t.bg};
|
|
46
|
-
--c-panel: ${t.panel};
|
|
47
|
-
--c-panel-soft: ${t.panelSoft};
|
|
48
|
-
--c-line: ${t.line};
|
|
49
|
-
--c-text: ${t.text};
|
|
50
|
-
--c-text-soft: ${t.textSoft};
|
|
51
|
-
--c-muted: ${t.muted};
|
|
52
|
-
--c-dim: ${t.dim};
|
|
53
|
-
--c-accent: ${t.accent};
|
|
54
|
-
--c-ok: ${t.ok};
|
|
55
|
-
--c-warn: ${t.warn};
|
|
56
|
-
--c-warn-alt: ${t.warnAlt};
|
|
57
|
-
--c-danger: ${t.danger};
|
|
58
|
-
--c-purple: ${t.purple};
|
|
59
|
-
|
|
60
|
-
/* Common aliases authored HTML might use. */
|
|
61
|
-
--color-bg: ${t.bg};
|
|
62
|
-
--color-panel: ${t.panel};
|
|
63
|
-
--color-surface: ${t.panelSoft};
|
|
64
|
-
--color-border: ${t.line};
|
|
65
|
-
--color-text: ${t.text};
|
|
66
|
-
--color-text-primary: ${t.text};
|
|
67
|
-
--color-text-secondary: ${t.textSoft};
|
|
68
|
-
--color-text-muted: ${t.muted};
|
|
69
|
-
--color-text-dim: ${t.dim};
|
|
70
|
-
--color-accent: ${t.accent};
|
|
71
|
-
--color-success: ${t.ok};
|
|
72
|
-
--color-warning: ${t.warn};
|
|
73
|
-
--color-danger: ${t.danger};
|
|
74
|
-
|
|
75
|
-
--font: ${t.font};
|
|
76
|
-
--font-sans: ${t.font};
|
|
77
|
-
--font-mono: ${t.mono};
|
|
78
|
-
|
|
79
|
-
color-scheme: dark light;
|
|
80
|
-
}
|
|
81
|
-
html, body {
|
|
82
|
-
margin: 0;
|
|
83
|
-
padding: 0;
|
|
84
|
-
background: ${t.bg};
|
|
85
|
-
color: ${t.text};
|
|
86
|
-
font-family: ${t.font || 'system-ui, sans-serif'};
|
|
87
|
-
font-size: 14px;
|
|
88
|
-
line-height: 1.5;
|
|
89
|
-
}
|
|
90
|
-
body { padding: 16px; box-sizing: border-box; }
|
|
91
|
-
a { color: ${t.accent}; }
|
|
92
|
-
`;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Inject the theme style block into the user-supplied HTML. If the document has
|
|
97
|
-
* a `<head>`, inject at the top of head; otherwise wrap the content in a full
|
|
98
|
-
* document. Returns a complete HTML string suitable for `srcdoc`.
|
|
99
|
-
*/
|
|
100
|
-
function buildPresentationEscapeBridge(exitToken?: string): string {
|
|
101
|
-
const token = JSON.stringify(exitToken ?? '');
|
|
102
|
-
return `<script data-pmx-canvas-presentation-bridge>
|
|
103
|
-
const PMX_CANVAS_PRESENTATION_EXIT_TOKEN = ${token};
|
|
104
|
-
document.addEventListener('keydown', (event) => {
|
|
105
|
-
if (event.key === 'Escape') {
|
|
106
|
-
window.parent.postMessage({ source: 'pmx-canvas-html-node', type: 'presentation-exit', token: PMX_CANVAS_PRESENTATION_EXIT_TOKEN }, '*');
|
|
107
|
-
}
|
|
108
|
-
}, true);
|
|
109
|
-
window.addEventListener('message', (event) => {
|
|
110
|
-
const message = event.data;
|
|
111
|
-
if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'presentation-key' || message.token !== PMX_CANVAS_PRESENTATION_EXIT_TOKEN) return;
|
|
112
|
-
if (typeof message.key !== 'string') return;
|
|
113
|
-
if (typeof window.PMX_CANVAS_PRESENTATION_HANDLE_KEY === 'function') {
|
|
114
|
-
window.PMX_CANVAS_PRESENTATION_HANDLE_KEY(message.key);
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
document.dispatchEvent(new CustomEvent('pmx-presentation-key', { detail: { key: message.key }, bubbles: true, cancelable: true }));
|
|
118
|
-
document.dispatchEvent(new KeyboardEvent('keydown', { key: message.key, bubbles: true, cancelable: true }));
|
|
119
|
-
});
|
|
120
|
-
</script>`;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function buildThemeBridge(themeToken: string): string {
|
|
124
|
-
const token = JSON.stringify(themeToken);
|
|
125
|
-
return `<script data-pmx-canvas-theme-bridge>
|
|
126
|
-
const PMX_CANVAS_THEME_TOKEN = ${token};
|
|
127
|
-
window.addEventListener('message', (event) => {
|
|
128
|
-
const message = event.data;
|
|
129
|
-
if (!message || message.source !== 'pmx-canvas-html-node' || message.type !== 'theme-update' || message.token !== PMX_CANVAS_THEME_TOKEN) return;
|
|
130
|
-
if (typeof message.css !== 'string' || typeof message.theme !== 'string') return;
|
|
131
|
-
let style = document.querySelector('style[data-pmx-canvas-theme]');
|
|
132
|
-
if (!style) {
|
|
133
|
-
style = document.createElement('style');
|
|
134
|
-
style.setAttribute('data-pmx-canvas-theme', '');
|
|
135
|
-
document.head.prepend(style);
|
|
136
|
-
}
|
|
137
|
-
style.textContent = message.css;
|
|
138
|
-
document.documentElement.setAttribute('data-pmx-canvas-theme', message.theme);
|
|
139
|
-
document.documentElement.setAttribute('data-theme', message.theme);
|
|
140
|
-
});
|
|
141
|
-
</script>`;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function injectIntoHead(html: string, content: string): string {
|
|
145
|
-
if (/<head[\s>]/i.test(html)) {
|
|
146
|
-
return html.replace(/<head([^>]*)>/i, `<head$1>${content}`);
|
|
147
|
-
}
|
|
148
|
-
if (/<html[\s>]/i.test(html)) {
|
|
149
|
-
return html.replace(/<html([^>]*)>/i, `<html$1><head>${content}</head>`);
|
|
150
|
-
}
|
|
151
|
-
return html;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function buildSrcDoc(userHtml: string, options: { presentation?: boolean; presentationExitToken?: string; themeToken: string; themeCss: string; theme: string }): string {
|
|
155
|
-
const styleBlock = `<style data-pmx-canvas-theme>${options.themeCss}</style>`;
|
|
156
|
-
const themeBridge = buildThemeBridge(options.themeToken);
|
|
157
|
-
const presentationBridge = options.presentation ? buildPresentationEscapeBridge(options.presentationExitToken) : '';
|
|
158
|
-
const injectedHeadContent = `${styleBlock}${themeBridge}${presentationBridge}`;
|
|
159
|
-
const presentationAttr = options.presentation ? ' data-pmx-presentation-mode="present"' : '';
|
|
160
|
-
const trimmed = userHtml.trim();
|
|
161
|
-
const isFullDoc = /<html[\s>]/i.test(trimmed);
|
|
162
|
-
if (isFullDoc) {
|
|
163
|
-
const withTheme = trimmed.replace(/<html([^>]*)>/i, `<html$1 data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}>`);
|
|
164
|
-
return injectIntoHead(withTheme, injectedHeadContent);
|
|
165
|
-
}
|
|
166
|
-
// Fragment — wrap in full document.
|
|
167
|
-
return `<!doctype html><html data-pmx-canvas-theme="${options.theme}" data-theme="${options.theme}"${presentationAttr}><head><meta charset="utf-8">${injectedHeadContent}</head><body>${userHtml}</body></html>`;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export function createHtmlNodeSrcDocForTest(userHtml: string, options: { theme: string; themeCss: string; themeToken?: string; presentation?: boolean; presentationExitToken?: string }): string {
|
|
171
|
-
return buildSrcDoc(userHtml, {
|
|
172
|
-
themeToken: options.themeToken ?? 'test-theme-token',
|
|
173
|
-
theme: options.theme,
|
|
174
|
-
themeCss: options.themeCss,
|
|
175
|
-
presentation: options.presentation,
|
|
176
|
-
presentationExitToken: options.presentationExitToken,
|
|
177
|
-
});
|
|
178
|
-
}
|
|
6
|
+
import { nodeSurfaceUrl, surfaceContentHash } from './surface-url';
|
|
179
7
|
|
|
180
8
|
export function shouldShowPresentationControls(node: CanvasNodeState): boolean {
|
|
181
9
|
return node.type === 'html' && node.data.presentation === true;
|
|
@@ -190,16 +18,59 @@ export function HtmlNode({
|
|
|
190
18
|
}: { node: CanvasNodeState; expanded?: boolean; presentation?: boolean; presentationExitToken?: string; autoFocus?: boolean }) {
|
|
191
19
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
192
20
|
const theme = canvasTheme.value;
|
|
193
|
-
|
|
194
|
-
const
|
|
21
|
+
// Stable per-mount nonce that authorizes parent → iframe theme-update messages.
|
|
22
|
+
const themeToken = useMemo(() => `theme-${crypto.randomUUID()}`, []);
|
|
23
|
+
// Per-mount nonce authorizing iframe → parent AX emits (Phase 3 HTML bridge).
|
|
24
|
+
const axToken = useMemo(() => `ax-${crypto.randomUUID()}`, []);
|
|
195
25
|
const html = typeof node.data.html === 'string'
|
|
196
26
|
? node.data.html
|
|
197
27
|
: typeof node.data.content === 'string'
|
|
198
28
|
? node.data.content
|
|
199
29
|
: '';
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
30
|
+
const v = useMemo(() => surfaceContentHash(html), [html]);
|
|
31
|
+
|
|
32
|
+
// The in-canvas iframe and the "Open as site" tab load the SAME server-rendered
|
|
33
|
+
// surface URL (/api/canvas/surface/:id) — one render path, no content fork.
|
|
34
|
+
// `theme` is intentionally excluded from the deps: live theme changes are pushed
|
|
35
|
+
// via postMessage below (no reload), while `v` reloads the frame when the HTML
|
|
36
|
+
// itself changes.
|
|
37
|
+
const surfaceSrc = useMemo(
|
|
38
|
+
() => (html
|
|
39
|
+
? nodeSurfaceUrl(node.id, { theme, themeToken, present: presentation, presentToken: presentationExitToken, v, axToken })
|
|
40
|
+
: ''),
|
|
41
|
+
[html, presentation, presentationExitToken, themeToken, v, node.id, axToken],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Phase 3 HTML bridge: receive window.PMX_AX.emit(...) messages from the
|
|
45
|
+
// sandboxed iframe, validate the nonce + node id, and submit the interaction
|
|
46
|
+
// through the capability-gated endpoint (the server re-validates capabilities).
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
function onAxMessage(event: MessageEvent) {
|
|
49
|
+
// Bind to THIS node's own iframe (matches the ext-app bridge); the nonce +
|
|
50
|
+
// nodeId are a second gate, not the only one.
|
|
51
|
+
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
52
|
+
const data = event.data as {
|
|
53
|
+
source?: string; token?: string; nodeId?: string;
|
|
54
|
+
interaction?: { type?: unknown; payload?: unknown };
|
|
55
|
+
} | null;
|
|
56
|
+
if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== node.id) return;
|
|
57
|
+
const interaction = data.interaction;
|
|
58
|
+
if (!interaction || typeof interaction.type !== 'string') return;
|
|
59
|
+
void submitAxInteractionFromClient({
|
|
60
|
+
type: interaction.type,
|
|
61
|
+
sourceNodeId: node.id,
|
|
62
|
+
sourceSurface: 'html-node',
|
|
63
|
+
...(interaction.payload && typeof interaction.payload === 'object'
|
|
64
|
+
? { payload: interaction.payload as Record<string, unknown> }
|
|
65
|
+
: {}),
|
|
66
|
+
}).then((res) => {
|
|
67
|
+
if (res.ok) showToast('context', 'AX interaction', interaction.type as string, [node.id]);
|
|
68
|
+
else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [node.id]);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
window.addEventListener('message', onAxMessage);
|
|
72
|
+
return () => window.removeEventListener('message', onAxMessage);
|
|
73
|
+
}, [axToken, node.id]);
|
|
203
74
|
|
|
204
75
|
useEffect(() => {
|
|
205
76
|
iframeRef.current?.contentWindow?.postMessage({
|
|
@@ -207,16 +78,15 @@ export function HtmlNode({
|
|
|
207
78
|
type: 'theme-update',
|
|
208
79
|
token: themeToken,
|
|
209
80
|
theme,
|
|
210
|
-
css: themeCss,
|
|
211
81
|
}, '*');
|
|
212
82
|
if (autoFocus) iframeRef.current?.focus();
|
|
213
|
-
}, [theme,
|
|
83
|
+
}, [theme, themeToken]);
|
|
214
84
|
|
|
215
85
|
useEffect(() => {
|
|
216
86
|
if (!autoFocus) return;
|
|
217
87
|
const id = window.setTimeout(() => iframeRef.current?.focus(), 0);
|
|
218
88
|
return () => window.clearTimeout(id);
|
|
219
|
-
}, [autoFocus,
|
|
89
|
+
}, [autoFocus, surfaceSrc]);
|
|
220
90
|
|
|
221
91
|
const handleFrameLoad = () => {
|
|
222
92
|
iframeRef.current?.contentWindow?.postMessage({
|
|
@@ -224,7 +94,6 @@ export function HtmlNode({
|
|
|
224
94
|
type: 'theme-update',
|
|
225
95
|
token: themeToken,
|
|
226
96
|
theme,
|
|
227
|
-
css: themeCss,
|
|
228
97
|
}, '*');
|
|
229
98
|
if (autoFocus) iframeRef.current?.focus();
|
|
230
99
|
};
|
|
@@ -241,15 +110,18 @@ export function HtmlNode({
|
|
|
241
110
|
// `allow-same-origin` (would grant the iframe access to parent localStorage
|
|
242
111
|
// and credentialed requests to the canvas origin), `allow-top-navigation`
|
|
243
112
|
// (would let scripts redirect the parent window), or `allow-forms` (would
|
|
244
|
-
// let the iframe POST back to the host). The
|
|
245
|
-
//
|
|
113
|
+
// let the iframe POST back to the host). The surface route reinforces this
|
|
114
|
+
// with a matching `Content-Security-Policy: sandbox allow-scripts` response
|
|
115
|
+
// header, so the document stays on an opaque origin even when opened as a
|
|
116
|
+
// standalone tab. The whole html-node tier assumes arbitrary author code runs
|
|
117
|
+
// inside this exact sandbox.
|
|
246
118
|
return (
|
|
247
119
|
<iframe
|
|
248
120
|
ref={iframeRef}
|
|
249
121
|
class={presentation ? 'html-node-frame html-node-frame-presentation' : 'html-node-frame'}
|
|
250
122
|
title={typeof node.data.title === 'string' ? node.data.title : 'HTML node'}
|
|
251
|
-
sandbox=
|
|
252
|
-
{
|
|
123
|
+
sandbox="allow-scripts"
|
|
124
|
+
src={surfaceSrc}
|
|
253
125
|
tabIndex={autoFocus ? 0 : undefined}
|
|
254
126
|
onLoad={handleFrameLoad}
|
|
255
127
|
style={{
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef } from 'preact/hooks';
|
|
1
2
|
import type { CanvasNodeState } from '../types';
|
|
2
3
|
import { canvasTheme } from '../state/canvas-store';
|
|
4
|
+
import { submitAxInteractionFromClient } from '../state/intent-bridge';
|
|
5
|
+
import { showToast } from '../state/attention-bridge';
|
|
3
6
|
import { ExtAppFrame } from './ExtAppFrame';
|
|
4
7
|
|
|
5
|
-
function withViewerParams(url: string, expanded: boolean, specVersion?: number): string {
|
|
8
|
+
function withViewerParams(url: string, expanded: boolean, specVersion?: number, axToken?: string): string {
|
|
6
9
|
if (!url) return url;
|
|
7
10
|
try {
|
|
8
11
|
const resolved = new URL(url, window.location.origin);
|
|
@@ -11,6 +14,8 @@ function withViewerParams(url: string, expanded: boolean, specVersion?: number):
|
|
|
11
14
|
// Streaming json-render nodes bump specVersion as patches accumulate; including
|
|
12
15
|
// it in the src reloads the iframe so it re-reads the latest accumulated spec.
|
|
13
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).
|
|
18
|
+
if (axToken) resolved.searchParams.set('axToken', axToken);
|
|
14
19
|
return resolved.toString();
|
|
15
20
|
} catch {
|
|
16
21
|
return url;
|
|
@@ -33,9 +38,48 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
33
38
|
if (node.data.mode === 'ext-app') {
|
|
34
39
|
return <ExtAppFrame node={node} expanded={expanded} />;
|
|
35
40
|
}
|
|
41
|
+
return <McpAppViewer node={node} expanded={expanded} />;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function McpAppViewer({ node, expanded }: { node: CanvasNodeState; expanded: boolean }) {
|
|
45
|
+
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
|
+
const axToken = useMemo(() => (isAxViewer ? `ax-${crypto.randomUUID()}` : ''), [isAxViewer]);
|
|
50
|
+
|
|
51
|
+
// Receive AX emits forwarded by the json-render viewer; validate (bound to this
|
|
52
|
+
// node's iframe + nonce + node id) and submit through the capability-gated
|
|
53
|
+
// endpoint, which re-validates server-side.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!isAxViewer || !axToken) return;
|
|
56
|
+
function onAxMessage(event: MessageEvent) {
|
|
57
|
+
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
58
|
+
const data = event.data as {
|
|
59
|
+
source?: string; token?: string; nodeId?: string;
|
|
60
|
+
interaction?: { type?: unknown; payload?: unknown };
|
|
61
|
+
} | null;
|
|
62
|
+
if (!data || data.source !== 'pmx-canvas-ax' || data.token !== axToken || data.nodeId !== node.id) return;
|
|
63
|
+
const interaction = data.interaction;
|
|
64
|
+
if (!interaction || typeof interaction.type !== 'string') return;
|
|
65
|
+
void submitAxInteractionFromClient({
|
|
66
|
+
type: interaction.type,
|
|
67
|
+
sourceNodeId: node.id,
|
|
68
|
+
sourceSurface: 'json-render',
|
|
69
|
+
...(interaction.payload && typeof interaction.payload === 'object'
|
|
70
|
+
? { payload: interaction.payload as Record<string, unknown> }
|
|
71
|
+
: {}),
|
|
72
|
+
}).then((res) => {
|
|
73
|
+
if (res.ok) showToast('context', 'AX interaction', interaction.type as string, [node.id]);
|
|
74
|
+
else showToast('remove', 'AX interaction rejected', res.error ?? res.code ?? '', [node.id]);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
window.addEventListener('message', onAxMessage);
|
|
78
|
+
return () => window.removeEventListener('message', onAxMessage);
|
|
79
|
+
}, [isAxViewer, axToken, node.id]);
|
|
36
80
|
|
|
37
81
|
const specVersion = typeof node.data.specVersion === 'number' ? node.data.specVersion : undefined;
|
|
38
|
-
const url = withViewerParams((node.data.url as string) || '', expanded, specVersion);
|
|
82
|
+
const url = withViewerParams((node.data.url as string) || '', expanded, specVersion, axToken || undefined);
|
|
39
83
|
const sourceServer = (node.data.sourceServer as string) || '';
|
|
40
84
|
const hostMode = (node.data.hostMode as string) || 'hosted';
|
|
41
85
|
const fallbackReason = node.data.fallbackReason as string | undefined;
|
|
@@ -96,6 +140,7 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
96
140
|
the explicit postMessage bridge instead, which is the only path that needs
|
|
97
141
|
app/host RPC and broader capabilities. */}
|
|
98
142
|
<iframe
|
|
143
|
+
ref={iframeRef}
|
|
99
144
|
src={url}
|
|
100
145
|
class="mcp-app-frame"
|
|
101
146
|
sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { PHASE_COLORS } from '../theme/tokens';
|
|
2
2
|
import type { CanvasNodeState } from '../types';
|
|
3
|
+
import { axNodeActionButtonStyle, runNodeAxInteraction } from './ax-node-actions';
|
|
3
4
|
|
|
4
5
|
export function getStatusDisplayPhase(node: CanvasNodeState): string {
|
|
5
6
|
const phase = typeof node.data.phase === 'string' && node.data.phase.trim().length > 0
|
|
@@ -94,6 +95,25 @@ export function StatusNode({ node }: { node: CanvasNodeState }) {
|
|
|
94
95
|
{message}
|
|
95
96
|
</div>
|
|
96
97
|
)}
|
|
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>
|
|
97
117
|
</div>
|
|
98
118
|
);
|
|
99
119
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { submitAxInteractionFromClient } from '../state/intent-bridge';
|
|
2
|
+
import { showToast } from '../state/attention-bridge';
|
|
3
|
+
import type { CanvasNodeState } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Submit a native-node AX interaction (plan-004 Phase 2) and surface the outcome
|
|
7
|
+
* as a transient toast. Inline node controls call this; the server enforces the
|
|
8
|
+
* node's capabilities, so a denied interaction simply shows an error toast.
|
|
9
|
+
*/
|
|
10
|
+
export async function runNodeAxInteraction(
|
|
11
|
+
node: CanvasNodeState,
|
|
12
|
+
type: string,
|
|
13
|
+
payload: Record<string, unknown> | undefined,
|
|
14
|
+
successTitle: string,
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
const res = await submitAxInteractionFromClient({
|
|
17
|
+
type,
|
|
18
|
+
sourceNodeId: node.id,
|
|
19
|
+
sourceSurface: 'native-node',
|
|
20
|
+
...(payload ? { payload } : {}),
|
|
21
|
+
});
|
|
22
|
+
if (res.ok) {
|
|
23
|
+
showToast('context', successTitle, '', [node.id]);
|
|
24
|
+
} else {
|
|
25
|
+
showToast('remove', 'AX action failed', res.error ?? res.code ?? 'Unknown error', [node.id]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Shared style for the small inline AX action button on native nodes. */
|
|
30
|
+
export const axNodeActionButtonStyle = {
|
|
31
|
+
padding: '3px 8px',
|
|
32
|
+
fontSize: '10px',
|
|
33
|
+
background: 'var(--c-accent-12)',
|
|
34
|
+
border: '1px solid var(--c-accent-25)',
|
|
35
|
+
borderRadius: '4px',
|
|
36
|
+
color: 'var(--c-text-soft)',
|
|
37
|
+
cursor: 'pointer',
|
|
38
|
+
flexShrink: 0,
|
|
39
|
+
} as const;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { canvasTheme } from '../state/canvas-store';
|
|
2
|
+
import { canOpenNodeAsSurface } from '../../shared/surface.js';
|
|
3
|
+
import type { CanvasNodeState } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Stable content hash (djb2) used to cache-bust the surface iframe `src` when a
|
|
7
|
+
* node's HTML changes. The server always serves current state, but a same `src`
|
|
8
|
+
* string won't reload the iframe on its own — bumping `?v=` does.
|
|
9
|
+
*/
|
|
10
|
+
export function surfaceContentHash(input: string): string {
|
|
11
|
+
let h = 5381;
|
|
12
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
13
|
+
h = ((h << 5) + h + input.charCodeAt(i)) | 0;
|
|
14
|
+
}
|
|
15
|
+
return (h >>> 0).toString(36);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SurfaceUrlOptions {
|
|
19
|
+
theme?: string;
|
|
20
|
+
themeToken?: string;
|
|
21
|
+
present?: boolean;
|
|
22
|
+
presentToken?: string;
|
|
23
|
+
v?: string;
|
|
24
|
+
/** Nonce authorizing iframe → parent AX emits (html bridge). */
|
|
25
|
+
axToken?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Build the stable per-node surface URL (/api/canvas/surface/:id) the iframe and "Open as site" both use. */
|
|
29
|
+
export function nodeSurfaceUrl(nodeId: string, opts: SurfaceUrlOptions = {}): string {
|
|
30
|
+
const params = new URLSearchParams();
|
|
31
|
+
params.set('theme', opts.theme ?? canvasTheme.value);
|
|
32
|
+
if (opts.themeToken) params.set('themeToken', opts.themeToken);
|
|
33
|
+
if (opts.present) params.set('present', '1');
|
|
34
|
+
if (opts.presentToken) params.set('presentToken', opts.presentToken);
|
|
35
|
+
if (opts.v) params.set('v', opts.v);
|
|
36
|
+
if (opts.axToken) params.set('axToken', opts.axToken);
|
|
37
|
+
return `/api/canvas/surface/${encodeURIComponent(nodeId)}?${params.toString()}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Whether a node can be opened as a standalone site (shared with the server). */
|
|
41
|
+
export function canOpenAsSite(node: CanvasNodeState): boolean {
|
|
42
|
+
return canOpenNodeAsSurface(node.type, node.data as Record<string, unknown>);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Open the node's surface in a new browser tab. */
|
|
46
|
+
export function openNodeAsSite(node: CanvasNodeState): void {
|
|
47
|
+
window.open(nodeSurfaceUrl(node.id), '_blank', 'noopener');
|
|
48
|
+
}
|
|
@@ -275,6 +275,11 @@ function enqueueToast(entry: AttentionEntry): void {
|
|
|
275
275
|
flushToastQueue();
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
/** Show a transient toast from arbitrary client code (e.g. AX action feedback). */
|
|
279
|
+
export function showToast(tone: AttentionTone, title: string, detail = '', nodeIds: string[] = []): void {
|
|
280
|
+
enqueueToast(makeEntry(tone, title, detail, nodeIds));
|
|
281
|
+
}
|
|
282
|
+
|
|
278
283
|
function pulseNodes(nodeIds: string[]): void {
|
|
279
284
|
cancelTimer(pulseTimer);
|
|
280
285
|
pulseTimer = null;
|
|
@@ -236,6 +236,39 @@ export async function removeNodeFromClient(id: string): Promise<{ ok: boolean; r
|
|
|
236
236
|
});
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
// ── PMX-AX node interactions ──────────────────────────────────
|
|
240
|
+
|
|
241
|
+
export interface AxInteractionRequest {
|
|
242
|
+
type: string;
|
|
243
|
+
sourceNodeId: string;
|
|
244
|
+
payload?: Record<string, unknown>;
|
|
245
|
+
sourceSurface?: 'native-node' | 'json-render' | 'html-node' | 'mcp-app' | 'adapter';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface AxInteractionResponse {
|
|
249
|
+
ok: boolean;
|
|
250
|
+
type?: string;
|
|
251
|
+
sourceNodeId?: string;
|
|
252
|
+
primitive?: unknown;
|
|
253
|
+
status?: number;
|
|
254
|
+
code?: string;
|
|
255
|
+
error?: string;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Submit a capability-gated AX interaction from a native node control. */
|
|
259
|
+
export async function submitAxInteractionFromClient(input: AxInteractionRequest): Promise<AxInteractionResponse> {
|
|
260
|
+
return requestJson<AxInteractionResponse>(
|
|
261
|
+
'submitAxInteractionFromClient',
|
|
262
|
+
'/api/canvas/ax/interaction',
|
|
263
|
+
{ ok: false, code: 'request-failed', error: 'Request failed' },
|
|
264
|
+
{
|
|
265
|
+
method: 'POST',
|
|
266
|
+
headers: { 'Content-Type': 'application/json' },
|
|
267
|
+
body: JSON.stringify({ sourceSurface: 'native-node', ...input, source: 'browser' }),
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
239
272
|
/** Commit the current viewport to the authoritative server state. */
|
|
240
273
|
export async function updateViewportFromClient(
|
|
241
274
|
viewport: { x: number; y: number; scale: number },
|