@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,1435 @@
1
+ // ---- Slash commands ---------------------------------------------------
2
+ // Typed in the compose box and handled entirely client-side: never sent
3
+ // to the model, never recorded in conversation history.
4
+ var HELP_TEXT = "## Captain's briefing\n\n" +
5
+ "You pick the destination, I help you get there. Here's everything on the panel.\n\n" +
6
+ "### Modes (one armed at a time)\n\n" +
7
+ "- **Query** (default) — chat about your flow, ask questions, get explanations. Nothing changes.\n" +
8
+ "- **Generate** — describe a flow and I'll draft it. Review the diff, then Apply.\n" +
9
+ "- **Document** — select node(s) and I'll explain what they do.\n" +
10
+ "- **Modify** — select node(s), tell me what to change, review the diff, then Apply.\n\n" +
11
+ "The compose area glows orange in Query and blue when an Execute mode is armed, so you always know which one you're flying.\n\n" +
12
+ "### Shortcuts\n\n" +
13
+ "- `/help` — show this briefing\n" +
14
+ "- `/generate` — arm Generate mode\n" +
15
+ "- `/document` — arm Document mode\n" +
16
+ "- `/modify` — arm Modify mode\n" +
17
+ "- `/query` — back to Query (disarm)\n" +
18
+ "- `/clear` — start a fresh conversation (clears chat and memory)\n" +
19
+ "- `/history` — open the Flight log (past conversations)\n" +
20
+ "- `/settings` — open the Hangar (providers, behavior, safety)\n\n" +
21
+ "Typing a shortcut with extra text, e.g. `/modify add a debug node`, switches mode and leaves the rest in the box so you can review before sending.\n\n" +
22
+ "- `/demo` — load a sample Generate request (a dad joke flow) into the compose box\n" +
23
+ "- `/feedback` — bug report / feature request info\n" +
24
+ "- `/build` — describe a goal; I'll plan, propose, and walk an iterative build → deploy → debug → review → fix loop with you\n" +
25
+ "- `/compact` — hide labels on the selected node(s) (icon-only); `/expand` restores them. Instant, no AI involved — one Ctrl+Z undoes it.\n" +
26
+ "- `/disable` — disable the selected node(s) (skipped on Deploy); `/enable` re-enables them. Instant, no AI involved — one Ctrl+Z undoes it.\n" +
27
+ "- `/refresh` — re-render all messages from the in-memory record store (restores interactive Apply buttons if they were lost).\n\n" +
28
+ "### Also worth knowing\n\n" +
29
+ "- Action chips (paper-plane buttons) offer a one-click follow-up — review and send, nothing fires automatically.\n" +
30
+ "- When I ask a clarifying question, I'll often offer quick-reply buttons (plus \"Other\" for your own answer) — clicking one sends it right away.\n" +
31
+ "- \"Pre-flight check\" in the Hangar tests a provider before you rely on it.\n" +
32
+ "- \"Touchdown\"/\"Landed\" notes confirm an applied or imported change. Ctrl+Z undoes an applied change.";
33
+
34
+ // /demo: a ready-made Generate request, used to show off FlowPilot's
35
+ // flow-generation capability end to end (HTTP request, status, debug)
36
+ // with a small, fast-to-generate flow.
37
+ var DEMO_PROMPT = "Using an online dad joke API (e.g. https://icanhazdadjoke.com/ with an \"Accept: application/json\" header), make an API call for a dad joke when triggered by an inject node. Set the node's status to show the joke text, and wire a debug node to output the joke itself.";
38
+
39
+ // /feedback: links back to the repo/issues, shown entirely client-side.
40
+ var FEEDBACK_TEXT = "## Thanks for flying with FlowPilot\n\n" +
41
+ "Bug? Rough edge? Idea for a feature? I'd love to hear about it — the " +
42
+ "human crew reads every report.\n\n" +
43
+ "- **Report an issue**: https://github.com/manny-est/flowpilot/issues\n" +
44
+ "- **Browse the repo**: https://github.com/manny-est/flowpilot\n\n" +
45
+ "A good report travels light but packs the essentials: your Node-RED " +
46
+ "version, the provider/model you're flying with, and the steps to " +
47
+ "reproduce. That's usually enough to get a fix off the ground.\n\n" +
48
+ "Safe travels.";
49
+
50
+ var demoTypeTimer = null;
51
+
52
+ // Registry of all slash commands — drives the autocomplete panel that
53
+ // appears when the user types "/" in the prompt box. Keep in sync with
54
+ // handleSlashCommand below.
55
+ var SLASH_COMMANDS = [
56
+ { cmd: "/generate", desc: "Switch to Generate mode" },
57
+ { cmd: "/document", desc: "Switch to Document mode" },
58
+ { cmd: "/modify", desc: "Switch to Modify mode" },
59
+ { cmd: "/build", desc: "Start a deploy-verify build loop" },
60
+ { cmd: "/chat", desc: "Switch to Chat mode" },
61
+ { cmd: "/query", desc: "Add or toggle a Query intent" },
62
+ { cmd: "/compact", desc: "Compact labels on selected nodes" },
63
+ { cmd: "/expand", desc: "Expand labels on selected nodes" },
64
+ { cmd: "/disable", desc: "Disable selected nodes" },
65
+ { cmd: "/enable", desc: "Enable selected nodes" },
66
+ { cmd: "/refresh", desc: "Re-render all messages from shadow record store" },
67
+ { cmd: "/demo", desc: "Type in a demo prompt" },
68
+ { cmd: "/help", desc: "Show all available commands" },
69
+ { cmd: "/feedback", desc: "Show feedback info" }
70
+ ];
71
+
72
+ function bindSlashAutocomplete($promptBox) {
73
+ var $wrap = $promptBox.closest(".fp-prompt-wrap");
74
+ var $panel = $('<div id="fp-slash-suggest"></div>').hide();
75
+ $wrap.prepend($panel);
76
+ var activeIndex = -1;
77
+
78
+ function getRows() { return $panel.find(".fp-slash-row"); }
79
+
80
+ function setActive(idx) {
81
+ getRows().removeClass("fp-slash-active");
82
+ activeIndex = idx;
83
+ if (idx >= 0) { getRows().eq(idx).addClass("fp-slash-active"); }
84
+ }
85
+
86
+ function showPanel(partial) {
87
+ var matches = SLASH_COMMANDS.filter(function (c) {
88
+ return c.cmd.indexOf(partial) === 0;
89
+ });
90
+ if (!matches.length) { $panel.hide(); return; }
91
+ $panel.empty();
92
+ matches.forEach(function (c) {
93
+ $('<div class="fp-slash-row">')
94
+ .append($('<span class="fp-slash-cmd">').text(c.cmd))
95
+ .append($('<span class="fp-slash-desc">').text(c.desc))
96
+ .on("mousedown", function (e) {
97
+ e.preventDefault();
98
+ completeWith(c.cmd);
99
+ })
100
+ .appendTo($panel);
101
+ });
102
+ setActive(0);
103
+ $panel.show();
104
+ }
105
+
106
+ function hidePanel() {
107
+ $panel.hide();
108
+ activeIndex = -1;
109
+ }
110
+
111
+ function completeWith(cmd) {
112
+ $promptBox.val(cmd + " ").focus();
113
+ hidePanel();
114
+ }
115
+
116
+ $promptBox.on("input.slashcomplete", function () {
117
+ var val = $promptBox.val();
118
+ if (/^\/\S*$/.test(val)) { showPanel(val); } else { hidePanel(); }
119
+ });
120
+
121
+ $promptBox.on("keydown.slashcomplete", function (e) {
122
+ if (!$panel.is(":visible")) { return; }
123
+ var $rows = getRows();
124
+ if (e.key === "ArrowDown") {
125
+ e.preventDefault();
126
+ setActive(Math.min(activeIndex + 1, $rows.length - 1));
127
+ } else if (e.key === "ArrowUp") {
128
+ e.preventDefault();
129
+ setActive(Math.max(activeIndex - 1, 0));
130
+ } else if (e.key === "Tab" || e.key === "Enter") {
131
+ if (activeIndex >= 0) {
132
+ e.preventDefault();
133
+ completeWith($rows.eq(activeIndex).find(".fp-slash-cmd").text());
134
+ }
135
+ } else if (e.key === "Escape") {
136
+ hidePanel();
137
+ }
138
+ });
139
+
140
+ $promptBox.on("blur.slashcomplete", function () {
141
+ setTimeout(function () { hidePanel(); }, 150);
142
+ });
143
+ }
144
+
145
+ // Streams `text` into the prompt box a few characters at a time, as if
146
+ // it were being typed, then calls `onDone`. Disabled while typing so the
147
+ // user doesn't fight the animation; cancels any in-progress run first.
148
+ function typeIntoPrompt(text, onDone) {
149
+ var $promptBox = el("#fp-prompt");
150
+ if (demoTypeTimer) { clearInterval(demoTypeTimer); demoTypeTimer = null; }
151
+ if (!$promptBox.length) { if (onDone) { onDone(); } return; }
152
+ $promptBox.val("").prop("disabled", true);
153
+ var i = 0;
154
+ var CHARS_PER_TICK = 3;
155
+ demoTypeTimer = setInterval(function () {
156
+ i = Math.min(text.length, i + CHARS_PER_TICK);
157
+ $promptBox.val(text.slice(0, i));
158
+ $promptBox.scrollTop($promptBox[0].scrollHeight);
159
+ if (i >= text.length) {
160
+ clearInterval(demoTypeTimer);
161
+ demoTypeTimer = null;
162
+ $promptBox.prop("disabled", false).focus();
163
+ if (onDone) { onDone(); }
164
+ }
165
+ }, 12);
166
+ }
167
+
168
+ // Returns true if `raw` was a recognized "/command" and has been fully
169
+ // handled (compose box updated, mode/panel switched, etc.) — callers
170
+ // should NOT also dispatch it as a normal chat/generate/document/modify
171
+ // send.
172
+ function handleSlashCommand(raw) {
173
+ var trimmed = (raw || "").trim();
174
+ if (trimmed.charAt(0) !== "/") { return false; }
175
+
176
+ var command = trimmed.split(/\s+/)[0].toLowerCase();
177
+ var rest = trimmed.slice(command.length).trim();
178
+ var $promptBox = el("#fp-prompt");
179
+
180
+ switch (command) {
181
+ case "/help":
182
+ case "/?":
183
+ addMessage("assistant", HELP_TEXT);
184
+ if ($promptBox.length) { $promptBox.val(""); }
185
+ break;
186
+ case "/generate":
187
+ armExecuteAction("generate");
188
+ addMessage("assistant", "Cleared for **Generate** — describe the flow you'd like me to build, then send.");
189
+ if ($promptBox.length) { $promptBox.val(rest); }
190
+ break;
191
+ case "/build":
192
+ armExecuteAction("build");
193
+ addMessage("assistant", "Cleared for **Build** — describe the goal. I'll plan, propose a first step, " +
194
+ "and after you apply/deploy/test it, walk through fix cycles with you until it works or we hit the attempt limit.");
195
+ if ($promptBox.length) { $promptBox.val(rest); }
196
+ break;
197
+ case "/document":
198
+ armExecuteAction("document");
199
+ addMessage("assistant", "Cleared for **Document** — select the node(s) you want explained, then send.");
200
+ if ($promptBox.length) { $promptBox.val(rest); }
201
+ break;
202
+ case "/modify":
203
+ armExecuteAction("modify");
204
+ addMessage("assistant", "Cleared for **Modify** — select the node(s) you want changed, describe the change, then send.");
205
+ if ($promptBox.length) { $promptBox.val(rest); }
206
+ break;
207
+ case "/query":
208
+ case "/chat":
209
+ disarmExecuteAction();
210
+ addMessage("assistant", "Back to **Query** — ask away.");
211
+ if ($promptBox.length) { $promptBox.val(rest); }
212
+ break;
213
+ case "/clear":
214
+ clearChat();
215
+ if ($promptBox.length) { $promptBox.val(""); }
216
+ break;
217
+ case "/history":
218
+ showHistory();
219
+ if ($promptBox.length) { $promptBox.val(""); }
220
+ break;
221
+ case "/settings":
222
+ showSettings();
223
+ if ($promptBox.length) { $promptBox.val(""); }
224
+ break;
225
+ case "/demo":
226
+ armExecuteAction("generate");
227
+ addMessage("assistant", "Cleared for **Generate** — loading a demo request into the compose box.");
228
+ typeIntoPrompt(DEMO_PROMPT, function () {
229
+ el("#fp-send").addClass("fp-send-breathe");
230
+ });
231
+ break;
232
+ case "/feedback":
233
+ addMessage("assistant", FEEDBACK_TEXT);
234
+ if ($promptBox.length) { $promptBox.val(""); }
235
+ break;
236
+ case "/refresh":
237
+ refreshView();
238
+ if ($promptBox.length) { $promptBox.val(""); }
239
+ break;
240
+ // Deterministic, no LLM round-trip: just invokes Node-RED's own
241
+ // native "show/hide selected node labels" action (RED.actions
242
+ // "core:show-selected-node-labels" / "core:hide-selected-node-
243
+ // labels", confirmed present in both NR4 and NR5 — the same
244
+ // action a right-click context menu item triggers). Reusing it
245
+ // outright means group-member expansion, no-op skipping, and
246
+ // batching every affected node into ONE compound undo step are
247
+ // already handled correctly — nothing to reimplement.
248
+ case "/compact":
249
+ case "/expand":
250
+ // These two need a live RED.view selection + RED.actions.invoke
251
+ // — dead in the pop-out's disconnected window. Relay the raw
252
+ // command to the parent and let it run this exact same case
253
+ // for real, instead of duplicating the selection-check/invoke
254
+ // logic here.
255
+ if (isPopoutContext) {
256
+ if (window.opener && !window.opener.closed) {
257
+ try { window.opener.postMessage({ event: "runSlashCommand", command: command }, location.origin); } catch (e) { /* ignore */ }
258
+ }
259
+ if ($promptBox.length) { $promptBox.val(""); }
260
+ break;
261
+ }
262
+ var sel = (RED.view && RED.view.selection) ? RED.view.selection() : null;
263
+ var selCount = (sel && sel.nodes) ? sel.nodes.length : 0;
264
+ if (selCount === 0) {
265
+ addMessage("error", "Select one or more nodes first.");
266
+ if ($promptBox.length) { $promptBox.val(""); }
267
+ break;
268
+ }
269
+ if (command === "/expand") {
270
+ RED.actions.invoke("core:show-selected-node-labels");
271
+ addMessage("assistant", "Touchdown — expanded " + selCount +
272
+ " node label" + (selCount === 1 ? "" : "s") + ". Ctrl+Z to undo.");
273
+ } else {
274
+ RED.actions.invoke("core:hide-selected-node-labels");
275
+ addMessage("assistant", "Touchdown — compacted " + selCount +
276
+ " node label" + (selCount === 1 ? "" : "s") + ". Ctrl+Z to undo.");
277
+ }
278
+ if ($promptBox.length) { $promptBox.val(""); }
279
+ break;
280
+ // Same deterministic pattern as /compact+/expand above, just
281
+ // toggling the "d" (disabled) flag instead of label visibility —
282
+ // Node-RED's own native "core:enable-selected-nodes"/
283
+ // "core:disable-selected-nodes" actions (confirmed present in
284
+ // both NR4 and NR5, the same actions the right-click context menu
285
+ // uses) already batch every affected node into one compound undo
286
+ // step and skip no-ops, so there's nothing to reimplement here.
287
+ case "/disable":
288
+ case "/enable":
289
+ if (isPopoutContext) {
290
+ if (window.opener && !window.opener.closed) {
291
+ try { window.opener.postMessage({ event: "runSlashCommand", command: command }, location.origin); } catch (e) { /* ignore */ }
292
+ }
293
+ if ($promptBox.length) { $promptBox.val(""); }
294
+ break;
295
+ }
296
+ var dSel = (RED.view && RED.view.selection) ? RED.view.selection() : null;
297
+ var dSelCount = (dSel && dSel.nodes) ? dSel.nodes.length : 0;
298
+ if (dSelCount === 0) {
299
+ addMessage("error", "Select one or more nodes first.");
300
+ if ($promptBox.length) { $promptBox.val(""); }
301
+ break;
302
+ }
303
+ if (command === "/enable") {
304
+ RED.actions.invoke("core:enable-selected-nodes");
305
+ addMessage("assistant", "Touchdown — enabled " + dSelCount +
306
+ " node" + (dSelCount === 1 ? "" : "s") + ". Ctrl+Z to undo.");
307
+ } else {
308
+ RED.actions.invoke("core:disable-selected-nodes");
309
+ addMessage("assistant", "Touchdown — disabled " + dSelCount +
310
+ " node" + (dSelCount === 1 ? "" : "s") + ". Ctrl+Z to undo.");
311
+ }
312
+ if ($promptBox.length) { $promptBox.val(""); }
313
+ break;
314
+ default:
315
+ addMessage("assistant", "Unrecognized command `" + command + "`. Type `/help` for the full list.");
316
+ if ($promptBox.length) { $promptBox.val(""); }
317
+ break;
318
+ }
319
+
320
+ return true;
321
+ }
322
+
323
+
324
+ // ---- Tabbed review + import --------------------------------------------
325
+ // Hands validated flow JSON to Node-RED's own import mechanism — the same
326
+ // path the editor's Import menu uses (confirmed live: RED.view.importNodes
327
+ // exists, attaches the new nodes to the cursor for the user to place with
328
+ // one click, and registers the whole add as a single native undo step).
329
+ // generateIds avoids id clashes with existing nodes, matching the behavior
330
+ // already validated via manual import of a generated flow.
331
+ // onImported: optional, called only after a successful import — used by
332
+ // the /build loop to advance to the "apply" waypoint once the proposal
333
+ // actually lands. Plain Generate passes nothing, so it's a no-op there.
334
+
335
+ // Copies text to the clipboard and gives the triggering button brief
336
+ // "Copied!" feedback. Tries the modern Clipboard API first — but that
337
+ // requires a "secure context" (HTTPS or localhost), which this editor may
338
+ // not be served over — and falls back to the long-supported
339
+ // execCommand("copy") approach via a temporary off-screen textarea, which
340
+ // works regardless of context. Plain clipboard access is a generic browser
341
+ // feature, not an editor capability, so there's nothing Node-RED-specific
342
+ // to reuse here.
343
+ function copyToClipboard($btn, text) {
344
+ var original = $btn.data("fp-label");
345
+ if (original === undefined) {
346
+ original = $btn.text();
347
+ $btn.data("fp-label", original);
348
+ }
349
+
350
+ function showCopied() {
351
+ $btn.text("Copied!");
352
+ setTimeout(function () { $btn.text(original); }, 1500);
353
+ }
354
+
355
+ function legacyCopy() {
356
+ var $ta = $("<textarea>").val(text)
357
+ .css({ position: "fixed", top: "-1000px", left: "-1000px" });
358
+ $("body").append($ta);
359
+ $ta[0].select();
360
+ var ok = false;
361
+ try { ok = document.execCommand("copy"); } catch (e) { ok = false; }
362
+ $ta.remove();
363
+ if (ok) { showCopied(); }
364
+ else { addMessage("error", "Couldn't copy to clipboard."); }
365
+ }
366
+
367
+ if (navigator.clipboard && navigator.clipboard.writeText) {
368
+ navigator.clipboard.writeText(text).then(showCopied, legacyCopy);
369
+ } else {
370
+ legacyCopy();
371
+ }
372
+ }
373
+
374
+ // Tabbed Summary/JSON review for a generated flow, modeled on the look of
375
+ // Node-RED's Export dialog but built from FlowPilot's own bespoke markup
376
+ // (fp-tab) themed with the editor's --red-ui-* variables — see the handoff
377
+ // doc for why we don't reuse Node-RED's internal tab/export classes.
378
+ // Summary lists each node with type warnings flagged inline plus a
379
+ // detail box; JSON shows the raw, copyable flow. Broken wire references
380
+ // block the "Add to workspace" action (the JSON itself is malformed, not
381
+ // a normal "node not installed" situation); type warnings do not — the
382
+ // user is informed and decides whether to proceed or regenerate.
383
+ // buildGoal: present only for the /build loop's first-step proposal
384
+ // (handleBuildResult) — lets the pop-out's Apply tag carry what
385
+ // startBuildLoop needs (the original goal text) alongside the flow,
386
+ // since plain Generate/Document have no goal/loop to start.
387
+
388
+ // ---- Pop-out window (Phase 8.5 C1) ------------------------------------
389
+ // Mirrors how Node-RED 5's own Debug panel pops out (confirmed against
390
+ // @node-red/nodes/core/common/21-debug.html / debug.js): the SAME
391
+ // renderer (this whole file) loads in both windows; canvas-touching
392
+ // pieces (importGeneratedFlow et al.) stay running in the MAIN window
393
+ // always — the pop-out only ever proxies an intent back via
394
+ // postMessage, never calls RED.* itself. addMessage() and everything it
395
+ // calls runs completely unmodified in either window. Unlike NR5's own
396
+ // reference code (which uses "*" everywhere), every postMessage here
397
+ // pins targetOrigin to location.origin.
398
+ //
399
+ // Slice 1: read-only chat mirror. Slice 2: sending chat from the
400
+ // pop-out (now generalized into the "dispatchSend" intent — see
401
+ // dispatchSend() — covering every mode, not just chat). Slice 3
402
+ // (below): a plain Generate/Document review panel's "Add to
403
+ // workspace" button becomes functional in the pop-out too — Modify
404
+ // and the /build loop are NOT covered by THIS mechanism (Modify's diff
405
+ // is computed against LIVE RED.nodes state, which the pop-out doesn't
406
+ // have; the build loop's import has loop-
407
+ // state follow-up that can't be safely re-triggered from a relayed
408
+ // click) — both stay inert-HTML-only for now, known gaps.
409
+
410
+ // Relays one newly-added top-level #fp-messages child to the pop-out by
411
+ // its rendered HTML. Event handlers don't survive serialization, so the
412
+ // mirrored copy is inert (buttons/links render but do nothing) — exactly
413
+ // right for a display-only v1.
414
+ function relayAppendToPopout(html) {
415
+ if (!popoutWindow || popoutWindow.closed) { return; }
416
+ try {
417
+ popoutWindow.postMessage({ event: "appendMessage", html: html }, location.origin);
418
+ } catch (e) { /* pop-out may be navigating/closing — drop silently */ }
419
+ }
420
+
421
+ // Relays a removal (e.g. renderLoopStepper replacing the previous
422
+ // stepper, or the pending "typing" indicator being cleared) so the
423
+ // mirror doesn't accumulate stale copies of elements that get replaced
424
+ // in place rather than appended fresh.
425
+ function relayRemoveToPopout(id) {
426
+ if (!popoutWindow || popoutWindow.closed || !id) { return; }
427
+ try {
428
+ popoutWindow.postMessage({ event: "removeMessage", id: id }, location.origin);
429
+ } catch (e) { /* ignore, same as above */ }
430
+ }
431
+
432
+ // clearChat()'s el("#fp-messages").empty() removes every bubble at
433
+ // once, but the generic MutationObserver relay only relays a removal
434
+ // when the removed node has an `id` (ordinary chat bubbles don't) —
435
+ // so a bulk clear would silently NOT mirror. Explicit, dedicated event
436
+ // instead of trying to make the generic observer handle bulk removal.
437
+ function relayClearMessagesToPopout() {
438
+ if (!popoutWindow || popoutWindow.closed) { return; }
439
+ try {
440
+ popoutWindow.postMessage({ event: "clearMessages" }, location.origin);
441
+ } catch (e) { /* ignore */ }
442
+ }
443
+
444
+ // Mirrors the status-strip's live-selection-dependent pieces into the
445
+ // pop-out — sent as plain text/class values, NOT outerHTML, since
446
+ // those elements sit in the SAME .fp-status-strip as the pop-out's own
447
+ // bound Send/Clear buttons (a blind HTML swap would clobber them).
448
+ // Called from the end of updateSelectionStatus()/updateDebugStatus()
449
+ // (both parent-only in practice — only the main window has a live
450
+ // RED.view.selection() to report on). The debug line is rebuilt from
451
+ // attachedDebugMessages directly rather than read from the DOM, since
452
+ // the live element also contains a "preview" link whose text would
453
+ // otherwise bleed into the relayed string (that link's click-through
454
+ // needs live context data and isn't relayed at all — known v1 gap,
455
+ // same as the Preview JSON link below).
456
+ function relayStatusStripToPopout() {
457
+ if (!popoutWindow || popoutWindow.closed) { return; }
458
+ var debugCount = attachedDebugMessages.length;
459
+ var payload = {
460
+ selectionText: el("#fp-selection-status").text(),
461
+ hasSelection: el("#fp-selection-status").hasClass("fp-has-selection"),
462
+ previewVisible: !el("#fp-preview-nodes").hasClass("fp-hidden"),
463
+ sizeText: el("#fp-size-status").text(),
464
+ sizeHidden: el("#fp-size-status").hasClass("fp-hidden"),
465
+ sizeWarn: el("#fp-size-status").hasClass("fp-size-warn"),
466
+ sizeHigh: el("#fp-size-status").hasClass("fp-size-high"),
467
+ secretsHidden: el("#fp-secrets-status").hasClass("fp-hidden"),
468
+ secretsOff: el("#fp-secrets-status").hasClass("fp-secrets-status-off"),
469
+ secretsTitle: el("#fp-secrets-status").attr("title") || "",
470
+ debugHidden: debugCount === 0,
471
+ debugText: debugCount
472
+ ? ("🐛 " + debugCount + " debug message" + (debugCount === 1 ? "" : "s") + " attached")
473
+ : ""
474
+ };
475
+ try {
476
+ popoutWindow.postMessage({ event: "statusStripSync", data: payload }, location.origin);
477
+ } catch (e) { /* ignore */ }
478
+ }
479
+
480
+ // Started once the pop-out is open. Only watches direct children of
481
+ // #fp-messages (childList) — an existing bubble's content changing in
482
+ // place (e.g. a streaming reply filling in) is NOT relayed in v1; the
483
+ // mirror catches up once the next sibling message is added. Known,
484
+ // accepted limitation for the smallest first slice — not a regression
485
+ // for tool-capable providers, which never stream today anyway.
486
+ function startPopoutRelay() {
487
+ var box = el("#fp-messages")[0];
488
+ if (!box || popoutObserver) { return; }
489
+ popoutObserver = new MutationObserver(function (mutations) {
490
+ mutations.forEach(function (m) {
491
+ m.removedNodes.forEach(function (node) {
492
+ if (node.nodeType === 1 && node.id) { relayRemoveToPopout(node.id); }
493
+ });
494
+ m.addedNodes.forEach(function (node) {
495
+ if (node.nodeType === 1) { relayAppendToPopout(node.outerHTML); }
496
+ });
497
+ });
498
+ });
499
+ popoutObserver.observe(box, { childList: true });
500
+ }
501
+
502
+ function stopPopoutRelay() {
503
+ if (popoutObserver) { popoutObserver.disconnect(); popoutObserver = null; }
504
+ }
505
+
506
+ // Opens (or focuses, if already open — named window target) the
507
+ // detached mirror, mirroring 21-debug.html's window.open call exactly
508
+ // (same options string shape). Sends a one-time snapshot of the
509
+ // CURRENT #fp-messages content once the pop-out finishes loading, then
510
+ // starts the live relay for everything after that point.
511
+ function openPopout() {
512
+ if (popoutWindow && !popoutWindow.closed) {
513
+ popoutWindow.focus();
514
+ return;
515
+ }
516
+ popoutWindow = window.open(
517
+ document.location.toString().replace(/[?#].*$/, "") + "flowpilot/popout/view.html" + document.location.search,
518
+ "flowpilotPopout",
519
+ "menubar=no,location=no,toolbar=no,chrome,height=700,width=480"
520
+ );
521
+ if (!popoutWindow) { return; }
522
+ popoutWindow.onload = function () {
523
+ var html = el("#fp-messages").length ? el("#fp-messages").html() : "";
524
+ try {
525
+ popoutWindow.postMessage({ event: "initialSync", html: html }, location.origin);
526
+ } catch (e) { /* ignore */ }
527
+ startPopoutRelay();
528
+ };
529
+ }
530
+
531
+ // Entry point for the pop-out's own page (lib/popout/view.html, loaded
532
+ // via this SAME script). Builds the SAME cockpit shell as the main
533
+ // window's action-bar/compose/status-strip (same ids/classes, so the
534
+ // existing CSS and functions below apply unmodified) and relays any
535
+ // user-initiated close/reopen back via window.opener so the main
536
+ // window's "the pop-out is closed" state (popoutWindow.closed) stays
537
+ // accurate without polling.
538
+ //
539
+ // Full cockpit parity (2026-06-26): arming/disarming Generate/Document/
540
+ // Modify, Query intents, and every slash command except /compact+
541
+ // /expand are pure local state (setArmedExecuteAction/
542
+ // handleSlashCommand/armQueryIntent have zero RED.* calls) and work
543
+ // completely unmodified here — only dispatchSend()'s FINAL dispatch and
544
+ // /compact+/expand need to relay instead of touching dead RED.* state
545
+ // (see isPopoutContext, checked inside those two functions). Settings
546
+ // are loaded independently via the pop-out's OWN loadSettings() call
547
+ // (plain ajaxJson, zero RED.* coupling) — gives a correct Provider-
548
+ // status line and working custom Query intents for free via the
549
+ // existing fillSettings(), which jQuery no-ops harmlessly on the
550
+ // Settings-panel field ids this pop-out doesn't render this slice.
551
+ //
552
+ // conversationId/conversationHistory/attachedDebugMessages/
553
+ // activeBuildLoop stay PARENT-OWNED — this window never keeps its own
554
+ // copies. Sending (any mode) and Clear Chat are relayed asks; the
555
+ // selection-status strip is a relayed MIRROR (relayStatusStripToPopout,
556
+ // called from the parent's updateSelectionStatus/updateDebugStatus).
557
+
558
+ // Phase 10 0B: all review panels carry data-fp-record-id instead of the
559
+ // old per-kind data-fp-apply-* attribute family. The pop-out posts a
560
+ // recordId to the parent, which looks up the live record and applies from
561
+ // stored payload — no giant JSON blob in the DOM attribute, no separate
562
+ // bind function per review kind.
563
+ function bindReviewApplyButtons($scope) {
564
+ $scope.filter("[data-fp-record-id]").add($scope.find("[data-fp-record-id]")).each(function () {
565
+ var $panel = $(this);
566
+ if ($panel.data("fp-review-apply-bound")) { return; }
567
+ $panel.data("fp-review-apply-bound", true);
568
+ var recordId = parseInt($panel.attr("data-fp-record-id"), 10);
569
+ if (isNaN(recordId)) { return; }
570
+ $panel.find(".fp-review-actions button.red-ui-button-primary").on("click", function () {
571
+ var $btn = $(this);
572
+ if ($btn.prop("disabled")) { return; }
573
+ $btn.prop("disabled", true).text("Applying…");
574
+ if (!window.opener || window.opener.closed) { return; }
575
+ try {
576
+ window.opener.postMessage({ event: "applyByRecordId", recordId: recordId }, location.origin);
577
+ } catch (e) { /* ignore */ }
578
+ });
579
+ });
580
+ }
581
+
582
+ // The loop stepper's "Stop build loop" button is relayed the same
583
+ // generic way as any other chat message (renderLoopStepper appends/
584
+ // replaces a #fp-loop-stepper element, which the MutationObserver
585
+ // relay already mirrors via plain add/remove) — but like every other
586
+ // relayed button, it loses its click handler on the way over. Rebind
587
+ // it to ask the parent to do exactly what a local click would.
588
+ function bindStopLoopButton($scope) {
589
+ $scope.filter("#fp-loop-stepper").add($scope.find("#fp-loop-stepper")).each(function () {
590
+ var $stepper = $(this);
591
+ if ($stepper.data("fp-stop-loop-bound")) { return; }
592
+ $stepper.data("fp-stop-loop-bound", true);
593
+ $stepper.find(".fp-loop-actions button").on("click", function () {
594
+ if (!window.opener || window.opener.closed) { return; }
595
+ try {
596
+ window.opener.postMessage({ event: "stopBuildLoop" }, location.origin);
597
+ } catch (e) { /* ignore */ }
598
+ });
599
+ });
600
+ }
601
+
602
+ // The Summary/JSON tab toggle (addGeneratedReview, addModifyReview,
603
+ // and anything else using the same .fp-tabs/.fp-tab-panel pattern) is
604
+ // purely local DOM show/hide — unlike Apply, it needs no parent
605
+ // access at all, so this works for EVERY relayed review panel. Lost
606
+ // the same way Apply's original handler did (relayed HTML has no
607
+ // event listeners) — this just rebinds the toggle.
608
+ function bindTabSwitching($scope) {
609
+ $scope.filter(".fp-tabs").add($scope.find(".fp-tabs")).each(function () {
610
+ var $tabs = $(this);
611
+ if ($tabs.data("fp-tabs-bound")) { return; }
612
+ $tabs.data("fp-tabs-bound", true);
613
+ var $tabButtons = $tabs.find(".fp-tab");
614
+ var $panels = $tabs.siblings(".fp-tab-panel");
615
+ $tabButtons.each(function (i) {
616
+ $(this).on("click", function () {
617
+ $tabButtons.removeClass("fp-tab-active");
618
+ $(this).addClass("fp-tab-active");
619
+ $panels.addClass("fp-hidden");
620
+ $panels.eq(i).removeClass("fp-hidden");
621
+ });
622
+ });
623
+ });
624
+ }
625
+
626
+ // Rebinds the "Attach" buttons in a relayed debug-log panel. The
627
+ // original handlers (closures over debugMessageBuffer entries) don't
628
+ // survive outerHTML serialization — same problem Apply buttons had.
629
+ // Each button carries data-fp-debug-id; clicking relays the entry id
630
+ // to the parent, which finds it in its own debugMessageBuffer and
631
+ // pushes it to attachedDebugMessages (then calls updateDebugStatus,
632
+ // which already calls relayStatusStripToPopout to sync the counter).
633
+ function bindDebugAttachButtons($scope) {
634
+ $scope.filter("[data-fp-debug-id]").add($scope.find("[data-fp-debug-id]")).each(function () {
635
+ var $btn = $(this);
636
+ if ($btn.data("fp-debug-attach-bound")) { return; }
637
+ $btn.data("fp-debug-attach-bound", true);
638
+ var entryId = $btn.attr("data-fp-debug-id");
639
+ $btn.on("click", function () {
640
+ if (!window.opener || window.opener.closed) { return; }
641
+ $btn.prop("disabled", true).text("Attached");
642
+ try { window.opener.postMessage({ event: "attachDebug", entryId: entryId }, location.origin); } catch (e) { /* ignore */ }
643
+ });
644
+ });
645
+ }
646
+
647
+ function bindPromptResize() {
648
+ var PROMPT_MIN_HEIGHT = 88;
649
+ var PROMPT_MAX_HEIGHT = 480;
650
+ var dragStartY = null;
651
+ var dragStartHeight = null;
652
+
653
+ function onDrag(e) {
654
+ if (dragStartY === null) { return; }
655
+ var clientY = e.touches ? e.touches[0].clientY : e.clientY;
656
+ var next = dragStartHeight + (dragStartY - clientY);
657
+ next = Math.min(PROMPT_MAX_HEIGHT, Math.max(PROMPT_MIN_HEIGHT, next));
658
+ el("#fp-prompt").css("height", next + "px");
659
+ e.preventDefault();
660
+ }
661
+
662
+ function endDrag() {
663
+ dragStartY = null;
664
+ el("#fp-prompt-resize").removeClass("fp-resizing");
665
+ $(document)
666
+ .off("mousemove", onDrag).off("mouseup", endDrag)
667
+ .off("touchmove", onDrag).off("touchend", endDrag);
668
+ }
669
+
670
+ function startDrag(e) {
671
+ var clientY = e.touches ? e.touches[0].clientY : e.clientY;
672
+ dragStartY = clientY;
673
+ dragStartHeight = el("#fp-prompt")[0].offsetHeight;
674
+ el("#fp-prompt-resize").addClass("fp-resizing");
675
+ $(document)
676
+ .on("mousemove", onDrag).on("mouseup", endDrag)
677
+ .on("touchmove", onDrag).on("touchend", endDrag);
678
+ e.preventDefault();
679
+ }
680
+
681
+ el("#fp-prompt-resize").on("mousedown touchstart", startDrag);
682
+ }
683
+
684
+ // Matches Node-RED 5's own debug pop-out (debug.js) exactly: the dark/
685
+ // light preference lives in localStorage under "view-dark-theme"
686
+ // ("dark" / "auto" / anything else = light), shared with the main
687
+ // window since both are same-origin. red/style.min.css's dark-mode
688
+ // variable overrides are scoped under the SAME nr-theme-dark class the
689
+ // main editor toggles on <html> — without this, the pop-out always
690
+ // rendered light regardless of the editor's actual theme.
691
+ function applyPopoutTheme() {
692
+ var themeVariant = localStorage.getItem("view-dark-theme");
693
+ var isDark = false;
694
+ if (themeVariant === "dark") {
695
+ isDark = true;
696
+ } else if (themeVariant === "auto") {
697
+ isDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
698
+ }
699
+ document.documentElement.classList.toggle("nr-theme-dark", isDark);
700
+ }
701
+
702
+ function initPopout() {
703
+ isPopoutContext = true;
704
+ applyPopoutTheme();
705
+ var content = $(
706
+ '<div id="fp-root">' +
707
+ ' <div class="fp-header">' +
708
+ ' <div class="fp-header-row">' +
709
+ ' <div class="fp-logo">FP</div>' +
710
+ ' <div class="fp-heading">' +
711
+ ' <div class="fp-title">FlowPilot</div>' +
712
+ ' <div class="fp-subtitle">AI flow assistant</div>' +
713
+ ' </div>' +
714
+ ' <div class="fp-view-buttons">' +
715
+ ' <button id="fp-clear-chat" class="red-ui-button red-ui-button-small" type="button" title="Clear chat and start a fresh conversation (resets memory)"><i class="fa fa-eraser"></i></button>' +
716
+ ' <button id="fp-recall" class="red-ui-button red-ui-button-small" type="button" title="Recall: search earlier conversations for the text in the prompt box"><i class="fa fa-search"></i></button>' +
717
+ ' <button id="fp-debug-log" class="red-ui-button red-ui-button-small" type="button" title="Debug log: view recent Debug sidebar output and attach messages as context"><i class="fa fa-bug"></i></button>' +
718
+ ' <button id="fp-show-chat" class="red-ui-button red-ui-button-small" type="button" title="Chat"><i class="fa fa-comments"></i></button>' +
719
+ ' <button id="fp-show-history" class="red-ui-button red-ui-button-small" type="button" title="Flight log — past conversations"><i class="fa fa-history"></i></button>' +
720
+ ' </div>' +
721
+ ' </div>' +
722
+ ' </div>' +
723
+ ' <div id="fp-chat-panel" class="fp-panel">' +
724
+ ' <div id="fp-messages" class="fp-messages"></div>' +
725
+ ' <div class="fp-compose">' +
726
+ ' <div class="fp-action-bar">' +
727
+ ' <div class="fp-action-group">' +
728
+ ' <div id="fp-intents" class="fp-intents fp-intents-query"></div>' +
729
+ ' </div>' +
730
+ ' <div class="fp-action-divider"></div>' +
731
+ ' <div class="fp-action-group">' +
732
+ ' <div class="fp-intents fp-intents-execute">' +
733
+ ' <button id="fp-document" class="red-ui-button red-ui-button-small fp-icon-btn fp-icon-btn-execute" type="button" title="Document — select nodes, optionally add notes, then hit Send to generate a comment-node explanation"><i class="fa fa-file-text-o"></i></button>' +
734
+ ' <button id="fp-generate" class="red-ui-button red-ui-button-small fp-icon-btn fp-icon-btn-execute" type="button" title="Generate — describe a flow, then hit Send to draft it"><i class="fa fa-magic"></i></button>' +
735
+ ' <button id="fp-modify" class="red-ui-button red-ui-button-small fp-icon-btn fp-icon-btn-execute" type="button" title="Modify — select node(s) in the MAIN window, describe the change, then hit Send"><i class="fa fa-pencil"></i></button>' +
736
+ ' </div>' +
737
+ ' </div>' +
738
+ ' </div>' +
739
+ ' <div class="fp-prompt-wrap">' +
740
+ ' <textarea id="fp-prompt" placeholder="Select nodes in the main window for context, or just type a question…"></textarea>' +
741
+ ' <div id="fp-prompt-resize" class="fp-resize-handle" title="Drag to resize"><i class="fa fa-arrows-v"></i></div>' +
742
+ ' </div>' +
743
+ ' <div class="fp-status-strip">' +
744
+ ' <span id="fp-selection-status" class="fp-selection-status">No nodes selected</span>' +
745
+ ' <a href="#" id="fp-preview-nodes" class="fp-preview-link fp-hidden" title="Open this from the main window to see the exact sanitized node JSON">Preview JSON</a>' +
746
+ ' <span id="fp-size-status" class="fp-size-status fp-hidden"></span>' +
747
+ ' <span id="fp-secrets-status" class="fp-secrets-status fp-hidden" title="Context may include node config and code. Don\'t send credentials or proprietary data. Local/private AI recommended.">⚠</span>' +
748
+ ' <span id="fp-debug-status" class="fp-debug-status fp-hidden"></span>' +
749
+ ' <a href="#" id="fp-debug-clear" class="fp-hidden" title="Remove all attached debug messages">✕</a>' +
750
+ ' <span class="fp-status-spacer"></span>' +
751
+ ' <div id="fp-provider-status">Provider: not loaded</div>' +
752
+ ' <button id="fp-clear-prompt" class="red-ui-button" type="button" title="Clear prompt box">Clear</button>' +
753
+ ' <button id="fp-send" class="red-ui-button red-ui-button-primary" type="button">Send</button>' +
754
+ ' </div>' +
755
+ ' </div>' +
756
+ ' </div>' +
757
+ ' <div id="fp-history-panel" class="fp-panel fp-hidden">' +
758
+ ' <div class="fp-form">' +
759
+ ' <div class="fp-settings-section">Flight log — past conversations</div>' +
760
+ ' <div class="fp-consent-hint">Click a conversation to load it back into Chat — ' +
761
+ ' new messages continue that conversation\'s memory. Deleting a conversation ' +
762
+ ' removes its saved transcript permanently.</div>' +
763
+ ' <div class="fp-settings-actions">' +
764
+ ' <button id="fp-history-delete-all" class="red-ui-button red-ui-button-small" type="button" title="Delete all saved conversation transcripts permanently"><i class="fa fa-trash"></i> Delete all</button>' +
765
+ ' </div>' +
766
+ ' <div id="fp-history-list" class="fp-history-list"></div>' +
767
+ ' </div>' +
768
+ ' </div>' +
769
+ '</div>'
770
+ );
771
+ $("#fp-popout-root").append(content);
772
+ $root = content;
773
+
774
+ // Arming/disarming/Send dispatch reuse the EXISTING functions
775
+ // verbatim — see the comment above this function for why that's
776
+ // safe (pure local state except dispatchSend's final step, which
777
+ // checks isPopoutContext itself).
778
+ el("#fp-generate").on("click", function () { setArmedExecuteAction("generate"); });
779
+ el("#fp-document").on("click", function () { setArmedExecuteAction("document"); });
780
+ el("#fp-modify").on("click", function () { setArmedExecuteAction("modify"); });
781
+ el("#fp-send").on("click", function () { dispatchSend(); });
782
+ el("#fp-prompt").on("keydown", function (e) {
783
+ if (e.key === "Enter" && !e.shiftKey) {
784
+ e.preventDefault();
785
+ dispatchSend();
786
+ }
787
+ });
788
+ el("#fp-clear-prompt").on("click", function () {
789
+ el("#fp-prompt").val("").focus();
790
+ });
791
+ // Clear Chat resets PARENT-owned conversationId/conversationHistory/
792
+ // activeBuildLoop — always relayed, never run locally (this window
793
+ // keeps no conversation state of its own to reset).
794
+ el("#fp-clear-chat").on("click", function () {
795
+ if (window.opener && !window.opener.closed) {
796
+ try { window.opener.postMessage({ event: "clearChat" }, location.origin); } catch (e) { /* ignore */ }
797
+ }
798
+ });
799
+ el("#fp-show-chat").on("click", showChat);
800
+ el("#fp-show-history").on("click", showHistory);
801
+ el("#fp-history-delete-all").on("click", deleteAllConversations);
802
+ el("#fp-recall").on("click", recallSearch);
803
+ el("#fp-debug-log").on("click", function () {
804
+ if (window.opener && !window.opener.closed) {
805
+ try { window.opener.postMessage({ event: "requestDebugBuffer" }, location.origin); } catch (e) { /* ignore */ }
806
+ }
807
+ });
808
+ el("#fp-debug-clear").on("click", function (ev) {
809
+ ev.preventDefault();
810
+ if (window.opener && !window.opener.closed) {
811
+ try { window.opener.postMessage({ event: "clearAttachedDebug" }, location.origin); } catch (e) { /* ignore */ }
812
+ }
813
+ });
814
+ el("#fp-preview-nodes").on("click", function (ev) {
815
+ ev.preventDefault();
816
+ addMessage("error", "Preview JSON isn't available in the pop-out yet — open it from the main window.");
817
+ });
818
+ bindPromptResize();
819
+ bindSlashAutocomplete(el("#fp-prompt"));
820
+
821
+ // Built-ins show immediately; loadSettings()'s fillSettings() call
822
+ // re-renders once custom intents (if any) are loaded, same
823
+ // two-step sequence initMainWindow uses.
824
+ renderIntents(el("#fp-intents"));
825
+ loadSettings();
826
+
827
+ window.addEventListener("message", function (evt) {
828
+ if (evt.origin !== location.origin) { return; }
829
+ var data = evt.data || {};
830
+ if (data.event === "initialSync") {
831
+ el("#fp-messages").html(data.html);
832
+ bindReviewApplyButtons(el("#fp-messages"));
833
+ bindStopLoopButton(el("#fp-messages"));
834
+ bindTabSwitching(el("#fp-messages"));
835
+ bindDebugAttachButtons(el("#fp-messages"));
836
+ scrollMessagesToBottom(true);
837
+ } else if (data.event === "appendMessage") {
838
+ el("#fp-messages").append(data.html);
839
+ bindReviewApplyButtons(el("#fp-messages").children().last());
840
+ bindStopLoopButton(el("#fp-messages").children().last());
841
+ bindTabSwitching(el("#fp-messages").children().last());
842
+ bindDebugAttachButtons(el("#fp-messages").children().last());
843
+ scrollMessagesToBottom();
844
+ } else if (data.event === "removeMessage") {
845
+ el("#" + data.id).remove();
846
+ } else if (data.event === "clearMessages") {
847
+ el("#fp-messages").empty();
848
+ } else if (data.event === "debugBufferSnapshot") {
849
+ showChat();
850
+ var entries = data.entries || [];
851
+ var attachedSet = {};
852
+ (data.attachedIds || []).forEach(function (id) { attachedSet[id] = true; });
853
+ if (!entries.length) {
854
+ addMessage("assistant", "No debug messages captured yet. Trigger a flow with a Debug node wired to the sidebar, then try again.");
855
+ return;
856
+ }
857
+ var $dbgMsg = $("<div>").addClass("fp-message fp-recall");
858
+ $("<div>").addClass("fp-label").text("DEBUG LOG").appendTo($dbgMsg);
859
+ $("<div>").addClass("fp-debug-warning").text("Debug payloads can contain credentials from connected " +
860
+ "systems. Common secret patterns are redacted automatically, but review before attaching.").appendTo($dbgMsg);
861
+ entries.forEach(function (entry) {
862
+ var $item = $("<div>").addClass("fp-recall-item");
863
+ var when = new Date(entry.timestamp).toLocaleTimeString();
864
+ var meta = when + " · " + entry.name + (entry.topic ? " · topic: " + entry.topic : "");
865
+ $("<div>").addClass("fp-recall-meta").text(meta).appendTo($item);
866
+ $("<div>").addClass("fp-recall-text").text(entry.previewValue).appendTo($item);
867
+ var already = !!attachedSet[entry.id];
868
+ var $btn = $("<button>").addClass("fp-recall-use red-ui-button red-ui-button-small")
869
+ .attr("type", "button").prop("disabled", already).text(already ? "Attached" : "Attach");
870
+ $btn.on("click", function () {
871
+ $btn.prop("disabled", true).text("Attached");
872
+ if (window.opener && !window.opener.closed) {
873
+ try { window.opener.postMessage({ event: "attachDebug", entryId: entry.id }, location.origin); } catch (e) { /* ignore */ }
874
+ }
875
+ });
876
+ $item.append($btn);
877
+ $dbgMsg.append($item);
878
+ });
879
+ el("#fp-messages").append($dbgMsg);
880
+ scrollMessagesToBottom();
881
+ } else if (data.event === "statusStripSync") {
882
+ var s = data.data || {};
883
+ el("#fp-selection-status").text(s.selectionText || "").toggleClass("fp-has-selection", !!s.hasSelection);
884
+ el("#fp-preview-nodes").toggleClass("fp-hidden", !s.previewVisible);
885
+ el("#fp-size-status").text(s.sizeText || "")
886
+ .toggleClass("fp-hidden", !!s.sizeHidden)
887
+ .toggleClass("fp-size-warn", !!s.sizeWarn)
888
+ .toggleClass("fp-size-high", !!s.sizeHigh);
889
+ el("#fp-secrets-status").toggleClass("fp-hidden", !!s.secretsHidden)
890
+ .toggleClass("fp-secrets-status-off", !!s.secretsOff)
891
+ .attr("title", s.secretsTitle || "");
892
+ el("#fp-debug-status").text(s.debugText || "").toggleClass("fp-hidden", !!s.debugHidden);
893
+ el("#fp-debug-clear").toggleClass("fp-hidden", !!s.debugHidden);
894
+ } else if (data.event === "recallResults") {
895
+ hidePending();
896
+ setBusy(false);
897
+ renderRecallResults(data.results || []);
898
+ } else if (data.event === "recallError") {
899
+ hidePending();
900
+ setBusy(false);
901
+ addMessage("error", data.msg || "Recall search failed.");
902
+ } else if (data.event === "conversationList") {
903
+ renderHistoryList(data.conversations || []);
904
+ } else if (data.event === "settingsLoaded") {
905
+ fillSettings(data.settings);
906
+ renderIntents(el("#fp-intents"));
907
+ updateSelectionStatus();
908
+ }
909
+ });
910
+ }
911
+
912
+ // ---- Plugin registration --------------------------------------------
913
+
914
+ function initMainWindow() {
915
+ // If onadd has already run this session, do nothing. This is the
916
+ // primary fix for the duplicate #fp-root: the second invocation
917
+ // returns before building or inserting any DOM.
918
+ if (initialised) { return; }
919
+ initialised = true;
920
+
921
+ // Belt-and-suspenders: clear any orphan left by a prior version
922
+ // before this guard existed. Harmless once the guard is in place.
923
+ try { RED.sidebar.removeTab("flowpilot"); } catch (e) {}
924
+ $("#fp-root").remove();
925
+
926
+ var content = $(
927
+ '<div id="fp-root">' +
928
+ ' <div class="fp-header">' +
929
+ ' <div class="fp-header-row">' +
930
+ ' <div class="fp-logo">FP</div>' +
931
+ ' <div class="fp-heading">' +
932
+ ' <div class="fp-title">FlowPilot</div>' +
933
+ ' <div class="fp-subtitle">AI flow assistant</div>' +
934
+ ' </div>' +
935
+ ' <div class="fp-view-buttons">' +
936
+ ' <button id="fp-clear-chat" class="red-ui-button red-ui-button-small" type="button" title="Clear chat and start a fresh conversation (resets memory)"><i class="fa fa-eraser"></i></button>' +
937
+ ' <button id="fp-recall" class="red-ui-button red-ui-button-small" type="button" title="Recall: search earlier conversations for the text in the prompt box"><i class="fa fa-search"></i></button>' +
938
+ ' <button id="fp-debug-log" class="red-ui-button red-ui-button-small" type="button" title="Debug log: view recent Debug sidebar output and attach messages as context"><i class="fa fa-bug"></i></button>' +
939
+ ' <button id="fp-show-chat" class="red-ui-button red-ui-button-small" type="button" title="Chat"><i class="fa fa-comments"></i></button>' +
940
+ ' <button id="fp-show-history" class="red-ui-button red-ui-button-small" type="button" title="Flight log — past conversations"><i class="fa fa-history"></i></button>' +
941
+ ' <button id="fp-popout" class="red-ui-button red-ui-button-small" type="button" title="Open in a separate window (read-only mirror — v1)"><i class="fa fa-external-link"></i></button>' +
942
+ ' <button id="fp-show-settings" class="red-ui-button red-ui-button-small" type="button" title="Settings"><i class="fa fa-cog"></i></button>' +
943
+ ' </div>' +
944
+ ' </div>' +
945
+ ' </div>' +
946
+
947
+ ' <div id="fp-chat-panel" class="fp-panel">' +
948
+ ' <div id="fp-dev-banner" class="fp-warning">' +
949
+ ' <strong>Development/test only.</strong> ' +
950
+ ' Anything you send may leave this Node-RED instance. ' +
951
+ ' Don\'t include credentials or proprietary data; local/private AI recommended.' +
952
+ ' </div>' +
953
+ ' <div id="fp-messages" class="fp-messages"></div>' +
954
+ ' <div class="fp-compose">' +
955
+ ' <div class="fp-action-bar">' +
956
+ ' <div class="fp-action-group">' +
957
+ ' <div id="fp-intents" class="fp-intents fp-intents-query"></div>' +
958
+ ' </div>' +
959
+ ' <div class="fp-action-divider"></div>' +
960
+ ' <div class="fp-action-group">' +
961
+ ' <div class="fp-intents fp-intents-execute">' +
962
+ ' <button id="fp-document" class="red-ui-button red-ui-button-small fp-icon-btn fp-icon-btn-execute" type="button" title="Document — select nodes, optionally add notes, then hit Send to generate a comment-node explanation"><i class="fa fa-file-text-o"></i></button>' +
963
+ ' <button id="fp-generate" class="red-ui-button red-ui-button-small fp-icon-btn fp-icon-btn-execute" type="button" title="Generate — describe a flow, then hit Send to draft it"><i class="fa fa-magic"></i></button>' +
964
+ ' <button id="fp-modify" class="red-ui-button red-ui-button-small fp-icon-btn fp-icon-btn-execute" type="button" title="Modify — select node(s), describe the change, then hit Send"><i class="fa fa-pencil"></i></button>' +
965
+ ' </div>' +
966
+ ' </div>' +
967
+ ' </div>' +
968
+ ' <div class="fp-prompt-wrap">' +
969
+ ' <textarea id="fp-prompt" placeholder="Select nodes for context, or just type a question…"></textarea>' +
970
+ ' <div id="fp-prompt-resize" class="fp-resize-handle" title="Drag to resize"><i class="fa fa-arrows-v"></i></div>' +
971
+ ' </div>' +
972
+ ' <div class="fp-status-strip">' +
973
+ ' <span id="fp-selection-status" class="fp-selection-status">No nodes selected</span>' +
974
+ ' <a href="#" id="fp-preview-nodes" class="fp-preview-link fp-hidden" title="Show the exact sanitized node JSON that will be sent">Preview JSON</a>' +
975
+ ' <span id="fp-size-status" class="fp-size-status fp-hidden"></span>' +
976
+ ' <span id="fp-secrets-status" class="fp-secrets-status fp-hidden" title="Context may include node config and code. Don\'t send credentials or proprietary data. Local/private AI recommended.">⚠</span>' +
977
+ ' <span id="fp-debug-status" class="fp-debug-status fp-hidden"></span>' +
978
+ ' <span class="fp-status-spacer"></span>' +
979
+ ' <div id="fp-provider-status">Provider: not loaded</div>' +
980
+ ' <button id="fp-clear-prompt" class="red-ui-button" type="button" title="Clear prompt box">Clear</button>' +
981
+ ' <button id="fp-send" class="red-ui-button red-ui-button-primary" type="button">Send</button>' +
982
+ ' </div>' +
983
+ ' </div>' +
984
+ ' </div>' +
985
+
986
+ ' <div id="fp-settings-panel" class="fp-panel fp-hidden">' +
987
+ ' <div class="fp-form">' +
988
+
989
+ ' <details class="fp-settings-group" open>' +
990
+ ' <summary title="Hangar — where your AI providers are configured">Providers</summary>' +
991
+ ' <div class="fp-warning">' +
992
+ ' <strong>Provider settings.</strong><br>' +
993
+ ' Stored locally under the Node-RED user directory in <code>flowpilot/settings.json</code>.' +
994
+ ' </div>' +
995
+ ' <label>Active provider</label>' +
996
+ ' <select id="fp-provider-select"></select>' +
997
+ ' <div class="fp-provider-actions">' +
998
+ ' <button id="fp-add-provider" class="red-ui-button red-ui-button-small" type="button">+ Add</button>' +
999
+ ' <button id="fp-remove-provider" class="red-ui-button red-ui-button-small" type="button">Remove</button>' +
1000
+ ' </div>' +
1001
+ ' <label>Provider Name</label>' +
1002
+ ' <input id="fp-provider-name" type="text" placeholder="LocalAI">' +
1003
+ ' <label>Base URL</label>' +
1004
+ ' <input id="fp-base-url" type="text" placeholder="http://localhost:8080">' +
1005
+ ' <label>API Key</label>' +
1006
+ ' <input id="fp-api-key" type="password" placeholder="Optional">' +
1007
+ ' <label>Model</label>' +
1008
+ ' <input id="fp-model" type="text" list="fp-model-options" placeholder="Model name for this provider">' +
1009
+ ' <datalist id="fp-model-options"></datalist>' +
1010
+ ' <div id="fp-models-hint" class="fp-consent-hint fp-hidden"></div>' +
1011
+ ' <label>Temperature</label>' +
1012
+ ' <input id="fp-temperature" type="number" min="0" max="2" step="0.1" placeholder="0.2">' +
1013
+ ' <div class="fp-consent-hint">Controls randomness. Lower (e.g. 0.2) is more ' +
1014
+ ' focused and consistent; higher is more creative and varied. 0.2 is a good ' +
1015
+ ' default for flow generation.</div>' +
1016
+ ' <div class="fp-settings-actions">' +
1017
+ ' <button id="fp-test-provider" class="red-ui-button" type="button" title="Pre-flight check — save and send a quick test to this provider">Pre-flight check</button>' +
1018
+ ' <button id="fp-refresh-models" class="red-ui-button" type="button" title="Save settings, then fetch this provider\'s model list via GET /v1/models">Refresh models</button>' +
1019
+ ' </div>' +
1020
+ ' </details>' +
1021
+
1022
+ ' <details class="fp-settings-group">' +
1023
+ ' <summary>Behavior</summary>' +
1024
+ ' <div class="fp-settings-section">System Prompt</div>' +
1025
+ ' <textarea id="fp-system-prompt"></textarea>' +
1026
+ ' <div class="fp-settings-actions">' +
1027
+ ' <button id="fp-reset-system-prompt" class="red-ui-button" type="button" title="Replace this text with FlowPilot\'s current built-in default — useful after an update adds new instructions">Reset to default</button>' +
1028
+ ' </div>' +
1029
+
1030
+ ' <div class="fp-settings-section">Personality</div>' +
1031
+ ' <label>Persona intensity: <span id="fp-persona-value">3</span>/10</label>' +
1032
+ ' <input id="fp-persona-intensity" type="range" min="1" max="10" step="1">' +
1033
+ ' <div id="fp-persona-label" class="fp-consent-hint"></div>' +
1034
+ ' <div class="fp-consent-hint">Chat only. Scales the AI\'s voice at greetings, ' +
1035
+ ' capability questions, and brief transitions — 1 is a plain Node-RED engineer, ' +
1036
+ ' 10 is a comically over-the-top airline captain who happens to be a Node-RED ' +
1037
+ ' expert. Explanations, troubleshooting, and errors always stay plain regardless ' +
1038
+ ' of this setting. Generate, Document, and Modify are unaffected.</div>' +
1039
+
1040
+ ' <div class="fp-settings-section">Conversation memory</div>' +
1041
+ ' <label>Remember last N exchanges</label>' +
1042
+ ' <input id="fp-history-max" type="number" min="0" step="1" placeholder="10">' +
1043
+ ' <div class="fp-consent-hint">How much of the visible chat is sent back to the ' +
1044
+ ' AI as conversation history with each request (0 = no memory). Older messages ' +
1045
+ ' drop off and the AI is told when that happens. Clear chat (eraser icon) ' +
1046
+ ' resets this entirely.</div>' +
1047
+ ' <label class="fp-checkbox-row">' +
1048
+ ' <input id="fp-streaming-enabled" type="checkbox"> ' +
1049
+ ' Stream chat replies as they generate' +
1050
+ ' </label>' +
1051
+ ' <div class="fp-consent-hint">Applies to every mode (Chat, Generate, ' +
1052
+ ' Document, Modify, Build) the same way — but only when the active ' +
1053
+ ' provider doesn\'t support tool/function calling. Tool-capable ' +
1054
+ ' providers always wait for the full response regardless of this ' +
1055
+ ' setting, since the read-tool loop has no streaming variant.</div>' +
1056
+
1057
+ ' <div class="fp-settings-section">Request timeout</div>' +
1058
+ ' <label>Give up after (seconds)</label>' +
1059
+ ' <input id="fp-request-timeout" type="number" min="5" step="5" placeholder="180">' +
1060
+ ' <div class="fp-consent-hint">How long to wait for a provider response before ' +
1061
+ ' giving up. Raise this if you\'re running a large local model on slow hardware ' +
1062
+ ' (e.g. Ollama without a GPU) and seeing timeout errors.</div>' +
1063
+
1064
+ ' <div class="fp-settings-section">Agentic build loop</div>' +
1065
+ ' <label>Max build/fix attempts</label>' +
1066
+ ' <input id="fp-agent-loop-max-iterations" type="number" min="1" max="20" step="1" placeholder="5">' +
1067
+ ' <div class="fp-consent-hint">When using the deploy-verify loop, how many build → deploy → ' +
1068
+ ' test → fix cycles to try before stopping with an honest "couldn\'t fully verify" ' +
1069
+ ' instead of proposing another fix.</div>' +
1070
+ ' <label class="fp-checkbox-row">' +
1071
+ ' <input id="fp-loop-hold-step" type="checkbox"> ' +
1072
+ ' Hold at each loop checkpoint (wait for confirmation before AI review)' +
1073
+ ' </label>' +
1074
+ ' <div class="fp-consent-hint">When checked, the loop pauses after auto-attaching debug ' +
1075
+ ' output and shows a "Continue review / Stop" prompt before sending to the AI. ' +
1076
+ ' When unchecked (default), the loop advances automatically.</div>' +
1077
+
1078
+ ' <div class="fp-settings-section">Custom intent buttons</div>' +
1079
+ ' <div class="fp-consent-hint">Add your own one-click prompt buttons. ' +
1080
+ ' They appear next to the built-in ones above the prompt.</div>' +
1081
+ ' <div id="fp-custom-intents" class="fp-custom-intents"></div>' +
1082
+ ' <label>New button label</label>' +
1083
+ ' <input id="fp-new-intent-label" type="text" placeholder="e.g. Security review">' +
1084
+ ' <label>Instruction text</label>' +
1085
+ ' <textarea id="fp-new-intent-text" placeholder="What should this button ask the AI to do?"></textarea>' +
1086
+ ' <div class="fp-settings-actions">' +
1087
+ ' <button id="fp-add-intent" class="red-ui-button" type="button">Add button</button>' +
1088
+ ' </div>' +
1089
+ ' </details>' +
1090
+
1091
+ ' <details class="fp-settings-group">' +
1092
+ ' <summary>Context &amp; Safety</summary>' +
1093
+ ' <div class="fp-settings-section">Context size warnings</div>' +
1094
+ ' <label>Warn above (estimated tokens)</label>' +
1095
+ ' <input id="fp-warn-tokens" type="number" min="0" step="500" placeholder="4000">' +
1096
+ ' <label>Strong warning above (estimated tokens)</label>' +
1097
+ ' <input id="fp-high-tokens" type="number" min="0" step="500" placeholder="8000">' +
1098
+
1099
+ ' <div class="fp-settings-section">Risk warnings</div>' +
1100
+ ' <label class="fp-checkbox-row">' +
1101
+ ' <input id="fp-suppress-warnings" type="checkbox"> ' +
1102
+ ' Hide the recurring credentials/size warning bar' +
1103
+ ' </label>' +
1104
+ ' <div class="fp-consent-hint">To hide the warning, check the box and type ' +
1105
+ ' <strong>I understand the risk</strong> below. ' +
1106
+ ' Anything you send may leave this Node-RED instance.</div>' +
1107
+ ' <input id="fp-suppress-confirm" type="text" placeholder="Type: I understand the risk">' +
1108
+
1109
+ ' <div class="fp-settings-section">Redaction</div>' +
1110
+ ' <label class="fp-checkbox-row">' +
1111
+ ' <input id="fp-redaction-disabled" type="checkbox"> ' +
1112
+ ' Disable secret-shaped-value redaction' +
1113
+ ' </label>' +
1114
+ ' <div class="fp-consent-hint">By default, values that look like secrets ' +
1115
+ ' (passwords, tokens, API keys) are replaced with a placeholder before sending. ' +
1116
+ ' This opt-out is intended for environments using local or private AIs. Debug ' +
1117
+ ' nodes and context may share confidential data, including secret keys, and ' +
1118
+ ' credentials. Use at your own risk! Node-RED\'s own credential store is never ' +
1119
+ ' sent either way. To disable, check the box and type ' +
1120
+ ' <strong>disable redaction</strong> below.</div>' +
1121
+ ' <input id="fp-redaction-confirm" type="text" placeholder="Type: disable redaction">' +
1122
+ ' </details>' +
1123
+
1124
+ ' <div class="fp-settings-actions">' +
1125
+ ' <button id="fp-save-settings" class="red-ui-button red-ui-button-primary" type="button">Save settings</button>' +
1126
+ ' <span id="fp-save-status" class="fp-save-status fp-hidden"></span>' +
1127
+ ' </div>' +
1128
+ ' </div>' +
1129
+ ' </div>' +
1130
+
1131
+ ' <div id="fp-history-panel" class="fp-panel fp-hidden">' +
1132
+ ' <div class="fp-form">' +
1133
+ ' <div class="fp-settings-section">Flight log — past conversations</div>' +
1134
+ ' <div class="fp-consent-hint">Click a conversation to load it back into Chat — ' +
1135
+ ' new messages continue that conversation\'s memory. Deleting a conversation ' +
1136
+ ' removes its saved transcript permanently.</div>' +
1137
+ ' <div class="fp-settings-actions">' +
1138
+ ' <button id="fp-history-delete-all" class="red-ui-button red-ui-button-small" type="button" title="Delete all saved conversation transcripts permanently"><i class="fa fa-trash"></i> Delete all</button>' +
1139
+ ' </div>' +
1140
+ ' <div id="fp-history-list" class="fp-history-list"></div>' +
1141
+ ' </div>' +
1142
+ ' </div>' +
1143
+ '</div>'
1144
+ );
1145
+
1146
+ // Keep the closure reference to the *inserted* content.
1147
+ $root = content;
1148
+
1149
+ // ---- Settings accordion: only one section expanded at a time.
1150
+ // Bind once on the parent (event delegation) rather than per-summary.
1151
+ content.find(".fp-settings-group > summary").on("click", function () {
1152
+ // Close all other details elements
1153
+ content.find(".fp-settings-group").not($(this).closest(".fp-settings-group")).removeAttr("open");
1154
+ });
1155
+
1156
+ // ---- Bind events here, after the DOM exists. No inline onclick.
1157
+ content.find("#fp-show-chat").on("click", showChat);
1158
+ content.find("#fp-show-settings").on("click", showSettings);
1159
+ content.find("#fp-show-history").on("click", showHistory);
1160
+ content.find("#fp-popout").on("click", openPopout);
1161
+ content.find("#fp-history-delete-all").on("click", deleteAllConversations);
1162
+ content.find("#fp-clear-chat").on("click", clearChat);
1163
+ content.find("#fp-recall").on("click", recallSearch);
1164
+ content.find("#fp-debug-log").on("click", showDebugMessages);
1165
+ content.find("#fp-preview-nodes").on("click", function (ev) {
1166
+ ev.preventDefault();
1167
+ showJsonPreview("Node JSON preview — exactly what will be sent", resolveCurrentSelectionContext());
1168
+ });
1169
+
1170
+ // Subscribe once to the same RED.comms "debug" topic the built-in
1171
+ // Debug sidebar uses, to buffer recent messages locally for
1172
+ // optional attachment (showDebugMessages/attachDebugContext).
1173
+ // Nothing is sent to the backend until the user explicitly
1174
+ // attaches a message and sends a request.
1175
+ try { RED.comms.subscribe("debug", onDebugMessage); } catch (e) { /* comms unavailable */ }
1176
+
1177
+ // Track whether the user is scrolled to the bottom of the chat,
1178
+ // so "Cruising…"/streaming updates only auto-follow when they
1179
+ // haven't scrolled up to read earlier messages.
1180
+ content.find("#fp-messages").on("scroll", function () {
1181
+ fpChatSnappedToBottom = (this.scrollHeight - this.scrollTop - this.clientHeight) <= FP_SCROLL_SNAP_PX;
1182
+ });
1183
+
1184
+ // Delegated: code blocks are injected as raw HTML (renderMarkdown
1185
+ // -> .html()), so individual buttons never exist at bind time.
1186
+ content.find("#fp-messages").on("click", ".fp-code-copy", function () {
1187
+ var $btn = $(this);
1188
+ var $pre = el("#" + $btn.attr("data-code-id"));
1189
+ if ($pre.length) { copyToClipboard($btn, $pre.text()); }
1190
+ });
1191
+
1192
+ content.find("#fp-clear-prompt").on("click", function () {
1193
+ el("#fp-prompt").val("").focus();
1194
+ });
1195
+
1196
+ content.find("#fp-send").on("click", function () {
1197
+ dispatchSend();
1198
+ });
1199
+ content.find("#fp-generate").on("click", function () {
1200
+ setArmedExecuteAction("generate");
1201
+ });
1202
+ content.find("#fp-document").on("click", function () {
1203
+ setArmedExecuteAction("document");
1204
+ });
1205
+ content.find("#fp-modify").on("click", function () {
1206
+ setArmedExecuteAction("modify");
1207
+ });
1208
+
1209
+ // Close the "more query actions" dropdown on any click outside it.
1210
+ // Bound once here (onadd guard above) rather than per-render.
1211
+ $(document).on("click.fpIntentMenu", function () {
1212
+ $(".fp-intent-menu").addClass("fp-hidden");
1213
+ });
1214
+
1215
+ // Provider management.
1216
+ content.find("#fp-provider-select").on("change", function () {
1217
+ switchProvider($(this).val());
1218
+ });
1219
+ content.find("#fp-add-provider").on("click", function () { addProvider(); });
1220
+ content.find("#fp-remove-provider").on("click", function () { removeProvider(); });
1221
+ content.find("#fp-test-provider").on("click", function () { testProvider(); });
1222
+ content.find("#fp-refresh-models").on("click", function () { refreshModels(); });
1223
+ content.find("#fp-reset-system-prompt").on("click", function () { resetSystemPrompt(); });
1224
+ content.find("#fp-persona-intensity").on("input", updatePersonaLabel);
1225
+
1226
+ // Re-enable Test provider live as the user types a model.
1227
+ content.find("#fp-model").on("input", function () {
1228
+ var val = ($(this).val() || "").trim();
1229
+ el("#fp-test-provider").prop("disabled", !val);
1230
+ // Mirror live edits to the provider status so the user can see
1231
+ // what model will be used before hitting Save.
1232
+ var ap = activeProvider();
1233
+ var name = (ap && ap.providerName) || "Provider";
1234
+ el("#fp-provider-status").text("Provider: " + (val ? (name + " / " + val) : name + " (no model)"));
1235
+ });
1236
+
1237
+ content.find("#fp-save-settings").on("click", function () {
1238
+ saveSettings(null, true);
1239
+ });
1240
+ content.find("#fp-add-intent").on("click", function () {
1241
+ addCustomIntent();
1242
+ });
1243
+
1244
+ // Enter-to-send (Shift+Enter for newline) on the prompt box.
1245
+ content.find("#fp-prompt").on("keydown", function (e) {
1246
+ if (e.key === "Enter" && !e.shiftKey) {
1247
+ e.preventDefault();
1248
+ dispatchSend();
1249
+ }
1250
+ });
1251
+
1252
+ // /demo's "breathe" cue on Send is meant to draw the eye right
1253
+ // after the prompt is filled in — once the user starts editing
1254
+ // it themselves, the nudge has done its job.
1255
+ content.find("#fp-prompt").on("input", function () {
1256
+ el("#fp-send").removeClass("fp-send-breathe");
1257
+ });
1258
+
1259
+ bindPromptResize();
1260
+ bindSlashAutocomplete(el("#fp-prompt"));
1261
+
1262
+ // Live selection indicator. RED.events fires this whenever the
1263
+ // user changes what's selected on the canvas. While armed, a
1264
+ // new non-empty selection refreshes the pinned context (an empty
1265
+ // selection does NOT clear it — that's what lets follow-ups skip
1266
+ // reselection).
1267
+ RED.events.on("view:selection-changed", function () {
1268
+ if (armedExecuteAction) { pinCurrentSelection(); }
1269
+ updateSelectionStatus();
1270
+ });
1271
+
1272
+ // /build loop: "deploy" only fires on a SUCCESSFUL deploy (no
1273
+ // native "deploy failed" event exists — a failed deploy just
1274
+ // shows the user a notification, nothing programmatic), so a
1275
+ // failed deploy simply leaves the loop waiting at "apply"
1276
+ // rather than advancing — exactly what we want.
1277
+ RED.events.on("deploy", function () {
1278
+ if (activeBuildLoop && activeBuildLoop.waypoint === "apply") {
1279
+ activeBuildLoop.waypoint = "attach";
1280
+ renderLoopStepper(activeBuildLoop);
1281
+ // Start a timer so flows with no debug nodes (e.g. HTTP
1282
+ // endpoints) don't leave the loop stuck silently waiting.
1283
+ buildLoopNoDebugTimer = setTimeout(function () {
1284
+ buildLoopNoDebugTimer = null;
1285
+ if (!activeBuildLoop || activeBuildLoop.waypoint !== "attach") { return; }
1286
+ addMessage("assistant",
1287
+ "No debug output detected yet. If this flow doesn't produce " +
1288
+ "automatic debug output (e.g. it's an HTTP endpoint), trigger " +
1289
+ "it and paste the response here — or describe what happened " +
1290
+ "and I'll review from that.");
1291
+ }, BUILD_LOOP_NO_DEBUG_TIMEOUT_MS);
1292
+ }
1293
+ });
1294
+
1295
+ // Pop-out: closing/refreshing the main editor also closes the
1296
+ // detached mirror, so it can never be left open and orphaned —
1297
+ // same as 21-debug.html's beforeunload handling.
1298
+ $(window).on("beforeunload", function () {
1299
+ if (popoutWindow) {
1300
+ try { popoutWindow.close(); } catch (e) { /* ignore */ }
1301
+ }
1302
+ });
1303
+
1304
+ // Pop-out child->parent intents: full Send dispatch
1305
+ // ("dispatchSend" — mode + prompt text, the pop-out's own
1306
+ // dispatchSend() relays here instead of calling
1307
+ // generate/document/modify/build/chat locally, since only the
1308
+ // main window has live RED.* context), "/compact"+"/expand"
1309
+ // ("runSlashCommand" — same reason), "import this already-
1310
+ // reviewed Generate/Document flow", "apply this already-
1311
+ // reviewed Modify diff", the /build loop's own apply/fix/stop
1312
+ // intents, and "clear the real conversation" (Clear Chat).
1313
+ // None of these run anything in the pop-out's own window — all
1314
+ // just ask the main window to do exactly what the equivalent
1315
+ // sidebar action would. Replies/confirmations reach the
1316
+ // pop-out via the existing #fp-messages relay, same as any
1317
+ // other new message.
1318
+ window.addEventListener("message", function (evt) {
1319
+ if (evt.origin !== location.origin) { return; }
1320
+ if (evt.source !== popoutWindow) { return; }
1321
+ var data = evt.data || {};
1322
+ if (data.event === "dispatchSend" && data.prompt) {
1323
+ el("#fp-prompt").val(data.prompt);
1324
+ if (data.mode === "generate") { generate(); }
1325
+ else if (data.mode === "build") { buildFlow(); }
1326
+ else if (data.mode === "document") { documentFlow(); }
1327
+ else if (data.mode === "modify") { modifyFlow(); }
1328
+ else { send("chat"); }
1329
+ } else if (data.event === "runSlashCommand" && data.command) {
1330
+ handleSlashCommand(data.command);
1331
+ } else if (data.event === "applyByRecordId" && typeof data.recordId === "number") {
1332
+ var rec = null;
1333
+ for (var ri = 0; ri < messageRecords.length; ri++) {
1334
+ if (messageRecords[ri].id === data.recordId) { rec = messageRecords[ri]; break; }
1335
+ }
1336
+ if (rec && rec.kind === "review" && rec.state !== "applied") {
1337
+ var d0 = rec.sharedApplyData || {};
1338
+ var nd0 = Array.isArray(d0.nodeDiffs) ? d0.nodeDiffs : [];
1339
+ var rn0 = Array.isArray(d0.removeNodes) ? d0.removeNodes : [];
1340
+ var nn0 = Array.isArray(d0.newNodes) ? d0.newNodes : [];
1341
+ var nw0 = Array.isArray(d0.newWires) ? d0.newWires : [];
1342
+ var ng0 = Array.isArray(d0.newGroups) ? d0.newGroups : [];
1343
+ var eids0 = Array.isArray(d0.existingNodeIds) ? d0.existingNodeIds : [];
1344
+ var idMap0 = {};
1345
+ if (rec.subkind === "generate") {
1346
+ importGeneratedFlow(rec.flow || [], null);
1347
+ } else if (rec.subkind === "build-generate") {
1348
+ importGeneratedFlow(rec.flow || [], function (importResult) {
1349
+ startBuildLoop(rec.buildGoal || "", rec.flow || [], importResult);
1350
+ });
1351
+ } else if (rec.subkind === "modify") {
1352
+ if (nn0.length) { idMap0 = applyInsertions(nn0, nw0, eids0) || {}; }
1353
+ if (d0.hasMutations) { applyModifications(nd0, rn0, null, idMap0); }
1354
+ if (ng0.length) { applyGroupChanges(ng0, idMap0); }
1355
+ } else if (rec.subkind === "build-fix") {
1356
+ if (nn0.length) { idMap0 = applyInsertions(nn0, nw0, eids0) || {}; }
1357
+ applyBuildLoopFix(nd0, rn0, idMap0, !!d0.capReached);
1358
+ }
1359
+ rec.state = "applied";
1360
+ }
1361
+ } else if (data.event === "stopBuildLoop") {
1362
+ stopBuildLoop("Build loop stopped — applied nodes remain as-is.");
1363
+ } else if (data.event === "clearChat") {
1364
+ clearChat();
1365
+ } else if (data.event === "requestDebugBuffer") {
1366
+ var snapshot = debugMessageBuffer.slice();
1367
+ var attachedIds = attachedDebugMessages.map(function (e) { return e.id; });
1368
+ try { popoutWindow.postMessage({ event: "debugBufferSnapshot", entries: snapshot, attachedIds: attachedIds }, location.origin); } catch (e) { /* ignore */ }
1369
+ } else if (data.event === "attachDebug" && data.entryId) {
1370
+ var found = null;
1371
+ for (var i = 0; i < debugMessageBuffer.length; i++) {
1372
+ if (debugMessageBuffer[i].id === data.entryId) { found = debugMessageBuffer[i]; break; }
1373
+ }
1374
+ if (found) {
1375
+ var alreadyIn = attachedDebugMessages.some(function (e) { return e.id === data.entryId; });
1376
+ if (!alreadyIn) { attachedDebugMessages.push(found); updateDebugStatus(); }
1377
+ }
1378
+ } else if (data.event === "useRecallItem") {
1379
+ if (data.user) { conversationHistory.push({ role: "user", content: String(data.user) }); }
1380
+ if (data.assistant) { conversationHistory.push({ role: "assistant", content: String(data.assistant) }); }
1381
+ updateSelectionStatus();
1382
+ } else if (data.event === "loadConversation" && data.id) {
1383
+ loadConversation(data.id);
1384
+ } else if (data.event === "requestRecallSearch" && data.query) {
1385
+ ajaxJson("POST", "flowpilot/recall", { query: data.query, conversationId: conversationId }, function (result) {
1386
+ try { popoutWindow.postMessage({ event: "recallResults", results: result.results || [] }, location.origin); } catch (e) { /* ignore */ }
1387
+ }, function (msg) {
1388
+ try { popoutWindow.postMessage({ event: "recallError", msg: msg }, location.origin); } catch (e) { /* ignore */ }
1389
+ });
1390
+ } else if (data.event === "requestConversationList") {
1391
+ ajaxJson("GET", "flowpilot/conversations", null, function (result) {
1392
+ try { popoutWindow.postMessage({ event: "conversationList", conversations: result.conversations || [] }, location.origin); } catch (e) { /* ignore */ }
1393
+ }, function () {
1394
+ try { popoutWindow.postMessage({ event: "conversationList", conversations: [] }, location.origin); } catch (e) { /* ignore */ }
1395
+ });
1396
+ } else if (data.event === "deleteConversation" && data.id) {
1397
+ ajaxJson("DELETE", "flowpilot/conversations/" + encodeURIComponent(data.id), null, function () {
1398
+ ajaxJson("GET", "flowpilot/conversations", null, function (result) {
1399
+ try { popoutWindow.postMessage({ event: "conversationList", conversations: result.conversations || [] }, location.origin); } catch (e) { /* ignore */ }
1400
+ });
1401
+ });
1402
+ } else if (data.event === "deleteAllConversations") {
1403
+ ajaxJson("DELETE", "flowpilot/conversations", null, function () {
1404
+ try { popoutWindow.postMessage({ event: "conversationList", conversations: [] }, location.origin); } catch (e) { /* ignore */ }
1405
+ });
1406
+ } else if (data.event === "clearAttachedDebug") {
1407
+ attachedDebugMessages = [];
1408
+ updateDebugStatus();
1409
+ } else if (data.event === "requestSettings") {
1410
+ ajaxJson("GET", "flowpilot/settings", null, function (result) {
1411
+ try { popoutWindow.postMessage({ event: "settingsLoaded", settings: result }, location.origin); } catch (e) { /* ignore */ }
1412
+ });
1413
+ }
1414
+ });
1415
+
1416
+ // Build intent buttons from built-in INTENTS plus any user-defined
1417
+ // customIntents (loaded from settings). Rebuilt after settings load
1418
+ // /save via renderIntents() so new custom buttons appear without a
1419
+ // reload.
1420
+ renderIntents(content.find("#fp-intents"));
1421
+
1422
+ RED.sidebar.addTab({
1423
+ id: "flowpilot",
1424
+ label: "FlowPilot",
1425
+ name: "FlowPilot",
1426
+ iconClass: "fa fa-paper-plane",
1427
+ content: content
1428
+ });
1429
+
1430
+ loadSettings();
1431
+ updateSelectionStatus();
1432
+ showChat();
1433
+ }
1434
+
1435
+ window.FlowPilotCore = { initMainWindow: initMainWindow, initPopout: initPopout };