pmx-canvas 0.1.31 → 0.1.33
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 +80 -0
- package/dist/canvas/global.css +18 -3
- package/dist/canvas/index.js +57 -57
- 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-operations.d.ts +1 -5
- package/dist/types/server/html-surface.d.ts +23 -0
- package/dist/types/server/index.d.ts +6 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +96 -1
- package/src/cli/agent.ts +18 -1
- package/src/client/App.tsx +3 -3
- package/src/client/canvas/CanvasNode.tsx +16 -1
- 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 +18 -3
- package/src/json-render/renderer/index.tsx +31 -2
- package/src/json-render/server.ts +3 -0
- package/src/mcp/canvas-access.ts +3 -0
- 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-operations.ts +2 -2
- package/src/server/canvas-state.ts +6 -1
- package/src/server/html-surface.ts +48 -11
- package/src/server/index.ts +8 -0
- package/src/server/server.ts +81 -14
|
@@ -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 {
|
|
@@ -457,13 +457,15 @@ body,
|
|
|
457
457
|
font-weight: 600;
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
-
/* 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. */
|
|
461
462
|
.hud-layer {
|
|
462
463
|
position: fixed;
|
|
463
464
|
top: 12px;
|
|
464
465
|
left: 12px;
|
|
465
466
|
right: 12px;
|
|
466
467
|
display: flex;
|
|
468
|
+
flex-wrap: wrap;
|
|
467
469
|
align-items: flex-start;
|
|
468
470
|
justify-content: center;
|
|
469
471
|
gap: 8px;
|
|
@@ -476,22 +478,24 @@ body,
|
|
|
476
478
|
.hud-left,
|
|
477
479
|
.hud-right {
|
|
478
480
|
display: flex;
|
|
481
|
+
flex-wrap: wrap;
|
|
479
482
|
gap: 8px;
|
|
480
483
|
}
|
|
481
484
|
|
|
482
485
|
/* Toolbar */
|
|
483
486
|
.canvas-toolbar {
|
|
484
487
|
display: flex;
|
|
488
|
+
flex-wrap: wrap;
|
|
485
489
|
align-items: center;
|
|
486
490
|
gap: 6px;
|
|
487
491
|
padding: 6px 10px;
|
|
488
492
|
min-height: var(--hud-bar-height);
|
|
493
|
+
max-width: 100%;
|
|
489
494
|
box-sizing: border-box;
|
|
490
495
|
background: var(--c-panel-glass);
|
|
491
496
|
backdrop-filter: blur(12px);
|
|
492
497
|
border: 1px solid var(--c-line);
|
|
493
498
|
border-radius: var(--radius);
|
|
494
|
-
flex-shrink: 0;
|
|
495
499
|
}
|
|
496
500
|
|
|
497
501
|
.toolbar-tooltip-anchor {
|
|
@@ -666,9 +670,11 @@ body,
|
|
|
666
670
|
|
|
667
671
|
.toolbar-group {
|
|
668
672
|
display: flex;
|
|
673
|
+
flex-wrap: wrap;
|
|
669
674
|
align-items: center;
|
|
670
675
|
gap: 6px;
|
|
671
|
-
|
|
676
|
+
min-width: 0;
|
|
677
|
+
max-width: 100%;
|
|
672
678
|
}
|
|
673
679
|
|
|
674
680
|
.canvas-toolbar button svg {
|
|
@@ -692,6 +698,15 @@ body,
|
|
|
692
698
|
}
|
|
693
699
|
}
|
|
694
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
|
+
|
|
695
710
|
/* Raw markdown source editor */
|
|
696
711
|
.md-editor-split {
|
|
697
712
|
display: flex;
|
|
@@ -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
|
@@ -685,6 +685,7 @@ class RemoteCanvasAccess implements CanvasAccess {
|
|
|
685
685
|
slideTitles,
|
|
686
686
|
embeddedNodeIds,
|
|
687
687
|
embeddedUrls,
|
|
688
|
+
axCapabilities,
|
|
688
689
|
...rest
|
|
689
690
|
} = input as AddHtmlNodeInput & {
|
|
690
691
|
summary?: string;
|
|
@@ -694,6 +695,7 @@ class RemoteCanvasAccess implements CanvasAccess {
|
|
|
694
695
|
slideTitles?: string[];
|
|
695
696
|
embeddedNodeIds?: string[];
|
|
696
697
|
embeddedUrls?: string[];
|
|
698
|
+
axCapabilities?: { enabled?: boolean; allowed?: string[] };
|
|
697
699
|
};
|
|
698
700
|
return await this.requestNodeId('POST', '/api/canvas/node', {
|
|
699
701
|
type: 'html',
|
|
@@ -706,6 +708,7 @@ class RemoteCanvasAccess implements CanvasAccess {
|
|
|
706
708
|
...(Array.isArray(slideTitles) ? { slideTitles } : {}),
|
|
707
709
|
...(Array.isArray(embeddedNodeIds) ? { embeddedNodeIds } : {}),
|
|
708
710
|
...(Array.isArray(embeddedUrls) ? { embeddedUrls } : {}),
|
|
711
|
+
...(axCapabilities ? { axCapabilities } : {}),
|
|
709
712
|
},
|
|
710
713
|
});
|
|
711
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 {
|
package/src/server/ax-context.ts
CHANGED
|
@@ -1,7 +1,55 @@
|
|
|
1
1
|
import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildAxContext,
|
|
4
|
+
type PmxAxContext,
|
|
5
|
+
type PmxAxPinnedContext,
|
|
6
|
+
type PmxAxWorkItem,
|
|
7
|
+
type PmxAxApprovalGate,
|
|
8
|
+
type PmxAxReviewAnnotation,
|
|
9
|
+
type PmxAxElicitation,
|
|
10
|
+
type PmxAxModeRequest,
|
|
11
|
+
type PmxAxPolicy,
|
|
12
|
+
} from './ax-state.js';
|
|
3
13
|
import { canvasState, type CanvasNodeState } from './canvas-state.js';
|
|
4
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Compact, surface-safe view of the canvas-bound AX state, injected into (and
|
|
17
|
+
* pushed to) AX-enabled surfaces so authored boards can RENDER the work queue /
|
|
18
|
+
* focus, not just emit interactions. Deliberately excludes the timeline, pinned
|
|
19
|
+
* preamble, and serialized node bodies to keep the payload small.
|
|
20
|
+
*/
|
|
21
|
+
export interface PmxAxSurfaceSnapshot {
|
|
22
|
+
focus: string[];
|
|
23
|
+
workItems: PmxAxWorkItem[];
|
|
24
|
+
approvalGates: PmxAxApprovalGate[];
|
|
25
|
+
// Free-text human fields (`body`, `author`) are redacted — a surface gets review
|
|
26
|
+
// status/severity/anchor for a review board, but not raw human comment text.
|
|
27
|
+
reviewAnnotations: Array<Omit<PmxAxReviewAnnotation, 'body' | 'author'>>;
|
|
28
|
+
elicitations: PmxAxElicitation[];
|
|
29
|
+
modeRequests: PmxAxModeRequest[];
|
|
30
|
+
policy: PmxAxPolicy;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* NOTE: this is whole-canvas AX state (every work item, etc.), exposed to ANY
|
|
35
|
+
* AX-enabled surface — reads are board-wide while emits are node-scoped. Acceptable
|
|
36
|
+
* under the single-workspace local-trust model, but author surfaces accordingly
|
|
37
|
+
* (don't embed untrusted third-party scripts in an AX-enabled surface). Sensitive
|
|
38
|
+
* human review text is redacted below.
|
|
39
|
+
*/
|
|
40
|
+
export function buildCanvasAxSurfaceSnapshot(): PmxAxSurfaceSnapshot {
|
|
41
|
+
const ax = canvasState.getAxState();
|
|
42
|
+
return {
|
|
43
|
+
focus: ax.focus.nodeIds,
|
|
44
|
+
workItems: ax.workItems,
|
|
45
|
+
approvalGates: ax.approvalGates,
|
|
46
|
+
reviewAnnotations: ax.reviewAnnotations.map(({ body: _body, author: _author, ...rest }) => rest),
|
|
47
|
+
elicitations: ax.elicitations,
|
|
48
|
+
modeRequests: ax.modeRequests,
|
|
49
|
+
policy: ax.policy,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
5
53
|
function serializeNodes(nodes: CanvasNodeState[]) {
|
|
6
54
|
return nodes.map((node) => serializeNodeForAgentContext(node, {
|
|
7
55
|
defaultTextLength: 700,
|
|
@@ -500,6 +500,9 @@ export function applyAxInteraction(
|
|
|
500
500
|
const p = payloadParsed.data as { body: string; kind?: PmxAxReviewKind; severity?: PmxAxReviewSeverity; anchorType?: PmxAxReviewAnchorType; nodeId?: string; file?: string; author?: string };
|
|
501
501
|
// Sandboxed surfaces may only review their own node; trusted surfaces may
|
|
502
502
|
// anchor to a file/region or another node.
|
|
503
|
+
// A node-interaction review carries a sourceNodeId, so it defaults to a node
|
|
504
|
+
// anchor on that source (see nodeId resolution below). Body-only/unanchored
|
|
505
|
+
// is the adapter/HTTP/MCP path (addReviewAnnotation's context-aware default).
|
|
503
506
|
const anchorType: PmxAxReviewAnchorType = scoped ? 'node' : (p.anchorType ?? 'node');
|
|
504
507
|
const reviewAnnotation = manager.addReviewAnnotation(
|
|
505
508
|
{
|
package/src/server/ax-state.ts
CHANGED
|
@@ -636,7 +636,9 @@ export function createAxReviewAnnotation(
|
|
|
636
636
|
source: PmxAxSource | null,
|
|
637
637
|
): PmxAxReviewAnnotation {
|
|
638
638
|
const now = nowIso();
|
|
639
|
-
|
|
639
|
+
// Mirror addReviewAnnotation's context-aware default so a body-only annotation
|
|
640
|
+
// (no anchorType, no nodeId) becomes an unanchored note instead of a node anchor.
|
|
641
|
+
const anchorType = input.anchorType ?? (typeof input.nodeId === 'string' && input.nodeId ? 'node' : 'file');
|
|
640
642
|
return {
|
|
641
643
|
id: axId('rev'),
|
|
642
644
|
kind: input.kind ?? 'comment',
|
|
@@ -1363,7 +1363,7 @@ export function addCanvasEdge(input: {
|
|
|
1363
1363
|
label?: string;
|
|
1364
1364
|
style?: CanvasEdge['style'];
|
|
1365
1365
|
animated?: boolean;
|
|
1366
|
-
}):
|
|
1366
|
+
}): CanvasEdge {
|
|
1367
1367
|
const fromResult = resolveCanvasNode({
|
|
1368
1368
|
...(typeof input.from === 'string' ? { id: input.from } : {}),
|
|
1369
1369
|
...(typeof input.fromSearch === 'string' ? { search: input.fromSearch } : {}),
|
|
@@ -1393,7 +1393,7 @@ export function addCanvasEdge(input: {
|
|
|
1393
1393
|
if (!added) {
|
|
1394
1394
|
throw new Error('Duplicate or self-edge.');
|
|
1395
1395
|
}
|
|
1396
|
-
return
|
|
1396
|
+
return edge;
|
|
1397
1397
|
}
|
|
1398
1398
|
|
|
1399
1399
|
export function removeCanvasEdge(id: string): { removed: boolean } {
|
|
@@ -1888,7 +1888,12 @@ class CanvasStateManager {
|
|
|
1888
1888
|
// normalizeAxForCurrentNodes after apply, yet still returned as a phantom
|
|
1889
1889
|
// success object — false success / silent data loss. Reject instead so the
|
|
1890
1890
|
// HTTP/MCP layers surface ok:false / 4xx.
|
|
1891
|
-
|
|
1891
|
+
// Context-aware default: only fall back to a node anchor when a usable nodeId
|
|
1892
|
+
// is present; otherwise treat it as an unanchored (body-only) note so a
|
|
1893
|
+
// `{ body }`-only annotation succeeds (anchorType is documented optional).
|
|
1894
|
+
const anchorType = input.anchorType ?? (typeof input.nodeId === 'string' && input.nodeId ? 'node' : 'file');
|
|
1895
|
+
// An EXPLICIT node anchor still requires a real nodeId — reject a phantom
|
|
1896
|
+
// node-anchored review rather than silently dropping it post-apply.
|
|
1892
1897
|
if (anchorType === 'node' && (typeof input.nodeId !== 'string' || !this.currentNodeIdSet().has(input.nodeId))) {
|
|
1893
1898
|
return null;
|
|
1894
1899
|
}
|
|
@@ -107,25 +107,49 @@ function injectIntoHead(html: string, content: string): string {
|
|
|
107
107
|
* the server re-validates every interaction — so this is a convenience surface,
|
|
108
108
|
* not a trust boundary.
|
|
109
109
|
*/
|
|
110
|
-
function buildAxBridge(axToken: string, nodeId: string): string {
|
|
110
|
+
export function buildAxBridge(axToken: string, nodeId: string): string {
|
|
111
111
|
const token = JSON.stringify(axToken);
|
|
112
112
|
const node = JSON.stringify(nodeId);
|
|
113
113
|
return `<script data-pmx-canvas-ax-bridge>
|
|
114
114
|
const PMX_AX_TOKEN = ${token};
|
|
115
115
|
const PMX_AX_NODE_ID = ${node};
|
|
116
|
-
window.PMX_AX = {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
},
|
|
116
|
+
window.PMX_AX = window.PMX_AX || {};
|
|
117
|
+
window.PMX_AX.emit = function (type, payload) {
|
|
118
|
+
window.parent.postMessage({
|
|
119
|
+
source: 'pmx-canvas-ax',
|
|
120
|
+
token: PMX_AX_TOKEN,
|
|
121
|
+
nodeId: PMX_AX_NODE_ID,
|
|
122
|
+
interaction: { type: String(type), payload: payload && typeof payload === 'object' ? payload : {} },
|
|
123
|
+
}, '*');
|
|
125
124
|
};
|
|
126
125
|
</script>`;
|
|
127
126
|
}
|
|
128
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Read-side bridge: seeds `window.PMX_AX.state` with a snapshot of the canvas AX
|
|
130
|
+
* state and keeps it live via nonce-validated `ax-update` messages from the parent
|
|
131
|
+
* canvas. Author HTML can read `window.PMX_AX.state` and subscribe to the
|
|
132
|
+
* `pmx-ax-update` CustomEvent to render a live work queue / focus. Injected only
|
|
133
|
+
* alongside the emit bridge (AX-enabled nodes). Read-only — no capability beyond
|
|
134
|
+
* the existing AX-enabled gate.
|
|
135
|
+
*/
|
|
136
|
+
export function buildAxStateBridge(axToken: string, snapshotJson: string): string {
|
|
137
|
+
const token = JSON.stringify(axToken);
|
|
138
|
+
return `<script data-pmx-canvas-ax-state-bridge>
|
|
139
|
+
(function () {
|
|
140
|
+
const PMX_AX_STATE_TOKEN = ${token};
|
|
141
|
+
window.PMX_AX = window.PMX_AX || {};
|
|
142
|
+
window.PMX_AX.state = ${snapshotJson};
|
|
143
|
+
window.addEventListener('message', function (event) {
|
|
144
|
+
const m = event.data;
|
|
145
|
+
if (!m || m.source !== 'pmx-canvas-html-node' || m.type !== 'ax-update' || m.token !== PMX_AX_STATE_TOKEN) return;
|
|
146
|
+
window.PMX_AX.state = m.state;
|
|
147
|
+
try { window.dispatchEvent(new CustomEvent('pmx-ax-update', { detail: m.state })); } catch (e) {}
|
|
148
|
+
});
|
|
149
|
+
})();
|
|
150
|
+
</script>`;
|
|
151
|
+
}
|
|
152
|
+
|
|
129
153
|
/** Escape a string for safe interpolation into element text (e.g. `<title>`). */
|
|
130
154
|
function escapeSurfaceHtml(value: string): string {
|
|
131
155
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
@@ -149,6 +173,11 @@ export interface HtmlSurfaceOptions {
|
|
|
149
173
|
axToken?: string;
|
|
150
174
|
/** Node id stamped on emitted interactions. */
|
|
151
175
|
nodeId?: string;
|
|
176
|
+
/**
|
|
177
|
+
* Initial AX state snapshot to seed `window.PMX_AX.state` (only used when
|
|
178
|
+
* axBridge is enabled). Kept live via parent → iframe `ax-update` messages.
|
|
179
|
+
*/
|
|
180
|
+
axState?: unknown;
|
|
152
181
|
}
|
|
153
182
|
|
|
154
183
|
/**
|
|
@@ -165,7 +194,15 @@ export function buildHtmlSurfaceDocument(userHtml: string, options: HtmlSurfaceO
|
|
|
165
194
|
const axBridge = options.axBridge
|
|
166
195
|
? buildAxBridge(sanitizeToken(options.axToken), sanitizeToken(options.nodeId))
|
|
167
196
|
: '';
|
|
168
|
-
|
|
197
|
+
// Read-side AX state bridge (seed + live push). `</` is escaped so a work-item
|
|
198
|
+
// title containing "</script>" can't break out of the inline script.
|
|
199
|
+
const axStateBridge = options.axBridge
|
|
200
|
+
? buildAxStateBridge(
|
|
201
|
+
sanitizeToken(options.axToken),
|
|
202
|
+
options.axState !== undefined ? JSON.stringify(options.axState).replace(/</g, '\\u003c') : 'null',
|
|
203
|
+
)
|
|
204
|
+
: '';
|
|
205
|
+
const injectedHeadContent = `${link}${themeBridge}${presentationBridge}${axBridge}${axStateBridge}`;
|
|
169
206
|
const presentationAttr = options.presentation ? ' data-pmx-presentation-mode="present"' : '';
|
|
170
207
|
const trimmed = userHtml.trim();
|
|
171
208
|
const isFullDoc = /<html[\s>]/i.test(trimmed);
|
package/src/server/index.ts
CHANGED
|
@@ -768,6 +768,9 @@ export class PmxCanvas extends EventEmitter {
|
|
|
768
768
|
if (!entry) return { ok: false, description: 'Nothing to undo' };
|
|
769
769
|
await syncCanvasRuntimeBackends();
|
|
770
770
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
771
|
+
// Undo can reverse an AX mutation (work item, focus, …); nudge AX surfaces to
|
|
772
|
+
// re-fetch so a live board reflects the reversal (debounced client-side).
|
|
773
|
+
emitPrimaryWorkbenchEvent('ax-state-changed', {});
|
|
771
774
|
return { ok: true, description: `Undid: ${entry.description}` };
|
|
772
775
|
}
|
|
773
776
|
|
|
@@ -776,6 +779,7 @@ export class PmxCanvas extends EventEmitter {
|
|
|
776
779
|
if (!entry) return { ok: false, description: 'Nothing to redo' };
|
|
777
780
|
await syncCanvasRuntimeBackends();
|
|
778
781
|
emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
782
|
+
emitPrimaryWorkbenchEvent('ax-state-changed', {});
|
|
779
783
|
return { ok: true, description: `Redid: ${entry.description}` };
|
|
780
784
|
}
|
|
781
785
|
|
|
@@ -1042,6 +1046,9 @@ export class PmxCanvas extends EventEmitter {
|
|
|
1042
1046
|
width?: number;
|
|
1043
1047
|
height?: number;
|
|
1044
1048
|
strictSize?: boolean;
|
|
1049
|
+
/** Opt this html node into AX interactions (window.PMX_AX.emit). Clamped to
|
|
1050
|
+
* the html capability ceiling server-side; cannot escalate. */
|
|
1051
|
+
axCapabilities?: { enabled?: boolean; allowed?: string[] };
|
|
1045
1052
|
}): SdkCanvasNode {
|
|
1046
1053
|
const { id } = addCanvasNode({
|
|
1047
1054
|
type: 'html',
|
|
@@ -1055,6 +1062,7 @@ export class PmxCanvas extends EventEmitter {
|
|
|
1055
1062
|
...(Array.isArray(input.slideTitles) ? { slideTitles: input.slideTitles } : {}),
|
|
1056
1063
|
...(Array.isArray(input.embeddedNodeIds) ? { embeddedNodeIds: input.embeddedNodeIds } : {}),
|
|
1057
1064
|
...(Array.isArray(input.embeddedUrls) ? { embeddedUrls: input.embeddedUrls } : {}),
|
|
1065
|
+
...(input.axCapabilities ? { axCapabilities: input.axCapabilities } : {}),
|
|
1058
1066
|
},
|
|
1059
1067
|
...(typeof input.x === 'number' ? { x: input.x } : {}),
|
|
1060
1068
|
...(typeof input.y === 'number' ? { y: input.y } : {}),
|