@manny-est/node-red-flowpilot 0.4.1 → 0.5.1

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.
@@ -0,0 +1,1926 @@
1
+ var DIFF_SKIP = {
2
+ x: 1, y: 1, z: 1, _def: 1, _: 1, changed: 1, dirty: 1, selected: 1,
3
+ valid: 1, validationErrors: 1, _index: 1, resize: 1, moved: 1,
4
+ w: 1, h: 1, l: 1, __outputs: 1, inputs: 1, g: 1,
5
+ _config: 1, _orig: 1, credentials: 1,
6
+ // "group" is a SYNTHETIC field sanitizeNode adds to context (the
7
+ // real live property is "g", already skipped above) — informational
8
+ // only. Live-confirmed gap: the model tried to "ungroup" by setting
9
+ // group:null on member nodes via a "changes" patch; without this
10
+ // skip, computeNodeDiff happily diffed a property that doesn't
11
+ // exist on the real node object, and Tier 1 "applied" it by writing
12
+ // a meaningless liveNode.group = null — reporting false success
13
+ // while actually doing nothing to real membership (RED.nodes.node()
14
+ // doesn't even read a key named "group"). Real ungrouping goes
15
+ // through applyGroupChanges()/"newGroups" instead.
16
+ group: 1,
17
+ // "nodes" is a GROUP's live membership array — holds real node
18
+ // OBJECT references, never ids. Live-confirmed data-corrupting gap
19
+ // (2026-06-29): asked to "merge" several groups into one via plain
20
+ // Modify, the model sent a "changes" patch targeting the group
21
+ // entities directly (findLiveNode resolves a group by id) with a
22
+ // "nodes" value shaped like a list of ID STRINGS instead of going
23
+ // through applyGroupChanges()/"newGroups". Tier 1 wrote it
24
+ // verbatim (liveNode.nodes = [...ids]), replacing real node
25
+ // references with strings; NR's own redraw/export code calls
26
+ // .id/.g on each "nodes" entry expecting an object, so this
27
+ // corrupted the group immediately (canvas selection started
28
+ // throwing) AND on disk (each string serializes with id
29
+ // undefined -> null, confirmed via a live flows.json). Group
30
+ // membership must only ever change through applyGroupChanges().
31
+ nodes: 1
32
+ };
33
+
34
+ // Sentinel strings written by sanitizeNode (and redactDebugValue) for
35
+ // values it couldn't include or had to redact. If the model echoes one
36
+ // of these back, the field is opaque — skip it entirely; never write a
37
+ // sentinel string into a live node property. "[redacted]" is the plain
38
+ // top-level-secret-field sentinel; "[redacted: <kind>, <n> chars]" is the
39
+ // informative value-shape sentinel from redactDebugValue.
40
+ //
41
+ // MUST be recursive: sentinels can be nested inside arrays/objects (e.g.
42
+ // an inject node's "props" array, where individual v-fields are redacted).
43
+ // Live-confirmed data-corrupting gap (2026-06-29): asked to "ungroup"
44
+ // with a stale canvas state that showed group:null, the model echoed the
45
+ // REDACTED props array back verbatim. Tier 1 compared the live props
46
+ // (real values) vs the proposed props (sentinel strings nested inside)
47
+ // and saw them as different — a false "change". The old flat sentinel
48
+ // check returned false for the array itself, so the change was applied,
49
+ // overwriting real API keys with "[redacted: secret field, N chars]"
50
+ // strings in the live canvas. Disk was safe (user never deployed), but
51
+ // a deploy would have lost the keys permanently.
52
+ function isSanitizeSentinel(value) {
53
+ if (typeof value === "string") {
54
+ return value === "[unserializable]" || value === "[redacted]" || value.indexOf("[redacted:") === 0;
55
+ }
56
+ if (Array.isArray(value)) {
57
+ return value.some(isSanitizeSentinel);
58
+ }
59
+ if (value !== null && typeof value === "object") {
60
+ return Object.keys(value).some(function (k) { return isSanitizeSentinel(value[k]); });
61
+ }
62
+ return false;
63
+ }
64
+
65
+ function generateNodeId() {
66
+ var chars = "0123456789abcdef";
67
+ var id = "";
68
+ for (var i = 0; i < 16; i++) { id += chars[Math.floor(Math.random() * 16)]; }
69
+ return id;
70
+ }
71
+
72
+ // Resolve a newWires from/to reference to a display label.
73
+ // ref is either a placeholder id present in newNodes or an existing node id.
74
+ function resolveWireRef(ref, newNodes) {
75
+ if (!ref) { return "(unknown)"; }
76
+ // Check against newNodes placeholder ids.
77
+ for (var i = 0; i < newNodes.length; i++) {
78
+ if (newNodes[i] && newNodes[i].id === ref) {
79
+ var n = newNodes[i];
80
+ return (n.name || n.type || ref) + " [new]";
81
+ }
82
+ }
83
+ // Existing node.
84
+ var live = RED.nodes && RED.nodes.node ? RED.nodes.node(ref) : null;
85
+ return live ? (live.name || live.type || ref) : ref;
86
+ }
87
+
88
+ // Port-count guards before ever calling RED.nodes.addLink() — confirmed
89
+ // via source that addLink does ZERO port validation: a wire to/from a
90
+ // nonexistent port silently registers in the data model and even
91
+ // RENDERS (NR's redraw anchors to a generic edge position regardless of
92
+ // whether a real port exists there). Live-confirmed: a wire from one
93
+ // comment node to another rendered fine but does nothing at runtime —
94
+ // comment/group are the common 0-port cases, but any node can have
95
+ // 0 outputs (e.g. debug) or a computed output count (e.g. switch's
96
+ // rules.length, already tracked on the live instance as .outputs).
97
+ function nodeOutputCount(node) {
98
+ if (!node || node.type === "group") { return 0; }
99
+ if (typeof node.outputs === "number") { return node.outputs; }
100
+ return (node._def && typeof node._def.outputs === "number") ? node._def.outputs : 1;
101
+ }
102
+ function nodeInputCount(node) {
103
+ if (!node || node.type === "group") { return 0; }
104
+ if (typeof node.inputs === "number") { return node.inputs; }
105
+ return (node._def && typeof node._def.inputs === "number") ? node._def.inputs : 1;
106
+ }
107
+ function canWire(fromNode, sourcePort, toNode) {
108
+ return nodeOutputCount(fromNode) > sourcePort && nodeInputCount(toNode) > 0;
109
+ }
110
+
111
+ // Insert new nodes into the live graph and add wires connecting them to
112
+ // existing nodes. Uses RED.nodes.add (the same path Node-RED's undo uses
113
+ // internally for t:"add" events) and pushes one compound history entry so
114
+ // a single Ctrl+Z removes both the new nodes and their wires together.
115
+ function applyInsertions(newNodes, newWires, contextNodeIds) {
116
+ if (!newNodes || !newNodes.length) { return; }
117
+
118
+ // Determine z (flow-tab id) from the active workspace.
119
+ var z = "";
120
+ if (RED.workspaces && RED.workspaces.active) {
121
+ var ws = RED.workspaces.active();
122
+ z = ws ? (typeof ws === "string" ? ws : ws.id || "") : "";
123
+ }
124
+
125
+ // Collect model placeholder ids to distinguish them from existing-node
126
+ // ids when scanning newWires for spatial anchors.
127
+ var placeholderIds = {};
128
+ newNodes.forEach(function (n) { if (n.id) { placeholderIds[n.id] = true; } });
129
+
130
+ // Group new nodes into connected clusters (by each node's own
131
+ // "wires" plus any "newWires" edges between two placeholders), so
132
+ // multiple unrelated insertions in one response are each anchored to
133
+ // THEIR OWN existing-node connections instead of bunching together at
134
+ // one shared location.
135
+ var parent = {};
136
+ newNodes.forEach(function (n) { parent[n.id] = n.id; });
137
+ function findRoot(x) {
138
+ while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; }
139
+ return x;
140
+ }
141
+ function union(a, b) {
142
+ if (parent[a] === undefined || parent[b] === undefined) { return; }
143
+ parent[findRoot(a)] = findRoot(b);
144
+ }
145
+ newNodes.forEach(function (n) {
146
+ (Array.isArray(n.wires) ? n.wires : []).forEach(function (port) {
147
+ (Array.isArray(port) ? port : []).forEach(function (tid) {
148
+ if (placeholderIds[tid]) { union(n.id, tid); }
149
+ });
150
+ });
151
+ });
152
+ (newWires || []).forEach(function (wire) {
153
+ if (placeholderIds[wire.from] && placeholderIds[wire.to]) {
154
+ union(wire.from, wire.to);
155
+ }
156
+ });
157
+ var componentOf = {};
158
+ newNodes.forEach(function (n) { componentOf[n.id] = findRoot(n.id); });
159
+
160
+ // Per-component anchors: existing-node connections (from newWires)
161
+ // touching THIS component only. Split into "upstream" (existing node
162
+ // feeds INTO this component) and "downstream" (this component feeds
163
+ // INTO an existing node) — a wire's direction matters for where the
164
+ // cluster should land.
165
+ var componentAnchors = {};
166
+ Object.keys(componentOf).forEach(function (id) {
167
+ var c = componentOf[id];
168
+ if (!componentAnchors[c]) { componentAnchors[c] = { upstream: [], downstream: [] }; }
169
+ });
170
+ (newWires || []).forEach(function (wire) {
171
+ var fromIsNew = placeholderIds[wire.from];
172
+ var toIsNew = placeholderIds[wire.to];
173
+ if (!fromIsNew && toIsNew) {
174
+ var src = RED.nodes.node(wire.from);
175
+ if (src && typeof src.x === "number" && typeof src.y === "number") {
176
+ componentAnchors[componentOf[wire.to]].upstream.push(src);
177
+ }
178
+ } else if (fromIsNew && !toIsNew) {
179
+ var tgt = RED.nodes.node(wire.to);
180
+ if (tgt && typeof tgt.x === "number" && typeof tgt.y === "number") {
181
+ componentAnchors[componentOf[wire.from]].downstream.push(tgt);
182
+ }
183
+ }
184
+ });
185
+
186
+ // Fallback (Finding #18): a cluster with no existing-node connection
187
+ // at all (e.g. "add a change node" with no wiring specified) anchors
188
+ // on the user's current Modify selection instead of
189
+ // layoutGeneratedFlow's hardcoded default (160,120), which can be far
190
+ // outside the user's current viewport on a larger flow.
191
+ var fallbackAnchors = [];
192
+ if (Array.isArray(contextNodeIds)) {
193
+ contextNodeIds.forEach(function (id) {
194
+ var live = RED.nodes.node(id);
195
+ if (live && typeof live.x === "number" && typeof live.y === "number") {
196
+ fallbackAnchors.push(live);
197
+ }
198
+ });
199
+ }
200
+ Object.keys(componentAnchors).forEach(function (c) {
201
+ var a = componentAnchors[c];
202
+ if (a.upstream.length === 0 && a.downstream.length === 0) {
203
+ a.upstream = fallbackAnchors.slice();
204
+ }
205
+ });
206
+
207
+ // Layout new nodes so they have x/y before adding to the graph.
208
+ var toLayout = newNodes.map(function (n) {
209
+ return Object.assign({}, n, { wires: Array.isArray(n.wires) ? n.wires : [[]] });
210
+ });
211
+ var laid = layoutGeneratedFlow(toLayout);
212
+
213
+ // Position each cluster relative to ITS OWN anchors, centred on their
214
+ // average Y, preserving that cluster's internal column/row layout
215
+ // from layoutGeneratedFlow.
216
+ // - If a cluster receives a connection FROM an existing node
217
+ // ("inserted after X" / "inserted between X and Y"), land 200px
218
+ // right of the rightmost such upstream anchor. This is correct
219
+ // even when downstream anchors exist further right (e.g. "insert
220
+ // a function between the inject and the existing debugs" should
221
+ // land right after the inject, not past the debugs).
222
+ // - Otherwise (the cluster only feeds existing nodes, e.g. "add an
223
+ // inject that triggers this debug"), land 200px left of the
224
+ // leftmost downstream anchor.
225
+ Object.keys(componentAnchors).forEach(function (c) {
226
+ var a = componentAnchors[c];
227
+ var anchorNodes = a.upstream.concat(a.downstream);
228
+ if (!anchorNodes.length) { return; }
229
+
230
+ var members = laid.filter(function (n) { return componentOf[n.id] === c; });
231
+ var minX = members.reduce(function (m, n) { return Math.min(m, n.x); }, members[0].x);
232
+ var minY = members.reduce(function (m, n) { return Math.min(m, n.y); }, members[0].y);
233
+
234
+ var avgY = anchorNodes.reduce(function (s, n) { return s + n.y; }, 0) / anchorNodes.length;
235
+ var targetX;
236
+ if (a.upstream.length > 0) {
237
+ var maxUpstreamX = a.upstream.reduce(function (m, n) { return Math.max(m, n.x); }, 0);
238
+ targetX = maxUpstreamX + 200;
239
+ } else {
240
+ var minDownstreamX = a.downstream.reduce(function (m, n) { return Math.min(m, n.x); }, a.downstream[0].x);
241
+ targetX = minDownstreamX - 200;
242
+ }
243
+ members.forEach(function (n) {
244
+ n.x = n.x - minX + targetX;
245
+ n.y = n.y - minY + avgY;
246
+ });
247
+ });
248
+
249
+ // Build placeholder-id → real-id map and assign real ids + z.
250
+ var idMap = {};
251
+ laid.forEach(function (n) {
252
+ var realId = generateNodeId();
253
+ idMap[n.id] = realId; // n.id is the model's placeholder
254
+ n.id = realId;
255
+ n.z = z;
256
+ if (!Array.isArray(n.wires)) { n.wires = [[]]; }
257
+ });
258
+
259
+ // Rewrite any intra-new-node wires to use real ids.
260
+ laid.forEach(function (n) {
261
+ n.wires = n.wires.map(function (port) {
262
+ return Array.isArray(port) ? port.map(function (tid) {
263
+ return idMap[tid] || tid;
264
+ }) : [];
265
+ });
266
+ });
267
+
268
+ // Rewrite config-node references that point at another new node's
269
+ // placeholder id — e.g. an "mqtt out" node's "broker" pointing at a
270
+ // newly-inserted "mqtt-broker". The model reuses the same placeholder
271
+ // ids for these as it does for wires, but only "wires" was rewritten
272
+ // above; without this, the reference is left dangling on the
273
+ // placeholder string and the node fails validation at Deploy.
274
+ laid.forEach(function (n) {
275
+ if (n.type === "junction") { return; }
276
+ var typeDef = RED.nodes.getType(n.type);
277
+ if (!typeDef || !typeDef.defaults) { return; }
278
+ Object.keys(typeDef.defaults).forEach(function (k) {
279
+ var def = typeDef.defaults[k];
280
+ if (def && def.type && typeof n[k] === "string" && idMap[n[k]]) {
281
+ n[k] = idMap[n[k]];
282
+ }
283
+ });
284
+ });
285
+
286
+ // Add each node to the live graph.
287
+ // RED.nodes.add requires _def (the type's registration object) to be
288
+ // set on the node — it's what the undo system restores, not a raw cfg.
289
+ var addedNodes = [];
290
+ var addedJunctions = [];
291
+ var insertFailed = false;
292
+ laid.forEach(function (n) {
293
+ // Junctions (wire-splice points) are NOT registered node types —
294
+ // RED.nodes.getType("junction") returns undefined, so the regular
295
+ // RED.nodes.add path below would reject them as "not installed".
296
+ // They're added via RED.nodes.addJunction with a different shape
297
+ // (no defaults/_def beyond a stub), mirroring the object NR's own
298
+ // "split wire with junction" action builds (red.js addJunctionsToWires).
299
+ if (n.type === "junction") {
300
+ var junctionObj = {
301
+ _def: { defaults: {} },
302
+ type: "junction",
303
+ z: n.z,
304
+ id: n.id,
305
+ x: n.x,
306
+ y: n.y,
307
+ w: 0,
308
+ h: 0,
309
+ inputs: 1,
310
+ outputs: 1,
311
+ dirty: true,
312
+ moved: true
313
+ };
314
+ try {
315
+ junctionObj = RED.nodes.addJunction(junctionObj);
316
+ addedJunctions.push(junctionObj);
317
+ } catch (e) {
318
+ addMessage("error", "Failed to add junction: " + (e.message || e));
319
+ insertFailed = true;
320
+ }
321
+ return;
322
+ }
323
+
324
+ var typeDef = RED.nodes.getType(n.type);
325
+ if (!typeDef) {
326
+ addMessage("error", "Node type not installed: " + n.type);
327
+ insertFailed = true;
328
+ return;
329
+ }
330
+ n._def = typeDef;
331
+ // inputs/outputs are runtime state that add() reads from the node
332
+ // object directly — it doesn't copy them from _def automatically.
333
+ if (typeof n.inputs === "undefined") {
334
+ n.inputs = typeDef.inputs !== undefined ? typeDef.inputs : 1;
335
+ }
336
+ if (typeof n.outputs === "undefined") {
337
+ n.outputs = typeDef.outputs !== undefined
338
+ ? typeDef.outputs
339
+ : (Array.isArray(n.wires) ? n.wires.length : 0);
340
+ }
341
+ // Apply type-definition defaults for any property the model omitted.
342
+ // This covers required fields (e.g. statusVal/statusType on debug)
343
+ // that oneditsave would normally set, preventing a spurious triangle.
344
+ if (typeDef.defaults) {
345
+ Object.keys(typeDef.defaults).forEach(function (k) {
346
+ if (n[k] === undefined) {
347
+ var d = typeDef.defaults[k];
348
+ if (d && d.value !== undefined) { n[k] = d.value; }
349
+ }
350
+ });
351
+ }
352
+ try {
353
+ RED.nodes.add(n);
354
+ // Try the public validator; if it isn't exposed or still leaves
355
+ // the node invalid, clear the triangle explicitly — real
356
+ // validation runs at edit-dialog-close and at Deploy.
357
+ if (typeof RED.nodes.validateNode === "function") {
358
+ RED.nodes.validateNode(n);
359
+ }
360
+ if (!n.valid) { n.valid = true; n.validationErrors = []; }
361
+ addedNodes.push(n);
362
+ } catch (e) {
363
+ addMessage("error", "Failed to add node '" + (n.type || "?") + "': " + (e.message || e));
364
+ insertFailed = true;
365
+ }
366
+ });
367
+ // Bug found via code-review archaeology (Phase 6 item #11, left open
368
+ // and never revisited): bailing out entirely on ANY failure used to
369
+ // discard whatever DID succeed before the failing node — those nodes
370
+ // stayed on the live canvas (RED.nodes.add already ran) but with no
371
+ // RED.history entry, so Ctrl+Z couldn't remove them, and no message
372
+ // told the user anything had landed at all. Now only bail when
373
+ // NOTHING succeeded; the history push and wiring below already only
374
+ // reference addedNodes/addedJunctions/addedLinks (whatever's actually
375
+ // there), so a partial success gets undo coverage same as a full one.
376
+ if (!addedNodes.length && !addedJunctions.length) { return; }
377
+
378
+ // Re-link config-node "users" now that all new nodes (including any
379
+ // new config nodes, e.g. mqtt-broker) exist. addNode() ran this
380
+ // per-node as it was added, but a node added BEFORE its config-node
381
+ // dependency wouldn't have found it in configNodes yet — without this,
382
+ // the config node shows 0 users and may not be included on Deploy.
383
+ if (typeof RED.nodes.updateConfigNodeUsers === "function") {
384
+ addedNodes.forEach(function (n) {
385
+ RED.nodes.updateConfigNodeUsers(n, { action: "add" });
386
+ });
387
+ }
388
+
389
+ // Resolve newWires refs and add link objects. findLiveNode (not
390
+ // RED.nodes.node) because endpoints may be junctions we just added —
391
+ // junctions live in RED.nodes.junctions(z), not the normal registry.
392
+ var addedLinks = [];
393
+ (newWires || []).forEach(function (wire) {
394
+ var fromId = idMap[wire.from] || wire.from;
395
+ var toId = idMap[wire.to] || wire.to;
396
+ var fromNode = findLiveNode(fromId);
397
+ var toNode = findLiveNode(toId);
398
+ if (!fromNode || !toNode) {
399
+ addMessage("error", "Cannot wire — node not found: " + (!fromNode ? fromId : toId));
400
+ return;
401
+ }
402
+ var fromPort = wire.fromPort || 0;
403
+ if (!canWire(fromNode, fromPort, toNode)) {
404
+ addMessage("error", "Cannot wire — " +
405
+ (nodeOutputCount(fromNode) <= fromPort ? (fromNode.name || fromNode.type) + " has no output port " + fromPort
406
+ : (toNode.name || toNode.type) + " has no input"));
407
+ return;
408
+ }
409
+ var link = { source: fromNode, sourcePort: fromPort, target: toNode };
410
+ try {
411
+ RED.nodes.addLink(link);
412
+ addedLinks.push(link);
413
+ } catch (e) {
414
+ addMessage("error", "Failed to add wire: " + (e.message || e));
415
+ }
416
+ });
417
+
418
+ // Also process wires inside newNodes that reference existing nodes.
419
+ // The model uses "wires" for intra-new-node connections and "newWires"
420
+ // for cross-boundary connections, but it may also use "wires" to point
421
+ // to existing nodes (e.g. a new junction wired to an existing debug).
422
+ laid.forEach(function (newNode) {
423
+ if (!newNode.wires || !Array.isArray(newNode.wires)) { return; }
424
+ newNode.wires.forEach(function (portWires, portIndex) {
425
+ if (!Array.isArray(portWires)) { return; }
426
+ portWires.forEach(function (targetId) {
427
+ // Skip if this is an intra-new-node wire (placeholder id)
428
+ if (idMap[targetId]) { return; }
429
+ // This is a wire to an existing node - create the link
430
+ var fromNode = findLiveNode(newNode.id);
431
+ var toNode = findLiveNode(targetId);
432
+ if (!fromNode || !toNode) {
433
+ addMessage("error", "Cannot wire new node to existing node — node not found: " + (!fromNode ? newNode.id : targetId));
434
+ return;
435
+ }
436
+ if (!canWire(fromNode, portIndex, toNode)) {
437
+ addMessage("error", "Cannot wire — " +
438
+ (nodeOutputCount(fromNode) <= portIndex ? (fromNode.name || fromNode.type) + " has no output port " + portIndex
439
+ : (toNode.name || toNode.type) + " has no input"));
440
+ return;
441
+ }
442
+ var link = { source: fromNode, sourcePort: portIndex, target: toNode };
443
+ try {
444
+ RED.nodes.addLink(link);
445
+ addedLinks.push(link);
446
+ } catch (e) {
447
+ addMessage("error", "Failed to add wire: " + (e.message || e));
448
+ }
449
+ });
450
+ });
451
+ });
452
+
453
+ // One compound undo entry covers both the new nodes and their wires.
454
+ // NB: for t:"add", ev.nodes must be an array of ID STRINGS — NR's undo
455
+ // does RED.nodes.node(ev.nodes[i]) then reads .z, which throws (and
456
+ // silently breaks Ctrl+Z) if given node objects instead of ids.
457
+ RED.history.push({
458
+ t: "add",
459
+ nodes: addedNodes.map(function (n) { return n.id; }),
460
+ links: addedLinks,
461
+ groups: [],
462
+ junctions: addedJunctions,
463
+ subflow: { id: undefined, instances: [] },
464
+ subflowInputs: [],
465
+ subflowOutputs: [],
466
+ dirty: RED.nodes.dirty()
467
+ });
468
+
469
+ RED.nodes.dirty(true);
470
+ RED.view.redraw(true);
471
+
472
+ // Ground follow-up turns in what was just inserted. Report partial
473
+ // failure honestly instead of staying silent about it — the per-node
474
+ // "Failed to add..." errors above already explain what didn't make
475
+ // it, but without this the user has no summary tying it together.
476
+ var insertedCount = addedNodes.length + addedJunctions.length;
477
+ var insertedNote = insertFailed
478
+ ? "Inserted " + insertedCount + " of " + laid.length + " node(s) — some failed " +
479
+ "(see errors above). Ctrl+Z undoes what was added."
480
+ : "Touchdown — inserted " + insertedCount + " node(s)" +
481
+ (addedLinks.length ? " and added " + addedLinks.length + " wire connection(s)" : "") +
482
+ ". Ctrl+Z to undo.";
483
+ addMessage("assistant", insertedNote);
484
+ pushHistory("assistant", insertedNote);
485
+ updateSelectionStatus();
486
+
487
+ // Returned so applyModifications can resolve Tier 3 wire-diff targets
488
+ // that point at one of these placeholder ids (e.g. an existing node's
489
+ // "wires" rewired to a brand-new node added in the same response).
490
+ return idMap;
491
+ }
492
+
493
+ // Look up a live node by id, falling back to the per-tab junction
494
+ // registry, then the group registry. Junction nodes (wire-splice
495
+ // points) and groups are NOT in RED.nodes.node()'s normal registry —
496
+ // confirmed via core source (red.js's getNode() only checks
497
+ // configNodes/allNodes) — junctions live in RED.nodes.junctions(z),
498
+ // groups in RED.nodes.group(id). Without these fallbacks, a
499
+ // modNode/removeNodes id referring to either reads as "not found",
500
+ // which (in addModifyReview) renders "⚠ Node not found in editor" and
501
+ // blocks Apply entirely.
502
+ function findLiveNode(id) {
503
+ var node = (RED.nodes && RED.nodes.node) ? RED.nodes.node(id) : null;
504
+ if (node) { return node; }
505
+ if (RED.nodes.junctions && RED.workspaces && RED.workspaces.active) {
506
+ var activeZ = RED.workspaces.active();
507
+ var junctionsOnTab = RED.nodes.junctions(activeZ) || [];
508
+ for (var i = 0; i < junctionsOnTab.length; i++) {
509
+ if (junctionsOnTab[i].id === id) { return junctionsOnTab[i]; }
510
+ }
511
+ }
512
+ if (RED.nodes.group) {
513
+ var grp = RED.nodes.group(id);
514
+ if (grp) { return grp; }
515
+ }
516
+ return null;
517
+ }
518
+
519
+ // Compare a live editor node against a returned (modified) node, key by
520
+ // key. We only diff keys present in the returned node — internal editor
521
+ // fields the model omits are untouched on apply and excluded from the diff.
522
+ // `wires` is separated out so the review can flag it without applying it.
523
+ function computeNodeDiff(liveNode, modNode) {
524
+ var propertyChanges = [];
525
+ var wiresChanged = false;
526
+ var redactionSkips = 0;
527
+ Object.keys(modNode).forEach(function (k) {
528
+ if (DIFF_SKIP[k]) { return; }
529
+ var newRaw = modNode[k];
530
+ // If the model echoed a sanitizer sentinel, the field is opaque —
531
+ // we can't meaningfully compare or apply it, so skip entirely.
532
+ if (isSanitizeSentinel(newRaw)) { redactionSkips++; return; }
533
+ var oldRaw = liveNode ? liveNode[k] : undefined;
534
+ var oldStr, newStr;
535
+ try { oldStr = JSON.stringify(oldRaw); } catch (e) { oldStr = String(oldRaw); }
536
+ try { newStr = JSON.stringify(newRaw); } catch (e) { newStr = String(newRaw); }
537
+ if (oldStr === newStr) { return; }
538
+ if (k === "wires") { wiresChanged = true; return; }
539
+ propertyChanges.push({ key: k, oldVal: oldRaw, newVal: newRaw });
540
+ });
541
+ return { propertyChanges: propertyChanges, wiresChanged: wiresChanged, redactionSkips: redactionSkips };
542
+ }
543
+
544
+ // Diff outgoing wires for an existing node: compare what's live in the graph
545
+ // against what the model returned. Returns { toRemove, toAdd } each an array
546
+ // of { sourcePort, targetId }. Used by Tier 3 (rewire) to apply wire changes.
547
+ //
548
+ // validTargetIds is the set of node ids the model actually had in context
549
+ // (i.e. ids present in the returned "flow"). The model can't see or preserve
550
+ // connections to nodes outside its selection, so a live connection whose
551
+ // target is NOT in validTargetIds is left alone even if it's missing from
552
+ // modelWires — otherwise every out-of-context connection would look like an
553
+ // unintended removal (e.g. a node wired to a debug node that wasn't selected).
554
+ function computeWireDiff(nodeId, modelWires, validTargetIds) {
555
+ var currentByPort = {};
556
+ RED.nodes.eachLink(function (l) {
557
+ if (l.source && l.source.id === nodeId) {
558
+ var port = l.sourcePort || 0;
559
+ if (!currentByPort[port]) { currentByPort[port] = []; }
560
+ currentByPort[port].push(l.target.id);
561
+ }
562
+ });
563
+
564
+ var desiredByPort = {};
565
+ var wires = Array.isArray(modelWires) ? modelWires : [];
566
+ wires.forEach(function (targets, port) {
567
+ if (Array.isArray(targets) && targets.length > 0) {
568
+ desiredByPort[port] = targets.slice();
569
+ }
570
+ });
571
+
572
+ var toRemove = [];
573
+ Object.keys(currentByPort).forEach(function (port) {
574
+ var portNum = parseInt(port, 10);
575
+ var curTargets = currentByPort[port];
576
+ var desTargets = desiredByPort[portNum] || [];
577
+ curTargets.forEach(function (tid) {
578
+ if (desTargets.indexOf(tid) === -1 && validTargetIds && validTargetIds[tid]) {
579
+ toRemove.push({ sourcePort: portNum, targetId: tid });
580
+ }
581
+ });
582
+ });
583
+
584
+ var toAdd = [];
585
+ Object.keys(desiredByPort).forEach(function (port) {
586
+ var portNum = parseInt(port, 10);
587
+ var desTargets = desiredByPort[port];
588
+ var curTargets = currentByPort[portNum] || [];
589
+ desTargets.forEach(function (tid) {
590
+ if (curTargets.indexOf(tid) === -1) {
591
+ toAdd.push({ sourcePort: portNum, targetId: tid });
592
+ }
593
+ });
594
+ });
595
+
596
+ return { toRemove: toRemove, toAdd: toAdd };
597
+ }
598
+
599
+ function formatDiffVal(v) {
600
+ if (v === undefined || v === null) { return "(none)"; }
601
+ if (typeof v === "object") { return JSON.stringify(v); }
602
+ return String(v);
603
+ }
604
+
605
+ // Tabbed diff review for a modify response. Shows property diffs for
606
+ // existing nodes and, when the model also returned new nodes to insert,
607
+ // a list of those plus the wire connections to be made. The Apply button
608
+ // label adapts: "Apply Changes" / "Insert Nodes" / "Apply & Insert".
609
+ // buildFixInfo: present only for a /build loop review's fix envelope
610
+ // (handleBuildReviewResult) — `{ capReached }`, carried in the pop-out's
611
+ // relay tag since applying there also needs the loop bookkeeping
612
+ // applyBuildLoopFix does, not just applyModifications.
613
+ // newGroups: Phase 8.5 C2 slice 3 — see applyGroupChanges.
614
+ function addModifyReview(modifiedFlow, newNodes, newWires, removeNodes, applyCallback, buildFixInfo, newGroups) {
615
+ var $box = el("#fp-messages");
616
+ if (!$box.length) { return; }
617
+
618
+ var nodes = Array.isArray(modifiedFlow) ? modifiedFlow : [];
619
+ newNodes = Array.isArray(newNodes) ? newNodes : [];
620
+ newWires = Array.isArray(newWires) ? newWires : [];
621
+ removeNodes = Array.isArray(removeNodes) ? removeNodes : [];
622
+ newGroups = Array.isArray(newGroups) ? newGroups : [];
623
+
624
+ // Ids the model actually had in context (the returned "flow"). Used by
625
+ // computeWireDiff to avoid flagging connections to out-of-context nodes
626
+ // as removals — the model can't see or preserve those.
627
+ var validTargetIds = {};
628
+ nodes.forEach(function (n) { if (n && n.id) { validTargetIds[n.id] = true; } });
629
+
630
+ // Match each returned node to its live counterpart and compute diffs.
631
+ // wiresDiff is computed from the live graph via eachLink, not from
632
+ // node.wires (which is empty in the live editor — export-time artifact).
633
+ var nodeDiffs = nodes.map(function (modNode) {
634
+ var liveNode = findLiveNode(modNode.id);
635
+ var diff = computeNodeDiff(liveNode || {}, modNode);
636
+ var wiresDiff = (diff.wiresChanged && liveNode)
637
+ ? computeWireDiff(modNode.id, modNode.wires, validTargetIds)
638
+ : { toRemove: [], toAdd: [] };
639
+ return {
640
+ modNode: modNode,
641
+ liveNode: liveNode,
642
+ propertyChanges: diff.propertyChanges,
643
+ wiresChanged: diff.wiresChanged,
644
+ redactionSkips: diff.redactionSkips,
645
+ wiresDiff: wiresDiff,
646
+ name: modNode.name || (liveNode && liveNode.name) || "",
647
+ type: modNode.type || (liveNode && liveNode.type) || ""
648
+ };
649
+ });
650
+
651
+ var totalPropChanges = nodeDiffs.reduce(function (s, d) {
652
+ return s + d.propertyChanges.length;
653
+ }, 0);
654
+ var nodesWithChanges = nodeDiffs.filter(function (d) {
655
+ return d.propertyChanges.length > 0;
656
+ }).length;
657
+ var missingLive = nodeDiffs.some(function (d) { return !d.liveNode; });
658
+ var hasPropChanges = totalPropChanges > 0;
659
+ var hasWireChanges = nodeDiffs.some(function (d) {
660
+ return d.wiresDiff && (d.wiresDiff.toRemove.length > 0 || d.wiresDiff.toAdd.length > 0);
661
+ });
662
+ var hasNewNodes = newNodes.length > 0;
663
+ var hasRemoveNodes = removeNodes.length > 0;
664
+ var hasNewGroups = newGroups.length > 0;
665
+ var hasAnyChanges = hasPropChanges || hasWireChanges || hasNewNodes || hasRemoveNodes || hasNewGroups;
666
+ var totalRedactionSkips = nodeDiffs.reduce(function (s, d) { return s + (d.redactionSkips || 0); }, 0);
667
+
668
+ var $msg = $("<div>").addClass("fp-message fp-review");
669
+ $("<div>").addClass("fp-label").text("MODIFY FLOW — REVIEW CHANGES").appendTo($msg);
670
+
671
+ var $tabSummary = $("<button>").addClass("fp-tab fp-tab-active").attr("type", "button").text("Summary");
672
+ var $tabJson = $("<button>").addClass("fp-tab").attr("type", "button").text("JSON");
673
+ $("<div>").addClass("fp-tabs").append($tabSummary, $tabJson).appendTo($msg);
674
+
675
+ var $summaryPanel = $("<div>").addClass("fp-tab-panel");
676
+ var $jsonPanel = $("<div>").addClass("fp-tab-panel fp-hidden");
677
+ $msg.append($summaryPanel, $jsonPanel);
678
+
679
+ // ---- Summary tab: property diffs for existing nodes ----
680
+ // Only show this section when there are actual property changes —
681
+ // "0 of N will change" is confusing when we're purely inserting or rewiring.
682
+ if (hasPropChanges) {
683
+ $("<div>").addClass("fp-review-count")
684
+ .text(nodesWithChanges + " of " + nodes.length + " node(s) will change:")
685
+ .appendTo($summaryPanel);
686
+ nodeDiffs.forEach(function (d) {
687
+ if (d.propertyChanges.length === 0) { return; }
688
+ var $section = $("<div>").addClass("fp-diff-node").appendTo($summaryPanel);
689
+ var title = d.type + (d.name ? " — \"" + d.name + "\"" : "");
690
+ $("<div>").addClass("fp-diff-node-title").text(title).appendTo($section);
691
+ if (!d.liveNode) {
692
+ $("<div>").addClass("fp-diff-warn")
693
+ .text("⚠ Node not found in editor (id: " + d.modNode.id + ")")
694
+ .appendTo($section);
695
+ return;
696
+ }
697
+ d.propertyChanges.forEach(function (c) {
698
+ var $row = $("<div>").addClass("fp-diff-row").appendTo($section);
699
+ $("<span>").addClass("fp-diff-key").text(c.key).appendTo($row);
700
+ $("<span>").addClass("fp-diff-old").text(formatDiffVal(c.oldVal)).appendTo($row);
701
+ $("<span>").addClass("fp-diff-arrow").text("→").appendTo($row);
702
+ $("<span>").addClass("fp-diff-new").text(formatDiffVal(c.newVal)).appendTo($row);
703
+ });
704
+ });
705
+ }
706
+
707
+ // ---- Summary tab: redaction-stripped changes ----
708
+ // Nodes where every proposed change was blocked by isSanitizeSentinel —
709
+ // the model proposed a change to a field that was shown as a placeholder,
710
+ // so the diff was silently dropped. Surfaces the node name + a hint
711
+ // rather than hiding the skip entirely (which was the old behavior).
712
+ var redactionOnlyDiffs = nodeDiffs.filter(function (d) {
713
+ return d.redactionSkips > 0 && d.propertyChanges.length === 0 &&
714
+ (!d.wiresDiff || (!d.wiresDiff.toRemove.length && !d.wiresDiff.toAdd.length));
715
+ });
716
+ if (redactionOnlyDiffs.length > 0) {
717
+ var redSectionTop = hasPropChanges ? "12px" : "0";
718
+ $("<div>").addClass("fp-review-count").css("margin-top", redSectionTop)
719
+ .text("Not applied — targets redacted value(s):")
720
+ .appendTo($summaryPanel);
721
+ redactionOnlyDiffs.forEach(function (d) {
722
+ var $section = $("<div>").addClass("fp-diff-node").appendTo($summaryPanel);
723
+ var title = d.type + (d.name ? " — \"" + d.name + "\"" : "");
724
+ $("<div>").addClass("fp-diff-node-title").text(title).appendTo($section);
725
+ $("<div>").addClass("fp-diff-warn")
726
+ .text("Proposed change targets a redacted field — edit the value directly in the node editor.")
727
+ .appendTo($section);
728
+ });
729
+ }
730
+
731
+ // ---- Summary tab: wiring changes (Tier 3) ----
732
+ // Shown when model changed wires on existing nodes. Removed wires appear
733
+ // in the old column (red strikethrough); added wires in the new column (green).
734
+ if (hasWireChanges) {
735
+ var sectionTop = (hasPropChanges || redactionOnlyDiffs.length > 0) ? "12px" : "0";
736
+ $("<div>").addClass("fp-review-count").css("margin-top", sectionTop)
737
+ .text("Wiring changes:")
738
+ .appendTo($summaryPanel);
739
+ nodeDiffs.forEach(function (d) {
740
+ var wd = d.wiresDiff;
741
+ if (!wd || (!wd.toRemove.length && !wd.toAdd.length)) { return; }
742
+ var $section = $("<div>").addClass("fp-diff-node").appendTo($summaryPanel);
743
+ var title = d.type + (d.name ? " — \"" + d.name + "\"" : "");
744
+ $("<div>").addClass("fp-diff-node-title").text(title).appendTo($section);
745
+ wd.toRemove.forEach(function (entry) {
746
+ var tgt = RED.nodes.node ? RED.nodes.node(entry.targetId) : null;
747
+ var tgtLabel = tgt ? (tgt.name || tgt.type || entry.targetId) : entry.targetId;
748
+ var $row = $("<div>").addClass("fp-diff-row").appendTo($section);
749
+ $("<span>").addClass("fp-diff-key").text("port " + entry.sourcePort).appendTo($row);
750
+ $("<span>").addClass("fp-diff-old").text("→ " + tgtLabel).appendTo($row);
751
+ $("<span>").addClass("fp-diff-arrow").text("✕").appendTo($row);
752
+ $("<span>").addClass("fp-diff-new").text("").appendTo($row);
753
+ });
754
+ wd.toAdd.forEach(function (entry) {
755
+ var tgt = RED.nodes.node ? RED.nodes.node(entry.targetId) : null;
756
+ var tgtLabel = tgt ? (tgt.name || tgt.type || entry.targetId) : entry.targetId;
757
+ var $row = $("<div>").addClass("fp-diff-row").appendTo($section);
758
+ $("<span>").addClass("fp-diff-key").text("port " + entry.sourcePort).appendTo($row);
759
+ $("<span>").addClass("fp-diff-old").text("").appendTo($row);
760
+ $("<span>").addClass("fp-diff-arrow").text("→").appendTo($row);
761
+ $("<span>").addClass("fp-diff-new").text(tgtLabel).appendTo($row);
762
+ });
763
+ });
764
+ }
765
+
766
+ // ---- Summary tab: nodes to remove (Tier 4) ----
767
+ if (hasRemoveNodes) {
768
+ var rmTop = (hasPropChanges || hasWireChanges) ? "12px" : "0";
769
+ $("<div>").addClass("fp-review-count fp-diff-warn").css("margin-top", rmTop)
770
+ .text(removeNodes.length + " node(s) to remove:")
771
+ .appendTo($summaryPanel);
772
+ var $rmList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
773
+ removeNodes.forEach(function (id) {
774
+ var lv = RED.nodes.node ? RED.nodes.node(id) : null;
775
+ var label = lv ? ((lv.name || lv.type || id) + " (" + id + ")") : id + " (not found)";
776
+ $("<li>").addClass("fp-diff-warn").text("✕ " + label).appendTo($rmList);
777
+ });
778
+ $("<div>").addClass("fp-diff-warn").css("margin-top", "4px")
779
+ .text("All wires to/from removed nodes will also be deleted.")
780
+ .appendTo($summaryPanel);
781
+ }
782
+
783
+ // ---- Summary tab: new nodes to insert ----
784
+ if (hasNewNodes) {
785
+ var insTop = (hasPropChanges || hasWireChanges || hasRemoveNodes) ? "12px" : "0";
786
+ $("<div>").addClass("fp-review-count").css("margin-top", insTop)
787
+ .text(newNodes.length + " node(s) to insert:")
788
+ .appendTo($summaryPanel);
789
+ var $newList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
790
+ newNodes.forEach(function (n) {
791
+ $("<li>").text((n.type || "unknown") + (n.name ? " — \"" + n.name + "\"" : "")).appendTo($newList);
792
+ });
793
+ if (newWires.length > 0) {
794
+ $("<div>").addClass("fp-review-count").css("margin-top", "8px")
795
+ .text(newWires.length + " wire connection(s):")
796
+ .appendTo($summaryPanel);
797
+ var $wireList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
798
+ newWires.forEach(function (wire) {
799
+ var fromLabel = resolveWireRef(wire.from, newNodes);
800
+ var toLabel = resolveWireRef(wire.to, newNodes);
801
+ var portNote = (wire.fromPort && wire.fromPort > 0) ? " [port " + wire.fromPort + "]" : "";
802
+ $("<li>").text(fromLabel + portNote + " → " + toLabel).appendTo($wireList);
803
+ });
804
+ }
805
+ }
806
+
807
+ // ---- Summary tab: groups to create/update ----
808
+ if (hasNewGroups) {
809
+ var grpTop = (hasPropChanges || hasWireChanges || hasRemoveNodes || hasNewNodes) ? "12px" : "0";
810
+ $("<div>").addClass("fp-review-count").css("margin-top", grpTop)
811
+ .text(newGroups.length + " group(s) to create/update:")
812
+ .appendTo($summaryPanel);
813
+ var $grpList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
814
+ newGroups.forEach(function (g) {
815
+ var label = (g.name ? "\"" + g.name + "\"" : "(unnamed group)") +
816
+ " — " + (Array.isArray(g.nodes) ? g.nodes.length : 0) + " node(s)";
817
+ $("<li>").text(label).appendTo($grpList);
818
+ });
819
+ }
820
+
821
+ // ---- JSON tab ----
822
+ var jsonPayload = (hasNewNodes || hasRemoveNodes || hasNewGroups)
823
+ ? { modifiedNodes: nodes, newNodes: newNodes, newWires: newWires, removeNodes: removeNodes, newGroups: newGroups }
824
+ : nodes;
825
+ var jsonText = JSON.stringify(jsonPayload, null, 2);
826
+ var $copyBtn = $("<button>")
827
+ .addClass("red-ui-button red-ui-button-small")
828
+ .attr("type", "button")
829
+ .text("Copy")
830
+ .on("click", function () { copyToClipboard($copyBtn, jsonText); });
831
+ $("<div>").addClass("fp-json-toolbar").append($copyBtn).appendTo($jsonPanel);
832
+ $("<pre>").addClass("fp-json").text(jsonText).appendTo($jsonPanel);
833
+
834
+ function activateTab(showSummary) {
835
+ $tabSummary.toggleClass("fp-tab-active", showSummary);
836
+ $tabJson.toggleClass("fp-tab-active", !showSummary);
837
+ $summaryPanel.toggleClass("fp-hidden", !showSummary);
838
+ $jsonPanel.toggleClass("fp-hidden", showSummary);
839
+ }
840
+ $tabSummary.on("click", function () { activateTab(true); });
841
+ $tabJson.on("click", function () { activateTab(false); });
842
+
843
+ // ---- Action row ----
844
+ var $actions = $("<div>").addClass("fp-review-actions").appendTo($msg);
845
+ if (!hasAnyChanges) {
846
+ if (totalRedactionSkips > 0) {
847
+ $("<div>").addClass("fp-warning")
848
+ .text("All proposed changes target redacted fields — FlowPilot can only see those values as placeholders. Edit the field(s) directly in the node editor, then retry for any remaining changes.")
849
+ .appendTo($actions);
850
+ } else {
851
+ $("<div>").addClass("fp-review-hint")
852
+ .text("No changes detected — nothing to apply.")
853
+ .appendTo($actions);
854
+ }
855
+ } else if (missingLive && (hasPropChanges || hasWireChanges)) {
856
+ $("<div>").addClass("fp-warning")
857
+ .text("One or more nodes could not be found in the editor. Cannot apply safely.")
858
+ .appendTo($actions);
859
+ } else {
860
+ var hasMutations = hasPropChanges || hasWireChanges || hasRemoveNodes;
861
+ var btnLabel = (hasMutations && hasNewNodes) ? "Apply & Insert"
862
+ : hasMutations ? "Apply Changes"
863
+ : hasNewNodes ? "Insert Nodes"
864
+ : "Apply Changes"; // covers a request that ONLY creates/updates a group
865
+
866
+ // sharedApplyData is stored in the review record (Phase 10 0B)
867
+ // so the pop-out can relay an applyByRecordId intent to the
868
+ // parent without serializing payload into DOM attributes.
869
+ // nodeDiffs is re-serialized without liveNode (a RED node object,
870
+ // not JSON-safe; applyModifications re-fetches via findLiveNode).
871
+ var sharedApplyData = {
872
+ nodeDiffs: nodeDiffs.map(function (d) {
873
+ return {
874
+ modNode: d.modNode,
875
+ propertyChanges: d.propertyChanges,
876
+ wiresChanged: d.wiresChanged,
877
+ wiresDiff: d.wiresDiff,
878
+ name: d.name,
879
+ type: d.type
880
+ };
881
+ }),
882
+ removeNodes: removeNodes,
883
+ newNodes: newNodes,
884
+ newWires: newWires,
885
+ newGroups: newGroups,
886
+ existingNodeIds: nodes.map(function (n) { return n.id; }),
887
+ hasMutations: hasMutations
888
+ };
889
+ if (buildFixInfo) { sharedApplyData.capReached = !!buildFixInfo.capReached; }
890
+ var _modRecord = addRecord("review", {
891
+ subkind: buildFixInfo ? "build-fix" : "modify",
892
+ sharedApplyData: sharedApplyData,
893
+ state: "pending"
894
+ });
895
+ $msg.attr("data-fp-record-id", _modRecord.id);
896
+
897
+ var $applyBtn = $("<button>")
898
+ .addClass("red-ui-button red-ui-button-primary")
899
+ .attr("type", "button")
900
+ .text(btnLabel)
901
+ .on("click", function () {
902
+ $applyBtn.prop("disabled", true).text("Applying…");
903
+ // Insertions run FIRST so their placeholder→real-id map is
904
+ // available to applyModifications/applyGroupChanges — an
905
+ // existing node's rewired "wires" (Tier 3) or a new
906
+ // group's membership may point at a node being inserted
907
+ // in this same response.
908
+ var idMap = {};
909
+ if (hasNewNodes) {
910
+ idMap = applyInsertions(newNodes, newWires, nodes.map(function (n) { return n.id; })) || {};
911
+ }
912
+ if (hasMutations && applyCallback) { applyCallback(nodeDiffs, removeNodes, null, idMap); }
913
+ if (hasNewGroups) { applyGroupChanges(newGroups, idMap); }
914
+ if (_modRecord) { _modRecord.state = "applied"; }
915
+ $applyBtn.text("Done ✓");
916
+ });
917
+ $actions.append($applyBtn);
918
+ var hintParts = [];
919
+ if (hasPropChanges || hasWireChanges) { hintParts.push("changes mutate live nodes"); }
920
+ if (hasRemoveNodes) { hintParts.push("removals delete nodes"); }
921
+ if (hasNewGroups) { hintParts.push("groups are created/updated"); }
922
+ if (hasNewNodes) { hintParts.push("insertions add new nodes"); }
923
+ var hintText = "Review above — " + hintParts.join(", ") + ". Ctrl+Z to undo.";
924
+ $("<span>").addClass("fp-review-hint").text(hintText).appendTo($actions);
925
+ }
926
+
927
+ $box.append($msg);
928
+ scrollMessagesToBottom();
929
+ }
930
+
931
+ // Apply mechanism covering all modification tiers:
932
+ // Tier 1 — property changes: mutate live node + {t:"edit"} history entry
933
+ // Tier 3 — wire changes: removeLink/addLink + {t:"add", removedLinks} entry
934
+ // Tier 4 — node removals: collect links, removeLink, remove node + {t:"delete"} entry
935
+ // One history entry per node per type so Ctrl+Z steps back through them cleanly.
936
+ function applyModifications(nodeDiffs, removeNodes, $applyBtn, idMap) {
937
+ idMap = idMap || {};
938
+ var propApplied = 0;
939
+ var wireNodesApplied = 0;
940
+ var nodesRemoved = 0;
941
+ var failed = [];
942
+ removeNodes = Array.isArray(removeNodes) ? removeNodes : [];
943
+
944
+ // --- Tier 1: property changes ---
945
+ nodeDiffs.forEach(function (d) {
946
+ if (d.propertyChanges.length === 0) { return; }
947
+ var liveNode = findLiveNode(d.modNode.id);
948
+ if (!liveNode) { failed.push(d.modNode.id); return; }
949
+
950
+ var oldValues = {};
951
+ d.propertyChanges.forEach(function (c) { oldValues[c.key] = liveNode[c.key]; });
952
+ d.propertyChanges.forEach(function (c) { liveNode[c.key] = c.newVal; });
953
+
954
+ // Switch nodes derive their port count from rules.length (the edit
955
+ // dialog's "outputCount" map assigns each rule its own output and
956
+ // writes the resulting count to node.outputs on save). FlowPilot's
957
+ // direct mutation above changes "rules" but never touches
958
+ // "outputs", leaving the canvas showing the old port count even
959
+ // though the new wires/rules are correct. Recompute it here so
960
+ // RED.view.redraw(true) (below) rebuilds the ports - the redraw
961
+ // loop rebuilds a node's output ports whenever __outputs__.length
962
+ // !== d.outputs.
963
+ if (liveNode.type === "switch" && Array.isArray(liveNode.rules) &&
964
+ liveNode.rules.length !== liveNode.outputs) {
965
+ if (!oldValues.hasOwnProperty("outputs")) { oldValues.outputs = liveNode.outputs; }
966
+ liveNode.outputs = liveNode.rules.length;
967
+ }
968
+
969
+ liveNode.changed = true;
970
+ liveNode.dirty = true;
971
+ RED.history.push({
972
+ t: "edit",
973
+ node: liveNode,
974
+ changes: oldValues,
975
+ dirty: RED.nodes.dirty()
976
+ });
977
+ propApplied++;
978
+ });
979
+
980
+ // --- Tier 3: wire changes ---
981
+ // removeLink old + addLink new, then push ONE {t:"add", removedLinks} entry
982
+ // per node. NR's undo for this shape removes the new links AND restores
983
+ // the old ones (removedLinks field is present on every "add" wire event).
984
+ nodeDiffs.forEach(function (d) {
985
+ var wd = d.wiresDiff;
986
+ if (!wd || (!wd.toRemove.length && !wd.toAdd.length)) { return; }
987
+ var liveNode = findLiveNode(d.modNode.id);
988
+ if (!liveNode) { return; }
989
+
990
+ var removedLinks = [];
991
+ var addedLinks = [];
992
+
993
+ wd.toRemove.forEach(function (entry) {
994
+ var found = null;
995
+ RED.nodes.eachLink(function (l) {
996
+ if (found) { return; }
997
+ if (l.source && l.source.id === d.modNode.id &&
998
+ (l.sourcePort || 0) === entry.sourcePort &&
999
+ l.target && l.target.id === entry.targetId) {
1000
+ found = l;
1001
+ }
1002
+ });
1003
+ if (found) {
1004
+ RED.nodes.removeLink(found);
1005
+ removedLinks.push(found);
1006
+ }
1007
+ });
1008
+
1009
+ wd.toAdd.forEach(function (entry) {
1010
+ // entry.targetId may be a placeholder id for a node inserted
1011
+ // by the SAME response (e.g. "fp-new-0") — resolve it to the
1012
+ // real id applyInsertions just assigned, if any.
1013
+ var targetId = idMap[entry.targetId] || entry.targetId;
1014
+ var toNode = (RED.nodes && RED.nodes.node) ? RED.nodes.node(targetId) : null;
1015
+ if (!toNode) { return; }
1016
+ if (!canWire(liveNode, entry.sourcePort, toNode)) {
1017
+ addMessage("error", "Cannot wire — " +
1018
+ (nodeOutputCount(liveNode) <= entry.sourcePort ? (liveNode.name || liveNode.type) + " has no output port " + entry.sourcePort
1019
+ : (toNode.name || toNode.type) + " has no input"));
1020
+ return;
1021
+ }
1022
+ var link = { source: liveNode, sourcePort: entry.sourcePort, target: toNode };
1023
+ try {
1024
+ RED.nodes.addLink(link);
1025
+ addedLinks.push(link);
1026
+ } catch (e) {
1027
+ addMessage("error", "Failed to add wire: " + (e.message || e));
1028
+ }
1029
+ });
1030
+
1031
+ if (removedLinks.length || addedLinks.length) {
1032
+ RED.history.push({
1033
+ t: "add",
1034
+ links: addedLinks,
1035
+ removedLinks: removedLinks,
1036
+ dirty: RED.nodes.dirty()
1037
+ });
1038
+ wireNodesApplied++;
1039
+ }
1040
+ });
1041
+
1042
+ // --- Tier 4: node removals ---
1043
+ // Collect connected links BEFORE removing anything (for the history entry),
1044
+ // then remove the node — RED.nodes.remove(id) cleans up its own links
1045
+ // internally. RED.nodes.remove(id) takes an ID STRING (not a node object —
1046
+ // confirmed via red.js: removeNode(id) does `allNodes.hasNode(id)`, which
1047
+ // a node object fails silently, making it a no-op with no error). Push one
1048
+ // compound {t:"delete"} entry per node so a single Ctrl+Z restores both
1049
+ // the node and its links.
1050
+ //
1051
+ // Junction nodes (wire-splice points) are removed via
1052
+ // RED.nodes.removeJunction(junctionObj) (object, not id), with the
1053
+ // history entry's junction going in `junctions`, not `nodes` —
1054
+ // findLiveNode's junction-registry fallback returns the junction
1055
+ // object itself, which doubles as the isJunction check via .type.
1056
+ removeNodes.forEach(function (id) {
1057
+ var liveNode = findLiveNode(id);
1058
+ var isJunction = !!(liveNode && liveNode.type === "junction");
1059
+
1060
+ if (!liveNode) { failed.push(id); return; }
1061
+
1062
+ var connectedLinks = [];
1063
+ RED.nodes.eachLink(function (l) {
1064
+ if ((l.source && l.source.id === id) || (l.target && l.target.id === id)) {
1065
+ connectedLinks.push(l);
1066
+ }
1067
+ });
1068
+
1069
+ try {
1070
+ if (isJunction) {
1071
+ RED.nodes.removeJunction(liveNode);
1072
+ } else {
1073
+ RED.nodes.remove(liveNode.id);
1074
+ }
1075
+ } catch (e) {
1076
+ addMessage("error", "Failed to remove node " + id + ": " + (e.message || e));
1077
+ return;
1078
+ }
1079
+
1080
+ // RED.nodes.remove()/removeJunction() do NOT clean up group
1081
+ // membership at all (confirmed via core source — getNode()'s
1082
+ // removal path never touches .g or group.nodes). Node-RED's
1083
+ // own UI delete action does this extra bookkeeping itself
1084
+ // (red.js's deleteSelection(), ~line 27151) rather than baking
1085
+ // it into the data-model removal call — replicate it here so a
1086
+ // removed grouped node doesn't leave a dangling reference in
1087
+ // group.nodes with a stale bounding box.
1088
+ if (liveNode.g && RED.nodes.group) {
1089
+ var ownerGroup = RED.nodes.group(liveNode.g);
1090
+ if (ownerGroup) {
1091
+ var memberIdx = ownerGroup.nodes.indexOf(liveNode);
1092
+ if (memberIdx !== -1) { ownerGroup.nodes.splice(memberIdx, 1); }
1093
+ RED.group.markDirty(ownerGroup);
1094
+ }
1095
+ }
1096
+
1097
+ RED.history.push({
1098
+ t: "delete",
1099
+ nodes: isJunction ? [] : [liveNode],
1100
+ links: connectedLinks,
1101
+ groups: [],
1102
+ junctions: isJunction ? [liveNode] : [],
1103
+ subflow: { id: undefined, instances: [] },
1104
+ subflowInputs: [],
1105
+ subflowOutputs: [],
1106
+ dirty: RED.nodes.dirty()
1107
+ });
1108
+ nodesRemoved++;
1109
+ });
1110
+
1111
+ RED.nodes.dirty(true);
1112
+ RED.view.redraw(true);
1113
+
1114
+ if ($applyBtn) { $applyBtn.prop("disabled", true).text("Applied ✓"); }
1115
+
1116
+ var parts = [];
1117
+ if (propApplied) { parts.push("changes to " + propApplied + " node(s)"); }
1118
+ if (wireNodesApplied) { parts.push("wiring updates on " + wireNodesApplied + " node(s)"); }
1119
+ if (nodesRemoved) { parts.push("removed " + nodesRemoved + " node(s)"); }
1120
+
1121
+ if (failed.length) {
1122
+ addMessage("error",
1123
+ (parts.length ? "Applied: " + parts.join(", ") + ". " : "") +
1124
+ failed.length + " node(s) not found and skipped: " + failed.join(", "));
1125
+ } else if (parts.length) {
1126
+ // Ground follow-up turns ("now undo the topic
1127
+ // change") in what was actually applied.
1128
+ var appliedNote = "Touchdown — applied " + parts.join(", ") + ". Ctrl+Z to undo.";
1129
+ addMessage("assistant", appliedNote);
1130
+ pushHistory("assistant", appliedNote);
1131
+ updateSelectionStatus();
1132
+ }
1133
+ }
1134
+
1135
+ // Reconciles a Modify response's "newGroups" entries (Phase 8.5 C2
1136
+ // slice 3) against live state. Each entry's "nodes" is the FULL
1137
+ // desired membership for that group id — declarative, like "changes"
1138
+ // — not a one-shot "add these" instruction:
1139
+ // - If a LIVE group already exists with this id (the model learned
1140
+ // about it via sanitizeNode's context "group" field), membership
1141
+ // is diffed against what's actually there now and reconciled via
1142
+ // RED.group.addToGroup/removeFromGroup (both confirmed via core
1143
+ // source to handle bounding-box math + dirty-marking themselves —
1144
+ // nothing to reimplement), and a changed "name" is applied as a
1145
+ // direct property edit, same shape as Tier 1.
1146
+ // - If no live group matches, RED.group.createGroup(memberNodes)
1147
+ // makes a brand new one — it always assigns its OWN fresh id
1148
+ // (RED.nodes.id()), unlike applyInsertions' regular nodes, so
1149
+ // there's no idMap entry to register for it (nothing in v1 wires
1150
+ // to a group afterward anyway).
1151
+ // One RED.history.push per discrete operation (matching how Node-RED's
1152
+ // own group UI actions push them separately too), not one giant batch.
1153
+ // RED.group.createGroup()/addToGroup() both require every member to
1154
+ // share the exact same STARTING .g (all currently ungrouped, or all
1155
+ // already in the identical group) — a mix is rejected by NR core
1156
+ // itself: createGroup silently console.warns and returns undefined;
1157
+ // addToGroup throws outright (and even then only tolerates ONE
1158
+ // pre-existing source group, and only if that node is at index 0).
1159
+ // Live-confirmed (2026-06-30): asked to group a mix of previously-
1160
+ // ungrouped comment nodes plus one already-grouped node, createGroup
1161
+ // returned undefined, and "newGroup.name = g.name" then threw on
1162
+ // that undefined — caught and reported as "Failed to create group".
1163
+ // Detach every member from whatever group it's CURRENTLY in first
1164
+ // (batched per distinct old group, so each is one clean undo step),
1165
+ // so every member starts from .g === undefined before create/extend
1166
+ // ever runs — handles members arriving from any mix of prior states.
1167
+ function detachFromCurrentGroups(nodes, exceptGroupId) {
1168
+ var byOldGroup = {};
1169
+ nodes.forEach(function (n) {
1170
+ if (n.g && n.g !== exceptGroupId) {
1171
+ (byOldGroup[n.g] = byOldGroup[n.g] || []).push(n);
1172
+ }
1173
+ });
1174
+ Object.keys(byOldGroup).forEach(function (oldGroupId) {
1175
+ var oldGroup = findLiveNode(oldGroupId);
1176
+ if (oldGroup && oldGroup.type === "group") {
1177
+ var detached = byOldGroup[oldGroupId];
1178
+ RED.group.removeFromGroup(oldGroup, detached, false);
1179
+ RED.history.push({ t: "removeFromGroup", group: oldGroup, nodes: detached, dirty: RED.nodes.dirty() });
1180
+ }
1181
+ });
1182
+ }
1183
+
1184
+ function applyGroupChanges(newGroups, idMap) {
1185
+ idMap = idMap || {};
1186
+ newGroups = Array.isArray(newGroups) ? newGroups : [];
1187
+ var groupsApplied = 0;
1188
+
1189
+ newGroups.forEach(function (g) {
1190
+ if (!g || !g.id) { return; }
1191
+ var memberIds = Array.isArray(g.nodes) ? g.nodes : [];
1192
+ var memberNodes = memberIds.map(function (ref) {
1193
+ return findLiveNode(idMap[ref] || ref);
1194
+ }).filter(function (n) { return !!n; });
1195
+
1196
+ // An EXISTING group reconciling down to ZERO members is a
1197
+ // legitimate, meaningful request — "ungroup everyone in this
1198
+ // group" — so the empty-members case is only a no-op for the
1199
+ // CREATE branch below (RED.group.createGroup on nothing makes
1200
+ // no sense; an existing group going empty does).
1201
+ var liveGroup = findLiveNode(g.id);
1202
+ if (!memberNodes.length && !(liveGroup && liveGroup.type === "group")) { return; }
1203
+
1204
+ if (liveGroup && liveGroup.type === "group") {
1205
+ if (!memberNodes.length) {
1206
+ // Full disband. RED.group.removeFromGroup only empties
1207
+ // .nodes - it never removes the group object itself, so
1208
+ // looping it down to zero members leaves a tiny, dangling
1209
+ // empty group on the canvas (confirmed live). The
1210
+ // editor's own "Ungroup Selection" action uses a
1211
+ // different API for this exact case - RED.group.ungroup
1212
+ // reparents members (or clears their .g) AND calls
1213
+ // RED.nodes.removeGroup to actually remove the group.
1214
+ RED.group.ungroup(liveGroup);
1215
+ RED.history.push({ t: "ungroup", groups: [liveGroup], dirty: RED.nodes.dirty() });
1216
+ groupsApplied++;
1217
+ } else {
1218
+ var desiredIds = {};
1219
+ memberNodes.forEach(function (n) { desiredIds[n.id] = true; });
1220
+ var currentIds = {};
1221
+ liveGroup.nodes.forEach(function (n) { currentIds[n.id] = true; });
1222
+ var toRemove = liveGroup.nodes.filter(function (n) { return !desiredIds[n.id]; });
1223
+ var toAdd = memberNodes.filter(function (n) { return !currentIds[n.id]; });
1224
+
1225
+ if (toRemove.length) {
1226
+ RED.group.removeFromGroup(liveGroup, toRemove, false);
1227
+ RED.history.push({ t: "removeFromGroup", group: liveGroup, nodes: toRemove, dirty: RED.nodes.dirty() });
1228
+ }
1229
+ if (toAdd.length) {
1230
+ detachFromCurrentGroups(toAdd, liveGroup.id);
1231
+ RED.group.addToGroup(liveGroup, toAdd);
1232
+ RED.history.push({ t: "addToGroup", group: liveGroup, nodes: toAdd, dirty: RED.nodes.dirty() });
1233
+ }
1234
+ if (g.name !== undefined && g.name !== liveGroup.name) {
1235
+ var oldName = liveGroup.name;
1236
+ liveGroup.name = g.name;
1237
+ liveGroup.changed = true;
1238
+ RED.history.push({ t: "edit", node: liveGroup, changes: { name: oldName }, dirty: RED.nodes.dirty() });
1239
+ }
1240
+ groupsApplied++;
1241
+ }
1242
+ } else {
1243
+ try {
1244
+ detachFromCurrentGroups(memberNodes);
1245
+ var newGroup = RED.group.createGroup(memberNodes);
1246
+ if (g.name) { newGroup.name = g.name; }
1247
+ RED.group.markDirty(newGroup);
1248
+ RED.history.push({ t: "createGroup", groups: [newGroup], dirty: RED.nodes.dirty() });
1249
+ groupsApplied++;
1250
+ } catch (e) {
1251
+ addMessage("error", "Failed to create group: " + (e.message || e));
1252
+ }
1253
+ }
1254
+ });
1255
+
1256
+ if (groupsApplied) {
1257
+ RED.nodes.dirty(true);
1258
+ RED.view.redraw(true);
1259
+ var groupNote = "Touchdown — created/updated " + groupsApplied +
1260
+ " group(s). Ctrl+Z to undo.";
1261
+ addMessage("assistant", groupNote);
1262
+ pushHistory("assistant", groupNote);
1263
+ }
1264
+ return groupsApplied;
1265
+ }
1266
+
1267
+ var CORE_NODE_TYPES = {
1268
+ "inject": true, "debug": true, "complete": true, "catch": true, "status": true,
1269
+ "link in": true, "link out": true, "link call": true, "comment": true,
1270
+ "junction": true, "unknown": true, "group": true,
1271
+ "function": true, "switch": true, "change": true, "range": true, "template": true,
1272
+ "mqtt in": true, "mqtt out": true, "mqtt-broker": true,
1273
+ "http in": true, "http response": true, "http request": true,
1274
+ "websocket in": true, "websocket out": true,
1275
+ "websocket-listener": true, "websocket-client": true,
1276
+ "tcp in": true, "tcp out": true, "tcp request": true,
1277
+ "udp in": true, "udp out": true, "tls-config": true, "httpproxy": true,
1278
+ "split": true, "join": true, "sort": true, "batch": true,
1279
+ "csv": true, "html": true, "json": true, "xml": true, "yaml": true,
1280
+ "file": true, "file in": true, "watch": true, "tail": true,
1281
+ "exec": true, "delay": true, "trigger": true
1282
+ };
1283
+
1284
+ // Checks the two things only the live editor can tell us before import:
1285
+ // (1) wire integrity — every wire target id must exist in the generated
1286
+ // set (the model can hallucinate ids); (2) node-type classification —
1287
+ // core / non-core-but-installed / not-installed, via RED.nodes.getType.
1288
+ // Returns per-node summary entries plus separated warning/problem lists
1289
+ // so the review UI can render them and decide whether import is offered.
1290
+ function validateGeneratedFlow(flow) {
1291
+ var nodes = Array.isArray(flow) ? flow : [];
1292
+ var ids = {};
1293
+ nodes.forEach(function (n) {
1294
+ if (n && n.id) { ids[n.id] = true; }
1295
+ });
1296
+
1297
+ var summary = [];
1298
+ var typeWarnings = [];
1299
+ var brokenWires = [];
1300
+ var realWireCount = 0;
1301
+
1302
+ nodes.forEach(function (n) {
1303
+ if (!n || !n.id || !n.type) { return; }
1304
+
1305
+ var isCore = !!CORE_NODE_TYPES[n.type];
1306
+ var isInstalled = !!RED.nodes.getType(n.type);
1307
+ var status = isCore ? "core" : (isInstalled ? "non-core-installed" : "not-installed");
1308
+
1309
+ var entry = { id: n.id, type: n.type, name: n.name || "", status: status };
1310
+ summary.push(entry);
1311
+ if (status !== "core") { typeWarnings.push(entry); }
1312
+
1313
+ (Array.isArray(n.wires) ? n.wires : []).forEach(function (port) {
1314
+ (Array.isArray(port) ? port : []).forEach(function (targetId) {
1315
+ if (!ids[targetId]) {
1316
+ brokenWires.push({ from: n.id, type: n.type, target: targetId });
1317
+ } else {
1318
+ realWireCount++;
1319
+ }
1320
+ });
1321
+ });
1322
+ });
1323
+
1324
+ // A multi-node flow with zero connections anywhere is almost always
1325
+ // a generation slip (the model omitted "wires" on every node) rather
1326
+ // than something the user actually wanted — flag it, but don't
1327
+ // block import; a handful of genuinely independent nodes is rare
1328
+ // but not impossible.
1329
+ var nonCommentCount = nodes.filter(function (n) { return n && n.type !== "comment"; }).length;
1330
+ var noConnections = nonCommentCount > 1 && realWireCount === 0;
1331
+
1332
+ return { summary: summary, typeWarnings: typeWarnings, brokenWires: brokenWires, noConnections: noConnections };
1333
+ }
1334
+
1335
+ // The generation prompt deliberately omits x/y ("the editor assigns those
1336
+ // on import"), but RED.view.importNodes does NOT auto-arrange nodes that
1337
+ // lack coordinates — it just places them on top of each other. So we lay
1338
+ // them out ourselves:
1339
+ //
1340
+ // - Non-comment wired nodes are split into connected components (a
1341
+ // component = nodes reachable from each other via wires, in either
1342
+ // direction). Each component gets its own horizontal "band", stacked
1343
+ // top to bottom, ordered by where its first node appears in `flow`.
1344
+ // Within a band, columns are topological depth (longest path from an
1345
+ // in-degree-0 node) and rows stack top-to-bottom within a column —
1346
+ // same approach as before, just scoped to one component at a time so
1347
+ // independent chains (e.g. a scheduler pipeline vs. an HTTP endpoint)
1348
+ // don't get interleaved into the same rows.
1349
+ // - Comment nodes have no wires to anchor them, so each is matched to
1350
+ // the nearest non-comment node adjacent to it in the `flow` array
1351
+ // (forward, then backward) — models place a comment next to the
1352
+ // section it describes — and placed in a header row above that node's
1353
+ // column, in that node's component's band.
1354
+ //
1355
+ // Config nodes (no "wires" array — e.g. mqtt-broker) are left untouched;
1356
+ // they don't live on the canvas and have no x/y/z of their own.
1357
+ function layoutGeneratedFlow(flow) {
1358
+ var COL_WIDTH = 200;
1359
+ var ROW_HEIGHT = 90;
1360
+ var BASE_X = 160;
1361
+ var BASE_Y = 120;
1362
+ var BAND_GAP_ROWS = 1;
1363
+
1364
+ var nodes = Array.isArray(flow) ? flow : [];
1365
+ var wiredNodes = nodes.filter(function (n) { return n && Array.isArray(n.wires) && n.type !== "comment"; });
1366
+ var commentNodes = nodes.filter(function (n) { return n && n.type === "comment"; });
1367
+
1368
+ if (!wiredNodes.length) {
1369
+ // Nothing to anchor comments to — just stack everything.
1370
+ nodes.forEach(function (n, i) {
1371
+ if (!n) { return; }
1372
+ n.x = BASE_X;
1373
+ n.y = BASE_Y + i * ROW_HEIGHT;
1374
+ });
1375
+ return nodes;
1376
+ }
1377
+
1378
+ var byId = {};
1379
+ wiredNodes.forEach(function (n) { byId[n.id] = n; });
1380
+
1381
+ // ---- Connected components (undirected: wires in either direction) ----
1382
+ var adjacency = {};
1383
+ wiredNodes.forEach(function (n) { adjacency[n.id] = []; });
1384
+ wiredNodes.forEach(function (n) {
1385
+ n.wires.forEach(function (port) {
1386
+ (Array.isArray(port) ? port : []).forEach(function (targetId) {
1387
+ if (!byId[targetId]) { return; }
1388
+ adjacency[n.id].push(targetId);
1389
+ adjacency[targetId].push(n.id);
1390
+ });
1391
+ });
1392
+ });
1393
+
1394
+ var orderIndex = {};
1395
+ nodes.forEach(function (n, i) { if (n && n.id) { orderIndex[n.id] = i; } });
1396
+
1397
+ var visited = {};
1398
+ var components = [];
1399
+ wiredNodes.forEach(function (start) {
1400
+ if (visited[start.id]) { return; }
1401
+ var stack = [start.id];
1402
+ var members = [];
1403
+ visited[start.id] = true;
1404
+ while (stack.length) {
1405
+ var id = stack.pop();
1406
+ members.push(id);
1407
+ adjacency[id].forEach(function (neighborId) {
1408
+ if (!visited[neighborId]) { visited[neighborId] = true; stack.push(neighborId); }
1409
+ });
1410
+ }
1411
+ components.push(members);
1412
+ });
1413
+
1414
+ // Order bands by where each component first appears in `flow`, so
1415
+ // the layout roughly follows generation order top to bottom.
1416
+ components.sort(function (a, b) {
1417
+ var minA = Math.min.apply(null, a.map(function (id) { return orderIndex[id]; }));
1418
+ var minB = Math.min.apply(null, b.map(function (id) { return orderIndex[id]; }));
1419
+ return minA - minB;
1420
+ });
1421
+
1422
+ // Column = longest path from an in-degree-0 node, scoped to this
1423
+ // component only (so independent chains don't influence each other).
1424
+ function assignColumns(members) {
1425
+ var memberSet = {};
1426
+ members.forEach(function (id) { memberSet[id] = true; });
1427
+
1428
+ var incoming = {};
1429
+ members.forEach(function (id) { incoming[id] = 0; });
1430
+ members.forEach(function (id) {
1431
+ byId[id].wires.forEach(function (port) {
1432
+ (Array.isArray(port) ? port : []).forEach(function (targetId) {
1433
+ if (memberSet[targetId]) { incoming[targetId] += 1; }
1434
+ });
1435
+ });
1436
+ });
1437
+
1438
+ var column = {};
1439
+ members.forEach(function (id) { column[id] = incoming[id] === 0 ? 0 : -1; });
1440
+ var changed = true;
1441
+ var guard = 0;
1442
+ while (changed && guard <= members.length) {
1443
+ changed = false;
1444
+ guard += 1;
1445
+ members.forEach(function (id) {
1446
+ var fromCol = column[id] < 0 ? 0 : column[id];
1447
+ byId[id].wires.forEach(function (port) {
1448
+ (Array.isArray(port) ? port : []).forEach(function (targetId) {
1449
+ if (!memberSet[targetId]) { return; }
1450
+ if (column[targetId] < fromCol + 1) {
1451
+ column[targetId] = fromCol + 1;
1452
+ changed = true;
1453
+ }
1454
+ });
1455
+ });
1456
+ });
1457
+ }
1458
+ members.forEach(function (id) { if (column[id] < 0) { column[id] = 0; } });
1459
+ return column;
1460
+ }
1461
+
1462
+ // ---- Anchor each comment to the nearest non-comment node adjacent
1463
+ // to it in `flow` (forward, then backward). ----
1464
+ function nearestWiredNeighbor(commentIndex) {
1465
+ var i, candidate;
1466
+ for (i = commentIndex + 1; i < nodes.length; i++) {
1467
+ candidate = nodes[i];
1468
+ if (candidate && byId[candidate.id]) { return candidate.id; }
1469
+ }
1470
+ for (i = commentIndex - 1; i >= 0; i--) {
1471
+ candidate = nodes[i];
1472
+ if (candidate && byId[candidate.id]) { return candidate.id; }
1473
+ }
1474
+ return null;
1475
+ }
1476
+
1477
+ var commentsByAnchor = {}; // wired-node id -> [comment nodes]
1478
+ var unanchoredComments = [];
1479
+ commentNodes.forEach(function (c) {
1480
+ var anchorId = nearestWiredNeighbor(nodes.indexOf(c));
1481
+ if (anchorId) {
1482
+ (commentsByAnchor[anchorId] = commentsByAnchor[anchorId] || []).push(c);
1483
+ } else {
1484
+ unanchoredComments.push(c);
1485
+ }
1486
+ });
1487
+
1488
+ // ---- Lay out each component in its own band, leaving a header row
1489
+ // for any comments anchored within it. ----
1490
+ var nextBandY = BASE_Y;
1491
+ components.forEach(function (members) {
1492
+ var column = assignColumns(members);
1493
+
1494
+ var headerCols = {};
1495
+ var hasHeader = false;
1496
+ members.forEach(function (id) {
1497
+ (commentsByAnchor[id] || []).forEach(function (c) {
1498
+ headerCols[column[id]] = headerCols[column[id]] || [];
1499
+ headerCols[column[id]].push(c);
1500
+ hasHeader = true;
1501
+ });
1502
+ });
1503
+ var bodyOffset = hasHeader ? 1 : 0;
1504
+
1505
+ var rowsUsed = {};
1506
+ var maxRows = 0;
1507
+ members.forEach(function (id) {
1508
+ var col = column[id];
1509
+ var row = rowsUsed[col] || 0;
1510
+ rowsUsed[col] = row + 1;
1511
+ maxRows = Math.max(maxRows, row + 1);
1512
+ var n = byId[id];
1513
+ n.x = BASE_X + col * COL_WIDTH;
1514
+ n.y = nextBandY + (bodyOffset + row) * ROW_HEIGHT;
1515
+ });
1516
+
1517
+ Object.keys(headerCols).forEach(function (col) {
1518
+ headerCols[col].forEach(function (c, i) {
1519
+ c.x = BASE_X + Number(col) * COL_WIDTH;
1520
+ c.y = nextBandY + i * Math.round(ROW_HEIGHT / 2);
1521
+ });
1522
+ });
1523
+
1524
+ nextBandY += (bodyOffset + maxRows + BAND_GAP_ROWS) * ROW_HEIGHT;
1525
+ });
1526
+
1527
+ // Comments that couldn't be anchored (only possible if `flow` is
1528
+ // entirely comments, which the early-return above already handles)
1529
+ // are stacked below everything else as a fallback.
1530
+ unanchoredComments.forEach(function (c, i) {
1531
+ c.x = BASE_X;
1532
+ c.y = nextBandY + i * ROW_HEIGHT;
1533
+ });
1534
+
1535
+ return nodes;
1536
+ }
1537
+
1538
+ function importGeneratedFlow(nodes, onImported) {
1539
+ try {
1540
+ // importNodes returns { nodeMap }, mapping each input node's own
1541
+ // id to the real live node object Node-RED just created (ids are
1542
+ // regenerated since generateIds:true) — onImported (the /build
1543
+ // loop) needs this to know what it actually has on the canvas.
1544
+ var importResult = RED.view.importNodes(nodes, { generateIds: true });
1545
+ // Ground follow-up turns in what was just
1546
+ // imported (placement itself happens on the next canvas click).
1547
+ var n = Array.isArray(nodes) ? nodes.length : 0;
1548
+ var importedNote = "Landed — imported " + n + " node(s). Click the canvas to place them.";
1549
+ addMessage("assistant", importedNote);
1550
+ pushHistory("assistant", importedNote);
1551
+ updateSelectionStatus();
1552
+ if (typeof onImported === "function") { onImported(importResult); }
1553
+ } catch (e) {
1554
+ addMessage("error", "Import failed: " + (e && e.message ? e.message : String(e)));
1555
+ }
1556
+ }
1557
+
1558
+ function addGeneratedReview(flow, onImported, buildGoal) {
1559
+ var $box = el("#fp-messages");
1560
+ if (!$box.length) { return; }
1561
+
1562
+ var nodes = Array.isArray(flow) ? flow : [];
1563
+ var v = validateGeneratedFlow(nodes);
1564
+
1565
+ var $msg = $("<div>").addClass("fp-message fp-review");
1566
+ // The review record (created in the action row when nodes are valid)
1567
+ // carries data-fp-record-id; the pop-out uses it to relay
1568
+ // applyByRecordId to the parent — see bindReviewApplyButtons/
1569
+ // applyByRecordId in initMainWindow (Phase 10 0B).
1570
+ $("<div>").addClass("fp-label").text("GENERATED FLOW — REVIEW").appendTo($msg);
1571
+
1572
+ var $tabSummary = $("<button>").addClass("fp-tab fp-tab-active").attr("type", "button").text("Summary");
1573
+ var $tabJson = $("<button>").addClass("fp-tab").attr("type", "button").text("JSON");
1574
+ $("<div>").addClass("fp-tabs").append($tabSummary, $tabJson).appendTo($msg);
1575
+
1576
+ var $summaryPanel = $("<div>").addClass("fp-tab-panel");
1577
+ var $jsonPanel = $("<div>").addClass("fp-tab-panel fp-hidden");
1578
+ $msg.append($summaryPanel, $jsonPanel);
1579
+
1580
+ // ---- Summary tab ----
1581
+ $("<div>").addClass("fp-review-count")
1582
+ .text("Generated " + nodes.length + " node" + (nodes.length === 1 ? "" : "s") + ":")
1583
+ .appendTo($summaryPanel);
1584
+
1585
+ var $list = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
1586
+ v.summary.forEach(function (item) {
1587
+ var $li = $("<li>").text(item.type + (item.name ? " — \"" + item.name + "\"" : ""));
1588
+ if (item.status === "not-installed") {
1589
+ $("<span>").addClass("fp-type-flag").text(" ⚠ not installed").appendTo($li);
1590
+ } else if (item.status === "non-core-installed") {
1591
+ $("<span>").addClass("fp-type-flag").text(" ⚠ non-core").appendTo($li);
1592
+ }
1593
+ $list.append($li);
1594
+ });
1595
+
1596
+ if (v.noConnections) {
1597
+ var $wireWarn = $("<div>").addClass("fp-warning fp-review-warning").appendTo($summaryPanel);
1598
+ $("<strong>").text("⚠ These nodes aren't wired to each other.").appendTo($wireWarn);
1599
+ $("<div>").text("None of the " + nodes.length + " generated nodes connect to one another — " +
1600
+ "they'll land on the canvas disconnected. You can wire them manually, or ask FlowPilot " +
1601
+ "to regenerate.").appendTo($wireWarn);
1602
+ }
1603
+
1604
+ if (v.typeWarnings.length) {
1605
+ var $warn = $("<div>").addClass("fp-warning fp-review-warning").appendTo($summaryPanel);
1606
+ $("<strong>").text("Type warnings — review before adding:").appendTo($warn);
1607
+ var $wlist = $("<ul>").appendTo($warn);
1608
+ v.typeWarnings.forEach(function (w) {
1609
+ var reason = w.status === "not-installed"
1610
+ ? "not installed — will appear as a broken placeholder until the module is added"
1611
+ : "installed, but not a core Node-RED type — may be less stable across versions";
1612
+ $("<li>").text(w.type + ": " + reason).appendTo($wlist);
1613
+ });
1614
+ $("<div>").text("You can add it anyway, or ask FlowPilot to regenerate using only core nodes.").appendTo($warn);
1615
+ }
1616
+
1617
+ // ---- JSON tab ----
1618
+ var jsonText = JSON.stringify(nodes, null, 2);
1619
+ var $copyBtn = $("<button>")
1620
+ .addClass("red-ui-button red-ui-button-small")
1621
+ .attr("type", "button")
1622
+ .text("Copy")
1623
+ .on("click", function () { copyToClipboard($copyBtn, jsonText); });
1624
+ $("<div>").addClass("fp-json-toolbar").append($copyBtn).appendTo($jsonPanel);
1625
+ $("<pre>").addClass("fp-json").text(jsonText).appendTo($jsonPanel);
1626
+
1627
+ function activateTab(showSummary) {
1628
+ $tabSummary.toggleClass("fp-tab-active", showSummary);
1629
+ $tabJson.toggleClass("fp-tab-active", !showSummary);
1630
+ $summaryPanel.toggleClass("fp-hidden", !showSummary);
1631
+ $jsonPanel.toggleClass("fp-hidden", showSummary);
1632
+ }
1633
+ $tabSummary.on("click", function () { activateTab(true); });
1634
+ $tabJson.on("click", function () { activateTab(false); });
1635
+
1636
+ // ---- Action row ----
1637
+ var $actions = $("<div>").addClass("fp-review-actions").appendTo($msg);
1638
+ if (v.brokenWires.length) {
1639
+ $("<div>").addClass("fp-warning").text(
1640
+ "This flow has " + v.brokenWires.length + " wire(s) pointing to node ids " +
1641
+ "that don't exist in the generated set, so it can't be safely imported. " +
1642
+ "Try asking FlowPilot to regenerate it."
1643
+ ).appendTo($actions);
1644
+ } else if (!nodes.length) {
1645
+ $("<div>").addClass("fp-warning").text("No nodes were generated — nothing to add.").appendTo($actions);
1646
+ } else {
1647
+ var _genRecord = addRecord("review", {
1648
+ subkind: onImported ? "build-generate" : "generate",
1649
+ flow: nodes,
1650
+ buildGoal: buildGoal || null,
1651
+ onImported: onImported || null,
1652
+ state: "pending"
1653
+ });
1654
+ $msg.attr("data-fp-record-id", _genRecord.id);
1655
+ var $addBtn = $("<button>")
1656
+ .addClass("red-ui-button red-ui-button-primary")
1657
+ .attr("type", "button")
1658
+ .text("Add to workspace")
1659
+ .on("click", function () {
1660
+ $addBtn.prop("disabled", true).text("Click the canvas to place…");
1661
+ if (_genRecord) { _genRecord.state = "applied"; }
1662
+ importGeneratedFlow(nodes, onImported);
1663
+ });
1664
+ $actions.append($addBtn);
1665
+ $("<span>").addClass("fp-review-hint")
1666
+ .text("Opens Node-RED's normal place-at-cursor import — click the canvas to drop the nodes.")
1667
+ .appendTo($actions);
1668
+ }
1669
+
1670
+ $box.append($msg);
1671
+ scrollMessagesToBottom();
1672
+ return $msg;
1673
+ }
1674
+
1675
+ // ---- Refresh-from-history re-render helpers (Phase 10, 0A) -------------
1676
+ // Called by rerenderRecord() in main.js during refreshView(). Reconstruct
1677
+ // review panels from stored record data — the sharedApplyData already
1678
+ // holds everything needed without re-querying the live editor.
1679
+
1680
+ function rerenderReviewRecord(rec) {
1681
+ if (!rec) { return; }
1682
+ switch (rec.subkind) {
1683
+ case "modify":
1684
+ case "build-fix":
1685
+ rerenderModifyReview(rec);
1686
+ break;
1687
+ case "generate":
1688
+ case "build-generate":
1689
+ rerenderGeneratedReview(rec);
1690
+ break;
1691
+ }
1692
+ }
1693
+
1694
+ function rerenderModifyReview(rec) {
1695
+ var $box = el("#fp-messages");
1696
+ if (!$box.length) { return; }
1697
+
1698
+ var d = rec.sharedApplyData || {};
1699
+ var nodeDiffs = Array.isArray(d.nodeDiffs) ? d.nodeDiffs : [];
1700
+ var removeNodes = Array.isArray(d.removeNodes) ? d.removeNodes : [];
1701
+ var newNodes = Array.isArray(d.newNodes) ? d.newNodes : [];
1702
+ var newWires = Array.isArray(d.newWires) ? d.newWires : [];
1703
+ var newGroups = Array.isArray(d.newGroups) ? d.newGroups : [];
1704
+ var existingIds = Array.isArray(d.existingNodeIds) ? d.existingNodeIds : [];
1705
+ var hasMutations = !!d.hasMutations;
1706
+ var capReached = !!d.capReached;
1707
+ var isBuildFix = rec.subkind === "build-fix";
1708
+
1709
+ var hasPropChanges = nodeDiffs.some(function (nd) { return nd.propertyChanges && nd.propertyChanges.length > 0; });
1710
+ var hasWireChanges = nodeDiffs.some(function (nd) { return nd.wiresDiff && (nd.wiresDiff.toRemove.length > 0 || nd.wiresDiff.toAdd.length > 0); });
1711
+ var hasNewNodes = newNodes.length > 0;
1712
+ var hasRemoveNodes = removeNodes.length > 0;
1713
+ var hasNewGroups = newGroups.length > 0;
1714
+ var nodesWithChanges = nodeDiffs.filter(function (nd) { return nd.propertyChanges && nd.propertyChanges.length > 0; }).length;
1715
+ var missingLive = nodeDiffs.some(function (nd) { return nd.propertyChanges && nd.propertyChanges.length > 0 && !findLiveNode(nd.modNode && nd.modNode.id); });
1716
+
1717
+ // Add the record first so the Apply button's closure can reference it.
1718
+ var _newRec = addRecord("review", {
1719
+ subkind: rec.subkind,
1720
+ sharedApplyData: rec.sharedApplyData,
1721
+ state: rec.state
1722
+ });
1723
+
1724
+ var $msg = $("<div>").addClass("fp-message fp-review");
1725
+ $("<div>").addClass("fp-label")
1726
+ .text(isBuildFix ? "BUILD LOOP — FIX REVIEW" : "MODIFY FLOW — REVIEW CHANGES")
1727
+ .appendTo($msg);
1728
+
1729
+ $msg.attr("data-fp-record-id", _newRec.id);
1730
+
1731
+ var $tabSummary = $("<button>").addClass("fp-tab fp-tab-active").attr("type", "button").text("Summary");
1732
+ var $tabJson = $("<button>").addClass("fp-tab").attr("type", "button").text("JSON");
1733
+ $("<div>").addClass("fp-tabs").append($tabSummary, $tabJson).appendTo($msg);
1734
+
1735
+ var $summaryPanel = $("<div>").addClass("fp-tab-panel");
1736
+ var $jsonPanel = $("<div>").addClass("fp-tab-panel fp-hidden");
1737
+ $msg.append($summaryPanel, $jsonPanel);
1738
+
1739
+ // Prop diffs
1740
+ if (hasPropChanges) {
1741
+ $("<div>").addClass("fp-review-count")
1742
+ .text(nodesWithChanges + " of " + nodeDiffs.length + " node(s) will change:")
1743
+ .appendTo($summaryPanel);
1744
+ nodeDiffs.forEach(function (nd) {
1745
+ if (!nd.propertyChanges || !nd.propertyChanges.length) { return; }
1746
+ var $section = $("<div>").addClass("fp-diff-node").appendTo($summaryPanel);
1747
+ var title = nd.type + (nd.name ? " — \"" + nd.name + "\"" : "");
1748
+ $("<div>").addClass("fp-diff-node-title").text(title).appendTo($section);
1749
+ if (!findLiveNode(nd.modNode && nd.modNode.id)) {
1750
+ $("<div>").addClass("fp-diff-warn")
1751
+ .text("⚠ Node not found in editor (id: " + (nd.modNode && nd.modNode.id) + ")")
1752
+ .appendTo($section);
1753
+ return;
1754
+ }
1755
+ nd.propertyChanges.forEach(function (c) {
1756
+ var $row = $("<div>").addClass("fp-diff-row").appendTo($section);
1757
+ $("<span>").addClass("fp-diff-key").text(c.key).appendTo($row);
1758
+ $("<span>").addClass("fp-diff-old").text(formatDiffVal(c.oldVal)).appendTo($row);
1759
+ $("<span>").addClass("fp-diff-arrow").text("→").appendTo($row);
1760
+ $("<span>").addClass("fp-diff-new").text(formatDiffVal(c.newVal)).appendTo($row);
1761
+ });
1762
+ });
1763
+ }
1764
+ // Wire diffs — same rendering as original addModifyReview
1765
+ if (hasWireChanges) {
1766
+ nodeDiffs.forEach(function (nd) {
1767
+ var wd = nd.wiresDiff;
1768
+ if (!wd || (!wd.toRemove.length && !wd.toAdd.length)) { return; }
1769
+ var $section = $("<div>").addClass("fp-diff-node").appendTo($summaryPanel);
1770
+ $("<div>").addClass("fp-diff-node-title")
1771
+ .text(nd.type + (nd.name ? " — \"" + nd.name + "\"" : ""))
1772
+ .appendTo($section);
1773
+ wd.toRemove.forEach(function (entry) {
1774
+ var tgt = RED.nodes.node ? RED.nodes.node(entry.targetId) : null;
1775
+ var tgtLabel = tgt ? (tgt.name || tgt.type || entry.targetId) : entry.targetId;
1776
+ var $row = $("<div>").addClass("fp-diff-row").appendTo($section);
1777
+ $("<span>").addClass("fp-diff-key").text("port " + entry.sourcePort).appendTo($row);
1778
+ $("<span>").addClass("fp-diff-old").text("→ " + tgtLabel).appendTo($row);
1779
+ $("<span>").addClass("fp-diff-arrow").text("✕").appendTo($row);
1780
+ $("<span>").addClass("fp-diff-new").text("").appendTo($row);
1781
+ });
1782
+ wd.toAdd.forEach(function (entry) {
1783
+ var tgt = RED.nodes.node ? RED.nodes.node(entry.targetId) : null;
1784
+ var tgtLabel = tgt ? (tgt.name || tgt.type || entry.targetId) : entry.targetId;
1785
+ var $row = $("<div>").addClass("fp-diff-row").appendTo($section);
1786
+ $("<span>").addClass("fp-diff-key").text("port " + entry.sourcePort).appendTo($row);
1787
+ $("<span>").addClass("fp-diff-old").text("").appendTo($row);
1788
+ $("<span>").addClass("fp-diff-arrow").text("→").appendTo($row);
1789
+ $("<span>").addClass("fp-diff-new").text(tgtLabel).appendTo($row);
1790
+ });
1791
+ });
1792
+ }
1793
+ // Removals
1794
+ if (hasRemoveNodes) {
1795
+ $("<div>").addClass("fp-review-count fp-diff-warn")
1796
+ .text(removeNodes.length + " node(s) to remove:")
1797
+ .appendTo($summaryPanel);
1798
+ var $rmList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
1799
+ removeNodes.forEach(function (id) {
1800
+ var lv = RED.nodes.node ? RED.nodes.node(id) : null;
1801
+ var label = lv ? ((lv.name || lv.type || id) + " (" + id + ")") : id + " (not found)";
1802
+ $("<li>").addClass("fp-diff-warn").text("✕ " + label).appendTo($rmList);
1803
+ });
1804
+ }
1805
+ // New nodes
1806
+ if (hasNewNodes) {
1807
+ $("<div>").addClass("fp-review-count")
1808
+ .text(newNodes.length + " node(s) to insert:")
1809
+ .appendTo($summaryPanel);
1810
+ var $newList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
1811
+ newNodes.forEach(function (n) {
1812
+ $("<li>").text((n.type || "unknown") + (n.name ? " — \"" + n.name + "\"" : "")).appendTo($newList);
1813
+ });
1814
+ if (newWires.length > 0) {
1815
+ $("<div>").addClass("fp-review-count").css("margin-top", "8px")
1816
+ .text(newWires.length + " wire connection(s):")
1817
+ .appendTo($summaryPanel);
1818
+ var $wireList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
1819
+ newWires.forEach(function (wire) {
1820
+ var fromLabel = resolveWireRef(wire.from, newNodes);
1821
+ var toLabel = resolveWireRef(wire.to, newNodes);
1822
+ var portNote = (wire.fromPort && wire.fromPort > 0) ? " [port " + wire.fromPort + "]" : "";
1823
+ $("<li>").text(fromLabel + portNote + " → " + toLabel).appendTo($wireList);
1824
+ });
1825
+ }
1826
+ }
1827
+ // Groups
1828
+ if (hasNewGroups) {
1829
+ $("<div>").addClass("fp-review-count")
1830
+ .text(newGroups.length + " group(s) to create/update:")
1831
+ .appendTo($summaryPanel);
1832
+ var $grpList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
1833
+ newGroups.forEach(function (g) {
1834
+ $("<li>").text((g.name ? "\"" + g.name + "\"" : "(unnamed)") +
1835
+ " — " + (Array.isArray(g.nodes) ? g.nodes.length : 0) + " node(s)").appendTo($grpList);
1836
+ });
1837
+ }
1838
+
1839
+ // JSON tab
1840
+ var jsonText = JSON.stringify(d, null, 2);
1841
+ var $copyBtn = $("<button>").addClass("red-ui-button red-ui-button-small").attr("type", "button").text("Copy")
1842
+ .on("click", function () { copyToClipboard($copyBtn, jsonText); });
1843
+ $("<div>").addClass("fp-json-toolbar").append($copyBtn).appendTo($jsonPanel);
1844
+ $("<pre>").addClass("fp-json").text(jsonText).appendTo($jsonPanel);
1845
+
1846
+ $tabSummary.on("click", function () {
1847
+ $tabSummary.addClass("fp-tab-active"); $tabJson.removeClass("fp-tab-active");
1848
+ $summaryPanel.removeClass("fp-hidden"); $jsonPanel.addClass("fp-hidden");
1849
+ });
1850
+ $tabJson.on("click", function () {
1851
+ $tabJson.addClass("fp-tab-active"); $tabSummary.removeClass("fp-tab-active");
1852
+ $jsonPanel.removeClass("fp-hidden"); $summaryPanel.addClass("fp-hidden");
1853
+ });
1854
+
1855
+ // Action row
1856
+ var $actions = $("<div>").addClass("fp-review-actions").appendTo($msg);
1857
+ if (rec.state === "applied") {
1858
+ $("<button>").addClass("red-ui-button red-ui-button-primary")
1859
+ .attr("type", "button").prop("disabled", true).text("Applied ✓")
1860
+ .appendTo($actions);
1861
+ } else if (missingLive && (hasPropChanges || hasWireChanges)) {
1862
+ $("<div>").addClass("fp-warning")
1863
+ .text("One or more nodes could not be found in the editor. Cannot apply safely.")
1864
+ .appendTo($actions);
1865
+ } else {
1866
+ var btnLabel = (hasMutations && hasNewNodes) ? "Apply & Insert"
1867
+ : hasMutations ? "Apply Changes"
1868
+ : hasNewNodes ? "Insert Nodes"
1869
+ : "Apply Changes";
1870
+ var $applyBtn = $("<button>").addClass("red-ui-button red-ui-button-primary")
1871
+ .attr("type", "button").text(btnLabel)
1872
+ .on("click", function () {
1873
+ $applyBtn.prop("disabled", true).text("Applying…");
1874
+ var idMap = {};
1875
+ if (hasNewNodes) { idMap = applyInsertions(newNodes, newWires, existingIds) || {}; }
1876
+ if (isBuildFix) {
1877
+ applyBuildLoopFix(nodeDiffs, removeNodes, idMap, capReached);
1878
+ } else {
1879
+ if (hasMutations) { applyModifications(nodeDiffs, removeNodes, null, idMap); }
1880
+ if (hasNewGroups) { applyGroupChanges(newGroups, idMap); }
1881
+ }
1882
+ if (_newRec) { _newRec.state = "applied"; }
1883
+ $applyBtn.text("Done ✓");
1884
+ });
1885
+ $actions.append($applyBtn);
1886
+ var hintParts = [];
1887
+ if (hasPropChanges || hasWireChanges) { hintParts.push("changes mutate live nodes"); }
1888
+ if (hasRemoveNodes) { hintParts.push("removals delete nodes"); }
1889
+ if (hasNewGroups) { hintParts.push("groups are created/updated"); }
1890
+ if (hasNewNodes) { hintParts.push("insertions add new nodes"); }
1891
+ if (hintParts.length) {
1892
+ $("<span>").addClass("fp-review-hint")
1893
+ .text("Review above — " + hintParts.join(", ") + ". Ctrl+Z to undo.")
1894
+ .appendTo($actions);
1895
+ }
1896
+ }
1897
+
1898
+ $box.append($msg);
1899
+ scrollMessagesToBottom();
1900
+ }
1901
+
1902
+ function rerenderGeneratedReview(rec) {
1903
+ var nodes = Array.isArray(rec.flow) ? rec.flow : [];
1904
+ if (rec.state === "applied") {
1905
+ var $box = el("#fp-messages");
1906
+ if (!$box.length) { return; }
1907
+ var $msg = $("<div>").addClass("fp-message fp-review");
1908
+ $("<div>").addClass("fp-label").text("GENERATED FLOW — APPLIED ✓").appendTo($msg);
1909
+ $("<div>").addClass("fp-review-actions")
1910
+ .append($("<button>").addClass("red-ui-button red-ui-button-primary")
1911
+ .attr("type", "button").prop("disabled", true).text("Applied ✓"))
1912
+ .appendTo($msg);
1913
+ $box.append($msg);
1914
+ addRecord("review", {
1915
+ subkind: rec.subkind, flow: rec.flow,
1916
+ buildGoal: rec.buildGoal, onImported: rec.onImported, state: "applied"
1917
+ });
1918
+ scrollMessagesToBottom();
1919
+ } else {
1920
+ // Re-call addGeneratedReview — it revalidates and creates a fully
1921
+ // interactive panel, including its own addRecord call. onImported is
1922
+ // a live function ref (valid within the same session).
1923
+ addGeneratedReview(nodes, rec.onImported || null, rec.buildGoal || null);
1924
+ }
1925
+ }
1926
+