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

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/lib/core/modes.js CHANGED
@@ -812,15 +812,20 @@
812
812
  var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
813
813
  addMessage("assistant", data.explanation || "(no explanation returned)");
814
814
  pushHistory("assistant", data.explanation || "(no explanation returned)");
815
- addGeneratedReview(flow);
816
- // After any Generate result, offer the deploy-verify loop as a one-click
817
- // option. Only shown when no loop is already running and the original
818
- // prompt is available (it always is here goalPrompt comes from the
819
- // compose box value captured at send time via wrappedOnResult).
820
- if (goalPrompt && !activeBuildLoop) {
821
- renderActionChip({ mode: "build", prompt: goalPrompt, customTitle: "Run deploy-verify loop on this →" });
822
- }
823
- renderActionChip(data.suggestedAction);
815
+ // B1: bake the deploy-verify option into the review panel as the
816
+ // primary chip rather than a separate chip below it. Only for
817
+ // executable flows (not documentation-only comment nodes) and when no
818
+ // loop is already active. The secondary "Just add to canvas" button is
819
+ // always shown alongside it as an escape hatch.
820
+ var _hasDeployable = flow.some(function (n) { return n && n.type !== "comment" && n.type !== "group"; });
821
+ var _buildOnImported = (goalPrompt && !activeBuildLoop && _hasDeployable)
822
+ ? function (importResult) { startBuildLoop(goalPrompt, flow, importResult); }
823
+ : null;
824
+ addGeneratedReview(flow, _buildOnImported, _buildOnImported ? goalPrompt : null);
825
+ // Suppress a server-suggested build chip when deploy-verify is already
826
+ // the primary action inside the review panel — it would be a duplicate.
827
+ renderActionChip(_buildOnImported && data.suggestedAction && data.suggestedAction.mode === "build"
828
+ ? null : data.suggestedAction);
824
829
  setBusy(false);
825
830
  updateSelectionStatus();
826
831
  }
@@ -831,10 +836,41 @@
831
836
  hidePending();
832
837
  if (renderQuestionOrProse(data)) { return; }
833
838
 
839
+ // W4: parse and surface the Plan: block if present.
840
+ var planItems = parseTodoPlan(data.explanation || "");
841
+ var todoRec = null;
842
+ if (planItems.length) {
843
+ // Verification only produces one aggregate pass/fail result for
844
+ // the whole Modify response — mark every item active up front so
845
+ // they all resolve together instead of leaving items 2+ stuck at
846
+ // "pending" forever (only item 1 would ever flip otherwise).
847
+ planItems.forEach(function (item) { item.status = "active"; });
848
+ todoRec = addRecord("todo", { action: "modify", items: planItems });
849
+ rerenderTodoRecord(todoRec);
850
+ }
851
+
834
852
  addMessage("assistant", data.explanation || "(no explanation returned)");
835
853
  pushHistory("assistant", data.explanation || "(no explanation returned)");
836
854
  if (data.skippedNote) { addMessage("assistant", "⚠ " + data.skippedNote); }
837
- addModifyReview(data.flow, data.newNodes || [], data.newWires || [], data.removeNodes || [], applyModifications, null, data.newGroups || []);
855
+
856
+ // W4 Phase 2: wrap apply to run a real graph read-back via
857
+ // verifySteps (Track A, server) instead of unconditionally marking
858
+ // the todo done. Falls back to the Phase 1 behavior (check off with
859
+ // no verification) when the server sent no verifySteps.
860
+ var verifySteps = Array.isArray(data.verifySteps) ? data.verifySteps : [];
861
+ var applyCallback = todoRec ? function(nodeDiffs, removeNodes, $btn, idMap) {
862
+ applyModifications(nodeDiffs, removeNodes, $btn, idMap);
863
+ if (verifySteps.length) {
864
+ verifyModifySteps(verifySteps, idMap, todoRec);
865
+ } else {
866
+ todoRec.items.forEach(function(item) {
867
+ if (item.status === "active") { item.status = "done"; }
868
+ });
869
+ rerenderTodoRecord(todoRec);
870
+ }
871
+ } : applyModifications;
872
+
873
+ addModifyReview(data.flow, data.newNodes || [], data.newWires || [], data.removeNodes || [], applyCallback, null, data.newGroups || []);
838
874
  renderActionChip(data.suggestedAction);
839
875
  setBusy(false);
840
876
  updateSelectionStatus();
@@ -1055,8 +1091,272 @@
1055
1091
  ajaxJson("POST", fullEndpoint, payload, wrappedOnResult, onError);
1056
1092
  }
1057
1093
 
1094
+ // W4: parse a "Plan:" block from the model's explanation field.
1095
+ // Returns an array of { text, status } items, or [] if none found.
1096
+ // Each numbered/bulleted line under "Plan:" up to the first blank line
1097
+ // becomes one item. Status starts as "pending" for all items — the
1098
+ // caller sets the first to "active" before rendering.
1099
+ function parseTodoPlan(explanation) {
1100
+ if (!explanation || typeof explanation !== "string") { return []; }
1101
+ var planStart = explanation.indexOf("Plan:");
1102
+ if (planStart === -1) { return []; }
1103
+ var afterPlan = explanation.slice(planStart + 5);
1104
+ var planBlock = afterPlan.split(/\n\n/)[0];
1105
+ var lines = planBlock.split("\n");
1106
+ var items = [];
1107
+ lines.forEach(function (line) {
1108
+ var stripped = line.replace(/^\s*\d+[.):\s]+/, "").replace(/^\s*[-*]\s+/, "").trim();
1109
+ if (stripped) { items.push({ text: stripped, status: "pending" }); }
1110
+ });
1111
+ return items;
1112
+ }
1113
+
1114
+ // W4: render or re-render a "todo" record. For a 1-item plan, renders
1115
+ // as a compact status line (one chip). For N>1 items, renders as a
1116
+ // checklist card. Updates in place when the record already has a
1117
+ // data-fp-todo-id element in the message box (e.g. on verify check-off).
1118
+ function rerenderTodoRecord(rec) {
1119
+ if (!rec || !rec.items) { return; }
1120
+ var $box = el("#fp-messages");
1121
+ if (!$box.length) { return; }
1122
+ var items = rec.items;
1123
+ var $existing = $box.find("[data-fp-todo-id='" + rec.id + "']");
1124
+
1125
+ var $wrap;
1126
+ if (items.length === 1) {
1127
+ var item = items[0];
1128
+ var icon = item.status === "done" ? "✓" : item.status === "failed" ? "✗" : "▶";
1129
+ $wrap = $("<div>")
1130
+ .addClass("fp-todo-status fp-todo-" + item.status)
1131
+ .attr("data-fp-todo-id", rec.id)
1132
+ .text(icon + " " + item.text);
1133
+ } else {
1134
+ $wrap = $("<div>")
1135
+ .addClass("fp-todo-card")
1136
+ .attr("data-fp-todo-id", rec.id);
1137
+ var $ul = $("<ul>").addClass("fp-todo-list");
1138
+ items.forEach(function (item) {
1139
+ var icon = item.status === "done" ? "✓" :
1140
+ item.status === "failed" ? "✗" :
1141
+ item.status === "active" ? "▶" : "○";
1142
+ $("<li>")
1143
+ .addClass("fp-todo-item fp-todo-item-" + item.status)
1144
+ .text(icon + " " + item.text)
1145
+ .appendTo($ul);
1146
+ });
1147
+ $wrap.append($ul);
1148
+ }
1149
+
1150
+ if ($existing.length) {
1151
+ $existing.replaceWith($wrap);
1152
+ } else {
1153
+ $box.append($wrap);
1154
+ }
1155
+ }
1156
+
1157
+ // Step-queue path for Generate (opt-in via enableStepQueue setting).
1158
+ // After the user clicks "Add to workspace", performs a synchronous graph
1159
+ // read-back — calls RED.nodes.node(id) for every node that landed on the
1160
+ // canvas — and surfaces the result as a verification notice. This is the
1161
+ // structural verification step for Generate: "did the import actually land?"
1162
+ // not "does it do the thing?" (that's the semantic loop, Build's domain).
1163
+ // Skips comment and group nodes — those aren't addressable via RED.nodes.node.
1164
+ // todoRec: optional todo record to check off (or fail) after verification.
1165
+ function verifyImportedNodes(importResult, todoRec) {
1166
+ if (!importResult || !importResult.nodeMap) { return; }
1167
+ var nodeMap = importResult.nodeMap;
1168
+ var total = 0, found = 0, missing = [];
1169
+ // Config nodes (e.g. an http-request's TLS config, an mqtt broker
1170
+ // config) ride along in nodeMap whenever the model's own `flow`
1171
+ // array included them, but they aren't part of what the user asked
1172
+ // for — RED.nodes.node() resolves them same as regular nodes (it
1173
+ // checks configNodes[id] before falling back), so left uncounted
1174
+ // they'd silently inflate the headline total (e.g. "8" instead of
1175
+ // "5"). Track and verify them separately instead.
1176
+ var configTotal = 0, configFound = 0;
1177
+ // Node-RED's own RED.nodes.import (the generateIds:true path used
1178
+ // for every Generate/Build import) keys nodeMap TWICE per imported
1179
+ // node: once under the model's own placeholder id (assigned while
1180
+ // constructing the node, before it's added to the live registry)
1181
+ // and again under the freshly-generated real editor id (assigned in
1182
+ // the final addNode/addGroup/addJunction registration loop) — both
1183
+ // entries point to the same live node object. Left undeduped this
1184
+ // doubles every count here (visible AND config alike), independent
1185
+ // of the config/visible split above. Confirmed by reading
1186
+ // @node-red/editor-client/public/red/red.js's importNodes directly.
1187
+ var seenLiveIds = {};
1188
+ Object.keys(nodeMap).forEach(function (pid) {
1189
+ var liveNode = nodeMap[pid];
1190
+ if (!liveNode || !liveNode.id) { return; }
1191
+ if (seenLiveIds[liveNode.id]) { return; }
1192
+ seenLiveIds[liveNode.id] = true;
1193
+ if (liveNode.type === "comment" || liveNode.type === "group") { return; }
1194
+ if (liveNode._def && liveNode._def.category === "config") {
1195
+ configTotal++;
1196
+ if (RED.nodes.node(liveNode.id)) { configFound++; }
1197
+ return;
1198
+ }
1199
+ total++;
1200
+ if (RED.nodes.node(liveNode.id)) {
1201
+ found++;
1202
+ } else {
1203
+ missing.push(liveNode.type || pid);
1204
+ }
1205
+ });
1206
+ var configMissing = configTotal - configFound;
1207
+ var allGood = total > 0 && missing.length === 0 && configMissing === 0;
1208
+ if (total === 0) {
1209
+ // Nothing user-visible to verify (only comments/groups/config
1210
+ // nodes) — skip notice.
1211
+ } else if (allGood) {
1212
+ var configSuffix = configTotal > 0
1213
+ ? " (+" + configTotal + " supporting config node(s))"
1214
+ : "";
1215
+ addMessage("fp-notice", "✓ Verified: all " + found + " node(s) confirmed on canvas" + configSuffix + ".");
1216
+ } else {
1217
+ var allMissing = missing.slice();
1218
+ if (configMissing > 0) { allMissing.push(configMissing + " config node(s)"); }
1219
+ addMessage("fp-notice", "⚠ Verification: " + found + "/" + total + " node(s) on canvas — " +
1220
+ allMissing.length + " not found after import (" + allMissing.join(", ") + "). " +
1221
+ "These may be uninstalled node types that were silently dropped.");
1222
+ }
1223
+ // Check off (or fail) the active todo item.
1224
+ if (todoRec && todoRec.items) {
1225
+ todoRec.items.forEach(function (item) {
1226
+ if (item.status === "active") { item.status = allGood ? "done" : "failed"; }
1227
+ });
1228
+ rerenderTodoRecord(todoRec);
1229
+ }
1230
+ }
1231
+
1232
+ // W4 Phase 2: real graph read-back verification for Modify, using the
1233
+ // server-derived verifySteps (Track A — property/exists/absent/wire
1234
+ // checks; see finalizeModifyResult in flowpilot.js). Mirrors
1235
+ // verifyImportedNodes's design for Generate: aggregate pass/fail across
1236
+ // all steps, surface one notice, and check off (or fail) the active
1237
+ // todo item. idMap resolves the response-time placeholder ids that
1238
+ // existence/wire checks on newly-inserted nodes carry (the server can't
1239
+ // know the browser-assigned id at response time — applyInsertions
1240
+ // assigns it and returns idMap). Property/absent checks already use
1241
+ // real existing-node ids, so idMap[id] simply misses and falls through
1242
+ // to the id unchanged.
1243
+ function verifyModifySteps(verifySteps, idMap, todoRec) {
1244
+ if (!Array.isArray(verifySteps) || !verifySteps.length) { return; }
1245
+ idMap = idMap || {};
1246
+ function resolve(id) { return (idMap && idMap[id]) || id; }
1247
+
1248
+ var total = 0, passed = 0, failures = [];
1249
+ verifySteps.forEach(function (step) {
1250
+ var ok = false;
1251
+ switch (step.check) {
1252
+ case "property": {
1253
+ var pNode = RED.nodes.node(resolve(step.nodeId));
1254
+ ok = !!pNode && pNode[step.prop] === step.expected;
1255
+ if (!ok) { failures.push((step.prop || "property") + " on " + step.nodeId); }
1256
+ break;
1257
+ }
1258
+ case "exists": {
1259
+ ok = !!RED.nodes.node(resolve(step.nodeId));
1260
+ if (!ok) { failures.push(step.nodeId + " missing"); }
1261
+ break;
1262
+ }
1263
+ case "absent": {
1264
+ ok = !RED.nodes.node(resolve(step.nodeId));
1265
+ if (!ok) { failures.push(step.nodeId + " still present"); }
1266
+ break;
1267
+ }
1268
+ case "wire": {
1269
+ // Read from the live link registry (RED.nodes.eachLink), not
1270
+ // node.wires — RED.nodes.addLink/removeLink never re-sync a
1271
+ // live node's own .wires array mid-session (it's only set at
1272
+ // import and recomputed at export), so fromNode.wires[port] is
1273
+ // stale for any wire added/removed during the current editing
1274
+ // session. Mirrors computeWireDiff (apply-review.js).
1275
+ var fromId = resolve(step.fromId);
1276
+ var toId = resolve(step.toId);
1277
+ var port = step.fromPort || 0;
1278
+ ok = false;
1279
+ RED.nodes.eachLink(function (l) {
1280
+ if (ok) { return; }
1281
+ if (l.source && l.source.id === fromId &&
1282
+ (l.sourcePort || 0) === port &&
1283
+ l.target && l.target.id === toId) {
1284
+ ok = true;
1285
+ }
1286
+ });
1287
+ if (!ok) { failures.push("wire " + step.fromId + " → " + step.toId); }
1288
+ break;
1289
+ }
1290
+ default:
1291
+ return; // unrecognized check type — don't count it either way
1292
+ }
1293
+ total++;
1294
+ if (ok) { passed++; }
1295
+ });
1296
+
1297
+ if (total === 0) { return; }
1298
+ var allGood = failures.length === 0;
1299
+ if (allGood) {
1300
+ addMessage("fp-notice", "✓ Verified: all " + passed + " change(s) confirmed on canvas.");
1301
+ } else {
1302
+ addMessage("fp-notice", "⚠ Verification: " + passed + "/" + total + " change(s) confirmed — " +
1303
+ failures.length + " did not land as expected (" + failures.join(", ") + ").");
1304
+ }
1305
+ if (todoRec && todoRec.items) {
1306
+ todoRec.items.forEach(function (item) {
1307
+ if (item.status === "active") { item.status = allGood ? "done" : "failed"; }
1308
+ });
1309
+ rerenderTodoRecord(todoRec);
1310
+ }
1311
+ }
1312
+
1313
+ function handleStepQueueGenerateResult(data, goalPrompt) {
1314
+ hidePending();
1315
+ if (renderQuestionOrProse(data)) { return; }
1316
+ var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
1317
+
1318
+ // Build the todo plan. Parse "Plan:" from explanation if present;
1319
+ // fall back to an implicit single item from the goal prompt.
1320
+ var planItems = parseTodoPlan(data.explanation || "");
1321
+ if (!planItems.length) {
1322
+ planItems = [{ text: goalPrompt || "Generate flow", status: "pending" }];
1323
+ }
1324
+ // Same aggregate-verification reasoning as the Modify path: mark
1325
+ // every item active up front so a multi-item plan resolves together.
1326
+ planItems.forEach(function (item) { item.status = "active"; });
1327
+ var todoRec = addRecord("todo", { action: "generate", items: planItems });
1328
+ rerenderTodoRecord(todoRec);
1329
+
1330
+ addMessage("assistant", data.explanation || "(no explanation returned)");
1331
+ pushHistory("assistant", data.explanation || "(no explanation returned)");
1332
+ // B1: when a build loop is appropriate, bake deploy-verify into the
1333
+ // primary chip (same as handleSimpleGenerationResult). The callback
1334
+ // also runs verifyImportedNodes so the todo record still gets checked
1335
+ // off. Without a build loop, fall back to a plain "Add to canvas"
1336
+ // button that still fires the verify callback.
1337
+ var _hasDeployable = flow.some(function (n) { return n && n.type !== "comment" && n.type !== "group"; });
1338
+ var _wantLoop = goalPrompt && !activeBuildLoop && _hasDeployable;
1339
+ var _onImported = _wantLoop
1340
+ ? function (importResult) {
1341
+ verifyImportedNodes(importResult, todoRec);
1342
+ startBuildLoop(goalPrompt, flow, importResult);
1343
+ }
1344
+ : function (importResult) { verifyImportedNodes(importResult, todoRec); };
1345
+ addGeneratedReview(flow, _onImported, _wantLoop ? goalPrompt : null);
1346
+ // Suppress a server-suggested build chip when deploy-verify is already
1347
+ // the primary action inside the review panel.
1348
+ renderActionChip(_wantLoop && data.suggestedAction && data.suggestedAction.mode === "build"
1349
+ ? null : data.suggestedAction);
1350
+ setBusy(false);
1351
+ updateSelectionStatus();
1352
+ }
1353
+
1058
1354
  function generate() {
1059
- runGenerateLikeAction("generate", "generate", "Generate: ", handleSimpleGenerationResult);
1355
+ if (currentSettings.enableStepQueue) {
1356
+ runGenerateLikeAction("generate", "generate", "Generate: ", handleStepQueueGenerateResult);
1357
+ } else {
1358
+ runGenerateLikeAction("generate", "generate", "Generate: ", handleSimpleGenerationResult);
1359
+ }
1060
1360
  }
1061
1361
 
1062
1362
  // /build's first step. Reuses Generate's pipeline wholesale for the
@@ -1165,6 +1465,127 @@
1165
1465
  updateSelectionStatus();
1166
1466
  }
1167
1467
 
1468
+ // WS4: real consent gate for side-effecting build steps. Renders one
1469
+ // combined chip covering every side-effecting node this step's
1470
+ // classification found (classifyFlowNodes in flowpilot.js, server-side)
1471
+ // — a single decision rather than per-node chips, which is sufficient
1472
+ // because only the FIRST /flowpilot/build response carries
1473
+ // stepNodeClasses today (fix iterations via /flowpilot/modify don't, so
1474
+ // there's exactly one consent point per loop lifetime under the current
1475
+ // limitation — see the handleBuildResult call site).
1476
+ //
1477
+ // Reconstructed entirely from `src` (plain data, never a live closure)
1478
+ // on every call — the initial render from handleBuildResult AND every
1479
+ // later rerender (refresh, pop-out reopen, the W0A idle/focus
1480
+ // auto-refresh) via rerenderRecord's buildConsentGate branch below.
1481
+ // Mirrors rerenderReviewRecord's pattern in apply-review.js: a fresh
1482
+ // record is added each time from the source's stored fields (including
1483
+ // `decision`, once made), rather than relying on an in-memory callback
1484
+ // surviving a refresh. That in-memory-callback version is exactly what
1485
+ // broke before this fix — a refresh mid-decision fell through to
1486
+ // renderClarifyingQuestion's generic path, whose buttons send the
1487
+ // clicked label as a new chat message instead of resolving Proceed/Skip,
1488
+ // permanently stranding the loop.
1489
+ //
1490
+ // src fields: sideEffecting, flow, goalPrompt, fpUidManifest,
1491
+ // suggestedAction — everything runBuildConsentDecision needs — plus,
1492
+ // once resolved, `decision` ("proceed"|"skip") so a later rerender shows
1493
+ // a settled state instead of re-offering an already-made choice.
1494
+ function renderBuildConsentGate(src) {
1495
+ var $box = el("#fp-messages");
1496
+ if (!$box.length) {
1497
+ if (!src.decision) { runBuildConsentDecision(src, true); }
1498
+ return;
1499
+ }
1500
+
1501
+ var _rec = addRecord("question", {
1502
+ buildConsentGate: true,
1503
+ options: ["Auto-verify", "I'll check myself"],
1504
+ sideEffecting: src.sideEffecting,
1505
+ flow: src.flow,
1506
+ goalPrompt: src.goalPrompt,
1507
+ fpUidManifest: src.fpUidManifest,
1508
+ suggestedAction: src.suggestedAction,
1509
+ decision: src.decision
1510
+ });
1511
+
1512
+ var sideEffecting = Array.isArray(_rec.sideEffecting) ? _rec.sideEffecting : [];
1513
+ var labels = sideEffecting.map(function (n) { return n.name || n.type; }).join(", ");
1514
+
1515
+ if (_rec.decision) {
1516
+ // Already resolved before this render (e.g. resolved earlier in
1517
+ // the session, now showing again after a refresh) — settled
1518
+ // state, not an interactive choice.
1519
+ addMessage("assistant", "This step calls an external service: " + labels + ".");
1520
+ var $settledRow = $("<div>").addClass("fp-chip-row fp-question-row");
1521
+ $("<button>")
1522
+ .addClass("fp-consent-chip")
1523
+ .addClass(_rec.decision === "proceed" ? "fp-consent-chip-primary" : "fp-consent-chip-alt")
1524
+ .attr("type", "button").prop("disabled", true)
1525
+ .text(_rec.decision === "proceed" ? "Auto-verify ✓" : "Checking myself ✓")
1526
+ .appendTo($settledRow);
1527
+ $box.append($settledRow);
1528
+ scrollMessagesToBottom();
1529
+ return;
1530
+ }
1531
+
1532
+ addMessage("assistant", "This step calls an external service: " + labels +
1533
+ ". Want it verified automatically once triggered, or would you rather check it yourself?");
1534
+
1535
+ var $row = $("<div>").addClass("fp-chip-row fp-question-row");
1536
+
1537
+ function decide(proceed) {
1538
+ $row.find("button").prop("disabled", true);
1539
+ _rec.decision = proceed ? "proceed" : "skip";
1540
+ runBuildConsentDecision(_rec, proceed);
1541
+ }
1542
+
1543
+ $("<button>")
1544
+ .addClass("fp-consent-chip fp-consent-chip-primary")
1545
+ .attr("type", "button")
1546
+ .text("Auto-verify")
1547
+ .on("click", function () { decide(true); })
1548
+ .appendTo($row);
1549
+ $("<button>")
1550
+ .addClass("fp-consent-chip fp-consent-chip-alt")
1551
+ .attr("type", "button")
1552
+ .text("I'll check myself")
1553
+ .on("click", function () { decide(false); })
1554
+ .appendTo($row);
1555
+
1556
+ $box.append($row);
1557
+ scrollMessagesToBottom();
1558
+ }
1559
+
1560
+ // The actual "proceed with review + loop" action, factored out so both
1561
+ // the fresh (no side-effecting nodes) and gated (decision made) paths in
1562
+ // handleBuildResult, and a rerendered consent-gate record's decide(),
1563
+ // all run identical logic sourced from plain data — never a captured
1564
+ // closure. consentGranted=false builds the Skip consent object
1565
+ // (skippedNodeIds/fpUidManifest) that startBuildLoop resolves into real
1566
+ // ids via importResult.nodeMap — see startBuildLoop's own comment.
1567
+ function runBuildConsentDecision(src, consentGranted) {
1568
+ var sideEffecting = Array.isArray(src.sideEffecting) ? src.sideEffecting : [];
1569
+ var flow = src.flow;
1570
+ var goalPrompt = src.goalPrompt;
1571
+ var fpUidManifest = Array.isArray(src.fpUidManifest) ? src.fpUidManifest : [];
1572
+
1573
+ if (sideEffecting.length > 0) {
1574
+ var sideLabels = sideEffecting.map(function (n) { return n.name || n.type; }).join(", ");
1575
+ addMessage("fp-notice", consentGranted
1576
+ ? "⚠ External calls: " + sideLabels + " — the deploy-test loop will auto-verify these once triggered."
1577
+ : "⚠ External calls: " + sideLabels + " — auto-verify skipped for these node(s); confirm the result yourself.");
1578
+ }
1579
+ var consent = consentGranted ? null : {
1580
+ skippedNodeIds: sideEffecting.map(function (n) { return n.id; }),
1581
+ fpUidManifest: fpUidManifest
1582
+ };
1583
+ addGeneratedReview(flow, function (importResult) {
1584
+ startBuildLoop(goalPrompt, flow, importResult, consent);
1585
+ }, goalPrompt);
1586
+ renderActionChip(src.suggestedAction);
1587
+ }
1588
+
1168
1589
  function handleBuildResult(data, goalPrompt) {
1169
1590
  hidePending();
1170
1591
  if (renderQuestionOrProse(data)) { return; }
@@ -1173,8 +1594,24 @@
1173
1594
  var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
1174
1595
  addMessage("assistant", data.explanation || "(no explanation returned)");
1175
1596
  pushHistory("assistant", data.explanation || "(no explanation returned)");
1176
- addGeneratedReview(flow, function (importResult) { startBuildLoop(goalPrompt, flow, importResult); }, goalPrompt);
1177
- renderActionChip(data.suggestedAction);
1597
+
1598
+ var nodeClasses = data.stepNodeClasses;
1599
+ var sideEffecting = (nodeClasses && Array.isArray(nodeClasses.sideEffecting))
1600
+ ? nodeClasses.sideEffecting : [];
1601
+ var fpUidManifest = Array.isArray(data.fpUidManifest) ? data.fpUidManifest : [];
1602
+ var consentSrc = {
1603
+ sideEffecting: sideEffecting,
1604
+ flow: flow,
1605
+ goalPrompt: goalPrompt,
1606
+ fpUidManifest: fpUidManifest,
1607
+ suggestedAction: data.suggestedAction
1608
+ };
1609
+
1610
+ if (sideEffecting.length > 0) {
1611
+ renderBuildConsentGate(consentSrc);
1612
+ } else {
1613
+ runBuildConsentDecision(consentSrc, true);
1614
+ }
1178
1615
  setBusy(false);
1179
1616
  updateSelectionStatus();
1180
1617
  }
@@ -1265,6 +1702,8 @@
1265
1702
  addMessage("error", "Describe what you want to change.");
1266
1703
  return;
1267
1704
  }
1705
+ var existingNodeIds = context.nodes.map(function (n) { return n.id; });
1706
+
1268
1707
  var label = "Modify: " + instruction + contextAttachmentNote(context);
1269
1708
  addMessage("user", label);
1270
1709
  // Snapshot history before pushing this turn (see send()).
@@ -1289,6 +1728,10 @@
1289
1728
  handleExecuteError(msg, raw);
1290
1729
  }
1291
1730
 
1731
+ function onModifyResult(data) {
1732
+ handleModifyResult(data);
1733
+ }
1734
+
1292
1735
  // Explore-then-propose, same as generate(). The
1293
1736
  // model may call read tools (e.g. to re-check the selected node's
1294
1737
  // current config) before producing the modify envelope; the final
@@ -1296,7 +1739,7 @@
1296
1739
  if (isAgentLoop) {
1297
1740
  runAgentLoop("flowpilot/modify", payload,
1298
1741
  { mode: "modify", context: context, prompt: instruction },
1299
- handleModifyResult, onModifyError);
1742
+ onModifyResult, onModifyError);
1300
1743
  return;
1301
1744
  }
1302
1745
 
@@ -1304,11 +1747,11 @@
1304
1747
  // generate() for details.
1305
1748
  if (currentSettings.streamingEnabled) {
1306
1749
  payload.stream = true;
1307
- sendExecuteStream("modify", payload, handleModifyResult);
1750
+ sendExecuteStream("modify", payload, onModifyResult);
1308
1751
  return;
1309
1752
  }
1310
1753
 
1311
- ajaxJson("POST", "flowpilot/modify", payload, handleModifyResult, onModifyError);
1754
+ ajaxJson("POST", "flowpilot/modify", payload, onModifyResult, onModifyError);
1312
1755
  }
1313
1756
 
1314
1757
  // Render generated flow JSON in a preformatted, copyable block. Used for
@@ -1357,6 +1800,10 @@
1357
1800
  // the review — see onDebugMessage for why (a forked/split flow can
1358
1801
  // fire its debug node more than once per trigger).
1359
1802
  var BUILD_LOOP_ATTACH_DEBOUNCE_MS = 1200;
1803
+ // W0.3: how many times the model can bail (emit a prose reply with a
1804
+ // suggestedAction mode-redirect) before the loop gives up with an
1805
+ // honest-timeout instead of silently treating the bail as success.
1806
+ var BUILD_LOOP_MAX_BAILS = 2;
1360
1807
  var buildLoopAttachTimer = null;
1361
1808
  // Fires when "attach" waits too long with no debug — surfaces a prompt
1362
1809
  // for flows that don't produce automatic debug output (HTTP endpoints, etc).
@@ -1410,6 +1857,55 @@
1410
1857
  scrollMessagesToBottom();
1411
1858
  }
1412
1859
 
1860
+ // WS3: remove FP-UID checkpoint tap nodes that the build prompt placed
1861
+ // on the canvas as FlowPilot scaffolding. These are debug nodes named
1862
+ // FP-UID001, FP-UID002, etc. — wired in parallel to external-call nodes
1863
+ // so the loop can attribute debug messages to specific checkpoints.
1864
+ // Called at loop end (any outcome) to clean up before returning control
1865
+ // to the user. Replicates the remove-and-history pattern from
1866
+ // applyModifications in apply-review.js (confirmed group-cleanup bookkeeping).
1867
+ function removeFpUidTaps(loop) {
1868
+ if (!loop || !Array.isArray(loop.nodeIds) || !loop.nodeIds.length) { return 0; }
1869
+ var FP_UID_RE = /^FP-UID\d+$/;
1870
+ var removed = 0;
1871
+ loop.nodeIds.forEach(function (id) {
1872
+ var liveNode = RED.nodes.node(id);
1873
+ if (!liveNode || !FP_UID_RE.test(liveNode.name)) { return; }
1874
+ var connectedLinks = [];
1875
+ RED.nodes.eachLink(function (l) {
1876
+ if ((l.source && l.source.id === id) || (l.target && l.target.id === id)) {
1877
+ connectedLinks.push(l);
1878
+ }
1879
+ });
1880
+ try { RED.nodes.remove(liveNode.id); } catch (e) { return; }
1881
+ if (liveNode.g && RED.nodes.group) {
1882
+ var ownerGroup = RED.nodes.group(liveNode.g);
1883
+ if (ownerGroup) {
1884
+ var idx = ownerGroup.nodes.indexOf(liveNode);
1885
+ if (idx !== -1) { ownerGroup.nodes.splice(idx, 1); }
1886
+ RED.group.markDirty(ownerGroup);
1887
+ }
1888
+ }
1889
+ RED.history.push({
1890
+ t: "delete",
1891
+ nodes: [liveNode],
1892
+ links: connectedLinks,
1893
+ groups: [],
1894
+ junctions: [],
1895
+ subflow: { id: undefined, instances: [] },
1896
+ subflowInputs: [],
1897
+ subflowOutputs: [],
1898
+ dirty: RED.nodes.dirty()
1899
+ });
1900
+ removed++;
1901
+ });
1902
+ if (removed) {
1903
+ RED.nodes.dirty(true);
1904
+ RED.view.redraw(true);
1905
+ }
1906
+ return removed;
1907
+ }
1908
+
1413
1909
  // The single exit point for every way a build loop ends — Touchdown,
1414
1910
  // the cap being reached, pausing on a clarifying question, or the user
1415
1911
  // clicking Stop. Releases Build mode and its pinned selection too: once
@@ -1420,6 +1916,7 @@
1420
1916
  // visible as a completion badge. success=false (default): remove the stepper
1421
1917
  // (user stop, cap reached, paused for question).
1422
1918
  function stopBuildLoop(note, success) {
1919
+ var tapCount = activeBuildLoop ? removeFpUidTaps(activeBuildLoop) : 0;
1423
1920
  if (success && activeBuildLoop) {
1424
1921
  activeBuildLoop.waypoint = "done";
1425
1922
  renderLoopStepper(activeBuildLoop);
@@ -1430,6 +1927,7 @@
1430
1927
  if (!success) { el("#fp-loop-stepper").remove(); }
1431
1928
  disarmExecuteAction();
1432
1929
  if (note) { addMessage("assistant", note); }
1930
+ if (tapCount) { addMessage("assistant", "Removed " + tapCount + " FP-UID checkpoint tap(s) from the canvas."); }
1433
1931
  }
1434
1932
 
1435
1933
  // Applies a build-loop review's fix envelope, then keeps the loop's
@@ -1492,7 +1990,21 @@
1492
1990
  if (loop.waypoint === "apply") {
1493
1991
  hint = "Click the canvas to place the new node(s), then Deploy — I'll move on automatically once you deploy.";
1494
1992
  } else if (loop.waypoint === "attach") {
1495
- hint = "Trigger the flow, then check the Debug sidebar — I'll attach the next debug message automatically.";
1993
+ var eps = loop.httpEndpoints;
1994
+ if (eps && eps.length > 0) {
1995
+ var ep = eps[0];
1996
+ var baseUrl = (typeof window !== "undefined" && window.location)
1997
+ ? window.location.origin : "";
1998
+ var curlMethod = ep.method === "GET" ? "" : " -X " + ep.method;
1999
+ hint = "Send " + ep.method + " " + ep.url + " to trigger the flow — " +
2000
+ "e.g. curl" + curlMethod + " " + baseUrl + ep.url +
2001
+ ". I’ll attach the debug output automatically.";
2002
+ if (eps.length > 1) {
2003
+ hint += " (" + (eps.length - 1) + " more endpoint(s) in this flow.)";
2004
+ }
2005
+ } else {
2006
+ hint = "Trigger the flow, then check the Debug sidebar — I'll attach the next debug message automatically.";
2007
+ }
1496
2008
  } else if (loop.waypoint === "review") {
1497
2009
  hint = "Debug output attached — reviewing against the goal…";
1498
2010
  }
@@ -1513,7 +2025,8 @@
1513
2025
  iteration: loop.iteration,
1514
2026
  maxIterations: loop.maxIterations,
1515
2027
  goal: loop.goal,
1516
- nodeIds: Array.isArray(loop.nodeIds) ? loop.nodeIds.slice() : []
2028
+ nodeIds: Array.isArray(loop.nodeIds) ? loop.nodeIds.slice() : [],
2029
+ httpEndpoints: Array.isArray(loop.httpEndpoints) ? loop.httpEndpoints.slice() : []
1517
2030
  });
1518
2031
 
1519
2032
  $box.append($msg);
@@ -1526,7 +2039,8 @@
1526
2039
  iteration: rec.iteration || 1,
1527
2040
  maxIterations: rec.maxIterations || 5,
1528
2041
  goal: rec.goal || "",
1529
- nodeIds: Array.isArray(rec.nodeIds) ? rec.nodeIds : []
2042
+ nodeIds: Array.isArray(rec.nodeIds) ? rec.nodeIds : [],
2043
+ httpEndpoints: Array.isArray(rec.httpEndpoints) ? rec.httpEndpoints : []
1530
2044
  });
1531
2045
  }
1532
2046
 
@@ -1542,12 +2056,22 @@
1542
2056
  // end up on the canvas — importResult.nodeMap maps each placeholder id
1543
2057
  // to the real live node object, which is the only way later review/fix
1544
2058
  // requests can target the right nodes via collectSelectionContext.
1545
- function startBuildLoop(goal, nodeIdsOrNodes, importResult) {
2059
+ //
2060
+ // consent (WS4, optional): { skippedNodeIds, fpUidManifest } from a
2061
+ // Skip decision at the build consent gate (see handleBuildResult /
2062
+ // renderBuildConsentGate) — skippedNodeIds are the side-effecting
2063
+ // nodes' own PLACEHOLDER ids, fpUidManifest maps each FP-UID debug tap's
2064
+ // placeholder id to the placeholder id of the node it's wired from
2065
+ // (wiredFrom). Resolved here into two REAL-id sets: the skipped nodes
2066
+ // themselves (onNodeStatus's status/<nodeId> path checks against these)
2067
+ // and the taps wired to them (onDebugMessage's msg.id check does) — so
2068
+ // both auto-verify evidence paths honor the same Skip decision.
2069
+ function startBuildLoop(goal, nodeIdsOrNodes, importResult, consent) {
1546
2070
  var nodeIds = [];
2071
+ var nodeMap = importResult && importResult.nodeMap;
1547
2072
  if (importResult) {
1548
2073
  // Fresh build: map placeholder ids from the proposal to the real
1549
2074
  // ids importNodes assigned on the canvas.
1550
- var nodeMap = importResult.nodeMap;
1551
2075
  if (nodeMap && Array.isArray(nodeIdsOrNodes)) {
1552
2076
  nodeIdsOrNodes.forEach(function (n) {
1553
2077
  var real = n && n.id && nodeMap[n.id];
@@ -1558,13 +2082,43 @@
1558
2082
  // Existing-flow build: ids are already resolved real canvas ids.
1559
2083
  nodeIds = nodeIdsOrNodes.filter(function (id) { return typeof id === "string" && id; });
1560
2084
  }
2085
+ // Detect HTTP-in endpoints so the "attach" step can show a specific
2086
+ // trigger hint instead of the generic "trigger the flow" message.
2087
+ var httpEndpoints = [];
2088
+ nodeIds.forEach(function (id) {
2089
+ var n = RED.nodes.node(id);
2090
+ if (n && n.type === "http in" && n.url) {
2091
+ httpEndpoints.push({ method: (n.method || "get").toUpperCase(), url: n.url });
2092
+ }
2093
+ });
2094
+
2095
+ var skipCheckpointNodeIds = [];
2096
+ var skipCheckpointTapIds = [];
2097
+ var skippedPlaceholderIds = consent && Array.isArray(consent.skippedNodeIds) ? consent.skippedNodeIds : [];
2098
+ if (skippedPlaceholderIds.length && nodeMap) {
2099
+ skippedPlaceholderIds.forEach(function (placeholderId) {
2100
+ var real = nodeMap[placeholderId];
2101
+ if (real && real.id) { skipCheckpointNodeIds.push(real.id); }
2102
+ });
2103
+ var manifest = Array.isArray(consent.fpUidManifest) ? consent.fpUidManifest : [];
2104
+ manifest.forEach(function (tap) {
2105
+ if (!tap || skippedPlaceholderIds.indexOf(tap.wiredFrom) === -1) { return; }
2106
+ var realTap = nodeMap[tap.id];
2107
+ if (realTap && realTap.id) { skipCheckpointTapIds.push(realTap.id); }
2108
+ });
2109
+ }
2110
+
1561
2111
  activeBuildLoop = {
1562
2112
  goal: goal,
1563
2113
  nodeIds: nodeIds,
1564
2114
  iteration: 1,
1565
2115
  maxIterations: getAgentLoopMaxIterations(),
1566
2116
  waypoint: "apply",
1567
- conversationId: conversationId
2117
+ conversationId: conversationId,
2118
+ bailCount: 0,
2119
+ httpEndpoints: httpEndpoints,
2120
+ skipCheckpointNodeIds: skipCheckpointNodeIds,
2121
+ skipCheckpointTapIds: skipCheckpointTapIds
1568
2122
  };
1569
2123
  renderLoopStepper(activeBuildLoop);
1570
2124
  }
@@ -1580,7 +2134,47 @@
1580
2134
  function runBuildReview(loop) {
1581
2135
  var context = collectSelectionContext(loop.nodeIds);
1582
2136
  context = attachDebugContext(context);
1583
- var instruction = "Review the attached debug output against this build goal: \"" +
2137
+ var reviewEvidence = context && Array.isArray(context.debugMessages)
2138
+ ? context.debugMessages : [];
2139
+ var statusOnlyEvidence = reviewEvidence.length > 0 &&
2140
+ reviewEvidence.every(function (entry) {
2141
+ return entry && entry.sourceKind === "status";
2142
+ });
2143
+ // W0.3: framing block — suppresses the Modify escape hatch
2144
+ // (suggestedAction mode-redirect) inside the build loop context.
2145
+ // The code-side handler (handleBuildReviewResult) also detects and
2146
+ // counts bail attempts so N bails trigger an honest-timeout instead
2147
+ // of silently treating a redirect as success.
2148
+ var instruction = "CONTEXT: You are the fix engine inside a build-test-fix loop. " +
2149
+ "Your only valid responses are: (1) plain text when the goal is fully " +
2150
+ "satisfied, or (2) a {\"explanation\", \"changes\", ...} fix envelope " +
2151
+ "when something needs patching." +
2152
+ (statusOnlyEvidence
2153
+ ? " (3) Because the attached evidence contains ONLY coarse node-status " +
2154
+ "lines, you may instead return the atomic {\"question\", " +
2155
+ "\"questionOptions\"} envelope described below."
2156
+ : "") +
2157
+ " Do NOT use the <<<FLOWPILOT_DATA>>> " +
2158
+ "block or suggest switching to chat/generate/document — you are already " +
2159
+ "in the right context and any mode-redirect will be ignored. If you are " +
2160
+ "genuinely uncertain what to fix, " +
2161
+ (statusOnlyEvidence
2162
+ ? "use the status-only confirmation question below."
2163
+ : "describe the uncertainty inside \"explanation\" in a fix envelope.") +
2164
+ "\n\nEach attached evidence object has sourceKind. sourceKind:\"debug\" " +
2165
+ "is real message content emitted by a debug node. sourceKind:\"status\" " +
2166
+ "is only a coarse connection/status line synthesized from node status; " +
2167
+ "never treat it as proof of message payload content or successful " +
2168
+ "end-to-end behavior. " +
2169
+ (statusOnlyEvidence
2170
+ ? "STATUS-ONLY FALLBACK: if the coarse status does not prove whether " +
2171
+ "the deployed node is actually connected/working, do not guess or " +
2172
+ "assert failure. Ask one concrete yes/no confirmation such as " +
2173
+ "\"Does the node show connected after deploy?\" by returning ONLY " +
2174
+ "{\"question\":\"...\",\"questionOptions\":[\"Yes\",\"No\"]}. "
2175
+ : "") +
2176
+ "\n\n" +
2177
+ "Review the attached debug output against this build goal: \"" +
1584
2178
  loop.goal + "\". Before concluding anything, list out every distinct " +
1585
2179
  "piece of data or behavior the goal actually requires, then check the " +
1586
2180
  "attached debug payload(s) contain EACH one — a payload that's merely " +
@@ -1588,7 +2182,22 @@
1588
2182
  "goal asked to combine two things but the payload only shows one), " +
1589
2183
  "does NOT fully satisfy it. If more than one debug message is " +
1590
2184
  "attached, treat them together as the full picture from one trigger, " +
1591
- "not as separate independent attempts. If it fully satisfies the goal, " +
2185
+ "not as separate independent attempts. " +
2186
+ "SPECIAL CASE — network errors: if the debug output shows ONLY a " +
2187
+ "network-level error (EHOSTUNREACH, ECONNREFUSED, ETIMEDOUT, " +
2188
+ "ENOTFOUND, getaddrinfo ENOTFOUND, EAI_AGAIN, EAI_NODATA), the " +
2189
+ "flow MIGHT be correctly built — BUT you MUST first check the node " +
2190
+ "context: if any http-request node has an empty url field, a " +
2191
+ "placeholder, or a clearly malformed url (no hostname, no protocol, " +
2192
+ "etc.), the DNS or connection error is a CONFIGURATION problem — " +
2193
+ "fix the url field, do NOT declare it an infrastructure issue. Only " +
2194
+ "apply this special case when the url is a real, non-empty, " +
2195
+ "well-formed URL and the external service is simply unreachable. In " +
2196
+ "that case reply in plain text acknowledging the flow is structurally " +
2197
+ "correct and the network error is an infrastructure issue outside the " +
2198
+ "flow. Do NOT propose any changes; this error cannot be resolved by " +
2199
+ "modifying the flow. " +
2200
+ "If it fully satisfies the goal, " +
1592
2201
  "say so in plain text — no changes needed. If something's wrong " +
1593
2202
  "(including a node that never fired, or a value that's missing/empty " +
1594
2203
  "when the goal needed it), propose the fix directly as a patch in " +
@@ -1644,6 +2253,26 @@
1644
2253
  // diff-then-Apply pipeline as a manual Modify, then the loop advances
1645
2254
  // back to "apply" for the next deploy/test cycle, or stops if the
1646
2255
  // iteration cap is reached).
2256
+ // Returns false when all of data's proposed changes are sentinel-echoed
2257
+ // with no insertions, removals, or wire changes. Used by
2258
+ // handleBuildReviewResult to avoid showing an all-blocked review panel
2259
+ // when the model said "no changes needed" but still emitted a modify
2260
+ // envelope (a common model behavior after a build-loop review).
2261
+ function reviewHasRealDiffs(data) {
2262
+ if ((data.newNodes && data.newNodes.length) ||
2263
+ (data.removeNodes && data.removeNodes.length) ||
2264
+ (data.newWires && data.newWires.length) ||
2265
+ (data.newGroups && data.newGroups.length)) { return true; }
2266
+ var nodes = Array.isArray(data.flow) ? data.flow : [];
2267
+ return nodes.some(function (modNode) {
2268
+ if (!modNode || !modNode.id) { return false; }
2269
+ var liveNode = findLiveNode(modNode.id);
2270
+ if (!liveNode) { return false; }
2271
+ var diff = computeNodeDiff(liveNode, modNode);
2272
+ return diff.propertyChanges.length > 0 || diff.wiresChanged;
2273
+ });
2274
+ }
2275
+
1647
2276
  function handleBuildReviewResult(data) {
1648
2277
  hidePending();
1649
2278
  var loop = activeBuildLoop;
@@ -1667,6 +2296,51 @@
1667
2296
  }
1668
2297
 
1669
2298
  if (data.prose) {
2299
+ var explanation = data.explanation || "(no content returned)";
2300
+
2301
+ // W0.3: bail detection — a prose reply with a mode-redirect
2302
+ // suggestedAction means the model tried to exit the loop
2303
+ // context via the Modify escape hatch. Count it and retry or
2304
+ // honest-timeout rather than treating it as success.
2305
+ var sa = data.suggestedAction;
2306
+ var isBail = sa && (sa.mode === "chat" || sa.mode === "generate" || sa.mode === "document");
2307
+ if (isBail) {
2308
+ loop.bailCount = (loop.bailCount || 0) + 1;
2309
+ console.warn("[FlowPilot] build-loop bail #" + loop.bailCount +
2310
+ " mode=" + sa.mode + ": " + explanation);
2311
+ addMessage("assistant", explanation);
2312
+ pushHistory("assistant", explanation);
2313
+ if (loop.bailCount >= BUILD_LOOP_MAX_BAILS) {
2314
+ stopBuildLoop("Build loop could not assess the debug output — the AI kept redirecting instead of reviewing. Try attaching more debug context or continuing manually with Modify.", false);
2315
+ setBusy(false);
2316
+ updateSelectionStatus();
2317
+ } else {
2318
+ addMessage("fp-notice", "Build-loop: review redirected to " + sa.mode +
2319
+ " — staying in build context and retrying (bail " + loop.bailCount +
2320
+ "/" + BUILD_LOOP_MAX_BAILS + ").");
2321
+ runBuildReview(loop);
2322
+ }
2323
+ return;
2324
+ }
2325
+
2326
+ addMessage("assistant", explanation);
2327
+ pushHistory("assistant", explanation);
2328
+ renderActionChip(data.suggestedAction);
2329
+ var stopMsg = /EHOSTUNREACH|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EAI_NODATA|getaddrinfo|unreachable|infrastructure/i
2330
+ .test(explanation)
2331
+ ? "Build complete — the flow is correctly structured, but the external endpoint was unreachable during testing (infrastructure issue, not a flow problem)."
2332
+ : "Touchdown — the debug output matches the goal.";
2333
+ stopBuildLoop(stopMsg, true);
2334
+ setBusy(false);
2335
+ updateSelectionStatus();
2336
+ return;
2337
+ }
2338
+
2339
+ // Modify envelope where every proposed change is a sentinel echo —
2340
+ // the model emitted a changes object but all fields are redacted
2341
+ // placeholders with no insertions, removals, or wire changes. Treat
2342
+ // it as "no changes needed" rather than showing an all-blocked panel.
2343
+ if (!reviewHasRealDiffs(data)) {
1670
2344
  addMessage("assistant", data.explanation || "(no content returned)");
1671
2345
  pushHistory("assistant", data.explanation || "");
1672
2346
  renderActionChip(data.suggestedAction);