pmx-canvas 0.4.5 → 0.4.6
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 +56 -0
- package/Readme.md +21 -4
- package/dist/canvas/global.css +68 -0
- package/dist/canvas/index.js +91 -70
- package/dist/canvas/mermaid-entry.js +3563 -0
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/smoke.d.ts +1 -0
- package/dist/types/client/mermaid-entry.d.ts +11 -0
- package/dist/types/client/nodes/DiffNode.d.ts +20 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +0 -2
- package/dist/types/client/nodes/MermaidNode.d.ts +12 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +10 -1
- package/dist/types/server/canvas-validation.d.ts +34 -1
- package/dist/types/server/html-surface.d.ts +8 -0
- package/dist/types/server/index.d.ts +5 -0
- package/dist/types/server/operations/ops/nodes.d.ts +1 -1
- package/dist/types/server/workboard.d.ts +46 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
- package/docs/cli.md +12 -0
- package/docs/http-api.md +12 -0
- package/docs/mcp.md +1 -1
- package/docs/node-types.md +2 -0
- package/docs/screenshot.png +0 -0
- package/package.json +4 -3
- package/skills/pmx-canvas/SKILL.md +56 -19
- package/skills/pmx-canvas-testing/SKILL.md +3 -0
- package/src/cli/agent.ts +2 -0
- package/src/cli/commands/ax.ts +28 -3
- package/src/cli/commands/smoke.ts +212 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/shared.ts +1 -0
- package/src/client/canvas/CanvasViewport.tsx +6 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +10 -0
- package/src/client/canvas/Minimap.tsx +2 -0
- package/src/client/canvas/auto-fit.ts +2 -0
- package/src/client/icons.tsx +4 -0
- package/src/client/mermaid-entry.ts +76 -0
- package/src/client/nodes/DiffNode.tsx +114 -0
- package/src/client/nodes/ExtAppFrame.tsx +259 -84
- package/src/client/nodes/MermaidNode.tsx +81 -0
- package/src/client/state/node-factory.ts +2 -0
- package/src/client/state/sse-bridge.ts +28 -0
- package/src/client/theme/global.css +68 -0
- package/src/client/types.ts +6 -0
- package/src/mcp/server.ts +19 -1
- package/src/server/ax-interaction.ts +6 -0
- package/src/server/canvas-operations.ts +39 -9
- package/src/server/canvas-provenance.ts +2 -0
- package/src/server/canvas-schema.ts +33 -0
- package/src/server/canvas-state.ts +26 -0
- package/src/server/canvas-validation.ts +79 -0
- package/src/server/html-surface.ts +11 -0
- package/src/server/index.ts +25 -6
- package/src/server/operations/composites.ts +3 -2
- package/src/server/operations/ops/batch.ts +8 -0
- package/src/server/operations/ops/json-render.ts +44 -0
- package/src/server/operations/ops/nodes.ts +9 -1
- package/src/server/operations/ops/viewport.ts +8 -1
- package/src/server/server.ts +48 -1
- package/src/server/workboard.ts +159 -0
- package/src/shared/ax-surface-protocol.ts +8 -0
- package/src/shared/surface.ts +2 -0
|
@@ -1895,6 +1895,74 @@ html.is-node-resizing .ext-app-preview-catcher {
|
|
|
1895
1895
|
}
|
|
1896
1896
|
}
|
|
1897
1897
|
|
|
1898
|
+
/* Diff nodes — unified diff viewer with per-file sections */
|
|
1899
|
+
.diff-node {
|
|
1900
|
+
display: flex;
|
|
1901
|
+
flex-direction: column;
|
|
1902
|
+
gap: 10px;
|
|
1903
|
+
height: 100%;
|
|
1904
|
+
overflow: auto;
|
|
1905
|
+
font-family: var(--mono);
|
|
1906
|
+
font-size: 12px;
|
|
1907
|
+
color: var(--c-text);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
.diff-node.diff-node-empty {
|
|
1911
|
+
align-items: center;
|
|
1912
|
+
justify-content: center;
|
|
1913
|
+
color: var(--c-muted);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
.diff-node .diff-file {
|
|
1917
|
+
border: 1px solid var(--c-line);
|
|
1918
|
+
border-radius: 6px;
|
|
1919
|
+
overflow: hidden;
|
|
1920
|
+
flex-shrink: 0;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
.diff-node .diff-file-header {
|
|
1924
|
+
padding: 4px 8px;
|
|
1925
|
+
background: var(--c-panel-overlay);
|
|
1926
|
+
border-bottom: 1px solid var(--c-line);
|
|
1927
|
+
color: var(--c-text-soft);
|
|
1928
|
+
font-weight: 600;
|
|
1929
|
+
white-space: nowrap;
|
|
1930
|
+
overflow: hidden;
|
|
1931
|
+
text-overflow: ellipsis;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
.diff-node .diff-lines {
|
|
1935
|
+
margin: 0;
|
|
1936
|
+
padding: 4px 0;
|
|
1937
|
+
overflow-x: auto;
|
|
1938
|
+
font-family: inherit;
|
|
1939
|
+
font-size: inherit;
|
|
1940
|
+
line-height: 1.5;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
.diff-node .diff-lines > div {
|
|
1944
|
+
padding: 0 8px;
|
|
1945
|
+
white-space: pre;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
.diff-node .diff-hunk {
|
|
1949
|
+
color: var(--c-muted);
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
.diff-node .diff-line-add {
|
|
1953
|
+
color: var(--c-ok);
|
|
1954
|
+
background: var(--c-ok-10);
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
.diff-node .diff-line-remove {
|
|
1958
|
+
color: var(--c-danger);
|
|
1959
|
+
background: var(--c-danger-12);
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
.diff-node .diff-line-context {
|
|
1963
|
+
color: var(--c-text-soft);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1898
1966
|
/* Docked node — compact toolbar-flanking panel */
|
|
1899
1967
|
.docked-node {
|
|
1900
1968
|
min-width: 200px;
|
package/src/client/types.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface CanvasNodeState {
|
|
|
21
21
|
| 'ledger'
|
|
22
22
|
| 'trace'
|
|
23
23
|
| 'file'
|
|
24
|
+
| 'diff'
|
|
25
|
+
| 'mermaid'
|
|
24
26
|
| 'image'
|
|
25
27
|
| 'html'
|
|
26
28
|
| 'group';
|
|
@@ -77,6 +79,8 @@ export const TYPE_LABELS: Record<CanvasNodeState['type'], string> = {
|
|
|
77
79
|
ledger: 'LOG',
|
|
78
80
|
trace: 'TRACE',
|
|
79
81
|
file: 'FILE',
|
|
82
|
+
diff: 'DIFF',
|
|
83
|
+
mermaid: 'MERMAID',
|
|
80
84
|
image: 'IMG',
|
|
81
85
|
html: 'HTML',
|
|
82
86
|
group: 'GROUP',
|
|
@@ -92,6 +96,8 @@ export const EXPANDABLE_TYPES = new Set<CanvasNodeState['type']>([
|
|
|
92
96
|
'context',
|
|
93
97
|
'ledger',
|
|
94
98
|
'file',
|
|
99
|
+
'diff',
|
|
100
|
+
'mermaid',
|
|
95
101
|
'image',
|
|
96
102
|
'html',
|
|
97
103
|
]);
|
package/src/mcp/server.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
27
27
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
28
28
|
import { z } from 'zod';
|
|
29
29
|
import { AX_SOURCES } from '../server/operations/ops/ax-shared.js';
|
|
30
|
-
import { canvasState, describeCanvasSchema } from '../server/index.js';
|
|
30
|
+
import { canvasState, describeCanvasSchema, stopCanvasServer } from '../server/index.js';
|
|
31
31
|
import { AX_INTERACTION_TYPES } from '../server/ax-interaction.js';
|
|
32
32
|
import { buildPendingAxActivity } from '../server/ax-state.js';
|
|
33
33
|
import { registerOperationTools, registerCompositeTools } from '../server/operations/index.js';
|
|
@@ -884,6 +884,24 @@ export async function startMcpServer(): Promise<void> {
|
|
|
884
884
|
// Connect via stdio
|
|
885
885
|
const transport = new StdioServerTransport();
|
|
886
886
|
await server.connect(transport);
|
|
887
|
+
|
|
888
|
+
// Exit when the stdio channel closes (0.4.5 report Finding Y): without this,
|
|
889
|
+
// a client that completes the official close sequence leaves this process
|
|
890
|
+
// orphaned — the auto-started canvas server and its timers keep the event
|
|
891
|
+
// loop alive forever. stopCanvasServer() checkpoints the SQLite DB before
|
|
892
|
+
// exit and is a no-op when this process only attached to a remote daemon.
|
|
893
|
+
const shutdown = (): void => {
|
|
894
|
+
try {
|
|
895
|
+
stopCanvasServer();
|
|
896
|
+
} catch {
|
|
897
|
+
// Exit regardless — a failed cleanup must not resurrect the orphan.
|
|
898
|
+
}
|
|
899
|
+
process.exit(0);
|
|
900
|
+
};
|
|
901
|
+
server.server.onclose = shutdown;
|
|
902
|
+
process.stdin.on('end', shutdown);
|
|
903
|
+
process.on('SIGINT', shutdown);
|
|
904
|
+
process.on('SIGTERM', shutdown);
|
|
887
905
|
}
|
|
888
906
|
|
|
889
907
|
// Allow direct execution: bun run src/mcp/server.ts
|
|
@@ -102,6 +102,12 @@ export const DEFAULT_NODE_AX_CAPABILITIES: Record<CanvasNodeType, NodeAxCapabili
|
|
|
102
102
|
'notify-agent',
|
|
103
103
|
),
|
|
104
104
|
file: caps(true, ['ax.evidence.add', 'ax.review.add', 'ax.focus.set', 'ax.event.record']),
|
|
105
|
+
diff: caps(true, ['ax.evidence.add', 'ax.review.add', 'ax.focus.set', 'ax.event.record']),
|
|
106
|
+
mermaid: caps(
|
|
107
|
+
true,
|
|
108
|
+
['ax.steer', 'ax.work.create', 'ax.evidence.add', 'ax.command.invoke', 'ax.event.record'],
|
|
109
|
+
'notify-agent',
|
|
110
|
+
),
|
|
105
111
|
'json-render': caps(true, [
|
|
106
112
|
'ax.work.create',
|
|
107
113
|
'ax.work.update',
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type ExternalMcpTransportConfig,
|
|
18
18
|
} from './mcp-app-runtime.js';
|
|
19
19
|
import { mutationHistory } from './mutation-history.js';
|
|
20
|
+
import { clampCreateNodeSize } from './canvas-validation.js';
|
|
20
21
|
import { computeGroupBounds, findOpenCanvasPosition } from './placement.js';
|
|
21
22
|
import { searchNodes } from './spatial-analysis.js';
|
|
22
23
|
import { getCanvasNodeTitle } from './canvas-serialization.js';
|
|
@@ -114,6 +115,10 @@ export const MCP_APP_NODE_DEFAULT_SIZE = { width: 960, height: 600 };
|
|
|
114
115
|
export const IMAGE_NODE_DEFAULT_SIZE = { width: 480, height: 360 };
|
|
115
116
|
export const LEDGER_NODE_DEFAULT_SIZE = { width: 420, height: 280 };
|
|
116
117
|
|
|
118
|
+
// Per-type minimum creation sizes + clamp live in canvas-validation.ts
|
|
119
|
+
// (NODE_MIN_CREATE_SIZES / clampCreateNodeSize) — shared with the validate
|
|
120
|
+
// surface's sizeWarnings.
|
|
121
|
+
|
|
117
122
|
interface CanvasCreateGroupInput {
|
|
118
123
|
title?: string;
|
|
119
124
|
childIds?: string[];
|
|
@@ -360,11 +365,20 @@ export function buildJsonRenderNodeUpdate(
|
|
|
360
365
|
if (node.type !== 'json-render') throw new Error(`Node "${node.id}" is not a json-render node.`);
|
|
361
366
|
const spec = normalizeAndValidateJsonRenderSpec(input.spec);
|
|
362
367
|
const title = input.title?.trim() || inferJsonRenderNodeTitle(spec);
|
|
368
|
+
// Bump specVersion so the viewer URL changes: the iframe src is keyed on
|
|
369
|
+
// nodeId + ?v=specVersion, and the viewer only reads the spec at document
|
|
370
|
+
// load. Without the bump the src is byte-identical after a content update,
|
|
371
|
+
// the iframe never reloads, and the node renders stale forever (the live
|
|
372
|
+
// workboard's refresh depends on this).
|
|
373
|
+
const prevVersion = typeof node.data.specVersion === 'number' ? node.data.specVersion : 0;
|
|
363
374
|
return {
|
|
364
375
|
spec,
|
|
365
376
|
data: {
|
|
366
377
|
...node.data,
|
|
367
|
-
...createJsonRenderNodeData(node.id, title, spec, {
|
|
378
|
+
...createJsonRenderNodeData(node.id, title, spec, {
|
|
379
|
+
viewerType: 'json-render',
|
|
380
|
+
specVersion: prevVersion + 1,
|
|
381
|
+
}),
|
|
368
382
|
},
|
|
369
383
|
};
|
|
370
384
|
}
|
|
@@ -906,8 +920,12 @@ export function addCanvasNode(input: CanvasAddNodeInput): {
|
|
|
906
920
|
throw new Error(`Use the dedicated ${input.type} node APIs for structured viewer nodes.`);
|
|
907
921
|
}
|
|
908
922
|
|
|
909
|
-
const
|
|
910
|
-
|
|
923
|
+
const { width, height } = clampCreateNodeSize(
|
|
924
|
+
input.type,
|
|
925
|
+
input.width ?? input.defaultWidth ?? 720,
|
|
926
|
+
input.height ?? input.defaultHeight ?? 600,
|
|
927
|
+
input.strictSize,
|
|
928
|
+
);
|
|
911
929
|
const position =
|
|
912
930
|
input.x !== undefined && input.y !== undefined
|
|
913
931
|
? { x: input.x, y: input.y }
|
|
@@ -1540,8 +1558,12 @@ export function createCanvasJsonRenderNode(input: JsonRenderNodeInput): {
|
|
|
1540
1558
|
node: CanvasNodeState;
|
|
1541
1559
|
} {
|
|
1542
1560
|
const spec = normalizeAndValidateJsonRenderSpec(input.spec);
|
|
1543
|
-
const width
|
|
1544
|
-
|
|
1561
|
+
const { width, height } = clampCreateNodeSize(
|
|
1562
|
+
'json-render',
|
|
1563
|
+
input.width ?? JSON_RENDER_NODE_SIZE.width,
|
|
1564
|
+
input.height ?? JSON_RENDER_NODE_SIZE.height,
|
|
1565
|
+
input.strictSize,
|
|
1566
|
+
);
|
|
1545
1567
|
const position =
|
|
1546
1568
|
input.x !== undefined && input.y !== undefined
|
|
1547
1569
|
? { x: input.x, y: input.y }
|
|
@@ -1580,8 +1602,12 @@ export function createCanvasStreamingJsonRenderNode(input: {
|
|
|
1580
1602
|
strictSize?: boolean;
|
|
1581
1603
|
}): { id: string; url: string; spec: JsonRenderSpec; node: CanvasNodeState } {
|
|
1582
1604
|
const spec = emptyStreamingSpec();
|
|
1583
|
-
const width
|
|
1584
|
-
|
|
1605
|
+
const { width, height } = clampCreateNodeSize(
|
|
1606
|
+
'json-render',
|
|
1607
|
+
input.width ?? JSON_RENDER_NODE_SIZE.width,
|
|
1608
|
+
input.height ?? JSON_RENDER_NODE_SIZE.height,
|
|
1609
|
+
input.strictSize,
|
|
1610
|
+
);
|
|
1585
1611
|
const position =
|
|
1586
1612
|
input.x !== undefined && input.y !== undefined
|
|
1587
1613
|
? { x: input.x, y: input.y }
|
|
@@ -1652,8 +1678,12 @@ export function createCanvasGraphNode(input: GraphNodeInput): {
|
|
|
1652
1678
|
} {
|
|
1653
1679
|
const title = input.title?.trim() || 'Graph';
|
|
1654
1680
|
const spec = buildGraphSpec(input);
|
|
1655
|
-
const width
|
|
1656
|
-
|
|
1681
|
+
const { width, height } = clampCreateNodeSize(
|
|
1682
|
+
'graph',
|
|
1683
|
+
input.width ?? GRAPH_NODE_SIZE.width,
|
|
1684
|
+
input.heightPx ?? GRAPH_NODE_SIZE.height,
|
|
1685
|
+
input.strictSize,
|
|
1686
|
+
);
|
|
1657
1687
|
const position =
|
|
1658
1688
|
input.x !== undefined && input.y !== undefined
|
|
1659
1689
|
? { x: input.x, y: input.y }
|
|
@@ -209,6 +209,39 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
|
|
|
209
209
|
'Path-backed file nodes automatically persist `data.provenance` with a file URI and file-watch refresh strategy.',
|
|
210
210
|
],
|
|
211
211
|
},
|
|
212
|
+
{
|
|
213
|
+
type: 'diff',
|
|
214
|
+
kind: 'node',
|
|
215
|
+
description: 'Unified diff viewer with per-file sections and colored add/remove lines.',
|
|
216
|
+
endpoint: '/api/canvas/node',
|
|
217
|
+
mcpTool: 'canvas_node (action:"add")',
|
|
218
|
+
fields: [
|
|
219
|
+
{ name: 'title', type: 'string', required: false, description: 'Optional node title.' },
|
|
220
|
+
{ name: 'content', type: 'string', required: false, description: 'Unified diff text (git or raw hunks).' },
|
|
221
|
+
],
|
|
222
|
+
example: {
|
|
223
|
+
type: 'diff',
|
|
224
|
+
title: 'Fix null check',
|
|
225
|
+
content: '--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1,3 +1,3 @@\n-const a = b.c;\n+const a = b?.c;\n context',
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
type: 'mermaid',
|
|
230
|
+
kind: 'node',
|
|
231
|
+
description:
|
|
232
|
+
'Text-to-diagram node rendered client-side with Mermaid (flowcharts, sequence, state, class diagrams, …).',
|
|
233
|
+
endpoint: '/api/canvas/node',
|
|
234
|
+
mcpTool: 'canvas_node (action:"add")',
|
|
235
|
+
fields: [
|
|
236
|
+
{ name: 'title', type: 'string', required: false, description: 'Optional node title.' },
|
|
237
|
+
{ name: 'content', type: 'string', required: false, description: 'Mermaid diagram source text.' },
|
|
238
|
+
],
|
|
239
|
+
example: {
|
|
240
|
+
type: 'mermaid',
|
|
241
|
+
title: 'Request flow',
|
|
242
|
+
content: 'graph TD; A[Client] --> B[Server]; B --> C[(DB)];',
|
|
243
|
+
},
|
|
244
|
+
},
|
|
212
245
|
{
|
|
213
246
|
type: 'image',
|
|
214
247
|
kind: 'node',
|
|
@@ -175,6 +175,8 @@ export interface CanvasNodeState {
|
|
|
175
175
|
| 'ledger'
|
|
176
176
|
| 'trace'
|
|
177
177
|
| 'file'
|
|
178
|
+
| 'diff'
|
|
179
|
+
| 'mermaid'
|
|
178
180
|
| 'image'
|
|
179
181
|
| 'html'
|
|
180
182
|
| 'group';
|
|
@@ -368,6 +370,28 @@ class CanvasStateManager {
|
|
|
368
370
|
};
|
|
369
371
|
}
|
|
370
372
|
|
|
373
|
+
// ── Work-item change listener (single slot, for live workboard refresh) ──
|
|
374
|
+
private _workItemsChangedListener: (() => void) | null = null;
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Register THE work-item change listener (single slot, last-write-wins).
|
|
378
|
+
* Fired after addWorkItem/updateWorkItem completes (including the node
|
|
379
|
+
* status mirror), so live views like the workboard can rebuild from the
|
|
380
|
+
* fresh work-item list.
|
|
381
|
+
*/
|
|
382
|
+
setWorkItemsChangedListener(listener: (() => void) | null): void {
|
|
383
|
+
this._workItemsChangedListener = listener;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private notifyWorkItemsChanged(): void {
|
|
387
|
+
if (!this._workItemsChangedListener) return;
|
|
388
|
+
try {
|
|
389
|
+
this._workItemsChangedListener();
|
|
390
|
+
} catch (error) {
|
|
391
|
+
logCanvasStateWarning('work-items-changed listener failed', error);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
371
395
|
private notifyChange(type: CanvasChangeType): void {
|
|
372
396
|
// Iterate a snapshot: a listener (e.g. a blocking-wait via onChange) may dispose
|
|
373
397
|
// itself synchronously here, and splicing the live array mid-iteration would skip
|
|
@@ -1727,6 +1751,7 @@ class CanvasStateManager {
|
|
|
1727
1751
|
): PmxAxWorkItem {
|
|
1728
1752
|
const item = this.ax.addWorkItem(input, options);
|
|
1729
1753
|
this.mirrorAxWorkStatusToNodes(item.nodeIds, [], item.status);
|
|
1754
|
+
this.notifyWorkItemsChanged();
|
|
1730
1755
|
return item;
|
|
1731
1756
|
}
|
|
1732
1757
|
|
|
@@ -1745,6 +1770,7 @@ class CanvasStateManager {
|
|
|
1745
1770
|
const item = this.ax.updateWorkItem(id, patch, options);
|
|
1746
1771
|
if (!item) return null;
|
|
1747
1772
|
this.mirrorAxWorkStatusToNodes(item.nodeIds, previousNodeIds, item.status);
|
|
1773
|
+
this.notifyWorkItemsChanged();
|
|
1748
1774
|
return item;
|
|
1749
1775
|
}
|
|
1750
1776
|
|
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
import type { CanvasLayout, CanvasNodeState } from './canvas-state.js';
|
|
2
2
|
import { getCanvasNodeTitle } from './canvas-serialization.js';
|
|
3
3
|
|
|
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
|
+
}
|
|
44
|
+
|
|
4
45
|
export interface CanvasValidationPair {
|
|
5
46
|
aId: string;
|
|
6
47
|
aTitle: string | null;
|
|
@@ -15,12 +56,24 @@ export interface CanvasContainmentIssue {
|
|
|
15
56
|
childTitle: string | null;
|
|
16
57
|
}
|
|
17
58
|
|
|
59
|
+
export interface CanvasSizeWarning {
|
|
60
|
+
id: string;
|
|
61
|
+
title: string | null;
|
|
62
|
+
type: CanvasNodeState['type'];
|
|
63
|
+
width: number;
|
|
64
|
+
height: number;
|
|
65
|
+
minWidth: number;
|
|
66
|
+
minHeight: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
18
69
|
export interface CanvasValidationResult {
|
|
19
70
|
ok: boolean;
|
|
20
71
|
collisions: CanvasValidationPair[];
|
|
21
72
|
containments: CanvasContainmentIssue[];
|
|
22
73
|
containmentViolations: CanvasContainmentIssue[];
|
|
23
74
|
missingEdgeEndpoints: Array<{ edgeId: string; from: string; to: string }>;
|
|
75
|
+
/** Nodes below their type's readable minimum (advisory — does not fail `ok`). */
|
|
76
|
+
sizeWarnings: CanvasSizeWarning[];
|
|
24
77
|
summary: {
|
|
25
78
|
nodes: number;
|
|
26
79
|
edges: number;
|
|
@@ -28,6 +81,7 @@ export interface CanvasValidationResult {
|
|
|
28
81
|
containments: number;
|
|
29
82
|
containmentViolations: number;
|
|
30
83
|
missingEdgeEndpoints: number;
|
|
84
|
+
sizeWarnings: number;
|
|
31
85
|
};
|
|
32
86
|
}
|
|
33
87
|
|
|
@@ -109,12 +163,36 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
|
|
|
109
163
|
.filter((edge) => !nodeIds.has(edge.from) || !nodeIds.has(edge.to))
|
|
110
164
|
.map((edge) => ({ edgeId: edge.id, from: edge.from, to: edge.to }));
|
|
111
165
|
|
|
166
|
+
// Advisory: nodes below their type's readable floor (clipped/unreadable
|
|
167
|
+
// content — 0.4.5 report follow-up). Creation clamps these, but resized or
|
|
168
|
+
// strictSize-created nodes can still be undersized; surface them so an
|
|
169
|
+
// agent's validate pass sees the problem. Collapsed/docked nodes render as
|
|
170
|
+
// bars, and strictSize is the deliberate opt-out — all skipped.
|
|
171
|
+
const sizeWarnings: CanvasSizeWarning[] = layout.nodes
|
|
172
|
+
.filter((node) => !node.collapsed && node.dockPosition == null && node.data?.strictSize !== true)
|
|
173
|
+
.flatMap((node) => {
|
|
174
|
+
const min = NODE_MIN_CREATE_SIZES[node.type];
|
|
175
|
+
if (!min || (node.size.width >= min.width && node.size.height >= min.height)) return [];
|
|
176
|
+
return [
|
|
177
|
+
{
|
|
178
|
+
id: node.id,
|
|
179
|
+
title: getCanvasNodeTitle(node),
|
|
180
|
+
type: node.type,
|
|
181
|
+
width: node.size.width,
|
|
182
|
+
height: node.size.height,
|
|
183
|
+
minWidth: min.width,
|
|
184
|
+
minHeight: min.height,
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
});
|
|
188
|
+
|
|
112
189
|
return {
|
|
113
190
|
ok: collisions.length === 0 && containmentViolations.length === 0 && missingEdgeEndpoints.length === 0,
|
|
114
191
|
collisions,
|
|
115
192
|
containments,
|
|
116
193
|
containmentViolations,
|
|
117
194
|
missingEdgeEndpoints,
|
|
195
|
+
sizeWarnings,
|
|
118
196
|
summary: {
|
|
119
197
|
nodes: layout.nodes.length,
|
|
120
198
|
edges: layout.edges.length,
|
|
@@ -122,6 +200,7 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
|
|
|
122
200
|
containments: containments.length,
|
|
123
201
|
containmentViolations: containmentViolations.length,
|
|
124
202
|
missingEdgeEndpoints: missingEdgeEndpoints.length,
|
|
203
|
+
sizeWarnings: sizeWarnings.length,
|
|
125
204
|
},
|
|
126
205
|
};
|
|
127
206
|
}
|
|
@@ -211,6 +211,17 @@ function escapeSurfaceHtml(value: string): string {
|
|
|
211
211
|
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Author-HTML fragment for a `mermaid` node surface: the HTML-escaped diagram
|
|
216
|
+
* source in a hidden `<pre>` the entry script reads back via textContent, plus
|
|
217
|
+
* the separately-built renderer bundle (dist/canvas/mermaid-entry.js — kept out
|
|
218
|
+
* of the main SPA bundle). Escaping is mandatory: the source is arbitrary node
|
|
219
|
+
* data and must not be parsed as markup.
|
|
220
|
+
*/
|
|
221
|
+
export function buildMermaidSurfaceHtml(source: string): string {
|
|
222
|
+
return `<pre class="mermaid-source" style="display:none">${escapeSurfaceHtml(source)}</pre><script src="/canvas/mermaid-entry.js"></script>`;
|
|
223
|
+
}
|
|
224
|
+
|
|
214
225
|
export interface HtmlSurfaceOptions {
|
|
215
226
|
theme: SurfaceTheme;
|
|
216
227
|
/**
|
package/src/server/index.ts
CHANGED
|
@@ -542,15 +542,22 @@ export class PmxCanvas extends EventEmitter {
|
|
|
542
542
|
nodeIds?: string[];
|
|
543
543
|
data?: Record<string, unknown> | null;
|
|
544
544
|
},
|
|
545
|
-
options?: { source?: PmxAxSource },
|
|
545
|
+
options?: { source?: PmxAxSource; agentId?: string | null },
|
|
546
546
|
): PmxAxEvent {
|
|
547
|
-
const event = canvasState.recordAxEvent(input, { source: options?.source ?? 'sdk' });
|
|
547
|
+
const event = canvasState.recordAxEvent(input, { source: options?.source ?? 'sdk', agentId: options?.agentId });
|
|
548
548
|
emitPrimaryWorkbenchEvent('ax-event-created', { event });
|
|
549
549
|
return event;
|
|
550
550
|
}
|
|
551
551
|
|
|
552
|
-
sendSteering(
|
|
553
|
-
|
|
552
|
+
sendSteering(
|
|
553
|
+
message: string,
|
|
554
|
+
options?: { source?: PmxAxSource; agentId?: string | null; target?: string | null },
|
|
555
|
+
): PmxAxSteeringMessage {
|
|
556
|
+
const steering = canvasState.recordSteeringMessage(message, {
|
|
557
|
+
source: options?.source ?? 'sdk',
|
|
558
|
+
agentId: options?.agentId,
|
|
559
|
+
target: options?.target,
|
|
560
|
+
});
|
|
554
561
|
emitPrimaryWorkbenchEvent('ax-event-created', { steering });
|
|
555
562
|
return steering;
|
|
556
563
|
}
|
|
@@ -604,7 +611,13 @@ export class PmxCanvas extends EventEmitter {
|
|
|
604
611
|
}
|
|
605
612
|
|
|
606
613
|
addWorkItem(
|
|
607
|
-
input: {
|
|
614
|
+
input: {
|
|
615
|
+
title: string;
|
|
616
|
+
status?: PmxAxWorkItemStatus;
|
|
617
|
+
detail?: string | null;
|
|
618
|
+
nodeIds?: string[];
|
|
619
|
+
agentId?: string | null;
|
|
620
|
+
},
|
|
608
621
|
options?: { source?: PmxAxSource },
|
|
609
622
|
): PmxAxWorkItem {
|
|
610
623
|
const workItem = canvasState.addWorkItem(input, { source: options?.source ?? 'sdk' });
|
|
@@ -614,7 +627,13 @@ export class PmxCanvas extends EventEmitter {
|
|
|
614
627
|
|
|
615
628
|
updateWorkItem(
|
|
616
629
|
id: string,
|
|
617
|
-
patch: {
|
|
630
|
+
patch: {
|
|
631
|
+
title?: string;
|
|
632
|
+
status?: PmxAxWorkItemStatus;
|
|
633
|
+
detail?: string | null;
|
|
634
|
+
nodeIds?: string[];
|
|
635
|
+
agentId?: string | null;
|
|
636
|
+
},
|
|
618
637
|
options?: { source?: PmxAxSource },
|
|
619
638
|
): PmxAxWorkItem | null {
|
|
620
639
|
const workItem = canvasState.updateWorkItem(id, patch, { source: options?.source ?? 'sdk' });
|
|
@@ -156,14 +156,15 @@ export const compositeToolDefinitions: CompositeToolDefinition[] = [
|
|
|
156
156
|
{
|
|
157
157
|
toolName: 'canvas_render',
|
|
158
158
|
description:
|
|
159
|
-
'Spec-driven content: discover the schema, validate a payload, or create a json-render / graph node. Action "describe-schema" returns the json-render component catalog + create schemas; "validate" checks a spec or graph payload without creating a node; "add-json-render" creates a json-render node from a complete spec; "stream-json-render" progressively builds one from SpecStream patches (omit nodeId to create, pass it back to append, done=true to finish); "add-graph" creates a chart node (line, bar, pie, area, scatter, radar, stacked-bar, composed, sparkline, dot-plot, bullet, slopegraph).',
|
|
160
|
-
actionSummary: 'describe-schema | validate | add-json-render | stream-json-render | add-graph',
|
|
159
|
+
'Spec-driven content: discover the schema, validate a payload, or create a json-render / graph node. Action "describe-schema" returns the json-render component catalog + create schemas; "validate" checks a spec or graph payload without creating a node; "add-json-render" creates a json-render node from a complete spec; "stream-json-render" progressively builds one from SpecStream patches (omit nodeId to create, pass it back to append, done=true to finish); "add-graph" creates a chart node (line, bar, pie, area, scatter, radar, stacked-bar, composed, sparkline, dot-plot, bullet, slopegraph); "workboard" materializes a live AX work-item board (a json-render node with a column per status) that auto-refreshes whenever work items change — idempotent, rebuilding the existing board in place.',
|
|
160
|
+
actionSummary: 'describe-schema | validate | add-json-render | stream-json-render | add-graph | workboard',
|
|
161
161
|
actions: {
|
|
162
162
|
'describe-schema': 'schema.describe',
|
|
163
163
|
validate: 'spec.validate',
|
|
164
164
|
'add-json-render': 'jsonrender.add',
|
|
165
165
|
'stream-json-render': 'jsonrender.stream',
|
|
166
166
|
'add-graph': 'graph.add',
|
|
167
|
+
workboard: 'render.workboard',
|
|
167
168
|
},
|
|
168
169
|
},
|
|
169
170
|
{
|
|
@@ -375,6 +375,14 @@ const batchOperation = defineOperation<z.infer<typeof batchSchema>, BatchEnvelop
|
|
|
375
375
|
// ctx.emit). After the loop completes, emit ONE final canvas-layout-update.
|
|
376
376
|
const envelope = await runWithSuppressedEmits(() => runBatch(operations));
|
|
377
377
|
ctx.emit('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
378
|
+
// The suppression window also swallows the pins op's context-pins-changed
|
|
379
|
+
// frame, leaving the client's pin state (menus, indicators) stale until
|
|
380
|
+
// reload (0.4.5 report Finding X). If the batch touched pins, follow the
|
|
381
|
+
// layout frame with ONE final pins frame carrying the end state.
|
|
382
|
+
if (operations.some((entry) => entry.op.startsWith('pin.'))) {
|
|
383
|
+
const nodeIds = Array.from(canvasState.contextPinnedNodeIds);
|
|
384
|
+
ctx.emit('context-pins-changed', { count: nodeIds.length, nodeIds });
|
|
385
|
+
}
|
|
378
386
|
return envelope;
|
|
379
387
|
},
|
|
380
388
|
});
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from '../../canvas-operations.js';
|
|
40
40
|
import { describeCanvasSchema, validateStructuredCanvasPayload } from '../../canvas-schema.js';
|
|
41
41
|
import { isHtmlPrimitiveKind } from '../../html-primitives.js';
|
|
42
|
+
import { renderWorkboard } from '../../workboard.js';
|
|
42
43
|
import { defineOperation, OperationError, type Operation } from '../types.js';
|
|
43
44
|
import {
|
|
44
45
|
buildNodeResponse,
|
|
@@ -311,6 +312,48 @@ const jsonRenderStreamOperation = defineOperation<z.infer<typeof jsonRenderStrea
|
|
|
311
312
|
// Wire shape is `{ id, url, ok, applied, ... }` — handler output verbatim.
|
|
312
313
|
});
|
|
313
314
|
|
|
315
|
+
// ── render.workboard ──────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
const workboardRenderShape = {
|
|
318
|
+
intentId: z
|
|
319
|
+
.string()
|
|
320
|
+
.optional()
|
|
321
|
+
.catch(undefined)
|
|
322
|
+
.describe('Ghost intent id returned by canvas_intent signal. A vetoed or expired intent blocks this mutation.'),
|
|
323
|
+
x: z.number().optional().catch(undefined).describe('Optional X position (only used when creating the board)'),
|
|
324
|
+
y: z.number().optional().catch(undefined).describe('Optional Y position (only used when creating the board)'),
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const workboardRenderSchema = z.looseObject(workboardRenderShape);
|
|
328
|
+
|
|
329
|
+
const workboardRenderOperation = defineOperation<
|
|
330
|
+
z.infer<typeof workboardRenderSchema>,
|
|
331
|
+
ReturnType<typeof renderWorkboard>
|
|
332
|
+
>({
|
|
333
|
+
name: 'render.workboard',
|
|
334
|
+
mutates: true,
|
|
335
|
+
input: workboardRenderSchema,
|
|
336
|
+
inputShape: workboardRenderShape,
|
|
337
|
+
http: {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
path: '/api/canvas/workboard',
|
|
340
|
+
},
|
|
341
|
+
mcp: {
|
|
342
|
+
toolName: 'canvas_render_workboard',
|
|
343
|
+
description:
|
|
344
|
+
'Materialize a live work-item board: a json-render node with one column per status (todo → in-progress → blocked → done → cancelled) showing every AX work item as a card (title, agentId chip, detail). Idempotent: rebuilds the existing board node in place when one exists, otherwise creates it. The board auto-refreshes whenever work items change.',
|
|
345
|
+
},
|
|
346
|
+
handler: (input) => {
|
|
347
|
+
const body: Record<string, unknown> = input;
|
|
348
|
+
const x = pickFiniteNumber(body, 'x');
|
|
349
|
+
const y = pickFiniteNumber(body, 'y');
|
|
350
|
+
return renderWorkboard({
|
|
351
|
+
...(x !== undefined ? { x } : {}),
|
|
352
|
+
...(y !== undefined ? { y } : {}),
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
314
357
|
// ── graph.add ─────────────────────────────────────────────────
|
|
315
358
|
|
|
316
359
|
const graphAddShape = {
|
|
@@ -829,6 +872,7 @@ const specValidateOperation = defineOperation<z.infer<typeof specValidateSchema>
|
|
|
829
872
|
export const jsonRenderOperations: Operation[] = [
|
|
830
873
|
jsonRenderAddOperation,
|
|
831
874
|
jsonRenderStreamOperation,
|
|
875
|
+
workboardRenderOperation,
|
|
832
876
|
graphAddOperation,
|
|
833
877
|
schemaDescribeOperation,
|
|
834
878
|
specValidateOperation,
|