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
@@ -287,9 +287,9 @@ export declare const allComponentDefinitions: {
287
287
  props: z.ZodObject<{
288
288
  text: z.ZodString;
289
289
  variant: z.ZodNullable<z.ZodEnum<{
290
+ default: "default";
290
291
  error: "error";
291
292
  success: "success";
292
- default: "default";
293
293
  secondary: "secondary";
294
294
  destructive: "destructive";
295
295
  outline: "outline";
@@ -7,6 +7,7 @@ type OpenMcpAppResult = Awaited<ReturnType<PmxCanvas['openMcpApp']>>;
7
7
  type AddDiagramInput = Parameters<PmxCanvas['addDiagram']>[0];
8
8
  type AddJsonRenderNodeInput = Parameters<PmxCanvas['addJsonRenderNode']>[0];
9
9
  type AddJsonRenderNodeResult = ReturnType<PmxCanvas['addJsonRenderNode']>;
10
+ type AddHtmlNodeInput = Parameters<PmxCanvas['addHtmlNode']>[0];
10
11
  type AddGraphNodeInput = Parameters<PmxCanvas['addGraphNode']>[0];
11
12
  type AddGraphNodeResult = ReturnType<PmxCanvas['addGraphNode']>;
12
13
  type UpdateNodePatch = Parameters<PmxCanvas['updateNode']>[1];
@@ -23,8 +24,11 @@ type HistoryResult = ReturnType<PmxCanvas['getHistory']>;
23
24
  type SetContextPinsResult = ReturnType<PmxCanvas['setContextPins']>;
24
25
  type RunBatchInput = Parameters<PmxCanvas['runBatch']>[0];
25
26
  type RunBatchResult = Awaited<ReturnType<PmxCanvas['runBatch']>>;
27
+ type SnapshotListOptions = Parameters<PmxCanvas['listSnapshots']>[0];
26
28
  type SnapshotList = ReturnType<PmxCanvas['listSnapshots']>;
27
29
  type DeleteSnapshotResult = ReturnType<PmxCanvas['deleteSnapshot']>;
30
+ type GcSnapshotsOptions = Parameters<PmxCanvas['gcSnapshots']>[0];
31
+ type GcSnapshotsResult = ReturnType<PmxCanvas['gcSnapshots']>;
28
32
  type DiffSnapshotResult = ReturnType<PmxCanvas['diffSnapshot']>;
29
33
  type CodeGraphResult = ReturnType<PmxCanvas['getCodeGraph']>;
30
34
  type ValidationResult = ReturnType<PmxCanvas['validate']>;
@@ -45,6 +49,7 @@ export interface CanvasAccess {
45
49
  openMcpApp(input: OpenMcpAppInput): Promise<OpenMcpAppResult>;
46
50
  addDiagram(input: AddDiagramInput): Promise<OpenMcpAppResult>;
47
51
  addJsonRenderNode(input: AddJsonRenderNodeInput): Promise<AddJsonRenderNodeResult>;
52
+ addHtmlNode(input: AddHtmlNodeInput): Promise<string>;
48
53
  addGraphNode(input: AddGraphNodeInput): Promise<AddGraphNodeResult>;
49
54
  buildWebArtifact(input: WebArtifactInput): Promise<WebArtifactResult>;
50
55
  updateNode(id: string, patch: UpdateNodePatch): Promise<void>;
@@ -67,12 +72,13 @@ export interface CanvasAccess {
67
72
  setContextPins(nodeIds: string[], mode?: 'set' | 'add' | 'remove'): Promise<SetContextPinsResult>;
68
73
  getPinnedNodeIds(): Promise<string[]>;
69
74
  runBatch(operations: RunBatchInput): Promise<RunBatchResult>;
70
- listSnapshots(): Promise<SnapshotList>;
75
+ listSnapshots(options?: SnapshotListOptions): Promise<SnapshotList>;
71
76
  saveSnapshot(name: string): Promise<CanvasSnapshot | null>;
72
77
  restoreSnapshot(id: string): Promise<{
73
78
  ok: boolean;
74
79
  }>;
75
80
  deleteSnapshot(id: string): Promise<DeleteSnapshotResult>;
81
+ gcSnapshots(options?: GcSnapshotsOptions): Promise<GcSnapshotsResult>;
76
82
  diffSnapshot(idOrName: string): Promise<DiffSnapshotResult>;
77
83
  getCodeGraph(): Promise<CodeGraphResult>;
78
84
  validate(): Promise<ValidationResult>;
@@ -2,6 +2,7 @@ import type { CanvasNodeState } from './canvas-state.js';
2
2
  export interface AgentContextNode {
3
3
  id: string;
4
4
  type: CanvasNodeState['type'];
5
+ kind: string;
5
6
  title: string | null;
6
7
  content: string | null;
7
8
  metadata?: Record<string, unknown>;
@@ -1,7 +1,8 @@
1
- import { type CanvasEdge, type CanvasNodeState, type CanvasNodeUpdate, type CanvasSnapshot } from './canvas-state.js';
1
+ import { canvasState, type CanvasEdge, type CanvasNodeState, type CanvasNodeUpdate, type CanvasSnapshot } from './canvas-state.js';
2
2
  import { type GraphNodeInput, type JsonRenderNodeInput, type JsonRenderSpec } from '../json-render/server.js';
3
3
  export type CanvasArrangeMode = 'grid' | 'column' | 'flow';
4
4
  export type CanvasPinMode = 'set' | 'add' | 'remove';
5
+ export declare function setCanvasLayoutUpdateEmitter(emitter: (() => void) | null): void;
5
6
  export interface CanvasFitViewOptions {
6
7
  width?: number;
7
8
  height?: number;
@@ -150,7 +151,7 @@ export declare function setCanvasContextPins(nodeIds: string[], mode?: CanvasPin
150
151
  count: number;
151
152
  nodeIds: string[];
152
153
  };
153
- export declare function listCanvasSnapshots(): CanvasSnapshot[];
154
+ export declare function listCanvasSnapshots(options?: Parameters<typeof canvasState.listSnapshots>[0]): CanvasSnapshot[];
154
155
  export declare function saveCanvasSnapshot(name: string): CanvasSnapshot | null;
155
156
  export declare function restoreCanvasSnapshot(idOrName: string): Promise<{
156
157
  ok: boolean;
@@ -158,6 +159,7 @@ export declare function restoreCanvasSnapshot(idOrName: string): Promise<{
158
159
  export declare function deleteCanvasSnapshot(id: string): {
159
160
  ok: boolean;
160
161
  };
162
+ export declare function gcCanvasSnapshots(options?: Parameters<typeof canvasState.gcSnapshots>[0]): ReturnType<typeof canvasState.gcSnapshots>;
161
163
  export declare function addCanvasEdge(input: {
162
164
  from?: string;
163
165
  to?: string;
@@ -1,4 +1,4 @@
1
- export type CanvasNodeType = 'markdown' | 'mcp-app' | 'webpage' | 'json-render' | 'graph' | 'prompt' | 'response' | 'status' | 'context' | 'ledger' | 'trace' | 'file' | 'image' | 'group';
1
+ export type CanvasNodeType = 'markdown' | 'mcp-app' | 'webpage' | 'json-render' | 'graph' | 'prompt' | 'response' | 'status' | 'context' | 'ledger' | 'trace' | 'file' | 'image' | 'html' | 'group';
2
2
  export type CanvasNodeProvenanceSourceKind = 'workspace-file' | 'webpage-url' | 'mcp-tool' | 'artifact-file' | 'image-url';
3
3
  export type CanvasNodeRefreshStrategy = 'file-watch' | 'file-read-write' | 'image-reload' | 'webpage-refresh' | 'mcp-app-rehydrate' | 'artifact-reopen';
4
4
  export interface CanvasNodeProvenance {
@@ -11,10 +11,13 @@ export interface SerializedCanvasNode extends CanvasNodeState {
11
11
  export interface SerializedCanvasLayout extends Omit<CanvasLayout, 'nodes'> {
12
12
  nodes: SerializedCanvasNode[];
13
13
  }
14
+ export declare function getCanvasNodeKind(node: CanvasNodeState, data: Record<string, unknown>): string;
14
15
  export declare function getCanvasNodeTitle(node: CanvasNodeState): string | null;
15
16
  export declare function getCanvasNodeContent(node: CanvasNodeState): string | null;
16
17
  export declare function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode;
18
+ export declare function serializeCanvasNodeWithBlobSummaries(node: CanvasNodeState): SerializedCanvasNode;
17
19
  export declare function serializeCanvasLayout(layout: CanvasLayout): SerializedCanvasLayout;
20
+ export declare function serializeCanvasLayoutWithBlobSummaries(layout: CanvasLayout): SerializedCanvasLayout;
18
21
  export interface CanvasSummary {
19
22
  totalNodes: number;
20
23
  totalEdges: number;
@@ -9,6 +9,21 @@
9
9
  * workspace root on every mutation (debounced). Auto-loads on `loadFromDisk()`.
10
10
  */
11
11
  export declare const PMX_CANVAS_DIR = ".pmx-canvas";
12
+ export interface PersistedBlobRef {
13
+ __pmxCanvasBlob: 'v1';
14
+ path: string;
15
+ sha256: string;
16
+ encoding: 'json+gzip';
17
+ bytes: number;
18
+ jsonBytes: number;
19
+ }
20
+ interface PersistedCanvasState {
21
+ version: number;
22
+ viewport: ViewportState;
23
+ nodes: CanvasNodeState[];
24
+ edges: CanvasEdge[];
25
+ contextPins: string[];
26
+ }
12
27
  interface LoadFromDiskOptions {
13
28
  clearExisting?: boolean;
14
29
  }
@@ -20,9 +35,24 @@ export interface CanvasSnapshot {
20
35
  nodeCount: number;
21
36
  edgeCount: number;
22
37
  }
38
+ export interface CanvasSnapshotListOptions {
39
+ limit?: number;
40
+ query?: string;
41
+ all?: boolean;
42
+ }
43
+ export interface CanvasSnapshotGcOptions {
44
+ keep?: number;
45
+ dryRun?: boolean;
46
+ }
47
+ export interface CanvasSnapshotGcResult {
48
+ ok: boolean;
49
+ kept: number;
50
+ deleted: CanvasSnapshot[];
51
+ dryRun: boolean;
52
+ }
23
53
  export interface CanvasNodeState {
24
54
  id: string;
25
- type: 'markdown' | 'mcp-app' | 'webpage' | 'json-render' | 'graph' | 'prompt' | 'response' | 'status' | 'context' | 'ledger' | 'trace' | 'file' | 'image' | 'group';
55
+ type: 'markdown' | 'mcp-app' | 'webpage' | 'json-render' | 'graph' | 'prompt' | 'response' | 'status' | 'context' | 'ledger' | 'trace' | 'file' | 'image' | 'html' | 'group';
26
56
  position: {
27
57
  x: number;
28
58
  y: number;
@@ -92,7 +122,7 @@ declare class CanvasStateManager {
92
122
  onChange(cb: (type: CanvasChangeType) => void): void;
93
123
  private notifyChange;
94
124
  private _mutationRecorder;
95
- private _suppressRecording;
125
+ private _suppressRecordingDepth;
96
126
  /** Register a mutation recorder. Used by mutation-history to capture undo/redo closures. */
97
127
  onMutation(cb: (info: MutationRecordInfo) => void): void;
98
128
  /** Run a function with mutation recording suppressed (for undo/redo replay and computed edges). */
@@ -111,6 +141,16 @@ declare class CanvasStateManager {
111
141
  private _saveTimer;
112
142
  /** Set the workspace root to enable auto-persistence. */
113
143
  setWorkspaceRoot(workspaceRoot: string): void;
144
+ private get blobsDir();
145
+ private relativeBlobPath;
146
+ private resolveBlobPath;
147
+ private writeBlobValue;
148
+ private readBlobValue;
149
+ private externalizeNodeDataBlobs;
150
+ private resolveNodeDataBlobs;
151
+ isBlobReference(value: unknown): value is PersistedBlobRef;
152
+ resolveBlobReference(value: unknown): unknown;
153
+ private externalizePersistedStateBlobs;
114
154
  /**
115
155
  * One-time migration: rename files from the pre-consolidation layout
116
156
  * (`.pmx-canvas.json` + `.pmx-canvas-snapshots/`) into `.pmx-canvas/`.
@@ -129,10 +169,15 @@ declare class CanvasStateManager {
129
169
  private get snapshotsDir();
130
170
  private applyPersistedState;
131
171
  private readResolvedSnapshot;
172
+ getSnapshotDataForPersistence(idOrName: string): {
173
+ snapshot: CanvasSnapshot;
174
+ state: PersistedCanvasState;
175
+ } | null;
132
176
  /** Save current canvas state as a named snapshot. */
133
177
  saveSnapshot(name: string): CanvasSnapshot | null;
134
- /** List all saved snapshots. */
135
- listSnapshots(): CanvasSnapshot[];
178
+ /** List saved snapshots, newest first. */
179
+ listSnapshots(options?: CanvasSnapshotListOptions): CanvasSnapshot[];
180
+ gcSnapshots(options?: CanvasSnapshotGcOptions): CanvasSnapshotGcResult;
136
181
  /** Restore canvas state from a snapshot. */
137
182
  restoreSnapshot(idOrName: string): boolean;
138
183
  /** Read a snapshot's data without restoring it (for diff). Resolves by ID or name. */
@@ -150,12 +195,14 @@ declare class CanvasStateManager {
150
195
  updateNode(id: string, patch: Partial<CanvasNodeState>): void;
151
196
  removeNode(id: string): void;
152
197
  getNode(id: string): CanvasNodeState | undefined;
198
+ getNodeForPersistence(id: string): CanvasNodeState | undefined;
153
199
  addEdge(edge: CanvasEdge): boolean;
154
200
  removeEdge(id: string): boolean;
155
201
  getEdges(): CanvasEdge[];
156
202
  getEdgesForNode(nodeId: string): CanvasEdge[];
157
203
  private removeEdgesForNode;
158
204
  getLayout(): CanvasLayout;
205
+ getLayoutForPersistence(): CanvasLayout;
159
206
  applyUpdates(updates: CanvasNodeUpdate[]): {
160
207
  applied: number;
161
208
  skipped: number;
@@ -0,0 +1,5 @@
1
+ export declare function seedDemoCanvas(): {
2
+ nodes: number;
3
+ edges: number;
4
+ groups: number;
5
+ };
@@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events';
2
2
  import type { CanvasNodeState, CanvasEdge, CanvasLayout } from './canvas-state.js';
3
3
  import { searchNodes } from './spatial-analysis.js';
4
4
  import { diffLayouts } from './mutation-history.js';
5
- import { fitCanvasView } from './canvas-operations.js';
5
+ import { fitCanvasView, gcCanvasSnapshots, listCanvasSnapshots } from './canvas-operations.js';
6
6
  import { type WebArtifactBuildInput, type WebArtifactCanvasBuildResult } from './web-artifacts.js';
7
7
  import { type ExternalMcpTransportConfig } from './mcp-app-runtime.js';
8
8
  import { type DiagramPresetOpenInput } from './diagram-presets.js';
@@ -143,7 +143,7 @@ export declare class PmxCanvas extends EventEmitter {
143
143
  count: number;
144
144
  nodeIds: string[];
145
145
  };
146
- listSnapshots(): import("./canvas-state.js").CanvasSnapshot[];
146
+ listSnapshots(options?: Parameters<typeof listCanvasSnapshots>[0]): import("./canvas-state.js").CanvasSnapshot[];
147
147
  saveSnapshot(name: string): import("./canvas-state.js").CanvasSnapshot | null;
148
148
  restoreSnapshot(id: string): Promise<{
149
149
  ok: boolean;
@@ -151,6 +151,7 @@ export declare class PmxCanvas extends EventEmitter {
151
151
  deleteSnapshot(id: string): {
152
152
  ok: boolean;
153
153
  };
154
+ gcSnapshots(options?: Parameters<typeof gcCanvasSnapshots>[0]): ReturnType<typeof gcCanvasSnapshots>;
154
155
  diffSnapshot(idOrName: string): {
155
156
  ok: boolean;
156
157
  text?: string;
@@ -233,6 +234,15 @@ export declare class PmxCanvas extends EventEmitter {
233
234
  url: string;
234
235
  spec: JsonRenderSpec;
235
236
  };
237
+ addHtmlNode(input: {
238
+ html: string;
239
+ title?: string;
240
+ x?: number;
241
+ y?: number;
242
+ width?: number;
243
+ height?: number;
244
+ strictSize?: boolean;
245
+ }): string;
236
246
  addGraphNode(input: GraphNodeInput): {
237
247
  id: string;
238
248
  url: string;
@@ -253,7 +263,7 @@ export type { CanvasNodeState, CanvasEdge, CanvasLayout, ViewportState } from '.
253
263
  export type { CanvasAutomationWebViewOptions, CanvasAutomationWebViewStatus, PrimaryWorkbenchCanvasPromptRequest, PrimaryWorkbenchIntent, } from './server.js';
254
264
  export { emitPrimaryWorkbenchEvent, consumePrimaryWorkbenchIntents, setPrimaryWorkbenchAutoOpenEnabled, setPrimaryWorkbenchCanvasPromptHandler, startCanvasServer, stopCanvasServer, getCanvasServerPort, openUrlInExternalBrowser, getCanvasAutomationWebViewStatus, startCanvasAutomationWebView, stopCanvasAutomationWebView, evaluateCanvasAutomationWebView, resizeCanvasAutomationWebView, screenshotCanvasAutomationWebView, } from './server.js';
255
265
  export { canvasState } from './canvas-state.js';
256
- export type { CanvasSnapshot } from './canvas-state.js';
266
+ export type { CanvasSnapshot, CanvasSnapshotGcResult, CanvasSnapshotListOptions } from './canvas-state.js';
257
267
  export { findOpenCanvasPosition } from './placement.js';
258
268
  export { searchNodes, buildSpatialContext, detectClusters, findNeighborhoods } from './spatial-analysis.js';
259
269
  export type { SpatialCluster, SpatialContext, SpatialNeighbor, NodeSpatialInfo } from './spatial-analysis.js';
@@ -17,6 +17,7 @@ export interface WebArtifactBuildOutput {
17
17
  fileSize: number;
18
18
  projectPath: string;
19
19
  metadata: Record<string, unknown>;
20
+ sourceContext: WebArtifactSourceContext;
20
21
  logs?: {
21
22
  stdout?: WebArtifactLogSummary;
22
23
  stderr?: WebArtifactLogSummary;
@@ -30,6 +31,13 @@ export interface WebArtifactLogSummary {
30
31
  truncated: boolean;
31
32
  suppressedNoiseCount: number;
32
33
  }
34
+ export interface WebArtifactSourceContext {
35
+ content: string;
36
+ sourceFiles: string[];
37
+ sourceFileCount: number;
38
+ sourcePreview: string;
39
+ deps?: string[];
40
+ }
33
41
  export interface WebArtifactCanvasOpenResult {
34
42
  nodeId: string;
35
43
  url: string;
@@ -38,7 +46,10 @@ export interface WebArtifactCanvasBuildResult extends WebArtifactBuildOutput {
38
46
  openedInCanvas: boolean;
39
47
  nodeId?: string;
40
48
  url?: string;
49
+ startedAt: string;
41
50
  completedAt: string;
51
+ durationMs: number;
52
+ timeoutMs: number;
42
53
  }
43
54
  export declare function resolveWorkspacePath(pathLike: string, cwd?: string): string;
44
55
  export declare function resolveWebArtifactScriptPath(kind: 'init' | 'bundle'): string;
@@ -46,6 +57,13 @@ export declare function executeWebArtifactBuild(input: WebArtifactBuildInput): P
46
57
  export declare function openWebArtifactInCanvas(input: {
47
58
  title: string;
48
59
  filePath: string;
60
+ fileSize?: number;
61
+ projectPath?: string;
62
+ content?: string;
63
+ sourceFiles?: string[];
64
+ sourceFileCount?: number;
65
+ sourcePreview?: string;
66
+ deps?: string[];
49
67
  }): WebArtifactCanvasOpenResult;
50
68
  export declare function buildWebArtifactOnCanvas(input: WebArtifactBuildInput & {
51
69
  openInCanvas?: boolean;
@@ -0,0 +1,5 @@
1
+ export interface CanvasNodeKindInput {
2
+ type: string;
3
+ data: Record<string, unknown>;
4
+ }
5
+ export declare function getCanvasNodeKind(node: CanvasNodeKindInput): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -225,6 +225,7 @@ The CLI targets `http://localhost:4313` by default. Override with `PMX_CANVAS_UR
225
225
  | `mcp-app` | Hosted app/embed frame | Tool-backed MCP apps or external app content; not generic CLI-created notes |
226
226
  | `json-render` | Native structured UI panel | Dashboards, forms, tables, interactive layouts from json-render specs |
227
227
  | `graph` | Native chart panel | Line, bar, pie, area, scatter, radar, stacked-bar, and composed charts rendered inside the canvas |
228
+ | `html` | Sandboxed HTML+JS document | Self-contained HTML with optional inline `<script>` and CDN imports rendered in a sandbox-restricted iframe; canvas theme tokens are auto-injected |
228
229
  | `group` | Spatial container/frame | Visually group related nodes together |
229
230
  | `prompt` | Prompt thread root | Canvas-native prompt entry points for agent conversations. **Internal type — surfaces in `canvas://layout` for thread rendering but is not created via the public `canvas_add_node` API. Don't try to add one directly.** |
230
231
  | `response` | Prompt reply / streamed answer | Agent responses linked to prompt threads. **Same internal-only restriction as `prompt`.** |
@@ -269,6 +270,7 @@ MCP node-type routing:
269
270
  | Basic nodes (`markdown`, `status`, `context`, `ledger`, `trace`, `file`, `image`, `webpage`) | `canvas_add_node` |
270
271
  | `json-render` | `canvas_add_json_render_node` |
271
272
  | `graph` | `canvas_add_graph_node` |
273
+ | `html` | `canvas_add_html_node` |
272
274
  | `web-artifact` | `canvas_build_web_artifact` |
273
275
  | `external-app` / tool-backed `mcp-app` | `canvas_open_mcp_app` |
274
276
  | `group` | `canvas_create_group` |
@@ -431,6 +433,27 @@ ID extraction for mixed tool responses:
431
433
  **`canvas_ungroup`** — Release children from a group
432
434
  - `groupId` (required): group to dissolve
433
435
 
436
+ ### Group Layout Guidance
437
+
438
+ Use groups as spacious semantic regions, not as tight containers.
439
+
440
+ - Size the child nodes first, especially `graph`, `json-render`, `mcp-app`, image, and webpage
441
+ nodes whose rendered content may need more height than their visible title suggests.
442
+ - Give every group generous interior padding. Reserve extra top padding for the group header, then
443
+ keep children clear of the frame edges so headers, glow, resize handles, and node chrome do not
444
+ visually collide.
445
+ - If creating a group manually, compute its frame from the final child bounds plus padding. If the
446
+ group exists first, expand it before adding large children rather than shrinking children to fit.
447
+ - Use groups to label major regions of a board. Avoid wrapping every small relationship; too many
448
+ tight groups make the canvas harder to read than no groups.
449
+ - Keep edges local to a group where possible. Long cross-board edges can look like they come from
450
+ nowhere; use a nearby bridge/context node or split the relationship into shorter labeled edges.
451
+ - After grouping, verify the result in `canvas_get_layout` or the browser: child nodes should be
452
+ fully inside the group with padding, visible nodes should not overlap, and group headers should
453
+ not cover content.
454
+ - If a group makes important content less visible, enlarge the group, split it into clearer
455
+ regions, or remove the group. Visibility is more important than preserving a frame.
456
+
434
457
  ### Grouped Comparison Boards
435
458
 
436
459
  Use groups as named comparison areas, not just visual boxes.
@@ -613,6 +636,26 @@ server's `ui://` resource as an iframe node on the canvas
613
636
  - Prefer the dedicated `web-artifacts-builder` skill when you need the full React + shadcn workflow
614
637
  - Use the `playwright-cli` skill when you need to validate the built artifact in a live browser
615
638
 
639
+ ### HTML Nodes (Sandboxed iframe)
640
+
641
+ **`canvas_add_html_node`** — Add a self-contained HTML document rendered in a sandboxed iframe
642
+ - Required: `html` (full document or fragment; inline `<script>` and CDN `<script src="...">` are allowed)
643
+ - Optional: `title`, `x`, `y`, `width` (default 720), `height` (default 640), `strictSize`
644
+ - Iframe sandbox is `allow-scripts` only — no same-origin access, no top-navigation, no forms
645
+ - Canvas theme tokens are auto-injected as CSS custom properties (both `--c-*` and common `--color-*` aliases such as `--color-text-primary`, `--color-bg`, `--color-accent`) so authored HTML inherits the active theme
646
+ - Use for moderate-complexity visualizations and interactive widgets that need real JS but do not warrant a full React build (Chart.js demos, D3 sketches, custom HTML report views)
647
+
648
+ ### Choosing the Right Visual Tier
649
+
650
+ When the output is more than markdown, pick the lightest tier that fits:
651
+
652
+ | Tier | Tool | Build cost | When to pick it |
653
+ |------|------|------------|-----------------|
654
+ | Declarative UI | `canvas_add_json_render_node` / `canvas_add_graph_node` | None | Schema-driven dashboards, forms, charts; agent-friendly to read back via `canvas_get_node` |
655
+ | Sandboxed HTML+JS | `canvas_add_html_node` | None | Self-contained HTML with inline JS or CDN scripts; one-off visualizations or report views |
656
+ | Hosted MCP app | `canvas_open_mcp_app` / `canvas_add_diagram` | None | Interactive editors backed by an external MCP server (e.g. Excalidraw) |
657
+ | Bundled React app | `canvas_build_web_artifact` | Heavy (npm install + bundle) | Multi-component UIs needing React state, routing, shadcn/ui, or Tailwind class composition |
658
+
616
659
  ### Native Structured UI
617
660
 
618
661
  Use native `json-render` and `graph` nodes when the output should stay fully inside PMX Canvas:
@@ -72,6 +72,23 @@ Prefer extending the existing suites before inventing a one-off script.
72
72
  - If a change spans server and client, add at least one server-side assertion and one browser or
73
73
  API-level proof
74
74
 
75
+ ## Layout And Embedded Content Checks
76
+
77
+ - For seeded or generated boards, add API-level geometry assertions: expected node/edge counts,
78
+ group counts, valid edge endpoints, no visible node overlaps, and group children contained with
79
+ header/padding space.
80
+ - For grouped layouts, test non-group node overlap separately from group containment. Group frames
81
+ are allowed to contain children; children should not overlap each other or collide with headers.
82
+ - For edge-heavy layouts, assert endpoints exist and long cross-board edges are intentional. If a
83
+ user says an edge “comes from nowhere,” add a regression check for missing endpoints or excessive
84
+ edge distance in that board.
85
+ - For `graph`, `json-render`, `mcp-app`, webpage, and image nodes, API geometry is not enough.
86
+ Verify the rendered browser frame when changing sizing: iframe/body `scrollHeight` and
87
+ `scrollWidth` should fit the available frame unless scrolling is the intended behavior.
88
+ - When checking embedded frame fit manually, start from a clean seeded state, rebuild stale bundles,
89
+ and inspect the actual iframe document in a browser. Server dimensions can look correct while the
90
+ embedded content is still clipped.
91
+
75
92
  ## Failure Handling
76
93
 
77
94
  - Never wave away a failure without checking whether your change caused it
package/src/cli/agent.ts CHANGED
@@ -154,7 +154,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
154
154
  const flags: Record<string, string | true> = {};
155
155
  // Boolean-only flags (never take a value argument)
156
156
  const BOOL_FLAGS = new Set([
157
- 'help', 'h', 'ids', 'stdin', 'yes', 'list', 'clear', 'set', 'animated', 'dry-run',
157
+ 'help', 'h', 'ids', 'stdin', 'yes', 'list', 'clear', 'set', 'animated', 'dry-run', 'all',
158
158
  'no-open-in-canvas', 'lock-arrange', 'unlock-arrange', 'json', 'compact', 'summary',
159
159
  'verbose', 'include-logs', 'no-pan', 'schema', 'example', 'examples', 'strict-size', 'scroll-overflow',
160
160
  ]);
@@ -1081,7 +1081,7 @@ cmd('node add', 'Add a node to the canvas', [
1081
1081
  applyStrictSizeFlags(body, flags);
1082
1082
  if (type === 'trace') {
1083
1083
  for (const field of TRACE_NODE_FIELDS) {
1084
- const value = getStringFlag(flags, field);
1084
+ const value = getStringFlag(flags, field, field.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`));
1085
1085
  if (value !== undefined) body[field] = value;
1086
1086
  }
1087
1087
  }
@@ -1315,6 +1315,11 @@ cmd('node update', 'Update a node by ID', [
1315
1315
 
1316
1316
  applyStrictSizeFlags(body, flags);
1317
1317
 
1318
+ for (const field of TRACE_NODE_FIELDS) {
1319
+ const value = getStringFlag(flags, field, field.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`));
1320
+ if (value !== undefined) body[field] = value;
1321
+ }
1322
+
1318
1323
  if (x !== undefined || y !== undefined || width !== undefined || frameHeight !== undefined || arrangeLocked !== undefined) {
1319
1324
  const existing = await api('GET', `/api/canvas/node/${encodeURIComponent(id)}`) as {
1320
1325
  position: { x: number; y: number };
@@ -1346,7 +1351,7 @@ cmd('node update', 'Update a node by ID', [
1346
1351
  if (Object.keys(body).length === 0) {
1347
1352
  die(
1348
1353
  'No updates specified',
1349
- 'Use --title, --content, --x, --y, --width, --height, --strict-size, --pinned, --lock-arrange, --unlock-arrange, or --stdin',
1354
+ 'Use --title, --content, --x, --y, --width, --height, --strict-size, --pinned, trace fields, --lock-arrange, --unlock-arrange, or --stdin',
1350
1355
  );
1351
1356
  }
1352
1357
 
@@ -1684,13 +1689,41 @@ cmd('snapshot save', 'Save a named snapshot of the current canvas', [
1684
1689
  });
1685
1690
 
1686
1691
  // ── snapshot list ────────────────────────────────────────────
1687
- cmd('snapshot list', 'List all saved snapshots', [
1692
+ cmd('snapshot list', 'List saved snapshots', [
1688
1693
  'pmx-canvas snapshot list',
1694
+ 'pmx-canvas snapshot list --limit 50 --query baseline',
1695
+ 'pmx-canvas snapshot list --all',
1689
1696
  ], async (args) => {
1690
1697
  const { flags } = parseFlags(args);
1691
1698
  if (flags.help || flags.h) return showCommandHelp('snapshot list');
1692
1699
 
1693
- const result = await api('GET', '/api/canvas/snapshots');
1700
+ const params = new URLSearchParams();
1701
+ const limit = optionalNumberFlag(flags, 'limit', 'Use a positive integer, e.g. --limit 50');
1702
+ const query = getStringFlag(flags, 'query', 'q');
1703
+ if (limit !== undefined) params.set('limit', String(limit));
1704
+ if (query) params.set('q', query);
1705
+ if (flags.all) params.set('all', 'true');
1706
+ const result = await api('GET', `/api/canvas/snapshots${params.size > 0 ? `?${params.toString()}` : ''}`);
1707
+ output(result);
1708
+ });
1709
+
1710
+ // ── snapshot gc ──────────────────────────────────────────────
1711
+ cmd('snapshot gc', 'Delete old snapshots, keeping the newest N', [
1712
+ 'pmx-canvas snapshot gc --keep 20 --dry-run',
1713
+ 'pmx-canvas snapshot gc --keep 50 --yes',
1714
+ ], async (args) => {
1715
+ const { flags } = parseFlags(args);
1716
+ if (flags.help || flags.h) return showCommandHelp('snapshot gc');
1717
+
1718
+ const keep = optionalNumberFlag(flags, 'keep', 'Use a positive integer, e.g. --keep 20');
1719
+ const dryRun = flags['dry-run'] === true;
1720
+ if (!dryRun && !flags.yes) {
1721
+ die('Destructive operation requires --yes flag', 'Preview with: pmx-canvas snapshot gc --keep 20 --dry-run');
1722
+ }
1723
+ const result = await api('POST', '/api/canvas/snapshots/gc', {
1724
+ ...(keep !== undefined ? { keep } : {}),
1725
+ dryRun,
1726
+ });
1694
1727
  output(result);
1695
1728
  });
1696
1729
 
@@ -2285,12 +2318,25 @@ function showCommandHelp(name: string): void {
2285
2318
  console.log('\nOutput control:');
2286
2319
  console.log(' --summary Return only validation summary metadata');
2287
2320
  }
2321
+ if (name === 'snapshot list') {
2322
+ console.log('\nOptions:');
2323
+ console.log(' --limit <number> Maximum snapshots to return (default 20)');
2324
+ console.log(' --query <text> Case-insensitive ID/name filter');
2325
+ console.log(' --all Return all snapshots');
2326
+ }
2327
+ if (name === 'snapshot gc') {
2328
+ console.log('\nOptions:');
2329
+ console.log(' --keep <number> Number of newest snapshots to keep (default 20)');
2330
+ console.log(' --dry-run Preview deletions without removing files');
2331
+ console.log(' --yes Confirm deletion');
2332
+ }
2288
2333
  if (name === 'web-artifact build') {
2289
2334
  console.log('\nDependencies:');
2290
2335
  console.log(' --deps <list> Add npm dependencies before bundling, e.g. --deps recharts,zod');
2291
2336
  console.log('\nOutput control:');
2292
2337
  console.log(' --include-logs Include raw build stdout/stderr in the response');
2293
2338
  console.log(' --verbose Alias for --include-logs');
2339
+ console.log(' --timeout-ms <number> Optional init/install/build timeout in milliseconds');
2294
2340
  }
2295
2341
  if (name === 'focus') {
2296
2342
  console.log('\nViewport:');
@@ -2389,6 +2435,7 @@ History:
2389
2435
  Snapshots:
2390
2436
  pmx-canvas snapshot save --name X Save a named snapshot
2391
2437
  pmx-canvas snapshot list List snapshots
2438
+ pmx-canvas snapshot gc --keep 20 Delete old snapshots
2392
2439
  pmx-canvas snapshot restore <id> Restore from snapshot
2393
2440
  pmx-canvas snapshot diff <id> Compare current canvas to snapshot
2394
2441
  pmx-canvas snapshot delete <id> Delete a snapshot
package/src/cli/index.ts CHANGED
@@ -5,6 +5,7 @@ import { dirname, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { runAgentCli } from './agent.js';
7
7
  import { createCanvas } from '../server/index.js';
8
+ import { seedDemoCanvas } from '../server/demo.js';
8
9
 
9
10
  const args = process.argv.slice(2);
10
11
 
@@ -594,29 +595,7 @@ Examples:
594
595
  process.exit(1);
595
596
  }
596
597
 
597
- if (demo && canvas.getLayout().nodes.length === 0) {
598
- const n1 = canvas.addNode({
599
- type: 'markdown',
600
- title: 'Welcome to PMX Canvas',
601
- content: '# PMX Canvas Workbench\n\nA spatial canvas for coding agents.\n\n## Features\n- Infinite 2D canvas with pan/zoom\n- Multiple node types\n- Edges between nodes\n- Real-time SSE updates\n- HTTP API for agent control',
602
- });
603
-
604
- const n2 = canvas.addNode({
605
- type: 'markdown',
606
- title: 'Getting Started',
607
- content: `# Quick Start\n\n\`\`\`bash\n# Add a node via CLI\npmx-canvas node add --type markdown --title "Hello" --content "# World"\n\n# List nodes\npmx-canvas node list\n\n# Get canvas state\npmx-canvas layout\n\`\`\``,
608
- });
609
-
610
- const n3 = canvas.addNode({
611
- type: 'status',
612
- title: 'Agent Status',
613
- content: 'Ready',
614
- });
615
-
616
- canvas.addEdge({ from: n1, to: n2, type: 'flow', label: 'next' });
617
- canvas.addEdge({ from: n2, to: n3, type: 'flow' });
618
- canvas.arrange('grid');
619
- }
598
+ if (demo && canvas.getLayout().nodes.length === 0) seedDemoCanvas();
620
599
 
621
600
  console.log(`\n PMX Canvas running at http://localhost:${canvas.port}`);
622
601
  console.log(` Health: http://localhost:${canvas.port}/health\n`);
@@ -5,6 +5,7 @@ import {
5
5
  closeAttentionHistory,
6
6
  openAttentionHistory,
7
7
  } from '../state/attention-store';
8
+ import { collapseDockedContextNodes, hasOpenDockedContextPanel } from '../state/canvas-store';
8
9
 
9
10
  function formatTimestamp(timestamp: number): string {
10
11
  return new Date(timestamp).toLocaleTimeString([], {
@@ -13,19 +14,31 @@ function formatTimestamp(timestamp: number): string {
13
14
  });
14
15
  }
15
16
 
17
+ function handleOpenUpdates(): void {
18
+ // Mutual exclusion with the Context panel — only one side panel open at a
19
+ // time (they share the same right-edge anchor).
20
+ collapseDockedContextNodes();
21
+ openAttentionHistory();
22
+ }
23
+
16
24
  export function AttentionHistory() {
17
25
  const entries = attentionHistory.value;
18
26
  if (entries.length === 0) return null;
19
27
 
20
28
  const isOpen = attentionHistoryOpen.value;
21
29
  const unread = attentionHistoryUnread.value;
30
+ // Hide the collapsed Updates pill while the Context side panel is open —
31
+ // the panel sits at the same right-edge and would visually cover the pill.
32
+ // Mutual exclusion guarantees both can't be expanded simultaneously, so the
33
+ // pill only needs to hide while context is expanded.
34
+ if (!isOpen && hasOpenDockedContextPanel.value) return null;
22
35
 
23
36
  if (!isOpen) {
24
37
  return (
25
38
  <button
26
39
  type="button"
27
40
  class="attention-history-tab"
28
- onClick={openAttentionHistory}
41
+ onClick={handleOpenUpdates}
29
42
  aria-label={unread > 0 ? `Recent updates — ${unread} new` : 'Recent updates'}
30
43
  title={unread > 0 ? `${unread} new updates since last viewed` : 'Recent updates'}
31
44
  >
@@ -164,7 +164,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
164
164
  window.clearTimeout(autoFitPersistTimer.current);
165
165
  }
166
166
  autoFitPersistTimer.current = window.setTimeout(() => {
167
- persistLayout();
167
+ persistLayout({ recordHistory: false });
168
168
  autoFitPersistTimer.current = null;
169
169
  }, 0);
170
170
  }