pmx-canvas 0.4.5 → 0.4.7
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 +118 -0
- package/Readme.md +76 -36
- package/dist/canvas/global.css +169 -3
- package/dist/canvas/index.js +112 -87
- 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/cli/shared.d.ts +13 -1
- package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
- package/dist/types/client/mermaid-entry.d.ts +11 -0
- package/dist/types/client/nodes/AxStepControls.d.ts +4 -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/FileNode.d.ts +14 -0
- package/dist/types/client/nodes/MermaidNode.d.ts +12 -0
- package/dist/types/client/state/canvas-store.d.ts +10 -0
- package/dist/types/client/state/intent-bridge.d.ts +9 -0
- package/dist/types/client/types.d.ts +2 -2
- package/dist/types/server/ax-flow-loop.d.ts +5 -0
- package/dist/types/server/ax-interaction.d.ts +21 -2
- package/dist/types/server/canvas-operations.d.ts +5 -0
- package/dist/types/server/canvas-provenance.d.ts +1 -1
- package/dist/types/server/canvas-state.d.ts +18 -1
- package/dist/types/server/canvas-validation.d.ts +28 -1
- package/dist/types/server/file-content.d.ts +46 -0
- package/dist/types/server/html-primitives.d.ts +13 -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-work.d.ts +1 -0
- package/dist/types/server/operations/ops/nodes.d.ts +1 -1
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/web-artifacts.d.ts +14 -0
- package/dist/types/server/workboard.d.ts +46 -0
- package/dist/types/shared/ax-flow.d.ts +51 -0
- package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
- package/dist/types/shared/node-sizes.d.ts +33 -0
- package/docs/cli.md +31 -2
- package/docs/environment.md +1 -0
- package/docs/http-api.md +29 -0
- package/docs/mcp.md +13 -1
- package/docs/node-types.md +47 -1
- package/docs/screenshot.png +0 -0
- package/package.json +5 -4
- package/skills/pmx-canvas/SKILL.md +123 -25
- package/skills/pmx-canvas/evals/evals.json +54 -3
- package/skills/pmx-canvas/references/full-reference.md +11 -6
- package/skills/pmx-canvas-testing/SKILL.md +3 -0
- package/src/cli/agent.ts +2 -0
- package/src/cli/commands/ax.ts +28 -3
- package/src/cli/commands/history.ts +1 -1
- package/src/cli/commands/nodes.ts +2 -2
- package/src/cli/commands/query.ts +1 -1
- package/src/cli/commands/smoke.ts +212 -0
- package/src/cli/commands/view.ts +1 -1
- package/src/cli/index.ts +7 -0
- package/src/cli/shared.ts +35 -5
- package/src/client/App.tsx +28 -15
- package/src/client/canvas/CanvasNode.tsx +54 -5
- package/src/client/canvas/CanvasViewport.tsx +6 -0
- package/src/client/canvas/EdgeLayer.tsx +44 -24
- package/src/client/canvas/ExpandedNodeOverlay.tsx +21 -0
- package/src/client/canvas/Minimap.tsx +2 -0
- package/src/client/canvas/auto-fit.ts +12 -1
- package/src/client/canvas/use-pan-zoom.ts +16 -1
- package/src/client/icons.tsx +4 -0
- package/src/client/mermaid-entry.ts +76 -0
- package/src/client/nodes/AxStepControls.tsx +226 -0
- package/src/client/nodes/DiffNode.tsx +114 -0
- package/src/client/nodes/ExtAppFrame.tsx +259 -84
- package/src/client/nodes/FileNode.tsx +249 -51
- package/src/client/nodes/MermaidNode.tsx +81 -0
- package/src/client/state/canvas-store.ts +19 -0
- package/src/client/state/intent-bridge.ts +32 -0
- package/src/client/state/node-factory.ts +2 -0
- package/src/client/state/sse-bridge.ts +32 -1
- package/src/client/theme/global.css +169 -3
- package/src/client/types.ts +6 -0
- package/src/mcp/server.ts +19 -1
- package/src/server/ax-flow-loop.ts +114 -0
- package/src/server/ax-interaction.ts +305 -4
- package/src/server/canvas-operations.ts +83 -26
- package/src/server/canvas-provenance.ts +2 -0
- package/src/server/canvas-schema.ts +33 -0
- package/src/server/canvas-state.ts +46 -10
- package/src/server/canvas-validation.ts +88 -0
- package/src/server/demo-state.json +4605 -523
- package/src/server/demo.ts +24 -0
- package/src/server/file-content.ts +152 -0
- package/src/server/file-watcher.ts +7 -10
- package/src/server/html-primitives.ts +636 -0
- package/src/server/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/ax-work.ts +5 -0
- 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 +24 -2
- package/src/server/operations/ops/viewport.ts +15 -2
- package/src/server/server.ts +154 -6
- package/src/server/web-artifacts.ts +109 -0
- package/src/server/workboard.ts +159 -0
- package/src/shared/ax-flow.ts +102 -0
- package/src/shared/ax-surface-protocol.ts +8 -0
- package/src/shared/node-sizes.ts +52 -0
- package/src/shared/surface.ts +2 -0
- package/dist/types/server/chart-template.d.ts +0 -29
- package/src/server/chart-template.ts +0 -448
|
@@ -27,6 +27,19 @@ const LEGACY_SKILL_WEB_ARTIFACT_SCRIPTS_DIR = join(
|
|
|
27
27
|
);
|
|
28
28
|
const DEFAULT_TIMEOUT_MS = 10 * 60_000;
|
|
29
29
|
const DEFAULT_PACKAGE_MANAGER = 'pnpm@10.33.0';
|
|
30
|
+
const NODE_VERSION_PROBE_TIMEOUT_MS = 15_000;
|
|
31
|
+
/**
|
|
32
|
+
* Minimum Node.js (`major.minor`) each pnpm major declares in its published
|
|
33
|
+
* `engines.node` — pnpm 10.x is `>=18.12`, pnpm 11.x is `>=22.13`. The pnpm that
|
|
34
|
+
* runs a build is not a local dependency (the scripts take it from PATH, `bun x`,
|
|
35
|
+
* or a global install), so its engines field cannot be read from disk; this table
|
|
36
|
+
* carries the same constraint keyed by the pinned major. An unlisted major
|
|
37
|
+
* imposes no preflight floor rather than an invented one.
|
|
38
|
+
*/
|
|
39
|
+
const PNPM_MAJOR_MIN_NODE: Record<string, string> = {
|
|
40
|
+
'10': '18.12',
|
|
41
|
+
'11': '22.13',
|
|
42
|
+
};
|
|
30
43
|
const DEFAULT_WEB_ARTIFACT_NODE_SIZE = { width: 960, height: 720 };
|
|
31
44
|
const FALLBACK_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'];
|
|
32
45
|
const WEB_ARTIFACT_CONTEXT_MAX_LENGTH = 1200;
|
|
@@ -470,6 +483,101 @@ function summarizeArtifactLog(text: string): WebArtifactLogSummary | undefined {
|
|
|
470
483
|
};
|
|
471
484
|
}
|
|
472
485
|
|
|
486
|
+
export type NodeVersionCheck = { ok: true } | { ok: false; message: string };
|
|
487
|
+
|
|
488
|
+
function parseMajorMinor(value: string): { major: number; minor: number } | null {
|
|
489
|
+
const match = /^v?(\d+)\.(\d+)/.exec(value.trim());
|
|
490
|
+
if (!match) return null;
|
|
491
|
+
return { major: Number(match[1]), minor: Number(match[2]) };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Pure preflight comparison used before any package-manager invocation.
|
|
496
|
+
* `detected` is raw `node --version` output ("v20.11.1"), or null when no node
|
|
497
|
+
* executable answered; `required` is a `major.minor` floor; `packageManager` is
|
|
498
|
+
* the pin that imposes it (e.g. "pnpm@11.1.2"). Returns an actionable message
|
|
499
|
+
* instead of throwing so every caller surfaces it on its own error channel.
|
|
500
|
+
*/
|
|
501
|
+
export function checkWebArtifactNodeVersion(
|
|
502
|
+
detected: string | null,
|
|
503
|
+
required: string,
|
|
504
|
+
packageManager: string,
|
|
505
|
+
): NodeVersionCheck {
|
|
506
|
+
const requiredParts = parseMajorMinor(required);
|
|
507
|
+
if (!requiredParts) return { ok: true };
|
|
508
|
+
|
|
509
|
+
const detectedLabel = detected === null ? null : detected.trim();
|
|
510
|
+
const detectedParts = detectedLabel === null ? null : parseMajorMinor(detectedLabel);
|
|
511
|
+
if (
|
|
512
|
+
detectedParts &&
|
|
513
|
+
(detectedParts.major > requiredParts.major ||
|
|
514
|
+
(detectedParts.major === requiredParts.major && detectedParts.minor >= requiredParts.minor))
|
|
515
|
+
) {
|
|
516
|
+
return { ok: true };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const found =
|
|
520
|
+
detectedLabel && detectedParts
|
|
521
|
+
? `the build environment has Node.js ${detectedLabel}`
|
|
522
|
+
: 'no usable `node` executable was found on the build PATH';
|
|
523
|
+
return {
|
|
524
|
+
ok: false,
|
|
525
|
+
message:
|
|
526
|
+
`Web-artifact build preflight failed: ${packageManager} requires Node.js >= ${required}, but ${found}. ` +
|
|
527
|
+
`Run PMX Canvas under Node.js ${required} or newer, then retry the build.`,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** `node --version` as the build subprocesses see it, or null when node is unavailable. */
|
|
532
|
+
async function detectSubprocessNodeVersion(cwd: string): Promise<string | null> {
|
|
533
|
+
try {
|
|
534
|
+
const { stdout } = await runProcess('node', ['--version'], { cwd, timeoutMs: NODE_VERSION_PROBE_TIMEOUT_MS });
|
|
535
|
+
return stdout.trim() || null;
|
|
536
|
+
} catch {
|
|
537
|
+
// A missing or non-runnable node IS the preflight failure — reported below
|
|
538
|
+
// with the required version, not rethrown as a spawn error here.
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* The `packageManager` pin that governs this build: a reused artifact project
|
|
545
|
+
* carries its own pin, a fresh one gets the bundled default that init-artifact.sh
|
|
546
|
+
* and ensurePackageManagerBoundary write.
|
|
547
|
+
*/
|
|
548
|
+
function resolvePinnedPackageManager(projectPath: string): string {
|
|
549
|
+
const packageJsonPath = join(projectPath, 'package.json');
|
|
550
|
+
if (!existsSync(packageJsonPath)) return DEFAULT_PACKAGE_MANAGER;
|
|
551
|
+
try {
|
|
552
|
+
const parsed: unknown = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
553
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
554
|
+
const pinned = (parsed as Record<string, unknown>).packageManager;
|
|
555
|
+
if (typeof pinned === 'string' && pinned.includes('@')) return pinned;
|
|
556
|
+
}
|
|
557
|
+
} catch {
|
|
558
|
+
// A corrupt project package.json is the init script's problem; the preflight
|
|
559
|
+
// falls back to the pin PMX Canvas would write anyway.
|
|
560
|
+
}
|
|
561
|
+
return DEFAULT_PACKAGE_MANAGER;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Fail fast when the Node.js available to the build subprocesses is older than
|
|
566
|
+
* the pinned package manager supports. Without this, pnpm's engine error lands
|
|
567
|
+
* deep inside the init/bundle script output and reads as an unrelated build
|
|
568
|
+
* failure (reported: pnpm 11 needs Node 22.13+, the build ran on Node 20).
|
|
569
|
+
*/
|
|
570
|
+
async function preflightBuildRuntime(cwd: string, projectPath: string): Promise<void> {
|
|
571
|
+
const packageManager = resolvePinnedPackageManager(projectPath);
|
|
572
|
+
const [name, version] = packageManager.split('@');
|
|
573
|
+
if (name !== 'pnpm') return;
|
|
574
|
+
const required = PNPM_MAJOR_MIN_NODE[version.split('.')[0]];
|
|
575
|
+
if (!required) return;
|
|
576
|
+
|
|
577
|
+
const check = checkWebArtifactNodeVersion(await detectSubprocessNodeVersion(cwd), required, packageManager);
|
|
578
|
+
if (!check.ok) throw new Error(check.message);
|
|
579
|
+
}
|
|
580
|
+
|
|
473
581
|
export async function executeWebArtifactBuild(input: WebArtifactBuildInput): Promise<WebArtifactBuildOutput> {
|
|
474
582
|
const workspaceRoot = currentWorkspaceRoot();
|
|
475
583
|
const artifactsDir = ensureArtifactsDir(workspaceRoot);
|
|
@@ -490,6 +598,7 @@ export async function executeWebArtifactBuild(input: WebArtifactBuildInput): Pro
|
|
|
490
598
|
const parentDir = dirname(projectPath);
|
|
491
599
|
mkdirSync(parentDir, { recursive: true });
|
|
492
600
|
ensurePackageManagerBoundary(parentDir);
|
|
601
|
+
await preflightBuildRuntime(parentDir, projectPath);
|
|
493
602
|
|
|
494
603
|
let stdout = '';
|
|
495
604
|
let stderr = '';
|
|
@@ -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);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The node-data contract for a materialized AX flow.
|
|
3
|
+
*
|
|
4
|
+
* `ax.flow.materialize` stamps every step node with `data.axStep` and the anchor
|
|
5
|
+
* (first) step with `data.axFlow`. Three consumers read those stamps and none of
|
|
6
|
+
* them owns the shape, so it lives here:
|
|
7
|
+
* - the server loop (`ax-flow-loop.ts`) advances the flow from work-item changes
|
|
8
|
+
* - the browser renders native Start/Done/Blocked + Run loop/Stop controls
|
|
9
|
+
* - snapshots/restore carry the stamps as ordinary node data
|
|
10
|
+
*
|
|
11
|
+
* Node data is `Record<string, unknown>` on both sides, so the readers below are
|
|
12
|
+
* the ONLY way to narrow it: they validate rather than cast, because a stamp can
|
|
13
|
+
* also arrive from a restored snapshot or a hand-edited node.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Hard ceiling on loop passes, whatever a node's stamped `maxRuns` claims. */
|
|
17
|
+
export const AX_FLOW_LOOP_HARD_CAP = 20;
|
|
18
|
+
|
|
19
|
+
/** One step of a flow, as recorded on the anchor node. */
|
|
20
|
+
export interface AxFlowStepRef {
|
|
21
|
+
/** 1-based position in the flow. */
|
|
22
|
+
index: number;
|
|
23
|
+
nodeId: string;
|
|
24
|
+
workItemId: string;
|
|
25
|
+
title: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AxFlowLoopState {
|
|
29
|
+
/** The loop only advances while this is true; Stop persists `false`. */
|
|
30
|
+
running: boolean;
|
|
31
|
+
/** Completed passes. `run >= maxRuns` ends the loop. */
|
|
32
|
+
run: number;
|
|
33
|
+
maxRuns: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `data.axStep` — stamped on EVERY step node, including the anchor. */
|
|
37
|
+
export interface AxFlowStepStamp {
|
|
38
|
+
flowId: string;
|
|
39
|
+
index: number;
|
|
40
|
+
total: number;
|
|
41
|
+
workItemId: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `data.axFlow` — stamped on the anchor (first) step node only. */
|
|
45
|
+
export interface AxFlowStamp {
|
|
46
|
+
flowId: string;
|
|
47
|
+
title: string;
|
|
48
|
+
steps: AxFlowStepRef[];
|
|
49
|
+
loop: AxFlowLoopState;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Clamp a stamped `maxRuns` into [1, AX_FLOW_LOOP_HARD_CAP]. */
|
|
53
|
+
export function clampAxFlowMaxRuns(value: unknown): number {
|
|
54
|
+
const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 1;
|
|
55
|
+
return Math.max(1, Math.min(AX_FLOW_LOOP_HARD_CAP, n));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
59
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function nonEmptyString(value: unknown): value is string {
|
|
63
|
+
return typeof value === 'string' && value.length > 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Read `data.axStep`, or null when the node carries no valid step stamp. */
|
|
67
|
+
export function readAxStep(data: Record<string, unknown>): AxFlowStepStamp | null {
|
|
68
|
+
const step = data.axStep;
|
|
69
|
+
if (!isRecord(step)) return null;
|
|
70
|
+
if (!nonEmptyString(step.flowId) || !nonEmptyString(step.workItemId)) return null;
|
|
71
|
+
if (typeof step.index !== 'number' || typeof step.total !== 'number') return null;
|
|
72
|
+
return { flowId: step.flowId, index: step.index, total: step.total, workItemId: step.workItemId };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Read `data.axFlow`, or null when the node is not a flow anchor. */
|
|
76
|
+
export function readAxFlow(data: Record<string, unknown>): AxFlowStamp | null {
|
|
77
|
+
const flow = data.axFlow;
|
|
78
|
+
if (!isRecord(flow) || !nonEmptyString(flow.flowId) || !Array.isArray(flow.steps)) return null;
|
|
79
|
+
const steps: AxFlowStepRef[] = [];
|
|
80
|
+
for (const raw of flow.steps) {
|
|
81
|
+
if (!isRecord(raw) || !nonEmptyString(raw.nodeId) || !nonEmptyString(raw.workItemId)) return null;
|
|
82
|
+
if (typeof raw.index !== 'number') return null;
|
|
83
|
+
steps.push({
|
|
84
|
+
index: raw.index,
|
|
85
|
+
nodeId: raw.nodeId,
|
|
86
|
+
workItemId: raw.workItemId,
|
|
87
|
+
title: typeof raw.title === 'string' ? raw.title : '',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (steps.length === 0) return null;
|
|
91
|
+
const loop = isRecord(flow.loop) ? flow.loop : {};
|
|
92
|
+
return {
|
|
93
|
+
flowId: flow.flowId,
|
|
94
|
+
title: typeof flow.title === 'string' ? flow.title : 'Flow',
|
|
95
|
+
steps,
|
|
96
|
+
loop: {
|
|
97
|
+
running: loop.running === true,
|
|
98
|
+
run: typeof loop.run === 'number' && Number.isFinite(loop.run) ? Math.max(0, Math.floor(loop.run)) : 0,
|
|
99
|
+
maxRuns: clampAxFlowMaxRuns(loop.maxRuns),
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -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';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-type minimum node size — the readability floor.
|
|
3
|
+
*
|
|
4
|
+
* Agents frequently create nodes with explicit frames far too small for their
|
|
5
|
+
* content (clipped markdown, charts squeezed behind inner scrollbars), so the
|
|
6
|
+
* server clamps undersized explicit sizes UP at creation and `validate` reports
|
|
7
|
+
* anything below the floor.
|
|
8
|
+
*
|
|
9
|
+
* This lives in `shared/` because BOTH sides must honor it. The 0.4.6 report's
|
|
10
|
+
* Finding AA: the server clamped a 200x100 markdown node up to 360x180, and
|
|
11
|
+
* then the browser's DOM auto-fit measured the (short) content and persisted
|
|
12
|
+
* 360x132 — undoing the guarantee on the most common path, a connected
|
|
13
|
+
* workbench. A floor only one side knows about is not a floor.
|
|
14
|
+
*
|
|
15
|
+
* `strictSize: true` is the escape hatch for a deliberately small fixed frame,
|
|
16
|
+
* and it exempts a node from both the creation clamp and the auto-fit floor.
|
|
17
|
+
* Types absent from the table are intentionally unclamped — `trace` is small by
|
|
18
|
+
* design, `group` sizes to its children.
|
|
19
|
+
*/
|
|
20
|
+
export const NODE_MIN_SIZES: Record<string, { width: number; height: number }> = {
|
|
21
|
+
markdown: { width: 360, height: 180 },
|
|
22
|
+
context: { width: 360, height: 180 },
|
|
23
|
+
file: { width: 360, height: 200 },
|
|
24
|
+
diff: { width: 420, height: 240 },
|
|
25
|
+
mermaid: { width: 360, height: 240 },
|
|
26
|
+
status: { width: 280, height: 120 },
|
|
27
|
+
ledger: { width: 320, height: 200 },
|
|
28
|
+
image: { width: 240, height: 180 },
|
|
29
|
+
html: { width: 420, height: 280 },
|
|
30
|
+
webpage: { width: 420, height: 280 },
|
|
31
|
+
'json-render': { width: 420, height: 280 },
|
|
32
|
+
graph: { width: 420, height: 280 },
|
|
33
|
+
'mcp-app': { width: 480, height: 320 },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The readability floor for a node type, or undefined when it has none. */
|
|
37
|
+
export function nodeMinSize(type: string): { width: number; height: number } | undefined {
|
|
38
|
+
return NODE_MIN_SIZES[type];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Creation-time clamp: an explicit size below the floor is raised to it. */
|
|
42
|
+
export function clampCreateNodeSize(
|
|
43
|
+
type: string,
|
|
44
|
+
width: number,
|
|
45
|
+
height: number,
|
|
46
|
+
strictSize?: boolean,
|
|
47
|
+
): { width: number; height: number } {
|
|
48
|
+
if (strictSize === true) return { width, height };
|
|
49
|
+
const min = nodeMinSize(type);
|
|
50
|
+
if (!min) return { width, height };
|
|
51
|
+
return { width: Math.max(width, min.width), height: Math.max(height, min.height) };
|
|
52
|
+
}
|
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;
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Chart HTML template generator — produces self-contained ext-app HTML
|
|
3
|
-
* documents that render interactive Chart.js charts inside the canvas
|
|
4
|
-
* ExtAppFrame iframe.
|
|
5
|
-
*
|
|
6
|
-
* The generated HTML:
|
|
7
|
-
* 1. Renders immediately from inline data (no bridge needed)
|
|
8
|
-
* 2. Connects to host AppBridge via the embedded ext-app App SDK runtime
|
|
9
|
-
* 3. Accepts updated data via toolInput for re-rendering
|
|
10
|
-
*/
|
|
11
|
-
export interface ChartDataset {
|
|
12
|
-
label: string;
|
|
13
|
-
values: number[];
|
|
14
|
-
color?: string;
|
|
15
|
-
}
|
|
16
|
-
export interface ChartConfig {
|
|
17
|
-
title: string;
|
|
18
|
-
chartType: 'bar' | 'line' | 'pie' | 'scatter' | 'doughnut' | 'radar';
|
|
19
|
-
labels: string[];
|
|
20
|
-
datasets: ChartDataset[];
|
|
21
|
-
xAxisLabel?: string;
|
|
22
|
-
yAxisLabel?: string;
|
|
23
|
-
stacked?: boolean;
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Generate a self-contained HTML document that renders a Chart.js chart
|
|
27
|
-
* and optionally connects to the host via the ext-app App SDK.
|
|
28
|
-
*/
|
|
29
|
-
export declare function generateChartHtml(config: ChartConfig): string;
|