pmx-canvas 0.4.4 → 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 +108 -0
- package/Readme.md +52 -4
- package/dist/canvas/global.css +115 -11
- package/dist/canvas/index.js +93 -72
- package/dist/canvas/mermaid-entry.js +3563 -0
- package/dist/canvas/surface-theme.css +2 -2
- package/dist/json-render/index.css +1 -1
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/skills.d.ts +1 -1
- 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/state/intent-store.d.ts +6 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/server/ax-state-manager.d.ts +5 -0
- package/dist/types/server/ax-state.d.ts +9 -3
- package/dist/types/server/canvas-db.d.ts +3 -2
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +22 -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/ax-shared.d.ts +4 -1
- package/dist/types/server/operations/ops/nodes.d.ts +1 -1
- package/dist/types/server/operations/registry.d.ts +9 -1
- package/dist/types/server/workboard.d.ts +46 -0
- package/dist/types/shared/ax-intent.d.ts +6 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
- package/docs/cli.md +12 -0
- package/docs/environment.md +1 -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/docs/screenshots/amp-orb-portal.png +0 -0
- package/docs/screenshots/claude-code-desktop.png +0 -0
- package/docs/screenshots/codex-app.png +0 -0
- package/docs/screenshots/github-copilot-app.png +0 -0
- package/package.json +4 -3
- package/skills/pmx-canvas/SKILL.md +71 -24
- package/skills/pmx-canvas-orchestration/SKILL.md +82 -0
- 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/skills.ts +1 -1
- package/src/cli/commands/smoke.ts +212 -0
- package/src/cli/index.ts +15 -2
- package/src/cli/shared.ts +1 -0
- package/src/client/canvas/CanvasNode.tsx +12 -1
- package/src/client/canvas/CanvasViewport.tsx +6 -0
- package/src/client/canvas/ExpandedNodeOverlay.tsx +10 -0
- package/src/client/canvas/IntentLayer.tsx +8 -6
- 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/intent-bridge.ts +12 -3
- package/src/client/state/intent-store.ts +47 -2
- package/src/client/state/node-factory.ts +2 -0
- package/src/client/state/sse-bridge.ts +28 -0
- package/src/client/theme/global.css +115 -11
- package/src/client/theme/surface-theme.css +2 -2
- package/src/client/types.ts +6 -0
- package/src/mcp/server.ts +22 -6
- package/src/server/ax-interaction.ts +6 -0
- package/src/server/ax-state-manager.ts +27 -6
- package/src/server/ax-state.ts +36 -3
- package/src/server/canvas-db.ts +83 -60
- 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 +78 -6
- 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/intent-registry.ts +2 -0
- package/src/server/operations/composites.ts +3 -2
- package/src/server/operations/http.ts +5 -1
- package/src/server/operations/ops/ax-shared.ts +20 -1
- package/src/server/operations/ops/ax-state.ts +4 -7
- package/src/server/operations/ops/ax-timeline.ts +26 -3
- package/src/server/operations/ops/ax-work.ts +27 -1
- package/src/server/operations/ops/batch.ts +11 -1
- package/src/server/operations/ops/json-render.ts +44 -0
- package/src/server/operations/ops/nodes.ts +24 -2
- package/src/server/operations/ops/viewport.ts +8 -1
- package/src/server/operations/registry.ts +122 -1
- package/src/server/server.ts +58 -3
- package/src/server/workboard.ts +159 -0
- package/src/shared/ax-intent.ts +6 -0
- package/src/shared/ax-surface-protocol.ts +8 -0
- package/src/shared/surface.ts +2 -0
- package/src/shared/themes.ts +1 -1
|
@@ -105,6 +105,19 @@ function allowedIntentKinds(name: string, rawInput: unknown): readonly PmxAxInte
|
|
|
105
105
|
return INTENT_KINDS_BY_OPERATION[name];
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Ops exempt from auto-ghost SYNTHESIS only (explicit intent linking above is
|
|
110
|
+
* unaffected). Stream appends arrive in rapid succession — a fresh ghost per
|
|
111
|
+
* chunk is exactly the high-frequency churn class the batch exemption exists
|
|
112
|
+
* for; the stream's CREATING call (no nodeId yet) still ghosts once.
|
|
113
|
+
*/
|
|
114
|
+
function autoGhostExempt(name: string, rawInput: unknown): boolean {
|
|
115
|
+
if (name !== 'jsonrender.stream') return false;
|
|
116
|
+
const input =
|
|
117
|
+
rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? (rawInput as Record<string, unknown>) : {};
|
|
118
|
+
return typeof input.nodeId === 'string' && input.nodeId.length > 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
108
121
|
function settledNodeId(result: unknown, intent: PmxAxIntent): string | undefined {
|
|
109
122
|
if (intent.kind === 'connect' || intent.kind === 'remove') return undefined;
|
|
110
123
|
if (!result || typeof result !== 'object' || Array.isArray(result)) return undefined;
|
|
@@ -118,7 +131,75 @@ function settledNodeId(result: unknown, intent: PmxAxIntent): string | undefined
|
|
|
118
131
|
return typeof record.id === 'string' ? record.id : undefined;
|
|
119
132
|
}
|
|
120
133
|
|
|
121
|
-
|
|
134
|
+
/** Verb for the synthesized auto-ghost chip, per intent kind. */
|
|
135
|
+
const AUTO_GHOST_VERBS: Record<string, string> = {
|
|
136
|
+
create: 'Adding',
|
|
137
|
+
move: 'Moving',
|
|
138
|
+
edit: 'Updating',
|
|
139
|
+
connect: 'Connecting',
|
|
140
|
+
remove: 'Removing',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const AUTO_GHOST_EDGE_TYPES = new Set(['flow', 'depends-on', 'relation', 'references']);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build a valid signal input for a synthesized ghost, or null when the raw
|
|
147
|
+
* input can't satisfy the kind's required fields (e.g. a create with server
|
|
148
|
+
* auto-placement has no position to ghost at) — in that case the mutation
|
|
149
|
+
* simply runs unghosted.
|
|
150
|
+
*/
|
|
151
|
+
function autoGhostInput(name: string, rawInput: unknown, kind: string): Record<string, unknown> | null {
|
|
152
|
+
const body =
|
|
153
|
+
rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? (rawInput as Record<string, unknown>) : {};
|
|
154
|
+
const subject =
|
|
155
|
+
typeof body.title === 'string' && body.title.trim()
|
|
156
|
+
? body.title.trim().slice(0, 80)
|
|
157
|
+
: typeof body.type === 'string'
|
|
158
|
+
? `${body.type} node`
|
|
159
|
+
: typeof body.id === 'string'
|
|
160
|
+
? body.id
|
|
161
|
+
: name.split('.')[0];
|
|
162
|
+
const base = {
|
|
163
|
+
kind,
|
|
164
|
+
label: `${AUTO_GHOST_VERBS[kind] ?? 'Changing'} ${subject}`,
|
|
165
|
+
...(typeof body.type === 'string' ? { nodeType: body.type } : {}),
|
|
166
|
+
source: 'auto',
|
|
167
|
+
auto: true,
|
|
168
|
+
ttlMs: 6000,
|
|
169
|
+
};
|
|
170
|
+
const position = typeof body.x === 'number' && typeof body.y === 'number' ? { x: body.x, y: body.y } : undefined;
|
|
171
|
+
switch (kind) {
|
|
172
|
+
case 'create':
|
|
173
|
+
return position ? { ...base, position } : null;
|
|
174
|
+
case 'move':
|
|
175
|
+
return typeof body.id === 'string' && position ? { ...base, nodeId: body.id, position } : null;
|
|
176
|
+
case 'edit':
|
|
177
|
+
case 'remove':
|
|
178
|
+
return typeof body.id === 'string' ? { ...base, nodeId: body.id } : null;
|
|
179
|
+
case 'connect': {
|
|
180
|
+
if (typeof body.from !== 'string' || typeof body.to !== 'string') return null;
|
|
181
|
+
const edgeType = typeof body.type === 'string' && AUTO_GHOST_EDGE_TYPES.has(body.type) ? body.type : 'flow';
|
|
182
|
+
return { ...base, nodeType: undefined, edge: { from: body.from, to: body.to, type: edgeType } };
|
|
183
|
+
}
|
|
184
|
+
default:
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface ExecuteOperationMeta {
|
|
190
|
+
/**
|
|
191
|
+
* Skip the synthesized auto-ghost: set by the workbench's own HTTP calls
|
|
192
|
+
* (a human dragging a node is not agent activity) and by canvas.batch for
|
|
193
|
+
* its inner dispatches (batch churn is exempt, matching the skill contract).
|
|
194
|
+
*/
|
|
195
|
+
suppressAutoGhost?: boolean;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function executeOperation(
|
|
199
|
+
name: string,
|
|
200
|
+
rawInput: unknown,
|
|
201
|
+
meta: ExecuteOperationMeta = {},
|
|
202
|
+
): Promise<unknown> {
|
|
122
203
|
const op = getOperation(name);
|
|
123
204
|
const intentId = linkedIntentId(rawInput);
|
|
124
205
|
const allowedKinds = intentId ? allowedIntentKinds(name, rawInput) : undefined;
|
|
@@ -140,6 +221,46 @@ export async function executeOperation(name: string, rawInput: unknown): Promise
|
|
|
140
221
|
);
|
|
141
222
|
}
|
|
142
223
|
|
|
224
|
+
// Auto-ghost: an agent-originated visible mutation with NO explicit intent
|
|
225
|
+
// still shows its move on the canvas — a server-synthesized ghost that
|
|
226
|
+
// settles the moment the mutation lands (the client's minimum-dwell keeps
|
|
227
|
+
// the flash perceptible). Explicit canvas_intent signalling stays the
|
|
228
|
+
// richer path (labels, reasons, a real veto window before the mutation).
|
|
229
|
+
const autoKinds = allowedIntentKinds(name, rawInput);
|
|
230
|
+
if (
|
|
231
|
+
autoKinds &&
|
|
232
|
+
!meta.suppressAutoGhost &&
|
|
233
|
+
!autoGhostExempt(name, rawInput) &&
|
|
234
|
+
process.env.PMX_CANVAS_AUTO_INTENT !== '0'
|
|
235
|
+
) {
|
|
236
|
+
// Ghost synthesis must NEVER break the mutation: skip silently when the
|
|
237
|
+
// input can't satisfy a kind's required fields or signalling throws.
|
|
238
|
+
let ghost: PmxAxIntent | null = null;
|
|
239
|
+
try {
|
|
240
|
+
for (const kind of autoKinds) {
|
|
241
|
+
const input = autoGhostInput(name, rawInput, kind);
|
|
242
|
+
if (input) {
|
|
243
|
+
ghost = intentRegistry.signal(input);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} catch {
|
|
248
|
+
ghost = null;
|
|
249
|
+
}
|
|
250
|
+
if (ghost) {
|
|
251
|
+
const ghostId = ghost.id;
|
|
252
|
+
try {
|
|
253
|
+
const result = await op.execute(rawInput, operationContext);
|
|
254
|
+
if (op.mutates) emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
255
|
+
intentRegistry.clear(ghostId, { settledNodeId: settledNodeId(result, ghost) ?? undefined });
|
|
256
|
+
return result;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
intentRegistry.clear(ghostId);
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
143
264
|
const result = await op.execute(rawInput, operationContext);
|
|
144
265
|
if (op.mutates) emitOperationEvent('canvas-layout-update', { layout: canvasState.getLayout() });
|
|
145
266
|
return result;
|
package/src/server/server.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
buildAxStateBridge,
|
|
48
48
|
buildContentHeightReporter,
|
|
49
49
|
buildHtmlSurfaceDocument,
|
|
50
|
+
buildMermaidSurfaceHtml,
|
|
50
51
|
HTML_SURFACE_SANDBOX,
|
|
51
52
|
normalizeSurfaceTheme,
|
|
52
53
|
} from './html-surface.js';
|
|
@@ -455,7 +456,7 @@ export async function startCanvasAutomationWebView(
|
|
|
455
456
|
return runCanvasAutomationWebViewTask(async () => {
|
|
456
457
|
const WebView = getCanvasWebViewConstructor();
|
|
457
458
|
if (!WebView) {
|
|
458
|
-
const message =
|
|
459
|
+
const message = `Bun.WebView is not available in this Bun runtime (Bun ${Bun.version}). Bun >=1.3.12 is required — the canvas itself keeps working; only WebView automation/screenshots are unavailable.`;
|
|
459
460
|
canvasAutomationWebViewStatus = {
|
|
460
461
|
...canvasAutomationWebViewStatus,
|
|
461
462
|
lastError: message,
|
|
@@ -1004,6 +1005,7 @@ function canvasSpaHtml(): string {
|
|
|
1004
1005
|
// (see src/client/state/iframe-mode.ts).
|
|
1005
1006
|
process.env.AMP_ORB ? '<script>window.__PMX_AMP_ORB = true;</script>' : ''
|
|
1006
1007
|
}
|
|
1008
|
+
<script>window.__PMX_BOOT_SERVER_VERSION = ${JSON.stringify(serverPackageVersion())};</script>
|
|
1007
1009
|
<script type="module" src="/canvas/index.js?v=${CANVAS_ASSET_VERSION}"></script>
|
|
1008
1010
|
</body>
|
|
1009
1011
|
</html>`;
|
|
@@ -1271,6 +1273,28 @@ function handleNodeSurface(pathname: string, url: URL): Response {
|
|
|
1271
1273
|
|
|
1272
1274
|
const theme = normalizeSurfaceTheme(url.searchParams.get('theme'));
|
|
1273
1275
|
|
|
1276
|
+
if (node.type === 'mermaid') {
|
|
1277
|
+
const source = typeof node.data.content === 'string' ? node.data.content : '';
|
|
1278
|
+
if (!source.trim()) return responseText('Mermaid node has no content', 404);
|
|
1279
|
+
const surfaceTitle = typeof node.data.title === 'string' && node.data.title.trim() ? node.data.title : node.id;
|
|
1280
|
+
// Display-only surface: same theme/height plumbing as html nodes, but no AX
|
|
1281
|
+
// bridge and no presentation mode — the document is our own wrapper around
|
|
1282
|
+
// escaped diagram source, rendered by /canvas/mermaid-entry.js.
|
|
1283
|
+
const doc = buildHtmlSurfaceDocument(buildMermaidSurfaceHtml(source), {
|
|
1284
|
+
theme,
|
|
1285
|
+
title: surfaceTitle,
|
|
1286
|
+
themeToken: url.searchParams.get('themeToken') ?? undefined,
|
|
1287
|
+
// Content-height reporter nonce (lets the node grow to fit the diagram).
|
|
1288
|
+
...(url.searchParams.get('frameToken')
|
|
1289
|
+
? { contentHeightToken: url.searchParams.get('frameToken') as string }
|
|
1290
|
+
: {}),
|
|
1291
|
+
// Inline the theme tokens so srcdoc-rendered surfaces (Amp orb portals)
|
|
1292
|
+
// are styled without depending on the <link> subresource.
|
|
1293
|
+
...(surfaceThemeCssInline() ? { inlineThemeCss: surfaceThemeCssInline() as string } : {}),
|
|
1294
|
+
});
|
|
1295
|
+
return surfaceHtmlResponse(doc, HTML_SURFACE_SANDBOX);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1274
1298
|
if (node.type === 'html') {
|
|
1275
1299
|
const html =
|
|
1276
1300
|
typeof node.data.html === 'string'
|
|
@@ -1449,6 +1473,23 @@ function responseJson(data: unknown, status = 200): Response {
|
|
|
1449
1473
|
});
|
|
1450
1474
|
}
|
|
1451
1475
|
|
|
1476
|
+
// Resolved from the sibling package.json so it stays accurate through bunx,
|
|
1477
|
+
// global npm installs, and repo-local runs — same mechanism as --version in
|
|
1478
|
+
// src/cli/index.ts and the MCP server. Memoized: /health is polled.
|
|
1479
|
+
let cachedPackageVersion: string | null = null;
|
|
1480
|
+
function serverPackageVersion(): string {
|
|
1481
|
+
if (cachedPackageVersion !== null) return cachedPackageVersion;
|
|
1482
|
+
try {
|
|
1483
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf-8')) as {
|
|
1484
|
+
version?: string;
|
|
1485
|
+
};
|
|
1486
|
+
cachedPackageVersion = pkg.version ?? 'unknown';
|
|
1487
|
+
} catch {
|
|
1488
|
+
cachedPackageVersion = 'unknown';
|
|
1489
|
+
}
|
|
1490
|
+
return cachedPackageVersion;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1452
1493
|
// v0.4.0 (M4): every hand-written error response is a JSON envelope
|
|
1453
1494
|
// ({ ok: false, error }, same status codes) matching the registry dispatcher —
|
|
1454
1495
|
// consumers no longer need to branch on text/plain vs JSON error bodies.
|
|
@@ -1799,6 +1840,9 @@ function buildWorkbenchConnectSnapshot(
|
|
|
1799
1840
|
continuity,
|
|
1800
1841
|
path: primaryWorkbenchPath,
|
|
1801
1842
|
theme: canvasState.theme,
|
|
1843
|
+
// Lets a long-lived client detect that the server was upgraded under it
|
|
1844
|
+
// (stale in-memory SPA — 0.4.5 report Finding W) and reload once.
|
|
1845
|
+
version: serverPackageVersion(),
|
|
1802
1846
|
timestamp: new Date().toISOString(),
|
|
1803
1847
|
},
|
|
1804
1848
|
});
|
|
@@ -2999,7 +3043,15 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
2999
3043
|
// M11: PMX_WEB_CANVAS_PORT (server-specific) wins, then PMX_CANVAS_PORT — so
|
|
3000
3044
|
// setting only the client var no longer splits the client and server ports.
|
|
3001
3045
|
const preferredPort =
|
|
3002
|
-
options.port ??
|
|
3046
|
+
options.port ??
|
|
3047
|
+
Number(
|
|
3048
|
+
process.env.PMX_WEB_CANVAS_PORT ??
|
|
3049
|
+
process.env.PMX_CANVAS_PORT ??
|
|
3050
|
+
// Amp orbs assign the portal port via $PORT (gated on AMP_ORB so a
|
|
3051
|
+
// stray PORT in normal shells never shifts the default).
|
|
3052
|
+
(process.env.AMP_ORB ? process.env.PORT || undefined : undefined) ??
|
|
3053
|
+
DEFAULT_PORT,
|
|
3054
|
+
);
|
|
3003
3055
|
const portCandidates =
|
|
3004
3056
|
options.port === 0
|
|
3005
3057
|
? [0]
|
|
@@ -3030,11 +3082,14 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3030
3082
|
// when the pid file is stale (e.g. an adapter respawned the server
|
|
3031
3083
|
// outside `serve --daemon` — 0.3.2 report Finding P). `persistence`
|
|
3032
3084
|
// surfaces silent save failures (M4): the canvas keeps serving from
|
|
3033
|
-
// memory, but ok:false means durability is degraded.
|
|
3085
|
+
// memory, but ok:false means durability is degraded. `version` lets
|
|
3086
|
+
// `pmx-canvas smoke` verify the RUNNING server, not just the CLI —
|
|
3087
|
+
// version skew between the two is a known failure mode.
|
|
3034
3088
|
return responseJson({
|
|
3035
3089
|
ok: true,
|
|
3036
3090
|
workspace: activeWorkspaceRoot,
|
|
3037
3091
|
pid: process.pid,
|
|
3092
|
+
version: serverPackageVersion(),
|
|
3038
3093
|
persistence: canvasState.persistenceHealth,
|
|
3039
3094
|
});
|
|
3040
3095
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live AX work-item board, materialized as a json-render node.
|
|
3
|
+
*
|
|
4
|
+
* `buildWorkboardSpec` is the pure spec builder (status columns of work-item
|
|
5
|
+
* cards); `renderWorkboard` is the operation core (create-or-rebuild the
|
|
6
|
+
* single `data.workboard === true` json-render node); `refreshWorkboardNodes`
|
|
7
|
+
* is the live-refresh hook this module registers on `canvasState` so every
|
|
8
|
+
* addWorkItem/updateWorkItem rebuilds the board from the fresh work-item list
|
|
9
|
+
* through the SAME spec/update path the operation uses.
|
|
10
|
+
*
|
|
11
|
+
* The refresh runs inside `withSuppressedRecording` so live board rebuilds do
|
|
12
|
+
* not spam undo/redo history (persistence and SSE emits are unaffected).
|
|
13
|
+
*/
|
|
14
|
+
import type { JsonRenderSpec } from '../json-render/server.js';
|
|
15
|
+
import type { PmxAxWorkItem, PmxAxWorkItemStatus } from './ax-state.js';
|
|
16
|
+
import { buildJsonRenderNodeUpdate, createCanvasJsonRenderNode, emitCanvasLayoutUpdate } from './canvas-operations.js';
|
|
17
|
+
import { canvasState, type CanvasNodeState } from './canvas-state.js';
|
|
18
|
+
|
|
19
|
+
export const WORKBOARD_NODE_TITLE = 'Work Board';
|
|
20
|
+
export const WORKBOARD_NODE_SIZE = { width: 960, height: 560 };
|
|
21
|
+
|
|
22
|
+
const WORKBOARD_STATUS_ORDER: PmxAxWorkItemStatus[] = ['todo', 'in-progress', 'blocked', 'done', 'cancelled'];
|
|
23
|
+
|
|
24
|
+
const WORKBOARD_STATUS_LABELS: Record<PmxAxWorkItemStatus, string> = {
|
|
25
|
+
todo: 'To Do',
|
|
26
|
+
'in-progress': 'In Progress',
|
|
27
|
+
blocked: 'Blocked',
|
|
28
|
+
done: 'Done',
|
|
29
|
+
cancelled: 'Cancelled',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build the workboard json-render spec from a work-item list: one column per
|
|
34
|
+
* status (todo → in-progress → blocked → done → cancelled; empty statuses are
|
|
35
|
+
* omitted), each item a Card with an agentId Badge and detail Text when
|
|
36
|
+
* present. An empty list renders a single muted "No work items" text block.
|
|
37
|
+
*/
|
|
38
|
+
export function buildWorkboardSpec(workItems: PmxAxWorkItem[]): JsonRenderSpec {
|
|
39
|
+
if (workItems.length === 0) {
|
|
40
|
+
return {
|
|
41
|
+
root: 'empty',
|
|
42
|
+
elements: {
|
|
43
|
+
empty: { type: 'Text', props: { text: 'No work items', variant: 'muted' }, children: [] },
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const elements: Record<string, unknown> = {};
|
|
49
|
+
const columnIds: string[] = [];
|
|
50
|
+
for (const status of WORKBOARD_STATUS_ORDER) {
|
|
51
|
+
const items = workItems.filter((item) => item.status === status);
|
|
52
|
+
if (items.length === 0) continue;
|
|
53
|
+
const columnId = `col-${status}`;
|
|
54
|
+
const headerId = `${columnId}-header`;
|
|
55
|
+
elements[headerId] = {
|
|
56
|
+
type: 'Heading',
|
|
57
|
+
props: { level: 'h4', text: `${WORKBOARD_STATUS_LABELS[status]} (${items.length})` },
|
|
58
|
+
children: [],
|
|
59
|
+
};
|
|
60
|
+
const columnChildren = [headerId];
|
|
61
|
+
for (const item of items) {
|
|
62
|
+
const cardId = `item-${item.id}`;
|
|
63
|
+
const cardChildren: string[] = [];
|
|
64
|
+
if (item.agentId) {
|
|
65
|
+
elements[`${cardId}-agent`] = {
|
|
66
|
+
type: 'Badge',
|
|
67
|
+
props: { text: item.agentId, variant: 'secondary' },
|
|
68
|
+
children: [],
|
|
69
|
+
};
|
|
70
|
+
cardChildren.push(`${cardId}-agent`);
|
|
71
|
+
}
|
|
72
|
+
if (item.detail) {
|
|
73
|
+
elements[`${cardId}-detail`] = {
|
|
74
|
+
type: 'Text',
|
|
75
|
+
props: { text: item.detail, variant: 'muted' },
|
|
76
|
+
children: [],
|
|
77
|
+
};
|
|
78
|
+
cardChildren.push(`${cardId}-detail`);
|
|
79
|
+
}
|
|
80
|
+
elements[cardId] = { type: 'Card', props: { title: item.title }, children: cardChildren };
|
|
81
|
+
columnChildren.push(cardId);
|
|
82
|
+
}
|
|
83
|
+
elements[columnId] = {
|
|
84
|
+
type: 'Stack',
|
|
85
|
+
props: { direction: 'vertical', gap: 'sm' },
|
|
86
|
+
children: columnChildren,
|
|
87
|
+
};
|
|
88
|
+
columnIds.push(columnId);
|
|
89
|
+
}
|
|
90
|
+
elements.board = {
|
|
91
|
+
type: 'Stack',
|
|
92
|
+
props: { direction: 'horizontal', gap: 'md', align: 'start' },
|
|
93
|
+
children: columnIds,
|
|
94
|
+
};
|
|
95
|
+
return { root: 'board', elements };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function findWorkboardNodes(): CanvasNodeState[] {
|
|
99
|
+
return canvasState.getLayout().nodes.filter((node) => node.type === 'json-render' && node.data.workboard === true);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Rebuild one workboard node's spec in place (shared by the op and the live refresh). */
|
|
103
|
+
function applyWorkboardSpec(node: CanvasNodeState, spec: JsonRenderSpec): void {
|
|
104
|
+
const title = typeof node.data.title === 'string' && node.data.title ? node.data.title : WORKBOARD_NODE_TITLE;
|
|
105
|
+
const update = buildJsonRenderNodeUpdate(node, { title, spec });
|
|
106
|
+
canvasState.updateNode(node.id, { data: update.data });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Operation core for `render.workboard`: rebuild the existing workboard node
|
|
111
|
+
* in place, or create one (tagged `data.workboard: true`) when none exists.
|
|
112
|
+
*/
|
|
113
|
+
export function renderWorkboard(input: { x?: number; y?: number } = {}): {
|
|
114
|
+
ok: true;
|
|
115
|
+
id: string;
|
|
116
|
+
created: boolean;
|
|
117
|
+
itemCount: number;
|
|
118
|
+
} {
|
|
119
|
+
const workItems = canvasState.getWorkItems();
|
|
120
|
+
const spec = buildWorkboardSpec(workItems);
|
|
121
|
+
const existing = findWorkboardNodes()[0];
|
|
122
|
+
if (existing) {
|
|
123
|
+
applyWorkboardSpec(existing, spec);
|
|
124
|
+
return { ok: true, id: existing.id, created: false, itemCount: workItems.length };
|
|
125
|
+
}
|
|
126
|
+
const created = createCanvasJsonRenderNode({
|
|
127
|
+
title: WORKBOARD_NODE_TITLE,
|
|
128
|
+
spec,
|
|
129
|
+
width: WORKBOARD_NODE_SIZE.width,
|
|
130
|
+
height: WORKBOARD_NODE_SIZE.height,
|
|
131
|
+
...(input.x !== undefined ? { x: input.x } : {}),
|
|
132
|
+
...(input.y !== undefined ? { y: input.y } : {}),
|
|
133
|
+
});
|
|
134
|
+
// Tag the node as THE workboard. Suppressed: the tag is part of the single
|
|
135
|
+
// logical "create workboard" mutation, not a second undo step.
|
|
136
|
+
canvasState.withSuppressedRecording(() => {
|
|
137
|
+
canvasState.updateNode(created.id, { data: { ...created.node.data, workboard: true } });
|
|
138
|
+
});
|
|
139
|
+
return { ok: true, id: created.id, created: true, itemCount: workItems.length };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Live refresh: rebuild every workboard node's spec from the current work-item
|
|
144
|
+
* list. Registered below as the canvasState work-item change listener. Node
|
|
145
|
+
* updates never touch work items, so this cannot recurse.
|
|
146
|
+
*/
|
|
147
|
+
export function refreshWorkboardNodes(): void {
|
|
148
|
+
const nodes = findWorkboardNodes();
|
|
149
|
+
if (nodes.length === 0) return;
|
|
150
|
+
const spec = buildWorkboardSpec(canvasState.getWorkItems());
|
|
151
|
+
canvasState.withSuppressedRecording(() => {
|
|
152
|
+
for (const node of nodes) {
|
|
153
|
+
applyWorkboardSpec(node, spec);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
emitCanvasLayoutUpdate();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
canvasState.setWorkItemsChangedListener(refreshWorkboardNodes);
|
package/src/shared/ax-intent.ts
CHANGED
|
@@ -44,6 +44,12 @@ export interface PmxAxIntent {
|
|
|
44
44
|
seq?: number;
|
|
45
45
|
/** Source label of the surface that signalled the intent (mcp/api/sdk/...). */
|
|
46
46
|
source?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Server-synthesized ghost for an agent mutation that arrived WITHOUT an
|
|
49
|
+
* explicit canvas_intent signal (auto-ghost). Rendered visually lighter than
|
|
50
|
+
* a deliberate signal, and not vetoable — it settles immediately.
|
|
51
|
+
*/
|
|
52
|
+
auto?: boolean;
|
|
47
53
|
/** Epoch ms when the intent was first signalled. */
|
|
48
54
|
createdAt: number;
|
|
49
55
|
/** Epoch ms when the intent auto-dissolves if not settled/cleared first. */
|
|
@@ -20,3 +20,11 @@ export const HTML_SURFACE_PUSH_SOURCE = 'pmx-canvas-html-node';
|
|
|
20
20
|
|
|
21
21
|
/** Ext-app boot beacon: the iframe's scripts executed (WebKit watchdog liveness). */
|
|
22
22
|
export const EXT_APP_BOOT_BEACON_SOURCE = 'pmx-canvas-ext-app-alive';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parent→ext-app paint probe (Finding N): the parent asks the app document to
|
|
26
|
+
* answer with a double-rAF `paint-tick` beacon. A frame whose rendering
|
|
27
|
+
* pipeline is live answers within a frame or two; silence past the timeout is
|
|
28
|
+
* the paint-fail signal that drives the recovery ladder.
|
|
29
|
+
*/
|
|
30
|
+
export const EXT_APP_PAINT_PROBE_SOURCE = 'pmx-canvas-ext-app-paint-probe';
|
package/src/shared/surface.ts
CHANGED
|
@@ -17,6 +17,8 @@ export function canOpenNodeAsSurface(type: string, data: Record<string, unknown>
|
|
|
17
17
|
(typeof data.html === 'string' && data.html.length > 0) ||
|
|
18
18
|
(typeof data.content === 'string' && data.content.length > 0)
|
|
19
19
|
);
|
|
20
|
+
case 'mermaid':
|
|
21
|
+
return typeof data.content === 'string' && data.content.trim().length > 0;
|
|
20
22
|
case 'json-render':
|
|
21
23
|
case 'graph':
|
|
22
24
|
return true;
|
package/src/shared/themes.ts
CHANGED
|
@@ -51,7 +51,7 @@ export const CANVAS_THEME_META: Record<CanvasThemeName, CanvasThemeMeta> = {
|
|
|
51
51
|
arctic: { label: 'Arctic', scheme: 'dark', swatchBg: '#20242E', swatchAccent: '#7FC8DE' },
|
|
52
52
|
ember: { label: 'Ember', scheme: 'dark', swatchBg: '#0B0B0C', swatchAccent: '#F34E1C' },
|
|
53
53
|
forest: { label: 'Forest', scheme: 'dark', swatchBg: '#0C1712', swatchAccent: '#5CCF8F' },
|
|
54
|
-
volt: { label: 'Volt', scheme: 'dark', swatchBg: '#0B0D0B', swatchAccent: '#
|
|
54
|
+
volt: { label: 'Volt', scheme: 'dark', swatchBg: '#0B0D0B', swatchAccent: '#DFDFC1' },
|
|
55
55
|
};
|
|
56
56
|
|
|
57
57
|
/** Collapse a theme to the dark/light scheme embedded dark|light-only viewers understand. */
|