pmx-canvas 0.7.3 → 0.8.2
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/.github/extensions/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
- package/.github/extensions/pmx-canvas/extension.mjs +25 -172
- package/.github/extensions/pmx-canvas/mcp-bootstrap.ts +198 -0
- package/.github/extensions/pmx-canvas/runtime.d.mts +41 -0
- package/.github/extensions/pmx-canvas/runtime.json +3 -0
- package/.github/extensions/pmx-canvas/runtime.mjs +194 -0
- package/.github/extensions/pmx-canvas/start-mcp.mjs +51 -0
- package/.github/extensions/pmx-canvas/tools.json +2560 -0
- package/CHANGELOG.md +64 -0
- package/Readme.md +21 -7
- package/dist/canvas/fonts/OFL.txt +93 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-ext-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-mono-latin-ext-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-600-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-400-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-500-normal.woff2 +0 -0
- package/dist/canvas/fonts/ibm-plex-sans-latin-ext-600-normal.woff2 +0 -0
- package/dist/canvas/global.css +726 -674
- package/dist/canvas/index.js +90 -88
- package/dist/canvas/mcp-app.html +7879 -0
- package/dist/canvas/mermaid-entry.js +1 -1
- package/dist/canvas/surface-theme.css +151 -46
- package/dist/json-render/index.css +81 -1
- package/dist/json-render/index.js +1 -1
- package/dist/types/cli/commands/record.d.ts +1 -1
- package/dist/types/client/canvas/NodeContextMark.d.ts +24 -0
- package/dist/types/client/canvas/SessionReceipt.d.ts +3 -1
- package/dist/types/client/icons.d.ts +1 -2
- package/dist/types/client/mcp-app/index.d.ts +1 -0
- package/dist/types/client/nodes/HtmlNode.d.ts +1 -0
- package/dist/types/client/nodes/ax-node-actions.d.ts +2 -2
- package/dist/types/client/nodes/markdown-sanitize.d.ts +2 -0
- package/dist/types/client/nodes/use-workbench-asset.d.ts +2 -0
- package/dist/types/client/state/attention-store.d.ts +0 -5
- package/dist/types/client/state/context-status-store.d.ts +53 -0
- package/dist/types/client/state/intent-bridge.d.ts +3 -0
- package/dist/types/client/state/near-pin-store.d.ts +16 -0
- package/dist/types/client/state/presence-store.d.ts +5 -0
- package/dist/types/client/state/session-store.d.ts +33 -3
- package/dist/types/client/state/workbench-transport.d.ts +16 -0
- package/dist/types/client/theme/tokens.d.ts +1 -3
- package/dist/types/client/types.d.ts +9 -0
- package/dist/types/json-render/server.d.ts +1 -1
- package/dist/types/mcp/app-server.d.ts +3 -0
- package/dist/types/mcp/canvas-access.d.ts +1 -1
- package/dist/types/mcp/canvas-app.d.ts +4 -0
- package/dist/types/mcp/workbench-app.d.ts +4 -0
- package/dist/types/server/agent-presence.d.ts +1 -1
- package/dist/types/server/ax-state.d.ts +1 -1
- package/dist/types/server/canvas-db.d.ts +8 -0
- package/dist/types/server/canvas-geometry.d.ts +25 -0
- package/dist/types/server/canvas-operations.d.ts +1 -11
- package/dist/types/server/canvas-state.d.ts +25 -3
- package/dist/types/server/context-brief.d.ts +8 -1
- package/dist/types/server/context-reads.d.ts +30 -7
- package/dist/types/server/index.d.ts +6 -1
- package/dist/types/server/mcp-app-runtime.d.ts +1 -0
- package/dist/types/server/operations/http.d.ts +2 -0
- package/dist/types/server/operations/invoker.d.ts +4 -2
- package/dist/types/server/operations/ops/nodes.d.ts +0 -6
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/spatial-analysis.d.ts +2 -0
- package/dist/types/shared/agent-context.d.ts +22 -0
- package/dist/types/shared/agent-presence.d.ts +3 -10
- package/dist/types/shared/canvas-app.d.ts +54 -0
- package/dist/types/shared/themes.d.ts +1 -1
- package/docs/RELEASE.md +7 -3
- package/docs/agent-plugin.md +123 -0
- package/docs/ax-state-contract.md +40 -0
- package/docs/cli.md +7 -2
- package/docs/design/AgentContext.dc.html +186 -0
- package/docs/design/Before.dc.html +208 -0
- package/docs/design/BoardPins.dc.html +151 -0
- package/docs/design/Chrome.dc.html +211 -0
- package/docs/design/Context.dc.html +239 -0
- package/docs/design/Flows.dc.html +181 -0
- package/docs/design/Home.dc.html +209 -0
- package/docs/design/HomeGraph.dc.html +211 -0
- package/docs/design/HomeMap.dc.html +117 -0
- package/docs/design/HomeTeam.dc.html +176 -0
- package/docs/design/Light.dc.html +28 -0
- package/docs/design/Main.dc.html +280 -0
- package/docs/design/NearPin.dc.html +260 -0
- package/docs/design/Pane600.dc.html +120 -0
- package/docs/design/Relations.dc.html +297 -0
- package/docs/design/Themes.dc.html +121 -0
- package/docs/design/TokenSystem.dc.html +136 -0
- package/docs/design/Tokens.dc.html +163 -0
- package/docs/design/Types1.dc.html +177 -0
- package/docs/design/Types2.dc.html +226 -0
- package/docs/design/Types3.dc.html +195 -0
- package/docs/design/Uses.dc.html +152 -0
- package/docs/design/Vision.dc.html +116 -0
- package/docs/design/Zoom.dc.html +82 -0
- package/docs/design/canvas.json +274 -0
- package/docs/design.md +217 -0
- package/docs/environment.md +1 -1
- package/docs/http-api.md +86 -16
- package/docs/mcp.md +202 -4
- package/docs/node-types.md +34 -10
- package/docs/plans/plan-009-tech-debt-backlog.md +1 -1
- package/docs/plans/plan-015-design-foundation-wave-0.md +177 -0
- package/docs/plans/plan-016-context-made-visible-wave-1.md +148 -0
- package/docs/product-vision-2026-09.md +47 -7
- package/docs/screenshot.png +0 -0
- package/docs/sdk.md +1 -0
- package/package.json +10 -4
- package/plugins/pmx-canvas/LICENSE +21 -0
- package/plugins/pmx-canvas/THIRD_PARTY_LICENSES.txt +331 -0
- package/plugins/pmx-canvas/assets/preview.png +0 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/extension.mjs +930 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +3 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.mjs +194 -0
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/steering-delivery.mjs +79 -0
- package/plugins/pmx-canvas/mcp.json +12 -0
- package/plugins/pmx-canvas/plugin.json +24 -0
- package/plugins/pmx-canvas/runtime.json +3 -0
- package/plugins/pmx-canvas/runtime.mjs +194 -0
- package/plugins/pmx-canvas/start-mcp.mjs +51 -0
- package/plugins/pmx-canvas/tools.json +2560 -0
- package/skills/pmx-canvas/SKILL.md +29 -9
- package/skills/pmx-canvas/evals/evals.json +62 -0
- package/skills/pmx-canvas/references/ax-html-control-surface.md +6 -0
- package/skills/pmx-canvas/references/full-reference.md +11 -7
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +20 -8
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
- package/skills/pmx-canvas-testing/SKILL.md +5 -4
- package/src/cli/commands/copilot.ts +5 -0
- package/src/cli/commands/nodes.ts +3 -2
- package/src/cli/commands/view.ts +1 -1
- package/src/cli/index.ts +10 -4
- package/src/client/App.tsx +6 -1
- package/src/client/canvas/AgentPresenceLayer.tsx +3 -3
- package/src/client/canvas/AnnotationLayer.tsx +1 -1
- package/src/client/canvas/CanvasNode.tsx +32 -49
- package/src/client/canvas/CanvasViewport.tsx +0 -2
- package/src/client/canvas/CommandPalette.tsx +3 -2
- package/src/client/canvas/ContextMenu.tsx +11 -0
- package/src/client/canvas/EdgeLayer.tsx +1 -1
- package/src/client/canvas/ExportDialog.tsx +33 -6
- package/src/client/canvas/IntentLayer.tsx +2 -2
- package/src/client/canvas/Minimap.tsx +12 -7
- package/src/client/canvas/NodeContextMark.tsx +156 -0
- package/src/client/canvas/SessionPanel.tsx +4 -7
- package/src/client/canvas/SessionReceipt.tsx +114 -17
- package/src/client/canvas/ShortcutOverlay.tsx +6 -1
- package/src/client/canvas/SnapshotPanel.tsx +11 -2
- package/src/client/canvas/ToolRail.tsx +12 -9
- package/src/client/canvas/TopBar.tsx +16 -11
- package/src/client/icons.tsx +2 -12
- package/src/client/mcp-app/index.tsx +137 -0
- package/src/client/mcp-app/style.css +10 -0
- package/src/client/nodes/AttachmentNode.tsx +8 -1
- package/src/client/nodes/AxStepControls.tsx +1 -1
- package/src/client/nodes/ContextNode.tsx +9 -9
- package/src/client/nodes/ExtAppFrame.tsx +13 -8
- package/src/client/nodes/FileNode.tsx +8 -5
- package/src/client/nodes/HtmlNode.tsx +12 -2
- package/src/client/nodes/ImageNode.tsx +4 -2
- package/src/client/nodes/InlineMarkdownEditor.tsx +2 -1
- package/src/client/nodes/LedgerNode.tsx +2 -2
- package/src/client/nodes/MarkdownNode.tsx +44 -9
- package/src/client/nodes/McpAppNode.tsx +6 -4
- package/src/client/nodes/PromptNode.tsx +5 -17
- package/src/client/nodes/ResponseNode.tsx +3 -15
- package/src/client/nodes/StatusNode.tsx +2 -2
- package/src/client/nodes/StatusSummary.tsx +3 -1
- package/src/client/nodes/TraceNode.tsx +6 -4
- package/src/client/nodes/WebpageNode.tsx +1 -1
- package/src/client/nodes/ax-node-actions.ts +2 -2
- package/src/client/nodes/iframe-document-url.ts +3 -2
- package/src/client/nodes/markdown-sanitize.ts +10 -0
- package/src/client/nodes/surface-url.ts +10 -0
- package/src/client/nodes/use-surface-frame.ts +3 -1
- package/src/client/nodes/use-workbench-asset.ts +26 -0
- package/src/client/state/attention-bridge.ts +0 -8
- package/src/client/state/attention-store.ts +0 -17
- package/src/client/state/canvas-store.ts +4 -2
- package/src/client/state/context-status-store.ts +66 -0
- package/src/client/state/human-store.ts +2 -0
- package/src/client/state/iframe-mode.ts +5 -0
- package/src/client/state/intent-bridge.ts +10 -4
- package/src/client/state/near-pin-store.ts +53 -0
- package/src/client/state/presence-store.ts +5 -10
- package/src/client/state/session-store.ts +106 -9
- package/src/client/state/sse-bridge.ts +26 -7
- package/src/client/state/theme-override.ts +1 -1
- package/src/client/state/workbench-transport.ts +36 -0
- package/src/client/theme/global.css +726 -674
- package/src/client/theme/surface-theme.css +151 -46
- package/src/client/theme/tokens.ts +7 -11
- package/src/client/types.ts +10 -0
- package/src/json-render/renderer/index.css +6 -1
- package/src/json-render/renderer/index.tsx +0 -1
- package/src/json-render/server.ts +71 -14
- package/src/mcp/app-server.ts +64 -0
- package/src/mcp/canvas-access.ts +14 -11
- package/src/mcp/canvas-app.ts +318 -0
- package/src/mcp/server.ts +1 -1
- package/src/mcp/workbench-app.ts +211 -0
- package/src/server/agent-presence.ts +45 -6
- package/src/server/ax-context.ts +1 -1
- package/src/server/ax-state.ts +1 -1
- package/src/server/board-export.ts +5 -2
- package/src/server/canvas-db.ts +46 -12
- package/src/server/canvas-geometry.ts +64 -0
- package/src/server/canvas-operations.ts +7 -25
- package/src/server/canvas-state.ts +105 -15
- package/src/server/canvas-validation.ts +19 -4
- package/src/server/context-brief.ts +39 -2
- package/src/server/context-reads.ts +87 -15
- package/src/server/demo-state.json +31 -31
- package/src/server/html-surface.ts +4 -5
- package/src/server/index.ts +41 -5
- package/src/server/mcp-app-runtime.ts +11 -0
- package/src/server/operations/composites.ts +4 -3
- package/src/server/operations/http.ts +4 -4
- package/src/server/operations/invoker.ts +13 -4
- package/src/server/operations/mcp.ts +6 -0
- package/src/server/operations/ops/ax-read.ts +1 -1
- package/src/server/operations/ops/ax-timeline.ts +37 -0
- package/src/server/operations/ops/boards.ts +1 -1
- package/src/server/operations/ops/canvas-wire.ts +3 -6
- package/src/server/operations/ops/intent.ts +31 -2
- package/src/server/operations/ops/json-render.ts +6 -2
- package/src/server/operations/ops/nodes.ts +8 -76
- package/src/server/operations/ops/query.ts +8 -2
- package/src/server/operations/ops/snapshots.ts +4 -1
- package/src/server/operations/ops/viewport.ts +29 -5
- package/src/server/operations/registry.ts +41 -5
- package/src/server/scope-fence.ts +55 -10
- package/src/server/server.ts +83 -5
- package/src/server/spatial-analysis.ts +2 -2
- package/src/server/workboard.ts +6 -1
- package/src/{server → shared}/agent-context.ts +2 -2
- package/src/shared/agent-presence.ts +3 -15
- package/src/shared/canvas-app.ts +62 -0
- package/src/shared/themes.ts +5 -5
- package/src/client/canvas/FocusFieldLayer.tsx +0 -116
- package/src/client/canvas/kind-colors.ts +0 -23
|
@@ -208,6 +208,8 @@ async function collect(
|
|
|
208
208
|
if (!read) return null;
|
|
209
209
|
const { board, state } = read;
|
|
210
210
|
const theme = canvasThemeScheme(state.theme ?? canvasState.theme);
|
|
211
|
+
// Exports collapse to a scheme; surface documents take the theme that draws it.
|
|
212
|
+
const surfaceTheme = theme === 'light' ? 'daylight' : 'harbor';
|
|
211
213
|
const remoteImages = new Set<string>();
|
|
212
214
|
const links = new Set<string>();
|
|
213
215
|
const frameNetworkDestinations = new Set<string>();
|
|
@@ -383,7 +385,8 @@ async function collect(
|
|
|
383
385
|
const html = text(data.html) || text(data.content);
|
|
384
386
|
collectFrameNetworkDestinations(html, frameNetworkDestinations);
|
|
385
387
|
manifest.frames += 1;
|
|
386
|
-
if (withFrames)
|
|
388
|
+
if (withFrames)
|
|
389
|
+
card.frame = buildHtmlSurfaceDocument(html, { theme: surfaceTheme, title, inlineThemeCss: themeCss });
|
|
387
390
|
break;
|
|
388
391
|
}
|
|
389
392
|
case 'mermaid':
|
|
@@ -395,7 +398,7 @@ async function collect(
|
|
|
395
398
|
'<script src="/canvas/mermaid-entry.js"></script>',
|
|
396
399
|
`<script src="${MERMAID_SRC}"></script>`,
|
|
397
400
|
),
|
|
398
|
-
{ theme, title, inlineThemeCss: themeCss },
|
|
401
|
+
{ theme: surfaceTheme, title, inlineThemeCss: themeCss },
|
|
399
402
|
);
|
|
400
403
|
}
|
|
401
404
|
break;
|
package/src/server/canvas-db.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { Database } from 'bun:sqlite';
|
|
9
|
+
import type { ActorAttribution } from './attribution.js';
|
|
9
10
|
import { CONTEXT_READS_SCHEMA_SQL } from './context-reads.js';
|
|
10
11
|
import { MIGRATED_SNAPSHOT_CATEGORY, normalizeBoardCategory } from '../shared/boards.js';
|
|
11
12
|
import { tourSchema, type Tour } from '../shared/tour.js';
|
|
@@ -48,7 +49,7 @@ const SCHEMA_VERSION = 2;
|
|
|
48
49
|
|
|
49
50
|
export type CanvasTheme = CanvasThemeName;
|
|
50
51
|
|
|
51
|
-
export function normalizeCanvasTheme(value: unknown, fallback: CanvasTheme = '
|
|
52
|
+
export function normalizeCanvasTheme(value: unknown, fallback: CanvasTheme = 'harbor'): CanvasTheme {
|
|
52
53
|
return normalizeCanvasThemeName(value, fallback);
|
|
53
54
|
}
|
|
54
55
|
|
|
@@ -122,6 +123,7 @@ const SCHEMA_SQL = `
|
|
|
122
123
|
CREATE TABLE IF NOT EXISTS context_pins (
|
|
123
124
|
board_id TEXT NOT NULL,
|
|
124
125
|
node_id TEXT NOT NULL,
|
|
126
|
+
meta TEXT,
|
|
125
127
|
PRIMARY KEY (board_id, node_id)
|
|
126
128
|
);
|
|
127
129
|
|
|
@@ -185,6 +187,7 @@ const SCHEMA_SQL = `
|
|
|
185
187
|
CREATE TABLE IF NOT EXISTS snapshot_pins (
|
|
186
188
|
snapshot_id TEXT NOT NULL,
|
|
187
189
|
node_id TEXT NOT NULL,
|
|
190
|
+
meta TEXT,
|
|
188
191
|
PRIMARY KEY (snapshot_id, node_id)
|
|
189
192
|
);
|
|
190
193
|
|
|
@@ -335,10 +338,29 @@ export interface PersistedCanvasState {
|
|
|
335
338
|
edges: CanvasEdge[];
|
|
336
339
|
annotations?: CanvasAnnotation[];
|
|
337
340
|
contextPins: string[];
|
|
341
|
+
/** Who pinned each context pin, when and why; pins without an entry have an unknown pinner. */
|
|
342
|
+
contextPinMeta?: Record<string, ContextPinMeta>;
|
|
338
343
|
ax?: PmxAxState;
|
|
339
344
|
revisionState?: PersistedRevisionState;
|
|
340
345
|
}
|
|
341
346
|
|
|
347
|
+
export interface ContextPinMeta {
|
|
348
|
+
pinnedBy: ActorAttribution;
|
|
349
|
+
pinnedAt: string;
|
|
350
|
+
reason?: string;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function pinMetaJson(state: PersistedCanvasState, nodeId: string): string | null {
|
|
354
|
+
const meta = state.contextPinMeta?.[nodeId];
|
|
355
|
+
return meta ? JSON.stringify(meta) : null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function pinMetaFromRows(rows: Array<{ node_id: string; meta: string | null }>): Record<string, ContextPinMeta> {
|
|
359
|
+
const meta: Record<string, ContextPinMeta> = {};
|
|
360
|
+
for (const row of rows) if (row.meta) meta[row.node_id] = JSON.parse(row.meta) as ContextPinMeta;
|
|
361
|
+
return meta;
|
|
362
|
+
}
|
|
363
|
+
|
|
342
364
|
export interface PersistedRevisionState {
|
|
343
365
|
revision: number;
|
|
344
366
|
floor: number;
|
|
@@ -400,6 +422,9 @@ export function openCanvasDb(dbPath: string, options: OpenCanvasDbOptions = {}):
|
|
|
400
422
|
ensureColumn(db, 'ax_steering', 'agent_id', 'agent_id TEXT');
|
|
401
423
|
ensureColumn(db, 'ax_steering', 'target', 'target TEXT');
|
|
402
424
|
ensureColumn(db, 'context_reads', 'board_id', 'board_id TEXT');
|
|
425
|
+
ensureColumn(db, 'context_reads', 'read_nodes', "read_nodes TEXT NOT NULL DEFAULT '{}'");
|
|
426
|
+
ensureColumn(db, 'context_pins', 'meta', 'meta TEXT');
|
|
427
|
+
ensureColumn(db, 'snapshot_pins', 'meta', 'meta TEXT');
|
|
403
428
|
ensureColumn(db, 'boards', 'category', 'category TEXT');
|
|
404
429
|
ensureColumn(db, 'boards', 'readme_node_id', 'readme_node_id TEXT');
|
|
405
430
|
ensureColumn(db, 'nodes', 'attribution', "attribution TEXT NOT NULL DEFAULT '{}' ");
|
|
@@ -470,6 +495,7 @@ function migrateToBoards(db: Database, dbPath: string, boardName: string): void
|
|
|
470
495
|
if (existing.has(table)) db.exec(`ALTER TABLE ${table} RENAME TO ${table}_v1`);
|
|
471
496
|
}
|
|
472
497
|
if (existing.has('snapshots')) ensureColumn(db, 'snapshots', 'board_id', 'board_id TEXT');
|
|
498
|
+
if (existing.has('snapshot_pins')) ensureColumn(db, 'snapshot_pins', 'meta', 'meta TEXT');
|
|
473
499
|
db.exec(SCHEMA_SQL);
|
|
474
500
|
|
|
475
501
|
if (hasBoard) {
|
|
@@ -552,10 +578,10 @@ function snapshotsToBoards(db: Database): void {
|
|
|
552
578
|
`INSERT INTO annotations (board_id, ${annotationColumns}) SELECT ?, ${annotationColumns} FROM snapshot_annotations WHERE snapshot_id = ?`,
|
|
553
579
|
[boardId, snapshot.id],
|
|
554
580
|
);
|
|
555
|
-
db.run(
|
|
556
|
-
|
|
557
|
-
snapshot.id,
|
|
558
|
-
|
|
581
|
+
db.run(
|
|
582
|
+
'INSERT INTO context_pins (board_id, node_id, meta) SELECT ?, node_id, meta FROM snapshot_pins WHERE snapshot_id = ?',
|
|
583
|
+
[boardId, snapshot.id],
|
|
584
|
+
);
|
|
559
585
|
const ax = meta.get('ax_state');
|
|
560
586
|
if (ax) db.run("INSERT INTO ax_state (board_id, key, value) VALUES (?, 'state', ?)", [boardId, ax]);
|
|
561
587
|
deleteSnapshotFromDB(db, snapshot.id);
|
|
@@ -775,10 +801,12 @@ export function saveStateToDB(db: Database, boardId: string, state: PersistedCan
|
|
|
775
801
|
}
|
|
776
802
|
deleteMissingRows(db, 'annotations', 'id', boardId, new Set(annotations.map((annotation) => annotation.id)));
|
|
777
803
|
|
|
778
|
-
// Save context pins
|
|
779
|
-
const insertPin = db.prepare(
|
|
804
|
+
// Save context pins with who pinned them
|
|
805
|
+
const insertPin = db.prepare(
|
|
806
|
+
'INSERT INTO context_pins (board_id, node_id, meta) VALUES (?, ?, ?) ON CONFLICT(board_id, node_id) DO UPDATE SET meta = excluded.meta WHERE context_pins.meta IS NOT excluded.meta',
|
|
807
|
+
);
|
|
780
808
|
for (const pinId of state.contextPins) {
|
|
781
|
-
insertPin.run(boardId, pinId);
|
|
809
|
+
insertPin.run(boardId, pinId, pinMetaJson(state, pinId));
|
|
782
810
|
}
|
|
783
811
|
deleteMissingRows(db, 'context_pins', 'node_id', boardId, new Set(state.contextPins));
|
|
784
812
|
|
|
@@ -905,9 +933,11 @@ export function loadStateFromDB(db: Database, boardId?: string): PersistedCanvas
|
|
|
905
933
|
// Load context pins
|
|
906
934
|
interface PinRow {
|
|
907
935
|
node_id: string;
|
|
936
|
+
meta: string | null;
|
|
908
937
|
}
|
|
909
|
-
const pinRows = db.query<PinRow, [string]>('SELECT node_id FROM context_pins WHERE board_id = ?').all(id);
|
|
938
|
+
const pinRows = db.query<PinRow, [string]>('SELECT node_id, meta FROM context_pins WHERE board_id = ?').all(id);
|
|
910
939
|
const contextPins = pinRows.map((row) => row.node_id);
|
|
940
|
+
const contextPinMeta = pinMetaFromRows(pinRows);
|
|
911
941
|
|
|
912
942
|
const axRow = db
|
|
913
943
|
.query<{ value: string }, [string, string]>('SELECT value FROM ax_state WHERE board_id = ? AND key = ?')
|
|
@@ -925,6 +955,7 @@ export function loadStateFromDB(db: Database, boardId?: string): PersistedCanvas
|
|
|
925
955
|
edges,
|
|
926
956
|
annotations,
|
|
927
957
|
contextPins,
|
|
958
|
+
contextPinMeta,
|
|
928
959
|
ax: parsePersistedAxState(axRow?.value),
|
|
929
960
|
revisionState: revisionRaw ? (JSON.parse(revisionRaw) as PersistedRevisionState) : undefined,
|
|
930
961
|
};
|
|
@@ -1191,9 +1222,9 @@ export function saveSnapshotToDB(
|
|
|
1191
1222
|
}
|
|
1192
1223
|
|
|
1193
1224
|
// Insert snapshot pins
|
|
1194
|
-
const insertPin = db.prepare('INSERT INTO snapshot_pins (snapshot_id, node_id) VALUES (?, ?)');
|
|
1225
|
+
const insertPin = db.prepare('INSERT INTO snapshot_pins (snapshot_id, node_id, meta) VALUES (?, ?, ?)');
|
|
1195
1226
|
for (const pinId of state.contextPins) {
|
|
1196
|
-
insertPin.run(snapshot.id, pinId);
|
|
1227
|
+
insertPin.run(snapshot.id, pinId, pinMetaJson(state, pinId));
|
|
1197
1228
|
}
|
|
1198
1229
|
db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [
|
|
1199
1230
|
snapshot.id,
|
|
@@ -1348,11 +1379,13 @@ export function loadSnapshotFromDB(
|
|
|
1348
1379
|
// Load snapshot pins
|
|
1349
1380
|
interface PinRow {
|
|
1350
1381
|
node_id: string;
|
|
1382
|
+
meta: string | null;
|
|
1351
1383
|
}
|
|
1352
1384
|
const pinRows = db
|
|
1353
|
-
.query<PinRow, [string]>('SELECT node_id FROM snapshot_pins WHERE snapshot_id = ?')
|
|
1385
|
+
.query<PinRow, [string]>('SELECT node_id, meta FROM snapshot_pins WHERE snapshot_id = ?')
|
|
1354
1386
|
.all(snapshotRow.id);
|
|
1355
1387
|
const contextPins = pinRows.map((row) => row.node_id);
|
|
1388
|
+
const contextPinMeta = pinMetaFromRows(pinRows);
|
|
1356
1389
|
|
|
1357
1390
|
return {
|
|
1358
1391
|
snapshot,
|
|
@@ -1364,6 +1397,7 @@ export function loadSnapshotFromDB(
|
|
|
1364
1397
|
edges,
|
|
1365
1398
|
annotations,
|
|
1366
1399
|
contextPins,
|
|
1400
|
+
contextPinMeta,
|
|
1367
1401
|
ax: parsePersistedAxState(metaMap.get('ax_state')),
|
|
1368
1402
|
revisionState: metaMap.has('revision_state')
|
|
1369
1403
|
? (JSON.parse(metaMap.get('revision_state')!) as PersistedRevisionState)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { CanvasNodeState, CanvasNodeUpdate } from './canvas-state.js';
|
|
2
|
+
|
|
3
|
+
function record(value: unknown): Record<string, unknown> {
|
|
4
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function finite(value: unknown): number | undefined {
|
|
8
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Flat fields take precedence over nested aliases; absent axes remain absent. */
|
|
12
|
+
export function resolveCreateGeometry(body: Record<string, unknown>) {
|
|
13
|
+
const position = record(body.position);
|
|
14
|
+
const size = record(body.size);
|
|
15
|
+
return {
|
|
16
|
+
x: finite(body.x) ?? finite(position.x),
|
|
17
|
+
y: finite(body.y) ?? finite(position.y),
|
|
18
|
+
width: finite(body.width) ?? finite(size.width),
|
|
19
|
+
height: finite(body.height) ?? finite(size.height),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Guards and PATCH execution must resolve aliases and single-axis moves identically. */
|
|
24
|
+
export function resolvePatchGeometry(body: Record<string, unknown>, existing: CanvasNodeState) {
|
|
25
|
+
const { x, y, width, height } = resolveCreateGeometry(body);
|
|
26
|
+
return {
|
|
27
|
+
...(x !== undefined || y !== undefined
|
|
28
|
+
? { position: { x: x ?? existing.position.x, y: y ?? existing.position.y } }
|
|
29
|
+
: {}),
|
|
30
|
+
...(width !== undefined || height !== undefined
|
|
31
|
+
? { size: { width: width ?? existing.size.width, height: height ?? existing.size.height } }
|
|
32
|
+
: {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function validateCanvasNodePatch(patch: { position?: unknown; size?: unknown }): string | null {
|
|
37
|
+
if (patch.position !== undefined) {
|
|
38
|
+
const position = record(patch.position);
|
|
39
|
+
if (finite(position.x) === undefined || finite(position.y) === undefined) {
|
|
40
|
+
return 'Position must contain finite x and y values.';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (patch.size !== undefined) {
|
|
44
|
+
const size = record(patch.size);
|
|
45
|
+
const width = finite(size.width);
|
|
46
|
+
const height = finite(size.height);
|
|
47
|
+
if (width === undefined || height === undefined) return 'Size must contain finite width and height values.';
|
|
48
|
+
if (width <= 0 || height <= 0) return 'Size width and height must be greater than zero.';
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Invalid patches cannot count as explicit child-position overrides. */
|
|
54
|
+
export function validCanvasNodeUpdates(input: unknown): CanvasNodeUpdate[] {
|
|
55
|
+
if (!Array.isArray(input)) return [];
|
|
56
|
+
return input.filter((value: unknown): value is CanvasNodeUpdate => {
|
|
57
|
+
const update = record(value);
|
|
58
|
+
return (
|
|
59
|
+
typeof update.id === 'string' &&
|
|
60
|
+
(update.collapsed === undefined || typeof update.collapsed === 'boolean') &&
|
|
61
|
+
validateCanvasNodePatch(update) === null
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { mutationHistory } from './mutation-history.js';
|
|
24
24
|
import { intentRegistry } from './intent-registry.js';
|
|
25
25
|
import { clampCreateNodeSize } from './canvas-validation.js';
|
|
26
|
+
import { validCanvasNodeUpdates } from './canvas-geometry.js';
|
|
26
27
|
import { computeGroupBounds, findOpenCanvasPosition } from './placement.js';
|
|
27
28
|
import { searchNodes } from './spatial-analysis.js';
|
|
28
29
|
import { getCanvasNodeTitle } from './canvas-serialization.js';
|
|
@@ -393,7 +394,7 @@ export function buildJsonRenderNodeUpdate(
|
|
|
393
394
|
): { data: Record<string, unknown>; spec: JsonRenderSpec } {
|
|
394
395
|
if (node.type !== 'json-render') throw new Error(`Node "${node.id}" is not a json-render node.`);
|
|
395
396
|
const spec = normalizeAndValidateJsonRenderSpec(input.spec);
|
|
396
|
-
const title = input.title?.trim() || inferJsonRenderNodeTitle(spec);
|
|
397
|
+
const title = input.title?.trim() || pickString(node.data, 'title') || inferJsonRenderNodeTitle(spec);
|
|
397
398
|
// Bump specVersion so the viewer URL changes: the iframe src is keyed on
|
|
398
399
|
// nodeId + ?v=specVersion, and the viewer only reads the spec at document
|
|
399
400
|
// load. Without the bump the src is byte-identical after a content update,
|
|
@@ -730,26 +731,6 @@ export async function syncCanvasRuntimeBackends(
|
|
|
730
731
|
return { rehydrated, failed };
|
|
731
732
|
}
|
|
732
733
|
|
|
733
|
-
export function validateCanvasNodePatch(patch: {
|
|
734
|
-
position?: { x: number; y: number };
|
|
735
|
-
size?: { width: number; height: number };
|
|
736
|
-
}): string | null {
|
|
737
|
-
if (patch.position) {
|
|
738
|
-
if (!Number.isFinite(patch.position.x) || !Number.isFinite(patch.position.y)) {
|
|
739
|
-
return 'Position must contain finite x and y values.';
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
if (patch.size) {
|
|
743
|
-
if (!Number.isFinite(patch.size.width) || !Number.isFinite(patch.size.height)) {
|
|
744
|
-
return 'Size must contain finite width and height values.';
|
|
745
|
-
}
|
|
746
|
-
if (patch.size.width <= 0 || patch.size.height <= 0) {
|
|
747
|
-
return 'Size width and height must be greater than zero.';
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
return null;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
734
|
let codeGraphTimer: ReturnType<typeof setTimeout> | null = null;
|
|
754
735
|
|
|
755
736
|
function shouldTreatFileContentAsPath(input: CanvasAddNodeInput): boolean {
|
|
@@ -1406,22 +1387,23 @@ export function arrangeCanvasNodes(layout: CanvasArrangeMode): { arranged: numbe
|
|
|
1406
1387
|
}
|
|
1407
1388
|
|
|
1408
1389
|
export function applyCanvasNodeUpdates(updates: CanvasNodeUpdate[]): { applied: number; skipped: number } {
|
|
1409
|
-
|
|
1410
|
-
return canvasState.applyUpdates(safe);
|
|
1390
|
+
return canvasState.applyUpdates(validCanvasNodeUpdates(updates));
|
|
1411
1391
|
}
|
|
1412
1392
|
|
|
1413
1393
|
export function setCanvasContextPins(
|
|
1414
1394
|
nodeIds: string[],
|
|
1415
1395
|
mode: CanvasPinMode = 'set',
|
|
1396
|
+
reason?: string,
|
|
1416
1397
|
): { count: number; nodeIds: string[] } {
|
|
1417
1398
|
const normalizePins = (ids: string[]): string[] =>
|
|
1418
1399
|
ids.filter((id, index) => ids.indexOf(id) === index).slice(0, MAX_CONTEXT_PINS);
|
|
1419
1400
|
const normalizedNodeIds = normalizePins(nodeIds);
|
|
1401
|
+
const options = reason ? { reason } : {};
|
|
1420
1402
|
if (mode === 'set') {
|
|
1421
|
-
canvasState.setContextPins(normalizedNodeIds);
|
|
1403
|
+
canvasState.setContextPins(normalizedNodeIds, options);
|
|
1422
1404
|
} else if (mode === 'add') {
|
|
1423
1405
|
const current = Array.from(canvasState.contextPinnedNodeIds);
|
|
1424
|
-
canvasState.setContextPins(normalizePins([...current, ...normalizedNodeIds]));
|
|
1406
|
+
canvasState.setContextPins(normalizePins([...current, ...normalizedNodeIds]), options);
|
|
1425
1407
|
} else {
|
|
1426
1408
|
const current = Array.from(canvasState.contextPinnedNodeIds);
|
|
1427
1409
|
canvasState.setContextPins(current.filter((id) => !normalizedNodeIds.includes(id)));
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
setActiveBoardIdInDB,
|
|
56
56
|
type CanvasBoard,
|
|
57
57
|
finalizeCanvasDbForClose,
|
|
58
|
+
type ContextPinMeta,
|
|
58
59
|
type PersistedCanvasState,
|
|
59
60
|
type CanvasTheme,
|
|
60
61
|
type AxTimelineQuery,
|
|
@@ -115,9 +116,12 @@ import { currentActor, unknownActor, type ActorAttribution } from './attribution
|
|
|
115
116
|
import {
|
|
116
117
|
appendContextReadToDB,
|
|
117
118
|
loadContextReadsFromDB,
|
|
119
|
+
loadNodeReadStatusFromDB,
|
|
120
|
+
loadReadNodeIdsSince,
|
|
118
121
|
type ContextRead,
|
|
119
122
|
type ContextReadConsumerSummary,
|
|
120
123
|
type ContextReadInput,
|
|
124
|
+
type NodeReadStatus,
|
|
121
125
|
} from './context-reads.js';
|
|
122
126
|
|
|
123
127
|
function logCanvasStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
|
|
@@ -411,8 +415,10 @@ class CanvasStateManager {
|
|
|
411
415
|
private annotations = new Map<string, CanvasAnnotation>();
|
|
412
416
|
private _viewport: ViewportState = { x: 0, y: 0, scale: 1 };
|
|
413
417
|
private _tour: Tour | undefined;
|
|
414
|
-
private _theme: CanvasTheme = '
|
|
418
|
+
private _theme: CanvasTheme = 'harbor';
|
|
415
419
|
private _contextPinnedNodeIds = new Set<string>();
|
|
420
|
+
/** Who pinned each pin, when and why; kept only for ids in `_contextPinnedNodeIds`. */
|
|
421
|
+
private _contextPinMeta = new Map<string, ContextPinMeta>();
|
|
416
422
|
private _workspaceRoot = process.cwd();
|
|
417
423
|
private _contentRevision = 0;
|
|
418
424
|
private _revisionFloor = 0;
|
|
@@ -450,6 +456,8 @@ class CanvasStateManager {
|
|
|
450
456
|
|
|
451
457
|
// ── Work-item change listener (single slot, for live workboard refresh) ──
|
|
452
458
|
private _workItemsChangedListener: (() => void) | null = null;
|
|
459
|
+
/** Single slot (architecture rule 8): server.ts tells the workbench a read landed. Never MCP. */
|
|
460
|
+
private _contextReadListener: ((boardId: string | null) => void) | null = null;
|
|
453
461
|
|
|
454
462
|
/**
|
|
455
463
|
* Register THE work-item change listener (single slot, last-write-wins).
|
|
@@ -457,6 +465,10 @@ class CanvasStateManager {
|
|
|
457
465
|
* status mirror), so live views like the workboard can rebuild from the
|
|
458
466
|
* fresh work-item list.
|
|
459
467
|
*/
|
|
468
|
+
setContextReadListener(listener: ((boardId: string | null) => void) | null): void {
|
|
469
|
+
this._contextReadListener = listener;
|
|
470
|
+
}
|
|
471
|
+
|
|
460
472
|
setWorkItemsChangedListener(listener: (() => void) | null): void {
|
|
461
473
|
this._workItemsChangedListener = listener;
|
|
462
474
|
}
|
|
@@ -687,8 +699,9 @@ class CanvasStateManager {
|
|
|
687
699
|
}
|
|
688
700
|
}
|
|
689
701
|
|
|
690
|
-
private recomputeParentGroupBounds(groupId: string | undefined): void {
|
|
691
|
-
if (!groupId) return;
|
|
702
|
+
private recomputeParentGroupBounds(groupId: string | undefined, visited = new Set<string>()): void {
|
|
703
|
+
if (!groupId || visited.has(groupId)) return;
|
|
704
|
+
visited.add(groupId);
|
|
692
705
|
const snapshot = this.getGroupSnapshot(groupId);
|
|
693
706
|
if (!snapshot) return;
|
|
694
707
|
if (snapshot.group.data.frameMode === 'manual') return;
|
|
@@ -701,6 +714,10 @@ class CanvasStateManager {
|
|
|
701
714
|
position: { x: bounds.x, y: bounds.y },
|
|
702
715
|
size: { width: bounds.width, height: bounds.height },
|
|
703
716
|
});
|
|
717
|
+
// Only the affected ancestor chain may re-fit. Never repair unrelated
|
|
718
|
+
// automatic frames as a side effect of an otherwise scoped child update.
|
|
719
|
+
const parentId = snapshot.group.data.parentGroup;
|
|
720
|
+
if (typeof parentId === 'string') this.recomputeParentGroupBounds(parentId, visited);
|
|
704
721
|
}
|
|
705
722
|
|
|
706
723
|
private compactGroupChildren(groupId: string, layout: 'grid' | 'column' | 'flow' = 'grid'): void {
|
|
@@ -1121,6 +1138,7 @@ class CanvasStateManager {
|
|
|
1121
1138
|
edges: layout.edges,
|
|
1122
1139
|
annotations: layout.annotations,
|
|
1123
1140
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1141
|
+
contextPinMeta: this.pinMetaRecord(),
|
|
1124
1142
|
revisionState: this.revisionState(),
|
|
1125
1143
|
},
|
|
1126
1144
|
};
|
|
@@ -1478,6 +1496,7 @@ class CanvasStateManager {
|
|
|
1478
1496
|
edges: Array.from(this.edges.values()),
|
|
1479
1497
|
annotations: Array.from(this.annotations.values()),
|
|
1480
1498
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1499
|
+
contextPinMeta: this.pinMetaRecord(),
|
|
1481
1500
|
ax: this.getAxState(),
|
|
1482
1501
|
revisionState: this.revisionState(),
|
|
1483
1502
|
});
|
|
@@ -1540,6 +1559,7 @@ class CanvasStateManager {
|
|
|
1540
1559
|
this.edges.clear();
|
|
1541
1560
|
this.annotations.clear();
|
|
1542
1561
|
this._contextPinnedNodeIds.clear();
|
|
1562
|
+
this._contextPinMeta.clear();
|
|
1543
1563
|
this.ax.resetCanvasBound();
|
|
1544
1564
|
this._contentRevision = state.revisionState?.revision ?? 0;
|
|
1545
1565
|
this._revisionFloor = state.revisionState?.floor ?? 0;
|
|
@@ -1571,7 +1591,10 @@ class CanvasStateManager {
|
|
|
1571
1591
|
}
|
|
1572
1592
|
if (Array.isArray(state.contextPins)) {
|
|
1573
1593
|
for (const pinId of state.contextPins) {
|
|
1574
|
-
if (this.nodes.has(pinId))
|
|
1594
|
+
if (!this.nodes.has(pinId)) continue;
|
|
1595
|
+
this._contextPinnedNodeIds.add(pinId);
|
|
1596
|
+
const meta = state.contextPinMeta?.[pinId];
|
|
1597
|
+
if (meta) this._contextPinMeta.set(pinId, structuredClone(meta));
|
|
1575
1598
|
}
|
|
1576
1599
|
}
|
|
1577
1600
|
this.ax.applyPersistedAx(state.ax);
|
|
@@ -1677,6 +1700,7 @@ class CanvasStateManager {
|
|
|
1677
1700
|
edges: Array.from(this.edges.values()),
|
|
1678
1701
|
annotations: Array.from(this.annotations.values()),
|
|
1679
1702
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1703
|
+
contextPinMeta: this.pinMetaRecord(),
|
|
1680
1704
|
ax: this.getAxState(),
|
|
1681
1705
|
revisionState: this.revisionState(),
|
|
1682
1706
|
});
|
|
@@ -1774,6 +1798,7 @@ class CanvasStateManager {
|
|
|
1774
1798
|
edges: Array.from(this.edges.values(), (edge) => structuredClone(edge)),
|
|
1775
1799
|
annotations: Array.from(this.annotations.values(), (annotation) => structuredClone(annotation)),
|
|
1776
1800
|
contextPins: Array.from(this._contextPinnedNodeIds),
|
|
1801
|
+
contextPinMeta: this.pinMetaRecord(),
|
|
1777
1802
|
ax: this.getAxState(),
|
|
1778
1803
|
revisionState: this.revisionState(),
|
|
1779
1804
|
});
|
|
@@ -1788,6 +1813,7 @@ class CanvasStateManager {
|
|
|
1788
1813
|
? resolved.state.annotations.map((annotation) => structuredClone(annotation))
|
|
1789
1814
|
: [],
|
|
1790
1815
|
contextPins: Array.isArray(resolved.state.contextPins) ? [...resolved.state.contextPins] : [],
|
|
1816
|
+
contextPinMeta: resolved.state.contextPinMeta ? structuredClone(resolved.state.contextPinMeta) : undefined,
|
|
1791
1817
|
ax: resolved.state.ax ? structuredClone(resolved.state.ax) : createEmptyAxState(),
|
|
1792
1818
|
revisionState: resolved.state.revisionState ? structuredClone(resolved.state.revisionState) : undefined,
|
|
1793
1819
|
};
|
|
@@ -2062,7 +2088,6 @@ class CanvasStateManager {
|
|
|
2062
2088
|
// moved child's requested coordinates. Compaction is opt-in (group
|
|
2063
2089
|
// create/add with childLayout, or arrange).
|
|
2064
2090
|
this.recomputeParentGroupBounds(parentGroupId);
|
|
2065
|
-
this.reflowAllGroups();
|
|
2066
2091
|
}
|
|
2067
2092
|
this.scheduleSave();
|
|
2068
2093
|
this.notifyChange('nodes');
|
|
@@ -2093,6 +2118,7 @@ class CanvasStateManager {
|
|
|
2093
2118
|
const cloned = existing ? structuredClone(existing) : null;
|
|
2094
2119
|
const oldAxState = this.getAxState();
|
|
2095
2120
|
const wasPinned = this._contextPinnedNodeIds.has(id);
|
|
2121
|
+
const pinMeta = this._contextPinMeta.get(id);
|
|
2096
2122
|
// The enclosing group's membership before this removal — restored verbatim on undo.
|
|
2097
2123
|
let enclosingBefore: { id: string; children: string[] } | null = null;
|
|
2098
2124
|
// A removed group DISSOLVES: its children are released — into the enclosing
|
|
@@ -2127,6 +2153,7 @@ class CanvasStateManager {
|
|
|
2127
2153
|
this.nodes.delete(id);
|
|
2128
2154
|
this.removeEdgesForNode(id);
|
|
2129
2155
|
this._contextPinnedNodeIds.delete(id);
|
|
2156
|
+
this._contextPinMeta.delete(id);
|
|
2130
2157
|
// Re-normalize canvas-bound AX against the surviving node set. This strips the
|
|
2131
2158
|
// dangling node ref from work items / approval gates / elicitations / mode
|
|
2132
2159
|
// requests (re-anchored) and drops node-anchored review annotations (removed).
|
|
@@ -2191,7 +2218,10 @@ class CanvasStateManager {
|
|
|
2191
2218
|
}
|
|
2192
2219
|
}
|
|
2193
2220
|
for (const edge of connectedEdges) this.addEdge(structuredClone(edge));
|
|
2194
|
-
if (wasPinned)
|
|
2221
|
+
if (wasPinned) {
|
|
2222
|
+
this._contextPinnedNodeIds.add(id);
|
|
2223
|
+
if (pinMeta) this._contextPinMeta.set(id, pinMeta);
|
|
2224
|
+
}
|
|
2195
2225
|
this.ax.applyPersistedAx(oldAxState);
|
|
2196
2226
|
this.scheduleSave();
|
|
2197
2227
|
this.notifyChange('nodes');
|
|
@@ -2444,7 +2474,6 @@ class CanvasStateManager {
|
|
|
2444
2474
|
for (const groupId of touchedParentGroups) {
|
|
2445
2475
|
this.recomputeParentGroupBounds(groupId);
|
|
2446
2476
|
}
|
|
2447
|
-
if (touchedParentGroups.size > 0) this.reflowAllGroups();
|
|
2448
2477
|
|
|
2449
2478
|
if (applied > 0) {
|
|
2450
2479
|
this.scheduleSave();
|
|
@@ -2849,13 +2878,41 @@ class CanvasStateManager {
|
|
|
2849
2878
|
recordContextRead(input: ContextReadInput, boardId: string | null = this._activeBoardId): ContextRead | null {
|
|
2850
2879
|
if (!this._db) return null;
|
|
2851
2880
|
try {
|
|
2852
|
-
|
|
2881
|
+
const read = appendContextReadToDB(this._db, input, boardId, this.readRevisions(input, boardId));
|
|
2882
|
+
this._contextReadListener?.(boardId);
|
|
2883
|
+
return read;
|
|
2853
2884
|
} catch (error) {
|
|
2854
2885
|
logCanvasStateWarning('record context read failed', error);
|
|
2855
2886
|
return null;
|
|
2856
2887
|
}
|
|
2857
2888
|
}
|
|
2858
2889
|
|
|
2890
|
+
/** The delivered ids that are nodes on the read board, each with its content revision now. */
|
|
2891
|
+
private readRevisions(input: ContextReadInput, boardId: string | null): Record<string, number> {
|
|
2892
|
+
if (!boardId) return {};
|
|
2893
|
+
const nodes =
|
|
2894
|
+
boardId === this._activeBoardId ? [...this.nodes.values()] : (this.readBoard(boardId, false)?.state.nodes ?? []);
|
|
2895
|
+
const byId = new Map(nodes.map((node) => [node.id, node.contentRevision ?? 0]));
|
|
2896
|
+
const revisions: Record<string, number> = {};
|
|
2897
|
+
for (const id of input.readNodeIds ?? input.deliveredNodeIds) {
|
|
2898
|
+
const revision = byId.get(id);
|
|
2899
|
+
if (revision !== undefined) revisions[id] = revision;
|
|
2900
|
+
}
|
|
2901
|
+
return revisions;
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
/** Per node on a board, the latest agent read that delivered its content. */
|
|
2905
|
+
getNodeReadStatus(boardId: string | null = this._activeBoardId): NodeReadStatus[] {
|
|
2906
|
+
if (!this._db || !boardId) return [];
|
|
2907
|
+
return loadNodeReadStatusFromDB(this._db, boardId);
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
/** Nodes on a board whose content an agent read at or after `since` (ISO). */
|
|
2911
|
+
getReadNodeIdsSince(boardId: string | null, since: string): string[] {
|
|
2912
|
+
if (!this._db || !boardId) return [];
|
|
2913
|
+
return loadReadNodeIdsSince(this._db, boardId, since);
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2859
2916
|
getContextReads(limit?: number): { reads: ContextRead[]; summary: ContextReadConsumerSummary[] } {
|
|
2860
2917
|
return this._db ? loadContextReadsFromDB(this._db, limit) : { reads: [], summary: [] };
|
|
2861
2918
|
}
|
|
@@ -2869,25 +2926,57 @@ class CanvasStateManager {
|
|
|
2869
2926
|
return this.ax.getAxTimeline(q);
|
|
2870
2927
|
}
|
|
2871
2928
|
|
|
2872
|
-
|
|
2929
|
+
/**
|
|
2930
|
+
* Replace the pin set. Newly pinned nodes are attributed to the current
|
|
2931
|
+
* actor (with `reason` when given); pins that stay keep their attribution.
|
|
2932
|
+
* `restore` re-applies saved attribution, so undo does not re-attribute.
|
|
2933
|
+
*/
|
|
2934
|
+
setContextPins(nodeIds: string[], options: { reason?: string; restore?: Map<string, ContextPinMeta> } = {}): void {
|
|
2873
2935
|
const oldPins = Array.from(this._contextPinnedNodeIds);
|
|
2936
|
+
const oldMeta = new Map(this._contextPinMeta);
|
|
2937
|
+
const pinnedAt = new Date().toISOString();
|
|
2874
2938
|
this._contextPinnedNodeIds.clear();
|
|
2875
2939
|
for (const id of nodeIds) {
|
|
2876
|
-
if (this.nodes.has(id))
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2940
|
+
if (!this.nodes.has(id)) continue;
|
|
2941
|
+
this._contextPinnedNodeIds.add(id);
|
|
2942
|
+
const meta =
|
|
2943
|
+
options.restore?.get(id) ??
|
|
2944
|
+
(oldPins.includes(id) ? oldMeta.get(id) : undefined) ??
|
|
2945
|
+
(oldPins.includes(id)
|
|
2946
|
+
? undefined
|
|
2947
|
+
: { pinnedBy: currentActor(), pinnedAt, ...(options.reason ? { reason: options.reason } : {}) });
|
|
2948
|
+
if (meta) this._contextPinMeta.set(id, meta);
|
|
2949
|
+
}
|
|
2950
|
+
for (const id of [...this._contextPinMeta.keys()]) {
|
|
2951
|
+
if (!this._contextPinnedNodeIds.has(id)) this._contextPinMeta.delete(id);
|
|
2952
|
+
}
|
|
2953
|
+
const newMeta = new Map(this._contextPinMeta);
|
|
2880
2954
|
this.scheduleSave();
|
|
2881
2955
|
this.notifyChange('pins');
|
|
2882
2956
|
this.recordMutation({
|
|
2883
2957
|
operationType: 'setPins',
|
|
2884
2958
|
description: `Set context pins (${this._contextPinnedNodeIds.size} nodes)`,
|
|
2885
|
-
forward: this.suppressed(() => this.setContextPins([...nodeIds])),
|
|
2886
|
-
inverse: this.suppressed(() => this.setContextPins(oldPins)),
|
|
2959
|
+
forward: this.suppressed(() => this.setContextPins([...nodeIds], { restore: newMeta })),
|
|
2960
|
+
inverse: this.suppressed(() => this.setContextPins(oldPins, { restore: oldMeta })),
|
|
2887
2961
|
});
|
|
2888
2962
|
}
|
|
2889
2963
|
|
|
2964
|
+
/** Who pinned each current pin, when and why (pins with an unknown pinner are absent). */
|
|
2965
|
+
getContextPinMeta(): Record<string, ContextPinMeta> {
|
|
2966
|
+
return this.pinMetaRecord();
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
private pinMetaRecord(): Record<string, ContextPinMeta> {
|
|
2970
|
+
const record: Record<string, ContextPinMeta> = {};
|
|
2971
|
+
for (const id of this._contextPinnedNodeIds) {
|
|
2972
|
+
const meta = this._contextPinMeta.get(id);
|
|
2973
|
+
if (meta) record[id] = structuredClone(meta);
|
|
2974
|
+
}
|
|
2975
|
+
return record;
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2890
2978
|
clearContextPins(): void {
|
|
2979
|
+
this._contextPinMeta.clear();
|
|
2891
2980
|
this._contextPinnedNodeIds.clear();
|
|
2892
2981
|
this.scheduleSave();
|
|
2893
2982
|
this.notifyChange('pins');
|
|
@@ -3019,6 +3108,7 @@ class CanvasStateManager {
|
|
|
3019
3108
|
this.edges.clear();
|
|
3020
3109
|
this.annotations.clear();
|
|
3021
3110
|
this._contextPinnedNodeIds.clear();
|
|
3111
|
+
this._contextPinMeta.clear();
|
|
3022
3112
|
// Clears canvas-bound AX state (focus, work items, approvals, review annotations).
|
|
3023
3113
|
// Timeline tables (ax_events/ax_evidence/ax_steering) and host capability are
|
|
3024
3114
|
// deliberately retained per the AX state-partition policy.
|
|
@@ -71,13 +71,27 @@ function fullyContains(group: CanvasNodeState, child: CanvasNodeState): boolean
|
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
function
|
|
75
|
-
if (group.type !== 'group') return false;
|
|
74
|
+
function isDirectGroupChild(group: CanvasNodeState, child: CanvasNodeState): boolean {
|
|
76
75
|
if (child.data.parentGroup === group.id) return true;
|
|
77
76
|
const children = group.data.children;
|
|
78
77
|
return Array.isArray(children) && children.includes(child.id);
|
|
79
78
|
}
|
|
80
79
|
|
|
80
|
+
/** True when `child` sits anywhere inside `group` — a nested group's members are
|
|
81
|
+
* inside every ancestor frame, not overlapping it. */
|
|
82
|
+
function isGroupChildPair(group: CanvasNodeState, child: CanvasNodeState, byId: Map<string, CanvasNodeState>): boolean {
|
|
83
|
+
if (group.type !== 'group') return false;
|
|
84
|
+
const seen = new Set<string>();
|
|
85
|
+
let current: CanvasNodeState | undefined = child;
|
|
86
|
+
while (current && !seen.has(current.id)) {
|
|
87
|
+
if (isDirectGroupChild(group, current)) return true;
|
|
88
|
+
seen.add(current.id);
|
|
89
|
+
const parentId: unknown = current.data.parentGroup;
|
|
90
|
+
current = typeof parentId === 'string' ? byId.get(parentId) : undefined;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
function pair(a: CanvasNodeState, b: CanvasNodeState): CanvasValidationPair {
|
|
82
96
|
return {
|
|
83
97
|
aId: a.id,
|
|
@@ -101,6 +115,7 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
|
|
|
101
115
|
const containments: CanvasContainmentIssue[] = [];
|
|
102
116
|
const containmentViolations: CanvasContainmentIssue[] = [];
|
|
103
117
|
const groupFrameOverlaps: CanvasValidationPair[] = [];
|
|
118
|
+
const byId = new Map(layout.nodes.map((node) => [node.id, node]));
|
|
104
119
|
|
|
105
120
|
for (let i = 0; i < layout.nodes.length; i++) {
|
|
106
121
|
const a = layout.nodes[i]!;
|
|
@@ -108,11 +123,11 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
|
|
|
108
123
|
const b = layout.nodes[j]!;
|
|
109
124
|
if (!overlaps(a, b)) continue;
|
|
110
125
|
|
|
111
|
-
if (isGroupChildPair(a, b)) {
|
|
126
|
+
if (isGroupChildPair(a, b, byId)) {
|
|
112
127
|
(fullyContains(a, b) ? containments : containmentViolations).push(containment(a, b));
|
|
113
128
|
continue;
|
|
114
129
|
}
|
|
115
|
-
if (isGroupChildPair(b, a)) {
|
|
130
|
+
if (isGroupChildPair(b, a, byId)) {
|
|
116
131
|
(fullyContains(b, a) ? containments : containmentViolations).push(containment(b, a));
|
|
117
132
|
continue;
|
|
118
133
|
}
|