pmx-canvas 0.1.13 → 0.1.15

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/Readme.md +108 -1058
  3. package/dist/canvas/global.css +141 -0
  4. package/dist/canvas/index.js +137 -87
  5. package/dist/json-render/index.css +1 -1
  6. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -3
  7. package/dist/types/client/nodes/HtmlNode.d.ts +5 -0
  8. package/dist/types/client/nodes/McpAppNode.d.ts +2 -1
  9. package/dist/types/client/state/canvas-store.d.ts +5 -1
  10. package/dist/types/client/state/intent-bridge.d.ts +3 -1
  11. package/dist/types/client/types.d.ts +2 -2
  12. package/dist/types/json-render/catalog.d.ts +1 -1
  13. package/dist/types/mcp/canvas-access.d.ts +7 -1
  14. package/dist/types/server/agent-context.d.ts +1 -0
  15. package/dist/types/server/canvas-operations.d.ts +12 -2
  16. package/dist/types/server/canvas-provenance.d.ts +1 -1
  17. package/dist/types/server/canvas-serialization.d.ts +3 -0
  18. package/dist/types/server/canvas-state.d.ts +51 -4
  19. package/dist/types/server/demo.d.ts +5 -0
  20. package/dist/types/server/diagram-presets.d.ts +4 -0
  21. package/dist/types/server/index.d.ts +21 -3
  22. package/dist/types/server/mcp-app-runtime.d.ts +1 -0
  23. package/dist/types/server/web-artifacts.d.ts +18 -0
  24. package/dist/types/shared/canvas-node-kind.d.ts +5 -0
  25. package/package.json +1 -1
  26. package/skills/pmx-canvas/SKILL.md +43 -0
  27. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  28. package/src/cli/agent.ts +66 -5
  29. package/src/cli/index.ts +2 -23
  30. package/src/client/canvas/AttentionHistory.tsx +14 -1
  31. package/src/client/canvas/CanvasNode.tsx +1 -1
  32. package/src/client/canvas/CanvasViewport.tsx +3 -0
  33. package/src/client/canvas/DockedNode.tsx +110 -12
  34. package/src/client/canvas/ExpandedNodeOverlay.tsx +8 -3
  35. package/src/client/canvas/Minimap.tsx +1 -0
  36. package/src/client/icons.tsx +1 -0
  37. package/src/client/nodes/ExtAppFrame.tsx +10 -35
  38. package/src/client/nodes/HtmlNode.tsx +151 -0
  39. package/src/client/nodes/McpAppNode.tsx +2 -2
  40. package/src/client/state/canvas-store.ts +24 -2
  41. package/src/client/state/intent-bridge.ts +4 -3
  42. package/src/client/state/sse-bridge.ts +2 -0
  43. package/src/client/theme/global.css +141 -0
  44. package/src/client/types.ts +3 -0
  45. package/src/mcp/canvas-access.ts +34 -7
  46. package/src/mcp/server.ts +199 -26
  47. package/src/server/agent-context.ts +50 -3
  48. package/src/server/canvas-operations.ts +55 -3
  49. package/src/server/canvas-provenance.ts +2 -1
  50. package/src/server/canvas-serialization.ts +38 -13
  51. package/src/server/canvas-state.ts +305 -34
  52. package/src/server/demo.ts +792 -0
  53. package/src/server/diagram-presets.ts +45 -25
  54. package/src/server/index.ts +64 -7
  55. package/src/server/mcp-app-runtime.ts +15 -5
  56. package/src/server/server.ts +169 -63
  57. package/src/server/web-artifacts.ts +116 -3
  58. package/src/shared/canvas-node-kind.ts +14 -0
@@ -65,7 +65,13 @@ import {
65
65
  import { findOpenCanvasPosition, computeGroupBounds } from './placement.js';
66
66
  import { searchNodes, buildSpatialContext } from './spatial-analysis.js';
67
67
  import { diffLayouts, formatDiff, mutationHistory } from './mutation-history.js';
68
- import { buildCanvasSummary, serializeCanvasLayout, serializeCanvasNode } from './canvas-serialization.js';
68
+ import {
69
+ buildCanvasSummary,
70
+ serializeCanvasLayout,
71
+ serializeCanvasLayoutWithBlobSummaries,
72
+ serializeCanvasNode,
73
+ serializeCanvasNodeWithBlobSummaries,
74
+ } from './canvas-serialization.js';
69
75
  import { buildCodeGraphSummary, formatCodeGraph } from './code-graph.js';
70
76
  import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
71
77
  import { validateLocalImageFile } from './image-source.js';
@@ -82,6 +88,7 @@ import {
82
88
  deleteCanvasSnapshot,
83
89
  executeCanvasBatch,
84
90
  fitCanvasView,
91
+ gcCanvasSnapshots,
85
92
  groupCanvasNodes,
86
93
  listCanvasSnapshots,
87
94
  refreshCanvasWebpageNode,
@@ -91,11 +98,14 @@ import {
91
98
  saveCanvasSnapshot,
92
99
  scheduleCodeGraphRecompute,
93
100
  primeCanvasRuntimeBackends,
101
+ setCanvasLayoutUpdateEmitter,
94
102
  syncCanvasRuntimeBackends,
95
103
  setCanvasContextPins,
96
104
  ungroupCanvasNodes,
97
105
  validateCanvasNodePatch,
98
106
  hasStructuredNodeUpdateFields,
107
+ hasTraceNodeDataFields,
108
+ mergeTraceNodeDataFields,
99
109
  } from './canvas-operations.js';
100
110
  import { validateCanvasLayout } from './canvas-validation.js';
101
111
  import { describeCanvasSchema, validateStructuredCanvasPayload } from './canvas-schema.js';
@@ -1184,7 +1194,15 @@ function serveCanvasFavicon(): Response {
1184
1194
  async function handleCanvasUpdate(req: Request): Promise<Response> {
1185
1195
  const body = await readJson(req);
1186
1196
  const updates = Array.isArray(body.updates) ? body.updates : [];
1187
- const result = applyCanvasNodeUpdates(updates);
1197
+ const result = body.recordHistory === false
1198
+ ? (() => {
1199
+ let suppressedResult: ReturnType<typeof applyCanvasNodeUpdates> = { applied: 0, skipped: updates.length };
1200
+ canvasState.withSuppressedRecording(() => {
1201
+ suppressedResult = applyCanvasNodeUpdates(updates);
1202
+ });
1203
+ return suppressedResult;
1204
+ })()
1205
+ : applyCanvasNodeUpdates(updates);
1188
1206
  if (result.applied > 0) {
1189
1207
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1190
1208
  }
@@ -1234,7 +1252,7 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
1234
1252
  }
1235
1253
 
1236
1254
  // ── Add node from client ─────────────────────────────────────
1237
- const VALID_NODE_TYPES = new Set(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'mcp-app', 'webpage', 'group']);
1255
+ const VALID_NODE_TYPES = new Set(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'mcp-app', 'webpage', 'html', 'group']);
1238
1256
 
1239
1257
  function buildNodeResponse(node: CanvasNodeState): Record<string, unknown> {
1240
1258
  const serialized = serializeCanvasNode(node);
@@ -1323,6 +1341,11 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1323
1341
  const content = type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string'
1324
1342
  ? body.path
1325
1343
  : body.content;
1344
+ // For html nodes, accept top-level `html` field and merge into data so callers
1345
+ // can POST { type: 'html', title, html } without nesting under `data`.
1346
+ const htmlMergedData = type === 'html' && typeof body.html === 'string'
1347
+ ? { ...(extraData ?? {}), html: body.html }
1348
+ : extraData;
1326
1349
  let added: ReturnType<typeof addCanvasNode>;
1327
1350
  const geometry = resolveCreateGeometry(body);
1328
1351
  try {
@@ -1330,11 +1353,17 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1330
1353
  type: type as CanvasNodeState['type'],
1331
1354
  ...(typeof body.title === 'string' ? { title: body.title } : {}),
1332
1355
  ...(typeof content === 'string' ? { content } : {}),
1333
- ...(extraData ? { data: extraData } : {}),
1356
+ ...(htmlMergedData ? { data: htmlMergedData } : {}),
1357
+ ...(type === 'trace' && typeof body.toolName === 'string' ? { toolName: body.toolName } : {}),
1358
+ ...(type === 'trace' && typeof body.category === 'string' ? { category: body.category } : {}),
1359
+ ...(type === 'trace' && typeof body.status === 'string' ? { status: body.status } : {}),
1360
+ ...(type === 'trace' && typeof body.duration === 'string' ? { duration: body.duration } : {}),
1361
+ ...(type === 'trace' && typeof body.resultSummary === 'string' ? { resultSummary: body.resultSummary } : {}),
1362
+ ...(type === 'trace' && typeof body.error === 'string' ? { error: body.error } : {}),
1334
1363
  ...(body.strictSize === true ? { strictSize: true } : {}),
1335
1364
  ...geometry,
1336
- defaultWidth: 360,
1337
- defaultHeight: 200,
1365
+ defaultWidth: type === 'html' ? 720 : 360,
1366
+ defaultHeight: type === 'html' ? 640 : 200,
1338
1367
  fileMode: 'auto',
1339
1368
  });
1340
1369
  } catch (error) {
@@ -1495,7 +1524,14 @@ async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Res
1495
1524
  } catch (error) {
1496
1525
  return responseJson({ ok: false, error: error instanceof Error ? error.message : String(error) }, 400);
1497
1526
  }
1498
- } else if (body.title !== undefined || body.content !== undefined || body.data || typeof body.arrangeLocked === 'boolean' || typeof body.strictSize === 'boolean') {
1527
+ } else if (
1528
+ body.title !== undefined ||
1529
+ body.content !== undefined ||
1530
+ body.data ||
1531
+ typeof body.arrangeLocked === 'boolean' ||
1532
+ typeof body.strictSize === 'boolean' ||
1533
+ (existing.type === 'trace' && hasTraceNodeDataFields(body))
1534
+ ) {
1499
1535
  const data = { ...existing.data };
1500
1536
  if (body.title !== undefined) {
1501
1537
  data.title = String(body.title);
@@ -1524,7 +1560,9 @@ async function handleCanvasUpdateNode(nodeId: string, req: Request): Promise<Res
1524
1560
  }
1525
1561
  }
1526
1562
  }
1527
- patch.data = data;
1563
+ patch.data = existing.type === 'trace'
1564
+ ? mergeTraceNodeDataFields(data, body)
1565
+ : data;
1528
1566
  }
1529
1567
  const error = validateCanvasNodePatch({
1530
1568
  ...(patch.position ? { position: patch.position as { x: number; y: number } } : {}),
@@ -1639,7 +1677,10 @@ async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
1639
1677
  bytes: result.fileSize,
1640
1678
  projectPath: result.projectPath,
1641
1679
  openedInCanvas: result.openedInCanvas,
1680
+ startedAt: result.startedAt,
1642
1681
  completedAt: result.completedAt,
1682
+ durationMs: result.durationMs,
1683
+ timeoutMs: result.timeoutMs,
1643
1684
  // `id` is the canvas node id alias used by every other add-style
1644
1685
  // response. It is only present when a canvas node was actually
1645
1686
  // created (i.e. openInCanvas was not explicitly disabled). When
@@ -1888,6 +1929,13 @@ function responseText(text: string, status = 400): Response {
1888
1929
  });
1889
1930
  }
1890
1931
 
1932
+ function parsePositiveIntegerParam(value: string | null): number | undefined {
1933
+ if (!value) return undefined;
1934
+ const parsed = Number(value);
1935
+ if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
1936
+ return Math.floor(parsed);
1937
+ }
1938
+
1891
1939
  function handleArtifactView(url: URL): Response {
1892
1940
  const pathLike = url.searchParams.get('path') ?? '';
1893
1941
  const safePath = resolveWorkspaceArtifactPath(pathLike);
@@ -2111,29 +2159,44 @@ interface RunAndEmitOpenMcpAppParams {
2111
2159
  transport: ExternalMcpTransportConfig;
2112
2160
  toolName: string;
2113
2161
  toolArguments?: Record<string, unknown>;
2162
+ nodeId?: string;
2114
2163
  serverName?: string;
2115
2164
  title?: string;
2116
2165
  x?: number;
2117
2166
  y?: number;
2118
2167
  width?: number;
2119
2168
  height?: number;
2169
+ timeoutMs?: number;
2120
2170
  }
2121
2171
 
2122
2172
  async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise<Response> {
2123
2173
  try {
2174
+ const targetNode = params.nodeId ? canvasState.getNode(params.nodeId) : undefined;
2175
+ if (params.nodeId && !targetNode) {
2176
+ return responseJson({ ok: false, error: `Node "${params.nodeId}" not found.` }, 404);
2177
+ }
2178
+ if (targetNode && (targetNode.type !== 'mcp-app' || targetNode.data.mode !== 'ext-app')) {
2179
+ return responseJson({ ok: false, error: `Node "${params.nodeId}" is not an external app node.` }, 400);
2180
+ }
2181
+
2124
2182
  const opened = await openMcpApp({
2125
2183
  transport: params.transport,
2126
2184
  toolName: params.toolName,
2127
2185
  ...(params.toolArguments ? { toolArguments: params.toolArguments } : {}),
2128
2186
  ...(params.serverName ? { serverName: params.serverName } : {}),
2187
+ ...(typeof params.timeoutMs === 'number' ? { timeoutMs: params.timeoutMs } : {}),
2129
2188
  });
2130
2189
 
2131
2190
  const toolCallId = randomExtAppToolCallId();
2132
- const nodeIdSeed = toolCallId.startsWith('ext-app-') ? toolCallId : `ext-app-${toolCallId}`;
2191
+ if (params.nodeId) closeNodeAppSession(targetNode);
2192
+ const nodeIdSeed = params.nodeId ?? (toolCallId.startsWith('ext-app-') ? toolCallId : `ext-app-${toolCallId}`);
2133
2193
  const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
2134
2194
  ? ensureExcalidrawCheckpointId(opened.toolResult, nodeIdSeed)
2135
2195
  : opened.toolResult;
2136
- const nodeTitle = params.title ?? opened.tool.title ?? opened.tool.name;
2196
+ const nodeTitle = params.title
2197
+ ?? (typeof targetNode?.data.title === 'string' ? targetNode.data.title : undefined)
2198
+ ?? opened.tool.title
2199
+ ?? opened.tool.name;
2137
2200
 
2138
2201
  emitPrimaryWorkbenchEvent('ext-app-open', {
2139
2202
  toolCallId,
@@ -2163,7 +2226,7 @@ async function runAndEmitOpenMcpApp(params: RunAndEmitOpenMcpAppParams): Promise
2163
2226
  success: toolResult.isError !== true,
2164
2227
  result: toolResult,
2165
2228
  });
2166
- const nodeId = findCanvasExtAppNodeId(toolCallId);
2229
+ const nodeId = params.nodeId ?? findCanvasExtAppNodeId(toolCallId);
2167
2230
 
2168
2231
  return responseJson({
2169
2232
  ok: true,
@@ -2202,17 +2265,22 @@ async function handleCanvasOpenMcpApp(req: Request): Promise<Response> {
2202
2265
  const requestedServerName = typeof body.serverName === 'string' && body.serverName.trim().length > 0
2203
2266
  ? body.serverName.trim()
2204
2267
  : undefined;
2268
+ const requestedNodeId = typeof body.nodeId === 'string' && body.nodeId.trim().length > 0
2269
+ ? body.nodeId.trim()
2270
+ : undefined;
2205
2271
 
2206
2272
  return runAndEmitOpenMcpApp({
2207
2273
  transport,
2208
2274
  toolName,
2209
2275
  ...(toolArguments ? { toolArguments } : {}),
2276
+ ...(requestedNodeId ? { nodeId: requestedNodeId } : {}),
2210
2277
  ...(requestedServerName ? { serverName: requestedServerName } : {}),
2211
2278
  ...(requestedTitle ? { title: requestedTitle } : {}),
2212
2279
  ...(typeof body.x === 'number' ? { x: body.x } : {}),
2213
2280
  ...(typeof body.y === 'number' ? { y: body.y } : {}),
2214
2281
  ...(typeof body.width === 'number' ? { width: body.width } : {}),
2215
2282
  ...(typeof body.height === 'number' ? { height: body.height } : {}),
2283
+ ...(typeof body.timeoutMs === 'number' ? { timeoutMs: body.timeoutMs } : {}),
2216
2284
  });
2217
2285
  }
2218
2286
 
@@ -2222,11 +2290,13 @@ async function handleCanvasAddDiagram(req: Request): Promise<Response> {
2222
2290
  try {
2223
2291
  built = buildExcalidrawOpenMcpAppInput({
2224
2292
  elements: body.elements,
2293
+ ...(typeof body.nodeId === 'string' ? { nodeId: body.nodeId } : {}),
2225
2294
  ...(typeof body.title === 'string' ? { title: body.title } : {}),
2226
2295
  ...(typeof body.x === 'number' ? { x: body.x } : {}),
2227
2296
  ...(typeof body.y === 'number' ? { y: body.y } : {}),
2228
2297
  ...(typeof body.width === 'number' ? { width: body.width } : {}),
2229
2298
  ...(typeof body.height === 'number' ? { height: body.height } : {}),
2299
+ ...(typeof body.timeoutMs === 'number' ? { timeoutMs: body.timeoutMs } : {}),
2230
2300
  });
2231
2301
  } catch (error) {
2232
2302
  return responseJson({
@@ -2239,11 +2309,13 @@ async function handleCanvasAddDiagram(req: Request): Promise<Response> {
2239
2309
  toolName: built.toolName,
2240
2310
  toolArguments: built.toolArguments,
2241
2311
  serverName: built.serverName,
2312
+ ...(built.nodeId ? { nodeId: built.nodeId } : {}),
2242
2313
  ...(built.title ? { title: built.title } : {}),
2243
2314
  ...(typeof built.x === 'number' ? { x: built.x } : {}),
2244
2315
  ...(typeof built.y === 'number' ? { y: built.y } : {}),
2245
2316
  ...(typeof built.width === 'number' ? { width: built.width } : {}),
2246
2317
  ...(typeof built.height === 'number' ? { height: built.height } : {}),
2318
+ ...(typeof built.timeoutMs === 'number' ? { timeoutMs: built.timeoutMs } : {}),
2247
2319
  });
2248
2320
  }
2249
2321
 
@@ -3044,6 +3116,18 @@ async function handleSnapshotSave(req: Request): Promise<Response> {
3044
3116
  return responseJson({ ok: true, id: snapshot.id, snapshot });
3045
3117
  }
3046
3118
 
3119
+ async function handleSnapshotGc(req: Request): Promise<Response> {
3120
+ const body = await readJson(req);
3121
+ const keepValue = body.keep;
3122
+ const keep = typeof keepValue === 'number'
3123
+ ? keepValue
3124
+ : typeof keepValue === 'string'
3125
+ ? Number(keepValue)
3126
+ : undefined;
3127
+ const dryRun = body.dryRun === true || body['dry-run'] === true;
3128
+ return responseJson(gcCanvasSnapshots({ keep, dryRun }));
3129
+ }
3130
+
3047
3131
  async function handleContextPinsUpdate(req: Request): Promise<Response> {
3048
3132
  const body = await readJson(req);
3049
3133
  const MAX_PINS = 20;
@@ -3284,9 +3368,9 @@ function syncContextNodeToCanvasState(
3284
3368
  position: { x: 1130, y: 80 },
3285
3369
  size: { width: 320, height: 400 },
3286
3370
  zIndex: 1,
3287
- collapsed: false,
3371
+ collapsed: true,
3288
3372
  pinned: false,
3289
- dockPosition: null,
3373
+ dockPosition: 'right',
3290
3374
  data: mergedData,
3291
3375
  });
3292
3376
  return;
@@ -3408,52 +3492,56 @@ function syncEventToCanvasState(event: string, payload: PrimaryWorkbenchEventPay
3408
3492
  });
3409
3493
  }
3410
3494
  } else if (event === 'ext-app-update') {
3411
- const toolCallId = payload.toolCallId as string;
3412
- if (!toolCallId) return;
3413
- const payloadNodeId = typeof payload.nodeId === 'string' ? payload.nodeId : '';
3414
- const id =
3415
- (payloadNodeId && canvasState.getNode(payloadNodeId) ? payloadNodeId : null) ||
3416
- findCanvasExtAppNodeId(toolCallId) ||
3417
- (typeof payload.serverName === 'string' && typeof payload.toolName === 'string'
3418
- ? findOnlyPendingCanvasExtAppNodeId(payload.serverName, payload.toolName)
3419
- : null);
3420
- if (!id) return;
3421
- const existing = canvasState.getNode(id);
3422
- if (existing) {
3423
- canvasState.updateNode(id, { data: { ...existing.data, html: payload.html } });
3424
- }
3495
+ canvasState.withSuppressedRecording(() => {
3496
+ const toolCallId = payload.toolCallId as string;
3497
+ if (!toolCallId) return;
3498
+ const payloadNodeId = typeof payload.nodeId === 'string' ? payload.nodeId : '';
3499
+ const id =
3500
+ (payloadNodeId && canvasState.getNode(payloadNodeId) ? payloadNodeId : null) ||
3501
+ findCanvasExtAppNodeId(toolCallId) ||
3502
+ (typeof payload.serverName === 'string' && typeof payload.toolName === 'string'
3503
+ ? findOnlyPendingCanvasExtAppNodeId(payload.serverName, payload.toolName)
3504
+ : null);
3505
+ if (!id) return;
3506
+ const existing = canvasState.getNode(id);
3507
+ if (existing) {
3508
+ canvasState.updateNode(id, { data: { ...existing.data, html: payload.html } });
3509
+ }
3510
+ });
3425
3511
  } else if (event === 'ext-app-result') {
3426
- const toolCallId = payload.toolCallId as string;
3427
- if (!toolCallId) return;
3428
- const payloadNodeId = typeof payload.nodeId === 'string' ? payload.nodeId : '';
3429
- const id =
3430
- (payloadNodeId && canvasState.getNode(payloadNodeId) ? payloadNodeId : null) ||
3431
- findCanvasExtAppNodeId(toolCallId) ||
3432
- (typeof payload.serverName === 'string' && typeof payload.toolName === 'string'
3433
- ? findOnlyPendingCanvasExtAppNodeId(payload.serverName, payload.toolName)
3434
- : null);
3435
- if (!id) return;
3436
- if (payload.success === false) {
3437
- closeNodeAppSession(canvasState.getNode(id));
3438
- canvasState.removeNode(id);
3439
- return;
3440
- }
3441
- const existing = canvasState.getNode(id);
3442
- if (existing) {
3443
- canvasState.updateNode(id, {
3444
- data: {
3445
- ...existing.data,
3446
- toolResult: normalizeExtAppToolResult({
3447
- result: payload.result,
3448
- success: typeof payload.success === 'boolean' ? payload.success : undefined,
3449
- error: typeof payload.error === 'string' ? payload.error : undefined,
3450
- content: typeof payload.content === 'string' ? payload.content : undefined,
3451
- detailedContent:
3452
- typeof payload.detailedContent === 'string' ? payload.detailedContent : undefined,
3453
- }),
3454
- },
3455
- });
3456
- }
3512
+ canvasState.withSuppressedRecording(() => {
3513
+ const toolCallId = payload.toolCallId as string;
3514
+ if (!toolCallId) return;
3515
+ const payloadNodeId = typeof payload.nodeId === 'string' ? payload.nodeId : '';
3516
+ const id =
3517
+ (payloadNodeId && canvasState.getNode(payloadNodeId) ? payloadNodeId : null) ||
3518
+ findCanvasExtAppNodeId(toolCallId) ||
3519
+ (typeof payload.serverName === 'string' && typeof payload.toolName === 'string'
3520
+ ? findOnlyPendingCanvasExtAppNodeId(payload.serverName, payload.toolName)
3521
+ : null);
3522
+ if (!id) return;
3523
+ if (payload.success === false) {
3524
+ closeNodeAppSession(canvasState.getNode(id));
3525
+ canvasState.removeNode(id);
3526
+ return;
3527
+ }
3528
+ const existing = canvasState.getNode(id);
3529
+ if (existing) {
3530
+ canvasState.updateNode(id, {
3531
+ data: {
3532
+ ...existing.data,
3533
+ toolResult: normalizeExtAppToolResult({
3534
+ result: payload.result,
3535
+ success: typeof payload.success === 'boolean' ? payload.success : undefined,
3536
+ error: typeof payload.error === 'string' ? payload.error : undefined,
3537
+ content: typeof payload.content === 'string' ? payload.content : undefined,
3538
+ detailedContent:
3539
+ typeof payload.detailedContent === 'string' ? payload.detailedContent : undefined,
3540
+ }),
3541
+ },
3542
+ });
3543
+ }
3544
+ });
3457
3545
  } else if (event === 'context-cards') {
3458
3546
  syncContextNodeToCanvasState(
3459
3547
  { cards: Array.isArray(payload.cards) ? payload.cards : [] },
@@ -3755,6 +3843,9 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3755
3843
  // ── Canvas persistence: set workspace root and load saved state ──
3756
3844
  canvasState.setWorkspaceRoot(activeWorkspaceRoot);
3757
3845
  const loaded = canvasState.loadFromDisk({ clearExisting: true });
3846
+ setCanvasLayoutUpdateEmitter(() => {
3847
+ emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
3848
+ });
3758
3849
  if (loaded) {
3759
3850
  console.log(' Canvas state restored from .pmx-canvas/state.json');
3760
3851
  primeCanvasRuntimeBackends({ forceRehydrateExtApps: true });
@@ -3858,7 +3949,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3858
3949
 
3859
3950
  // Canvas state API
3860
3951
  if (url.pathname === '/api/canvas/state' && req.method === 'GET') {
3861
- return responseJson(serializeCanvasLayout(canvasState.getLayout()));
3952
+ const includeBlobs = url.searchParams.get('includeBlobs') === 'true';
3953
+ return responseJson(includeBlobs
3954
+ ? serializeCanvasLayout(canvasState.getLayout())
3955
+ : serializeCanvasLayoutWithBlobSummaries(canvasState.getLayoutForPersistence()));
3862
3956
  }
3863
3957
 
3864
3958
  if (url.pathname === '/api/canvas/summary' && req.method === 'GET') {
@@ -3908,10 +4002,13 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3908
4002
  }
3909
4003
 
3910
4004
  if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'GET') {
3911
- const nodeId = url.pathname.slice('/api/canvas/node/'.length);
3912
- const node = canvasState.getNode(nodeId);
4005
+ const nodeId = decodeURIComponent(url.pathname.slice('/api/canvas/node/'.length));
4006
+ const includeBlobs = url.searchParams.get('includeBlobs') === 'true';
4007
+ const node = includeBlobs ? canvasState.getNode(nodeId) : canvasState.getNodeForPersistence(nodeId);
3913
4008
  if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
3914
- return responseJson(serializeCanvasNode(node));
4009
+ return responseJson(includeBlobs
4010
+ ? serializeCanvasNode(node)
4011
+ : serializeCanvasNodeWithBlobSummaries(node));
3915
4012
  }
3916
4013
 
3917
4014
  if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'PATCH') {
@@ -3947,13 +4044,21 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3947
4044
 
3948
4045
  // Snapshot API
3949
4046
  if (url.pathname === '/api/canvas/snapshots' && req.method === 'GET') {
3950
- return responseJson(listCanvasSnapshots());
4047
+ return responseJson(listCanvasSnapshots({
4048
+ limit: parsePositiveIntegerParam(url.searchParams.get('limit')),
4049
+ query: url.searchParams.get('q') ?? url.searchParams.get('query') ?? undefined,
4050
+ all: url.searchParams.get('all') === 'true',
4051
+ }));
3951
4052
  }
3952
4053
 
3953
4054
  if (url.pathname === '/api/canvas/snapshots' && req.method === 'POST') {
3954
4055
  return handleSnapshotSave(req);
3955
4056
  }
3956
4057
 
4058
+ if (url.pathname === '/api/canvas/snapshots/gc' && req.method === 'POST') {
4059
+ return handleSnapshotGc(req);
4060
+ }
4061
+
3957
4062
  if (url.pathname.startsWith('/api/canvas/snapshots/') && url.pathname.endsWith('/diff') && req.method === 'GET') {
3958
4063
  const id = decodeURIComponent(url.pathname.slice('/api/canvas/snapshots/'.length, -'/diff'.length));
3959
4064
  const snapshot = canvasState.getSnapshotData(id);
@@ -4137,6 +4242,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4137
4242
  export function stopCanvasServer(): void {
4138
4243
  canvasState.flushToDisk();
4139
4244
  closeAllMcpAppSessions();
4245
+ setCanvasLayoutUpdateEmitter(null);
4140
4246
  void closeCanvasAutomationWebViewInternal().catch((error) => {
4141
4247
  logWorkbenchWarning('stopCanvasServer closeCanvasAutomationWebViewInternal', error);
4142
4248
  });
@@ -28,6 +28,11 @@ const DEFAULT_TIMEOUT_MS = 10 * 60_000;
28
28
  const DEFAULT_PACKAGE_MANAGER = 'pnpm@10.33.0';
29
29
  const DEFAULT_WEB_ARTIFACT_NODE_SIZE = { width: 960, height: 720 };
30
30
  const FALLBACK_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'];
31
+ const WEB_ARTIFACT_CONTEXT_MAX_LENGTH = 1200;
32
+ const WEB_ARTIFACT_SOURCE_PREVIEW_MAX_LENGTH = 700;
33
+ const WEB_ARTIFACT_CSS_PREVIEW_MAX_LENGTH = 260;
34
+ const WEB_ARTIFACT_MAX_LISTED_SOURCE_FILES = 8;
35
+ const WEB_ARTIFACT_MAX_STORED_SOURCE_FILES = 32;
31
36
 
32
37
  export interface WebArtifactBuildInput {
33
38
  title: string;
@@ -49,6 +54,7 @@ export interface WebArtifactBuildOutput {
49
54
  fileSize: number;
50
55
  projectPath: string;
51
56
  metadata: Record<string, unknown>;
57
+ sourceContext: WebArtifactSourceContext;
52
58
  logs?: {
53
59
  stdout?: WebArtifactLogSummary;
54
60
  stderr?: WebArtifactLogSummary;
@@ -64,6 +70,14 @@ export interface WebArtifactLogSummary {
64
70
  suppressedNoiseCount: number;
65
71
  }
66
72
 
73
+ export interface WebArtifactSourceContext {
74
+ content: string;
75
+ sourceFiles: string[];
76
+ sourceFileCount: number;
77
+ sourcePreview: string;
78
+ deps?: string[];
79
+ }
80
+
67
81
  export interface WebArtifactCanvasOpenResult {
68
82
  nodeId: string;
69
83
  url: string;
@@ -73,7 +87,10 @@ export interface WebArtifactCanvasBuildResult extends WebArtifactBuildOutput {
73
87
  openedInCanvas: boolean;
74
88
  nodeId?: string;
75
89
  url?: string;
90
+ startedAt: string;
76
91
  completedAt: string;
92
+ durationMs: number;
93
+ timeoutMs: number;
77
94
  }
78
95
 
79
96
  function currentWorkspaceRoot(): string {
@@ -114,6 +131,67 @@ function slugify(value: string): string {
114
131
  return slug.length > 0 ? slug : 'web-artifact';
115
132
  }
116
133
 
134
+ function normalizePreviewText(text: string): string {
135
+ return text
136
+ .replace(/\r\n?/g, '\n')
137
+ .replace(/[ \t\f\v]+/g, ' ')
138
+ .replace(/\n\s+/g, '\n')
139
+ .replace(/\s+\n/g, '\n')
140
+ .replace(/\n{3,}/g, '\n\n')
141
+ .trim();
142
+ }
143
+
144
+ function truncatePreviewText(text: string, maxLength: number): string {
145
+ if (maxLength <= 0) return '';
146
+ const normalized = normalizePreviewText(text);
147
+ if (normalized.length <= maxLength) return normalized;
148
+ if (maxLength <= 3) return normalized.slice(0, maxLength);
149
+ return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
150
+ }
151
+
152
+ function formatSourceFileList(files: string[]): string {
153
+ const visible = files.slice(0, WEB_ARTIFACT_MAX_LISTED_SOURCE_FILES);
154
+ const remaining = files.length - visible.length;
155
+ return `${visible.join(', ')}${remaining > 0 ? `, +${remaining} more` : ''}`;
156
+ }
157
+
158
+ function buildWebArtifactSourceContext(
159
+ input: Pick<WebArtifactBuildInput, 'title' | 'appTsx' | 'indexCss' | 'mainTsx' | 'indexHtml' | 'files'>,
160
+ options: { deps: string[]; fileSize: number },
161
+ ): WebArtifactSourceContext {
162
+ const sourceFiles = new Set<string>(['src/App.tsx']);
163
+ if (typeof input.indexCss === 'string') sourceFiles.add('src/index.css');
164
+ if (typeof input.mainTsx === 'string') sourceFiles.add('src/main.tsx');
165
+ if (typeof input.indexHtml === 'string') sourceFiles.add('index.html');
166
+ for (const pathKey of Object.keys(input.files ?? {}).sort()) {
167
+ sourceFiles.add(pathKey);
168
+ }
169
+
170
+ const appPreview = truncatePreviewText(input.appTsx, WEB_ARTIFACT_SOURCE_PREVIEW_MAX_LENGTH);
171
+ const cssPreview = typeof input.indexCss === 'string'
172
+ ? truncatePreviewText(input.indexCss, WEB_ARTIFACT_CSS_PREVIEW_MAX_LENGTH)
173
+ : '';
174
+ const allSourceFiles = [...sourceFiles];
175
+ const storedSourceFiles = allSourceFiles.slice(0, WEB_ARTIFACT_MAX_STORED_SOURCE_FILES);
176
+ const parts = [
177
+ `Web artifact: ${input.title}`,
178
+ `Source files: ${formatSourceFileList(allSourceFiles)}`,
179
+ `Artifact bytes: ${options.fileSize}`,
180
+ options.deps.length > 0 ? `Dependencies: ${options.deps.join(', ')}` : '',
181
+ `App source preview:\n${appPreview}`,
182
+ cssPreview ? `CSS source preview:\n${cssPreview}` : '',
183
+ ].filter(Boolean);
184
+
185
+ const content = truncatePreviewText(parts.join('\n'), WEB_ARTIFACT_CONTEXT_MAX_LENGTH);
186
+ return {
187
+ content,
188
+ sourceFiles: storedSourceFiles,
189
+ sourceFileCount: allSourceFiles.length,
190
+ sourcePreview: appPreview,
191
+ ...(options.deps.length > 0 ? { deps: options.deps } : {}),
192
+ };
193
+ }
194
+
117
195
  function isPathInside(base: string, candidate: string): boolean {
118
196
  const rel = relative(base, candidate);
119
197
  if (rel === '') return true;
@@ -458,19 +536,23 @@ export async function executeWebArtifactBuild(
458
536
  // on a filesystem failure. A post-copy size check would be redundant
459
537
  // defensive noise — see CLAUDE.md TypeScript Guardrail #3.
460
538
  const fileSize = bundleSize;
539
+ const sourceContext = buildWebArtifactSourceContext(input, { deps, fileSize });
461
540
 
462
541
  return {
463
542
  filePath: outputPath,
464
543
  fileSize,
465
544
  projectPath,
545
+ sourceContext,
466
546
  metadata: {
467
547
  title: input.title,
468
548
  bundlePath,
469
549
  projectPath,
550
+ sourceFiles: sourceContext.sourceFiles,
551
+ sourceFileCount: sourceContext.sourceFileCount,
552
+ sourcePreview: sourceContext.sourcePreview,
470
553
  hasIndexCss: typeof input.indexCss === 'string',
471
554
  ...(deps.length > 0 ? { deps } : {}),
472
555
  extraFileCount: Object.keys(input.files ?? {}).length,
473
- outputPreview: readFileSync(outputPath, 'utf-8').slice(0, 200),
474
556
  },
475
557
  logs: {
476
558
  ...(summarizeArtifactLog(stdout) ? { stdout: summarizeArtifactLog(stdout) } : {}),
@@ -484,6 +566,13 @@ export async function executeWebArtifactBuild(
484
566
  export function openWebArtifactInCanvas(input: {
485
567
  title: string;
486
568
  filePath: string;
569
+ fileSize?: number;
570
+ projectPath?: string;
571
+ content?: string;
572
+ sourceFiles?: string[];
573
+ sourceFileCount?: number;
574
+ sourcePreview?: string;
575
+ deps?: string[];
487
576
  }): WebArtifactCanvasOpenResult {
488
577
  const width = DEFAULT_WEB_ARTIFACT_NODE_SIZE.width;
489
578
  const height = DEFAULT_WEB_ARTIFACT_NODE_SIZE.height;
@@ -501,8 +590,15 @@ export function openWebArtifactInCanvas(input: {
501
590
  dockPosition: null,
502
591
  data: {
503
592
  title: input.title,
593
+ ...(typeof input.content === 'string' ? { content: input.content } : {}),
504
594
  url,
505
595
  path: input.filePath,
596
+ ...(typeof input.fileSize === 'number' ? { artifactBytes: input.fileSize } : {}),
597
+ ...(typeof input.projectPath === 'string' ? { projectPath: input.projectPath } : {}),
598
+ ...(Array.isArray(input.sourceFiles) ? { sourceFiles: input.sourceFiles } : {}),
599
+ ...(typeof input.sourceFileCount === 'number' ? { sourceFileCount: input.sourceFileCount } : {}),
600
+ ...(typeof input.sourcePreview === 'string' ? { sourcePreview: input.sourcePreview } : {}),
601
+ ...(Array.isArray(input.deps) && input.deps.length > 0 ? { deps: input.deps } : {}),
506
602
  trustedDomain: true,
507
603
  sourceServer: 'pmx-canvas',
508
604
  hostMode: 'hosted',
@@ -518,19 +614,36 @@ export function openWebArtifactInCanvas(input: {
518
614
  export async function buildWebArtifactOnCanvas(input: WebArtifactBuildInput & {
519
615
  openInCanvas?: boolean;
520
616
  }): Promise<WebArtifactCanvasBuildResult> {
617
+ const startedMs = Date.now();
618
+ const startedAt = new Date(startedMs).toISOString();
619
+ const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
521
620
  const build = await executeWebArtifactBuild(input);
621
+ const completedMs = Date.now();
622
+ const timing = {
623
+ startedAt,
624
+ completedAt: new Date(completedMs).toISOString(),
625
+ durationMs: completedMs - startedMs,
626
+ timeoutMs,
627
+ };
522
628
  if (input.openInCanvas === false) {
523
- return { ...build, openedInCanvas: false, completedAt: new Date().toISOString() };
629
+ return { ...build, openedInCanvas: false, ...timing };
524
630
  }
525
631
  const opened = openWebArtifactInCanvas({
526
632
  title: input.title,
527
633
  filePath: build.filePath,
634
+ fileSize: build.fileSize,
635
+ projectPath: build.projectPath,
636
+ content: build.sourceContext.content,
637
+ sourceFiles: build.sourceContext.sourceFiles,
638
+ sourceFileCount: build.sourceContext.sourceFileCount,
639
+ sourcePreview: build.sourceContext.sourcePreview,
640
+ ...(Array.isArray(build.sourceContext.deps) ? { deps: build.sourceContext.deps } : {}),
528
641
  });
529
642
  return {
530
643
  ...build,
531
644
  openedInCanvas: true,
532
645
  nodeId: opened.nodeId,
533
646
  url: opened.url,
534
- completedAt: new Date().toISOString(),
647
+ ...timing,
535
648
  };
536
649
  }
@@ -0,0 +1,14 @@
1
+ export interface CanvasNodeKindInput {
2
+ type: string;
3
+ data: Record<string, unknown>;
4
+ }
5
+
6
+ export function getCanvasNodeKind(node: CanvasNodeKindInput): string {
7
+ if (node.type !== 'mcp-app') return node.type;
8
+
9
+ const data = node.data;
10
+ if (data.viewerType === 'web-artifact') return 'web-artifact';
11
+ if (data.mode === 'ext-app') return 'external-app';
12
+ if (data.hostMode === 'hosted' && typeof data.path === 'string') return 'web-artifact';
13
+ return 'mcp-app';
14
+ }