pmx-canvas 0.4.6 → 0.4.8
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 +79 -0
- package/Readme.md +56 -33
- package/dist/canvas/global.css +101 -3
- package/dist/canvas/index.js +93 -89
- 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/nodes/AxStepControls.d.ts +4 -0
- package/dist/types/client/nodes/ExtAppFrame.d.ts +32 -0
- package/dist/types/client/nodes/FileNode.d.ts +14 -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/server/ax-flow-loop.d.ts +5 -0
- package/dist/types/server/ax-interaction.d.ts +21 -2
- package/dist/types/server/canvas-db.d.ts +14 -0
- package/dist/types/server/canvas-operations.d.ts +5 -0
- package/dist/types/server/canvas-state.d.ts +8 -0
- package/dist/types/server/canvas-validation.d.ts +15 -21
- package/dist/types/server/file-content.d.ts +46 -0
- package/dist/types/server/html-primitives.d.ts +13 -1
- package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
- package/dist/types/server/server.d.ts +1 -0
- package/dist/types/server/web-artifacts.d.ts +14 -0
- package/dist/types/shared/ax-flow.d.ts +51 -0
- package/dist/types/shared/node-sizes.d.ts +33 -0
- package/docs/RELEASE.md +48 -0
- package/docs/cli.md +19 -2
- package/docs/environment.md +1 -0
- package/docs/http-api.md +17 -0
- package/docs/mcp.md +12 -0
- package/docs/node-types.md +45 -1
- package/docs/screenshot.png +0 -0
- package/package.json +2 -2
- package/skills/pmx-canvas/SKILL.md +74 -9
- 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 +40 -0
- 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/view.ts +1 -1
- package/src/cli/index.ts +6 -0
- package/src/cli/shared.ts +34 -5
- package/src/client/App.tsx +28 -15
- package/src/client/canvas/CanvasNode.tsx +54 -5
- package/src/client/canvas/EdgeLayer.tsx +44 -24
- package/src/client/canvas/ExpandedNodeOverlay.tsx +11 -0
- package/src/client/canvas/auto-fit.ts +10 -1
- package/src/client/canvas/use-pan-zoom.ts +16 -1
- package/src/client/nodes/AxStepControls.tsx +226 -0
- package/src/client/nodes/ExtAppFrame.tsx +79 -2
- package/src/client/nodes/FileNode.tsx +249 -51
- package/src/client/state/canvas-store.ts +19 -0
- package/src/client/state/intent-bridge.ts +32 -0
- package/src/client/state/sse-bridge.ts +4 -1
- package/src/client/theme/global.css +101 -3
- package/src/server/ax-flow-loop.ts +114 -0
- package/src/server/ax-interaction.ts +299 -4
- package/src/server/canvas-db.ts +138 -32
- package/src/server/canvas-operations.ts +44 -17
- package/src/server/canvas-state.ts +20 -10
- package/src/server/canvas-validation.ts +50 -41
- 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/operations/ops/ax-work.ts +5 -0
- package/src/server/operations/ops/nodes.ts +15 -1
- package/src/server/operations/ops/viewport.ts +7 -1
- package/src/server/server.ts +106 -5
- package/src/server/web-artifacts.ts +109 -0
- package/src/shared/ax-flow.ts +102 -0
- package/src/shared/node-sizes.ts +52 -0
- package/dist/types/server/chart-template.d.ts +0 -29
- package/src/server/chart-template.ts +0 -448
package/src/client/App.tsx
CHANGED
|
@@ -36,10 +36,11 @@ import {
|
|
|
36
36
|
traceEnabled,
|
|
37
37
|
viewport,
|
|
38
38
|
walkGraph,
|
|
39
|
+
zoomByFactor,
|
|
39
40
|
} from './state/canvas-store';
|
|
40
41
|
import { connectSSE } from './state/sse-bridge';
|
|
41
42
|
import { intents } from './state/intent-store';
|
|
42
|
-
import { saveCanvasTheme } from './state/intent-bridge';
|
|
43
|
+
import { reportClientViewportSize, saveCanvasTheme } from './state/intent-bridge';
|
|
43
44
|
import {
|
|
44
45
|
IconArrange,
|
|
45
46
|
IconClearTrace,
|
|
@@ -258,20 +259,12 @@ function Toolbar({
|
|
|
258
259
|
</button>
|
|
259
260
|
</ToolbarHint>
|
|
260
261
|
<ToolbarHint label="Zoom in" shortcut={`${MOD_KEY}++`}>
|
|
261
|
-
<button
|
|
262
|
-
type="button"
|
|
263
|
-
onClick={() => animateViewport({ ...v, scale: Math.min(4, v.scale * 1.25) }, 150)}
|
|
264
|
-
aria-label="Zoom in"
|
|
265
|
-
>
|
|
262
|
+
<button type="button" onClick={() => zoomByFactor(1.25)} aria-label="Zoom in">
|
|
266
263
|
<IconZoomIn />
|
|
267
264
|
</button>
|
|
268
265
|
</ToolbarHint>
|
|
269
266
|
<ToolbarHint label="Zoom out" shortcut={`${MOD_KEY}+-`}>
|
|
270
|
-
<button
|
|
271
|
-
type="button"
|
|
272
|
-
onClick={() => animateViewport({ ...v, scale: Math.max(0.1, v.scale / 1.25) }, 150)}
|
|
273
|
-
aria-label="Zoom out"
|
|
274
|
-
>
|
|
267
|
+
<button type="button" onClick={() => zoomByFactor(1 / 1.25)} aria-label="Zoom out">
|
|
275
268
|
<IconZoomOut />
|
|
276
269
|
</button>
|
|
277
270
|
</ToolbarHint>
|
|
@@ -585,6 +578,25 @@ export function App() {
|
|
|
585
578
|
return connectSSE();
|
|
586
579
|
}, []);
|
|
587
580
|
|
|
581
|
+
// Keep the server's idea of this window's size current (0.4.6 orb feedback
|
|
582
|
+
// #2) so an agent `fit` computes a scale that actually fits the human's
|
|
583
|
+
// window. Connect-time reporting lives in the SSE bridge; this covers resizes.
|
|
584
|
+
useEffect(() => {
|
|
585
|
+
let timer: number | null = null;
|
|
586
|
+
const schedule = () => {
|
|
587
|
+
if (timer !== null) window.clearTimeout(timer);
|
|
588
|
+
timer = window.setTimeout(() => {
|
|
589
|
+
timer = null;
|
|
590
|
+
void reportClientViewportSize();
|
|
591
|
+
}, 400);
|
|
592
|
+
};
|
|
593
|
+
window.addEventListener('resize', schedule);
|
|
594
|
+
return () => {
|
|
595
|
+
if (timer !== null) window.clearTimeout(timer);
|
|
596
|
+
window.removeEventListener('resize', schedule);
|
|
597
|
+
};
|
|
598
|
+
}, []);
|
|
599
|
+
|
|
588
600
|
useEffect(() => {
|
|
589
601
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
590
602
|
const mod = e.metaKey || e.ctrlKey;
|
|
@@ -640,12 +652,13 @@ export function App() {
|
|
|
640
652
|
animateViewport({ x: 0, y: 0, scale: 1 }, 250);
|
|
641
653
|
} else if (mod && (e.key === '=' || e.key === '+')) {
|
|
642
654
|
e.preventDefault();
|
|
643
|
-
|
|
644
|
-
|
|
655
|
+
// Same centre-anchored zoom as the toolbar +/- buttons these shortcuts
|
|
656
|
+
// are advertised on (the tooltip names this key) — a raw scale change
|
|
657
|
+
// is origin-anchored and drifts the board away under the cursor.
|
|
658
|
+
zoomByFactor(1.25);
|
|
645
659
|
} else if (mod && e.key === '-') {
|
|
646
660
|
e.preventDefault();
|
|
647
|
-
|
|
648
|
-
animateViewport({ ...cur, scale: Math.max(0.1, cur.scale / 1.25) }, 150);
|
|
661
|
+
zoomByFactor(1 / 1.25);
|
|
649
662
|
} else if (e.key === 'Escape') {
|
|
650
663
|
if (selectedNodeIds.value.size > 0) {
|
|
651
664
|
clearSelection();
|
|
@@ -21,15 +21,54 @@ import {
|
|
|
21
21
|
viewport,
|
|
22
22
|
} from '../state/canvas-store';
|
|
23
23
|
import { removeNodeFromClient, updateNodeFromClient } from '../state/intent-bridge';
|
|
24
|
+
import { AxStepControls } from '../nodes/AxStepControls';
|
|
24
25
|
import { canOpenAsSite, openNodeAsSite } from '../nodes/surface-url';
|
|
25
26
|
import { getNodeIcon } from '../icons';
|
|
26
27
|
import { EXPANDABLE_TYPES, TYPE_LABELS } from '../types';
|
|
27
28
|
import type { CanvasNodeState } from '../types';
|
|
28
|
-
import { computeAutoFitHeight, shouldAutoFitNode } from './auto-fit';
|
|
29
|
+
import { AUTO_FIT_TITLEBAR_HEIGHT, computeAutoFitHeight, shouldAutoFitNode } from './auto-fit';
|
|
29
30
|
import { activeGuides, buildSnapCache, clearSnapCache, snapToGuides } from './snap-guides';
|
|
30
31
|
import { useNodeDrag } from './use-node-drag';
|
|
31
32
|
import { useNodeResize } from './use-node-resize';
|
|
32
33
|
|
|
34
|
+
/** Fraction of a node's height the title bar may occupy before it starts
|
|
35
|
+
* crowding out the content it is labelling. */
|
|
36
|
+
const MAX_TITLEBAR_HEIGHT_RATIO = 0.4;
|
|
37
|
+
/** Fraction of a node's width the fixed title-bar chrome may occupy before it
|
|
38
|
+
* starts eating the node's NAME. */
|
|
39
|
+
const MAX_TITLEBAR_CHROME_WIDTH_RATIO = 0.55;
|
|
40
|
+
/** Unscaled width of everything in the bar that is not the title: type icon,
|
|
41
|
+
* status/type pill, the control cluster, and the gaps between them. */
|
|
42
|
+
const TITLEBAR_CHROME_BASE_WIDTH = 175;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How much to enlarge node chrome (title bar, badges, icons) when zoomed out so
|
|
46
|
+
* it stays legible. Full inverse compensation is capped at 2.2x — but the cap
|
|
47
|
+
* alone ignored the node it is drawn on, so on SHORT nodes the growing bar ate
|
|
48
|
+
* the body: a 116px-tall section label at 46% zoom gave the title bar 72% of the
|
|
49
|
+
* node and left 14px for text that needed 41, so the markdown was simply cut off.
|
|
50
|
+
* The scale is therefore also bounded by the node's own height, keeping the bar
|
|
51
|
+
* under MAX_TITLEBAR_HEIGHT_RATIO of it. Tall nodes are unaffected (their height
|
|
52
|
+
* never binds); pass height 0 for collapsed/auto-height nodes to skip the bound.
|
|
53
|
+
*/
|
|
54
|
+
export function nodeChromeScale(viewportScale: number, nodeHeight: number, nodeWidth = 0): number {
|
|
55
|
+
if (!Number.isFinite(viewportScale) || viewportScale >= 1) return 1;
|
|
56
|
+
const zoomScale = Math.min(2.2, 1 / Math.max(viewportScale, 0.01));
|
|
57
|
+
let bounded = zoomScale;
|
|
58
|
+
if (Number.isFinite(nodeHeight) && nodeHeight > 0) {
|
|
59
|
+
bounded = Math.min(bounded, (nodeHeight * MAX_TITLEBAR_HEIGHT_RATIO) / AUTO_FIT_TITLEBAR_HEIGHT);
|
|
60
|
+
}
|
|
61
|
+
// Width matters as much as height: the icon, pill and control cluster are all
|
|
62
|
+
// flex-shrink: 0, so the NAME is the only thing that gives way. Unbounded, a
|
|
63
|
+
// 360px step node at 52% zoom spent 100 of 187 bar pixels on controls alone and
|
|
64
|
+
// the title collapsed to nothing. Bound the scale so the fixed chrome can never
|
|
65
|
+
// take more than MAX_TITLEBAR_CHROME_WIDTH_RATIO of the node.
|
|
66
|
+
if (Number.isFinite(nodeWidth) && nodeWidth > 0) {
|
|
67
|
+
bounded = Math.min(bounded, (nodeWidth * MAX_TITLEBAR_CHROME_WIDTH_RATIO) / TITLEBAR_CHROME_BASE_WIDTH);
|
|
68
|
+
}
|
|
69
|
+
return Math.max(1, bounded);
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
interface CanvasNodeProps {
|
|
34
73
|
node: CanvasNodeState;
|
|
35
74
|
children: preact.ComponentChildren;
|
|
@@ -220,7 +259,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
220
259
|
const isGroup = node.type === 'group';
|
|
221
260
|
const isStrictSize = node.data.strictSize === true;
|
|
222
261
|
const viewportScale = Math.max(viewport.value.scale, 0.01);
|
|
223
|
-
const chromeScale = viewportScale
|
|
262
|
+
const chromeScale = nodeChromeScale(viewportScale, node.collapsed ? 0 : node.size.height, node.size.width);
|
|
224
263
|
|
|
225
264
|
const groupColor = isGroup && node.data.color ? (node.data.color as string) : undefined;
|
|
226
265
|
const nodeStyle: Record<string, string | number> = {
|
|
@@ -261,9 +300,13 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
261
300
|
return <NodeIcon size={Math.round(14 * chromeScale)} />;
|
|
262
301
|
})()}
|
|
263
302
|
</span>
|
|
264
|
-
|
|
265
|
-
|
|
303
|
+
{/* The icon already says what type this is, so the type badge yields to
|
|
304
|
+
the status chip rather than stacking two pills in front of the name —
|
|
305
|
+
on a narrow node that combination pushed the title to an ellipsis. */}
|
|
306
|
+
{typeof node.data.axWorkStatus === 'string' ? (
|
|
266
307
|
<span class={`node-ax-status node-ax-status-${node.data.axWorkStatus}`}>{node.data.axWorkStatus}</span>
|
|
308
|
+
) : (
|
|
309
|
+
<span class="node-type-badge">{TYPE_LABELS[node.type]}</span>
|
|
267
310
|
)}
|
|
268
311
|
{renaming ? (
|
|
269
312
|
<input
|
|
@@ -278,7 +321,7 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
278
321
|
onClick={(e) => e.stopPropagation()}
|
|
279
322
|
/>
|
|
280
323
|
) : (
|
|
281
|
-
<span class="node-title" onDblClick={handleTitleDblClick} title=
|
|
324
|
+
<span class="node-title" onDblClick={handleTitleDblClick} title={`${title} — double-click to rename`}>
|
|
282
325
|
{title}
|
|
283
326
|
</span>
|
|
284
327
|
)}
|
|
@@ -356,6 +399,12 @@ export function CanvasNode({ node, children, onContextMenu }: CanvasNodeProps) {
|
|
|
356
399
|
{!node.collapsed && (
|
|
357
400
|
<div ref={bodyRef} class="node-body">
|
|
358
401
|
{children}
|
|
402
|
+
{/* AX flow step controls — rendered here rather than in a node renderer
|
|
403
|
+
because a materialized step can be ANY node type, and because the
|
|
404
|
+
body is what auto-fit measures (a footer outside it would be clipped
|
|
405
|
+
on a content-fitted node). Renders nothing unless the node carries a
|
|
406
|
+
`data.axStep` stamp. */}
|
|
407
|
+
<AxStepControls node={node} />
|
|
359
408
|
</div>
|
|
360
409
|
)}
|
|
361
410
|
{!node.collapsed && (
|
|
@@ -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
|
})()}
|
|
@@ -13,6 +13,7 @@ import { WebpageNode } from '../nodes/WebpageNode';
|
|
|
13
13
|
import { HtmlNode, shouldShowPresentationControls } from '../nodes/HtmlNode';
|
|
14
14
|
import { canOpenAsSite, openNodeAsSite } from '../nodes/surface-url';
|
|
15
15
|
import { PromptNode } from '../nodes/PromptNode';
|
|
16
|
+
import { AxStepControls } from '../nodes/AxStepControls';
|
|
16
17
|
import { ResponseNode } from '../nodes/ResponseNode';
|
|
17
18
|
import { TraceNode } from '../nodes/TraceNode';
|
|
18
19
|
import {
|
|
@@ -405,6 +406,16 @@ export function ExpandedNodeOverlay() {
|
|
|
405
406
|
renderContent(node, true)
|
|
406
407
|
)}
|
|
407
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>
|
|
408
419
|
{canPresent && presenting && (
|
|
409
420
|
<div
|
|
410
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;
|
|
@@ -60,7 +61,15 @@ export function shouldAutoFitNode(node: CanvasNodeState): boolean {
|
|
|
60
61
|
|
|
61
62
|
export function computeAutoFitHeight(node: CanvasNodeState, contentHeight: number): number | null {
|
|
62
63
|
if (!shouldAutoFitNode(node) || contentHeight <= 0) return null;
|
|
63
|
-
|
|
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;
|
|
64
73
|
}
|
|
65
74
|
|
|
66
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;
|
|
@@ -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
|
+
}
|