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.
- package/CHANGELOG.md +79 -0
- package/Readme.md +56 -33
- package/dist/canvas/global.css +101 -3
- package/dist/canvas/index.js +93 -89
- package/dist/types/cli/shared.d.ts +13 -1
- package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
- package/dist/types/client/nodes/FileNode.d.ts +14 -0
- package/dist/types/client/state/canvas-store.d.ts +10 -0
- package/dist/types/client/state/intent-bridge.d.ts +9 -0
- package/dist/types/server/ax-flow-loop.d.ts +5 -0
- package/dist/types/server/ax-interaction.d.ts +21 -2
- package/dist/types/server/canvas-db.d.ts +14 -0
- package/dist/types/server/canvas-operations.d.ts +5 -0
- package/dist/types/server/canvas-state.d.ts +8 -0
- package/dist/types/server/canvas-validation.d.ts +15 -21
- package/dist/types/server/file-content.d.ts +46 -0
- package/dist/types/server/html-primitives.d.ts +13 -1
- package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/web-artifacts.d.ts +14 -0
- package/dist/types/shared/ax-flow.d.ts +51 -0
- package/dist/types/shared/node-sizes.d.ts +33 -0
- package/docs/RELEASE.md +48 -0
- package/docs/cli.md +19 -2
- package/docs/environment.md +1 -0
- package/docs/http-api.md +17 -0
- package/docs/mcp.md +12 -0
- package/docs/node-types.md +45 -1
- package/docs/screenshot.png +0 -0
- package/package.json +2 -2
- package/skills/pmx-canvas/SKILL.md +74 -9
- package/skills/pmx-canvas/evals/evals.json +54 -3
- package/skills/pmx-canvas/references/full-reference.md +11 -6
- package/skills/pmx-canvas-testing/SKILL.md +40 -0
- package/src/cli/commands/history.ts +1 -1
- package/src/cli/commands/nodes.ts +2 -2
- package/src/cli/commands/query.ts +1 -1
- package/src/cli/commands/view.ts +1 -1
- package/src/cli/index.ts +6 -0
- package/src/cli/shared.ts +34 -5
- package/src/client/App.tsx +28 -15
- package/src/client/canvas/CanvasNode.tsx +54 -5
- package/src/client/canvas/EdgeLayer.tsx +44 -24
- package/src/client/canvas/ExpandedNodeOverlay.tsx +11 -0
- package/src/client/canvas/auto-fit.ts +10 -1
- package/src/client/canvas/use-pan-zoom.ts +16 -1
- package/src/client/nodes/AxStepControls.tsx +226 -0
- package/src/client/nodes/ExtAppFrame.tsx +79 -2
- package/src/client/nodes/FileNode.tsx +249 -51
- package/src/client/state/canvas-store.ts +19 -0
- package/src/client/state/intent-bridge.ts +32 -0
- package/src/client/state/sse-bridge.ts +4 -1
- package/src/client/theme/global.css +101 -3
- package/src/server/ax-flow-loop.ts +114 -0
- package/src/server/ax-interaction.ts +299 -4
- package/src/server/canvas-db.ts +138 -32
- package/src/server/canvas-operations.ts +44 -17
- package/src/server/canvas-state.ts +20 -10
- package/src/server/canvas-validation.ts +50 -41
- package/src/server/demo-state.json +4605 -523
- package/src/server/demo.ts +24 -0
- package/src/server/file-content.ts +152 -0
- package/src/server/file-watcher.ts +7 -10
- package/src/server/html-primitives.ts +636 -0
- package/src/server/operations/ops/ax-work.ts +5 -0
- package/src/server/operations/ops/nodes.ts +15 -1
- package/src/server/operations/ops/viewport.ts +7 -1
- package/src/server/server.ts +106 -5
- package/src/server/web-artifacts.ts +109 -0
- package/src/shared/ax-flow.ts +102 -0
- package/src/shared/node-sizes.ts +52 -0
- package/dist/types/server/chart-template.d.ts +0 -29
- package/src/server/chart-template.ts +0 -448
package/src/server/canvas-db.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
396
|
+
upsertMeta.run('state_populated', '1');
|
|
322
397
|
|
|
323
398
|
// Save nodes
|
|
324
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
367
|
-
|
|
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(
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
752
|
-
|
|
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
|
|
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
|
|
1324
|
-
//
|
|
1325
|
-
//
|
|
1326
|
-
//
|
|
1327
|
-
//
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
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 =
|
|
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
|
};
|