pmx-canvas 0.1.14 → 0.1.16

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 (56) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/Readme.md +108 -1058
  3. package/dist/canvas/global.css +141 -0
  4. package/dist/canvas/index.js +124 -74
  5. package/dist/json-render/index.css +1 -1
  6. package/dist/types/client/nodes/ContextNode.d.ts +11 -2
  7. package/dist/types/client/nodes/HtmlNode.d.ts +5 -0
  8. package/dist/types/client/nodes/StatusNode.d.ts +1 -0
  9. package/dist/types/client/state/canvas-store.d.ts +11 -3
  10. package/dist/types/client/state/intent-bridge.d.ts +5 -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 +4 -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/index.d.ts +13 -3
  21. package/dist/types/server/web-artifacts.d.ts +18 -0
  22. package/dist/types/shared/canvas-node-kind.d.ts +5 -0
  23. package/package.json +1 -1
  24. package/skills/pmx-canvas/SKILL.md +43 -0
  25. package/skills/pmx-canvas-testing/SKILL.md +17 -0
  26. package/src/cli/agent.ts +52 -5
  27. package/src/cli/index.ts +2 -23
  28. package/src/client/canvas/AttentionHistory.tsx +14 -1
  29. package/src/client/canvas/CanvasNode.tsx +1 -1
  30. package/src/client/canvas/CanvasViewport.tsx +3 -0
  31. package/src/client/canvas/ContextPinBar.tsx +2 -1
  32. package/src/client/canvas/DockedNode.tsx +112 -13
  33. package/src/client/canvas/ExpandedNodeOverlay.tsx +5 -0
  34. package/src/client/canvas/Minimap.tsx +1 -0
  35. package/src/client/icons.tsx +1 -0
  36. package/src/client/nodes/ContextNode.tsx +128 -6
  37. package/src/client/nodes/HtmlNode.tsx +151 -0
  38. package/src/client/nodes/StatusNode.tsx +16 -1
  39. package/src/client/nodes/StatusSummary.tsx +2 -1
  40. package/src/client/state/canvas-store.ts +37 -7
  41. package/src/client/state/intent-bridge.ts +9 -4
  42. package/src/client/state/sse-bridge.ts +2 -1
  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 +178 -25
  47. package/src/server/agent-context.ts +50 -3
  48. package/src/server/canvas-operations.ts +20 -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/index.ts +33 -3
  54. package/src/server/server.ts +98 -14
  55. package/src/server/web-artifacts.ts +116 -3
  56. package/src/shared/canvas-node-kind.ts +14 -0
@@ -20,6 +20,7 @@ import {
20
20
  fitCanvasView,
21
21
  deleteCanvasSnapshot,
22
22
  executeCanvasBatch,
23
+ gcCanvasSnapshots,
23
24
  groupCanvasNodes,
24
25
  listCanvasSnapshots,
25
26
  refreshCanvasWebpageNode,
@@ -459,8 +460,8 @@ export class PmxCanvas extends EventEmitter {
459
460
  return result;
460
461
  }
461
462
 
462
- listSnapshots() {
463
- return listCanvasSnapshots();
463
+ listSnapshots(options?: Parameters<typeof listCanvasSnapshots>[0]) {
464
+ return listCanvasSnapshots(options);
464
465
  }
465
466
 
466
467
  saveSnapshot(name: string) {
@@ -479,6 +480,10 @@ export class PmxCanvas extends EventEmitter {
479
480
  return deleteCanvasSnapshot(id);
480
481
  }
481
482
 
483
+ gcSnapshots(options?: Parameters<typeof gcCanvasSnapshots>[0]): ReturnType<typeof gcCanvasSnapshots> {
484
+ return gcCanvasSnapshots(options);
485
+ }
486
+
482
487
  diffSnapshot(idOrName: string): { ok: boolean; text?: string; diff?: ReturnType<typeof diffLayouts>; error?: string } {
483
488
  const snapData = canvasState.getSnapshotData(idOrName);
484
489
  if (!snapData) return { ok: false, error: `Snapshot "${idOrName}" not found` };
@@ -619,6 +624,31 @@ export class PmxCanvas extends EventEmitter {
619
624
  return result;
620
625
  }
621
626
 
627
+ addHtmlNode(input: {
628
+ html: string;
629
+ title?: string;
630
+ x?: number;
631
+ y?: number;
632
+ width?: number;
633
+ height?: number;
634
+ strictSize?: boolean;
635
+ }): string {
636
+ const { id } = addCanvasNode({
637
+ type: 'html',
638
+ ...(typeof input.title === 'string' ? { title: input.title } : {}),
639
+ data: { html: input.html },
640
+ ...(typeof input.x === 'number' ? { x: input.x } : {}),
641
+ ...(typeof input.y === 'number' ? { y: input.y } : {}),
642
+ ...(typeof input.width === 'number' ? { width: input.width } : {}),
643
+ ...(typeof input.height === 'number' ? { height: input.height } : {}),
644
+ ...(input.strictSize ? { strictSize: true } : {}),
645
+ defaultWidth: 720,
646
+ defaultHeight: 640,
647
+ });
648
+ emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
649
+ return id;
650
+ }
651
+
622
652
  addGraphNode(input: GraphNodeInput): { id: string; url: string; spec: JsonRenderSpec } {
623
653
  const result = createCanvasGraphNode(input);
624
654
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
@@ -693,7 +723,7 @@ export {
693
723
  screenshotCanvasAutomationWebView,
694
724
  } from './server.js';
695
725
  export { canvasState } from './canvas-state.js';
696
- export type { CanvasSnapshot } from './canvas-state.js';
726
+ export type { CanvasSnapshot, CanvasSnapshotGcResult, CanvasSnapshotListOptions } from './canvas-state.js';
697
727
  export { findOpenCanvasPosition } from './placement.js';
698
728
  export { searchNodes, buildSpatialContext, detectClusters, findNeighborhoods } from './spatial-analysis.js';
699
729
  export type { SpatialCluster, SpatialContext, SpatialNeighbor, NodeSpatialInfo } from './spatial-analysis.js';
@@ -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,6 +98,7 @@ import {
91
98
  saveCanvasSnapshot,
92
99
  scheduleCodeGraphRecompute,
93
100
  primeCanvasRuntimeBackends,
101
+ setCanvasLayoutUpdateEmitter,
94
102
  syncCanvasRuntimeBackends,
95
103
  setCanvasContextPins,
96
104
  ungroupCanvasNodes,
@@ -1186,7 +1194,15 @@ function serveCanvasFavicon(): Response {
1186
1194
  async function handleCanvasUpdate(req: Request): Promise<Response> {
1187
1195
  const body = await readJson(req);
1188
1196
  const updates = Array.isArray(body.updates) ? body.updates : [];
1189
- 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);
1190
1206
  if (result.applied > 0) {
1191
1207
  emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
1192
1208
  }
@@ -1200,7 +1216,13 @@ async function handleCanvasViewport(req: Request): Promise<Response> {
1200
1216
  y: typeof body.y === 'number' ? body.y : canvasState.viewport.y,
1201
1217
  scale: typeof body.scale === 'number' ? body.scale : canvasState.viewport.scale,
1202
1218
  };
1203
- canvasState.setViewport(next);
1219
+ if (body.recordHistory === false) {
1220
+ canvasState.withSuppressedRecording(() => {
1221
+ canvasState.setViewport(next);
1222
+ });
1223
+ } else {
1224
+ canvasState.setViewport(next);
1225
+ }
1204
1226
  emitPrimaryWorkbenchEvent('canvas-viewport-update', { viewport: canvasState.viewport });
1205
1227
  return responseJson({ ok: true });
1206
1228
  }
@@ -1236,7 +1258,7 @@ async function handleCanvasImage(pathname: string): Promise<Response> {
1236
1258
  }
1237
1259
 
1238
1260
  // ── Add node from client ─────────────────────────────────────
1239
- const VALID_NODE_TYPES = new Set(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'mcp-app', 'webpage', 'group']);
1261
+ const VALID_NODE_TYPES = new Set(['markdown', 'status', 'context', 'ledger', 'trace', 'file', 'image', 'mcp-app', 'webpage', 'html', 'group']);
1240
1262
 
1241
1263
  function buildNodeResponse(node: CanvasNodeState): Record<string, unknown> {
1242
1264
  const serialized = serializeCanvasNode(node);
@@ -1322,9 +1344,22 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1322
1344
  const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
1323
1345
  ? body.data as Record<string, unknown>
1324
1346
  : undefined;
1347
+ if (type === 'html') {
1348
+ if ('html' in body && typeof body.html !== 'string') {
1349
+ return responseJson({ ok: false, error: 'HTML node field "html" must be a string.' }, 400);
1350
+ }
1351
+ if (extraData && 'html' in extraData && typeof extraData.html !== 'string') {
1352
+ return responseJson({ ok: false, error: 'HTML node field "data.html" must be a string.' }, 400);
1353
+ }
1354
+ }
1325
1355
  const content = type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string'
1326
1356
  ? body.path
1327
1357
  : body.content;
1358
+ // For html nodes, accept top-level `html` field and merge into data so callers
1359
+ // can POST { type: 'html', title, html } without nesting under `data`.
1360
+ const htmlMergedData = type === 'html' && typeof body.html === 'string'
1361
+ ? { ...(extraData ?? {}), html: body.html }
1362
+ : extraData;
1328
1363
  let added: ReturnType<typeof addCanvasNode>;
1329
1364
  const geometry = resolveCreateGeometry(body);
1330
1365
  try {
@@ -1332,7 +1367,7 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1332
1367
  type: type as CanvasNodeState['type'],
1333
1368
  ...(typeof body.title === 'string' ? { title: body.title } : {}),
1334
1369
  ...(typeof content === 'string' ? { content } : {}),
1335
- ...(extraData ? { data: extraData } : {}),
1370
+ ...(htmlMergedData ? { data: htmlMergedData } : {}),
1336
1371
  ...(type === 'trace' && typeof body.toolName === 'string' ? { toolName: body.toolName } : {}),
1337
1372
  ...(type === 'trace' && typeof body.category === 'string' ? { category: body.category } : {}),
1338
1373
  ...(type === 'trace' && typeof body.status === 'string' ? { status: body.status } : {}),
@@ -1341,8 +1376,8 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1341
1376
  ...(type === 'trace' && typeof body.error === 'string' ? { error: body.error } : {}),
1342
1377
  ...(body.strictSize === true ? { strictSize: true } : {}),
1343
1378
  ...geometry,
1344
- defaultWidth: 360,
1345
- defaultHeight: 200,
1379
+ defaultWidth: type === 'html' ? 720 : 360,
1380
+ defaultHeight: type === 'html' ? 640 : 200,
1346
1381
  fileMode: 'auto',
1347
1382
  });
1348
1383
  } catch (error) {
@@ -1374,6 +1409,15 @@ async function handleCanvasCreateGroup(req: Request): Promise<Response> {
1374
1409
  body.childLayout === 'grid' || body.childLayout === 'column' || body.childLayout === 'flow'
1375
1410
  ? body.childLayout
1376
1411
  : undefined;
1412
+ if (childIds.length > 0) {
1413
+ const missingChildIds = childIds.filter((id) => !canvasState.getNode(id));
1414
+ if (missingChildIds.length > 0) {
1415
+ return responseJson({
1416
+ ok: false,
1417
+ error: `Cannot create group: missing child node ID${missingChildIds.length === 1 ? '' : 's'}: ${missingChildIds.join(', ')}.`,
1418
+ }, 400);
1419
+ }
1420
+ }
1377
1421
 
1378
1422
  const { node } = createCanvasGroup({ title, childIds, color, x, y, width, height, ...(childLayout ? { childLayout } : {}) });
1379
1423
 
@@ -1656,7 +1700,10 @@ async function handleCanvasBuildWebArtifact(req: Request): Promise<Response> {
1656
1700
  bytes: result.fileSize,
1657
1701
  projectPath: result.projectPath,
1658
1702
  openedInCanvas: result.openedInCanvas,
1703
+ startedAt: result.startedAt,
1659
1704
  completedAt: result.completedAt,
1705
+ durationMs: result.durationMs,
1706
+ timeoutMs: result.timeoutMs,
1660
1707
  // `id` is the canvas node id alias used by every other add-style
1661
1708
  // response. It is only present when a canvas node was actually
1662
1709
  // created (i.e. openInCanvas was not explicitly disabled). When
@@ -1905,6 +1952,13 @@ function responseText(text: string, status = 400): Response {
1905
1952
  });
1906
1953
  }
1907
1954
 
1955
+ function parsePositiveIntegerParam(value: string | null): number | undefined {
1956
+ if (!value) return undefined;
1957
+ const parsed = Number(value);
1958
+ if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
1959
+ return Math.floor(parsed);
1960
+ }
1961
+
1908
1962
  function handleArtifactView(url: URL): Response {
1909
1963
  const pathLike = url.searchParams.get('path') ?? '';
1910
1964
  const safePath = resolveWorkspaceArtifactPath(pathLike);
@@ -3085,6 +3139,18 @@ async function handleSnapshotSave(req: Request): Promise<Response> {
3085
3139
  return responseJson({ ok: true, id: snapshot.id, snapshot });
3086
3140
  }
3087
3141
 
3142
+ async function handleSnapshotGc(req: Request): Promise<Response> {
3143
+ const body = await readJson(req);
3144
+ const keepValue = body.keep;
3145
+ const keep = typeof keepValue === 'number'
3146
+ ? keepValue
3147
+ : typeof keepValue === 'string'
3148
+ ? Number(keepValue)
3149
+ : undefined;
3150
+ const dryRun = body.dryRun === true || body['dry-run'] === true;
3151
+ return responseJson(gcCanvasSnapshots({ keep, dryRun }));
3152
+ }
3153
+
3088
3154
  async function handleContextPinsUpdate(req: Request): Promise<Response> {
3089
3155
  const body = await readJson(req);
3090
3156
  const MAX_PINS = 20;
@@ -3325,9 +3391,9 @@ function syncContextNodeToCanvasState(
3325
3391
  position: { x: 1130, y: 80 },
3326
3392
  size: { width: 320, height: 400 },
3327
3393
  zIndex: 1,
3328
- collapsed: false,
3394
+ collapsed: true,
3329
3395
  pinned: false,
3330
- dockPosition: null,
3396
+ dockPosition: 'right',
3331
3397
  data: mergedData,
3332
3398
  });
3333
3399
  return;
@@ -3800,6 +3866,9 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3800
3866
  // ── Canvas persistence: set workspace root and load saved state ──
3801
3867
  canvasState.setWorkspaceRoot(activeWorkspaceRoot);
3802
3868
  const loaded = canvasState.loadFromDisk({ clearExisting: true });
3869
+ setCanvasLayoutUpdateEmitter(() => {
3870
+ emitPrimaryWorkbenchEvent('canvas-layout-update', { layout: canvasState.getLayout() });
3871
+ });
3803
3872
  if (loaded) {
3804
3873
  console.log(' Canvas state restored from .pmx-canvas/state.json');
3805
3874
  primeCanvasRuntimeBackends({ forceRehydrateExtApps: true });
@@ -3903,7 +3972,10 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3903
3972
 
3904
3973
  // Canvas state API
3905
3974
  if (url.pathname === '/api/canvas/state' && req.method === 'GET') {
3906
- return responseJson(serializeCanvasLayout(canvasState.getLayout()));
3975
+ const includeBlobs = url.searchParams.get('includeBlobs') === 'true';
3976
+ return responseJson(includeBlobs
3977
+ ? serializeCanvasLayout(canvasState.getLayout())
3978
+ : serializeCanvasLayoutWithBlobSummaries(canvasState.getLayoutForPersistence()));
3907
3979
  }
3908
3980
 
3909
3981
  if (url.pathname === '/api/canvas/summary' && req.method === 'GET') {
@@ -3953,10 +4025,13 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3953
4025
  }
3954
4026
 
3955
4027
  if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'GET') {
3956
- const nodeId = url.pathname.slice('/api/canvas/node/'.length);
3957
- const node = canvasState.getNode(nodeId);
4028
+ const nodeId = decodeURIComponent(url.pathname.slice('/api/canvas/node/'.length));
4029
+ const includeBlobs = url.searchParams.get('includeBlobs') === 'true';
4030
+ const node = includeBlobs ? canvasState.getNode(nodeId) : canvasState.getNodeForPersistence(nodeId);
3958
4031
  if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
3959
- return responseJson(serializeCanvasNode(node));
4032
+ return responseJson(includeBlobs
4033
+ ? serializeCanvasNode(node)
4034
+ : serializeCanvasNodeWithBlobSummaries(node));
3960
4035
  }
3961
4036
 
3962
4037
  if (url.pathname.startsWith('/api/canvas/node/') && req.method === 'PATCH') {
@@ -3992,13 +4067,21 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
3992
4067
 
3993
4068
  // Snapshot API
3994
4069
  if (url.pathname === '/api/canvas/snapshots' && req.method === 'GET') {
3995
- return responseJson(listCanvasSnapshots());
4070
+ return responseJson(listCanvasSnapshots({
4071
+ limit: parsePositiveIntegerParam(url.searchParams.get('limit')),
4072
+ query: url.searchParams.get('q') ?? url.searchParams.get('query') ?? undefined,
4073
+ all: url.searchParams.get('all') === 'true',
4074
+ }));
3996
4075
  }
3997
4076
 
3998
4077
  if (url.pathname === '/api/canvas/snapshots' && req.method === 'POST') {
3999
4078
  return handleSnapshotSave(req);
4000
4079
  }
4001
4080
 
4081
+ if (url.pathname === '/api/canvas/snapshots/gc' && req.method === 'POST') {
4082
+ return handleSnapshotGc(req);
4083
+ }
4084
+
4002
4085
  if (url.pathname.startsWith('/api/canvas/snapshots/') && url.pathname.endsWith('/diff') && req.method === 'GET') {
4003
4086
  const id = decodeURIComponent(url.pathname.slice('/api/canvas/snapshots/'.length, -'/diff'.length));
4004
4087
  const snapshot = canvasState.getSnapshotData(id);
@@ -4182,6 +4265,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
4182
4265
  export function stopCanvasServer(): void {
4183
4266
  canvasState.flushToDisk();
4184
4267
  closeAllMcpAppSessions();
4268
+ setCanvasLayoutUpdateEmitter(null);
4185
4269
  void closeCanvasAutomationWebViewInternal().catch((error) => {
4186
4270
  logWorkbenchWarning('stopCanvasServer closeCanvasAutomationWebViewInternal', error);
4187
4271
  });
@@ -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
+ }