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.
Files changed (42) hide show
  1. package/CHANGELOG.md +144 -0
  2. package/Readme.md +35 -8
  3. package/dist/canvas/index.js +69 -69
  4. package/dist/json-render/index.css +1 -1
  5. package/dist/json-render/index.js +1 -1
  6. package/dist/types/client/nodes/ExtAppFrame.d.ts +12 -0
  7. package/dist/types/client/state/canvas-store.d.ts +2 -1
  8. package/dist/types/client/types.d.ts +3 -0
  9. package/dist/types/json-render/charts/components.d.ts +2 -1
  10. package/dist/types/server/canvas-serialization.d.ts +1 -0
  11. package/dist/types/server/diagram-presets.d.ts +13 -0
  12. package/dist/types/server/ext-app-lookup.d.ts +22 -0
  13. package/dist/types/server/index.d.ts +8 -1
  14. package/dist/types/server/web-artifacts.d.ts +1 -0
  15. package/package.json +2 -1
  16. package/skills/pmx-canvas/SKILL.md +35 -10
  17. package/skills/pmx-canvas/references/installing-pmx-canvas.md +66 -0
  18. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +10 -0
  19. package/skills/web-artifacts-builder/scripts/init-artifact.sh +1 -1
  20. package/src/cli/agent.ts +114 -21
  21. package/src/cli/index.ts +3 -1
  22. package/src/client/App.tsx +2 -1
  23. package/src/client/canvas/CanvasNode.tsx +3 -2
  24. package/src/client/canvas/ExpandedNodeOverlay.tsx +6 -1
  25. package/src/client/nodes/ExtAppFrame.tsx +97 -26
  26. package/src/client/state/canvas-store.ts +63 -1
  27. package/src/client/state/sse-bridge.ts +19 -4
  28. package/src/client/types.ts +12 -0
  29. package/src/json-render/charts/components.tsx +6 -4
  30. package/src/json-render/charts/extra-components.tsx +5 -5
  31. package/src/json-render/renderer/index.css +14 -0
  32. package/src/mcp/server.ts +44 -5
  33. package/src/server/canvas-operations.ts +43 -5
  34. package/src/server/canvas-schema.ts +16 -14
  35. package/src/server/canvas-serialization.ts +19 -1
  36. package/src/server/diagram-presets.ts +219 -4
  37. package/src/server/ext-app-lookup.ts +49 -0
  38. package/src/server/index.ts +33 -25
  39. package/src/server/server.ts +199 -45
  40. package/src/server/web-artifacts/scripts/bundle-artifact.sh +10 -0
  41. package/src/server/web-artifacts/scripts/init-artifact.sh +1 -1
  42. package/src/server/web-artifacts.ts +44 -1
@@ -1,5 +1,5 @@
1
1
  import { batch, computed, signal } from '@preact/signals';
2
- import type { CanvasEdge, CanvasLayout, CanvasNodeState, ConnectionStatus, ViewportState } from '../types';
2
+ import { isExcalidrawNode, type CanvasEdge, type CanvasLayout, type CanvasNodeState, type ConnectionStatus, type ViewportState } from '../types';
3
3
  import { computeAutoArrange } from '../../shared/auto-arrange';
4
4
  import { pushCanvasUpdate, updateViewportFromClient } from './intent-bridge';
5
5
 
@@ -22,6 +22,11 @@ export const hasInitialServerLayout = signal<boolean>(false);
22
22
  // Only one node at a time can be in expanded/focus mode. When expanded, the
23
23
  // node renders as a full-viewport overlay for deep editing/reading.
24
24
  export const expandedNodeId = signal<string | null>(null);
25
+ export const pendingExpandedNodeCloseId = signal<string | null>(null);
26
+ let expandedCloseTimer: ReturnType<typeof setTimeout> | null = null;
27
+ let pendingCloseInitialCheckpointAt: unknown = undefined;
28
+ const EXCALIDRAW_CLOSE_POLL_MS = 100;
29
+ const EXCALIDRAW_CLOSE_MAX_WAIT_MS = 2500;
25
30
 
26
31
  // ── Pending edge connection (for context menu "Connect from") ─
27
32
  export const pendingConnection = signal<{ from: string } | null>(null);
@@ -192,6 +197,14 @@ export function updateNode(id: string, patch: Partial<CanvasNodeState>): void {
192
197
  }
193
198
  next.set(id, { ...existing, ...patch });
194
199
  nodes.value = next;
200
+ const updatedAt = (next.get(id)?.data.appCheckpoint as { updatedAt?: unknown } | undefined)?.updatedAt;
201
+ if (
202
+ pendingExpandedNodeCloseId.value === id &&
203
+ updatedAt !== undefined &&
204
+ updatedAt !== pendingCloseInitialCheckpointAt
205
+ ) {
206
+ finishExpandedNodeClose(id);
207
+ }
195
208
  }
196
209
 
197
210
  export function updateNodeData(id: string, dataPatch: Record<string, unknown>): void {
@@ -585,11 +598,60 @@ export function walkGraph(direction: 'up' | 'down' | 'left' | 'right'): void {
585
598
  export function expandNode(id: string): void {
586
599
  const node = nodes.value.get(id);
587
600
  if (!node) return;
601
+ if (expandedCloseTimer !== null) {
602
+ clearTimeout(expandedCloseTimer);
603
+ expandedCloseTimer = null;
604
+ }
605
+ pendingExpandedNodeCloseId.value = null;
606
+ pendingCloseInitialCheckpointAt = undefined;
588
607
  bringToFront(id);
589
608
  expandedNodeId.value = id;
590
609
  }
591
610
 
611
+ function finishExpandedNodeClose(nodeId: string): void {
612
+ if (expandedCloseTimer !== null) {
613
+ clearTimeout(expandedCloseTimer);
614
+ expandedCloseTimer = null;
615
+ }
616
+ if (expandedNodeId.value === nodeId) expandedNodeId.value = null;
617
+ if (pendingExpandedNodeCloseId.value === nodeId) pendingExpandedNodeCloseId.value = null;
618
+ pendingCloseInitialCheckpointAt = undefined;
619
+ }
620
+
592
621
  export function collapseExpandedNode(): void {
622
+ const nodeId = expandedNodeId.value;
623
+ const node = nodeId ? nodes.value.get(nodeId) : undefined;
624
+ if (nodeId && node && isExcalidrawNode(node)) {
625
+ const closingNodeId = nodeId;
626
+ const startedAt = Date.now();
627
+ pendingExpandedNodeCloseId.value = closingNodeId;
628
+ pendingCloseInitialCheckpointAt = (node.data.appCheckpoint as { updatedAt?: unknown } | undefined)?.updatedAt;
629
+ if (expandedCloseTimer !== null) clearTimeout(expandedCloseTimer);
630
+ const pollForSave = () => {
631
+ const latestNode = nodes.value.get(closingNodeId);
632
+ const latestCheckpointAt = (latestNode?.data.appCheckpoint as { updatedAt?: unknown } | undefined)?.updatedAt;
633
+ if (
634
+ latestCheckpointAt !== undefined &&
635
+ latestCheckpointAt !== pendingCloseInitialCheckpointAt
636
+ ) {
637
+ finishExpandedNodeClose(closingNodeId);
638
+ return;
639
+ }
640
+ if (Date.now() - startedAt >= EXCALIDRAW_CLOSE_MAX_WAIT_MS) {
641
+ finishExpandedNodeClose(closingNodeId);
642
+ return;
643
+ }
644
+ expandedCloseTimer = setTimeout(pollForSave, EXCALIDRAW_CLOSE_POLL_MS);
645
+ };
646
+ expandedCloseTimer = setTimeout(pollForSave, EXCALIDRAW_CLOSE_POLL_MS);
647
+ return;
648
+ }
649
+ if (expandedCloseTimer !== null) {
650
+ clearTimeout(expandedCloseTimer);
651
+ expandedCloseTimer = null;
652
+ }
653
+ pendingExpandedNodeCloseId.value = null;
654
+ pendingCloseInitialCheckpointAt = undefined;
593
655
  expandedNodeId.value = null;
594
656
  }
595
657
 
@@ -6,6 +6,7 @@ import {
6
6
  addEdge,
7
7
  addNode,
8
8
  applyServerCanvasLayout,
9
+ bringToFront,
9
10
  cancelViewportAnimation,
10
11
  canvasTheme,
11
12
  connectionStatus,
@@ -165,7 +166,8 @@ function ensureMcpAppNode(data: Record<string, unknown>): void {
165
166
 
166
167
  function ensureExtAppNode(data: Record<string, unknown>): void {
167
168
  const toolCallId = data.toolCallId as string;
168
- const id = `ext-app-${toolCallId}`;
169
+ const eventNodeId = typeof data.nodeId === 'string' && data.nodeId.length > 0 ? data.nodeId : null;
170
+ const id = eventNodeId ?? (toolCallId.startsWith('ext-app-') ? toolCallId : `ext-app-${toolCallId}`);
169
171
  const existing = nodes.value.get(id);
170
172
  if (existing) {
171
173
  updateNodeData(id, data);
@@ -231,8 +233,10 @@ function ensureExtAppNode(data: Record<string, unknown>): void {
231
233
  }
232
234
 
233
235
  function findExtAppNodeId(toolCallId: string): string | null {
234
- const directId = `ext-app-${toolCallId}`;
236
+ const directId = toolCallId.startsWith('ext-app-') ? toolCallId : `ext-app-${toolCallId}`;
235
237
  if (nodes.value.has(directId)) return directId;
238
+ const legacyDirectId = `ext-app-${toolCallId}`;
239
+ if (legacyDirectId !== directId && nodes.value.has(legacyDirectId)) return legacyDirectId;
236
240
  for (const [nodeId, node] of nodes.value.entries()) {
237
241
  if (
238
242
  node.type === 'mcp-app' &&
@@ -245,6 +249,13 @@ function findExtAppNodeId(toolCallId: string): string | null {
245
249
  return null;
246
250
  }
247
251
 
252
+ function findExtAppEventNodeId(data: Record<string, unknown>): string | null {
253
+ const eventNodeId = typeof data.nodeId === 'string' && data.nodeId.length > 0 ? data.nodeId : null;
254
+ if (eventNodeId && nodes.value.has(eventNodeId)) return eventNodeId;
255
+ if (typeof data.toolCallId !== 'string' || !data.toolCallId) return null;
256
+ return findExtAppNodeId(data.toolCallId);
257
+ }
258
+
248
259
  function findOnlyPendingExtAppNodeId(serverName: unknown, toolName: unknown): string | null {
249
260
  if (typeof serverName !== 'string' || !serverName) return null;
250
261
  if (typeof toolName !== 'string' || !toolName) return null;
@@ -541,7 +552,7 @@ function handleExtAppOpen(data: Record<string, unknown>): void {
541
552
  function handleExtAppUpdate(data: Record<string, unknown>): void {
542
553
  if (typeof data.toolCallId !== 'string' || !data.toolCallId) return;
543
554
  const id =
544
- findExtAppNodeId(data.toolCallId) ?? findOnlyPendingExtAppNodeId(data.serverName, data.toolName);
555
+ findExtAppEventNodeId(data) ?? findOnlyPendingExtAppNodeId(data.serverName, data.toolName);
545
556
  if (!id) return;
546
557
  if (nodes.value.has(id)) {
547
558
  updateNodeData(id, { html: data.html });
@@ -551,7 +562,7 @@ function handleExtAppUpdate(data: Record<string, unknown>): void {
551
562
  function handleExtAppResult(data: Record<string, unknown>): void {
552
563
  if (typeof data.toolCallId !== 'string' || !data.toolCallId) return;
553
564
  const id =
554
- findExtAppNodeId(data.toolCallId) ?? findOnlyPendingExtAppNodeId(data.serverName, data.toolName);
565
+ findExtAppEventNodeId(data) ?? findOnlyPendingExtAppNodeId(data.serverName, data.toolName);
555
566
  if (!id) return;
556
567
  if (nodes.value.has(id)) {
557
568
  if (data.success === false) {
@@ -827,6 +838,10 @@ function reconnectDelayMs(attempt: number): number {
827
838
  function handleCanvasFocusNode(data: Record<string, unknown>): void {
828
839
  const nodeId = data.nodeId as string;
829
840
  if (nodeId && nodes.value.has(nodeId)) {
841
+ if (data.noPan === true) {
842
+ bringToFront(nodeId);
843
+ return;
844
+ }
830
845
  focusNode(nodeId);
831
846
  }
832
847
  }
@@ -74,6 +74,18 @@ export const EXPANDABLE_TYPES = new Set<CanvasNodeState['type']>([
74
74
  'image',
75
75
  ]);
76
76
 
77
+ export const EXCALIDRAW_SERVER_NAME = 'Excalidraw';
78
+ export const EXCALIDRAW_CREATE_VIEW_TOOL = 'create_view';
79
+
80
+ export function isExcalidrawNode(node: CanvasNodeState): boolean {
81
+ return (
82
+ node.type === 'mcp-app' &&
83
+ node.data.mode === 'ext-app' &&
84
+ node.data.serverName === EXCALIDRAW_SERVER_NAME &&
85
+ node.data.toolName === EXCALIDRAW_CREATE_VIEW_TOOL
86
+ );
87
+ }
88
+
77
89
  export interface CanvasLayout {
78
90
  viewport: ViewportState;
79
91
  nodes: CanvasNodeState[];
@@ -103,15 +103,17 @@ export const tooltipStyle = {
103
103
  export function CartesianChart({
104
104
  props,
105
105
  children,
106
+ className,
106
107
  }: {
107
108
  props: CartesianChartProps;
108
109
  children: (data: Record<string, unknown>[]) => ReactNode;
110
+ className?: string;
109
111
  }) {
110
112
  const chartData = processChartData(props.data ?? [], props.xKey, props.yKey, props.aggregate);
111
113
  const h = props.height ?? 300;
112
114
 
113
115
  return (
114
- <div className="pmx-chart">
116
+ <div className={`pmx-chart${className ? ` ${className}` : ''}`}>
115
117
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
116
118
  <ResponsiveContainer width="100%" height={h}>
117
119
  {children(chartData)}
@@ -123,7 +125,7 @@ export function CartesianChart({
123
125
  function ChartLineChart({ props }: BaseComponentProps<CartesianChartProps>) {
124
126
  const stroke = props.color ?? CHART_COLORS[0];
125
127
  return (
126
- <CartesianChart props={props}>
128
+ <CartesianChart props={props} className="pmx-chart--line">
127
129
  {(data) => (
128
130
  <RechartsLineChart data={data}>
129
131
  <CartesianGrid strokeDasharray="3 3" stroke="var(--border, #e5e5e5)" />
@@ -147,7 +149,7 @@ function ChartLineChart({ props }: BaseComponentProps<CartesianChartProps>) {
147
149
  function ChartBarChart({ props }: BaseComponentProps<CartesianChartProps>) {
148
150
  const fill = props.color ?? CHART_COLORS[0];
149
151
  return (
150
- <CartesianChart props={props}>
152
+ <CartesianChart props={props} className="pmx-chart--bar">
151
153
  {(data) => (
152
154
  <RechartsBarChart data={data}>
153
155
  <CartesianGrid strokeDasharray="3 3" stroke="var(--border, #e5e5e5)" />
@@ -166,7 +168,7 @@ function ChartPieChart({ props }: BaseComponentProps<PieChartProps>) {
166
168
  const h = props.height ?? 300;
167
169
 
168
170
  return (
169
- <div className="pmx-chart">
171
+ <div className="pmx-chart pmx-chart--pie">
170
172
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
171
173
  <ResponsiveContainer width="100%" height={h}>
172
174
  <RechartsPieChart>
@@ -45,7 +45,7 @@ function ChartAreaChart({ props }: BaseComponentProps<AreaChartProps>) {
45
45
  const stroke = props.color ?? CHART_COLORS[0];
46
46
  const gradientId = `pmx-area-${props.yKey ?? 'value'}`;
47
47
  return (
48
- <CartesianChart props={props}>
48
+ <CartesianChart props={props} className="pmx-chart--area">
49
49
  {(data) => (
50
50
  <RechartsAreaChart data={data}>
51
51
  <defs>
@@ -88,7 +88,7 @@ function ChartScatterChart({ props }: BaseComponentProps<ScatterChartProps>) {
88
88
  const h = props.height ?? 300;
89
89
 
90
90
  return (
91
- <div className="pmx-chart">
91
+ <div className="pmx-chart pmx-chart--scatter">
92
92
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
93
93
  <ResponsiveContainer width="100%" height={h}>
94
94
  <RechartsScatterChart>
@@ -118,7 +118,7 @@ function ChartRadarChart({ props }: BaseComponentProps<RadarChartProps>) {
118
118
  const h = props.height ?? 320;
119
119
 
120
120
  return (
121
- <div className="pmx-chart">
121
+ <div className="pmx-chart pmx-chart--radar">
122
122
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
123
123
  <ResponsiveContainer width="100%" height={h}>
124
124
  <RechartsRadarChart data={data} outerRadius="75%">
@@ -163,7 +163,7 @@ function ChartStackedBarChart({ props }: BaseComponentProps<StackedBarChartProps
163
163
  const h = props.height ?? 300;
164
164
 
165
165
  return (
166
- <div className="pmx-chart">
166
+ <div className="pmx-chart pmx-chart--stacked-bar">
167
167
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
168
168
  <ResponsiveContainer width="100%" height={h}>
169
169
  <RechartsBarChart data={chartData}>
@@ -234,7 +234,7 @@ function ChartComposedChart({ props }: BaseComponentProps<ComposedChartProps>) {
234
234
  const h = props.height ?? 300;
235
235
 
236
236
  return (
237
- <div className="pmx-chart">
237
+ <div className="pmx-chart pmx-chart--composed">
238
238
  {props.title && <div className="pmx-chart__title">{props.title}</div>}
239
239
  <ResponsiveContainer width="100%" height={h}>
240
240
  <RechartsComposedChart data={data}>
@@ -147,9 +147,23 @@ button {
147
147
 
148
148
  .pmx-chart {
149
149
  width: 100%;
150
+ min-width: 280px;
151
+ overflow-x: auto;
150
152
  padding: 0.5rem 0;
151
153
  }
152
154
 
155
+ .pmx-chart--line,
156
+ .pmx-chart--area,
157
+ .pmx-chart--scatter,
158
+ .pmx-chart--stacked-bar,
159
+ .pmx-chart--composed {
160
+ min-width: 320px;
161
+ }
162
+
163
+ .pmx-chart--radar {
164
+ min-width: 360px;
165
+ }
166
+
153
167
  .pmx-chart__title {
154
168
  font-size: 0.875rem;
155
169
  font-weight: 600;
package/src/mcp/server.ts CHANGED
@@ -279,10 +279,18 @@ export async function startMcpServer(): Promise<void> {
279
279
  data: z.array(z.record(z.string(), z.unknown())).optional().describe('Graph dataset when type="graph"'),
280
280
  xKey: z.string().optional().describe('X-axis key for line/bar graphs'),
281
281
  yKey: z.string().optional().describe('Y-axis key for line/bar graphs'),
282
+ zKey: z.string().optional().describe('Optional bubble-size key for scatter charts'),
282
283
  nameKey: z.string().optional().describe('Slice name key for pie graphs'),
283
284
  valueKey: z.string().optional().describe('Slice value key for pie graphs'),
285
+ axisKey: z.string().optional().describe('Category key for radar charts'),
286
+ metrics: z.array(z.string()).optional().describe('Series keys to plot as radar polygons'),
287
+ series: z.array(z.string()).optional().describe('Series keys for stacked-bar segments'),
288
+ barKey: z.string().optional().describe('Bar series key for composed charts'),
289
+ lineKey: z.string().optional().describe('Line series key for composed charts'),
284
290
  aggregate: z.enum(['sum', 'count', 'avg']).optional().describe('Optional aggregation for repeated keys'),
285
291
  color: z.string().optional().describe('Optional graph color'),
292
+ barColor: z.string().optional().describe('Optional bar color for composed charts'),
293
+ lineColor: z.string().optional().describe('Optional line color for composed charts'),
286
294
  height: z.number().optional().describe('Optional graph content height'),
287
295
  },
288
296
  async (input) => {
@@ -300,10 +308,18 @@ export async function startMcpServer(): Promise<void> {
300
308
  data: input.data ?? [],
301
309
  ...(typeof input.xKey === 'string' ? { xKey: input.xKey } : {}),
302
310
  ...(typeof input.yKey === 'string' ? { yKey: input.yKey } : {}),
311
+ ...(typeof input.zKey === 'string' ? { zKey: input.zKey } : {}),
303
312
  ...(typeof input.nameKey === 'string' ? { nameKey: input.nameKey } : {}),
304
313
  ...(typeof input.valueKey === 'string' ? { valueKey: input.valueKey } : {}),
314
+ ...(typeof input.axisKey === 'string' ? { axisKey: input.axisKey } : {}),
315
+ ...(Array.isArray(input.metrics) ? { metrics: input.metrics } : {}),
316
+ ...(Array.isArray(input.series) ? { series: input.series } : {}),
317
+ ...(typeof input.barKey === 'string' ? { barKey: input.barKey } : {}),
318
+ ...(typeof input.lineKey === 'string' ? { lineKey: input.lineKey } : {}),
305
319
  ...(typeof input.aggregate === 'string' ? { aggregate: input.aggregate } : {}),
306
320
  ...(typeof input.color === 'string' ? { color: input.color } : {}),
321
+ ...(typeof input.barColor === 'string' ? { barColor: input.barColor } : {}),
322
+ ...(typeof input.lineColor === 'string' ? { lineColor: input.lineColor } : {}),
307
323
  ...(typeof input.height === 'number' ? { height: input.height } : {}),
308
324
  },
309
325
  });
@@ -348,6 +364,7 @@ export async function startMcpServer(): Promise<void> {
348
364
  mainTsx: z.string().optional().describe('Optional contents for src/main.tsx'),
349
365
  indexHtml: z.string().optional().describe('Optional contents for index.html'),
350
366
  files: z.record(z.string(), z.string()).optional().describe('Optional map of additional project-relative file paths to file contents'),
367
+ deps: z.array(z.string()).optional().describe('Optional npm dependencies to install before bundling (e.g. ["recharts", "framer-motion@^11"]). Validated against npm-name format; flags and shell metacharacters are rejected.'),
351
368
  projectPath: z.string().optional().describe('Optional workspace-relative reusable project path. Defaults to .pmx-canvas/artifacts/.web-artifacts/<slug>'),
352
369
  outputPath: z.string().optional().describe('Optional workspace-relative HTML output path. Defaults to .pmx-canvas/artifacts/<slug>.html'),
353
370
  openInCanvas: z.boolean().optional().describe('Open the generated artifact in canvas after build (default true)'),
@@ -366,6 +383,7 @@ export async function startMcpServer(): Promise<void> {
366
383
  ...(typeof input.mainTsx === 'string' ? { mainTsx: input.mainTsx } : {}),
367
384
  ...(typeof input.indexHtml === 'string' ? { indexHtml: input.indexHtml } : {}),
368
385
  ...(input.files ? { files: input.files } : {}),
386
+ ...(Array.isArray(input.deps) ? { deps: input.deps } : {}),
369
387
  ...(typeof input.projectPath === 'string'
370
388
  ? { projectPath: safeWorkspacePath(input.projectPath) }
371
389
  : {}),
@@ -672,13 +690,34 @@ export async function startMcpServer(): Promise<void> {
672
690
  // ── canvas_focus_node ──────────────────────────────────────────
673
691
  server.tool(
674
692
  'canvas_focus_node',
675
- 'Pan the viewport to center on a specific node.',
676
- { id: z.string().describe('Node ID to focus on') },
677
- async ({ id }) => {
693
+ 'Bring a node into focus. By default the viewport pans so the node is centered. Pass noPan=true to raise/select the node without moving the human\'s camera (useful when reacting to background events without disrupting the human\'s current view).',
694
+ {
695
+ id: z.string().describe('Node ID to focus on'),
696
+ noPan: z
697
+ .boolean()
698
+ .optional()
699
+ .describe('If true, raise/select the node without panning the viewport. Default false.'),
700
+ },
701
+ async ({ id, noPan }) => {
678
702
  const c = await ensureCanvas();
679
- c.focusNode(id);
703
+ const result = c.focusNode(id, { ...(noPan === true ? { noPan: true } : {}) });
704
+ if (!result) {
705
+ return {
706
+ content: [
707
+ {
708
+ type: 'text',
709
+ text: JSON.stringify({ ok: false, error: `Node "${id}" not found.` }),
710
+ },
711
+ ],
712
+ };
713
+ }
680
714
  return {
681
- content: [{ type: 'text', text: JSON.stringify({ ok: true, focused: id }) }],
715
+ content: [
716
+ {
717
+ type: 'text',
718
+ text: JSON.stringify({ ok: true, focused: result.focused, panned: result.panned }),
719
+ },
720
+ ],
682
721
  };
683
722
  },
684
723
  );
@@ -36,6 +36,7 @@ import {
36
36
  getWebpageFetchErrorDetails,
37
37
  normalizeWebpageUrl,
38
38
  } from './webpage-node.js';
39
+ import { buildExcalidrawRestoreCheckpointToolInput, ensureExcalidrawCheckpointId, isExcalidrawCreateView } from './diagram-presets.js';
39
40
 
40
41
  export type CanvasArrangeMode = 'grid' | 'column' | 'flow';
41
42
  export type CanvasPinMode = 'set' | 'add' | 'remove';
@@ -82,6 +83,29 @@ function isRecord(value: unknown): value is Record<string, unknown> {
82
83
  return value !== null && typeof value === 'object' && !Array.isArray(value);
83
84
  }
84
85
 
86
+ function getStoredExcalidrawCheckpointId(node: CanvasNodeState): string | null {
87
+ const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
88
+ const checkpointId = appCheckpoint?.id;
89
+ return typeof checkpointId === 'string' && checkpointId.trim().length > 0 ? checkpointId.trim() : null;
90
+ }
91
+
92
+ function resolveExtAppRehydratedToolInput(
93
+ node: CanvasNodeState,
94
+ openedToolInput: Record<string, unknown>,
95
+ ): Record<string, unknown> {
96
+ if (!isExcalidrawCreateView(node.data.serverName, node.data.toolName)) return openedToolInput;
97
+ const checkpointId = getStoredExcalidrawCheckpointId(node);
98
+ if (!checkpointId) return openedToolInput;
99
+ const appCheckpoint = isRecord(node.data.appCheckpoint) ? node.data.appCheckpoint : null;
100
+ return {
101
+ ...openedToolInput,
102
+ elements: buildExcalidrawRestoreCheckpointToolInput(
103
+ checkpointId,
104
+ typeof appCheckpoint?.data === 'string' ? appCheckpoint.data : undefined,
105
+ ),
106
+ };
107
+ }
108
+
85
109
  function isExtAppNode(node: CanvasNodeState | undefined): node is CanvasNodeState {
86
110
  return node?.type === 'mcp-app' && node.data.mode === 'ext-app';
87
111
  }
@@ -280,13 +304,18 @@ export async function syncCanvasRuntimeBackends(
280
304
  ? { serverName: current.data.serverName.trim() }
281
305
  : {}),
282
306
  });
307
+ const toolInput = resolveExtAppRehydratedToolInput(current, opened.toolInput);
308
+ const storedCheckpointId = getStoredExcalidrawCheckpointId(current);
309
+ const toolResult = isExcalidrawCreateView(opened.serverName, opened.toolName)
310
+ ? ensureExcalidrawCheckpointId(opened.toolResult, nodeId, storedCheckpointId)
311
+ : opened.toolResult;
283
312
 
284
313
  canvasState.withSuppressedRecording(() => {
285
314
  setExtAppRuntimeState(nodeId, {
286
315
  appSessionId: opened.sessionId,
287
316
  html: opened.html,
288
- toolInput: opened.toolInput,
289
- toolResult: opened.toolResult,
317
+ toolInput,
318
+ toolResult,
290
319
  resourceUri: opened.resourceUri,
291
320
  toolDefinition: opened.tool,
292
321
  resourceMeta: opened.resourceMeta,
@@ -704,7 +733,7 @@ function collectArrangeExcludedNodeIds(nodes: CanvasNodeState[]): Set<string> {
704
733
  const excluded = new Set<string>();
705
734
  for (const node of nodes) {
706
735
  const parentGroup = typeof node.data.parentGroup === 'string' ? node.data.parentGroup : null;
707
- if (isArrangeLocked(node) || (parentGroup && excludedGroupIds.has(parentGroup))) {
736
+ if (parentGroup || isArrangeLocked(node)) {
708
737
  excluded.add(node.id);
709
738
  }
710
739
  }
@@ -747,12 +776,13 @@ export function arrangeCanvasNodes(layout: CanvasArrangeMode): { arranged: numbe
747
776
  return;
748
777
  }
749
778
 
750
- const cols = Math.max(1, Math.floor(1440 / (360 + gap)));
779
+ const maxNodeWidth = movableNodes.reduce((max, node) => Math.max(max, node.size.width), 360);
780
+ const cols = Math.max(1, Math.floor(1440 / (maxNodeWidth + gap)));
751
781
  let col = 0;
752
782
  let rowY = 80;
753
783
  let rowMaxHeight = 0;
754
784
  for (const node of movableNodes) {
755
- const x = 40 + col * (360 + gap);
785
+ const x = 40 + col * (maxNodeWidth + gap);
756
786
  canvasState.updateNode(node.id, { position: { x, y: rowY } });
757
787
  rowMaxHeight = Math.max(rowMaxHeight, node.size.height);
758
788
  col++;
@@ -1180,12 +1210,20 @@ export async function executeCanvasBatch(
1180
1210
  ...(typeof args.title === 'string' ? { title: args.title } : {}),
1181
1211
  ...(typeof args.xKey === 'string' ? { xKey: args.xKey } : {}),
1182
1212
  ...(typeof args.yKey === 'string' ? { yKey: args.yKey } : {}),
1213
+ ...(typeof args.zKey === 'string' ? { zKey: args.zKey } : {}),
1183
1214
  ...(typeof args.nameKey === 'string' ? { nameKey: args.nameKey } : {}),
1184
1215
  ...(typeof args.valueKey === 'string' ? { valueKey: args.valueKey } : {}),
1216
+ ...(typeof args.axisKey === 'string' ? { axisKey: args.axisKey } : {}),
1217
+ ...(Array.isArray(args.metrics) ? { metrics: args.metrics.filter((m): m is string => typeof m === 'string') } : {}),
1218
+ ...(Array.isArray(args.series) ? { series: args.series.filter((s): s is string => typeof s === 'string') } : {}),
1219
+ ...(typeof args.barKey === 'string' ? { barKey: args.barKey } : {}),
1220
+ ...(typeof args.lineKey === 'string' ? { lineKey: args.lineKey } : {}),
1185
1221
  ...(args.aggregate === 'sum' || args.aggregate === 'count' || args.aggregate === 'avg'
1186
1222
  ? { aggregate: args.aggregate }
1187
1223
  : {}),
1188
1224
  ...(typeof args.color === 'string' ? { color: args.color } : {}),
1225
+ ...(typeof args.barColor === 'string' ? { barColor: args.barColor } : {}),
1226
+ ...(typeof args.lineColor === 'string' ? { lineColor: args.lineColor } : {}),
1189
1227
  ...(typeof args.height === 'number' ? { height: args.height } : {}),
1190
1228
  ...(typeof args.x === 'number' ? { x: args.x } : {}),
1191
1229
  ...(typeof args.y === 'number' ? { y: args.y } : {}),
@@ -282,26 +282,27 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
282
282
  type: '"line" | "bar" | "pie" | "area" | "scatter" | "radar" | "stacked-bar" | "composed"',
283
283
  required: true,
284
284
  description: 'Chart type. Aliases like "stack" and "combo" are normalized server-side.',
285
+ aliases: ['graph-type'],
285
286
  },
286
- { name: 'data', type: 'Record<string, unknown>[]', required: true, description: 'Chart dataset.' },
287
+ { name: 'data', type: 'Record<string, unknown>[]', required: true, description: 'Chart dataset. The CLI also accepts piped JSON via --stdin.', aliases: ['data-json', 'data-file'] },
287
288
  { name: 'title', type: 'string', required: false, description: 'Optional graph title.' },
288
- { name: 'xKey', type: 'string', required: false, description: 'X-axis/category key for line, bar, area, scatter, stacked-bar, and composed charts.' },
289
- { name: 'yKey', type: 'string', required: false, description: 'Y-axis value key for line, bar, area, and scatter charts. Also used as a fallback bar key for composed charts.' },
290
- { name: 'zKey', type: 'string', required: false, description: 'Optional bubble-size key for scatter charts.' },
291
- { name: 'nameKey', type: 'string', required: false, description: 'Slice name key for pie graphs.' },
292
- { name: 'valueKey', type: 'string', required: false, description: 'Slice value key for pie graphs.' },
293
- { name: 'axisKey', type: 'string', required: false, description: 'Category key for radar charts.' },
289
+ { name: 'xKey', type: 'string', required: false, description: 'X-axis/category key for line, bar, area, scatter, stacked-bar, and composed charts.', aliases: ['x-key'] },
290
+ { name: 'yKey', type: 'string', required: false, description: 'Y-axis value key for line, bar, area, and scatter charts. Also used as a fallback bar key for composed charts.', aliases: ['y-key'] },
291
+ { name: 'zKey', type: 'string', required: false, description: 'Optional bubble-size key for scatter charts.', aliases: ['z-key'] },
292
+ { name: 'nameKey', type: 'string', required: false, description: 'Slice name key for pie graphs.', aliases: ['name-key'] },
293
+ { name: 'valueKey', type: 'string', required: false, description: 'Slice value key for pie graphs.', aliases: ['value-key'] },
294
+ { name: 'axisKey', type: 'string', required: false, description: 'Category key for radar charts.', aliases: ['axis-key'] },
294
295
  { name: 'metrics', type: 'string[]', required: false, description: 'Series keys to plot as radar polygons. Defaults to non-axis numeric columns.' },
295
296
  { name: 'series', type: 'string[]', required: false, description: 'Series keys for stacked-bar segments. Defaults to non-x numeric columns.' },
296
- { name: 'barKey', type: 'string', required: false, description: 'Bar series key for composed charts.' },
297
- { name: 'lineKey', type: 'string', required: false, description: 'Line series key for composed charts.' },
297
+ { name: 'barKey', type: 'string', required: false, description: 'Bar series key for composed charts.', aliases: ['bar-key'] },
298
+ { name: 'lineKey', type: 'string', required: false, description: 'Line series key for composed charts.', aliases: ['line-key'] },
298
299
  { name: 'aggregate', type: '"sum" | "count" | "avg"', required: false, description: 'Optional aggregation for repeated x-axis values in line, bar, area, and stacked-bar charts.' },
299
300
  { name: 'color', type: 'string', required: false, description: 'Optional series color for line, bar, area, and scatter charts.' },
300
- { name: 'barColor', type: 'string', required: false, description: 'Optional bar color for composed charts.' },
301
- { name: 'lineColor', type: 'string', required: false, description: 'Optional line color for composed charts.' },
302
- { name: 'height', type: 'number', required: false, description: 'Optional chart content height.' },
301
+ { name: 'barColor', type: 'string', required: false, description: 'Optional bar color for composed charts.', aliases: ['bar-color'] },
302
+ { name: 'lineColor', type: 'string', required: false, description: 'Optional line color for composed charts.', aliases: ['line-color'] },
303
+ { name: 'height', type: 'number', required: false, description: 'Optional chart content height.', aliases: ['chart-height'] },
303
304
  { name: 'width', type: 'number', required: false, description: 'Optional node width.' },
304
- { name: 'nodeHeight', type: 'number', required: false, description: 'Optional node height.' },
305
+ { name: 'nodeHeight', type: 'number', required: false, description: 'Optional node height (canvas frame). Distinct from `height`, which sets only the chart content height inside the node.', aliases: ['node-height'] },
305
306
  ],
306
307
  example: {
307
308
  title: 'Deploy Trend',
@@ -326,7 +327,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
326
327
  endpoint: '/api/canvas/web-artifact',
327
328
  fields: [
328
329
  { name: 'title', type: 'string', required: true, description: 'Artifact title used for default paths.' },
329
- { name: 'appTsx', type: 'string', required: true, description: 'Contents for src/App.tsx.', aliases: ['stdin', 'app-file', 'app-tsx'] },
330
+ { name: 'appTsx', type: 'string', required: true, description: 'Contents for src/App.tsx. The CLI also accepts piped contents via --stdin.', aliases: ['app-file', 'app-tsx'] },
330
331
  { name: 'indexCss', type: 'string', required: false, description: 'Optional src/index.css contents.', aliases: ['index-css-file', 'index-css'] },
331
332
  { name: 'mainTsx', type: 'string', required: false, description: 'Optional src/main.tsx contents.', aliases: ['main-file', 'main-tsx'] },
332
333
  { name: 'indexHtml', type: 'string', required: false, description: 'Optional index.html contents.', aliases: ['index-html-file', 'index-html'] },
@@ -334,6 +335,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
334
335
  { name: 'outputPath', type: 'string', required: false, description: 'Optional output HTML path.' },
335
336
  { name: 'openInCanvas', type: 'boolean', required: false, description: 'Open the built artifact on the canvas (default true).' },
336
337
  { name: 'includeLogs', type: 'boolean', required: false, description: 'Include raw build stdout/stderr in the response (default false).' },
338
+ { name: 'deps', type: 'string[]', required: false, description: 'Optional npm dependencies to add before bundling, e.g. recharts.', aliases: ['deps'] },
337
339
  ],
338
340
  example: {
339
341
  title: 'Dashboard Artifact',
@@ -6,6 +6,7 @@ import {
6
6
  } from './canvas-provenance.js';
7
7
 
8
8
  export interface SerializedCanvasNode extends CanvasNodeState {
9
+ kind: string;
9
10
  title: string | null;
10
11
  content: string | null;
11
12
  path: string | null;
@@ -26,6 +27,21 @@ function pickProvenance(value: unknown): CanvasNodeProvenance | null {
26
27
  return value as CanvasNodeProvenance;
27
28
  }
28
29
 
30
+ function getCanvasNodeKind(node: CanvasNodeState, data: Record<string, unknown>): string {
31
+ if (node.type !== 'mcp-app') return node.type;
32
+ // Authoritative discriminator added in v0.1.4. New web-artifacts always set
33
+ // it; matching here first means a future URL-only artifact (no `data.path`)
34
+ // still classifies correctly without falling through to the legacy heuristic.
35
+ if (data.viewerType === 'web-artifact') return 'web-artifact';
36
+ if (data.mode === 'ext-app') return 'external-app';
37
+ // Transitional fallback for canvas state.json files persisted before v0.1.4
38
+ // introduced `viewerType`. Web-artifacts written by older versions always
39
+ // stored a `path` to the bundled HTML file, so this heuristic is safe for
40
+ // existing data. Remove in v0.2.x once a one-shot migration runs at boot.
41
+ if (data.hostMode === 'hosted' && typeof data.path === 'string') return 'web-artifact';
42
+ return 'mcp-app';
43
+ }
44
+
29
45
  export function getCanvasNodeTitle(node: CanvasNodeState): string | null {
30
46
  return pickString(node.data.title)
31
47
  ?? (node.type === 'webpage' ? pickString(node.data.pageTitle) : null)
@@ -47,6 +63,7 @@ export function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode
47
63
  return {
48
64
  ...node,
49
65
  data,
66
+ kind: getCanvasNodeKind(node, data),
50
67
  title: getCanvasNodeTitle(node),
51
68
  content: getCanvasNodeContent(node),
52
69
  path: pickString(data.path),
@@ -77,7 +94,8 @@ export function buildCanvasSummary(): CanvasSummary {
77
94
 
78
95
  const typeCounts: Record<string, number> = {};
79
96
  for (const n of layout.nodes) {
80
- typeCounts[n.type] = (typeCounts[n.type] ?? 0) + 1;
97
+ const kind = getCanvasNodeKind(n, normalizeCanvasNodeData(n.type, n.data));
98
+ typeCounts[kind] = (typeCounts[kind] ?? 0) + 1;
81
99
  }
82
100
 
83
101
  const pinnedTitles = layout.nodes