pmx-canvas 0.1.23 → 0.1.24
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/.github/extensions/pmx-canvas/extension.mjs +591 -0
- package/CHANGELOG.md +70 -0
- package/Readme.md +36 -5
- package/dist/canvas/global.css +36 -3
- package/dist/canvas/index.js +54 -54
- package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -0
- package/dist/types/client/nodes/iframe-document-url.d.ts +8 -0
- package/dist/types/client/state/intent-bridge.d.ts +4 -0
- package/dist/types/client/types.d.ts +1 -0
- package/dist/types/json-render/catalog.d.ts +1 -1
- package/dist/types/mcp/canvas-access.d.ts +9 -0
- package/dist/types/server/ax-context.d.ts +3 -0
- package/dist/types/server/ax-state.d.ts +43 -0
- package/dist/types/server/canvas-db.d.ts +5 -0
- package/dist/types/server/canvas-state.d.ts +19 -3
- package/dist/types/server/index.d.ts +6 -0
- package/dist/types/server/mutation-history.d.ts +1 -1
- package/docs/cli.md +13 -0
- package/docs/http-api.md +24 -0
- package/docs/mcp.md +20 -2
- package/docs/plans/plan-004-pmx-ax-primitives.md +463 -0
- package/docs/screenshot.png +0 -0
- package/docs/sdk.md +5 -0
- package/package.json +2 -1
- package/skills/pmx-canvas/SKILL.md +14 -0
- package/skills/pmx-canvas/references/codex-app-adapter.md +107 -0
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +111 -0
- package/src/cli/agent.ts +34 -0
- package/src/cli/index.ts +2 -1
- package/src/client/App.tsx +2 -0
- package/src/client/canvas/CanvasNode.tsx +7 -0
- package/src/client/canvas/CommandPalette.tsx +2 -1
- package/src/client/canvas/use-node-drag.ts +29 -7
- package/src/client/canvas/use-node-resize.ts +27 -7
- package/src/client/nodes/ExtAppFrame.tsx +51 -10
- package/src/client/nodes/HtmlNode.tsx +5 -2
- package/src/client/nodes/iframe-document-url.ts +58 -0
- package/src/client/state/intent-bridge.ts +8 -0
- package/src/client/state/sse-bridge.ts +2 -2
- package/src/client/theme/global.css +36 -3
- package/src/client/types.ts +1 -0
- package/src/mcp/canvas-access.ts +38 -0
- package/src/mcp/server.ts +113 -4
- package/src/server/ax-context.ts +38 -0
- package/src/server/ax-state.ts +130 -0
- package/src/server/canvas-db.ts +36 -1
- package/src/server/canvas-operations.ts +79 -0
- package/src/server/canvas-state.ts +113 -2
- package/src/server/index.ts +18 -0
- package/src/server/mutation-history.ts +1 -0
- package/src/server/server.ts +193 -8
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# GitHub Copilot App Adapter
|
|
2
|
+
|
|
3
|
+
Use this reference when PMX Canvas is running inside the GitHub Copilot app as a project canvas
|
|
4
|
+
extension. The adapter is intentionally thin: PMX Canvas remains the state owner, and the extension
|
|
5
|
+
maps Copilot SDK features onto PMX AX primitives.
|
|
6
|
+
|
|
7
|
+
## Adapter Identity
|
|
8
|
+
|
|
9
|
+
- Extension path: `.github/extensions/pmx-canvas/extension.mjs`
|
|
10
|
+
- Extension ID: `project:pmx-canvas`
|
|
11
|
+
- Canvas ID: `pmx-canvas`
|
|
12
|
+
- Display name: `PMX Canvas`
|
|
13
|
+
|
|
14
|
+
Open it with:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"extensionId": "project:pmx-canvas",
|
|
19
|
+
"canvasId": "pmx-canvas",
|
|
20
|
+
"instanceId": "pmx-canvas"
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Use a different `instanceId` for parallel panels. Reusing an `instanceId` focuses/reloads the same
|
|
25
|
+
panel.
|
|
26
|
+
|
|
27
|
+
## What the Adapter Does
|
|
28
|
+
|
|
29
|
+
- Opens the live PMX workbench directly in a native Copilot canvas panel.
|
|
30
|
+
- Uses PMX-served same-origin frame documents for iframe-backed nodes (`html` and hosted MCP apps).
|
|
31
|
+
The Copilot app webview can leave nested `srcdoc` and `blob:` iframes blank, so PMX should route
|
|
32
|
+
generated frame HTML through `/api/canvas/frame-documents/...` instead.
|
|
33
|
+
- Connects to a matching local PMX server for the current workspace, or starts one when needed.
|
|
34
|
+
- Reads `/api/canvas/ax/context` and injects pinned/focused context from
|
|
35
|
+
`onUserPromptSubmitted`.
|
|
36
|
+
- Exposes adapter actions for status, AX context refresh, AX focus, and explicit session steering.
|
|
37
|
+
- Keeps all persistent PMX state in `.pmx-canvas/canvas.db`; the extension does not own canvas
|
|
38
|
+
state.
|
|
39
|
+
|
|
40
|
+
## Open Input
|
|
41
|
+
|
|
42
|
+
All fields are optional:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"serverUrl": "http://127.0.0.1:4313",
|
|
47
|
+
"port": 4313,
|
|
48
|
+
"autoStart": true,
|
|
49
|
+
"allowWorkspaceMismatch": false,
|
|
50
|
+
"workspaceRoot": "/path/to/repo"
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Default discovery order:
|
|
55
|
+
|
|
56
|
+
1. `serverUrl` input.
|
|
57
|
+
2. `PMX_CANVAS_URL`.
|
|
58
|
+
3. `PMX_CANVAS_PORT` / `PMX_WEB_CANVAS_PORT` / `4313` on loopback.
|
|
59
|
+
4. Managed server startup for the current workspace when `autoStart` is not `false`.
|
|
60
|
+
|
|
61
|
+
The adapter rejects an unrelated running PMX server unless `serverUrl` is explicit or
|
|
62
|
+
`allowWorkspaceMismatch` is true.
|
|
63
|
+
|
|
64
|
+
## Actions
|
|
65
|
+
|
|
66
|
+
| Action | Purpose |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `status` | Return PMX server health and persisted AX state. |
|
|
69
|
+
| `get_ax_context` | Return current pinned + focused AX context. |
|
|
70
|
+
| `focus_nodes` | Set AX focus with `source: "copilot"`. |
|
|
71
|
+
| `send_instruction` | Send an explicit prompt into the active Copilot session. |
|
|
72
|
+
|
|
73
|
+
Example focus action:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"nodeIds": ["node-123"]
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Use `nodeIds: []` to clear adapter-set AX focus after a live test.
|
|
82
|
+
|
|
83
|
+
## Live-Test Checklist
|
|
84
|
+
|
|
85
|
+
After changing the adapter:
|
|
86
|
+
|
|
87
|
+
1. Reload extensions.
|
|
88
|
+
2. Inspect `pmx-canvas` and confirm status is `running`.
|
|
89
|
+
3. Call `list_canvas_capabilities` for `extensionId: "project:pmx-canvas"` and
|
|
90
|
+
`canvasId: "pmx-canvas"`.
|
|
91
|
+
4. Open the canvas with a stable `instanceId`.
|
|
92
|
+
5. Invoke `status`.
|
|
93
|
+
6. Invoke `get_ax_context`.
|
|
94
|
+
7. If at least one node exists, invoke `focus_nodes` for one node ID and confirm
|
|
95
|
+
`get_ax_context.focus.nodeIds` includes it.
|
|
96
|
+
8. Clear the focus with `focus_nodes` and `nodeIds: []`.
|
|
97
|
+
9. Add or reuse one `html` node and one hosted MCP app node and confirm both render visibly in the
|
|
98
|
+
native PMX panel, not only in an external browser.
|
|
99
|
+
10. Inspect the extension log tail and confirm there are no runtime errors.
|
|
100
|
+
|
|
101
|
+
## Agent Behavior
|
|
102
|
+
|
|
103
|
+
When this adapter is loaded, the next user prompt may include hidden AX context generated from PMX
|
|
104
|
+
pins and focus. Treat pinned nodes and focused nodes as human-selected working context, not as a
|
|
105
|
+
global instruction to ignore the rest of the repository.
|
|
106
|
+
|
|
107
|
+
For non-Copilot agents, use the same core primitives directly:
|
|
108
|
+
|
|
109
|
+
- HTTP: `/api/canvas/ax`, `/api/canvas/ax/context`, `/api/canvas/ax/focus`
|
|
110
|
+
- MCP: `canvas://ax`, `canvas://ax-context`, `canvas_get_ax`, `canvas_set_ax_focus`
|
|
111
|
+
- CLI: `pmx-canvas ax status|context|focus`
|
package/src/cli/agent.ts
CHANGED
|
@@ -1767,6 +1767,40 @@ cmd('pin', 'Manage context pins', [
|
|
|
1767
1767
|
output(result);
|
|
1768
1768
|
});
|
|
1769
1769
|
|
|
1770
|
+
// ── AX ────────────────────────────────────────────────────────
|
|
1771
|
+
cmd('ax status', 'Read host-agnostic PMX AX state', [
|
|
1772
|
+
'pmx-canvas ax status',
|
|
1773
|
+
], async (args) => {
|
|
1774
|
+
const { flags } = parseFlags(args);
|
|
1775
|
+
if (flags.help || flags.h) return showCommandHelp('ax status');
|
|
1776
|
+
|
|
1777
|
+
output(await api('GET', '/api/canvas/ax'));
|
|
1778
|
+
});
|
|
1779
|
+
|
|
1780
|
+
cmd('ax context', 'Read agent-ready PMX AX context', [
|
|
1781
|
+
'pmx-canvas ax context',
|
|
1782
|
+
], async (args) => {
|
|
1783
|
+
const { flags } = parseFlags(args);
|
|
1784
|
+
if (flags.help || flags.h) return showCommandHelp('ax context');
|
|
1785
|
+
|
|
1786
|
+
output(await api('GET', '/api/canvas/ax/context'));
|
|
1787
|
+
});
|
|
1788
|
+
|
|
1789
|
+
cmd('ax focus', 'Set or clear PMX AX focus without moving the viewport', [
|
|
1790
|
+
'pmx-canvas ax focus node1 node2',
|
|
1791
|
+
'pmx-canvas ax focus --clear',
|
|
1792
|
+
], async (args) => {
|
|
1793
|
+
const { positional, flags } = parseFlags(args);
|
|
1794
|
+
if (flags.help || flags.h) return showCommandHelp('ax focus');
|
|
1795
|
+
|
|
1796
|
+
const nodeIds = flags.clear ? [] : positional;
|
|
1797
|
+
if (!flags.clear && nodeIds.length === 0) {
|
|
1798
|
+
die('Missing node ID', 'pmx-canvas ax focus <node-id> [more-node-ids]');
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
output(await api('POST', '/api/canvas/ax/focus', { nodeIds, source: 'cli' }));
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1770
1804
|
// ── undo ─────────────────────────────────────────────────────
|
|
1771
1805
|
cmd('undo', 'Undo the last canvas mutation', [
|
|
1772
1806
|
'pmx-canvas undo',
|
package/src/cli/index.ts
CHANGED
|
@@ -31,7 +31,7 @@ if (args.includes('--version') || args.includes('-v')) {
|
|
|
31
31
|
// If first arg is a known subcommand (not a --flag), route to the agent CLI.
|
|
32
32
|
const AGENT_COMMANDS = new Set([
|
|
33
33
|
'node', 'edge', 'json-render', 'search', 'layout', 'status', 'arrange', 'focus',
|
|
34
|
-
'fit', 'screenshot', 'pin', 'undo', 'redo', 'history', 'snapshot', 'diff', 'group', 'webview', 'open',
|
|
34
|
+
'fit', 'screenshot', 'pin', 'ax', 'undo', 'redo', 'history', 'snapshot', 'diff', 'group', 'webview', 'open',
|
|
35
35
|
'clear', 'code-graph', 'spatial', 'watch', 'web-artifact', 'external-app', 'diagram', 'graph', 'html', 'batch', 'validate', 'serve',
|
|
36
36
|
]);
|
|
37
37
|
|
|
@@ -505,6 +505,7 @@ Agent CLI (works against running server):
|
|
|
505
505
|
validate spec Validate json-render/graph payloads without creating nodes
|
|
506
506
|
watch [--json] [--events ...] Watch low-token semantic canvas changes
|
|
507
507
|
focus <node-id> Pan to node
|
|
508
|
+
ax status|context|focus Inspect AX state and focus
|
|
508
509
|
external-app add Add hosted external apps like Excalidraw
|
|
509
510
|
diagram add Add an Excalidraw diagram node
|
|
510
511
|
pin <ids...> | --list | --clear Manage context pins
|
package/src/client/App.tsx
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
walkGraph,
|
|
39
39
|
} from './state/canvas-store';
|
|
40
40
|
import { connectSSE } from './state/sse-bridge';
|
|
41
|
+
import { saveCanvasTheme } from './state/intent-bridge';
|
|
41
42
|
import {
|
|
42
43
|
IconArrange,
|
|
43
44
|
IconClearTrace,
|
|
@@ -240,6 +241,7 @@ function Toolbar({
|
|
|
240
241
|
document.documentElement.setAttribute('data-theme', next);
|
|
241
242
|
invalidateTokenCache();
|
|
242
243
|
canvasTheme.value = next;
|
|
244
|
+
void saveCanvasTheme(next);
|
|
243
245
|
}}
|
|
244
246
|
aria-label={`Switch to ${canvasTheme.value === 'dark' ? 'light' : 'dark'} theme`}
|
|
245
247
|
>
|
|
@@ -56,6 +56,11 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
56
56
|
// ── Drag (with snap alignment) ──────────────────────
|
|
57
57
|
const handleMove = useCallback((id: string, x: number, y: number) => {
|
|
58
58
|
const snap = snapToGuides(x, y, node.size.width, node.size.height);
|
|
59
|
+
const current = nodes.value.get(id);
|
|
60
|
+
if (current?.position.x === snap.x && current.position.y === snap.y) {
|
|
61
|
+
activeGuides.value = snap.guides.length > 0 ? snap.guides : null;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
59
64
|
updateNode(id, { position: { x: snap.x, y: snap.y } });
|
|
60
65
|
activeGuides.value = snap.guides.length > 0 ? snap.guides : null;
|
|
61
66
|
}, [node.size.width, node.size.height]);
|
|
@@ -75,6 +80,8 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
75
80
|
|
|
76
81
|
// ── Resize ────────────────────────────────────────────
|
|
77
82
|
const handleResize = useCallback((id: string, width: number, height: number) => {
|
|
83
|
+
const current = nodes.value.get(id);
|
|
84
|
+
if (current?.size.width === width && current.size.height === height) return;
|
|
78
85
|
updateNode(id, { size: { width, height } });
|
|
79
86
|
}, []);
|
|
80
87
|
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
nodes,
|
|
9
9
|
searchHighlightIds,
|
|
10
10
|
} from '../state/canvas-store';
|
|
11
|
-
import { createNodeFromClient } from '../state/intent-bridge';
|
|
11
|
+
import { createNodeFromClient, saveCanvasTheme } from '../state/intent-bridge';
|
|
12
12
|
import { TYPE_LABELS, type CanvasNodeState } from '../types';
|
|
13
13
|
import { invalidateTokenCache } from '../theme/tokens';
|
|
14
14
|
|
|
@@ -148,6 +148,7 @@ export function CommandPalette({
|
|
|
148
148
|
document.documentElement.setAttribute('data-theme', next);
|
|
149
149
|
invalidateTokenCache();
|
|
150
150
|
canvasTheme.value = next;
|
|
151
|
+
void saveCanvasTheme(next);
|
|
151
152
|
onClose();
|
|
152
153
|
},
|
|
153
154
|
},
|
|
@@ -22,27 +22,49 @@ export function useNodeDrag({ nodeId, viewport, onMove, onDragEnd }: NodeDragOpt
|
|
|
22
22
|
const handlePointerDown = useCallback(
|
|
23
23
|
(e: PointerEvent, currentX: number, currentY: number) => {
|
|
24
24
|
e.stopPropagation();
|
|
25
|
+
e.preventDefault();
|
|
25
26
|
isDragging.current = true;
|
|
27
|
+
document.documentElement.classList.add('is-node-dragging');
|
|
28
|
+
window.getSelection()?.removeAllRanges();
|
|
26
29
|
startPointer.current = { x: e.clientX, y: e.clientY };
|
|
27
30
|
startPosition.current = { x: currentX, y: currentY };
|
|
31
|
+
let pendingPointer: { x: number; y: number } | null = null;
|
|
32
|
+
let frameId: number | null = null;
|
|
28
33
|
|
|
29
|
-
const
|
|
30
|
-
|
|
34
|
+
const flushMove = () => {
|
|
35
|
+
frameId = null;
|
|
36
|
+
if (!isDragging.current || !pendingPointer) return;
|
|
37
|
+
const pointer = pendingPointer;
|
|
38
|
+
pendingPointer = null;
|
|
31
39
|
const scale = viewport.value.scale;
|
|
32
|
-
const dx = (
|
|
33
|
-
const dy = (
|
|
40
|
+
const dx = (pointer.x - startPointer.current.x) / scale;
|
|
41
|
+
const dy = (pointer.y - startPointer.current.y) / scale;
|
|
34
42
|
onMove(nodeId, startPosition.current.x + dx, startPosition.current.y + dy);
|
|
35
43
|
};
|
|
36
44
|
|
|
37
|
-
const
|
|
45
|
+
const onPointerMove = (ev: PointerEvent) => {
|
|
46
|
+
if (!isDragging.current) return;
|
|
47
|
+
pendingPointer = { x: ev.clientX, y: ev.clientY };
|
|
48
|
+
if (frameId !== null) return;
|
|
49
|
+
frameId = window.requestAnimationFrame(flushMove);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const finishDrag = () => {
|
|
53
|
+
if (frameId !== null) {
|
|
54
|
+
window.cancelAnimationFrame(frameId);
|
|
55
|
+
flushMove();
|
|
56
|
+
}
|
|
38
57
|
isDragging.current = false;
|
|
58
|
+
document.documentElement.classList.remove('is-node-dragging');
|
|
39
59
|
document.removeEventListener('pointermove', onPointerMove);
|
|
40
|
-
document.removeEventListener('pointerup',
|
|
60
|
+
document.removeEventListener('pointerup', finishDrag);
|
|
61
|
+
document.removeEventListener('pointercancel', finishDrag);
|
|
41
62
|
onDragEnd();
|
|
42
63
|
};
|
|
43
64
|
|
|
44
65
|
document.addEventListener('pointermove', onPointerMove);
|
|
45
|
-
document.addEventListener('pointerup',
|
|
66
|
+
document.addEventListener('pointerup', finishDrag);
|
|
67
|
+
document.addEventListener('pointercancel', finishDrag);
|
|
46
68
|
},
|
|
47
69
|
[nodeId, viewport, onMove, onDragEnd],
|
|
48
70
|
);
|
|
@@ -29,12 +29,18 @@ export function useNodeResize({ nodeId, viewport, onResize, onResizeEnd }: NodeR
|
|
|
29
29
|
isResizing.current = true;
|
|
30
30
|
startPointer.current = { x: e.clientX, y: e.clientY };
|
|
31
31
|
startSize.current = { w: currentWidth, h: currentHeight };
|
|
32
|
+
document.documentElement.classList.add('is-node-resizing');
|
|
33
|
+
let pendingPointer: { x: number; y: number } | null = null;
|
|
34
|
+
let frameId: number | null = null;
|
|
32
35
|
|
|
33
|
-
const
|
|
34
|
-
|
|
36
|
+
const flushResize = () => {
|
|
37
|
+
frameId = null;
|
|
38
|
+
if (!isResizing.current || !pendingPointer) return;
|
|
39
|
+
const pointer = pendingPointer;
|
|
40
|
+
pendingPointer = null;
|
|
35
41
|
const scale = viewport.value.scale;
|
|
36
|
-
const dw = (
|
|
37
|
-
const dh = (
|
|
42
|
+
const dw = (pointer.x - startPointer.current.x) / scale;
|
|
43
|
+
const dh = (pointer.y - startPointer.current.y) / scale;
|
|
38
44
|
onResize(
|
|
39
45
|
nodeId,
|
|
40
46
|
Math.max(MIN_WIDTH, startSize.current.w + dw),
|
|
@@ -42,15 +48,29 @@ export function useNodeResize({ nodeId, viewport, onResize, onResizeEnd }: NodeR
|
|
|
42
48
|
);
|
|
43
49
|
};
|
|
44
50
|
|
|
45
|
-
const
|
|
51
|
+
const onPointerMove = (ev: PointerEvent) => {
|
|
52
|
+
if (!isResizing.current) return;
|
|
53
|
+
pendingPointer = { x: ev.clientX, y: ev.clientY };
|
|
54
|
+
if (frameId !== null) return;
|
|
55
|
+
frameId = window.requestAnimationFrame(flushResize);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const finishResize = () => {
|
|
59
|
+
if (frameId !== null) {
|
|
60
|
+
window.cancelAnimationFrame(frameId);
|
|
61
|
+
flushResize();
|
|
62
|
+
}
|
|
46
63
|
isResizing.current = false;
|
|
64
|
+
document.documentElement.classList.remove('is-node-resizing');
|
|
47
65
|
document.removeEventListener('pointermove', onPointerMove);
|
|
48
|
-
document.removeEventListener('pointerup',
|
|
66
|
+
document.removeEventListener('pointerup', finishResize);
|
|
67
|
+
document.removeEventListener('pointercancel', finishResize);
|
|
49
68
|
onResizeEnd();
|
|
50
69
|
};
|
|
51
70
|
|
|
52
71
|
document.addEventListener('pointermove', onPointerMove);
|
|
53
|
-
document.addEventListener('pointerup',
|
|
72
|
+
document.addEventListener('pointerup', finishResize);
|
|
73
|
+
document.addEventListener('pointercancel', finishResize);
|
|
54
74
|
},
|
|
55
75
|
[nodeId, viewport, onResize, onResizeEnd],
|
|
56
76
|
);
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
expandedNodeId,
|
|
11
11
|
} from '../state/canvas-store';
|
|
12
12
|
import type { CanvasNodeState } from '../types';
|
|
13
|
+
import { useIframeDocument } from './iframe-document-url';
|
|
13
14
|
|
|
14
15
|
type McpUiTheme = 'light' | 'dark';
|
|
15
16
|
|
|
@@ -43,7 +44,7 @@ export function getExtAppBridgeInitKey(node: CanvasNodeState, retryKey: number):
|
|
|
43
44
|
const serverName = typeof node.data.serverName === 'string' ? node.data.serverName : '';
|
|
44
45
|
const appSessionId = typeof node.data.appSessionId === 'string' ? node.data.appSessionId : '';
|
|
45
46
|
const sessionStatus = typeof node.data.sessionStatus === 'string' ? node.data.sessionStatus : '';
|
|
46
|
-
return `${node.id}:${retryKey}:${
|
|
47
|
+
return `${node.id}:${retryKey}:${serverName}:${appSessionId}:${sessionStatus}:${html}`;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export function resolveExtAppDisplayModeRequest(
|
|
@@ -111,6 +112,10 @@ export function shouldApplyExtAppSizeChange(height: unknown, isExpanded: boolean
|
|
|
111
112
|
return typeof height === 'number' && Number.isFinite(height) && height > 0 && !isExpanded;
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
export function resolveExtAppInlineFrameHeight(appHeight: number, hostHeight: number): number {
|
|
116
|
+
return Math.max(positiveDimension(appHeight, 1), positiveDimension(hostHeight, 1));
|
|
117
|
+
}
|
|
118
|
+
|
|
114
119
|
export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState; expanded?: boolean }) {
|
|
115
120
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
116
121
|
const bridgeRef = useRef<AppBridge | null>(null);
|
|
@@ -145,6 +150,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
145
150
|
const maxHeight = node.size.height;
|
|
146
151
|
const nodeId = node.id;
|
|
147
152
|
const frameKey = getExtAppBridgeInitKey(node, retryKey);
|
|
153
|
+
const iframeSandbox = resolveExtAppSandbox(null);
|
|
154
|
+
const iframeDocument = useIframeDocument(html ?? '', iframeSandbox);
|
|
148
155
|
const toMcpTheme = (theme: string): McpUiTheme => (theme === 'light' ? 'light' : 'dark');
|
|
149
156
|
const isExpanded = expanded || expandedNodeId.value === nodeId;
|
|
150
157
|
|
|
@@ -202,7 +209,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
202
209
|
|
|
203
210
|
// Initialize as soon as HTML is mounted; some apps send initialize before iframe load fires.
|
|
204
211
|
useEffect(() => {
|
|
205
|
-
if (!html) return;
|
|
212
|
+
if (!html || !iframeDocument.ready) return;
|
|
206
213
|
const iframe = iframeRef.current;
|
|
207
214
|
if (!iframe) return;
|
|
208
215
|
let disposed = false;
|
|
@@ -272,7 +279,11 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
272
279
|
// Register handlers BEFORE connect
|
|
273
280
|
bridge.onsizechange = async ({ height }) => {
|
|
274
281
|
if (shouldApplyExtAppSizeChange(height, expandedNodeId.value === nodeId)) {
|
|
275
|
-
iframe.
|
|
282
|
+
const hostDimensions = resolveExtAppContainerDimensions(iframe.parentElement ?? iframe, {
|
|
283
|
+
width: node.size.width,
|
|
284
|
+
height: maxHeight,
|
|
285
|
+
});
|
|
286
|
+
iframe.style.height = `${resolveExtAppInlineFrameHeight(height, hostDimensions.height)}px`;
|
|
276
287
|
}
|
|
277
288
|
return {};
|
|
278
289
|
};
|
|
@@ -387,7 +398,11 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
387
398
|
fallbackTimer = setTimeout(() => {
|
|
388
399
|
if (disposed || bridgeReadyRef.current) return;
|
|
389
400
|
const bootstrapToolResult = latestToolResultRef.current;
|
|
390
|
-
|
|
401
|
+
const hostContext = buildHostContext(isExpanded ? 'fullscreen' : 'inline');
|
|
402
|
+
bridgeReadyRef.current = true;
|
|
403
|
+
bridge.setHostContext?.(hostContext);
|
|
404
|
+
void Promise.resolve(bridge.sendHostContextChange(hostContext))
|
|
405
|
+
.then(() => sendExtAppBootstrapState(bridge, latestToolInputRef.current, bootstrapToolResult))
|
|
391
406
|
.then(() => {
|
|
392
407
|
toolResultSentRef.current = Boolean(bootstrapToolResult);
|
|
393
408
|
if (bootstrapToolResult) {
|
|
@@ -424,6 +439,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
424
439
|
...buildHostContext(),
|
|
425
440
|
theme: toMcpTheme(newTheme),
|
|
426
441
|
});
|
|
442
|
+
void bridge.sendHostContextChange?.(buildHostContext());
|
|
427
443
|
});
|
|
428
444
|
|
|
429
445
|
void flushToolResult(bridge);
|
|
@@ -456,7 +472,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
456
472
|
transportRef.current = null;
|
|
457
473
|
}
|
|
458
474
|
};
|
|
459
|
-
}, [frameKey]);
|
|
475
|
+
}, [frameKey, iframeDocument.key]);
|
|
460
476
|
|
|
461
477
|
// Forward tool result when it arrives after bridge is ready
|
|
462
478
|
useEffect(() => {
|
|
@@ -472,6 +488,9 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
472
488
|
// click that triggered the expansion.
|
|
473
489
|
useEffect(() => {
|
|
474
490
|
const bridge = bridgeRef.current;
|
|
491
|
+
if (iframeRef.current) {
|
|
492
|
+
iframeRef.current.style.height = '100%';
|
|
493
|
+
}
|
|
475
494
|
if (!bridge || !bridgeReadyRef.current) return;
|
|
476
495
|
bridge.setHostContext?.({
|
|
477
496
|
theme: toMcpTheme(canvasTheme.value),
|
|
@@ -484,6 +503,17 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
484
503
|
locale: navigator.language,
|
|
485
504
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
486
505
|
});
|
|
506
|
+
void bridge.sendHostContextChange?.({
|
|
507
|
+
theme: toMcpTheme(canvasTheme.value),
|
|
508
|
+
platform: 'web',
|
|
509
|
+
containerDimensions: resolveExtAppContainerDimensions(iframeRef.current, {
|
|
510
|
+
width: node.size.width,
|
|
511
|
+
height: maxHeight,
|
|
512
|
+
}),
|
|
513
|
+
displayMode: isExpanded ? 'fullscreen' : 'inline',
|
|
514
|
+
locale: navigator.language,
|
|
515
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
516
|
+
});
|
|
487
517
|
}, [isExpanded, maxHeight]);
|
|
488
518
|
|
|
489
519
|
// Loading state — HTML not yet fetched
|
|
@@ -518,7 +548,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
518
548
|
}
|
|
519
549
|
|
|
520
550
|
return (
|
|
521
|
-
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
|
551
|
+
<div style={{ flex: 1, width: '100%', height: '100%', minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
|
522
552
|
{sessionStatus && sessionStatus !== 'ready' && (
|
|
523
553
|
<div
|
|
524
554
|
style={{
|
|
@@ -598,10 +628,18 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
598
628
|
<iframe
|
|
599
629
|
key={frameKey}
|
|
600
630
|
ref={iframeRef}
|
|
601
|
-
|
|
602
|
-
sandbox={
|
|
631
|
+
{...iframeDocument.attributes}
|
|
632
|
+
sandbox={iframeSandbox}
|
|
603
633
|
allow={buildAllowAttribute(resourceMeta?.permissions)}
|
|
604
|
-
style={{
|
|
634
|
+
style={{
|
|
635
|
+
flex: 1,
|
|
636
|
+
width: '100%',
|
|
637
|
+
height: '100%',
|
|
638
|
+
minHeight: 0,
|
|
639
|
+
border: 'none',
|
|
640
|
+
background: 'var(--c-panel)',
|
|
641
|
+
pointerEvents: isExpanded ? 'auto' : 'none',
|
|
642
|
+
}}
|
|
605
643
|
title={`Ext App: ${toolName}`}
|
|
606
644
|
/>
|
|
607
645
|
{!isExpanded && (
|
|
@@ -615,7 +653,10 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
|
|
|
615
653
|
title="Click to open"
|
|
616
654
|
style={{
|
|
617
655
|
position: 'absolute',
|
|
618
|
-
|
|
656
|
+
top: 0,
|
|
657
|
+
right: '56px',
|
|
658
|
+
bottom: '56px',
|
|
659
|
+
left: 0,
|
|
619
660
|
background: 'transparent',
|
|
620
661
|
border: 'none',
|
|
621
662
|
padding: 0,
|
|
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from 'preact/hooks';
|
|
|
2
2
|
import { canvasTheme } from '../state/canvas-store';
|
|
3
3
|
import { getCanvasTokens } from '../theme/tokens';
|
|
4
4
|
import type { CanvasNodeState } from '../types';
|
|
5
|
+
import { useIframeDocument } from './iframe-document-url';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Strip characters that could break out of a CSS custom-property value context
|
|
@@ -197,6 +198,8 @@ export function HtmlNode({
|
|
|
197
198
|
? node.data.content
|
|
198
199
|
: '';
|
|
199
200
|
const srcDoc = useMemo(() => (html ? buildSrcDoc(html, { presentation, presentationExitToken, themeToken, themeCss, theme }) : ''), [html, presentation, presentationExitToken, themeToken]);
|
|
201
|
+
const iframeSandbox = 'allow-scripts';
|
|
202
|
+
const iframeDocument = useIframeDocument(srcDoc, iframeSandbox);
|
|
200
203
|
|
|
201
204
|
useEffect(() => {
|
|
202
205
|
iframeRef.current?.contentWindow?.postMessage({
|
|
@@ -245,8 +248,8 @@ export function HtmlNode({
|
|
|
245
248
|
ref={iframeRef}
|
|
246
249
|
class={presentation ? 'html-node-frame html-node-frame-presentation' : 'html-node-frame'}
|
|
247
250
|
title={typeof node.data.title === 'string' ? node.data.title : 'HTML node'}
|
|
248
|
-
sandbox=
|
|
249
|
-
|
|
251
|
+
sandbox={iframeSandbox}
|
|
252
|
+
{...iframeDocument.attributes}
|
|
250
253
|
tabIndex={autoFocus ? 0 : undefined}
|
|
251
254
|
onLoad={handleFrameLoad}
|
|
252
255
|
style={{
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'preact/hooks';
|
|
2
|
+
|
|
3
|
+
interface FrameDocumentCreateResponse {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
url?: string;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isFrameDocumentCreateResponse(value: unknown): value is FrameDocumentCreateResponse {
|
|
10
|
+
return Boolean(value)
|
|
11
|
+
&& typeof value === 'object'
|
|
12
|
+
&& value !== null
|
|
13
|
+
&& !Array.isArray(value)
|
|
14
|
+
&& 'ok' in value
|
|
15
|
+
&& typeof (value as { ok: unknown }).ok === 'boolean';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function createIframeDocumentUrl(html: string, sandbox: string): Promise<string> {
|
|
19
|
+
const response = await fetch('/api/canvas/frame-documents', {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { 'Content-Type': 'application/json' },
|
|
22
|
+
body: JSON.stringify({ html, sandbox }),
|
|
23
|
+
});
|
|
24
|
+
const json = await response.json() as unknown;
|
|
25
|
+
if (!response.ok || !isFrameDocumentCreateResponse(json) || !json.ok || typeof json.url !== 'string') {
|
|
26
|
+
const message = isFrameDocumentCreateResponse(json) && json.error
|
|
27
|
+
? json.error
|
|
28
|
+
: `Frame document request failed with HTTP ${response.status}`;
|
|
29
|
+
throw new Error(message);
|
|
30
|
+
}
|
|
31
|
+
return json.url;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useIframeDocument(html: string, sandbox: string): { attributes: { src?: string }; ready: boolean; key: string } {
|
|
35
|
+
const [src, setSrc] = useState<string | null>(null);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
setSrc(null);
|
|
39
|
+
if (!html) return;
|
|
40
|
+
let cancelled = false;
|
|
41
|
+
void createIframeDocumentUrl(html, sandbox)
|
|
42
|
+
.then((url) => {
|
|
43
|
+
if (!cancelled) setSrc(url);
|
|
44
|
+
})
|
|
45
|
+
.catch((error) => {
|
|
46
|
+
console.error('[iframe-document] failed to create frame document:', error);
|
|
47
|
+
});
|
|
48
|
+
return () => {
|
|
49
|
+
cancelled = true;
|
|
50
|
+
};
|
|
51
|
+
}, [html, sandbox]);
|
|
52
|
+
|
|
53
|
+
return useMemo(() => ({
|
|
54
|
+
attributes: src ? { src } : {},
|
|
55
|
+
ready: Boolean(src),
|
|
56
|
+
key: src ?? '',
|
|
57
|
+
}), [src]);
|
|
58
|
+
}
|
|
@@ -104,6 +104,14 @@ export async function fetchCanvasState(): Promise<Record<string, unknown>> {
|
|
|
104
104
|
return requestJson('fetchCanvasState', '/api/canvas/state?includeBlobs=true', {});
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
export async function saveCanvasTheme(theme: string): Promise<{ ok: boolean; theme?: string }> {
|
|
108
|
+
return requestJson('saveCanvasTheme', '/api/canvas/theme', { ok: false }, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { 'Content-Type': 'application/json' },
|
|
111
|
+
body: JSON.stringify({ theme }),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
107
115
|
/** Fetch available slash commands for prompt completion. */
|
|
108
116
|
export async function fetchSlashCommands(): Promise<Array<{ name: string; description: string }>> {
|
|
109
117
|
return [];
|
|
@@ -290,10 +290,10 @@ function ensureLedgerNode(summary: Record<string, unknown>): void {
|
|
|
290
290
|
|
|
291
291
|
function applyCanvasTheme(theme: string): void {
|
|
292
292
|
const valid = theme === 'dark' || theme === 'light' || theme === 'high-contrast';
|
|
293
|
-
if (!valid
|
|
293
|
+
if (!valid) return;
|
|
294
294
|
document.documentElement.setAttribute('data-theme', theme);
|
|
295
295
|
invalidateTokenCache();
|
|
296
|
-
canvasTheme.value = theme;
|
|
296
|
+
if (canvasTheme.value !== theme) canvasTheme.value = theme;
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
function isCanvasNodeType(value: unknown): value is CanvasNodeState['type'] {
|
|
@@ -1107,10 +1107,13 @@ body,
|
|
|
1107
1107
|
position: absolute;
|
|
1108
1108
|
bottom: 0;
|
|
1109
1109
|
right: 0;
|
|
1110
|
-
width:
|
|
1111
|
-
height:
|
|
1110
|
+
width: 32px;
|
|
1111
|
+
height: 32px;
|
|
1112
|
+
background: rgba(0, 0, 0, 0.001);
|
|
1112
1113
|
cursor: nwse-resize;
|
|
1113
|
-
z-index:
|
|
1114
|
+
z-index: 30;
|
|
1115
|
+
pointer-events: auto;
|
|
1116
|
+
touch-action: none;
|
|
1114
1117
|
}
|
|
1115
1118
|
|
|
1116
1119
|
.canvas-node .node-resize-handle::after {
|
|
@@ -1130,6 +1133,36 @@ body,
|
|
|
1130
1133
|
opacity: 1;
|
|
1131
1134
|
}
|
|
1132
1135
|
|
|
1136
|
+
html.is-node-resizing,
|
|
1137
|
+
html.is-node-resizing * {
|
|
1138
|
+
cursor: nwse-resize !important;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
html.is-node-resizing .canvas-node {
|
|
1142
|
+
transition: box-shadow 0.15s ease !important;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
html.is-node-dragging .attention-field-layer {
|
|
1146
|
+
visibility: hidden;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
html.is-node-dragging,
|
|
1150
|
+
html.is-node-dragging * {
|
|
1151
|
+
cursor: grabbing !important;
|
|
1152
|
+
user-select: none !important;
|
|
1153
|
+
-webkit-user-select: none !important;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
html.is-node-dragging iframe,
|
|
1157
|
+
html.is-node-dragging .ext-app-preview-catcher {
|
|
1158
|
+
pointer-events: none !important;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
html.is-node-resizing iframe,
|
|
1162
|
+
html.is-node-resizing .ext-app-preview-catcher {
|
|
1163
|
+
pointer-events: none !important;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1133
1166
|
/* Pinned node indicator */
|
|
1134
1167
|
.canvas-node.pinned {
|
|
1135
1168
|
border-style: dashed;
|