pmx-canvas 0.1.2 → 0.1.4
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 +144 -0
- package/Readme.md +35 -8
- package/dist/canvas/index.js +69 -69
- package/dist/json-render/index.css +1 -1
- package/dist/json-render/index.js +1 -1
- package/dist/types/client/nodes/ExtAppFrame.d.ts +12 -0
- package/dist/types/client/state/canvas-store.d.ts +2 -1
- package/dist/types/client/types.d.ts +3 -0
- package/dist/types/json-render/charts/components.d.ts +2 -1
- package/dist/types/server/canvas-serialization.d.ts +1 -0
- package/dist/types/server/diagram-presets.d.ts +13 -0
- package/dist/types/server/ext-app-lookup.d.ts +22 -0
- package/dist/types/server/index.d.ts +8 -1
- package/dist/types/server/web-artifacts.d.ts +1 -0
- package/package.json +2 -1
- package/skills/pmx-canvas/SKILL.md +35 -10
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +66 -0
- package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +10 -0
- package/skills/web-artifacts-builder/scripts/init-artifact.sh +1 -1
- package/src/cli/agent.ts +114 -21
- package/src/cli/index.ts +3 -1
- package/src/client/App.tsx +2 -1
- package/src/client/canvas/CanvasNode.tsx +3 -2
- package/src/client/canvas/ExpandedNodeOverlay.tsx +6 -1
- package/src/client/nodes/ExtAppFrame.tsx +97 -26
- package/src/client/state/canvas-store.ts +63 -1
- package/src/client/state/sse-bridge.ts +19 -4
- package/src/client/types.ts +12 -0
- package/src/json-render/charts/components.tsx +6 -4
- package/src/json-render/charts/extra-components.tsx +5 -5
- package/src/json-render/renderer/index.css +14 -0
- package/src/mcp/server.ts +44 -5
- package/src/server/canvas-operations.ts +43 -5
- package/src/server/canvas-schema.ts +16 -14
- package/src/server/canvas-serialization.ts +19 -1
- package/src/server/diagram-presets.ts +219 -4
- package/src/server/ext-app-lookup.ts +49 -0
- package/src/server/index.ts +33 -25
- package/src/server/server.ts +199 -45
- package/src/server/web-artifacts/scripts/bundle-artifact.sh +10 -0
- package/src/server/web-artifacts/scripts/init-artifact.sh +1 -1
- package/src/server/web-artifacts.ts +44 -1
package/src/cli/agent.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
15
15
|
import { openUrlInExternalBrowser } from '../server/server.js';
|
|
16
|
+
import { DEFAULT_EXCALIDRAW_ELEMENTS } from '../server/diagram-presets.js';
|
|
16
17
|
import {
|
|
17
18
|
ALL_SEMANTIC_WATCH_EVENT_TYPES,
|
|
18
19
|
formatCompactWatchEvent,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
// ── Helpers ──────────────────────────────────────────────────
|
|
25
26
|
|
|
26
27
|
const DEFAULT_PORT = 4313;
|
|
28
|
+
const defaultConsoleLog = console.log;
|
|
27
29
|
|
|
28
30
|
interface CanvasSchemaField {
|
|
29
31
|
name: string;
|
|
@@ -91,13 +93,19 @@ function die(message: string, hint?: string): never {
|
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
function output(data: unknown): void {
|
|
94
|
-
|
|
96
|
+
const text = JSON.stringify(data, null, 2);
|
|
97
|
+
if (console.log !== defaultConsoleLog) {
|
|
98
|
+
console.log(text);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
process.stdout.write(`${text}\n`);
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
async function api(
|
|
98
105
|
method: string,
|
|
99
106
|
path: string,
|
|
100
107
|
body?: Record<string, unknown>,
|
|
108
|
+
options?: { allowErrorJson?: boolean },
|
|
101
109
|
): Promise<unknown> {
|
|
102
110
|
const base = getBaseUrl();
|
|
103
111
|
const url = `${base}${path}`;
|
|
@@ -128,6 +136,7 @@ async function api(
|
|
|
128
136
|
}
|
|
129
137
|
|
|
130
138
|
if (!res.ok) {
|
|
139
|
+
if (options?.allowErrorJson) return json;
|
|
131
140
|
const err = json as Record<string, unknown>;
|
|
132
141
|
die(
|
|
133
142
|
err.error ? String(err.error) : `HTTP ${res.status}`,
|
|
@@ -146,7 +155,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
|
|
|
146
155
|
const BOOL_FLAGS = new Set([
|
|
147
156
|
'help', 'h', 'ids', 'stdin', 'yes', 'list', 'clear', 'set', 'animated', 'dry-run',
|
|
148
157
|
'no-open-in-canvas', 'lock-arrange', 'unlock-arrange', 'json', 'compact', 'summary',
|
|
149
|
-
'verbose', 'include-logs',
|
|
158
|
+
'verbose', 'include-logs', 'no-pan',
|
|
150
159
|
]);
|
|
151
160
|
for (let i = 0; i < args.length; i++) {
|
|
152
161
|
const arg = args[i];
|
|
@@ -228,8 +237,9 @@ function parseStringListFlag(
|
|
|
228
237
|
flags: Record<string, string | true>,
|
|
229
238
|
name: string,
|
|
230
239
|
hint: string,
|
|
240
|
+
...aliases: string[]
|
|
231
241
|
): string[] | undefined {
|
|
232
|
-
const raw = getStringFlag(flags, name);
|
|
242
|
+
const raw = getStringFlag(flags, name, ...aliases);
|
|
233
243
|
if (raw === undefined) return undefined;
|
|
234
244
|
const trimmed = raw.trim();
|
|
235
245
|
if (!trimmed) {
|
|
@@ -286,6 +296,7 @@ function summarizeNodeResult(node: Record<string, unknown>): Record<string, unkn
|
|
|
286
296
|
...(node.ok !== undefined ? { ok: node.ok } : {}),
|
|
287
297
|
id: node.id ?? null,
|
|
288
298
|
type: node.type ?? null,
|
|
299
|
+
...(typeof node.kind === 'string' ? { kind: node.kind } : {}),
|
|
289
300
|
title: node.title ?? null,
|
|
290
301
|
...(typeof node.content === 'string' ? { contentPreview: truncateText(node.content) } : {}),
|
|
291
302
|
...(node.position !== undefined ? { position: node.position } : {}),
|
|
@@ -532,37 +543,48 @@ async function buildGraphRequestBody(
|
|
|
532
543
|
'Use: pmx-canvas node add --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value';
|
|
533
544
|
const rawData = await readTextInput(flags, {
|
|
534
545
|
fileFlags: ['data-file'],
|
|
535
|
-
valueFlags: ['data-json'],
|
|
546
|
+
valueFlags: ['data-json', 'data'],
|
|
536
547
|
allowStdin: true,
|
|
537
548
|
label: 'graph JSON dataset',
|
|
538
549
|
hint,
|
|
539
|
-
requiredMessage: 'Graph nodes require --data-file, --data-json, or --stdin JSON data.',
|
|
550
|
+
requiredMessage: 'Graph nodes require --data-file, --data-json, --data, or --stdin JSON data.',
|
|
540
551
|
});
|
|
541
552
|
const data = parseRecordArrayJson(rawData, hint);
|
|
542
553
|
|
|
543
554
|
const body: Record<string, unknown> = {
|
|
544
|
-
graphType: getStringFlag(flags, 'graph-type') ?? 'line',
|
|
555
|
+
graphType: getStringFlag(flags, 'graph-type', 'graphType') ?? 'line',
|
|
545
556
|
data,
|
|
546
557
|
};
|
|
547
558
|
if (typeof flags.title === 'string') body.title = flags.title;
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
559
|
+
const xKey = getStringFlag(flags, 'x-key', 'xKey');
|
|
560
|
+
const yKey = getStringFlag(flags, 'y-key', 'yKey');
|
|
561
|
+
const zKey = getStringFlag(flags, 'z-key', 'zKey');
|
|
562
|
+
const nameKey = getStringFlag(flags, 'name-key', 'nameKey');
|
|
563
|
+
const valueKey = getStringFlag(flags, 'value-key', 'valueKey');
|
|
564
|
+
const axisKey = getStringFlag(flags, 'axis-key', 'axisKey');
|
|
565
|
+
if (xKey) body.xKey = xKey;
|
|
566
|
+
if (yKey) body.yKey = yKey;
|
|
567
|
+
if (zKey) body.zKey = zKey;
|
|
568
|
+
if (nameKey) body.nameKey = nameKey;
|
|
569
|
+
if (valueKey) body.valueKey = valueKey;
|
|
570
|
+
if (axisKey) body.axisKey = axisKey;
|
|
554
571
|
const metrics = parseStringListFlag(flags, 'metrics', 'Use a comma-separated list, e.g. --metrics north,south');
|
|
555
572
|
const series = parseStringListFlag(flags, 'series', 'Use a comma-separated list, e.g. --series north,south');
|
|
556
573
|
if (metrics) body.metrics = metrics;
|
|
557
574
|
if (series) body.series = series;
|
|
558
|
-
|
|
559
|
-
|
|
575
|
+
const barKey = getStringFlag(flags, 'bar-key', 'barKey');
|
|
576
|
+
const lineKey = getStringFlag(flags, 'line-key', 'lineKey');
|
|
577
|
+
if (barKey) body.barKey = barKey;
|
|
578
|
+
if (lineKey) body.lineKey = lineKey;
|
|
560
579
|
if (flags.aggregate === 'sum' || flags.aggregate === 'count' || flags.aggregate === 'avg') {
|
|
561
580
|
body.aggregate = flags.aggregate;
|
|
562
581
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
582
|
+
const color = getStringFlag(flags, 'color');
|
|
583
|
+
const barColor = getStringFlag(flags, 'bar-color', 'barColor');
|
|
584
|
+
const lineColor = getStringFlag(flags, 'line-color', 'lineColor');
|
|
585
|
+
if (color) body.color = color;
|
|
586
|
+
if (barColor) body.barColor = barColor;
|
|
587
|
+
if (lineColor) body.lineColor = lineColor;
|
|
566
588
|
|
|
567
589
|
const chartHeight = optionalPositiveFiniteFlag(flags, 'chart-height', 'Use a positive number, e.g. --chart-height 300');
|
|
568
590
|
const x = optionalFiniteFlag(flags, 'x', 'Use a finite number, e.g. --x 500');
|
|
@@ -621,6 +643,8 @@ async function buildWebArtifactRequestBody(
|
|
|
621
643
|
if (typeof flags['output-path'] === 'string') body.outputPath = flags['output-path'];
|
|
622
644
|
if (typeof flags['init-script-path'] === 'string') body.initScriptPath = flags['init-script-path'];
|
|
623
645
|
if (typeof flags['bundle-script-path'] === 'string') body.bundleScriptPath = flags['bundle-script-path'];
|
|
646
|
+
const deps = parseStringListFlag(flags, 'deps', 'Use a comma-separated list, e.g. --deps recharts,zod');
|
|
647
|
+
if (deps) body.deps = deps;
|
|
624
648
|
if (flags['no-open-in-canvas']) body.openInCanvas = false;
|
|
625
649
|
if (flags.verbose || flags['include-logs']) body.includeLogs = true;
|
|
626
650
|
|
|
@@ -631,8 +655,11 @@ async function buildWebArtifactRequestBody(
|
|
|
631
655
|
}
|
|
632
656
|
|
|
633
657
|
async function runWebArtifactBuildCommand(flags: Record<string, string | true>): Promise<void> {
|
|
634
|
-
const result = await api('POST', '/api/canvas/web-artifact', await buildWebArtifactRequestBody(flags));
|
|
658
|
+
const result = await api('POST', '/api/canvas/web-artifact', await buildWebArtifactRequestBody(flags), { allowErrorJson: true });
|
|
635
659
|
output(result);
|
|
660
|
+
if (isRecord(result) && result.ok === false) {
|
|
661
|
+
process.exit(1);
|
|
662
|
+
}
|
|
636
663
|
}
|
|
637
664
|
|
|
638
665
|
async function loadCanvasSchema(): Promise<CanvasSchemaResponse> {
|
|
@@ -915,6 +942,13 @@ cmd('node add', 'Add a node to the canvas', [
|
|
|
915
942
|
return;
|
|
916
943
|
}
|
|
917
944
|
|
|
945
|
+
if (type === 'mcp-app') {
|
|
946
|
+
die(
|
|
947
|
+
'mcp-app nodes require tool-backed app metadata and cannot be created with generic node add.',
|
|
948
|
+
'Use: pmx-canvas web-artifact build --title "Dashboard" --app-file ./App.tsx, or pmx-canvas external-app add --kind excalidraw --title "Diagram"',
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
|
|
918
952
|
const body: Record<string, unknown> = { type };
|
|
919
953
|
if (flags.title) body.title = flags.title;
|
|
920
954
|
const webpageUrl = getStringFlag(flags, 'url');
|
|
@@ -1014,7 +1048,7 @@ cmd('node list', 'List all nodes on the canvas', [
|
|
|
1014
1048
|
let nodes = layout.nodes;
|
|
1015
1049
|
|
|
1016
1050
|
if (flags.type && flags.type !== true) {
|
|
1017
|
-
nodes = nodes.filter((n) => n.type === flags.type);
|
|
1051
|
+
nodes = nodes.filter((n) => n.type === flags.type || n.kind === flags.type);
|
|
1018
1052
|
}
|
|
1019
1053
|
|
|
1020
1054
|
if (flags.ids) {
|
|
@@ -1269,7 +1303,12 @@ cmd('status', 'Quick canvas summary', [
|
|
|
1269
1303
|
|
|
1270
1304
|
const typeCounts: Record<string, number> = {};
|
|
1271
1305
|
for (const n of layout.nodes) {
|
|
1272
|
-
const
|
|
1306
|
+
const data = isRecord(n.data) ? n.data : {};
|
|
1307
|
+
const t = typeof n.kind === 'string'
|
|
1308
|
+
? n.kind
|
|
1309
|
+
: n.type === 'mcp-app' && data.hostMode === 'hosted' && typeof data.path === 'string'
|
|
1310
|
+
? 'web-artifact'
|
|
1311
|
+
: n.type as string;
|
|
1273
1312
|
typeCounts[t] = (typeCounts[t] || 0) + 1;
|
|
1274
1313
|
}
|
|
1275
1314
|
|
|
@@ -1308,10 +1347,43 @@ cmd('focus', 'Pan viewport to center on a node', [
|
|
|
1308
1347
|
const id = positional[0];
|
|
1309
1348
|
if (!id) die('Missing node ID', 'pmx-canvas focus <node-id>');
|
|
1310
1349
|
|
|
1311
|
-
const result = await api('POST', '/api/canvas/focus', { id });
|
|
1350
|
+
const result = await api('POST', '/api/canvas/focus', { id, ...(flags['no-pan'] ? { noPan: true } : {}) });
|
|
1312
1351
|
output(result);
|
|
1313
1352
|
});
|
|
1314
1353
|
|
|
1354
|
+
// ── external-app add ─────────────────────────────────────────
|
|
1355
|
+
cmd('external-app add', 'Create a hosted external app node', [
|
|
1356
|
+
'pmx-canvas external-app add --kind excalidraw --title "Diagram"',
|
|
1357
|
+
], async (args) => {
|
|
1358
|
+
const { flags } = parseFlags(args);
|
|
1359
|
+
if (flags.help || flags.h) return showCommandHelp('external-app add');
|
|
1360
|
+
|
|
1361
|
+
const kind = typeof flags.kind === 'string' ? flags.kind.trim() : '';
|
|
1362
|
+
if (kind !== 'excalidraw') {
|
|
1363
|
+
die('Unsupported external app kind.', 'Use: pmx-canvas external-app add --kind excalidraw --title "Diagram"');
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
const body: Record<string, unknown> = {
|
|
1367
|
+
title: typeof flags.title === 'string' ? flags.title : 'Excalidraw Diagram',
|
|
1368
|
+
elements: DEFAULT_EXCALIDRAW_ELEMENTS,
|
|
1369
|
+
};
|
|
1370
|
+
const elementsJson = getStringFlag(flags, 'elements-json');
|
|
1371
|
+
if (elementsJson !== undefined) body.elements = parseJsonValue(elementsJson, 'Excalidraw elements', 'Use --elements-json \'[{"type":"rectangle","id":"r1","x":0,"y":0,"width":120,"height":80}]\'');
|
|
1372
|
+
const elementsFile = getStringFlag(flags, 'elements-file', 'initial-file');
|
|
1373
|
+
if (elementsFile) body.elements = parseJsonValue(readFileSync(elementsFile, 'utf-8'), 'Excalidraw elements file', 'Use --elements-file ./scene.excalidraw');
|
|
1374
|
+
applyCommonGeometryFlags(body, flags, {
|
|
1375
|
+
x: 'Use a finite number, e.g. --x 500',
|
|
1376
|
+
y: 'Use a finite number, e.g. --y 300',
|
|
1377
|
+
width: 'Use a positive number, e.g. --width 960',
|
|
1378
|
+
height: 'Use a positive number, e.g. --height 720',
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
const result = await api('POST', '/api/canvas/diagram', body);
|
|
1382
|
+
output(result && typeof result === 'object' && !Array.isArray(result) && 'nodeId' in result && !('id' in result)
|
|
1383
|
+
? { id: (result as { nodeId?: unknown }).nodeId, ...result }
|
|
1384
|
+
: result);
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1315
1387
|
// ── pin ──────────────────────────────────────────────────────
|
|
1316
1388
|
cmd('pin', 'Manage context pins', [
|
|
1317
1389
|
'pmx-canvas pin node1 node2 node3',
|
|
@@ -1966,8 +2038,13 @@ function showCommandHelp(name: string): void {
|
|
|
1966
2038
|
console.log('\nSchema help:');
|
|
1967
2039
|
console.log(' pmx-canvas node add --help --type webpage');
|
|
1968
2040
|
console.log(' pmx-canvas node add --help --type json-render --component Table');
|
|
2041
|
+
console.log(' pmx-canvas node add --help --type graph');
|
|
1969
2042
|
console.log(' pmx-canvas node add --help --type webpage --json');
|
|
1970
2043
|
}
|
|
2044
|
+
if (name === 'node add' || name === 'validate spec') {
|
|
2045
|
+
console.log('\nGraph flags:');
|
|
2046
|
+
console.log(' Graph fields accept kebab-case CLI flags and camelCase schema names, e.g. --graph-type/--graphType and --x-key/--xKey');
|
|
2047
|
+
}
|
|
1971
2048
|
if (name === 'node schema') {
|
|
1972
2049
|
console.log('\nFilters:');
|
|
1973
2050
|
console.log(' --summary Show compact schema summaries');
|
|
@@ -1979,10 +2056,24 @@ function showCommandHelp(name: string): void {
|
|
|
1979
2056
|
console.log(' --summary Return only validation summary metadata');
|
|
1980
2057
|
}
|
|
1981
2058
|
if (name === 'web-artifact build') {
|
|
2059
|
+
console.log('\nDependencies:');
|
|
2060
|
+
console.log(' --deps <list> Add npm dependencies before bundling, e.g. --deps recharts,zod');
|
|
1982
2061
|
console.log('\nOutput control:');
|
|
1983
2062
|
console.log(' --include-logs Include raw build stdout/stderr in the response');
|
|
1984
2063
|
console.log(' --verbose Alias for --include-logs');
|
|
1985
2064
|
}
|
|
2065
|
+
if (name === 'focus') {
|
|
2066
|
+
console.log('\nViewport:');
|
|
2067
|
+
console.log(' --no-pan Select/raise the node without moving the viewport');
|
|
2068
|
+
}
|
|
2069
|
+
if (name === 'external-app add') {
|
|
2070
|
+
console.log('\nOptions:');
|
|
2071
|
+
console.log(' --kind excalidraw External app kind to create');
|
|
2072
|
+
console.log(' --title <title> Node title');
|
|
2073
|
+
console.log(' --elements-json <json> Optional Excalidraw elements array JSON');
|
|
2074
|
+
console.log(' --elements-file <path> Optional file containing Excalidraw elements JSON');
|
|
2075
|
+
console.log(' --initial-file <path> Alias for --elements-file');
|
|
2076
|
+
}
|
|
1986
2077
|
console.log('');
|
|
1987
2078
|
}
|
|
1988
2079
|
|
|
@@ -2023,6 +2114,7 @@ Canvas commands:
|
|
|
2023
2114
|
pmx-canvas validate spec Validate json-render/graph payloads without creating nodes
|
|
2024
2115
|
pmx-canvas watch [options] Watch semantic canvas changes over SSE
|
|
2025
2116
|
pmx-canvas focus <id> Pan viewport to node
|
|
2117
|
+
pmx-canvas external-app add Add hosted external apps like Excalidraw
|
|
2026
2118
|
pmx-canvas webview status Show WebView automation status
|
|
2027
2119
|
pmx-canvas webview start [options] Start or replace automation session
|
|
2028
2120
|
pmx-canvas webview evaluate Evaluate JS in automation session
|
|
@@ -2090,6 +2182,7 @@ Examples:
|
|
|
2090
2182
|
pmx-canvas validate spec --type json-render --spec-file ./dashboard.json --summary
|
|
2091
2183
|
pmx-canvas history --summary
|
|
2092
2184
|
pmx-canvas web-artifact build --title "Dashboard" --app-file ./App.tsx
|
|
2185
|
+
pmx-canvas external-app add --kind excalidraw --title "Diagram"
|
|
2093
2186
|
pmx-canvas web-artifact build --title "Dashboard" --app-file ./App.tsx --include-logs
|
|
2094
2187
|
pmx-canvas webview evaluate --script "const title = document.title; return title"
|
|
2095
2188
|
pmx-canvas snapshot save --name "pre-refactor"
|
package/src/cli/index.ts
CHANGED
|
@@ -31,7 +31,7 @@ if (args.includes('--version') || args.includes('-v')) {
|
|
|
31
31
|
const AGENT_COMMANDS = new Set([
|
|
32
32
|
'node', 'edge', 'search', 'layout', 'status', 'arrange', 'focus',
|
|
33
33
|
'pin', 'undo', 'redo', 'history', 'snapshot', 'diff', 'group', 'webview', 'open',
|
|
34
|
-
'clear', 'code-graph', 'spatial', 'watch', 'web-artifact', 'batch', 'validate', 'serve',
|
|
34
|
+
'clear', 'code-graph', 'spatial', 'watch', 'web-artifact', 'external-app', 'batch', 'validate', 'serve',
|
|
35
35
|
]);
|
|
36
36
|
|
|
37
37
|
const firstArg = args[0] ?? '';
|
|
@@ -501,6 +501,7 @@ Agent CLI (works against running server):
|
|
|
501
501
|
validate spec Validate json-render/graph payloads without creating nodes
|
|
502
502
|
watch [--json] [--events ...] Watch low-token semantic canvas changes
|
|
503
503
|
focus <node-id> Pan to node
|
|
504
|
+
external-app add Add hosted external apps like Excalidraw
|
|
504
505
|
pin <ids...> | --list | --clear Manage context pins
|
|
505
506
|
undo / redo / history Time travel
|
|
506
507
|
snapshot save|list|restore|diff|delete
|
|
@@ -542,6 +543,7 @@ Examples:
|
|
|
542
543
|
pmx-canvas node list List all nodes
|
|
543
544
|
pmx-canvas node schema --type json-render Show running-server schema info
|
|
544
545
|
pmx-canvas web-artifact build --title "Dashboard" --app-file ./App.tsx
|
|
546
|
+
pmx-canvas external-app add --kind excalidraw --title "Diagram"
|
|
545
547
|
pmx-canvas validate spec --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value
|
|
546
548
|
pmx-canvas open Open the workbench in a browser
|
|
547
549
|
pmx-canvas webview status Show WebView automation status
|
package/src/client/App.tsx
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
forceDirectedArrange,
|
|
29
29
|
hasInitialServerLayout,
|
|
30
30
|
nodes,
|
|
31
|
+
pendingExpandedNodeCloseId,
|
|
31
32
|
persistLayout,
|
|
32
33
|
selectedNodeIds,
|
|
33
34
|
sessionId,
|
|
@@ -366,7 +367,7 @@ export function App() {
|
|
|
366
367
|
}
|
|
367
368
|
|
|
368
369
|
// Esc always collapses expanded node first (even from inside inputs)
|
|
369
|
-
if (e.key === 'Escape' && expandedNodeId.value) {
|
|
370
|
+
if (e.key === 'Escape' && expandedNodeId.value && !pendingExpandedNodeCloseId.value) {
|
|
370
371
|
e.preventDefault();
|
|
371
372
|
collapseExpandedNode();
|
|
372
373
|
return;
|
|
@@ -143,9 +143,10 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
143
143
|
const autoFitPersistTimer = useRef<number | null>(null);
|
|
144
144
|
const AUTO_FIT_MAX = 600;
|
|
145
145
|
const TITLEBAR_HEIGHT = 37;
|
|
146
|
+
const isExtAppNode = node.type === 'mcp-app' && node.data.mode === 'ext-app';
|
|
146
147
|
|
|
147
148
|
useEffect(() => {
|
|
148
|
-
if (hasAutoFit.current || node.collapsed || node.dockPosition || node.type === 'group') return;
|
|
149
|
+
if (hasAutoFit.current || node.collapsed || node.dockPosition || node.type === 'group' || isExtAppNode) return;
|
|
149
150
|
const body = bodyRef.current;
|
|
150
151
|
if (!body) return;
|
|
151
152
|
|
|
@@ -180,7 +181,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
180
181
|
autoFitPersistTimer.current = null;
|
|
181
182
|
}
|
|
182
183
|
};
|
|
183
|
-
}, [node.id, node.type, node.collapsed, node.dockPosition, node.size.width, node.size.height]);
|
|
184
|
+
}, [node.id, node.type, isExtAppNode, node.collapsed, node.dockPosition, node.size.width, node.size.height]);
|
|
184
185
|
|
|
185
186
|
const isPinned = node.pinned;
|
|
186
187
|
const isTrace = node.type === 'trace';
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
contextPinnedNodeIds,
|
|
16
16
|
expandedNodeId,
|
|
17
17
|
nodes,
|
|
18
|
+
pendingExpandedNodeCloseId,
|
|
18
19
|
toggleContextPin,
|
|
19
20
|
} from '../state/canvas-store';
|
|
20
21
|
import { TYPE_LABELS } from '../types';
|
|
@@ -115,6 +116,7 @@ export function ExpandedNodeOverlay() {
|
|
|
115
116
|
const words = wordCount(textContent);
|
|
116
117
|
const isCtxPinned = nodeId ? contextPinnedNodeIds.value.has(nodeId) : false;
|
|
117
118
|
const hasText = textContent.length > 0;
|
|
119
|
+
const pendingClose = pendingExpandedNodeCloseId.value === nodeId;
|
|
118
120
|
|
|
119
121
|
return (
|
|
120
122
|
<div
|
|
@@ -130,6 +132,7 @@ export function ExpandedNodeOverlay() {
|
|
|
130
132
|
alignItems: 'stretch',
|
|
131
133
|
justifyContent: 'center',
|
|
132
134
|
padding: '32px',
|
|
135
|
+
pointerEvents: pendingClose ? 'none' : 'auto',
|
|
133
136
|
}}
|
|
134
137
|
>
|
|
135
138
|
<div
|
|
@@ -217,7 +220,9 @@ export function ExpandedNodeOverlay() {
|
|
|
217
220
|
)}
|
|
218
221
|
</div>
|
|
219
222
|
|
|
220
|
-
<span style={{ fontSize: '10px', color: 'var(--c-muted)' }}>
|
|
223
|
+
<span style={{ fontSize: '10px', color: pendingClose ? 'var(--c-warn)' : 'var(--c-muted)' }}>
|
|
224
|
+
{pendingClose ? 'Saving edits...' : 'Esc to close'}
|
|
225
|
+
</span>
|
|
221
226
|
<button
|
|
222
227
|
type="button"
|
|
223
228
|
onClick={handleClose}
|
|
@@ -20,6 +20,11 @@ type IframeLoadTarget = Pick<
|
|
|
20
20
|
|
|
21
21
|
type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
|
|
22
22
|
type DisplayMode = 'inline' | 'fullscreen' | 'pip';
|
|
23
|
+
const DEFAULT_EXT_APP_SANDBOX = 'allow-scripts allow-popups allow-popups-to-escape-sandbox';
|
|
24
|
+
|
|
25
|
+
interface ExtAppHostDimensionsTarget {
|
|
26
|
+
getBoundingClientRect(): Pick<DOMRectReadOnly, 'width' | 'height'>;
|
|
27
|
+
}
|
|
23
28
|
|
|
24
29
|
async function postJson<T>(url: string, body: Record<string, unknown>): Promise<T> {
|
|
25
30
|
const response = await fetch(url, {
|
|
@@ -97,6 +102,33 @@ export async function sendExtAppBootstrapState(
|
|
|
97
102
|
}
|
|
98
103
|
}
|
|
99
104
|
|
|
105
|
+
export function resolveExtAppSandbox(value: unknown): string {
|
|
106
|
+
return typeof value === 'string' && value.trim().length > 0
|
|
107
|
+
? value.trim()
|
|
108
|
+
: DEFAULT_EXT_APP_SANDBOX;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function positiveDimension(value: number, fallback: number): number {
|
|
112
|
+
if (Number.isFinite(value) && value > 0) return Math.round(value);
|
|
113
|
+
if (Number.isFinite(fallback) && fallback > 0) return Math.round(fallback);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function resolveExtAppContainerDimensions(
|
|
118
|
+
target: ExtAppHostDimensionsTarget | null | undefined,
|
|
119
|
+
fallback: { width: number; height: number },
|
|
120
|
+
): { width: number; height: number } {
|
|
121
|
+
const rect = target?.getBoundingClientRect();
|
|
122
|
+
return {
|
|
123
|
+
width: positiveDimension(rect?.width ?? 0, fallback.width),
|
|
124
|
+
height: positiveDimension(rect?.height ?? 0, fallback.height),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function shouldApplyExtAppSizeChange(height: unknown, isExpanded: boolean): height is number {
|
|
129
|
+
return typeof height === 'number' && Number.isFinite(height) && height > 0 && !isExpanded;
|
|
130
|
+
}
|
|
131
|
+
|
|
100
132
|
export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
101
133
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
102
134
|
const bridgeRef = useRef<AppBridge | null>(null);
|
|
@@ -122,7 +154,10 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
122
154
|
const rawToolCallId = node.data.toolCallId;
|
|
123
155
|
const toolCallId: RequestId | undefined =
|
|
124
156
|
typeof rawToolCallId === 'string' || typeof rawToolCallId === 'number' ? rawToolCallId : undefined;
|
|
125
|
-
const resourceMeta = node.data.resourceMeta as {
|
|
157
|
+
const resourceMeta = node.data.resourceMeta as {
|
|
158
|
+
csp?: Record<string, unknown>;
|
|
159
|
+
permissions?: Record<string, unknown>;
|
|
160
|
+
} | undefined;
|
|
126
161
|
const sessionStatus = node.data.sessionStatus as string | undefined;
|
|
127
162
|
const sessionError = node.data.sessionError as string | undefined;
|
|
128
163
|
const maxHeight = node.size.height;
|
|
@@ -191,6 +226,8 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
191
226
|
if (!iframe) return;
|
|
192
227
|
let disposed = false;
|
|
193
228
|
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
|
229
|
+
let hostContextResizeObserver: ResizeObserver | null = null;
|
|
230
|
+
let hostContextRaf: number | null = null;
|
|
194
231
|
toolResultSentRef.current = false;
|
|
195
232
|
lastSentToolResultRef.current = undefined;
|
|
196
233
|
toolResultSendingRef.current = null;
|
|
@@ -213,6 +250,33 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
213
250
|
throw new Error('Ext-app iframe window is unavailable');
|
|
214
251
|
}
|
|
215
252
|
|
|
253
|
+
const buildHostContext = (displayMode: DisplayMode = expandedNodeId.value === nodeId ? 'fullscreen' : 'inline') => ({
|
|
254
|
+
theme: toMcpTheme(canvasTheme.value),
|
|
255
|
+
platform: 'web' as const,
|
|
256
|
+
containerDimensions: resolveExtAppContainerDimensions(iframe, {
|
|
257
|
+
width: node.size.width,
|
|
258
|
+
height: maxHeight,
|
|
259
|
+
}),
|
|
260
|
+
displayMode,
|
|
261
|
+
locale: navigator.language,
|
|
262
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
263
|
+
...(toolDefinition ? {
|
|
264
|
+
toolInfo: {
|
|
265
|
+
id: toolCallId,
|
|
266
|
+
tool: toolDefinition,
|
|
267
|
+
},
|
|
268
|
+
} : {}),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const scheduleHostContextUpdate = () => {
|
|
272
|
+
if (hostContextRaf !== null) return;
|
|
273
|
+
hostContextRaf = requestAnimationFrame(() => {
|
|
274
|
+
hostContextRaf = null;
|
|
275
|
+
if (disposed || !bridgeReadyRef.current) return;
|
|
276
|
+
bridge.setHostContext?.(buildHostContext());
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
|
|
216
280
|
const bridge = new AppBridge(
|
|
217
281
|
null,
|
|
218
282
|
{ name: 'PMX Canvas', version: '1.0.0' },
|
|
@@ -224,26 +288,15 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
224
288
|
updateModelContext: { text: {}, structuredContent: {} },
|
|
225
289
|
},
|
|
226
290
|
{
|
|
227
|
-
hostContext:
|
|
228
|
-
theme: toMcpTheme(canvasTheme.value),
|
|
229
|
-
platform: 'web',
|
|
230
|
-
containerDimensions: { maxHeight },
|
|
231
|
-
displayMode: isExpanded ? 'fullscreen' : 'inline',
|
|
232
|
-
locale: navigator.language,
|
|
233
|
-
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
234
|
-
...(toolDefinition ? {
|
|
235
|
-
toolInfo: {
|
|
236
|
-
id: toolCallId,
|
|
237
|
-
tool: toolDefinition,
|
|
238
|
-
},
|
|
239
|
-
} : {}),
|
|
240
|
-
},
|
|
291
|
+
hostContext: buildHostContext(isExpanded ? 'fullscreen' : 'inline'),
|
|
241
292
|
},
|
|
242
293
|
);
|
|
243
294
|
|
|
244
295
|
// Register handlers BEFORE connect
|
|
245
296
|
bridge.onsizechange = async ({ height }) => {
|
|
246
|
-
if (height
|
|
297
|
+
if (shouldApplyExtAppSizeChange(height, expandedNodeId.value === nodeId)) {
|
|
298
|
+
iframe.style.height = `${height}px`;
|
|
299
|
+
}
|
|
247
300
|
return {};
|
|
248
301
|
};
|
|
249
302
|
|
|
@@ -252,6 +305,15 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
252
305
|
return {};
|
|
253
306
|
};
|
|
254
307
|
|
|
308
|
+
bridge.onsandboxready = async () => {
|
|
309
|
+
await bridge.sendSandboxResourceReady({
|
|
310
|
+
html,
|
|
311
|
+
sandbox: DEFAULT_EXT_APP_SANDBOX,
|
|
312
|
+
...(resourceMeta?.csp ? { csp: resourceMeta.csp } : {}),
|
|
313
|
+
...(resourceMeta?.permissions ? { permissions: resourceMeta.permissions } : {}),
|
|
314
|
+
});
|
|
315
|
+
};
|
|
316
|
+
|
|
255
317
|
// Handle native fullscreen requests from the widget (e.g. Excalidraw expand button)
|
|
256
318
|
bridge.onrequestdisplaymode = async ({ mode }) => {
|
|
257
319
|
const { nextMode, shouldExpand, shouldCollapse } = resolveExtAppDisplayModeRequest(mode, isExpanded);
|
|
@@ -333,6 +395,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
333
395
|
bridgeReadyRef.current = true;
|
|
334
396
|
setStatus('ready');
|
|
335
397
|
setError(null);
|
|
398
|
+
scheduleHostContextUpdate();
|
|
336
399
|
void sendExtAppBootstrapState(bridge, latestToolInputRef.current, undefined)
|
|
337
400
|
.then(() => flushToolResult(bridge))
|
|
338
401
|
.catch((err) => {
|
|
@@ -370,6 +433,9 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
370
433
|
}
|
|
371
434
|
bridgeRef.current = bridge;
|
|
372
435
|
transportRef.current = transport;
|
|
436
|
+
hostContextResizeObserver = new ResizeObserver(scheduleHostContextUpdate);
|
|
437
|
+
hostContextResizeObserver.observe(iframe);
|
|
438
|
+
if (iframe.parentElement) hostContextResizeObserver.observe(iframe.parentElement);
|
|
373
439
|
|
|
374
440
|
// Propagate theme changes to ext-app iframe. Read current expanded state
|
|
375
441
|
// at fire time so the widget keeps its fullscreen/inline context accurate.
|
|
@@ -378,12 +444,8 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
378
444
|
if (firstFire) { firstFire = false; return; }
|
|
379
445
|
if (disposed) return;
|
|
380
446
|
bridge.setHostContext?.({
|
|
447
|
+
...buildHostContext(),
|
|
381
448
|
theme: toMcpTheme(newTheme),
|
|
382
|
-
platform: 'web',
|
|
383
|
-
containerDimensions: { maxHeight },
|
|
384
|
-
displayMode: expandedNodeId.value === nodeId ? 'fullscreen' : 'inline',
|
|
385
|
-
locale: navigator.language,
|
|
386
|
-
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
387
449
|
});
|
|
388
450
|
});
|
|
389
451
|
|
|
@@ -399,6 +461,12 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
399
461
|
return () => {
|
|
400
462
|
disposed = true;
|
|
401
463
|
clearFallbackTimer();
|
|
464
|
+
hostContextResizeObserver?.disconnect();
|
|
465
|
+
hostContextResizeObserver = null;
|
|
466
|
+
if (hostContextRaf !== null) {
|
|
467
|
+
cancelAnimationFrame(hostContextRaf);
|
|
468
|
+
hostContextRaf = null;
|
|
469
|
+
}
|
|
402
470
|
bridgeReadyRef.current = false;
|
|
403
471
|
toolResultSendingRef.current = null;
|
|
404
472
|
themeUnsubRef.current?.();
|
|
@@ -431,7 +499,10 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
431
499
|
bridge.setHostContext?.({
|
|
432
500
|
theme: toMcpTheme(canvasTheme.value),
|
|
433
501
|
platform: 'web',
|
|
434
|
-
containerDimensions: {
|
|
502
|
+
containerDimensions: resolveExtAppContainerDimensions(iframeRef.current, {
|
|
503
|
+
width: node.size.width,
|
|
504
|
+
height: maxHeight,
|
|
505
|
+
}),
|
|
435
506
|
displayMode: isExpanded ? 'fullscreen' : 'inline',
|
|
436
507
|
locale: navigator.language,
|
|
437
508
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
@@ -470,7 +541,7 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
470
541
|
}
|
|
471
542
|
|
|
472
543
|
return (
|
|
473
|
-
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
544
|
+
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
|
474
545
|
{sessionStatus && sessionStatus !== 'ready' && (
|
|
475
546
|
<div
|
|
476
547
|
style={{
|
|
@@ -546,14 +617,14 @@ export function ExtAppFrame({ node }: { node: CanvasNodeState }) {
|
|
|
546
617
|
remounts the iframe in the overlay — forcing the user to click Edit
|
|
547
618
|
a second time to actually enter edit mode. Routing all inline clicks
|
|
548
619
|
to "expand" makes the flow "open → edit" instead of "edit → expand → edit". */}
|
|
549
|
-
<div style={{ flex: 1, position: 'relative', display: 'flex', minHeight: 0 }}>
|
|
620
|
+
<div style={{ flex: 1, position: 'relative', display: 'flex', minHeight: 0, height: '100%' }}>
|
|
550
621
|
<iframe
|
|
551
622
|
key={frameKey}
|
|
552
623
|
ref={iframeRef}
|
|
553
624
|
srcdoc={html}
|
|
554
|
-
sandbox=
|
|
625
|
+
sandbox={resolveExtAppSandbox(null)}
|
|
555
626
|
allow={buildAllowAttribute(resourceMeta?.permissions)}
|
|
556
|
-
style={{ flex: 1, border: 'none', background: 'var(--c-panel)' }}
|
|
627
|
+
style={{ flex: 1, width: '100%', height: '100%', minHeight: 0, border: 'none', background: 'var(--c-panel)' }}
|
|
557
628
|
title={`Ext App: ${toolName}`}
|
|
558
629
|
/>
|
|
559
630
|
{!isExpanded && (
|