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.
- package/CHANGELOG.md +117 -0
- package/dist/canvas/global.css +56 -59
- package/dist/canvas/index.js +59 -59
- package/dist/json-render/index.js +97 -97
- package/dist/types/client/nodes/surface-url.d.ts +7 -0
- package/dist/types/client/state/canvas-store.d.ts +1 -0
- package/dist/types/client/state/intent-bridge.d.ts +7 -0
- package/dist/types/json-render/renderer/index.d.ts +1 -0
- package/dist/types/json-render/server.d.ts +1 -0
- package/dist/types/server/ax-context.d.ts +24 -1
- package/dist/types/server/canvas-state.d.ts +7 -0
- package/dist/types/server/html-surface.d.ts +29 -0
- package/dist/types/server/index.d.ts +19 -3
- package/dist/types/server/server.d.ts +12 -0
- package/docs/sdk.md +3 -1
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +96 -1
- package/src/cli/agent.ts +18 -1
- package/src/cli/index.ts +8 -1
- package/src/client/App.tsx +3 -3
- package/src/client/canvas/CanvasNode.tsx +16 -1
- package/src/client/canvas/DockedNode.tsx +38 -38
- package/src/client/canvas/ExpandedNodeOverlay.tsx +12 -1
- package/src/client/nodes/ContextNode.tsx +1 -1
- package/src/client/nodes/HtmlNode.tsx +26 -1
- package/src/client/nodes/McpAppNode.tsx +35 -8
- package/src/client/nodes/StatusNode.tsx +0 -20
- package/src/client/nodes/surface-url.ts +12 -0
- package/src/client/state/canvas-store.ts +4 -0
- package/src/client/state/intent-bridge.ts +19 -0
- package/src/client/state/sse-bridge.ts +17 -0
- package/src/client/theme/global.css +56 -59
- package/src/json-render/renderer/index.tsx +31 -2
- package/src/json-render/server.ts +3 -0
- package/src/mcp/canvas-access.ts +6 -1
- package/src/mcp/server.ts +23 -1
- package/src/server/ax-context.ts +49 -1
- package/src/server/ax-interaction.ts +3 -0
- package/src/server/ax-state.ts +3 -1
- package/src/server/canvas-state.ts +30 -11
- package/src/server/html-surface.ts +70 -13
- package/src/server/index.ts +32 -7
- 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
|
|
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
|
|
47
|
-
//
|
|
48
|
-
|
|
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:
|
|
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 —
|
|
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
|
-
|
|
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={
|
|
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');
|
package/src/mcp/canvas-access.ts
CHANGED
|
@@ -271,7 +271,9 @@ class LocalCanvasAccess implements CanvasAccess {
|
|
|
271
271
|
}
|
|
272
272
|
|
|
273
273
|
async addHtmlNode(input: AddHtmlNodeInput): Promise<string> {
|
|
274
|
-
|
|
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 {
|