@tangle-network/sandbox-ui 0.113.7 → 0.114.0
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/dist/WorkflowGraph-B-NsdYz_.js +1961 -0
- package/dist/assets.d.ts +214 -206
- package/dist/assets.js +1057 -908
- package/dist/auth-CWe17Hly.js +37 -0
- package/dist/auth.d.ts +2 -30
- package/dist/auth.js +2 -15
- package/dist/chat-BOvFAe6p.js +903 -0
- package/dist/chat.d.ts +2 -230
- package/dist/chat.js +2 -34
- package/dist/connectors.d.ts +52 -49
- package/dist/connectors.js +595 -643
- package/dist/dashboard-CZ3VgNPR.js +4969 -0
- package/dist/dashboard.d.ts +4 -1297
- package/dist/dashboard.js +4 -152
- package/dist/editor.d.ts +2 -1
- package/dist/editor.js +2 -31
- package/dist/files.d.ts +2 -6
- package/dist/files.js +2 -33
- package/dist/globals.css +63 -3
- package/dist/hooks.d.ts +146 -148
- package/dist/hooks.js +222 -55
- package/dist/index-CKRaj1pM.d.ts +776 -0
- package/dist/index-CXX5IpH2.d.ts +2 -0
- package/dist/index-DPePq7c8.d.ts +1340 -0
- package/dist/index-DiyLLZuJ.d.ts +230 -0
- package/dist/index-DvmZLMT1.d.ts +30 -0
- package/dist/index-I8ZLVtHd.d.ts +330 -0
- package/dist/index-PRM636Ex.d.ts +86 -0
- package/dist/index.d.ts +21 -33
- package/dist/index.js +21 -701
- package/dist/info-panel-C9cqDzOL.js +594 -0
- package/dist/integrations.d.ts +133 -141
- package/dist/integrations.js +491 -673
- package/dist/markdown.d.ts +2 -1
- package/dist/markdown.js +2 -13
- package/dist/model-I-NIxeM-.js +1250 -0
- package/dist/model-brand-B9A4H9pJ.d.ts +98 -0
- package/dist/model-brand-hMyDTUqB.js +624 -0
- package/dist/motion-DkyBRJb9.js +37 -0
- package/dist/openui.d.ts +2 -1
- package/dist/openui.js +2 -7
- package/dist/pages.d.ts +307 -300
- package/dist/pages.js +4404 -4181
- package/dist/primitives-Dh1vG6J3.js +113 -0
- package/dist/primitives.d.ts +2 -74
- package/dist/primitives.js +2 -204
- package/dist/provider-logo-DjqmConF.js +311 -0
- package/dist/run.d.ts +2 -1
- package/dist/run.js +2 -23
- package/dist/sdk-hooks.d.ts +3 -4
- package/dist/sdk-hooks.js +3 -31
- package/dist/sidecar-DgH_vQ6m.d.ts +9 -0
- package/dist/stores.d.ts +14 -15
- package/dist/stores.js +13 -81
- package/dist/styles.css +63 -3
- package/dist/template-card-L-4gmjx2.d.ts +90 -0
- package/dist/terminal-CN9ZgHRt.js +197 -0
- package/dist/terminal.d.ts +60 -60
- package/dist/terminal.js +2 -10
- package/dist/tokens.css +51 -0
- package/dist/types.d.ts +4 -11
- package/dist/types.js +0 -1
- package/dist/use-pty-session-C8cE0adT.js +669 -0
- package/dist/use-sidecar-auth-CCTJcrq7.d.ts +145 -0
- package/dist/use-sidecar-auth-DkdcufOz.js +742 -0
- package/dist/utils-DzWbktWG.js +2 -0
- package/dist/utils.d.ts +2 -1
- package/dist/utils.js +2 -37
- package/dist/workbench-BCaCKl-J.js +8347 -0
- package/dist/workbench.d.ts +2 -310
- package/dist/workbench.js +2 -44
- package/dist/workflows.d.ts +341 -339
- package/dist/workflows.js +74 -109
- package/dist/workspace-B6AtI6ld.js +2510 -0
- package/dist/workspace.d.ts +2 -761
- package/dist/workspace.js +2 -51
- package/package.json +46 -40
- package/dist/WorkflowGraph-2QOORYIR.js +0 -1722
- package/dist/chunk-2BUPSB7O.js +0 -0
- package/dist/chunk-3KBNPQQF.js +0 -2697
- package/dist/chunk-3LIXVEEE.js +0 -279
- package/dist/chunk-72UF2H5H.js +0 -707
- package/dist/chunk-AI25G5MX.js +0 -944
- package/dist/chunk-ANSC5AFC.js +0 -314
- package/dist/chunk-AZ3AWMTM.js +0 -8
- package/dist/chunk-BGVEHENC.js +0 -10
- package/dist/chunk-CZ7CSFO4.js +0 -39
- package/dist/chunk-DYXXYGS4.js +0 -4700
- package/dist/chunk-EI44GEQ5.js +0 -6
- package/dist/chunk-IACRDXER.js +0 -219
- package/dist/chunk-IOB2PW5Z.js +0 -55
- package/dist/chunk-M4K7ZGR2.js +0 -24
- package/dist/chunk-MYZAW4RX.js +0 -665
- package/dist/chunk-OMUIPJQZ.js +0 -555
- package/dist/chunk-RQZRXKMR.js +0 -398
- package/dist/chunk-SOKKTB7W.js +0 -230
- package/dist/chunk-TADGS4Q7.js +0 -1010
- package/dist/chunk-UCQNOUN3.js +0 -7964
- package/dist/chunk-ULPLRBMF.js +0 -34
- package/dist/chunk-WID73FPH.js +0 -89
- package/dist/chunk-X3YXP7FG.js +0 -38
- package/dist/logo-DiAjq759.d.ts +0 -15
- package/dist/model-brand-BNnaUKKZ.d.ts +0 -99
- package/dist/ports-list-CDIvTNnX.d.ts +0 -17
- package/dist/sdk-hooks-Djkjq4cp.d.ts +0 -145
- package/dist/template-card-Bn-Bw5xC.d.ts +0 -87
|
@@ -0,0 +1,1961 @@
|
|
|
1
|
+
import { r as modelBrandFor, t as ModelBrandStack } from "./model-brand-hMyDTUqB.js";
|
|
2
|
+
import { a as buildWorkflowGraph, c as triggerNodeIndex, d as providerLabel, f as shortModel, g as fmtTokens, h as fmtDuration, l as wfEdgeId, m as fmtCost, o as isStructuralKind, p as clampPreview, t as COMPACT_NODE_SIZE, u as worstSeverity } from "./model-I-NIxeM-.js";
|
|
3
|
+
import { t as ProviderIcon } from "./provider-logo-DjqmConF.js";
|
|
4
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import { AlertTriangle, Bell, Box, Cable, Camera, Circle, Clock, Code, GitBranch, Maximize2, Minimize2, Plus, Repeat, ScanSearch, Sparkles, UserCheck, Webhook, X, Zap } from "lucide-react";
|
|
7
|
+
import { Background, BaseEdge, Controls, EdgeLabelRenderer, Handle, MarkerType, Panel, Position, ReactFlow, getSmoothStepPath, getViewportForBounds, useEdgesState, useNodesState } from "@xyflow/react";
|
|
8
|
+
import "@xyflow/react/dist/style.css";
|
|
9
|
+
//#region src/workflows/flow-graph.ts
|
|
10
|
+
/**
|
|
11
|
+
* React Flow node/edge construction for the workflow graph. Kept separate from
|
|
12
|
+
* the React-Flow-agnostic parsing in `model.ts` and from the component itself so
|
|
13
|
+
* the live `nodeState` merge — the load-bearing wiring of the run view — is pure
|
|
14
|
+
* and unit-testable without rendering React Flow.
|
|
15
|
+
*/
|
|
16
|
+
/** Edge type name for the custom renderer that draws guard chips and cycle
|
|
17
|
+
* badges. Used ONLY by edges that need one — everything else stays on the
|
|
18
|
+
* built-in `smoothstep`, so a graph without a declared topology renders
|
|
19
|
+
* exactly as it always has. */
|
|
20
|
+
const WF_EDGE_TYPE = "wfEdge";
|
|
21
|
+
/** The layouter's compass sides in React Flow's terms — the one place the
|
|
22
|
+
* RF-agnostic layout vocabulary is translated. */
|
|
23
|
+
const SIDE_POSITION = {
|
|
24
|
+
left: Position.Left,
|
|
25
|
+
right: Position.Right,
|
|
26
|
+
top: Position.Top,
|
|
27
|
+
bottom: Position.Bottom
|
|
28
|
+
};
|
|
29
|
+
/** A bare run-state map (`buildFlowGraph(yaml, { a0: { status: "running" } })`),
|
|
30
|
+
* recognized by what it CONTAINS: every value is a run state, i.e. an object with
|
|
31
|
+
* a `status`. */
|
|
32
|
+
function isNodeStateMap(arg) {
|
|
33
|
+
const values = Object.values(arg);
|
|
34
|
+
return values.length > 0 && values.every((v) => v !== null && typeof v === "object" && "status" in v);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Every key {@link BuildFlowGraphOptions} declares. An options object is
|
|
38
|
+
* recognized by carrying one of them, so a key missing here makes the whole
|
|
39
|
+
* object fall through to the run-state test below — which it fails, silently
|
|
40
|
+
* losing ALL of its options rather than the one that was forgotten.
|
|
41
|
+
*
|
|
42
|
+
* The completeness assertion under it turns that into a BUILD error: adding an
|
|
43
|
+
* option without adding it here stops compiling, rather than shipping a graph
|
|
44
|
+
* that quietly ignores the caller. Written as a tuple whose membership the
|
|
45
|
+
* option keys must be assignable to.
|
|
46
|
+
*/
|
|
47
|
+
const FLOW_GRAPH_OPTION_KEYS = [
|
|
48
|
+
"nodeState",
|
|
49
|
+
"direction",
|
|
50
|
+
"compact",
|
|
51
|
+
"wrap",
|
|
52
|
+
"edges",
|
|
53
|
+
"reserveEdgeInsert"
|
|
54
|
+
];
|
|
55
|
+
/**
|
|
56
|
+
* Accept either an options object or a bare `nodeState` map as the second arg, so
|
|
57
|
+
* an older positional caller keeps working instead of silently losing its run
|
|
58
|
+
* state.
|
|
59
|
+
*
|
|
60
|
+
* Both branches are POSITIVE tests — known option keys, or values that are run
|
|
61
|
+
* states. Nothing falls through to "must be a node-state map", because that is how
|
|
62
|
+
* an argument the graph doesn't understand (a dropped option, a typo, a key from a
|
|
63
|
+
* newer version) gets read as run state: `nodeState` would then be defined, which
|
|
64
|
+
* is the signal for "a run overlay is in play", and the graph would silently
|
|
65
|
+
* reserve run rows for a definition that has no run at all. An unrecognized shape
|
|
66
|
+
* is no options, not imaginary run state.
|
|
67
|
+
*/
|
|
68
|
+
function normalizeFlowGraphOptions(arg) {
|
|
69
|
+
if (!arg) return {};
|
|
70
|
+
if (FLOW_GRAPH_OPTION_KEYS.some((key) => key in arg)) return arg;
|
|
71
|
+
return isNodeStateMap(arg) ? { nodeState: arg } : {};
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Build the React Flow nodes/edges from workflow YAML, merging any live
|
|
75
|
+
* `nodeState` (keyed by node id) onto the matching node's data so the node
|
|
76
|
+
* component renders status/cost/output without a separate channel. A node with
|
|
77
|
+
* no entry in `nodeState` keeps its static data object unchanged (same
|
|
78
|
+
* reference), so the static-definition preview path is untouched. Each node
|
|
79
|
+
* carries its authoritative size and orientation-driven handle sides so the
|
|
80
|
+
* card renders at exactly the laid-out box and edges enter/leave the right edge.
|
|
81
|
+
*/
|
|
82
|
+
function buildFlowGraph(yaml, optionsOrNodeState) {
|
|
83
|
+
const options = normalizeFlowGraphOptions(optionsOrNodeState);
|
|
84
|
+
const nodeState = options.nodeState;
|
|
85
|
+
const direction = options.direction ?? "LR";
|
|
86
|
+
const compact = options.compact ?? false;
|
|
87
|
+
const graph = buildWorkflowGraph(yaml, {
|
|
88
|
+
reserveRunState: nodeState !== void 0,
|
|
89
|
+
direction,
|
|
90
|
+
compact,
|
|
91
|
+
...options.wrap ? { wrap: true } : {},
|
|
92
|
+
...options.edges ? { edges: options.edges } : {},
|
|
93
|
+
...options.reserveEdgeInsert ? { reserveEdgeInsert: true } : {}
|
|
94
|
+
});
|
|
95
|
+
const isLR = direction === "LR";
|
|
96
|
+
const sourcePosition = isLR ? Position.Right : Position.Bottom;
|
|
97
|
+
const targetPosition = isLR ? Position.Left : Position.Top;
|
|
98
|
+
return {
|
|
99
|
+
error: graph.error,
|
|
100
|
+
nodes: graph.nodes.map((n) => ({
|
|
101
|
+
id: n.id,
|
|
102
|
+
type: "wfNode",
|
|
103
|
+
position: n.position,
|
|
104
|
+
width: n.width,
|
|
105
|
+
height: n.height,
|
|
106
|
+
deletable: false,
|
|
107
|
+
style: {
|
|
108
|
+
width: n.width,
|
|
109
|
+
height: n.height
|
|
110
|
+
},
|
|
111
|
+
sourcePosition: n.sourceSide ? SIDE_POSITION[n.sourceSide] : sourcePosition,
|
|
112
|
+
targetPosition: n.targetSide ? SIDE_POSITION[n.targetSide] : targetPosition,
|
|
113
|
+
data: nodeState?.[n.id] ? {
|
|
114
|
+
...n.data,
|
|
115
|
+
state: nodeState[n.id]
|
|
116
|
+
} : n.data
|
|
117
|
+
})),
|
|
118
|
+
edges: graph.edges.map((e) => {
|
|
119
|
+
const decorated = e.whenLabel !== void 0 || e.backEdge === true;
|
|
120
|
+
return {
|
|
121
|
+
id: e.id,
|
|
122
|
+
source: e.source,
|
|
123
|
+
target: e.target,
|
|
124
|
+
type: decorated ? WF_EDGE_TYPE : "smoothstep",
|
|
125
|
+
data: {
|
|
126
|
+
kind: e.kind,
|
|
127
|
+
...e.whenLabel !== void 0 ? { whenLabel: e.whenLabel } : {},
|
|
128
|
+
...e.backEdge === true ? { backEdge: true } : {}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
})
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Shallow-equal two run states, so an unchanged node can be skipped on a tick.
|
|
135
|
+
* Compares over the union of keys rather than a hand-listed field set, so a new
|
|
136
|
+
* WfNodeState field can't be silently ignored here (which would leave a node
|
|
137
|
+
* stale when only that field changed). WfNodeState is a flat record of
|
|
138
|
+
* primitives, so a shallow compare is exact. */
|
|
139
|
+
function sameRunState(a, b) {
|
|
140
|
+
if (a === b) return true;
|
|
141
|
+
if (!a || !b) return false;
|
|
142
|
+
const aKeys = Object.keys(a);
|
|
143
|
+
const bKeys = Object.keys(b);
|
|
144
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
145
|
+
return aKeys.every((k) => a[k] === b[k]);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Merge live `nodeState` onto an EXISTING React Flow node list, returning a new
|
|
149
|
+
* list that updates only each node's `data` and preserves its identity (object
|
|
150
|
+
* reference), position, and React-Flow-measured size. The static structure comes
|
|
151
|
+
* from `baseDataById` (the definition-only node data, keyed by id), NOT from the
|
|
152
|
+
* previous node, so `state` never accumulates across ticks. A node whose state is
|
|
153
|
+
* unchanged is returned AS-IS (same reference) so React Flow doesn't re-render or
|
|
154
|
+
* re-measure it — this is what keeps a poll/SSE tick from blanking the canvas.
|
|
155
|
+
*/
|
|
156
|
+
function mergeRunState(prev, baseDataById, nodeState) {
|
|
157
|
+
return prev.map((n) => {
|
|
158
|
+
const base = baseDataById.get(n.id) ?? n.data;
|
|
159
|
+
const s = nodeState?.[n.id];
|
|
160
|
+
const nextData = s ? {
|
|
161
|
+
...base,
|
|
162
|
+
state: s
|
|
163
|
+
} : base;
|
|
164
|
+
return sameRunState(n.data.state, nextData.state) ? n : {
|
|
165
|
+
...n,
|
|
166
|
+
data: nextData
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
/** Group problems by node id and by edge id (see {@link wfEdgeId}). */
|
|
171
|
+
function indexProblems(problems) {
|
|
172
|
+
const byNode = /* @__PURE__ */ new Map();
|
|
173
|
+
const byEdge = /* @__PURE__ */ new Map();
|
|
174
|
+
const named = (value) => typeof value === "string" && value !== "";
|
|
175
|
+
for (const problem of problems ?? []) {
|
|
176
|
+
const into = problem.anchor === "node" ? named(problem.node) ? byNode : null : problem.anchor === "edge" && named(problem.from) && named(problem.to) ? byEdge : null;
|
|
177
|
+
if (!into) continue;
|
|
178
|
+
const key = problem.anchor === "node" ? problem.node : wfEdgeId(problem.from, problem.to);
|
|
179
|
+
const at = into.get(key);
|
|
180
|
+
if (at) at.push(problem);
|
|
181
|
+
else into.set(key, [problem]);
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
byNode,
|
|
185
|
+
byEdge
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** How far past a node's edge a cluster's own EDGE must sit to clear the card. */
|
|
189
|
+
const INSERT_CLEARANCE = 8;
|
|
190
|
+
/**
|
|
191
|
+
* Half the box a cluster occupies, in flow units, by what it carries.
|
|
192
|
+
*
|
|
193
|
+
* The label layer lives inside the zoomed viewport, so a 20px control is 20
|
|
194
|
+
* units at every zoom and these numbers hold at any scale. They are bounds
|
|
195
|
+
* rather than measurements: the control is a fixed 20px square, and the chip
|
|
196
|
+
* TRUNCATES at Tailwind's `max-w-40`, so 160 is the widest it can ever render.
|
|
197
|
+
* Nothing here has to be measured, which is what keeps the placement
|
|
198
|
+
* collision-free by construction rather than by a measure-then-reflow pass.
|
|
199
|
+
*
|
|
200
|
+
* Both axes are given because the cluster is not square and the flow direction
|
|
201
|
+
* decides which of them is the cross axis: a chip is wide and short, so in "LR"
|
|
202
|
+
* its width spans the corridor while in "TB" that same width is what has to
|
|
203
|
+
* clear the cards.
|
|
204
|
+
*/
|
|
205
|
+
const CLUSTER_HALF_SIZE = {
|
|
206
|
+
/** The insert control alone: a 20-unit square. */
|
|
207
|
+
control: {
|
|
208
|
+
width: 10,
|
|
209
|
+
height: 10
|
|
210
|
+
},
|
|
211
|
+
/**
|
|
212
|
+
* A problem chip — and, since the two stack in one column, the chip WITH the
|
|
213
|
+
* control under it, which is the taller of the two and so the one the bound
|
|
214
|
+
* has to cover. Measured on the rendered page at 160 x 38.5 flow units (the
|
|
215
|
+
* chip's `max-w-40` cap, over chip + gap + control), and rounded outward: a
|
|
216
|
+
* bound that understates is a bound that lets the cluster lap over a card.
|
|
217
|
+
*/
|
|
218
|
+
chip: {
|
|
219
|
+
width: 80,
|
|
220
|
+
height: 20
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
/**
|
|
224
|
+
* How many cards a cluster may step past before the nudge is abandoned.
|
|
225
|
+
*
|
|
226
|
+
* A BOX COUNT rather than a distance, because what has to be cleared scales with
|
|
227
|
+
* the layout: a node is 292 units across, so in "TB" — where that width is the
|
|
228
|
+
* cross axis — moving a control off the card it sits on costs over 160, while
|
|
229
|
+
* the same node in "LR" costs about 50. Any fixed distance is therefore either
|
|
230
|
+
* too small to clear one standard card in one orientation, or too loose to mean
|
|
231
|
+
* anything in the other. Counting cards says what was actually meant: clearing
|
|
232
|
+
* the card you are on is the whole point, the next one is its neighbour in a
|
|
233
|
+
* stacked layer and is cheap, and past that the cluster is crossing a dense
|
|
234
|
+
* fan-out and would end up nowhere near the edge it belongs to — where leaving
|
|
235
|
+
* it overlapping, still clickable through the raised label layer, is the better
|
|
236
|
+
* of two bad answers.
|
|
237
|
+
*/
|
|
238
|
+
const INSERT_MAX_CARDS_CROSSED = 2;
|
|
239
|
+
/**
|
|
240
|
+
* The CROSS-axis shift that moves a point off whatever node boxes it lands in:
|
|
241
|
+
* 0 when it is already clear, and null when it is on a card that cannot be left
|
|
242
|
+
* within the bound. A caller has to be able to tell those two apart — only the
|
|
243
|
+
* second has to give up its pointer events.
|
|
244
|
+
*
|
|
245
|
+
* An edge spanning more than one layer runs THROUGH the layer it skips, so its
|
|
246
|
+
* midpoint — where a control would otherwise sit — is inside an unrelated card.
|
|
247
|
+
* The reserved corridor only ever clears an adjacent-layer edge, because that is
|
|
248
|
+
* the only gap the layout can widen. Nudging along the cross axis keeps the
|
|
249
|
+
* control beside its own edge and off the card it was covering.
|
|
250
|
+
*
|
|
251
|
+
* Both directions are walked, so a stack of cards in the skipped layer is
|
|
252
|
+
* escaped rather than jumped into; the smaller shift wins, and a point that
|
|
253
|
+
* cannot be cleared inside {@link INSERT_MAX_CARDS_CROSSED} keeps its place, and
|
|
254
|
+
* says so by answering null rather than an offset.
|
|
255
|
+
*/
|
|
256
|
+
function clearOfNodeBoxes(point, boxes, direction, half) {
|
|
257
|
+
const isLR = direction === "LR";
|
|
258
|
+
const base = isLR ? point.y : point.x;
|
|
259
|
+
const main = isLR ? point.x : point.y;
|
|
260
|
+
const padMain = isLR ? half.width : half.height;
|
|
261
|
+
const padCross = isLR ? half.height : half.width;
|
|
262
|
+
const column = boxes.filter((b) => {
|
|
263
|
+
const lo = (isLR ? b.x : b.y) - padMain;
|
|
264
|
+
const size = (isLR ? b.width : b.height) + padMain * 2;
|
|
265
|
+
return main >= lo && main <= lo + size;
|
|
266
|
+
});
|
|
267
|
+
if (column.length === 0) return 0;
|
|
268
|
+
const walk = (sign) => {
|
|
269
|
+
let at = base;
|
|
270
|
+
for (let crossed = 0; crossed <= INSERT_MAX_CARDS_CROSSED; crossed += 1) {
|
|
271
|
+
const hit = column.find((b) => {
|
|
272
|
+
const lo = (isLR ? b.y : b.x) - padCross;
|
|
273
|
+
const size = (isLR ? b.height : b.width) + padCross * 2;
|
|
274
|
+
return at >= lo && at <= lo + size;
|
|
275
|
+
});
|
|
276
|
+
if (!hit) return at - base;
|
|
277
|
+
if (crossed === INSERT_MAX_CARDS_CROSSED) return null;
|
|
278
|
+
const lo = isLR ? hit.y : hit.x;
|
|
279
|
+
const size = isLR ? hit.height : hit.width;
|
|
280
|
+
at = sign > 0 ? lo + size + padCross + INSERT_CLEARANCE : lo - padCross - INSERT_CLEARANCE;
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
};
|
|
284
|
+
const up = walk(-1);
|
|
285
|
+
const down = walk(1);
|
|
286
|
+
if (up === null && down === null) return null;
|
|
287
|
+
if (up === null) return down;
|
|
288
|
+
if (down === null) return up;
|
|
289
|
+
return Math.abs(up) <= Math.abs(down) ? up : down;
|
|
290
|
+
}
|
|
291
|
+
//#endregion
|
|
292
|
+
//#region src/workflows/node-output.tsx
|
|
293
|
+
/** Failure text, in the semantic danger token — which carries a light AND a dark
|
|
294
|
+
* value. A palette shade (`text-red-400`) only ever reads well in one theme. */
|
|
295
|
+
const ERROR_TEXT = { color: "var(--surface-danger-text)" };
|
|
296
|
+
/** Max key/value pairs a shallow-object output renders before it reports
|
|
297
|
+
* `truncated` — the card only has room for a couple rows anyway. */
|
|
298
|
+
const MAX_JSON_ENTRIES = 6;
|
|
299
|
+
/**
|
|
300
|
+
* A line that is pure structure — a code fence (with or without a language tag), a
|
|
301
|
+
* table's rule row, a thematic break — and so carries no words to keep.
|
|
302
|
+
*
|
|
303
|
+
* Every test is a SINGLE quantified character class: unambiguous, and linear in the
|
|
304
|
+
* length of the line. Written as one pattern (`[\s|:-]+\|[\s|:-]*`) the two
|
|
305
|
+
* quantifiers would compete for the same `|`, which is the shape that backtracks.
|
|
306
|
+
* Recognize the charset in one pass instead, then ask the cheap questions — is
|
|
307
|
+
* there a pipe? a dash? — with plain string checks.
|
|
308
|
+
*/
|
|
309
|
+
function isStructureOnly(line) {
|
|
310
|
+
if (/^(```|~~~)/.test(line)) return true;
|
|
311
|
+
const isBreak = /^([-*_])\1{2,}$/.test(line);
|
|
312
|
+
if (/^[\s|:-]+$/.test(line) && line.includes("|")) return line.includes("-");
|
|
313
|
+
return isBreak;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Flatten an agent's markdown answer into the sentence a person would read out
|
|
317
|
+
* of it. On a two-line card preview the syntax IS the noise: a `## Heading` mid
|
|
318
|
+
* paragraph, a `**bold**` run and a bullet's `-` all survive the clamp while the
|
|
319
|
+
* words that carry the meaning get pushed past it. Stripping the markup (never
|
|
320
|
+
* the words) is what turns the preview back into prose — the full text, markup
|
|
321
|
+
* and all, is still rendered by the node's detail view.
|
|
322
|
+
*
|
|
323
|
+
* Structure-only lines (a fence, a table rule) are dropped; everything else keeps
|
|
324
|
+
* its text and is joined into one flowing line.
|
|
325
|
+
*/
|
|
326
|
+
function condenseText(text) {
|
|
327
|
+
const isListItem = (line) => /^\s*([-*+]\s|\d+[.)]\s)/.test(line);
|
|
328
|
+
return text.split("\n").map((line) => ({
|
|
329
|
+
item: isListItem(line),
|
|
330
|
+
text: line.replace(/^\s{0,3}#{1,6}\s+/, "").replace(/^\s{0,3}>\s?/, "").replace(/^\s*[-*+]\s+(\[[ xX]\]\s+)?/, "").replace(/^\s*\d+[.)]\s+/, "").trim()
|
|
331
|
+
})).filter(({ text: line }) => line !== "" && !isStructureOnly(line)).map(({ item, text: line }, i) => i > 0 && item ? `· ${line}` : line).join(" ").replace(/(?<=^|\s)(\*\*|~~)(?!\s)(.+?)(?<!\s)\1(?=[\s.,:;!?)\]}"']|$)/g, "$2").replace(/(?<![\w*])\*(?!\s)([^*]+?)(?<!\s)\*(?![\w*])/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\s+/g, " ").trim();
|
|
332
|
+
}
|
|
333
|
+
/** A single JSON value as a compact one-line string for the key/value render.
|
|
334
|
+
* Scalars render verbatim; a nested object/array collapses to a shape marker so
|
|
335
|
+
* one row never balloons into a wall of text. */
|
|
336
|
+
function scalar(value) {
|
|
337
|
+
if (value === null) return "null";
|
|
338
|
+
if (typeof value === "string") return value;
|
|
339
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
340
|
+
if (Array.isArray(value)) return `[${value.length} item${value.length === 1 ? "" : "s"}]`;
|
|
341
|
+
if (typeof value === "object") return "{…}";
|
|
342
|
+
return "";
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Recover the shape of an output preview string. Only text that *begins* like
|
|
346
|
+
* JSON (`{`/`[`) is parse-attempted, so ordinary prose starting with a brace-free
|
|
347
|
+
* sentence is never mis-parsed. A begins-like-JSON string that fails to parse
|
|
348
|
+
* (the host clamped it mid-value) still renders as `code`, not prose, so it keeps
|
|
349
|
+
* its monospace/data affordance.
|
|
350
|
+
*
|
|
351
|
+
* `condenseMarkdown` says the prose IS markdown and may be flattened for the
|
|
352
|
+
* preview ({@link condenseText}). It is OFF by default, and the caller must only
|
|
353
|
+
* turn it on for text an agent actually authored as markdown. Applied to anything
|
|
354
|
+
* else it does not merely look wrong — it silently REWRITES the text: a stack
|
|
355
|
+
* trace's `__init__.py` loses its underscores, and a shell glob `src/**` pairs
|
|
356
|
+
* with a later `**` and both vanish. An error message is the one string a person
|
|
357
|
+
* reads to find out what broke; it must reach them exactly as it was thrown.
|
|
358
|
+
*/
|
|
359
|
+
function classifyOutput(preview, condenseMarkdown = false) {
|
|
360
|
+
let trimmed = preview?.trim() ?? "";
|
|
361
|
+
trimmed = trimmed.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, "").replace(/(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "").trimEnd();
|
|
362
|
+
if (trimmed === "") return { kind: "empty" };
|
|
363
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) try {
|
|
364
|
+
const parsed = JSON.parse(trimmed);
|
|
365
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
366
|
+
const all = Object.entries(parsed);
|
|
367
|
+
const entries = all.slice(0, MAX_JSON_ENTRIES).map(([k, v]) => [k, scalar(v)]);
|
|
368
|
+
if (entries.length > 0) return {
|
|
369
|
+
kind: "json",
|
|
370
|
+
entries,
|
|
371
|
+
truncated: all.length > entries.length
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return {
|
|
375
|
+
kind: "code",
|
|
376
|
+
text: trimmed
|
|
377
|
+
};
|
|
378
|
+
} catch {
|
|
379
|
+
return {
|
|
380
|
+
kind: "code",
|
|
381
|
+
text: trimmed
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
if (!condenseMarkdown) return {
|
|
385
|
+
kind: "text",
|
|
386
|
+
text: trimmed
|
|
387
|
+
};
|
|
388
|
+
const condensed = condenseText(trimmed);
|
|
389
|
+
return condensed === "" ? { kind: "empty" } : {
|
|
390
|
+
kind: "text",
|
|
391
|
+
text: condensed
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
/** Line-clamp utility per row budget, so `code`/`text` shapes honor `rows` the
|
|
395
|
+
* same way the JSON branch does (rather than always clamping to two). Covers the
|
|
396
|
+
* full range a card can reserve; an unsupported count falls back to the two-line
|
|
397
|
+
* clamp, which renders FEWER lines than the caller asked for — so a card whose
|
|
398
|
+
* reservation outgrows this map shows a short body inside a tall well rather
|
|
399
|
+
* than erroring. Extend the map when a design reserves more.
|
|
400
|
+
* Class names are literal so the Tailwind scanner emits each utility. */
|
|
401
|
+
const CLAMP_BY_ROWS = {
|
|
402
|
+
1: "truncate",
|
|
403
|
+
2: "line-clamp-2",
|
|
404
|
+
3: "line-clamp-3",
|
|
405
|
+
4: "line-clamp-4",
|
|
406
|
+
5: "line-clamp-5",
|
|
407
|
+
6: "line-clamp-6"
|
|
408
|
+
};
|
|
409
|
+
/**
|
|
410
|
+
* Render a classified output preview's CONTENT (no surrounding frame — each node
|
|
411
|
+
* design supplies its own container/label). `rows` caps the visible lines so the
|
|
412
|
+
* body stays within a card's reserved output space; `tone="error"` colors it for
|
|
413
|
+
* a failure message.
|
|
414
|
+
*
|
|
415
|
+
* `rows="none"` renders the body UNCLAMPED, for a container that scrolls or is
|
|
416
|
+
* sized by its content — a drawer, a detail panel, a selection overlay. It is a
|
|
417
|
+
* word rather than a large number because there is no number that means "do not
|
|
418
|
+
* clamp": a count outside {@link CLAMP_BY_ROWS} falls back to two lines, so
|
|
419
|
+
* `rows={99}` asks for everything and silently shows the least.
|
|
420
|
+
*/
|
|
421
|
+
function NodeOutputBody({ shape, rows = 2, tone = "default" }) {
|
|
422
|
+
const unclamped = rows === "none";
|
|
423
|
+
const clampClass = unclamped ? "" : CLAMP_BY_ROWS[rows] ?? "line-clamp-2";
|
|
424
|
+
const codeWrap = rows === 1 ? "" : "whitespace-pre-wrap break-all";
|
|
425
|
+
if (shape.kind === "empty") return null;
|
|
426
|
+
if (shape.kind === "json") {
|
|
427
|
+
const overflow = unclamped ? shape.truncated : shape.truncated || shape.entries.length > rows;
|
|
428
|
+
const visible = unclamped ? shape.entries : shape.entries.slice(0, overflow ? Math.max(0, rows - 1) : rows);
|
|
429
|
+
const isError = tone === "error";
|
|
430
|
+
const keyStyle = isError ? ERROR_TEXT : void 0;
|
|
431
|
+
const keyClass = isError ? "" : "text-muted-foreground";
|
|
432
|
+
const valueClass = isError ? "" : "text-foreground";
|
|
433
|
+
return /* @__PURE__ */ jsxs("dl", {
|
|
434
|
+
className: "space-y-0.5 font-mono text-[10.5px] leading-snug",
|
|
435
|
+
children: [visible.map(([key, value]) => /* @__PURE__ */ jsxs("div", {
|
|
436
|
+
className: "flex gap-1.5",
|
|
437
|
+
children: [/* @__PURE__ */ jsx("dt", {
|
|
438
|
+
className: `max-w-[45%] shrink-0 truncate ${keyClass}`,
|
|
439
|
+
style: keyStyle,
|
|
440
|
+
title: key,
|
|
441
|
+
children: key
|
|
442
|
+
}), /* @__PURE__ */ jsx("dd", {
|
|
443
|
+
className: `min-w-0 flex-1 truncate ${valueClass}`,
|
|
444
|
+
style: isError ? ERROR_TEXT : void 0,
|
|
445
|
+
title: value,
|
|
446
|
+
children: value
|
|
447
|
+
})]
|
|
448
|
+
}, key)), overflow && /* @__PURE__ */ jsx("div", {
|
|
449
|
+
className: `text-[10px] ${keyClass}`,
|
|
450
|
+
style: keyStyle,
|
|
451
|
+
children: "…"
|
|
452
|
+
})]
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
if (shape.kind === "code") return /* @__PURE__ */ jsx("pre", {
|
|
456
|
+
className: `${clampClass} ${codeWrap} font-mono text-[10.5px] leading-snug ${tone === "error" ? "" : "text-foreground"}`,
|
|
457
|
+
style: tone === "error" ? ERROR_TEXT : void 0,
|
|
458
|
+
title: shape.text,
|
|
459
|
+
children: shape.text
|
|
460
|
+
});
|
|
461
|
+
return /* @__PURE__ */ jsx("p", {
|
|
462
|
+
className: `${clampClass} text-[11px] leading-snug ${tone === "error" ? "" : "text-foreground"}`,
|
|
463
|
+
style: tone === "error" ? ERROR_TEXT : void 0,
|
|
464
|
+
title: shape.text,
|
|
465
|
+
children: shape.text
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
//#endregion
|
|
469
|
+
//#region src/workflows/node-ui.tsx
|
|
470
|
+
/**
|
|
471
|
+
* Shared presentational atoms for a workflow graph node — the type glyph, the
|
|
472
|
+
* status color/label/badge maps, and the edge coloring. One source of truth so
|
|
473
|
+
* the node card and the edges that point at it read as one system.
|
|
474
|
+
*
|
|
475
|
+
* Colors resolve to the SEMANTIC surface tokens (`--surface-success-text`,
|
|
476
|
+
* `--surface-danger-text`, …), which carry a light AND a dark value tuned for
|
|
477
|
+
* contrast against the card. A literal palette hex (`#22c55e`) or a stock
|
|
478
|
+
* Tailwind shade (`text-red-400`) carries ONE value, so it can only ever hold its
|
|
479
|
+
* contrast in one of the two themes — never use one for a status color.
|
|
480
|
+
*
|
|
481
|
+
* Inline `style` colors here use the RAW brand tokens (`hsl(var(--primary))`,
|
|
482
|
+
* `var(--surface-…)`) — NOT the `--color-*` @theme aliases, which are undefined
|
|
483
|
+
* in a consumer that only imports `tokens.css` (e.g. platform-web), where a
|
|
484
|
+
* `var(--color-*)` would silently resolve to nothing. See the edge-color test.
|
|
485
|
+
*/
|
|
486
|
+
/** Track color behind a progress bar — a faint wash of the muted token. */
|
|
487
|
+
const MUTED_TRACK = "color-mix(in srgb, hsl(var(--muted-foreground)) 22%, transparent)";
|
|
488
|
+
const TONE_ACCENT = {
|
|
489
|
+
trigger: "hsl(var(--primary))",
|
|
490
|
+
structural: "var(--surface-warning-text)",
|
|
491
|
+
action: "hsl(var(--muted-foreground))"
|
|
492
|
+
};
|
|
493
|
+
const STATUS_QUEUED = "hsl(var(--muted-foreground))";
|
|
494
|
+
const STATUS_COLOR = {
|
|
495
|
+
queued: STATUS_QUEUED,
|
|
496
|
+
running: "hsl(var(--primary))",
|
|
497
|
+
waiting: "var(--surface-warning-text)",
|
|
498
|
+
succeeded: "var(--surface-success-text)",
|
|
499
|
+
failed: "var(--surface-danger-text)"
|
|
500
|
+
};
|
|
501
|
+
/** An edge is colored by the status of the node it points AT, so the run's
|
|
502
|
+
* "front" lights up. Neutral for a not-yet-reached (queued) target or the
|
|
503
|
+
* static definition view (`undefined`). */
|
|
504
|
+
function edgeColor(status) {
|
|
505
|
+
return status ? STATUS_COLOR[status] : STATUS_QUEUED;
|
|
506
|
+
}
|
|
507
|
+
const STATUS_LABEL = {
|
|
508
|
+
queued: "Queued",
|
|
509
|
+
running: "Running",
|
|
510
|
+
waiting: "Waiting on you",
|
|
511
|
+
succeeded: "Done",
|
|
512
|
+
failed: "Failed"
|
|
513
|
+
};
|
|
514
|
+
/** Status pill styling — a tinted well in the status color. Built from the
|
|
515
|
+
* semantic surface trio (bg/border/text) so the pill keeps its contrast in both
|
|
516
|
+
* themes rather than washing out in one of them. */
|
|
517
|
+
const STATUS_PILL = {
|
|
518
|
+
queued: {
|
|
519
|
+
background: "color-mix(in srgb, hsl(var(--muted-foreground)) 12%, transparent)",
|
|
520
|
+
color: "hsl(var(--muted-foreground))",
|
|
521
|
+
borderColor: "color-mix(in srgb, hsl(var(--muted-foreground)) 28%, transparent)"
|
|
522
|
+
},
|
|
523
|
+
running: {
|
|
524
|
+
background: "color-mix(in srgb, hsl(var(--primary)) 14%, transparent)",
|
|
525
|
+
color: "hsl(var(--primary))",
|
|
526
|
+
borderColor: "color-mix(in srgb, hsl(var(--primary)) 40%, transparent)"
|
|
527
|
+
},
|
|
528
|
+
waiting: {
|
|
529
|
+
background: "var(--surface-warning-bg)",
|
|
530
|
+
color: "var(--surface-warning-text)",
|
|
531
|
+
borderColor: "var(--surface-warning-border)"
|
|
532
|
+
},
|
|
533
|
+
succeeded: {
|
|
534
|
+
background: "var(--surface-success-bg)",
|
|
535
|
+
color: "var(--surface-success-text)",
|
|
536
|
+
borderColor: "var(--surface-success-border)"
|
|
537
|
+
},
|
|
538
|
+
failed: {
|
|
539
|
+
background: "var(--surface-danger-bg)",
|
|
540
|
+
color: "var(--surface-danger-text)",
|
|
541
|
+
borderColor: "var(--surface-danger-border)"
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
/** The card's border + ring once a node has live run state, so the running node
|
|
545
|
+
* and the terminal ones read at a glance. Returned as inline style (not classes)
|
|
546
|
+
* because the status colors are semantic tokens, not palette shades. */
|
|
547
|
+
function statusBorder(status) {
|
|
548
|
+
const color = STATUS_COLOR[status];
|
|
549
|
+
switch (status) {
|
|
550
|
+
case "running":
|
|
551
|
+
case "waiting": return {
|
|
552
|
+
borderColor: color,
|
|
553
|
+
boxShadow: `0 0 0 1px color-mix(in srgb, ${color} 45%, transparent), 0 0 24px -6px ${color}`
|
|
554
|
+
};
|
|
555
|
+
case "queued": return { borderColor: "hsl(var(--border))" };
|
|
556
|
+
default: return {
|
|
557
|
+
borderColor: color,
|
|
558
|
+
boxShadow: `0 0 0 1px color-mix(in srgb, ${color} 35%, transparent)`
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
/** Determinate progress fraction for a node's status: queued reads near-empty,
|
|
563
|
+
* running and waiting a partial (the run reached this node but has not finished
|
|
564
|
+
* it), terminal full. Shared so every progress treatment (bar, footer, fill) maps
|
|
565
|
+
* status → fill identically. A `waiting` bar is deliberately NOT animated by its
|
|
566
|
+
* callers: the run is stopped there, and a moving bar would say otherwise. */
|
|
567
|
+
function progressFill(status) {
|
|
568
|
+
return status === "succeeded" || status === "failed" ? "100%" : status === "running" || status === "waiting" ? "58%" : "6%";
|
|
569
|
+
}
|
|
570
|
+
const KIND_ICON = {
|
|
571
|
+
schedule: Clock,
|
|
572
|
+
provider_event: Webhook,
|
|
573
|
+
webhook: Webhook,
|
|
574
|
+
trigger: Zap,
|
|
575
|
+
"agent.run": Sparkles,
|
|
576
|
+
"integration.invoke": Cable,
|
|
577
|
+
notify: Bell,
|
|
578
|
+
parallel: GitBranch,
|
|
579
|
+
foreach: Repeat,
|
|
580
|
+
decision: UserCheck,
|
|
581
|
+
"sandbox.spawn": Box,
|
|
582
|
+
"sandbox.snapshot": Camera,
|
|
583
|
+
"script.run": Code,
|
|
584
|
+
"trace.analyze": ScanSearch
|
|
585
|
+
};
|
|
586
|
+
/** An authoring problem's colours — the same semantic surface trio the run
|
|
587
|
+
* statuses use, so a broken step and a failed one are recognisably the same
|
|
588
|
+
* family of "something is wrong here" without being the same signal. */
|
|
589
|
+
const PROBLEM_SURFACE_BY_SEVERITY = {
|
|
590
|
+
error: {
|
|
591
|
+
background: "var(--surface-danger-bg)",
|
|
592
|
+
color: "var(--surface-danger-text)",
|
|
593
|
+
borderColor: "var(--surface-danger-border)"
|
|
594
|
+
},
|
|
595
|
+
warning: {
|
|
596
|
+
background: "var(--surface-warning-bg)",
|
|
597
|
+
color: "var(--surface-warning-text)",
|
|
598
|
+
borderColor: "var(--surface-warning-border)"
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
/**
|
|
602
|
+
* The colours a severity is drawn in. A lookup rather than the table itself, so
|
|
603
|
+
* a severity this library has no entry for cannot throw its way out of a render
|
|
604
|
+
* — the same resilience `problemSentence` and `worstSeverity` already give it,
|
|
605
|
+
* and for the same reason: the package is consumed from JavaScript, and one
|
|
606
|
+
* malformed problem must not take the canvas down. An unrecognised severity is
|
|
607
|
+
* drawn as an error, matching how it is named and ranked.
|
|
608
|
+
*/
|
|
609
|
+
function problemSurface(severity) {
|
|
610
|
+
return PROBLEM_SURFACE_BY_SEVERITY[severity] ?? PROBLEM_SURFACE_BY_SEVERITY.error;
|
|
611
|
+
}
|
|
612
|
+
/** A node's border once it carries an authoring problem. Applied only where the
|
|
613
|
+
* node has no RUN status to state — a run's border is the more urgent reading
|
|
614
|
+
* of the same edge of the same box, and a definition being edited has no run. */
|
|
615
|
+
function problemBorder(severity) {
|
|
616
|
+
const color = problemSurface(severity).color;
|
|
617
|
+
return {
|
|
618
|
+
borderColor: color,
|
|
619
|
+
boxShadow: `0 0 0 1px color-mix(in srgb, ${color} 35%, transparent)`
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
/** How each severity is named where a colour cannot be seen. */
|
|
623
|
+
const SEVERITY_WORD = {
|
|
624
|
+
error: "Error",
|
|
625
|
+
warning: "Warning"
|
|
626
|
+
};
|
|
627
|
+
/**
|
|
628
|
+
* One problem as a reader receives it: WHICH KIND it is, then what it says.
|
|
629
|
+
*
|
|
630
|
+
* The kind has to be in the words. A mark shows one aggregate severity — as a
|
|
631
|
+
* colour and a glyph, neither of which a screen reader receives — so an anchor
|
|
632
|
+
* carrying an error and a warning together would otherwise offer no way to tell
|
|
633
|
+
* which of the two is the one blocking the compile.
|
|
634
|
+
*
|
|
635
|
+
* NEWLINES are the only thing rewritten: each line is trimmed and the lines are
|
|
636
|
+
* run together, so a multi-line host message cannot break the one-problem-per-
|
|
637
|
+
* line reading below. Everything else survives — including internal spacing and
|
|
638
|
+
* the trailing punctuation a diagnostic may be ABOUT ("unexpected ;") — because
|
|
639
|
+
* the canvas has to say what the host's own problem list says. Separators are
|
|
640
|
+
* handled at the join instead ({@link joinSentences}).
|
|
641
|
+
*
|
|
642
|
+
* A problem with no message at all is named by its severity alone, rather than
|
|
643
|
+
* by a colon with nothing after it: nothing in {@link WfProblem} requires the
|
|
644
|
+
* text to be non-empty, and "this step has an error" is still worth saying.
|
|
645
|
+
*/
|
|
646
|
+
function problemSentence(problem) {
|
|
647
|
+
const message = (typeof problem.message === "string" ? problem.message : "").split("\n").map((line) => line.trim()).filter(Boolean).join(" ");
|
|
648
|
+
const word = SEVERITY_WORD[problem.severity] ?? SEVERITY_WORD.error;
|
|
649
|
+
return message ? `${word}: ${message}` : word;
|
|
650
|
+
}
|
|
651
|
+
/** Run sentences together for a listener, adding a full stop only where the text
|
|
652
|
+
* does not already end in one — so "Why?" is followed by a space rather than
|
|
653
|
+
* "Why?.", and a message ending in a semicolon keeps it. Empty in, empty out:
|
|
654
|
+
* `reduce` with no seed throws on an empty list, and this is reachable from any
|
|
655
|
+
* caller holding an empty problem set. */
|
|
656
|
+
function joinSentences(parts) {
|
|
657
|
+
return parts.reduce((joined, part) => joined ? `${joined}${/[.!?;,]$/.test(joined) ? "" : "."} ${part}` : part, "");
|
|
658
|
+
}
|
|
659
|
+
/** Every problem on one anchor, as the tooltip text a marker hangs on. One per
|
|
660
|
+
* line, so a step with three faults lists three rather than concatenating them
|
|
661
|
+
* into a sentence nobody can parse. */
|
|
662
|
+
function problemTitle(problems) {
|
|
663
|
+
return problems.map(problemSentence).join("\n");
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Every message on one anchor, as the text a reader who cannot hover receives.
|
|
667
|
+
* The visible mark has room for a glyph and a count, so the messages themselves
|
|
668
|
+
* would otherwise live only in a `title` — which a pointer opens and a keyboard,
|
|
669
|
+
* a screen reader and a touch device do not. Rendering them visually hidden puts
|
|
670
|
+
* the same words in the accessibility tree, where they become the mark's own
|
|
671
|
+
* accessible name.
|
|
672
|
+
*/
|
|
673
|
+
function ProblemMessages({ problems }) {
|
|
674
|
+
if (problems.length === 0) return null;
|
|
675
|
+
return /* @__PURE__ */ jsx("span", {
|
|
676
|
+
className: "sr-only",
|
|
677
|
+
children: problems.length === 1 ? problemSentence(problems[0]) : `${problems.length} problems. ${joinSentences(problems.map(problemSentence))}`
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* The mark a node wears when the draft it is drawn from has a problem on it: a
|
|
682
|
+
* warning glyph, with a count once there is more than one. It is a READOUT, not
|
|
683
|
+
* a control — the node's own click already opens whatever the host shows for it,
|
|
684
|
+
* and a second target inside the card would compete with that.
|
|
685
|
+
*
|
|
686
|
+
* It carries every message twice over: on `title`, where a pointer finds it, and
|
|
687
|
+
* as visually hidden text, which is what the accessibility tree reads. That is
|
|
688
|
+
* what makes the mark worth more than the border tint — the canvas says WHICH
|
|
689
|
+
* step is broken at a glance and WHY without a trip to the problem list, however
|
|
690
|
+
* the reader is driving it.
|
|
691
|
+
*/
|
|
692
|
+
function ProblemMarker({ problems, className = "" }) {
|
|
693
|
+
const severity = worstSeverity(problems) ?? "error";
|
|
694
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
695
|
+
"data-testid": "wf-node-problem",
|
|
696
|
+
"data-severity": severity,
|
|
697
|
+
className: `flex shrink-0 items-center gap-0.5 rounded-full border px-1 py-[1px] font-medium text-[10px] leading-none ${className}`,
|
|
698
|
+
style: problemSurface(severity),
|
|
699
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
700
|
+
"aria-hidden": true,
|
|
701
|
+
className: "flex items-center gap-0.5",
|
|
702
|
+
title: problemTitle(problems),
|
|
703
|
+
children: [/* @__PURE__ */ jsx(AlertTriangle, { size: 9 }), problems.length > 1 && /* @__PURE__ */ jsx("span", { children: problems.length })]
|
|
704
|
+
}), /* @__PURE__ */ jsx(ProblemMessages, { problems })]
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
/** The status pill in a card's header — the one place the run state is spelled
|
|
708
|
+
* out in words. */
|
|
709
|
+
function StatusPill({ status }) {
|
|
710
|
+
return /* @__PURE__ */ jsx("span", {
|
|
711
|
+
className: "shrink-0 whitespace-nowrap rounded-full border px-2 py-[1px] font-medium text-[10px]",
|
|
712
|
+
style: STATUS_PILL[status],
|
|
713
|
+
children: STATUS_LABEL[status]
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* The run status FOOTER: a progress bar (queued = near-empty, running = pulsing
|
|
718
|
+
* partial, terminal = full) over a caption row. Pinned to the card's BOTTOM via
|
|
719
|
+
* `mt-auto` so it lands at the same place on every node regardless of how much
|
|
720
|
+
* content sits above it — unlike an inline strip, whose height drifts. The
|
|
721
|
+
* caption reads agent rounds on the left and elapsed on the right; status itself
|
|
722
|
+
* is carried by the bar color + the header pill, so it is NOT restated here. The
|
|
723
|
+
* caption row always renders (even when empty) so the footer band keeps a
|
|
724
|
+
* constant height across nodes.
|
|
725
|
+
*/
|
|
726
|
+
function StatusFooter({ status, rounds, cost, tokens, elapsed }) {
|
|
727
|
+
const caption = [
|
|
728
|
+
rounds !== void 0 ? `${rounds} round${rounds === 1 ? "" : "s"}` : void 0,
|
|
729
|
+
cost,
|
|
730
|
+
tokens
|
|
731
|
+
].filter(Boolean).join(" · ");
|
|
732
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
733
|
+
className: "wf-node-body-in mt-auto border-border border-t",
|
|
734
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
735
|
+
className: "h-1 w-full overflow-hidden",
|
|
736
|
+
style: { background: MUTED_TRACK },
|
|
737
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
738
|
+
"data-testid": "wf-node-progress",
|
|
739
|
+
className: `h-full ${status === "running" ? "animate-pulse" : ""}`,
|
|
740
|
+
...status === "running" ? { "data-motion": "essential" } : {},
|
|
741
|
+
style: {
|
|
742
|
+
width: progressFill(status),
|
|
743
|
+
background: STATUS_COLOR[status]
|
|
744
|
+
}
|
|
745
|
+
})
|
|
746
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
747
|
+
className: "flex items-center justify-between gap-2 px-3.5 py-1 text-[10px] text-muted-foreground leading-[15px]",
|
|
748
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
749
|
+
className: "min-h-[15px] truncate tabular-nums",
|
|
750
|
+
children: caption
|
|
751
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
752
|
+
className: "min-h-[15px] shrink-0 tabular-nums",
|
|
753
|
+
children: elapsed ?? ""
|
|
754
|
+
})]
|
|
755
|
+
})]
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* What an expanded card says where its output WOULD be, when it has none. The
|
|
760
|
+
* card is sized for the output it may yet have (the layout is computed once,
|
|
761
|
+
* before any run state, so it can never reflow mid-run) — leaving that space
|
|
762
|
+
* blank reads as a broken card rather than as a step with nothing to report. A
|
|
763
|
+
* running node is the exception: its output may still be on its way, so it waits
|
|
764
|
+
* quietly rather than claiming there is none.
|
|
765
|
+
*/
|
|
766
|
+
function emptySlotLabel(status) {
|
|
767
|
+
switch (status) {
|
|
768
|
+
case "queued": return "Not run yet";
|
|
769
|
+
case "succeeded": return "No output";
|
|
770
|
+
case "failed": return "No error reported";
|
|
771
|
+
default: return;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* A node's MARK, centered in a tile the caller sizes: the provider's brand logo
|
|
776
|
+
* when the step runs against a connector — the way n8n makes a node recognizable
|
|
777
|
+
* before a word of it is read — else the kind's glyph, tinted by tone. Both
|
|
778
|
+
* densities render the same mark at different sizes, so a node looks like itself
|
|
779
|
+
* whether it's an icon tile or a full card.
|
|
780
|
+
*/
|
|
781
|
+
function NodeMark({ kind, provider, model, accent, tile }) {
|
|
782
|
+
const Icon = kind && KIND_ICON[kind] || Circle;
|
|
783
|
+
const brand = !provider && model ? modelBrandFor(model) : null;
|
|
784
|
+
const mark = Math.round(tile * (provider ? .58 : .5));
|
|
785
|
+
return /* @__PURE__ */ jsx("span", {
|
|
786
|
+
"aria-hidden": "true",
|
|
787
|
+
className: "flex shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border",
|
|
788
|
+
style: {
|
|
789
|
+
width: tile,
|
|
790
|
+
height: tile,
|
|
791
|
+
background: `color-mix(in srgb, ${accent} 10%, hsl(var(--card)))`
|
|
792
|
+
},
|
|
793
|
+
children: provider ? /* @__PURE__ */ jsx(ProviderIcon, {
|
|
794
|
+
id: provider,
|
|
795
|
+
displayName: providerLabel(provider),
|
|
796
|
+
size: mark,
|
|
797
|
+
className: "rounded-[3px]"
|
|
798
|
+
}) : brand ? /* @__PURE__ */ jsx(ModelBrandStack, {
|
|
799
|
+
identity: brand,
|
|
800
|
+
size: brand.combined || tile >= 38 ? "md" : "sm"
|
|
801
|
+
}) : /* @__PURE__ */ jsx(Icon, {
|
|
802
|
+
size: mark,
|
|
803
|
+
strokeWidth: 1.75,
|
|
804
|
+
style: { color: accent }
|
|
805
|
+
})
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
//#endregion
|
|
809
|
+
//#region src/workflows/WorkflowGraph.tsx
|
|
810
|
+
/**
|
|
811
|
+
* n8n-style node-graph view of a workflow, rendered from its YAML definition.
|
|
812
|
+
* Used full-size on the workflow detail page and as a compact, non-interactive
|
|
813
|
+
* preview on assistant proposal cards. Both share one graph model + node
|
|
814
|
+
* component; only interactivity and sizing differ by `variant`.
|
|
815
|
+
*/
|
|
816
|
+
/**
|
|
817
|
+
* How much of the canvas a fit leaves as margin.
|
|
818
|
+
*
|
|
819
|
+
* A PIXEL gutter, not a fraction. Padding exists to keep the graph out from
|
|
820
|
+
* under the two overlays — the toolbar at top-left and the zoom controls at
|
|
821
|
+
* bottom-right — and those are a fixed size whatever the canvas is. Expressed
|
|
822
|
+
* as the old scale factor (0.16, i.e. fit into 1/1.16 of the frame) it instead
|
|
823
|
+
* grew with the panel, so the bigger the canvas the more of it a fit refused to
|
|
824
|
+
* use: measured on a 15-node pipeline in an 854×480 panel, a fit reached only
|
|
825
|
+
* 91% of the zoom that would still have fitted, and every pixel of that gap is
|
|
826
|
+
* legibility given away on exactly the graphs that need it most.
|
|
827
|
+
*
|
|
828
|
+
* 12px, because the gutter is breathing room and nothing more. The two overlays
|
|
829
|
+
* sit in OPPOSITE corners (toolbar top-left, zoom controls bottom-right) and are
|
|
830
|
+
* translucent chrome the canvas is expected to run under — reserving a band wide
|
|
831
|
+
* enough to clear them on all four sides would cost more zoom than the old
|
|
832
|
+
* scale factor did, on the short axis where a wide pipeline is already tightest.
|
|
833
|
+
*/
|
|
834
|
+
const FIT_VIEW = { padding: "12px" };
|
|
835
|
+
/**
|
|
836
|
+
* Zoom CEILING for a fit at the given density. Full cards at 1 are already
|
|
837
|
+
* their designed size — zooming a two-node graph past that just blows the cards
|
|
838
|
+
* up to fill the canvas. Compact tiles are small BY DESIGN, so fitting them
|
|
839
|
+
* into the same canvas legitimately zooms past 1; capping them there strands a
|
|
840
|
+
* short compact graph as specks in empty space.
|
|
841
|
+
*/
|
|
842
|
+
function fitZoomCeiling(compact) {
|
|
843
|
+
return compact ? 1.5 : 1;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Zoom FLOOR for a fit at the given density — the point below which the graph
|
|
847
|
+
* stops shrinking to fit and becomes pannable instead.
|
|
848
|
+
*
|
|
849
|
+
* It depends on density for the same reason the ceiling does, because what a
|
|
850
|
+
* floor BUYS depends on what the node is made of. A compact node is a logo: at
|
|
851
|
+
* 0.55 the tile is still 42px and every step is recognisable at a glance, so
|
|
852
|
+
* refusing to shrink past it keeps the graph worth looking at. An expanded card
|
|
853
|
+
* is made of TEXT, and its text stops being readable around 0.75 — well before
|
|
854
|
+
* any zoom that would fit a real pipeline. Holding those cards at the compact
|
|
855
|
+
* floor therefore buys nothing legible and costs the right-hand column, which
|
|
856
|
+
* gets sliced by the canvas edge. Letting them shrink shows the whole shape
|
|
857
|
+
* instead, and reading one card is a click (or a zoom) away either way.
|
|
858
|
+
*/
|
|
859
|
+
function fitZoomFloor(compact) {
|
|
860
|
+
return compact ? .55 : .35;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* The rectangle the LAID-OUT nodes occupy, in flow coordinates.
|
|
864
|
+
*
|
|
865
|
+
* Read straight off the layouter's own position + size rather than through
|
|
866
|
+
* React Flow's `getNodesBounds`. Both of that helper's forms prefer a node's
|
|
867
|
+
* MEASURED box — the standalone one falls back to it, and the `useReactFlow`
|
|
868
|
+
* one resolves every node through the store's `nodeLookup`, which holds nothing
|
|
869
|
+
* else — and measurement (a ResizeObserver) lags a relayout by a frame or more.
|
|
870
|
+
* Framing a fresh layout through either therefore frames the PREVIOUS layout's
|
|
871
|
+
* sizes as often as the new one, which is the same race that keeps `fitView`
|
|
872
|
+
* out of the reframe below. Every structural node carries an explicit
|
|
873
|
+
* width/height, and for exactly this layout that is the authority, so there is
|
|
874
|
+
* nothing to measure and no lookup to consult.
|
|
875
|
+
*/
|
|
876
|
+
function layoutBounds(nodes) {
|
|
877
|
+
if (nodes.length === 0) return {
|
|
878
|
+
x: 0,
|
|
879
|
+
y: 0,
|
|
880
|
+
width: 0,
|
|
881
|
+
height: 0
|
|
882
|
+
};
|
|
883
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
884
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
885
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
886
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
887
|
+
for (const node of nodes) {
|
|
888
|
+
minX = Math.min(minX, node.position.x);
|
|
889
|
+
minY = Math.min(minY, node.position.y);
|
|
890
|
+
maxX = Math.max(maxX, node.position.x + (node.width ?? 0));
|
|
891
|
+
maxY = Math.max(maxY, node.position.y + (node.height ?? 0));
|
|
892
|
+
}
|
|
893
|
+
return {
|
|
894
|
+
x: minX,
|
|
895
|
+
y: minY,
|
|
896
|
+
width: maxX - minX,
|
|
897
|
+
height: maxY - minY
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* The camera that frames `bounds` inside a `width`×`height` canvas.
|
|
902
|
+
*
|
|
903
|
+
* Whenever the zoom FLOOR clamps the fit (see FIT_VIEW), the graph is
|
|
904
|
+
* deliberately larger than the canvas — it is meant to be panned. React Flow
|
|
905
|
+
* CENTERS what it cannot fit, which splits the overflow across both ends at
|
|
906
|
+
* once: the trigger leaves the leading edge at the same moment the last step
|
|
907
|
+
* leaves the trailing one, so the reader sees neither terminus and has nothing
|
|
908
|
+
* to say which way the graph continues. Anchoring the leading edge instead puts
|
|
909
|
+
* the entry point on screen and leaves every hidden step in ONE direction — the
|
|
910
|
+
* direction the graph already reads in — so what is off-canvas is where panning
|
|
911
|
+
* naturally goes.
|
|
912
|
+
*
|
|
913
|
+
* The anchor is the canvas edge, not the padding inset: padding is slack, and a
|
|
914
|
+
* clamped fit is the case where there is none to give. It is also expressed as
|
|
915
|
+
* a clamp rather than a branch on "does it fit" — a fit the floor did NOT clamp
|
|
916
|
+
* is already centered with slack on both sides, so `max` returns it untouched,
|
|
917
|
+
* and nothing here has to re-derive React Flow's own reading of `padding`.
|
|
918
|
+
*/
|
|
919
|
+
function framingViewport(bounds, width, height, compact) {
|
|
920
|
+
const fit = getViewportForBounds(bounds, width, height, fitZoomFloor(compact), fitZoomCeiling(compact), FIT_VIEW.padding);
|
|
921
|
+
/** Slide `offset` just far enough that the bounds' leading edge is on canvas. */
|
|
922
|
+
const anchored = (offset, origin) => offset + Math.max(0, -(offset + origin * fit.zoom));
|
|
923
|
+
return {
|
|
924
|
+
zoom: fit.zoom,
|
|
925
|
+
x: anchored(fit.x, bounds.x),
|
|
926
|
+
y: anchored(fit.y, bounds.y)
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* When the RENDERED density flips inside the layout tween, as a fraction of
|
|
931
|
+
* LAYOUT_TRANSITION_MS. The box morphs first and the incoming content fades in
|
|
932
|
+
* mid-motion (see `.wf-node-body-in`) — flipping while everything is already
|
|
933
|
+
* moving is what keeps the swap from reading as a pop. Direction matters: the
|
|
934
|
+
* two densities' shells are closest in size near the COMPACT geometry, so
|
|
935
|
+
* growing (→ expanded) flips just after the box starts to grow, and shrinking
|
|
936
|
+
* (→ compact) flips once the card has nearly collapsed.
|
|
937
|
+
*/
|
|
938
|
+
const DENSITY_FLIP_AT = {
|
|
939
|
+
grow: .12,
|
|
940
|
+
shrink: .75
|
|
941
|
+
};
|
|
942
|
+
/** Motion is opt-OUT, so it is only used where the reader's preference can be
|
|
943
|
+
* read and says nothing against it. Somewhere without `matchMedia` cannot say
|
|
944
|
+
* a reader tolerates movement, so it gets none. */
|
|
945
|
+
function motionAllowed() {
|
|
946
|
+
return typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === false;
|
|
947
|
+
}
|
|
948
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
949
|
+
/** The layout tween's easing — gentle into and out of the motion, so neither
|
|
950
|
+
* end of the morph reads as a snap. */
|
|
951
|
+
const easeInOutCubic = (t) => t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
952
|
+
/** Tracks the app's dark/light class so React Flow's chrome (edges, controls,
|
|
953
|
+
* background) themes with the rest of the app. */
|
|
954
|
+
function useColorMode() {
|
|
955
|
+
const [mode, setMode] = useState(() => typeof document !== "undefined" && document.documentElement.classList.contains("dark") ? "dark" : "light");
|
|
956
|
+
useEffect(() => {
|
|
957
|
+
const el = document.documentElement;
|
|
958
|
+
const update = () => setMode(el.classList.contains("dark") ? "dark" : "light");
|
|
959
|
+
update();
|
|
960
|
+
const observer = new MutationObserver(update);
|
|
961
|
+
observer.observe(el, {
|
|
962
|
+
attributes: true,
|
|
963
|
+
attributeFilter: ["class"]
|
|
964
|
+
});
|
|
965
|
+
return () => observer.disconnect();
|
|
966
|
+
}, []);
|
|
967
|
+
return mode;
|
|
968
|
+
}
|
|
969
|
+
/** Flow direction for the current graph, so the node component can place its
|
|
970
|
+
* edge handles on the correct sides (edges enter the leading edge and leave the
|
|
971
|
+
* trailing edge) without threading a prop through React Flow's node renderer. */
|
|
972
|
+
const DirectionContext = createContext("LR");
|
|
973
|
+
/** Compact (collapsed) density for the current graph — read by the node so it
|
|
974
|
+
* renders the icon-tile summary instead of the full card. Set by the density
|
|
975
|
+
* toggle (and forced on for the proposal-card preview). */
|
|
976
|
+
const DensityContext = createContext(false);
|
|
977
|
+
/**
|
|
978
|
+
* The node the HOST considers selected, if any. Carried by context rather than
|
|
979
|
+
* merged into node data because selection is not a property of the workflow:
|
|
980
|
+
* writing it into `data` would rebuild the node objects on every selection
|
|
981
|
+
* change, which is the one thing the run-state merge works hard to avoid.
|
|
982
|
+
*/
|
|
983
|
+
const SelectedNodeContext = createContext(void 0);
|
|
984
|
+
/**
|
|
985
|
+
* Whether the GRAPH is showing a run — read by the node so its card matches what
|
|
986
|
+
* the layout reserved for it.
|
|
987
|
+
*
|
|
988
|
+
* `nodeHeight` reserves the run bands for every action the moment the graph has
|
|
989
|
+
* any run state, because the layout is computed once and cannot revisit the
|
|
990
|
+
* decision. A node the host has no entry for would otherwise render its
|
|
991
|
+
* definition card — identity and nothing else — inside a box sized for a run,
|
|
992
|
+
* leaving over a hundred pixels of void under it. A run's graph is in run mode
|
|
993
|
+
* for all of its nodes, or none.
|
|
994
|
+
*/
|
|
995
|
+
const RunModeContext = createContext(false);
|
|
996
|
+
/** The selected node's ring. An `outline` rather than a border or a box-shadow
|
|
997
|
+
* because both of those are already spoken for — the border carries tone and
|
|
998
|
+
* run status, the shadow carries the running/waiting glow — and an outline
|
|
999
|
+
* composes with both without disturbing the laid-out box. */
|
|
1000
|
+
const SELECTION_OUTLINE = {
|
|
1001
|
+
outline: "2px solid hsl(var(--primary))",
|
|
1002
|
+
outlineOffset: 2
|
|
1003
|
+
};
|
|
1004
|
+
/** Whether the canvas is currently an EDITOR — read by the node so its
|
|
1005
|
+
* connection handles become visible targets rather than the invisible anchors
|
|
1006
|
+
* a read-only diagram wants. A handle you cannot see is a handle you cannot
|
|
1007
|
+
* find, and dragging one is the whole gesture. */
|
|
1008
|
+
const ConnectableContext = createContext(false);
|
|
1009
|
+
/**
|
|
1010
|
+
* The AUTHORING problems anchored to each node, keyed by node id — read by the
|
|
1011
|
+
* node so its card can say it is the broken one.
|
|
1012
|
+
*
|
|
1013
|
+
* Delivered as CONTEXT rather than merged onto node data, unlike run state,
|
|
1014
|
+
* because the two arrive on different clocks. Run state ticks against a fixed
|
|
1015
|
+
* definition, so merging it into `data` costs nothing; a problem list changes
|
|
1016
|
+
* with the DRAFT, and merging it would put the problems into the memo the
|
|
1017
|
+
* layout is keyed on — relaying out the whole canvas on every keystroke of an
|
|
1018
|
+
* invalid draft.
|
|
1019
|
+
*/
|
|
1020
|
+
const NodeProblemsContext = createContext(/* @__PURE__ */ new Map());
|
|
1021
|
+
/** Removing the trigger this node stands for, when the host offers it. Null ⇒
|
|
1022
|
+
* no control is drawn. A trigger is the one node with a delete gesture on the
|
|
1023
|
+
* canvas: every other node is a `do` entry, whose removal is a list edit (see
|
|
1024
|
+
* `deletable: false` in flow-graph.ts), while an `on:` entry has no list of its
|
|
1025
|
+
* own to be edited in. */
|
|
1026
|
+
const TriggerDeleteContext = createContext(null);
|
|
1027
|
+
/** Inserting a step ON an edge, from the control the edge draws at its midpoint.
|
|
1028
|
+
* Reaches the edge renderer as context because React Flow edge `data` is
|
|
1029
|
+
* serialisable payload, not a place to hang a host callback. */
|
|
1030
|
+
const EdgeInsertContext = createContext(null);
|
|
1031
|
+
/**
|
|
1032
|
+
* Whether an edge is the HOST's to change. Only a declared edge is: a fork edge
|
|
1033
|
+
* is fan-out structure derived from a structural action's own config, and an
|
|
1034
|
+
* edge out of a trigger is what "nothing points at this node" renders as.
|
|
1035
|
+
* Neither appears in anyone's declared topology, so neither can be connected,
|
|
1036
|
+
* deleted, or guarded — offering the gesture would invite an edit with nowhere
|
|
1037
|
+
* to land.
|
|
1038
|
+
*/
|
|
1039
|
+
function isEditableEdge(edge) {
|
|
1040
|
+
return edge.data?.kind !== "fork" && triggerNodeIndex(edge.source) === null;
|
|
1041
|
+
}
|
|
1042
|
+
const HANDLE_CLASS = "!h-2 !w-2 !min-w-0 !border-0 !bg-transparent opacity-0";
|
|
1043
|
+
/** On an EDITABLE canvas the handle stops being a hidden anchor and becomes the
|
|
1044
|
+
* thing you drag. Drawn as a small ring in the muted token so it reads as an
|
|
1045
|
+
* affordance without competing with the node's own mark or status border.
|
|
1046
|
+
*
|
|
1047
|
+
* `!z-10` is load-bearing, not styling: the handle straddles the node card's
|
|
1048
|
+
* edge, and without it the card paints OVER the handle — every pointer event
|
|
1049
|
+
* lands on the card, so a connection drag can never start. Verified with a
|
|
1050
|
+
* live-browser hit-test (`elementFromPoint` returned the card at every pixel
|
|
1051
|
+
* of the handle; the identical drag landed once the handle was raised).
|
|
1052
|
+
* jsdom cannot catch a stacking regression, which is why the class is pinned
|
|
1053
|
+
* by name in WorkflowNode.test.tsx. */
|
|
1054
|
+
const HANDLE_CONNECTABLE_CLASS = "!z-10 !h-2.5 !w-2.5 !min-w-0 !rounded-full !border !border-border !bg-muted-foreground/70 opacity-100 transition-opacity hover:!bg-primary";
|
|
1055
|
+
/**
|
|
1056
|
+
* Where a compact node's edges attach. The compact BOX spans the icon tile AND
|
|
1057
|
+
* its name (so a name can never collide with a neighbour), but the edges belong
|
|
1058
|
+
* to the TILE — at the box's default anchors they would leave from the whitespace
|
|
1059
|
+
* beside the name. These pin each handle to the tile's own edge midpoint.
|
|
1060
|
+
*
|
|
1061
|
+
* Every anchor is given as an explicit `left`/`top` with a centering transform,
|
|
1062
|
+
* overriding React Flow's per-side defaults (which offset by half the handle and
|
|
1063
|
+
* anchor Bottom/Right from the far edge). Restating all four in one coordinate
|
|
1064
|
+
* system is what makes the anchor exactly the tile's edge — and not, say, 4px
|
|
1065
|
+
* past it, which is what the library's own bottom transform would have done.
|
|
1066
|
+
*/
|
|
1067
|
+
function compactHandleStyle(position, isLR) {
|
|
1068
|
+
const tileLeft = isLR ? (COMPACT_NODE_SIZE.width - 76) / 2 : 0;
|
|
1069
|
+
const center = 38;
|
|
1070
|
+
const anchor = (left, top) => ({
|
|
1071
|
+
left,
|
|
1072
|
+
top,
|
|
1073
|
+
right: "auto",
|
|
1074
|
+
bottom: "auto",
|
|
1075
|
+
transform: "translate(-50%, -50%)"
|
|
1076
|
+
});
|
|
1077
|
+
switch (position) {
|
|
1078
|
+
case Position.Left: return anchor(tileLeft, center);
|
|
1079
|
+
case Position.Right: return anchor(tileLeft + 76, center);
|
|
1080
|
+
case Position.Top: return anchor(tileLeft + center, 0);
|
|
1081
|
+
default: return anchor(tileLeft + center, 76);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
function WorkflowNode({ id, data, sourcePosition, targetPosition }) {
|
|
1085
|
+
const d = data;
|
|
1086
|
+
const state = d.state;
|
|
1087
|
+
const direction = useContext(DirectionContext);
|
|
1088
|
+
const compact = useContext(DensityContext);
|
|
1089
|
+
const runMode = useContext(RunModeContext);
|
|
1090
|
+
const connectable = useContext(ConnectableContext);
|
|
1091
|
+
const hostSelection = useContext(SelectedNodeContext);
|
|
1092
|
+
const problems = useContext(NodeProblemsContext).get(id);
|
|
1093
|
+
const problemSeverity = worstSeverity(problems);
|
|
1094
|
+
const removeTrigger = useContext(TriggerDeleteContext);
|
|
1095
|
+
const isTrigger = triggerNodeIndex(id) !== null;
|
|
1096
|
+
const selected = hostSelection !== void 0 && hostSelection === id;
|
|
1097
|
+
const isLR = direction === "LR";
|
|
1098
|
+
const targetPos = targetPosition ?? (isLR ? Position.Left : Position.Top);
|
|
1099
|
+
const sourcePos = sourcePosition ?? (isLR ? Position.Right : Position.Bottom);
|
|
1100
|
+
const accent = TONE_ACCENT[d.tone];
|
|
1101
|
+
const isAgent = d.kind === "agent.run";
|
|
1102
|
+
const subtitle = isAgent && state?.model ? shortModel(state.model) : d.subtitle;
|
|
1103
|
+
const markModel = isAgent && state?.model ? state.model : d.model;
|
|
1104
|
+
const runBands = runMode && d.tone !== "trigger";
|
|
1105
|
+
const runStatus = runBands ? state?.status ?? "queued" : state?.status;
|
|
1106
|
+
const duration = fmtDuration(state?.durationMs);
|
|
1107
|
+
const cost = fmtCost(state?.costUsd);
|
|
1108
|
+
const tokens = isAgent ? fmtTokens(state?.inputTokens, state?.outputTokens) : void 0;
|
|
1109
|
+
const runOutput = (() => {
|
|
1110
|
+
const source = state?.status === "failed" && state.error ? {
|
|
1111
|
+
text: state.error,
|
|
1112
|
+
tone: "error",
|
|
1113
|
+
label: "Error"
|
|
1114
|
+
} : state && state.status !== "failed" && state.outputPreview ? {
|
|
1115
|
+
text: state.outputPreview,
|
|
1116
|
+
tone: "default",
|
|
1117
|
+
label: "Output"
|
|
1118
|
+
} : null;
|
|
1119
|
+
if (!source) return null;
|
|
1120
|
+
const trimmed = source.text.trim();
|
|
1121
|
+
if (!trimmed) return null;
|
|
1122
|
+
const shape = classifyOutput(clampPreview(trimmed, 240), isAgent && source.tone !== "error");
|
|
1123
|
+
return shape.kind === "empty" ? null : {
|
|
1124
|
+
shape,
|
|
1125
|
+
tone: source.tone,
|
|
1126
|
+
label: source.label
|
|
1127
|
+
};
|
|
1128
|
+
})();
|
|
1129
|
+
/**
|
|
1130
|
+
* Remove this trigger. Two escapes, because the control sits INSIDE a node and
|
|
1131
|
+
* both of the node's own gestures would otherwise fire with it: `nodrag nopan`
|
|
1132
|
+
* is React Flow's own opt-out, so pressing the button never starts a node drag
|
|
1133
|
+
* or a canvas pan, and the click is stopped from bubbling to the wrapper that
|
|
1134
|
+
* carries `onNodeClick` — removing a trigger must not also open it.
|
|
1135
|
+
*/
|
|
1136
|
+
const triggerDeleteButton = isTrigger && removeTrigger ? /* @__PURE__ */ jsx("button", {
|
|
1137
|
+
type: "button",
|
|
1138
|
+
"data-testid": "wf-trigger-delete",
|
|
1139
|
+
title: "Remove this trigger",
|
|
1140
|
+
"aria-label": "Remove this trigger",
|
|
1141
|
+
className: "nodrag nopan -m-1 flex h-6 w-6 shrink-0 items-center justify-center p-1 text-muted-foreground transition hover:text-[var(--surface-danger-text)]",
|
|
1142
|
+
onClick: (event) => {
|
|
1143
|
+
event.stopPropagation();
|
|
1144
|
+
removeTrigger(id);
|
|
1145
|
+
},
|
|
1146
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
1147
|
+
className: "flex h-4 w-4 items-center justify-center rounded-full border border-border bg-card shadow-sm",
|
|
1148
|
+
children: /* @__PURE__ */ jsx(X, {
|
|
1149
|
+
size: 10,
|
|
1150
|
+
"aria-hidden": true
|
|
1151
|
+
})
|
|
1152
|
+
})
|
|
1153
|
+
}) : null;
|
|
1154
|
+
const handleClass = connectable ? HANDLE_CONNECTABLE_CLASS : HANDLE_CLASS;
|
|
1155
|
+
const handles = /* @__PURE__ */ jsxs(Fragment, { children: [!d.isRoot && /* @__PURE__ */ jsx(Handle, {
|
|
1156
|
+
type: "target",
|
|
1157
|
+
position: targetPos,
|
|
1158
|
+
className: handleClass,
|
|
1159
|
+
style: compact ? compactHandleStyle(targetPos, isLR) : void 0
|
|
1160
|
+
}), /* @__PURE__ */ jsx(Handle, {
|
|
1161
|
+
type: "source",
|
|
1162
|
+
position: sourcePos,
|
|
1163
|
+
className: handleClass,
|
|
1164
|
+
style: compact ? compactHandleStyle(sourcePos, isLR) : void 0
|
|
1165
|
+
})] });
|
|
1166
|
+
if (compact) return /* @__PURE__ */ jsxs("div", {
|
|
1167
|
+
className: `relative flex h-full w-full ${isLR ? "flex-col items-center" : "flex-row items-center"}`,
|
|
1168
|
+
children: [
|
|
1169
|
+
handles,
|
|
1170
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1171
|
+
"data-testid": "wf-node-card",
|
|
1172
|
+
className: "relative flex items-center justify-center rounded-xl border bg-card shadow-sm transition-colors",
|
|
1173
|
+
style: {
|
|
1174
|
+
width: 76,
|
|
1175
|
+
height: 76,
|
|
1176
|
+
...state ? statusBorder(state.status) : problemSeverity ? problemBorder(problemSeverity) : { borderColor: `color-mix(in srgb, ${accent} 40%, hsl(var(--border)))` },
|
|
1177
|
+
...selected ? SELECTION_OUTLINE : {}
|
|
1178
|
+
},
|
|
1179
|
+
children: [
|
|
1180
|
+
/* @__PURE__ */ jsx("span", {
|
|
1181
|
+
className: "wf-node-body-in flex items-center justify-center",
|
|
1182
|
+
children: /* @__PURE__ */ jsx(NodeMark, {
|
|
1183
|
+
kind: d.kind,
|
|
1184
|
+
provider: d.provider,
|
|
1185
|
+
model: markModel,
|
|
1186
|
+
accent,
|
|
1187
|
+
tile: Math.round(47.12)
|
|
1188
|
+
})
|
|
1189
|
+
}),
|
|
1190
|
+
problems && problemSeverity && /* @__PURE__ */ jsx(ProblemMarker, {
|
|
1191
|
+
problems,
|
|
1192
|
+
className: "-left-1.5 -top-1.5 absolute"
|
|
1193
|
+
}),
|
|
1194
|
+
triggerDeleteButton && /* @__PURE__ */ jsx("span", {
|
|
1195
|
+
className: "-right-1.5 -top-1.5 absolute",
|
|
1196
|
+
children: triggerDeleteButton
|
|
1197
|
+
}),
|
|
1198
|
+
d.badge && !state && /* @__PURE__ */ jsx("span", {
|
|
1199
|
+
className: "-right-1.5 -top-1.5 absolute rounded-full border border-border bg-card px-1.5 py-[1px] font-medium text-[10px] text-muted-foreground",
|
|
1200
|
+
children: d.badge
|
|
1201
|
+
}),
|
|
1202
|
+
state && /* @__PURE__ */ jsx("span", {
|
|
1203
|
+
className: `-right-1 -top-1 absolute h-2.5 w-2.5 rounded-full border-2 ${state.status === "running" ? "animate-pulse" : ""}`,
|
|
1204
|
+
...state.status === "running" ? { "data-motion": "essential" } : {},
|
|
1205
|
+
style: {
|
|
1206
|
+
background: STATUS_COLOR[state.status],
|
|
1207
|
+
borderColor: "hsl(var(--card))"
|
|
1208
|
+
},
|
|
1209
|
+
"aria-label": STATUS_LABEL[state.status]
|
|
1210
|
+
})
|
|
1211
|
+
]
|
|
1212
|
+
}),
|
|
1213
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1214
|
+
className: `wf-node-body-in min-w-0 ${isLR ? "mt-2 w-full px-1 text-center" : "ml-2 flex-1 text-left"}`,
|
|
1215
|
+
children: [
|
|
1216
|
+
/* @__PURE__ */ jsx("div", {
|
|
1217
|
+
className: "truncate font-semibold text-[12px] text-foreground leading-tight",
|
|
1218
|
+
title: d.title,
|
|
1219
|
+
children: d.title
|
|
1220
|
+
}),
|
|
1221
|
+
subtitle && /* @__PURE__ */ jsx("div", {
|
|
1222
|
+
className: "truncate text-[11px] text-muted-foreground leading-tight",
|
|
1223
|
+
title: subtitle,
|
|
1224
|
+
children: subtitle
|
|
1225
|
+
}),
|
|
1226
|
+
(duration || cost) && /* @__PURE__ */ jsx("div", {
|
|
1227
|
+
className: "truncate text-[10px] text-muted-foreground/90 leading-tight tabular-nums",
|
|
1228
|
+
children: [duration, cost].filter(Boolean).join(" · ")
|
|
1229
|
+
})
|
|
1230
|
+
]
|
|
1231
|
+
})
|
|
1232
|
+
]
|
|
1233
|
+
});
|
|
1234
|
+
const cardShell = (children) => /* @__PURE__ */ jsxs("div", {
|
|
1235
|
+
"data-testid": "wf-node-card",
|
|
1236
|
+
className: "relative flex h-full w-full flex-col overflow-hidden rounded-xl border bg-card shadow-sm transition-colors",
|
|
1237
|
+
style: {
|
|
1238
|
+
...runStatus ? statusBorder(runStatus) : problemSeverity ? problemBorder(problemSeverity) : { borderColor: `color-mix(in srgb, ${accent} 40%, hsl(var(--border)))` },
|
|
1239
|
+
...selected ? SELECTION_OUTLINE : {}
|
|
1240
|
+
},
|
|
1241
|
+
children: [handles, children]
|
|
1242
|
+
});
|
|
1243
|
+
const identityRow = /* @__PURE__ */ jsxs("div", {
|
|
1244
|
+
className: "flex shrink-0 items-center gap-2.5",
|
|
1245
|
+
children: [
|
|
1246
|
+
/* @__PURE__ */ jsx(NodeMark, {
|
|
1247
|
+
kind: d.kind,
|
|
1248
|
+
provider: d.provider,
|
|
1249
|
+
model: markModel,
|
|
1250
|
+
accent,
|
|
1251
|
+
tile: 34
|
|
1252
|
+
}),
|
|
1253
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1254
|
+
className: "min-w-0 flex-1",
|
|
1255
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1256
|
+
className: "truncate font-semibold text-[13px] text-foreground leading-tight",
|
|
1257
|
+
title: d.title,
|
|
1258
|
+
children: d.title
|
|
1259
|
+
}), subtitle && /* @__PURE__ */ jsx("div", {
|
|
1260
|
+
className: "truncate text-[11px] text-muted-foreground leading-tight",
|
|
1261
|
+
title: subtitle,
|
|
1262
|
+
children: subtitle
|
|
1263
|
+
})]
|
|
1264
|
+
}),
|
|
1265
|
+
problems && problemSeverity && /* @__PURE__ */ jsx(ProblemMarker, { problems }),
|
|
1266
|
+
runStatus ? /* @__PURE__ */ jsx(StatusPill, { status: runStatus }) : d.badge && /* @__PURE__ */ jsx("span", {
|
|
1267
|
+
className: "shrink-0 rounded-full border border-border bg-surface-container-high px-2 py-[1px] font-medium text-[10px] text-muted-foreground",
|
|
1268
|
+
children: d.badge
|
|
1269
|
+
}),
|
|
1270
|
+
triggerDeleteButton
|
|
1271
|
+
]
|
|
1272
|
+
});
|
|
1273
|
+
const footer = runStatus ? /* @__PURE__ */ jsx(StatusFooter, {
|
|
1274
|
+
status: runStatus,
|
|
1275
|
+
rounds: isAgent ? state?.rounds : void 0,
|
|
1276
|
+
cost,
|
|
1277
|
+
tokens,
|
|
1278
|
+
elapsed: duration
|
|
1279
|
+
}) : null;
|
|
1280
|
+
const descriptionBand = (emphasized) => d.description ? /* @__PURE__ */ jsx("p", {
|
|
1281
|
+
className: `mt-2 line-clamp-2 shrink-0 text-[11px] leading-snug ${emphasized ? "text-foreground" : "text-muted-foreground"}`,
|
|
1282
|
+
title: d.description,
|
|
1283
|
+
children: d.description
|
|
1284
|
+
}) : null;
|
|
1285
|
+
const emptySlot = runStatus && !runOutput && emptySlotLabel(runStatus) ? /* @__PURE__ */ jsx("div", {
|
|
1286
|
+
className: "mt-2 flex flex-1 items-center text-[11px] text-muted-foreground italic",
|
|
1287
|
+
children: emptySlotLabel(runStatus)
|
|
1288
|
+
}) : null;
|
|
1289
|
+
const outputWell = (rows) => runOutput ? /* @__PURE__ */ jsxs("div", {
|
|
1290
|
+
className: "mt-2 min-h-0 grow shrink-0 rounded-lg border border-border bg-surface-container-high/60 px-2 py-1.5",
|
|
1291
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1292
|
+
className: "mb-1 font-semibold text-[9px] text-muted-foreground uppercase tracking-[0.09em]",
|
|
1293
|
+
children: runOutput.label
|
|
1294
|
+
}), /* @__PURE__ */ jsx(NodeOutputBody, {
|
|
1295
|
+
shape: runOutput.shape,
|
|
1296
|
+
tone: runOutput.tone,
|
|
1297
|
+
rows
|
|
1298
|
+
})]
|
|
1299
|
+
}) : emptySlot;
|
|
1300
|
+
const body = (children, justify = "") => /* @__PURE__ */ jsx("div", {
|
|
1301
|
+
className: `wf-node-body-in flex min-h-0 flex-1 flex-col overflow-hidden px-3.5 pt-2.5 pb-2 ${justify}`,
|
|
1302
|
+
children
|
|
1303
|
+
});
|
|
1304
|
+
if (!runBands) return cardShell(body(/* @__PURE__ */ jsxs(Fragment, { children: [identityRow, descriptionBand(false)] })));
|
|
1305
|
+
if (d.kind === "decision") return cardShell(/* @__PURE__ */ jsxs(Fragment, { children: [body(/* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("div", {
|
|
1306
|
+
className: "flex shrink-0 items-start gap-2.5",
|
|
1307
|
+
children: [/* @__PURE__ */ jsx(NodeMark, {
|
|
1308
|
+
kind: d.kind,
|
|
1309
|
+
provider: d.provider,
|
|
1310
|
+
model: markModel,
|
|
1311
|
+
accent,
|
|
1312
|
+
tile: 34
|
|
1313
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
1314
|
+
className: "min-w-0 flex-1",
|
|
1315
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1316
|
+
className: "truncate font-semibold text-[13px] text-foreground leading-tight",
|
|
1317
|
+
title: d.title,
|
|
1318
|
+
children: d.title
|
|
1319
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
1320
|
+
className: "mt-0.5 flex items-center gap-2",
|
|
1321
|
+
children: [subtitle && /* @__PURE__ */ jsx("span", {
|
|
1322
|
+
className: "truncate text-[11px] text-muted-foreground leading-tight",
|
|
1323
|
+
title: subtitle,
|
|
1324
|
+
children: subtitle
|
|
1325
|
+
}), runStatus && /* @__PURE__ */ jsx("span", {
|
|
1326
|
+
className: "ml-auto shrink-0",
|
|
1327
|
+
children: /* @__PURE__ */ jsx(StatusPill, { status: runStatus })
|
|
1328
|
+
})]
|
|
1329
|
+
})]
|
|
1330
|
+
})]
|
|
1331
|
+
}), runOutput && runStatus === "failed" ? /* @__PURE__ */ jsx("div", {
|
|
1332
|
+
className: "mt-2 min-h-0 shrink-0",
|
|
1333
|
+
children: /* @__PURE__ */ jsx(NodeOutputBody, {
|
|
1334
|
+
shape: runOutput.shape,
|
|
1335
|
+
tone: runOutput.tone,
|
|
1336
|
+
rows: 2
|
|
1337
|
+
})
|
|
1338
|
+
}) : descriptionBand(true)] })), footer] }));
|
|
1339
|
+
if (isStructuralKind(d.kind)) return cardShell(/* @__PURE__ */ jsxs(Fragment, { children: [body(/* @__PURE__ */ jsxs(Fragment, { children: [identityRow, /* @__PURE__ */ jsx("div", {
|
|
1340
|
+
className: "mt-1.5 min-h-0 shrink-0",
|
|
1341
|
+
children: runOutput && /* @__PURE__ */ jsx(NodeOutputBody, {
|
|
1342
|
+
shape: runOutput.shape,
|
|
1343
|
+
tone: runOutput.tone,
|
|
1344
|
+
rows: 1
|
|
1345
|
+
})
|
|
1346
|
+
})] }), "justify-center"), footer] }));
|
|
1347
|
+
if (isAgent) {
|
|
1348
|
+
const strip = [d.title, subtitle].filter(Boolean).join(" · ");
|
|
1349
|
+
return cardShell(/* @__PURE__ */ jsxs(Fragment, { children: [body(/* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("div", {
|
|
1350
|
+
className: "flex shrink-0 items-center gap-1.5",
|
|
1351
|
+
children: [
|
|
1352
|
+
/* @__PURE__ */ jsx(NodeMark, {
|
|
1353
|
+
kind: d.kind,
|
|
1354
|
+
provider: d.provider,
|
|
1355
|
+
model: markModel,
|
|
1356
|
+
accent,
|
|
1357
|
+
tile: 18
|
|
1358
|
+
}),
|
|
1359
|
+
/* @__PURE__ */ jsx("span", {
|
|
1360
|
+
className: "truncate font-medium text-[11px] text-muted-foreground",
|
|
1361
|
+
title: strip,
|
|
1362
|
+
children: strip
|
|
1363
|
+
}),
|
|
1364
|
+
/* @__PURE__ */ jsx("span", { className: "flex-1" }),
|
|
1365
|
+
runStatus && /* @__PURE__ */ jsx(StatusPill, { status: runStatus })
|
|
1366
|
+
]
|
|
1367
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
1368
|
+
className: "mt-2 min-h-0 flex-1 text-foreground leading-snug",
|
|
1369
|
+
children: runOutput ? /* @__PURE__ */ jsx(NodeOutputBody, {
|
|
1370
|
+
shape: runOutput.shape,
|
|
1371
|
+
tone: runOutput.tone,
|
|
1372
|
+
rows: 5
|
|
1373
|
+
}) : runStatus && runStatus !== "succeeded" && runStatus !== "failed" && d.description ? /* @__PURE__ */ jsx("p", {
|
|
1374
|
+
className: "line-clamp-5 text-[11px] text-muted-foreground leading-snug",
|
|
1375
|
+
title: d.description,
|
|
1376
|
+
children: d.description
|
|
1377
|
+
}) : runStatus && /* @__PURE__ */ jsx("span", {
|
|
1378
|
+
className: "text-muted-foreground italic",
|
|
1379
|
+
children: emptySlotLabel(runStatus)
|
|
1380
|
+
})
|
|
1381
|
+
})] })), footer] }));
|
|
1382
|
+
}
|
|
1383
|
+
return cardShell(/* @__PURE__ */ jsxs(Fragment, { children: [body(/* @__PURE__ */ jsxs(Fragment, { children: [identityRow, outputWell(3)] })), footer] }));
|
|
1384
|
+
}
|
|
1385
|
+
/** Where a connection drag was let go, in viewport coordinates. A touch reports
|
|
1386
|
+
* its position on `changedTouches` — `touches` is empty by the time the last
|
|
1387
|
+
* finger lifts. Null when the event carries no position at all. */
|
|
1388
|
+
function releasePoint(event) {
|
|
1389
|
+
if (!event) return null;
|
|
1390
|
+
const source = ("changedTouches" in event ? event.changedTouches[0] : null) ?? ("clientX" in event ? event : null);
|
|
1391
|
+
return source ? {
|
|
1392
|
+
x: source.clientX,
|
|
1393
|
+
y: source.clientY
|
|
1394
|
+
} : null;
|
|
1395
|
+
}
|
|
1396
|
+
/** The empty canvas itself: React Flow's pane, and the dot grid drawn on it.
|
|
1397
|
+
* Everything else in the graph — a node, an edge, a label, the zoom controls,
|
|
1398
|
+
* the panel — sits ABOVE the pane and is therefore not it. */
|
|
1399
|
+
const CANVAS_SURFACE_SELECTOR = ".react-flow__pane, .react-flow__background";
|
|
1400
|
+
/**
|
|
1401
|
+
* Whether a release at this point landed on THIS graph's empty canvas.
|
|
1402
|
+
*
|
|
1403
|
+
* Identified POSITIVELY — the element under the pointer must BE the pane — and
|
|
1404
|
+
* not by listing what to exclude. A blacklist has to enumerate every layer that
|
|
1405
|
+
* can sit over the canvas (nodes, edges, edge labels, the controls, the panel,
|
|
1406
|
+
* whatever is added next), and each one it misses turns a release meant for that
|
|
1407
|
+
* element into a step nobody asked for. Asking "is this the pane" cannot miss
|
|
1408
|
+
* one, and it fails CLOSED: an environment with no hit-testing adds nothing
|
|
1409
|
+
* rather than adding something wrong.
|
|
1410
|
+
*
|
|
1411
|
+
* The pane also has to be OURS. React Flow completes a connection from a
|
|
1412
|
+
* document-level pointer-up, so a drag begun in one graph is still live over
|
|
1413
|
+
* every other graph on the page — a proposal preview beside an editor, a gallery
|
|
1414
|
+
* of them — and a bare "is this a pane" would let a release over a NEIGHBOUR add
|
|
1415
|
+
* a step to the draft the drag started in. Containment settles it, and carries
|
|
1416
|
+
* the frame check with it: a release off the graph lands on the page rather than
|
|
1417
|
+
* on anything this frame holds.
|
|
1418
|
+
*/
|
|
1419
|
+
function releasedOnCanvas(at, frame) {
|
|
1420
|
+
if (!frame) return false;
|
|
1421
|
+
const under = typeof document === "undefined" ? null : document.elementFromPoint(at.x, at.y);
|
|
1422
|
+
if (!under?.matches(CANVAS_SURFACE_SELECTOR)) return false;
|
|
1423
|
+
return frame.contains(under);
|
|
1424
|
+
}
|
|
1425
|
+
/**
|
|
1426
|
+
* The laid-out node boxes an edge's cluster has to keep clear of, in the same
|
|
1427
|
+
* flow coordinates the edge's own label point is expressed in.
|
|
1428
|
+
*
|
|
1429
|
+
* Taken from the LAYOUT rather than from React Flow's node store. The layout's
|
|
1430
|
+
* dimensions are authoritative — the card renders at exactly that box (see
|
|
1431
|
+
* `WfNode.height`) — so they need no measurement pass to become true, and they
|
|
1432
|
+
* change only when the layout does, which is precisely when a cluster needs to
|
|
1433
|
+
* be placed again. Reading the store instead would mean subscribing to a map
|
|
1434
|
+
* React Flow mutates in place, whose identity never changes and so never
|
|
1435
|
+
* notifies: the clearance would be computed once against unmeasured nodes and
|
|
1436
|
+
* then never revisited.
|
|
1437
|
+
*/
|
|
1438
|
+
const NodeBoxesContext = createContext([]);
|
|
1439
|
+
/** Chip styling shared by the guard summary and the cycle badge, so an edge's
|
|
1440
|
+
* two possible annotations read as one pair rather than two designs. */
|
|
1441
|
+
const EDGE_CHIP_CLASS = "rounded-full border border-border bg-card/90 px-1.5 py-[1px] text-[10px] leading-tight backdrop-blur";
|
|
1442
|
+
/**
|
|
1443
|
+
* An edge with something to say: a guard summary, a cycle marker, an authoring
|
|
1444
|
+
* problem, or the insert control an EDITING canvas draws on it. An edge with
|
|
1445
|
+
* none of those stays on React Flow's built-in `smoothstep` renderer and is
|
|
1446
|
+
* unaffected by this component's existence.
|
|
1447
|
+
*
|
|
1448
|
+
* The guard chip is truncated with its full text on `title`: a summary is
|
|
1449
|
+
* usually a few words, but it is host-supplied and nothing bounds it, and an
|
|
1450
|
+
* unbounded label on an edge overlaps the nodes either side of it.
|
|
1451
|
+
*/
|
|
1452
|
+
function WfEdgeRenderer({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, markerEnd, style }) {
|
|
1453
|
+
const insertStep = useContext(EdgeInsertContext);
|
|
1454
|
+
const [path, labelX, labelY] = getSmoothStepPath({
|
|
1455
|
+
sourceX,
|
|
1456
|
+
sourceY,
|
|
1457
|
+
sourcePosition,
|
|
1458
|
+
targetX,
|
|
1459
|
+
targetY,
|
|
1460
|
+
targetPosition
|
|
1461
|
+
});
|
|
1462
|
+
const backEdge = data?.backEdge === true;
|
|
1463
|
+
const whenLabel = data?.whenLabel;
|
|
1464
|
+
const visits = data?.maxNodeVisits;
|
|
1465
|
+
const problems = data?.problems;
|
|
1466
|
+
const problemSeverity = worstSeverity(problems);
|
|
1467
|
+
const offersInsert = data?.insertable === true && insertStep !== null;
|
|
1468
|
+
const nodeBoxes = useContext(NodeBoxesContext);
|
|
1469
|
+
const direction = useContext(DirectionContext);
|
|
1470
|
+
const cleared = offersInsert || problemSeverity ? clearOfNodeBoxes({
|
|
1471
|
+
x: labelX,
|
|
1472
|
+
y: labelY
|
|
1473
|
+
}, nodeBoxes, direction, problemSeverity ? CLUSTER_HALF_SIZE.chip : CLUSTER_HALF_SIZE.control) : 0;
|
|
1474
|
+
const clearance = cleared ?? 0;
|
|
1475
|
+
const stealsPointer = cleared === null;
|
|
1476
|
+
const isLR = direction === "LR";
|
|
1477
|
+
const clusterX = labelX + (isLR ? 0 : clearance);
|
|
1478
|
+
const clusterY = labelY + (isLR ? clearance : 0);
|
|
1479
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(BaseEdge, {
|
|
1480
|
+
id,
|
|
1481
|
+
path,
|
|
1482
|
+
markerEnd,
|
|
1483
|
+
style
|
|
1484
|
+
}), /* @__PURE__ */ jsx(EdgeLabelRenderer, { children: /* @__PURE__ */ jsxs("div", {
|
|
1485
|
+
className: "nodrag nopan absolute flex flex-col items-center gap-0.5",
|
|
1486
|
+
style: {
|
|
1487
|
+
zIndex: 1,
|
|
1488
|
+
transform: `translate(-50%, -50%) translate(${clusterX}px, ${clusterY}px)`
|
|
1489
|
+
},
|
|
1490
|
+
children: [
|
|
1491
|
+
problems && problemSeverity && /* @__PURE__ */ jsxs("span", {
|
|
1492
|
+
"data-testid": "wf-edge-problem",
|
|
1493
|
+
"data-severity": problemSeverity,
|
|
1494
|
+
className: `${EDGE_CHIP_CLASS} max-w-40 truncate ${stealsPointer ? "" : "pointer-events-auto"}`,
|
|
1495
|
+
style: problemSurface(problemSeverity),
|
|
1496
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1497
|
+
"aria-hidden": true,
|
|
1498
|
+
title: problemTitle(problems),
|
|
1499
|
+
children: problems.length === 1 ? problems[0].message : `${problems.length} problems`
|
|
1500
|
+
}), /* @__PURE__ */ jsx(ProblemMessages, { problems })]
|
|
1501
|
+
}),
|
|
1502
|
+
whenLabel && /* @__PURE__ */ jsx("span", {
|
|
1503
|
+
title: whenLabel,
|
|
1504
|
+
className: `${EDGE_CHIP_CLASS} max-w-40 truncate text-muted-foreground`,
|
|
1505
|
+
children: whenLabel
|
|
1506
|
+
}),
|
|
1507
|
+
backEdge && /* @__PURE__ */ jsx("span", {
|
|
1508
|
+
title: visits !== void 0 ? `Loops back — each node runs at most ${visits} time${visits === 1 ? "" : "s"} per run` : "Loops back to a step that has already run",
|
|
1509
|
+
className: `${EDGE_CHIP_CLASS} shrink-0 text-foreground`,
|
|
1510
|
+
children: visits !== void 0 ? `↺ ≤${visits}` : "↺"
|
|
1511
|
+
}),
|
|
1512
|
+
offersInsert && /* @__PURE__ */ jsx("button", {
|
|
1513
|
+
type: "button",
|
|
1514
|
+
"data-testid": "wf-edge-insert",
|
|
1515
|
+
title: "Insert a step here",
|
|
1516
|
+
"aria-label": "Insert a step here",
|
|
1517
|
+
className: `flex h-5 w-5 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-sm backdrop-blur transition hover:border-primary hover:text-primary ${stealsPointer ? "" : "pointer-events-auto"}`,
|
|
1518
|
+
onClick: (event) => {
|
|
1519
|
+
event.stopPropagation();
|
|
1520
|
+
insertStep(source, target);
|
|
1521
|
+
},
|
|
1522
|
+
children: /* @__PURE__ */ jsx(Plus, {
|
|
1523
|
+
size: 12,
|
|
1524
|
+
"aria-hidden": true
|
|
1525
|
+
})
|
|
1526
|
+
})
|
|
1527
|
+
]
|
|
1528
|
+
}) })] });
|
|
1529
|
+
}
|
|
1530
|
+
const NODE_TYPES = { wfNode: WorkflowNode };
|
|
1531
|
+
const EDGE_TYPES = { [WF_EDGE_TYPE]: WfEdgeRenderer };
|
|
1532
|
+
function buildStyledEdges(base, nodeState, maxNodeVisits) {
|
|
1533
|
+
return base.map((e) => {
|
|
1534
|
+
const status = nodeState?.[e.target]?.status;
|
|
1535
|
+
const color = edgeColor(nodeState ? status : void 0);
|
|
1536
|
+
const backEdge = e.data?.backEdge === true;
|
|
1537
|
+
return {
|
|
1538
|
+
...e,
|
|
1539
|
+
animated: !backEdge && status === "running",
|
|
1540
|
+
style: {
|
|
1541
|
+
strokeWidth: 1.75,
|
|
1542
|
+
stroke: color,
|
|
1543
|
+
...backEdge ? { strokeDasharray: "6 3" } : {}
|
|
1544
|
+
},
|
|
1545
|
+
markerEnd: {
|
|
1546
|
+
type: MarkerType.ArrowClosed,
|
|
1547
|
+
width: 15,
|
|
1548
|
+
height: 15,
|
|
1549
|
+
color
|
|
1550
|
+
},
|
|
1551
|
+
...backEdge && maxNodeVisits !== void 0 && e.data ? { data: {
|
|
1552
|
+
...e.data,
|
|
1553
|
+
maxNodeVisits
|
|
1554
|
+
} } : {}
|
|
1555
|
+
};
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Fold the AUTHORING decorations onto already-styled edges: the problems
|
|
1560
|
+
* anchored to each one, and whether it offers the insert control. Both are drawn
|
|
1561
|
+
* by the custom renderer, so an edge that gains either is switched onto it —
|
|
1562
|
+
* everything else keeps the built-in `smoothstep` it has always had.
|
|
1563
|
+
*
|
|
1564
|
+
* A problem also RECOLOURS the edge, from the same table the node it points at
|
|
1565
|
+
* is tinted from, because a chip alone is unreadable at the zoom a whole
|
|
1566
|
+
* pipeline is viewed at — but only while the graph is NOT showing a run.
|
|
1567
|
+
* A run's colour is the more urgent reading of the same line and wins, exactly
|
|
1568
|
+
* as a node's run border wins over its problem border; the chip still renders,
|
|
1569
|
+
* so nothing is lost on the one edge that could carry both.
|
|
1570
|
+
*
|
|
1571
|
+
* `insertable` is a predicate rather than a flag so this pass never has to
|
|
1572
|
+
* re-derive which edges are the host's to change — {@link isEditableEdge}
|
|
1573
|
+
* already answers that for delete and guard.
|
|
1574
|
+
*/
|
|
1575
|
+
function decorateAuthoringEdges(edges, byEdge, insertable, hasRunOverlay) {
|
|
1576
|
+
if (byEdge.size === 0 && !edges.some(insertable)) return edges;
|
|
1577
|
+
return edges.map((e) => {
|
|
1578
|
+
const problems = byEdge.get(e.id);
|
|
1579
|
+
const severity = worstSeverity(problems);
|
|
1580
|
+
const offersInsert = insertable(e);
|
|
1581
|
+
if (!severity && !offersInsert) return e;
|
|
1582
|
+
const stroke = severity && !hasRunOverlay ? problemSurface(severity).color : void 0;
|
|
1583
|
+
return {
|
|
1584
|
+
...e,
|
|
1585
|
+
type: WF_EDGE_TYPE,
|
|
1586
|
+
data: {
|
|
1587
|
+
...e.data ?? { kind: "spine" },
|
|
1588
|
+
...problems ? { problems } : {},
|
|
1589
|
+
...offersInsert ? { insertable: true } : {}
|
|
1590
|
+
},
|
|
1591
|
+
...stroke ? {
|
|
1592
|
+
style: {
|
|
1593
|
+
...e.style,
|
|
1594
|
+
stroke
|
|
1595
|
+
},
|
|
1596
|
+
markerEnd: e.markerEnd && typeof e.markerEnd === "object" ? {
|
|
1597
|
+
...e.markerEnd,
|
|
1598
|
+
color: stroke
|
|
1599
|
+
} : e.markerEnd
|
|
1600
|
+
} : {}
|
|
1601
|
+
};
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompact, onCompactChange, className, wrap = false, nodeState, edges: declaredEdges, maxNodeVisits, selectedNodeId, onNodeClick, onEdgeConnect, onEdgeDelete, onEdgeClick, onEdgeInsert, onNodeInsert, onTriggerAdd, onTriggerDelete, problems }) {
|
|
1605
|
+
const editable = onEdgeConnect !== void 0;
|
|
1606
|
+
const insertOnEdge = editable ? onEdgeInsert : void 0;
|
|
1607
|
+
const insertArmed = insertOnEdge !== void 0;
|
|
1608
|
+
const insertAtNode = editable ? onNodeInsert : void 0;
|
|
1609
|
+
const addTrigger = editable ? onTriggerAdd : void 0;
|
|
1610
|
+
const deleteTrigger = editable ? onTriggerDelete : void 0;
|
|
1611
|
+
const colorMode = useColorMode();
|
|
1612
|
+
const isPreview = variant === "preview";
|
|
1613
|
+
const [userCompact, setUserCompact] = useState(defaultCompact ?? nodeState === void 0);
|
|
1614
|
+
/**
|
|
1615
|
+
* What the NEXT press toggles away from.
|
|
1616
|
+
*
|
|
1617
|
+
* The press cannot read `userCompact` for this: two presses that land in one
|
|
1618
|
+
* batch share a render, so both would see the same density, both would
|
|
1619
|
+
* compute the same flip, and two presses would move the graph once — while
|
|
1620
|
+
* reporting that one density twice to a host that persists it. The ref
|
|
1621
|
+
* advances on the press itself, so the second press sees what the first
|
|
1622
|
+
* chose. It is the toggle's own bookkeeping; the RENDER still follows
|
|
1623
|
+
* `userCompact`, which is the only thing React may read for that.
|
|
1624
|
+
*/
|
|
1625
|
+
const nextDensityFrom = useRef(userCompact);
|
|
1626
|
+
const compact = isPreview || userCompact;
|
|
1627
|
+
const [displayCompact, setDisplayCompact] = useState(compact);
|
|
1628
|
+
const rfRef = useRef(null);
|
|
1629
|
+
const wrapperRef = useRef(null);
|
|
1630
|
+
const framedRef = useRef(false);
|
|
1631
|
+
const [framingCue, setFramingCue] = useState(0);
|
|
1632
|
+
const hasRunOverlay = nodeState !== void 0;
|
|
1633
|
+
const structural = useMemo(() => buildFlowGraph(yaml, {
|
|
1634
|
+
nodeState: hasRunOverlay ? {} : void 0,
|
|
1635
|
+
direction,
|
|
1636
|
+
compact,
|
|
1637
|
+
wrap,
|
|
1638
|
+
reserveEdgeInsert: insertArmed,
|
|
1639
|
+
...declaredEdges ? { edges: declaredEdges } : {}
|
|
1640
|
+
}), [
|
|
1641
|
+
yaml,
|
|
1642
|
+
hasRunOverlay,
|
|
1643
|
+
direction,
|
|
1644
|
+
compact,
|
|
1645
|
+
wrap,
|
|
1646
|
+
insertArmed,
|
|
1647
|
+
declaredEdges
|
|
1648
|
+
]);
|
|
1649
|
+
const nodeBoxes = useMemo(() => structural.nodes.map((node) => ({
|
|
1650
|
+
x: node.position.x,
|
|
1651
|
+
y: node.position.y,
|
|
1652
|
+
width: node.width ?? 0,
|
|
1653
|
+
height: node.height ?? 0
|
|
1654
|
+
})), [structural]);
|
|
1655
|
+
const problemIndex = useMemo(() => indexProblems(problems), [problems]);
|
|
1656
|
+
const styledEdges = useMemo(() => {
|
|
1657
|
+
return decorateAuthoringEdges(buildStyledEdges(structural.edges, nodeState, maxNodeVisits), problemIndex.byEdge, (edge) => insertArmed && isEditableEdge(edge), hasRunOverlay).map((e) => ({
|
|
1658
|
+
...e,
|
|
1659
|
+
deletable: editable ? isEditableEdge(e) : false
|
|
1660
|
+
}));
|
|
1661
|
+
}, [
|
|
1662
|
+
structural.edges,
|
|
1663
|
+
nodeState,
|
|
1664
|
+
maxNodeVisits,
|
|
1665
|
+
editable,
|
|
1666
|
+
problemIndex,
|
|
1667
|
+
insertArmed,
|
|
1668
|
+
hasRunOverlay
|
|
1669
|
+
]);
|
|
1670
|
+
const [nodes, setNodes, onNodesChange] = useNodesState(structural.nodes);
|
|
1671
|
+
const [edges, setEdges, onEdgesChange] = useEdgesState(styledEdges);
|
|
1672
|
+
const nodesRef = useRef(nodes);
|
|
1673
|
+
nodesRef.current = nodes;
|
|
1674
|
+
const nodeStateRef = useRef(nodeState);
|
|
1675
|
+
nodeStateRef.current = nodeState;
|
|
1676
|
+
useEffect(() => {
|
|
1677
|
+
setEdges(styledEdges);
|
|
1678
|
+
}, [styledEdges, setEdges]);
|
|
1679
|
+
useEffect(() => {
|
|
1680
|
+
const baseById = new Map(structural.nodes.map((n) => [n.id, n.data]));
|
|
1681
|
+
setNodes((prev) => mergeRunState(prev, baseById, nodeState));
|
|
1682
|
+
}, [
|
|
1683
|
+
nodeState,
|
|
1684
|
+
structural,
|
|
1685
|
+
setNodes
|
|
1686
|
+
]);
|
|
1687
|
+
const didInitialSeedRef = useRef(false);
|
|
1688
|
+
useEffect(() => {
|
|
1689
|
+
const withRunState = (nodes) => {
|
|
1690
|
+
const state = nodeStateRef.current;
|
|
1691
|
+
if (!state) return nodes;
|
|
1692
|
+
return nodes.map((n) => state[n.id] ? {
|
|
1693
|
+
...n,
|
|
1694
|
+
data: {
|
|
1695
|
+
...n.data,
|
|
1696
|
+
state: state[n.id]
|
|
1697
|
+
}
|
|
1698
|
+
} : n);
|
|
1699
|
+
};
|
|
1700
|
+
if (!didInitialSeedRef.current) {
|
|
1701
|
+
didInitialSeedRef.current = true;
|
|
1702
|
+
setNodes(withRunState(structural.nodes));
|
|
1703
|
+
setDisplayCompact(compact);
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
const inst = rfRef.current;
|
|
1707
|
+
const frame = wrapperRef.current?.getBoundingClientRect();
|
|
1708
|
+
if (!inst || !frame || frame.width === 0 || frame.height === 0) {
|
|
1709
|
+
setNodes(withRunState(structural.nodes));
|
|
1710
|
+
setDisplayCompact(compact);
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
const endViewport = framingViewport(layoutBounds(structural.nodes), frame.width, frame.height, compact);
|
|
1714
|
+
framedRef.current = true;
|
|
1715
|
+
const prev = nodesRef.current;
|
|
1716
|
+
const prevById = new Map(prev.map((n) => [n.id, n]));
|
|
1717
|
+
if (!(prev.length === structural.nodes.length && structural.nodes.every((n) => prevById.has(n.id))) || !motionAllowed() || typeof requestAnimationFrame === "undefined") {
|
|
1718
|
+
setNodes(withRunState(structural.nodes));
|
|
1719
|
+
setDisplayCompact(compact);
|
|
1720
|
+
inst.setViewport(endViewport);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
const flipTimer = setTimeout(() => setDisplayCompact(compact), 220 * (compact ? DENSITY_FLIP_AT.shrink : DENSITY_FLIP_AT.grow));
|
|
1724
|
+
const startViewport = inst.getViewport();
|
|
1725
|
+
const startedAt = performance.now();
|
|
1726
|
+
let raf = requestAnimationFrame(function step(now) {
|
|
1727
|
+
const t = Math.min(1, (now - startedAt) / 220);
|
|
1728
|
+
const e = easeInOutCubic(t);
|
|
1729
|
+
setNodes(withRunState(t >= 1 ? structural.nodes : structural.nodes.map((n) => {
|
|
1730
|
+
const p = prevById.get(n.id);
|
|
1731
|
+
if (!p || p.width === void 0 || p.height === void 0) return n;
|
|
1732
|
+
const width = lerp(p.width, n.width ?? p.width, e);
|
|
1733
|
+
const height = lerp(p.height, n.height ?? p.height, e);
|
|
1734
|
+
return {
|
|
1735
|
+
...n,
|
|
1736
|
+
position: {
|
|
1737
|
+
x: lerp(p.position.x, n.position.x, e),
|
|
1738
|
+
y: lerp(p.position.y, n.position.y, e)
|
|
1739
|
+
},
|
|
1740
|
+
width,
|
|
1741
|
+
height,
|
|
1742
|
+
style: {
|
|
1743
|
+
...n.style,
|
|
1744
|
+
width,
|
|
1745
|
+
height
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
})));
|
|
1749
|
+
inst.setViewport({
|
|
1750
|
+
x: lerp(startViewport.x, endViewport.x, e),
|
|
1751
|
+
y: lerp(startViewport.y, endViewport.y, e),
|
|
1752
|
+
zoom: lerp(startViewport.zoom, endViewport.zoom, e)
|
|
1753
|
+
});
|
|
1754
|
+
if (t < 1) raf = requestAnimationFrame(step);
|
|
1755
|
+
});
|
|
1756
|
+
return () => {
|
|
1757
|
+
cancelAnimationFrame(raf);
|
|
1758
|
+
clearTimeout(flipTimer);
|
|
1759
|
+
};
|
|
1760
|
+
}, [
|
|
1761
|
+
structural,
|
|
1762
|
+
compact,
|
|
1763
|
+
setNodes
|
|
1764
|
+
]);
|
|
1765
|
+
/**
|
|
1766
|
+
* Own the INITIAL frame once the instance and canvas have a size. React
|
|
1767
|
+
* Flow's one-time `fitView` can run after this effect and overwrite the
|
|
1768
|
+
* anchored camera, leaving the entry node off-screen. Only this effect may
|
|
1769
|
+
* set the initial camera; later resizes leave the reader's pan alone.
|
|
1770
|
+
*/
|
|
1771
|
+
useEffect(() => {
|
|
1772
|
+
if (framedRef.current) return;
|
|
1773
|
+
const inst = rfRef.current;
|
|
1774
|
+
const frame = wrapperRef.current?.getBoundingClientRect();
|
|
1775
|
+
if (!inst || !frame || frame.width === 0 || frame.height === 0) return;
|
|
1776
|
+
inst.setViewport(framingViewport(layoutBounds(structural.nodes), frame.width, frame.height, compact));
|
|
1777
|
+
framedRef.current = true;
|
|
1778
|
+
}, [
|
|
1779
|
+
framingCue,
|
|
1780
|
+
structural,
|
|
1781
|
+
compact
|
|
1782
|
+
]);
|
|
1783
|
+
useEffect(() => {
|
|
1784
|
+
const el = wrapperRef.current;
|
|
1785
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
1786
|
+
const observer = new ResizeObserver(() => {
|
|
1787
|
+
if (framedRef.current) {
|
|
1788
|
+
observer.disconnect();
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
setFramingCue((cue) => cue + 1);
|
|
1792
|
+
});
|
|
1793
|
+
observer.observe(el);
|
|
1794
|
+
return () => observer.disconnect();
|
|
1795
|
+
}, []);
|
|
1796
|
+
const handleNodeClick = useCallback((_event, node) => {
|
|
1797
|
+
onNodeClick?.(node.id, node.data);
|
|
1798
|
+
}, [onNodeClick]);
|
|
1799
|
+
/**
|
|
1800
|
+
* A connection dragged from a handle and released over EMPTY CANVAS: the
|
|
1801
|
+
* "and then what?" gesture, reported as the node it left and which end of it.
|
|
1802
|
+
*
|
|
1803
|
+
* Three things have to be true, and each rules out a different non-gesture:
|
|
1804
|
+
*
|
|
1805
|
+
* - Nothing was under the pointer (`toNode`/`toHandle`). React Flow ends
|
|
1806
|
+
* EVERY connection drag through this callback, including one that landed on
|
|
1807
|
+
* a handle and already went out through `onConnect`.
|
|
1808
|
+
* - The release landed on THIS graph's empty canvas — its own pane,
|
|
1809
|
+
* positively identified.
|
|
1810
|
+
* This is what rules out everything the first test does not: React Flow
|
|
1811
|
+
* sets `toNode` only when a handle was found within its connection radius,
|
|
1812
|
+
* so letting go in the middle of a wide card reports no target at all, and
|
|
1813
|
+
* without this a release aimed at a node added a step instead. It rules out
|
|
1814
|
+
* the same way a release on the zoom controls, on an edge, or right off the
|
|
1815
|
+
* graph does — React Flow listens for the pointer-up on the document, so
|
|
1816
|
+
* letting go anywhere on the page ends the drag, and pulling away from the
|
|
1817
|
+
* canvas is exactly how a gesture is abandoned.
|
|
1818
|
+
*
|
|
1819
|
+
* The gesture also needs both of its own ends: a drag that reports no node or
|
|
1820
|
+
* no handle it started from names nothing to insert beside.
|
|
1821
|
+
*/
|
|
1822
|
+
const handleConnectEnd = useCallback((event, state) => {
|
|
1823
|
+
if (!insertAtNode) return;
|
|
1824
|
+
if (state.toNode || state.toHandle) return;
|
|
1825
|
+
const from = state.fromNode;
|
|
1826
|
+
const fromHandle = state.fromHandle;
|
|
1827
|
+
if (!from || !fromHandle) return;
|
|
1828
|
+
const at = releasePoint(event);
|
|
1829
|
+
if (!at || !releasedOnCanvas(at, wrapperRef.current)) return;
|
|
1830
|
+
insertAtNode(from.id, fromHandle.type === "target" ? "before" : "after");
|
|
1831
|
+
}, [insertAtNode]);
|
|
1832
|
+
/**
|
|
1833
|
+
* Enter/Space on a focused node opens its detail, exactly as a click does.
|
|
1834
|
+
* React Flow makes a node TABBABLE but gives it no activation key of its own,
|
|
1835
|
+
* so a keyboard user could reach every node and open none of them — the
|
|
1836
|
+
* detail panel was mouse-only.
|
|
1837
|
+
*
|
|
1838
|
+
* The handler sits on the flow wrapper and reads the node id off the focused
|
|
1839
|
+
* element, because React Flow reports keys from the canvas rather than
|
|
1840
|
+
* per-node: `onNodeClick` has no keyboard counterpart to hang this on.
|
|
1841
|
+
*/
|
|
1842
|
+
const handleNodeKeyDown = useCallback((event) => {
|
|
1843
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
1844
|
+
if (!(event.target instanceof HTMLElement)) return;
|
|
1845
|
+
const nodeId = event.target.closest(".react-flow__node")?.getAttribute("data-id");
|
|
1846
|
+
if (!nodeId) return;
|
|
1847
|
+
const node = nodesRef.current.find((n) => n.id === nodeId);
|
|
1848
|
+
if (!node) return;
|
|
1849
|
+
event.preventDefault();
|
|
1850
|
+
event.stopPropagation();
|
|
1851
|
+
onNodeClick?.(node.id, node.data);
|
|
1852
|
+
}, [onNodeClick]);
|
|
1853
|
+
if (structural.error || structural.nodes.length === 0) return /* @__PURE__ */ jsx("div", {
|
|
1854
|
+
className: `flex items-center justify-center rounded-lg border border-border border-dashed p-4 text-center text-muted-foreground text-xs ${className ?? ""}`,
|
|
1855
|
+
children: structural.error ?? "Nothing to show"
|
|
1856
|
+
});
|
|
1857
|
+
return /* @__PURE__ */ jsx(DirectionContext.Provider, {
|
|
1858
|
+
value: direction,
|
|
1859
|
+
children: /* @__PURE__ */ jsx(DensityContext.Provider, {
|
|
1860
|
+
value: displayCompact,
|
|
1861
|
+
children: /* @__PURE__ */ jsx(RunModeContext.Provider, {
|
|
1862
|
+
value: hasRunOverlay,
|
|
1863
|
+
children: /* @__PURE__ */ jsx(SelectedNodeContext.Provider, {
|
|
1864
|
+
value: selectedNodeId,
|
|
1865
|
+
children: /* @__PURE__ */ jsx(ConnectableContext.Provider, {
|
|
1866
|
+
value: editable,
|
|
1867
|
+
children: /* @__PURE__ */ jsx(NodeBoxesContext.Provider, {
|
|
1868
|
+
value: nodeBoxes,
|
|
1869
|
+
children: /* @__PURE__ */ jsx(NodeProblemsContext.Provider, {
|
|
1870
|
+
value: problemIndex.byNode,
|
|
1871
|
+
children: /* @__PURE__ */ jsx(TriggerDeleteContext.Provider, {
|
|
1872
|
+
value: deleteTrigger ?? null,
|
|
1873
|
+
children: /* @__PURE__ */ jsx(EdgeInsertContext.Provider, {
|
|
1874
|
+
value: insertOnEdge ?? null,
|
|
1875
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1876
|
+
ref: wrapperRef,
|
|
1877
|
+
className: `wf-graph ${className ?? ""}`,
|
|
1878
|
+
children: /* @__PURE__ */ jsxs(ReactFlow, {
|
|
1879
|
+
nodes,
|
|
1880
|
+
edges,
|
|
1881
|
+
onNodesChange,
|
|
1882
|
+
onEdgesChange,
|
|
1883
|
+
nodeTypes: NODE_TYPES,
|
|
1884
|
+
edgeTypes: EDGE_TYPES,
|
|
1885
|
+
colorMode,
|
|
1886
|
+
onInit: (inst) => {
|
|
1887
|
+
rfRef.current = inst;
|
|
1888
|
+
if (!framedRef.current) setFramingCue((cue) => cue + 1);
|
|
1889
|
+
},
|
|
1890
|
+
onNodeClick: onNodeClick ? handleNodeClick : void 0,
|
|
1891
|
+
onKeyDown: onNodeClick ? handleNodeKeyDown : void 0,
|
|
1892
|
+
proOptions: { hideAttribution: true },
|
|
1893
|
+
nodesDraggable: !isPreview,
|
|
1894
|
+
nodesConnectable: editable,
|
|
1895
|
+
deleteKeyCode: editable ? ["Backspace", "Delete"] : null,
|
|
1896
|
+
edgesReconnectable: false,
|
|
1897
|
+
elementsSelectable: !isPreview,
|
|
1898
|
+
edgesFocusable: editable,
|
|
1899
|
+
onConnect: editable ? (connection) => {
|
|
1900
|
+
if (connection.source && connection.target) onEdgeConnect(connection.source, connection.target);
|
|
1901
|
+
} : void 0,
|
|
1902
|
+
onEdgesDelete: editable ? (deleted) => {
|
|
1903
|
+
for (const edge of deleted) if (isEditableEdge(edge)) onEdgeDelete?.(edge.source, edge.target);
|
|
1904
|
+
} : void 0,
|
|
1905
|
+
onConnectEnd: insertAtNode ? handleConnectEnd : void 0,
|
|
1906
|
+
onEdgeClick: editable ? (_event, edge) => {
|
|
1907
|
+
if (isEditableEdge(edge)) onEdgeClick?.(edge.source, edge.target);
|
|
1908
|
+
} : void 0,
|
|
1909
|
+
zoomOnScroll: false,
|
|
1910
|
+
zoomOnPinch: true,
|
|
1911
|
+
zoomOnDoubleClick: !isPreview,
|
|
1912
|
+
panOnDrag: true,
|
|
1913
|
+
preventScrolling: false,
|
|
1914
|
+
minZoom: .2,
|
|
1915
|
+
maxZoom: 1.5,
|
|
1916
|
+
children: [/* @__PURE__ */ jsx(Background, { gap: 18 }), !isPreview && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Controls, {
|
|
1917
|
+
showInteractive: false,
|
|
1918
|
+
position: "bottom-right",
|
|
1919
|
+
fitViewOptions: { padding: FIT_VIEW.padding }
|
|
1920
|
+
}), /* @__PURE__ */ jsxs(Panel, {
|
|
1921
|
+
position: "top-left",
|
|
1922
|
+
className: "flex items-center gap-1",
|
|
1923
|
+
children: [addTrigger && /* @__PURE__ */ jsxs("button", {
|
|
1924
|
+
type: "button",
|
|
1925
|
+
"data-testid": "wf-trigger-add",
|
|
1926
|
+
onClick: addTrigger,
|
|
1927
|
+
title: "Add a trigger",
|
|
1928
|
+
"aria-label": "Add a trigger",
|
|
1929
|
+
className: "flex items-center gap-1 rounded-md border border-border bg-card/80 px-2 py-1 text-[11px] text-muted-foreground shadow-sm backdrop-blur transition hover:text-foreground",
|
|
1930
|
+
children: [/* @__PURE__ */ jsx(Plus, {
|
|
1931
|
+
size: 12,
|
|
1932
|
+
"aria-hidden": true
|
|
1933
|
+
}), "Trigger"]
|
|
1934
|
+
}), /* @__PURE__ */ jsxs("button", {
|
|
1935
|
+
type: "button",
|
|
1936
|
+
onClick: () => {
|
|
1937
|
+
const next = !nextDensityFrom.current;
|
|
1938
|
+
nextDensityFrom.current = next;
|
|
1939
|
+
setUserCompact(next);
|
|
1940
|
+
onCompactChange?.(next);
|
|
1941
|
+
},
|
|
1942
|
+
"aria-pressed": userCompact,
|
|
1943
|
+
title: userCompact ? "Expand nodes" : "Collapse nodes",
|
|
1944
|
+
className: "flex items-center gap-1 rounded-md border border-border bg-card/80 px-2 py-1 text-[11px] text-muted-foreground shadow-sm backdrop-blur transition hover:text-foreground",
|
|
1945
|
+
children: [userCompact ? /* @__PURE__ */ jsx(Maximize2, { size: 12 }) : /* @__PURE__ */ jsx(Minimize2, { size: 12 }), userCompact ? "Expand" : "Compact"]
|
|
1946
|
+
})]
|
|
1947
|
+
})] })]
|
|
1948
|
+
})
|
|
1949
|
+
})
|
|
1950
|
+
})
|
|
1951
|
+
})
|
|
1952
|
+
})
|
|
1953
|
+
})
|
|
1954
|
+
})
|
|
1955
|
+
})
|
|
1956
|
+
})
|
|
1957
|
+
})
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
//#endregion
|
|
1961
|
+
export { WorkflowGraph };
|