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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Signal } from '@preact/signals';
|
|
2
|
-
import { activeNodeId, draggingEdge, searchHighlightIds } from '../state/canvas-store';
|
|
2
|
+
import { activeNodeId, draggingEdge, searchHighlightIds, viewport } from '../state/canvas-store';
|
|
3
3
|
import type { CanvasEdge, CanvasNodeState } from '../types';
|
|
4
4
|
|
|
5
5
|
// ── Edge type visual styles ──────────────────────────────────
|
|
@@ -7,15 +7,30 @@ const EDGE_COLORS: Record<CanvasEdge['type'], string> = {
|
|
|
7
7
|
relation: 'var(--c-muted)',
|
|
8
8
|
'depends-on': 'var(--c-warn)',
|
|
9
9
|
flow: 'var(--c-accent)',
|
|
10
|
-
|
|
10
|
+
// --c-dim is a very low-contrast hairline in dark palettes (forest: #5D7566
|
|
11
|
+
// on #0C1712); the dashed style is what distinguishes `references`.
|
|
12
|
+
references: 'var(--c-muted)',
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
const DIRECTED_TYPES = new Set<CanvasEdge['type']>(['depends-on', 'flow']);
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Edges are drawn in world space, so a 1.5px stroke renders as 0.4 screen px at
|
|
19
|
+
* 26% zoom. Full inverse compensation keeps edge chrome at a constant SCREEN
|
|
20
|
+
* size while zoomed out (standard graph-editor behaviour). Deliberately
|
|
21
|
+
* uncapped — the 2.2 cap used for node chrome still leaves hairlines invisible
|
|
22
|
+
* at overview zoom.
|
|
23
|
+
*/
|
|
24
|
+
export function edgeChromeScale(scale: number): number {
|
|
25
|
+
if (!Number.isFinite(scale) || scale <= 0) return 1;
|
|
26
|
+
return scale < 1 ? 1 / scale : 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function dashArray(edge: CanvasEdge, scale: number): string | undefined {
|
|
30
|
+
const dashed = `${8 * scale} ${4 * scale}`;
|
|
31
|
+
if (edge.style === 'dashed') return dashed;
|
|
32
|
+
if (edge.style === 'dotted') return `${3 * scale} ${3 * scale}`;
|
|
33
|
+
if (edge.type === 'references' && !edge.style) return dashed;
|
|
19
34
|
return undefined;
|
|
20
35
|
}
|
|
21
36
|
|
|
@@ -84,9 +99,10 @@ interface EdgePathProps {
|
|
|
84
99
|
toNode: CanvasNodeState;
|
|
85
100
|
focused: boolean; // connected to the active node
|
|
86
101
|
dimmed: boolean; // active node exists but this edge is NOT connected
|
|
102
|
+
scale: number; // inverse-viewport compensation, see edgeChromeScale()
|
|
87
103
|
}
|
|
88
104
|
|
|
89
|
-
function EdgePath({ edge, fromNode, toNode, focused, dimmed }: EdgePathProps) {
|
|
105
|
+
function EdgePath({ edge, fromNode, toNode, focused, dimmed, scale }: EdgePathProps) {
|
|
90
106
|
const start = computeAnchor(fromNode, toNode);
|
|
91
107
|
const end = computeAnchor(toNode, fromNode);
|
|
92
108
|
|
|
@@ -106,7 +122,7 @@ function EdgePath({ edge, fromNode, toNode, focused, dimmed }: EdgePathProps) {
|
|
|
106
122
|
const d = `M ${start.x} ${start.y} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${end.x} ${end.y}`;
|
|
107
123
|
const color = EDGE_COLORS[edge.type];
|
|
108
124
|
const directed = DIRECTED_TYPES.has(edge.type);
|
|
109
|
-
const dash = dashArray(edge);
|
|
125
|
+
const dash = dashArray(edge, scale);
|
|
110
126
|
|
|
111
127
|
const mid = edge.label ? bezierMidpoint(start.x, start.y, cx1, cy1, cx2, cy2, end.x, end.y) : null;
|
|
112
128
|
|
|
@@ -115,7 +131,7 @@ function EdgePath({ edge, fromNode, toNode, focused, dimmed }: EdgePathProps) {
|
|
|
115
131
|
return (
|
|
116
132
|
<g>
|
|
117
133
|
{/* Invisible wide hitbox for hover/click */}
|
|
118
|
-
<path d={d} fill="none" stroke="transparent" stroke-width=
|
|
134
|
+
<path d={d} fill="none" stroke="transparent" stroke-width={12 * scale} style={{ cursor: 'pointer' }} />
|
|
119
135
|
|
|
120
136
|
{/* Glow layer for focused edges */}
|
|
121
137
|
{focused && (
|
|
@@ -123,29 +139,30 @@ function EdgePath({ edge, fromNode, toNode, focused, dimmed }: EdgePathProps) {
|
|
|
123
139
|
d={d}
|
|
124
140
|
fill="none"
|
|
125
141
|
stroke={color}
|
|
126
|
-
stroke-width=
|
|
142
|
+
stroke-width={6 * scale}
|
|
127
143
|
stroke-dasharray={dash}
|
|
128
144
|
opacity="0.15"
|
|
129
145
|
style={{ filter: 'blur(3px)' }}
|
|
130
146
|
/>
|
|
131
147
|
)}
|
|
132
148
|
|
|
133
|
-
{/* Visible edge
|
|
149
|
+
{/* Visible edge — the `edge-arrow` marker uses the default
|
|
150
|
+
markerUnits="strokeWidth", so the arrowhead scales with this. */}
|
|
134
151
|
<path
|
|
135
152
|
id={pathId}
|
|
136
153
|
d={d}
|
|
137
154
|
fill="none"
|
|
138
155
|
stroke={color}
|
|
139
|
-
stroke-width={focused ? 2.5 : 1.5}
|
|
156
|
+
stroke-width={(focused ? 2.5 : 1.5) * scale}
|
|
140
157
|
stroke-dasharray={dash}
|
|
141
158
|
marker-end={directed ? 'url(#edge-arrow)' : undefined}
|
|
142
|
-
opacity={dimmed ? 0.2 : focused ? 1 : 0.
|
|
159
|
+
opacity={dimmed ? 0.2 : focused ? 1 : 0.85}
|
|
143
160
|
style={{ transition: 'opacity 0.2s, stroke-width 0.2s' }}
|
|
144
161
|
/>
|
|
145
162
|
|
|
146
163
|
{/* Animated pulse dot */}
|
|
147
164
|
{edge.animated && (
|
|
148
|
-
<circle r=
|
|
165
|
+
<circle r={3 * scale} fill={color} opacity="0.9">
|
|
149
166
|
<animateMotion dur="2s" repeatCount="indefinite">
|
|
150
167
|
<mpath href={`#${pathId}`} />
|
|
151
168
|
</animateMotion>
|
|
@@ -157,13 +174,13 @@ function EdgePath({ edge, fromNode, toNode, focused, dimmed }: EdgePathProps) {
|
|
|
157
174
|
<g transform={`translate(${mid.x}, ${mid.y})`}>
|
|
158
175
|
<rect
|
|
159
176
|
class="edge-label-bg"
|
|
160
|
-
x={-(edge.label.length * 3.5 + 8)}
|
|
161
|
-
y=
|
|
162
|
-
width={edge.label.length * 7 + 16}
|
|
163
|
-
height=
|
|
164
|
-
rx=
|
|
177
|
+
x={-(edge.label.length * 3.5 + 8) * scale}
|
|
178
|
+
y={-10 * scale}
|
|
179
|
+
width={(edge.label.length * 7 + 16) * scale}
|
|
180
|
+
height={20 * scale}
|
|
181
|
+
rx={4 * scale}
|
|
165
182
|
/>
|
|
166
|
-
<text class="edge-label" text-anchor="middle" dominant-baseline="central" fill="var(--c-text)"
|
|
183
|
+
<text class="edge-label" text-anchor="middle" dominant-baseline="central" fill="var(--c-text)">
|
|
167
184
|
{edge.label}
|
|
168
185
|
</text>
|
|
169
186
|
</g>
|
|
@@ -185,6 +202,7 @@ export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
|
|
|
185
202
|
const hasFocus = focusId !== null;
|
|
186
203
|
const searchSet = searchHighlightIds.value;
|
|
187
204
|
const hasSearch = searchSet !== null;
|
|
205
|
+
const scale = edgeChromeScale(viewport.value.scale);
|
|
188
206
|
|
|
189
207
|
if (edgeList.length === 0) return null;
|
|
190
208
|
|
|
@@ -210,6 +228,7 @@ export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
|
|
|
210
228
|
left: `${minX}px`,
|
|
211
229
|
pointerEvents: 'none',
|
|
212
230
|
overflow: 'visible',
|
|
231
|
+
'--edge-chrome-scale': scale.toFixed(3),
|
|
213
232
|
}}
|
|
214
233
|
>
|
|
215
234
|
<title>Canvas connections</title>
|
|
@@ -240,6 +259,7 @@ export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
|
|
|
240
259
|
toNode={toNode}
|
|
241
260
|
focused={isConnected}
|
|
242
261
|
dimmed={(hasFocus && !isConnected) || searchDimmed}
|
|
262
|
+
scale={scale}
|
|
243
263
|
/>
|
|
244
264
|
);
|
|
245
265
|
})}
|
|
@@ -260,7 +280,7 @@ export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
|
|
|
260
280
|
d={previewD}
|
|
261
281
|
fill="none"
|
|
262
282
|
stroke="var(--c-accent)"
|
|
263
|
-
stroke-width=
|
|
283
|
+
stroke-width={6 * scale}
|
|
264
284
|
opacity="0.1"
|
|
265
285
|
style={{ filter: 'blur(3px)' }}
|
|
266
286
|
/>
|
|
@@ -268,11 +288,11 @@ export function EdgeLayer({ nodes, edges }: EdgeLayerProps) {
|
|
|
268
288
|
d={previewD}
|
|
269
289
|
fill="none"
|
|
270
290
|
stroke="var(--c-accent)"
|
|
271
|
-
stroke-width=
|
|
272
|
-
stroke-dasharray=
|
|
291
|
+
stroke-width={2 * scale}
|
|
292
|
+
stroke-dasharray={`${6 * scale} ${4 * scale}`}
|
|
273
293
|
opacity="0.8"
|
|
274
294
|
/>
|
|
275
|
-
<circle cx={de.cursorX} cy={de.cursorY} r=
|
|
295
|
+
<circle cx={de.cursorX} cy={de.cursorY} r={5 * scale} fill="var(--c-accent)" opacity="0.5" />
|
|
276
296
|
</g>
|
|
277
297
|
);
|
|
278
298
|
})()}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { HTML_SURFACE_PUSH_SOURCE } from '../../shared/ax-surface-protocol.js';
|
|
2
2
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
3
3
|
import { ContextNode } from '../nodes/ContextNode';
|
|
4
|
+
import { DiffNode } from '../nodes/DiffNode';
|
|
4
5
|
import { FileNode } from '../nodes/FileNode';
|
|
5
6
|
import { LedgerNode } from '../nodes/LedgerNode';
|
|
6
7
|
import { MarkdownNode } from '../nodes/MarkdownNode';
|
|
8
|
+
import { MermaidNode } from '../nodes/MermaidNode';
|
|
7
9
|
import { McpAppNode } from '../nodes/McpAppNode';
|
|
8
10
|
import { StatusNode } from '../nodes/StatusNode';
|
|
9
11
|
import { ImageNode } from '../nodes/ImageNode';
|
|
@@ -11,6 +13,7 @@ import { WebpageNode } from '../nodes/WebpageNode';
|
|
|
11
13
|
import { HtmlNode, shouldShowPresentationControls } from '../nodes/HtmlNode';
|
|
12
14
|
import { canOpenAsSite, openNodeAsSite } from '../nodes/surface-url';
|
|
13
15
|
import { PromptNode } from '../nodes/PromptNode';
|
|
16
|
+
import { AxStepControls } from '../nodes/AxStepControls';
|
|
14
17
|
import { ResponseNode } from '../nodes/ResponseNode';
|
|
15
18
|
import { TraceNode } from '../nodes/TraceNode';
|
|
16
19
|
import {
|
|
@@ -50,6 +53,10 @@ function renderContent(node: CanvasNodeState, expanded: boolean) {
|
|
|
50
53
|
return <TraceNode node={node} />;
|
|
51
54
|
case 'file':
|
|
52
55
|
return <FileNode node={node} expanded={expanded} />;
|
|
56
|
+
case 'diff':
|
|
57
|
+
return <DiffNode node={node} />;
|
|
58
|
+
case 'mermaid':
|
|
59
|
+
return <MermaidNode node={node} expanded={expanded} />;
|
|
53
60
|
case 'image':
|
|
54
61
|
return <ImageNode node={node} expanded={expanded} />;
|
|
55
62
|
case 'html':
|
|
@@ -64,6 +71,10 @@ function getNodeTextContent(node: CanvasNodeState): string {
|
|
|
64
71
|
switch (node.type) {
|
|
65
72
|
case 'markdown':
|
|
66
73
|
return (node.data.content as string) || '';
|
|
74
|
+
case 'diff':
|
|
75
|
+
return (node.data.content as string) || '';
|
|
76
|
+
case 'mermaid':
|
|
77
|
+
return (node.data.content as string) || '';
|
|
67
78
|
case 'file':
|
|
68
79
|
return (node.data.fileContent as string) || '';
|
|
69
80
|
case 'webpage':
|
|
@@ -395,6 +406,16 @@ export function ExpandedNodeOverlay() {
|
|
|
395
406
|
renderContent(node, true)
|
|
396
407
|
)}
|
|
397
408
|
</div>
|
|
409
|
+
{/* AX step controls follow the node into focus mode. Without this,
|
|
410
|
+
expanding a materialized flow step showed only its text — the
|
|
411
|
+
Start/Done/Blocked and loop controls live in CanvasNode's body,
|
|
412
|
+
which the overlay does not render. */}
|
|
413
|
+
{/* Here the dock is a SIBLING of the padded content area, so it is
|
|
414
|
+
already flush with the panel: no bleed, and 16px of its own padding
|
|
415
|
+
to line its labels up with the content above. */}
|
|
416
|
+
<div style={{ '--ax-dock-bleed': '0px', '--ax-dock-pad': '16px' } as Record<string, string>}>
|
|
417
|
+
<AxStepControls node={node} />
|
|
418
|
+
</div>
|
|
398
419
|
{canPresent && presenting && (
|
|
399
420
|
<div
|
|
400
421
|
ref={presentationOverlayRef}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CanvasNodeState } from '../types';
|
|
2
|
+
import { nodeMinSize } from '../../shared/node-sizes.js';
|
|
2
3
|
|
|
3
4
|
export const AUTO_FIT_TITLEBAR_HEIGHT = 37;
|
|
4
5
|
export const AUTO_FIT_MAX_HEIGHT = 600;
|
|
@@ -21,6 +22,7 @@ export const AUTO_FIT_BODY_PADDING = 24;
|
|
|
21
22
|
function isIframeNode(node: CanvasNodeState): boolean {
|
|
22
23
|
return (
|
|
23
24
|
node.type === 'html' ||
|
|
25
|
+
node.type === 'mermaid' ||
|
|
24
26
|
node.type === 'json-render' ||
|
|
25
27
|
node.type === 'graph' ||
|
|
26
28
|
node.type === 'mcp-app' ||
|
|
@@ -33,6 +35,7 @@ function isIframeNode(node: CanvasNodeState): boolean {
|
|
|
33
35
|
* viewers (unbounded/scrolling content that must not drive node height). */
|
|
34
36
|
function isContentFitSurface(node: CanvasNodeState): boolean {
|
|
35
37
|
if (node.type === 'html') return node.data.presentation !== true;
|
|
38
|
+
if (node.type === 'mermaid') return true;
|
|
36
39
|
if (node.type === 'json-render' || node.type === 'graph') return true;
|
|
37
40
|
if (node.type === 'mcp-app') return node.data.viewerType === 'web-artifact';
|
|
38
41
|
return false;
|
|
@@ -58,7 +61,15 @@ export function shouldAutoFitNode(node: CanvasNodeState): boolean {
|
|
|
58
61
|
|
|
59
62
|
export function computeAutoFitHeight(node: CanvasNodeState, contentHeight: number): number | null {
|
|
60
63
|
if (!shouldAutoFitNode(node) || contentHeight <= 0) return null;
|
|
61
|
-
|
|
64
|
+
const fitted = Math.min(contentHeight + AUTO_FIT_TITLEBAR_HEIGHT, AUTO_FIT_MAX_HEIGHT);
|
|
65
|
+
// Honor the shared readability floor (0.4.6 report Finding AA): the server
|
|
66
|
+
// clamps an undersized create up to the floor, then this auto-fit measured
|
|
67
|
+
// the short content and persisted a SMALLER height — undoing the clamp on
|
|
68
|
+
// the most common path (a connected workbench). Auto-fit may grow a node and
|
|
69
|
+
// may shrink it toward its content, but never below the type's floor.
|
|
70
|
+
// strictSize nodes never reach here (isAutoSizeExempt).
|
|
71
|
+
const min = nodeMinSize(node.type);
|
|
72
|
+
return min ? Math.max(fitted, min.height) : fitted;
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
/** Iframe surfaces that should GROW to fit their reported content height. */
|
|
@@ -44,13 +44,28 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disab
|
|
|
44
44
|
[onViewportCommit],
|
|
45
45
|
);
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A mouse wheel should zoom the canvas; a trackpad two-finger scroll should still
|
|
49
|
+
* pan. The events are the same type, so distinguish them by shape: wheels emit
|
|
50
|
+
* coarse, whole-number vertical deltas with no horizontal component, while
|
|
51
|
+
* trackpads emit small and/or fractional deltas and usually some deltaX. Pinch
|
|
52
|
+
* arrives as ctrl+wheel and is handled alongside this.
|
|
53
|
+
*/
|
|
54
|
+
function isMouseWheel(e: WheelEvent): boolean {
|
|
55
|
+
// Line/page deltaMode is only ever produced by a real wheel (Firefox emits
|
|
56
|
+
// DOM_DELTA_LINE with deltaY ~3 per notch, which the pixel heuristic below
|
|
57
|
+
// would never match — wheel zoom simply never fired there).
|
|
58
|
+
if (e.deltaMode !== 0) return e.deltaX === 0;
|
|
59
|
+
return e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 50;
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
const handleWheel = useCallback(
|
|
48
63
|
(e: WheelEvent) => {
|
|
49
64
|
if (disabled) return;
|
|
50
65
|
e.preventDefault();
|
|
51
66
|
const v = viewport.value;
|
|
52
67
|
|
|
53
|
-
if (e.ctrlKey || e.metaKey) {
|
|
68
|
+
if (e.ctrlKey || e.metaKey || isMouseWheel(e)) {
|
|
54
69
|
// Zoom centered on pointer
|
|
55
70
|
const rect = containerRef.current?.getBoundingClientRect();
|
|
56
71
|
if (!rect) return;
|
package/src/client/icons.tsx
CHANGED
|
@@ -473,6 +473,10 @@ export function getNodeIcon(type: string): (p: IconProps) => JSX.Element {
|
|
|
473
473
|
return IconNodeResponse;
|
|
474
474
|
case 'file':
|
|
475
475
|
return IconNodeFile;
|
|
476
|
+
case 'diff':
|
|
477
|
+
return IconNodeFile;
|
|
478
|
+
case 'mermaid':
|
|
479
|
+
return IconNodeGraph;
|
|
476
480
|
case 'image':
|
|
477
481
|
return IconNodeImage;
|
|
478
482
|
case 'webpage':
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface-side renderer for `mermaid` nodes — the entry point of the separately
|
|
3
|
+
* built dist/canvas/mermaid-entry.js bundle (see package.json build:client).
|
|
4
|
+
* The server (handleNodeSurface) serves a themed surface document containing the
|
|
5
|
+
* HTML-escaped diagram source in `<pre class="mermaid-source">`; this script
|
|
6
|
+
* reads it back via textContent, renders it to SVG, and re-renders when the
|
|
7
|
+
* parent canvas live-switches the theme (the theme bridge toggles the document's
|
|
8
|
+
* `data-theme` attribute). Keeping mermaid out of the main SPA bundle keeps the
|
|
9
|
+
* canvas boot lean — only mermaid-node surfaces pay for the library.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import mermaid from 'mermaid';
|
|
13
|
+
import { canvasThemeScheme } from '../shared/themes.js';
|
|
14
|
+
|
|
15
|
+
function documentScheme(): 'dark' | 'light' {
|
|
16
|
+
return canvasThemeScheme(document.documentElement.getAttribute('data-theme'));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function showError(container: HTMLElement, message: string): void {
|
|
20
|
+
container.innerHTML = '';
|
|
21
|
+
const block = document.createElement('pre');
|
|
22
|
+
block.className = 'mermaid-error';
|
|
23
|
+
block.style.cssText =
|
|
24
|
+
'margin:12px;padding:12px;border-radius:6px;color:var(--c-dim,#8a93a6);' +
|
|
25
|
+
'font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;white-space:pre-wrap;';
|
|
26
|
+
block.textContent = message;
|
|
27
|
+
container.appendChild(block);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let renderSeq = 0;
|
|
31
|
+
|
|
32
|
+
async function renderDiagram(source: string, container: HTMLElement): Promise<void> {
|
|
33
|
+
const scheme = documentScheme();
|
|
34
|
+
mermaid.initialize({
|
|
35
|
+
startOnLoad: false,
|
|
36
|
+
theme: scheme === 'dark' ? 'dark' : 'default',
|
|
37
|
+
securityLevel: 'strict',
|
|
38
|
+
});
|
|
39
|
+
renderSeq += 1;
|
|
40
|
+
try {
|
|
41
|
+
const { svg } = await mermaid.render(`pmx-mermaid-${renderSeq}`, source);
|
|
42
|
+
container.innerHTML = svg;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
// Never leave a blank/black frame: show the parse/render error instead.
|
|
45
|
+
showError(container, error instanceof Error ? error.message : String(error));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function boot(): void {
|
|
50
|
+
const sourceEl = document.querySelector('.mermaid-source');
|
|
51
|
+
const source = sourceEl?.textContent ?? '';
|
|
52
|
+
const container = document.createElement('div');
|
|
53
|
+
container.className = 'mermaid-diagram';
|
|
54
|
+
document.body.appendChild(container);
|
|
55
|
+
if (!source.trim()) {
|
|
56
|
+
showError(container, 'Empty mermaid diagram');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
void renderDiagram(source, container);
|
|
60
|
+
// Live theme switching: the parent's theme bridge toggles data-theme on the
|
|
61
|
+
// root element; re-render so the diagram palette follows the canvas theme.
|
|
62
|
+
let lastScheme = documentScheme();
|
|
63
|
+
const observer = new MutationObserver(() => {
|
|
64
|
+
const scheme = documentScheme();
|
|
65
|
+
if (scheme === lastScheme) return;
|
|
66
|
+
lastScheme = scheme;
|
|
67
|
+
void renderDiagram(source, container);
|
|
68
|
+
});
|
|
69
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (document.readyState === 'loading') {
|
|
73
|
+
document.addEventListener('DOMContentLoaded', boot);
|
|
74
|
+
} else {
|
|
75
|
+
boot();
|
|
76
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { useState } from 'preact/hooks';
|
|
2
|
+
import { readAxFlow, readAxStep, type AxFlowStamp } from '../../shared/ax-flow.js';
|
|
3
|
+
import { nodes, updateNodeData } from '../state/canvas-store';
|
|
4
|
+
import { updateNodeFromClient } from '../state/intent-bridge';
|
|
5
|
+
import type { CanvasNodeState } from '../types';
|
|
6
|
+
import { axNodeActionButtonStyle, runNodeAxInteraction } from './ax-node-actions';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Native AX controls for a materialized flow step — the same capability the
|
|
10
|
+
* ax-flow HTML panel has, on an ORDINARY canvas node.
|
|
11
|
+
*
|
|
12
|
+
* Rendered by CanvasNode for any node carrying `data.axStep`, whatever its type,
|
|
13
|
+
* so the flow is driven from the board itself: Start/Done/Blocked emit
|
|
14
|
+
* `ax.work.update` for the step's work item through the trusted `native-node`
|
|
15
|
+
* surface, and the anchor step additionally runs/stops the durable server loop
|
|
16
|
+
* (ax-flow-loop.ts) and steers the agent.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The rows sit in a dock stuck to the bottom of the scrollable node body: a step
|
|
21
|
+
* node is short (360x200 as materialized), so a control row that simply followed
|
|
22
|
+
* the content scrolled out of sight. Sticky keeps it reachable at any node height
|
|
23
|
+
* while still being INSIDE the body, which is what auto-fit measures.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* The dock reads as a real footer toolbar rather than stray text at the bottom
|
|
27
|
+
* edge. It bleeds to the container's edges via negative margins (`.node-body`
|
|
28
|
+
* has 12px padding; the expanded overlay's content area has 16px, and the dock
|
|
29
|
+
* is a sibling of that area, so it starts flush) and then re-applies its own
|
|
30
|
+
* padding — otherwise the first label sits hard against the left edge and looks
|
|
31
|
+
* clipped. `--c-panel-overlay` separates it from the content above it.
|
|
32
|
+
*/
|
|
33
|
+
const dockStyle = {
|
|
34
|
+
position: 'sticky',
|
|
35
|
+
// Bleed OUT of the container's padding so the bar spans edge to edge, then
|
|
36
|
+
// re-apply padding inside it. These are two different numbers: in a node body
|
|
37
|
+
// the dock sits INSIDE 12px of padding and must pull back out of it, while in
|
|
38
|
+
// the expanded overlay it is a sibling of the padded content area and is
|
|
39
|
+
// already flush — bleeding there would push it off the panel.
|
|
40
|
+
bottom: 'calc(-1 * var(--ax-dock-bleed, 12px))',
|
|
41
|
+
marginTop: '12px',
|
|
42
|
+
marginLeft: 'calc(-1 * var(--ax-dock-bleed, 12px))',
|
|
43
|
+
marginRight: 'calc(-1 * var(--ax-dock-bleed, 12px))',
|
|
44
|
+
marginBottom: 'calc(-1 * var(--ax-dock-bleed, 12px))',
|
|
45
|
+
padding: '9px var(--ax-dock-pad, 12px) 10px',
|
|
46
|
+
background: 'var(--c-panel-overlay)',
|
|
47
|
+
borderTop: '1px solid var(--c-line)',
|
|
48
|
+
backdropFilter: 'blur(6px)',
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
const rowStyle = {
|
|
52
|
+
display: 'flex',
|
|
53
|
+
alignItems: 'center',
|
|
54
|
+
gap: '6px',
|
|
55
|
+
flexWrap: 'wrap',
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
const labelStyle = {
|
|
59
|
+
fontSize: '10px',
|
|
60
|
+
color: 'var(--c-muted)',
|
|
61
|
+
textTransform: 'uppercase',
|
|
62
|
+
letterSpacing: '0.04em',
|
|
63
|
+
marginRight: 'auto',
|
|
64
|
+
} as const;
|
|
65
|
+
|
|
66
|
+
const activeButtonStyle = {
|
|
67
|
+
...axNodeActionButtonStyle,
|
|
68
|
+
background: 'var(--c-accent-25)',
|
|
69
|
+
color: 'var(--c-text)',
|
|
70
|
+
} as const;
|
|
71
|
+
|
|
72
|
+
const STEP_ACTIONS = [
|
|
73
|
+
{ status: 'in-progress', label: 'Start', toast: 'Step started' },
|
|
74
|
+
{ status: 'done', label: 'Done', toast: 'Step done' },
|
|
75
|
+
{ status: 'blocked', label: 'Blocked', toast: 'Step blocked' },
|
|
76
|
+
] as const;
|
|
77
|
+
|
|
78
|
+
/** Persist a new loop state on the anchor node (optimistic locally, durable on the server). */
|
|
79
|
+
async function persistLoop(node: CanvasNodeState, flow: AxFlowStamp, loop: AxFlowStamp['loop']): Promise<void> {
|
|
80
|
+
const axFlow: AxFlowStamp = { ...flow, loop };
|
|
81
|
+
updateNodeData(node.id, { axFlow });
|
|
82
|
+
await updateNodeFromClient(node.id, { data: { axFlow } });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The work-item status mirrored onto a node by the server (`data.axWorkStatus`). */
|
|
86
|
+
function mirroredStatus(data: Record<string, unknown> | undefined): string {
|
|
87
|
+
return typeof data?.axWorkStatus === 'string' ? data.axWorkStatus : 'todo';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function FlowLoopControls({ node, flow }: { node: CanvasNodeState; flow: AxFlowStamp }) {
|
|
91
|
+
const [message, setMessage] = useState('');
|
|
92
|
+
const { running, run, maxRuns } = flow.loop;
|
|
93
|
+
|
|
94
|
+
const handleRun = async () => {
|
|
95
|
+
// A finished loop restarts from zero rather than stopping on its first check.
|
|
96
|
+
await persistLoop(node, flow, { running: true, run: run >= maxRuns ? 0 : run, maxRuns });
|
|
97
|
+
// The server loop only advances on a WORK-ITEM change, so starting it has to
|
|
98
|
+
// produce one: open the first unfinished step, or re-assert the last step's
|
|
99
|
+
// `done` when the flow is already complete (which closes the run and opens
|
|
100
|
+
// the next one server-side).
|
|
101
|
+
const next = flow.steps.find((step) => mirroredStatus(nodes.value.get(step.nodeId)?.data) !== 'done');
|
|
102
|
+
const target = next ?? flow.steps[flow.steps.length - 1];
|
|
103
|
+
await runNodeAxInteraction(
|
|
104
|
+
node,
|
|
105
|
+
'ax.work.update',
|
|
106
|
+
{ id: target.workItemId, status: next ? 'in-progress' : 'done' },
|
|
107
|
+
'Loop running',
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handleSteer = async () => {
|
|
112
|
+
const text = message.trim();
|
|
113
|
+
if (!text) return;
|
|
114
|
+
await runNodeAxInteraction(node, 'ax.steer', { message: text }, 'Steering sent');
|
|
115
|
+
setMessage('');
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div class="ax-flow-controls" style={{ ...rowStyle, marginTop: '6px', rowGap: '4px' }}>
|
|
120
|
+
<span class="ax-flow-runs" style={labelStyle}>
|
|
121
|
+
{running ? `Loop running · run ${run + 1}/${maxRuns}` : `Loop idle · ${run}/${maxRuns} runs`}
|
|
122
|
+
</span>
|
|
123
|
+
{running ? (
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
class="ax-node-action"
|
|
127
|
+
style={activeButtonStyle}
|
|
128
|
+
title="Stop the loop now. The stop is persisted on this node, so it survives a refresh and no further step is opened."
|
|
129
|
+
onClick={(e) => {
|
|
130
|
+
e.stopPropagation();
|
|
131
|
+
void persistLoop(node, flow, { ...flow.loop, running: false });
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
Stop
|
|
135
|
+
</button>
|
|
136
|
+
) : (
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
class="ax-node-action"
|
|
140
|
+
style={axNodeActionButtonStyle}
|
|
141
|
+
title="Run this flow as a loop: each completed step opens the next one, and the last step starts the next run — until the run cap is reached or you press Stop."
|
|
142
|
+
onClick={(e) => {
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
void handleRun();
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
Run loop
|
|
148
|
+
</button>
|
|
149
|
+
)}
|
|
150
|
+
<input
|
|
151
|
+
class="ax-flow-steer-input"
|
|
152
|
+
value={message}
|
|
153
|
+
placeholder="Steer the agent…"
|
|
154
|
+
aria-label="Steer the agent"
|
|
155
|
+
onInput={(e) => setMessage((e.target as HTMLInputElement).value)}
|
|
156
|
+
onClick={(e) => e.stopPropagation()}
|
|
157
|
+
onKeyDown={(e) => {
|
|
158
|
+
if (e.key !== 'Enter') return;
|
|
159
|
+
e.stopPropagation();
|
|
160
|
+
void handleSteer();
|
|
161
|
+
}}
|
|
162
|
+
style={{
|
|
163
|
+
flex: '1 1 120px',
|
|
164
|
+
minWidth: '90px',
|
|
165
|
+
fontSize: '10px',
|
|
166
|
+
padding: '3px 6px',
|
|
167
|
+
borderRadius: '4px',
|
|
168
|
+
border: '1px solid var(--c-line)',
|
|
169
|
+
background: 'var(--c-panel-overlay)',
|
|
170
|
+
color: 'var(--c-text)',
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
class="ax-node-action"
|
|
176
|
+
style={axNodeActionButtonStyle}
|
|
177
|
+
title="Send a steering message to the agent working this flow."
|
|
178
|
+
onClick={(e) => {
|
|
179
|
+
e.stopPropagation();
|
|
180
|
+
void handleSteer();
|
|
181
|
+
}}
|
|
182
|
+
>
|
|
183
|
+
Steer
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function AxStepControls({ node }: { node: CanvasNodeState }) {
|
|
190
|
+
const step = readAxStep(node.data);
|
|
191
|
+
if (!step) return null;
|
|
192
|
+
const flow = readAxFlow(node.data);
|
|
193
|
+
const status = mirroredStatus(node.data);
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<div class="ax-step-dock" style={dockStyle}>
|
|
197
|
+
<div class="ax-step-controls" style={rowStyle}>
|
|
198
|
+
<span class="ax-step-position" style={labelStyle}>
|
|
199
|
+
Step {step.index}/{step.total}
|
|
200
|
+
</span>
|
|
201
|
+
{STEP_ACTIONS.map((action) => (
|
|
202
|
+
<button
|
|
203
|
+
key={action.status}
|
|
204
|
+
type="button"
|
|
205
|
+
class="ax-node-action"
|
|
206
|
+
aria-pressed={status === action.status}
|
|
207
|
+
style={status === action.status ? activeButtonStyle : axNodeActionButtonStyle}
|
|
208
|
+
title={`Set this step's work item to "${action.status}".`}
|
|
209
|
+
onClick={(e) => {
|
|
210
|
+
e.stopPropagation();
|
|
211
|
+
void runNodeAxInteraction(
|
|
212
|
+
node,
|
|
213
|
+
'ax.work.update',
|
|
214
|
+
{ id: step.workItemId, status: action.status },
|
|
215
|
+
action.toast,
|
|
216
|
+
);
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
{action.label}
|
|
220
|
+
</button>
|
|
221
|
+
))}
|
|
222
|
+
</div>
|
|
223
|
+
{flow && <FlowLoopControls node={node} flow={flow} />}
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|