pmx-canvas 0.4.6 → 0.4.8

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 (76) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/Readme.md +56 -33
  3. package/dist/canvas/global.css +101 -3
  4. package/dist/canvas/index.js +93 -89
  5. package/dist/types/cli/shared.d.ts +13 -1
  6. package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
  7. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  8. package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
  9. package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
  10. package/dist/types/client/nodes/FileNode.d.ts +14 -0
  11. package/dist/types/client/state/canvas-store.d.ts +10 -0
  12. package/dist/types/client/state/intent-bridge.d.ts +9 -0
  13. package/dist/types/server/ax-flow-loop.d.ts +5 -0
  14. package/dist/types/server/ax-interaction.d.ts +21 -2
  15. package/dist/types/server/canvas-db.d.ts +14 -0
  16. package/dist/types/server/canvas-operations.d.ts +5 -0
  17. package/dist/types/server/canvas-state.d.ts +8 -0
  18. package/dist/types/server/canvas-validation.d.ts +15 -21
  19. package/dist/types/server/file-content.d.ts +46 -0
  20. package/dist/types/server/html-primitives.d.ts +13 -1
  21. package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
  22. package/dist/types/server/server.d.ts +1 -0
  23. package/dist/types/server/web-artifacts.d.ts +14 -0
  24. package/dist/types/shared/ax-flow.d.ts +51 -0
  25. package/dist/types/shared/node-sizes.d.ts +33 -0
  26. package/docs/RELEASE.md +48 -0
  27. package/docs/cli.md +19 -2
  28. package/docs/environment.md +1 -0
  29. package/docs/http-api.md +17 -0
  30. package/docs/mcp.md +12 -0
  31. package/docs/node-types.md +45 -1
  32. package/docs/screenshot.png +0 -0
  33. package/package.json +2 -2
  34. package/skills/pmx-canvas/SKILL.md +74 -9
  35. package/skills/pmx-canvas/evals/evals.json +54 -3
  36. package/skills/pmx-canvas/references/full-reference.md +11 -6
  37. package/skills/pmx-canvas-testing/SKILL.md +40 -0
  38. package/src/cli/commands/history.ts +1 -1
  39. package/src/cli/commands/nodes.ts +2 -2
  40. package/src/cli/commands/query.ts +1 -1
  41. package/src/cli/commands/view.ts +1 -1
  42. package/src/cli/index.ts +6 -0
  43. package/src/cli/shared.ts +34 -5
  44. package/src/client/App.tsx +28 -15
  45. package/src/client/canvas/CanvasNode.tsx +54 -5
  46. package/src/client/canvas/EdgeLayer.tsx +44 -24
  47. package/src/client/canvas/ExpandedNodeOverlay.tsx +11 -0
  48. package/src/client/canvas/auto-fit.ts +10 -1
  49. package/src/client/canvas/use-pan-zoom.ts +16 -1
  50. package/src/client/nodes/AxStepControls.tsx +226 -0
  51. package/src/client/nodes/ExtAppFrame.tsx +79 -2
  52. package/src/client/nodes/FileNode.tsx +249 -51
  53. package/src/client/state/canvas-store.ts +19 -0
  54. package/src/client/state/intent-bridge.ts +32 -0
  55. package/src/client/state/sse-bridge.ts +4 -1
  56. package/src/client/theme/global.css +101 -3
  57. package/src/server/ax-flow-loop.ts +114 -0
  58. package/src/server/ax-interaction.ts +299 -4
  59. package/src/server/canvas-db.ts +138 -32
  60. package/src/server/canvas-operations.ts +44 -17
  61. package/src/server/canvas-state.ts +20 -10
  62. package/src/server/canvas-validation.ts +50 -41
  63. package/src/server/demo-state.json +4605 -523
  64. package/src/server/demo.ts +24 -0
  65. package/src/server/file-content.ts +152 -0
  66. package/src/server/file-watcher.ts +7 -10
  67. package/src/server/html-primitives.ts +636 -0
  68. package/src/server/operations/ops/ax-work.ts +5 -0
  69. package/src/server/operations/ops/nodes.ts +15 -1
  70. package/src/server/operations/ops/viewport.ts +7 -1
  71. package/src/server/server.ts +106 -5
  72. package/src/server/web-artifacts.ts +109 -0
  73. package/src/shared/ax-flow.ts +102 -0
  74. package/src/shared/node-sizes.ts +52 -0
  75. package/dist/types/server/chart-template.d.ts +0 -29
  76. package/src/server/chart-template.ts +0 -448
@@ -273,7 +273,13 @@ export function openCanvasDb(dbPath: string): Database {
273
273
 
274
274
  const db = new Database(dbPath);
275
275
  db.exec('PRAGMA journal_mode=WAL');
276
- db.exec('PRAGMA synchronous=NORMAL');
276
+ // FULL, not NORMAL: under WAL, NORMAL lets a host/OS-level crash lose the most
277
+ // recent commits (a clean process exit loses nothing either way). The reporter of
278
+ // issue #22 declined FULL specifically because a save rewrote the whole board, so
279
+ // an fsync per commit scaled with board size. Saves are incremental now — a
280
+ // typical save is a row or two — so that objection is gone and the durability is
281
+ // worth the fsync.
282
+ db.exec('PRAGMA synchronous=FULL');
277
283
  db.exec('PRAGMA busy_timeout=5000');
278
284
  db.exec(SCHEMA_SQL);
279
285
 
@@ -295,38 +301,117 @@ export function checkpointCanvasDb(db: Database): void {
295
301
  db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
296
302
  }
297
303
 
304
+ /**
305
+ * Delete blob rows nothing references any more. Blobs are content-addressed and
306
+ * written with INSERT OR IGNORE, so editing a node's html supersedes its old blob
307
+ * and leaves it orphaned — without this, `canvas.db` (which is git-committable)
308
+ * would grow without bound once externalization applies to ordinary html nodes.
309
+ *
310
+ * References are collected by scanning the raw persisted JSON for sha256 fields
311
+ * rather than parsing every node. That deliberately OVER-approximates: a stray
312
+ * 64-hex string keeps a blob alive that could have been dropped, which wastes a
313
+ * little space. The opposite error would delete live content, so the scan is
314
+ * biased to the safe side on purpose. Snapshot rows are scanned too — a snapshot
315
+ * restore must still find its blobs.
316
+ */
317
+ export function gcBlobsInDB(db: Database): number {
318
+ const referenced = new Set<string>();
319
+ const collect = (sql: string): void => {
320
+ for (const row of db.query<{ data: string }, []>(sql).all()) {
321
+ if (!row.data) continue;
322
+ for (const match of row.data.matchAll(/"sha256"\s*:\s*"([0-9a-f]{64})"/g)) referenced.add(match[1]);
323
+ }
324
+ };
325
+ collect('SELECT data FROM nodes');
326
+ collect('SELECT data FROM snapshot_nodes');
327
+
328
+ const all = db.query<{ sha256: string }, []>('SELECT sha256 FROM blobs').all();
329
+ const orphans = all.filter((row) => !referenced.has(row.sha256));
330
+ if (orphans.length === 0) return 0;
331
+ const remove = db.prepare('DELETE FROM blobs WHERE sha256 = ?');
332
+ const transaction = db.transaction(() => {
333
+ for (const row of orphans) remove.run(row.sha256);
334
+ });
335
+ transaction();
336
+ return orphans.length;
337
+ }
338
+
298
339
  export function finalizeCanvasDbForClose(db: Database): void {
299
340
  checkpointCanvasDb(db);
300
- db.exec('PRAGMA journal_mode=DELETE');
341
+ // The WAL→DELETE switch takes an exclusive lock, so it succeeds only when no
342
+ // other connection has the DB open — and that makes it the GC gate. GC deletes
343
+ // blob rows a second live process may still depend on: its in-memory nodes
344
+ // hold content-addressed refs, not content, so a GC'd blob is unrecoverable
345
+ // once that process re-saves (review of #22, reproduced). Locked ⇒ someone
346
+ // else is on this canvas: leave WAL in place and keep every blob.
347
+ try {
348
+ db.exec('PRAGMA journal_mode=DELETE');
349
+ } catch {
350
+ return;
351
+ }
352
+ gcBlobsInDB(db);
301
353
  }
302
354
 
303
355
  // ── State Persistence ───────────────────────────────────────────
304
356
 
357
+ /**
358
+ * Drop rows whose id is no longer in the live state. Reads only the id column
359
+ * (index-only scan) so it stays cheap on large boards.
360
+ */
361
+ function deleteMissingRows(
362
+ db: Database,
363
+ table: 'nodes' | 'edges' | 'annotations' | 'context_pins',
364
+ idColumn: 'id' | 'node_id',
365
+ keep: Set<string>,
366
+ ): void {
367
+ const rows = db.query<{ id: string }, []>(`SELECT ${idColumn} AS id FROM ${table}`).all();
368
+ if (rows.length === 0) return;
369
+ const remove = db.prepare(`DELETE FROM ${table} WHERE ${idColumn} = ?`);
370
+ for (const row of rows) {
371
+ if (!keep.has(row.id)) remove.run(row.id);
372
+ }
373
+ }
374
+
305
375
  export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
306
376
  const transaction = db.transaction(() => {
307
- // Clear current state tables
308
- db.run('DELETE FROM nodes');
309
- db.run('DELETE FROM edges');
310
- db.run('DELETE FROM annotations');
311
- db.run('DELETE FROM context_pins');
312
- db.run('DELETE FROM ax_state');
313
-
314
- // Save viewport and UI preferences
315
- db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['theme', normalizeCanvasTheme(state.theme)]);
316
- db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_x', String(state.viewport.x)]);
317
- db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_y', String(state.viewport.y)]);
318
- db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_scale', String(state.viewport.scale)]);
319
-
377
+ // Rows are upserted only when a column actually differs, and rows that are
378
+ // gone are deleted by id. This used to be DELETE-all + INSERT-all, which
379
+ // rewrote every row on every debounced save: dragging one node rewrote the
380
+ // whole board, multi-MB html payloads included, so the write cost scaled
381
+ // with board size instead of change size (issue #22). The
382
+ // `WHERE ... IS NOT excluded....` guard makes an unchanged row a true
383
+ // no-op — SQLite performs no update, so no page is written. The comparison
384
+ // is against the DB itself rather than an in-memory cache, so nothing can
385
+ // go stale and silently skip a write that was actually needed.
386
+ const upsertMeta = db.prepare(
387
+ `INSERT INTO meta (key, value) VALUES (?, ?)
388
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
389
+ WHERE value IS NOT excluded.value`,
390
+ );
391
+ upsertMeta.run('theme', normalizeCanvasTheme(state.theme));
392
+ upsertMeta.run('viewport_x', String(state.viewport.x));
393
+ upsertMeta.run('viewport_y', String(state.viewport.y));
394
+ upsertMeta.run('viewport_scale', String(state.viewport.scale));
320
395
  // Mark DB as populated (for migration detection)
321
- db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['state_populated', '1']);
396
+ upsertMeta.run('state_populated', '1');
322
397
 
323
398
  // Save nodes
324
- const insertNode = db.prepare(
399
+ const upsertNode = db.prepare(
325
400
  `INSERT INTO nodes (id, type, pos_x, pos_y, width, height, z_index, collapsed, pinned, dock_position, data)
326
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
401
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
402
+ ON CONFLICT(id) DO UPDATE SET
403
+ type = excluded.type, pos_x = excluded.pos_x, pos_y = excluded.pos_y,
404
+ width = excluded.width, height = excluded.height, z_index = excluded.z_index,
405
+ collapsed = excluded.collapsed, pinned = excluded.pinned,
406
+ dock_position = excluded.dock_position, data = excluded.data
407
+ WHERE type IS NOT excluded.type OR pos_x IS NOT excluded.pos_x
408
+ OR pos_y IS NOT excluded.pos_y OR width IS NOT excluded.width
409
+ OR height IS NOT excluded.height OR z_index IS NOT excluded.z_index
410
+ OR collapsed IS NOT excluded.collapsed OR pinned IS NOT excluded.pinned
411
+ OR dock_position IS NOT excluded.dock_position OR data IS NOT excluded.data`,
327
412
  );
328
413
  for (const node of state.nodes) {
329
- insertNode.run(
414
+ upsertNode.run(
330
415
  node.id,
331
416
  node.type,
332
417
  node.position.x,
@@ -340,14 +425,21 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
340
425
  JSON.stringify(node.data),
341
426
  );
342
427
  }
428
+ deleteMissingRows(db, 'nodes', 'id', new Set(state.nodes.map((node) => node.id)));
343
429
 
344
430
  // Save edges
345
- const insertEdge = db.prepare(
431
+ const upsertEdge = db.prepare(
346
432
  `INSERT INTO edges (id, from_node, to_node, type, label, style, animated)
347
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
433
+ VALUES (?, ?, ?, ?, ?, ?, ?)
434
+ ON CONFLICT(id) DO UPDATE SET
435
+ from_node = excluded.from_node, to_node = excluded.to_node, type = excluded.type,
436
+ label = excluded.label, style = excluded.style, animated = excluded.animated
437
+ WHERE from_node IS NOT excluded.from_node OR to_node IS NOT excluded.to_node
438
+ OR type IS NOT excluded.type OR label IS NOT excluded.label
439
+ OR style IS NOT excluded.style OR animated IS NOT excluded.animated`,
348
440
  );
349
441
  for (const edge of state.edges) {
350
- insertEdge.run(
442
+ upsertEdge.run(
351
443
  edge.id,
352
444
  edge.from,
353
445
  edge.to,
@@ -357,14 +449,24 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
357
449
  edge.animated ? 1 : 0,
358
450
  );
359
451
  }
452
+ deleteMissingRows(db, 'edges', 'id', new Set(state.edges.map((edge) => edge.id)));
360
453
 
361
454
  // Save annotations
362
- const insertAnnotation = db.prepare(
455
+ const annotations = state.annotations ?? [];
456
+ const upsertAnnotation = db.prepare(
363
457
  `INSERT INTO annotations (id, type, points, bounds, color, width, text, label, created_at)
364
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
458
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
459
+ ON CONFLICT(id) DO UPDATE SET
460
+ type = excluded.type, points = excluded.points, bounds = excluded.bounds,
461
+ color = excluded.color, width = excluded.width, text = excluded.text,
462
+ label = excluded.label, created_at = excluded.created_at
463
+ WHERE type IS NOT excluded.type OR points IS NOT excluded.points
464
+ OR bounds IS NOT excluded.bounds OR color IS NOT excluded.color
465
+ OR width IS NOT excluded.width OR text IS NOT excluded.text
466
+ OR label IS NOT excluded.label OR created_at IS NOT excluded.created_at`,
365
467
  );
366
- for (const annotation of state.annotations ?? []) {
367
- insertAnnotation.run(
468
+ for (const annotation of annotations) {
469
+ upsertAnnotation.run(
368
470
  annotation.id,
369
471
  annotation.type,
370
472
  JSON.stringify(annotation.points),
@@ -376,17 +478,21 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
376
478
  annotation.createdAt,
377
479
  );
378
480
  }
481
+ deleteMissingRows(db, 'annotations', 'id', new Set(annotations.map((annotation) => annotation.id)));
379
482
 
380
- // Save context pins
381
- const insertPin = db.prepare('INSERT INTO context_pins (node_id) VALUES (?)');
483
+ // Save context pins (node_id is the whole row — nothing to update)
484
+ const insertPin = db.prepare('INSERT OR IGNORE INTO context_pins (node_id) VALUES (?)');
382
485
  for (const pinId of state.contextPins) {
383
486
  insertPin.run(pinId);
384
487
  }
488
+ deleteMissingRows(db, 'context_pins', 'node_id', new Set(state.contextPins));
385
489
 
386
- db.run('INSERT INTO ax_state (key, value) VALUES (?, ?)', [
387
- 'state',
388
- JSON.stringify(state.ax ?? createEmptyAxState()),
389
- ]);
490
+ db.run(
491
+ `INSERT INTO ax_state (key, value) VALUES (?, ?)
492
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
493
+ WHERE value IS NOT excluded.value`,
494
+ ['state', JSON.stringify(state.ax ?? createEmptyAxState())],
495
+ );
390
496
  });
391
497
 
392
498
  transaction();
@@ -8,6 +8,7 @@ import {
8
8
  type CanvasNodeUpdate,
9
9
  type CanvasSnapshot,
10
10
  } from './canvas-state.js';
11
+ import { applyFileContentToNodeData, readFileNodeContent } from './file-content.js';
11
12
  import { rewatchAllFileNodes, unwatchAll, unwatchFileForNode, watchFileForNode } from './file-watcher.js';
12
13
  import {
13
14
  closeMcpAppSession,
@@ -568,6 +569,11 @@ function prepareExtAppNodesForSessionSync(forceRehydrate: boolean): string[] {
568
569
 
569
570
  const transportConfig = normalizeTransportConfig(node.data.transportConfig);
570
571
  if (!transportConfig) {
572
+ // Only a node that HAD a session has one to fail restoring. A static
573
+ // ext-app node (no session, no transport — e.g. the demo board's
574
+ // showcase tile) would otherwise be stamped with a persisted error
575
+ // banner on every start after the first, for a session it never had.
576
+ if (!sessionId) continue;
571
577
  setExtAppRuntimeState(node.id, {
572
578
  appSessionId: null,
573
579
  sessionStatus: 'error',
@@ -748,19 +754,8 @@ function buildFileNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
748
754
  title: input.title ?? fileName,
749
755
  };
750
756
 
751
- try {
752
- if (existsSync(resolved)) {
753
- const fileContent = readFileSync(resolved, 'utf-8');
754
- const stat = statSync(resolved);
755
- data.fileContent = fileContent;
756
- data.lineCount = fileContent.split('\n').length;
757
- data.updatedAt = new Date(stat.mtimeMs).toISOString();
758
- }
759
- } catch {
760
- // Missing or unreadable files still render as path-backed file nodes.
761
- }
762
-
763
- return data;
757
+ // Missing or unreadable files still render as path-backed file nodes.
758
+ return applyFileContentToNodeData(data, readFileNodeContent(resolved));
764
759
  }
765
760
 
766
761
  function buildImageNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
@@ -833,7 +828,7 @@ function normalizeTraceNodeData(input: CanvasAddNodeInput): Record<string, unkno
833
828
  return data;
834
829
  }
835
830
 
836
- function buildNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
831
+ function buildNodeDataForType(input: CanvasAddNodeInput): Record<string, unknown> {
837
832
  if (input.type === 'file') return buildFileNodeData(input);
838
833
  if (input.type === 'image') return buildImageNodeData(input);
839
834
  if (input.type === 'webpage') return buildWebpageNodeData(input);
@@ -842,10 +837,21 @@ function buildNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
842
837
  ...(input.data ?? {}),
843
838
  ...(input.title ? { title: input.title } : {}),
844
839
  ...(input.content ? { content: input.content } : {}),
845
- ...(input.strictSize ? { strictSize: true } : {}),
846
840
  };
847
841
  }
848
842
 
843
+ function buildNodeData(input: CanvasAddNodeInput): Record<string, unknown> {
844
+ const data = buildNodeDataForType(input);
845
+ // `strictSize` is type-agnostic — it exempts a node from BOTH the creation
846
+ // size clamp and the browser's auto-fit. It used to be applied inside each
847
+ // per-type builder, and the file / image / webpage builders return a fresh
848
+ // object without it, so those three silently dropped the flag: the clamp
849
+ // honored it (it reads `input.strictSize` directly) but nothing persisted
850
+ // `data.strictSize`, so auto-fit resized the node in the browser anyway.
851
+ // Applying it once here makes it uniform for every type.
852
+ return input.strictSize ? { ...data, strictSize: true } : data;
853
+ }
854
+
849
855
  export function mergeTraceNodeDataFields(
850
856
  base: Record<string, unknown>,
851
857
  input: Record<string, unknown>,
@@ -1709,9 +1715,30 @@ export function createCanvasGraphNode(input: GraphNodeInput): {
1709
1715
  return { id, url: String(node.data.url), spec, node };
1710
1716
  }
1711
1717
 
1718
+ /**
1719
+ * The connected workbench's actual window size, reported by the client (0.4.6
1720
+ * orb feedback #2). `fit` used to compute against a hardcoded 1440x900, so an
1721
+ * agent calling fit without dimensions produced a scale sized for a window the
1722
+ * human did not have — the board overflowed and clipped on both sides. The
1723
+ * client reports its size on connect and on resize; explicit width/height in a
1724
+ * fit call still win. In-memory only (a live client fact, never persisted);
1725
+ * with several clients connected, the most recent reporter wins.
1726
+ */
1727
+ let lastClientViewportSize: { width: number; height: number } | null = null;
1728
+
1729
+ export function setClientViewportSize(width: unknown, height: unknown): void {
1730
+ if (typeof width !== 'number' || typeof height !== 'number') return;
1731
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return;
1732
+ lastClientViewportSize = { width, height };
1733
+ }
1734
+
1735
+ export function getClientViewportSize(): { width: number; height: number } | null {
1736
+ return lastClientViewportSize ? { ...lastClientViewportSize } : null;
1737
+ }
1738
+
1712
1739
  export function fitCanvasView(options: CanvasFitViewOptions = {}): CanvasFitViewResult {
1713
- const width = positiveNumber(options.width, 1440);
1714
- const height = positiveNumber(options.height, 900);
1740
+ const width = positiveNumber(options.width, lastClientViewportSize?.width ?? 1440);
1741
+ const height = positiveNumber(options.height, lastClientViewportSize?.height ?? 900);
1715
1742
  const padding = positiveNumber(options.padding, 60);
1716
1743
  const maxScale = positiveNumber(options.maxScale, 1);
1717
1744
  const nodeIdFilter = options.nodeIds && options.nodeIds.length > 0 ? new Set(options.nodeIds) : null;
@@ -1320,16 +1320,15 @@ class CanvasStateManager {
1320
1320
  }
1321
1321
 
1322
1322
  addNode(node: CanvasNodeState): void {
1323
- // Context nodes default to a right-docked, collapsed pill when created without
1324
- // an explicit dock position. CREATE-time default only — once placed, updates
1325
- // (including undock → dockPosition null) are respected (see normalizeNode).
1326
- // Skip during suppressed replay (undo/redo re-add) so a deliberately-undocked
1327
- // context node is restored verbatim instead of being snapped back to the dock.
1328
- const seeded =
1329
- node.type === 'context' && node.dockPosition == null && this._suppressRecordingDepth === 0
1330
- ? { ...node, dockPosition: 'right' as const, collapsed: true }
1331
- : node;
1332
- const cloned = structuredClone(this.normalizeNode(seeded));
1323
+ // Docking is EXPLICIT (0.4.6). Context nodes used to auto-dock to a collapsed
1324
+ // right-hand pill when created without a dock position, which meant an agent
1325
+ // creating a context node — even at explicit coordinates — got a node that
1326
+ // existed in the API and passed validation but never rendered on the canvas,
1327
+ // and any edge to it terminated in empty space (0.4.6 orb feedback #1). The
1328
+ // seeded HUD widgets (status-main / context-main) pass `dockPosition` on
1329
+ // create, so the docked pills are unaffected; anything else reaches the
1330
+ // canvas where the caller put it. Dock deliberately via node.update.
1331
+ const cloned = structuredClone(this.normalizeNode(node));
1333
1332
  this.nodes.set(node.id, cloned);
1334
1333
  this.scheduleSave();
1335
1334
  this.notifyChange('nodes');
@@ -1722,6 +1721,17 @@ class CanvasStateManager {
1722
1721
  return this.ax.getAxState();
1723
1722
  }
1724
1723
 
1724
+ /**
1725
+ * Replace the canvas-bound AX partition from a persisted blob, normalized against
1726
+ * the current node set — the same operation snapshot restore performs internally
1727
+ * (`applyPersistedState`), exposed for the demo seed. Both restore nodes whose
1728
+ * `data.axStep.workItemId` only resolves if the work items come back with their
1729
+ * original ids, so this preserves them verbatim rather than re-creating them.
1730
+ */
1731
+ applyPersistedAxState(ax: unknown): void {
1732
+ this.ax.applyPersistedAx(ax);
1733
+ }
1734
+
1725
1735
  getAxFocus(): PmxAxFocusState {
1726
1736
  return this.ax.getAxFocus();
1727
1737
  }
@@ -1,46 +1,11 @@
1
1
  import type { CanvasLayout, CanvasNodeState } from './canvas-state.js';
2
2
  import { getCanvasNodeTitle } from './canvas-serialization.js';
3
+ // The readability floor is SHARED with the client: the server clamps it at
4
+ // creation and `validate` reports violations, but the browser's auto-fit must
5
+ // honor the same floor or it silently undoes the clamp (0.4.6 Finding AA).
6
+ import { nodeMinSize } from '../shared/node-sizes.js';
3
7
 
4
- /**
5
- * Per-type minimum node size (0.4.5 report follow-up): agents frequently
6
- * create nodes with explicit frames far too small for their content — clipped
7
- * markdown, charts squeezed behind inner scrollbars. Explicit sizes below the
8
- * floor are clamped UP at creation (canvas-operations.ts creators);
9
- * `strictSize: true` is the escape hatch for a genuinely small fixed frame.
10
- * Floors sit below every type default and above the point of unreadability.
11
- * Creation-only: later updates are untouched (the browser's drag-resize
12
- * enforces its own 200×100 floor client-side), but `validate` reports any
13
- * node below its floor as an advisory sizeWarning. Absent types (trace,
14
- * group, prompt/response) are intentionally unclamped — trace is small by
15
- * design, groups size to their children.
16
- */
17
- export const NODE_MIN_CREATE_SIZES: Partial<Record<CanvasNodeState['type'], { width: number; height: number }>> = {
18
- markdown: { width: 360, height: 180 },
19
- context: { width: 360, height: 180 },
20
- file: { width: 360, height: 200 },
21
- diff: { width: 420, height: 240 },
22
- mermaid: { width: 360, height: 240 },
23
- status: { width: 280, height: 120 },
24
- ledger: { width: 320, height: 200 },
25
- image: { width: 240, height: 180 },
26
- html: { width: 420, height: 280 },
27
- webpage: { width: 420, height: 280 },
28
- 'json-render': { width: 420, height: 280 },
29
- graph: { width: 420, height: 280 },
30
- 'mcp-app': { width: 480, height: 320 },
31
- };
32
-
33
- export function clampCreateNodeSize(
34
- type: CanvasNodeState['type'],
35
- width: number,
36
- height: number,
37
- strictSize?: boolean,
38
- ): { width: number; height: number } {
39
- if (strictSize === true) return { width, height };
40
- const min = NODE_MIN_CREATE_SIZES[type];
41
- if (!min) return { width, height };
42
- return { width: Math.max(width, min.width), height: Math.max(height, min.height) };
43
- }
8
+ export { NODE_MIN_SIZES, clampCreateNodeSize, nodeMinSize } from '../shared/node-sizes.js';
44
9
 
45
10
  export interface CanvasValidationPair {
46
11
  aId: string;
@@ -66,12 +31,26 @@ export interface CanvasSizeWarning {
66
31
  minHeight: number;
67
32
  }
68
33
 
34
+ export interface CanvasHiddenEdgeEndpoint {
35
+ edgeId: string;
36
+ nodeId: string;
37
+ nodeTitle: string | null;
38
+ dockPosition: 'left' | 'right';
39
+ }
40
+
69
41
  export interface CanvasValidationResult {
70
42
  ok: boolean;
71
43
  collisions: CanvasValidationPair[];
72
44
  containments: CanvasContainmentIssue[];
73
45
  containmentViolations: CanvasContainmentIssue[];
74
46
  missingEdgeEndpoints: Array<{ edgeId: string; from: string; to: string }>;
47
+ /**
48
+ * Edges whose endpoint node is DOCKED — it renders in the HUD column, not on
49
+ * the canvas, so the edge visually terminates in empty space even though both
50
+ * endpoint IDs resolve (0.4.6 orb feedback #1). Advisory: reported for
51
+ * diagnosis, but does NOT fail `ok` (see the note at the return site).
52
+ */
53
+ hiddenEdgeEndpoints: CanvasHiddenEdgeEndpoint[];
75
54
  /** Nodes below their type's readable minimum (advisory — does not fail `ok`). */
76
55
  sizeWarnings: CanvasSizeWarning[];
77
56
  summary: {
@@ -81,6 +60,7 @@ export interface CanvasValidationResult {
81
60
  containments: number;
82
61
  containmentViolations: number;
83
62
  missingEdgeEndpoints: number;
63
+ hiddenEdgeEndpoints: number;
84
64
  sizeWarnings: number;
85
65
  };
86
66
  }
@@ -163,6 +143,28 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
163
143
  .filter((edge) => !nodeIds.has(edge.from) || !nodeIds.has(edge.to))
164
144
  .map((edge) => ({ edgeId: edge.id, from: edge.from, to: edge.to }));
165
145
 
146
+ // An edge to a DOCKED node cannot be drawn: docked nodes live in the HUD
147
+ // column, and the world-space edge layer skips them, so the edge trails off
148
+ // into empty canvas. Structural validation used to miss this entirely because
149
+ // both endpoint IDs resolve (0.4.6 orb feedback #1).
150
+ const dockedById = new Map(
151
+ layout.nodes.filter((node) => node.dockPosition != null).map((node) => [node.id, node] as const),
152
+ );
153
+ const hiddenEdgeEndpoints: CanvasHiddenEdgeEndpoint[] = layout.edges.flatMap((edge) =>
154
+ [edge.from, edge.to].flatMap((nodeId) => {
155
+ const docked = dockedById.get(nodeId);
156
+ if (!docked || docked.dockPosition == null) return [];
157
+ return [
158
+ {
159
+ edgeId: edge.id,
160
+ nodeId,
161
+ nodeTitle: getCanvasNodeTitle(docked),
162
+ dockPosition: docked.dockPosition,
163
+ },
164
+ ];
165
+ }),
166
+ );
167
+
166
168
  // Advisory: nodes below their type's readable floor (clipped/unreadable
167
169
  // content — 0.4.5 report follow-up). Creation clamps these, but resized or
168
170
  // strictSize-created nodes can still be undersized; surface them so an
@@ -171,7 +173,7 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
171
173
  const sizeWarnings: CanvasSizeWarning[] = layout.nodes
172
174
  .filter((node) => !node.collapsed && node.dockPosition == null && node.data?.strictSize !== true)
173
175
  .flatMap((node) => {
174
- const min = NODE_MIN_CREATE_SIZES[node.type];
176
+ const min = nodeMinSize(node.type);
175
177
  if (!min || (node.size.width >= min.width && node.size.height >= min.height)) return [];
176
178
  return [
177
179
  {
@@ -187,11 +189,17 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
187
189
  });
188
190
 
189
191
  return {
192
+ // `hiddenEdgeEndpoints` is ADVISORY (like sizeWarnings): a docked endpoint
193
+ // is a rendering observation, not a structural defect, and folding it into
194
+ // `ok` would flip previously-healthy boards to ok:false with no user change
195
+ // — `ok` is a documented response field of a frozen surface, so that is a
196
+ // contract break, not a patch. It is still reported for diagnosis.
190
197
  ok: collisions.length === 0 && containmentViolations.length === 0 && missingEdgeEndpoints.length === 0,
191
198
  collisions,
192
199
  containments,
193
200
  containmentViolations,
194
201
  missingEdgeEndpoints,
202
+ hiddenEdgeEndpoints,
195
203
  sizeWarnings,
196
204
  summary: {
197
205
  nodes: layout.nodes.length,
@@ -200,6 +208,7 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
200
208
  containments: containments.length,
201
209
  containmentViolations: containmentViolations.length,
202
210
  missingEdgeEndpoints: missingEdgeEndpoints.length,
211
+ hiddenEdgeEndpoints: hiddenEdgeEndpoints.length,
203
212
  sizeWarnings: sizeWarnings.length,
204
213
  },
205
214
  };