pmx-canvas 0.4.6 → 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.
Files changed (70) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/Readme.md +56 -33
  3. package/dist/canvas/global.css +101 -3
  4. package/dist/canvas/index.js +87 -83
  5. package/dist/types/cli/shared.d.ts +13 -1
  6. package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
  7. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  8. package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
  9. package/dist/types/client/nodes/FileNode.d.ts +14 -0
  10. package/dist/types/client/state/canvas-store.d.ts +10 -0
  11. package/dist/types/client/state/intent-bridge.d.ts +9 -0
  12. package/dist/types/server/ax-flow-loop.d.ts +5 -0
  13. package/dist/types/server/ax-interaction.d.ts +21 -2
  14. package/dist/types/server/canvas-operations.d.ts +5 -0
  15. package/dist/types/server/canvas-state.d.ts +8 -0
  16. package/dist/types/server/canvas-validation.d.ts +15 -21
  17. package/dist/types/server/file-content.d.ts +46 -0
  18. package/dist/types/server/html-primitives.d.ts +13 -1
  19. package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
  20. package/dist/types/server/server.d.ts +1 -0
  21. package/dist/types/server/web-artifacts.d.ts +14 -0
  22. package/dist/types/shared/ax-flow.d.ts +51 -0
  23. package/dist/types/shared/node-sizes.d.ts +33 -0
  24. package/docs/cli.md +19 -2
  25. package/docs/environment.md +1 -0
  26. package/docs/http-api.md +17 -0
  27. package/docs/mcp.md +12 -0
  28. package/docs/node-types.md +45 -1
  29. package/docs/screenshot.png +0 -0
  30. package/package.json +2 -2
  31. package/skills/pmx-canvas/SKILL.md +68 -7
  32. package/skills/pmx-canvas/evals/evals.json +54 -3
  33. package/skills/pmx-canvas/references/full-reference.md +11 -6
  34. package/src/cli/commands/history.ts +1 -1
  35. package/src/cli/commands/nodes.ts +2 -2
  36. package/src/cli/commands/query.ts +1 -1
  37. package/src/cli/commands/view.ts +1 -1
  38. package/src/cli/index.ts +6 -0
  39. package/src/cli/shared.ts +34 -5
  40. package/src/client/App.tsx +28 -15
  41. package/src/client/canvas/CanvasNode.tsx +54 -5
  42. package/src/client/canvas/EdgeLayer.tsx +44 -24
  43. package/src/client/canvas/ExpandedNodeOverlay.tsx +11 -0
  44. package/src/client/canvas/auto-fit.ts +10 -1
  45. package/src/client/canvas/use-pan-zoom.ts +16 -1
  46. package/src/client/nodes/AxStepControls.tsx +226 -0
  47. package/src/client/nodes/FileNode.tsx +249 -51
  48. package/src/client/state/canvas-store.ts +19 -0
  49. package/src/client/state/intent-bridge.ts +32 -0
  50. package/src/client/state/sse-bridge.ts +4 -1
  51. package/src/client/theme/global.css +101 -3
  52. package/src/server/ax-flow-loop.ts +114 -0
  53. package/src/server/ax-interaction.ts +299 -4
  54. package/src/server/canvas-operations.ts +44 -17
  55. package/src/server/canvas-state.ts +20 -10
  56. package/src/server/canvas-validation.ts +50 -41
  57. package/src/server/demo-state.json +4605 -523
  58. package/src/server/demo.ts +24 -0
  59. package/src/server/file-content.ts +152 -0
  60. package/src/server/file-watcher.ts +7 -10
  61. package/src/server/html-primitives.ts +636 -0
  62. package/src/server/operations/ops/ax-work.ts +5 -0
  63. package/src/server/operations/ops/nodes.ts +15 -1
  64. package/src/server/operations/ops/viewport.ts +7 -1
  65. package/src/server/server.ts +106 -5
  66. package/src/server/web-artifacts.ts +109 -0
  67. package/src/shared/ax-flow.ts +102 -0
  68. package/src/shared/node-sizes.ts +52 -0
  69. package/dist/types/server/chart-template.d.ts +0 -29
  70. 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
- references: 'var(--c-dim)',
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
- function dashArray(edge: CanvasEdge): string | undefined {
16
- if (edge.style === 'dashed') return '8 4';
17
- if (edge.style === 'dotted') return '3 3';
18
- if (edge.type === 'references' && !edge.style) return '8 4';
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="12" style={{ cursor: 'pointer' }} />
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="6"
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.75}
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="3" fill={color} opacity="0.9">
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="-10"
162
- width={edge.label.length * 7 + 16}
163
- height="20"
164
- rx="4"
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)" font-size="11">
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="6"
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="2"
272
- stroke-dasharray="6 4"
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="5" fill="var(--c-accent)" opacity="0.5" />
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
- return Math.min(contentHeight + AUTO_FIT_TITLEBAR_HEIGHT, AUTO_FIT_MAX_HEIGHT);
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
+ }