From 268ae7076fb4ff210ffe802f6e6ce8412789777f Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 22 May 2026 14:52:44 +0300 Subject: [PATCH 1/4] feat(flows): hide internal node_key + reply_id behind Advanced disclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node key and per-button/row reply IDs are auto-generated and consumed only by the runner — exposing them in the main form read as developer leakage. Move them behind a per-node "Show advanced" toggle so the default UI shows only customer-facing fields. Power users who need stable IDs for analytics can still get them. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/flows/flow-builder.tsx | 129 ++++++++++++++++++-------- 1 file changed, 91 insertions(+), 38 deletions(-) diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx index 095d097..23e00c2 100644 --- a/src/components/flows/flow-builder.tsx +++ b/src/components/flows/flow-builder.tsx @@ -873,21 +873,16 @@ function NodeConfigForm({ onUpdateConfig: (patch: Record) => void; }) { const cfg = node.config; + // Internal identifiers (node_key, reply_id columns on buttons/list rows) + // are auto-generated and the runner is the only consumer. Hide them by + // default so the form reads as plain editing; expose under "Advanced" + // for the rare case where someone wants to lock a key for stable + // analytics or external integration. + const [showAdvanced, setShowAdvanced] = useState(false); + const hasReplyIds = + node.node_type === "send_buttons" || node.node_type === "send_list"; return (
-
- - - onUpdate({ node_key: slugify(e.target.value, node.node_key) }) - } - className="bg-slate-800" - /> -
- {node.node_type === "start" && ( )} @@ -930,6 +926,7 @@ function NodeConfigForm({ allNodes={allNodes} currentKey={node.node_key} onUpdateConfig={onUpdateConfig} + showAdvanced={showAdvanced} /> )} @@ -1008,6 +1005,44 @@ function NodeConfigForm({ complete. No config needed.

)} + +
+ + {showAdvanced && ( +
+
+ + + onUpdate({ node_key: slugify(e.target.value, node.node_key) }) + } + className="bg-slate-800 font-mono text-xs" + /> +
+ {hasReplyIds && ( +

+ Reply IDs for each option are shown inline above. They're + returned by WhatsApp when a customer taps; you usually don't + need to touch them. +

+ )} +
+ )} +
); } @@ -1025,11 +1060,13 @@ function SendButtonsForm({ allNodes, currentKey, onUpdateConfig, + showAdvanced, }: { cfg: SendButtonsCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; + showAdvanced: boolean; }) { const buttons = cfg.buttons ?? []; const updateButton = ( @@ -1077,18 +1114,25 @@ function SendButtonsForm({ {buttons.map((b, i) => (
- - updateButton(i, { - reply_id: slugify(e.target.value, `btn_${i + 1}`), - }) - } - placeholder="reply_id" - className="bg-slate-800 font-mono text-xs" - /> + {showAdvanced && ( + + updateButton(i, { + reply_id: slugify(e.target.value, `btn_${i + 1}`), + }) + } + placeholder="reply_id" + className="bg-slate-800 font-mono text-xs" + /> + )} updateButton(i, { title: e.target.value })} @@ -1152,11 +1196,13 @@ function SendListForm({ allNodes, currentKey, onUpdateConfig, + showAdvanced, }: { cfg: SendListCfg; allNodes: BuilderNode[]; currentKey: string; onUpdateConfig: (patch: Record) => void; + showAdvanced: boolean; }) { const sections = cfg.sections ?? []; const totalRows = sections.reduce((sum, s) => sum + s.rows.length, 0); @@ -1255,21 +1301,28 @@ function SendListForm({ {section.rows.map((row, rIdx) => (
- - updateRow(sIdx, rIdx, { - reply_id: slugify( - e.target.value, - `row_${rIdx + 1}`, - ), - }) - } - placeholder="reply_id" - className="bg-slate-800 font-mono text-xs" - /> + {showAdvanced && ( + + updateRow(sIdx, rIdx, { + reply_id: slugify( + e.target.value, + `row_${rIdx + 1}`, + ), + }) + } + placeholder="reply_id" + className="bg-slate-800 font-mono text-xs" + /> + )} From 5a9a69a3c5021a36153cd72d68c88117c2481cb9 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 22 May 2026 14:56:52 +0300 Subject: [PATCH 2/4] feat(flows): add multi-section support to send_list builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data model and the runner already supported multiple sections in a WhatsApp interactive list, but the builder UI exposed no way to add one — it only let you add rows within the single default section. So users couldn't build a categorized menu (Billing / Support / Sales) without editing the DB directly. Adds an "Add section" button below the sections list (capped at the WhatsApp spec's 10) and a per-section remove button (shown when more than one section exists). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/flows/flow-builder.tsx | 60 +++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx index 095d097..f67d747 100644 --- a/src/components/flows/flow-builder.tsx +++ b/src/components/flows/flow-builder.tsx @@ -1171,6 +1171,24 @@ function SendListForm({ ), }); }; + const addSection = () => + onUpdateConfig({ + sections: [ + ...sections, + { + title: "", + rows: [ + { + reply_id: `row_${totalRows + 1}`, + title: `Option ${totalRows + 1}`, + next_node_key: "", + }, + ], + }, + ], + }); + const removeSection = (sIdx: number) => + onUpdateConfig({ sections: sections.filter((_, i) => i !== sIdx) }); const updateRow = ( sIdx: number, rIdx: number, @@ -1244,14 +1262,27 @@ function SendListForm({ key={sIdx} className="mb-3 rounded-md border border-slate-800 bg-slate-800/40 p-3" > - - updateSection(sIdx, { title: e.target.value }) - } - placeholder="Section title (optional)" - className="mb-2 bg-slate-800 text-xs" - /> +
+ + updateSection(sIdx, { title: e.target.value }) + } + placeholder={`Section ${sIdx + 1} title (optional)`} + className="bg-slate-800 text-xs" + /> + {sections.length > 1 && ( + + )} +
{section.rows.map((row, rIdx) => (
))} + {/* WhatsApp's interactive-list spec caps sections at 10. Group rows + by category (Billing / Support / Sales etc.) to give customers a + scannable menu. */} + {sections.length < 10 && ( + + )}
); From 6079bf2046f1d3368f219b61933986132a798dfb Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 22 May 2026 15:01:53 +0300 Subject: [PATCH 3/4] feat(flows): show content preview in collapsed node cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flow with 8 send_message nodes used to show 8 identical-looking pills in the editor — the only way to tell them apart was to expand each one. Adds a one-line summary below the meta label when the card is collapsed: - send_message → first 80 chars of text - send_buttons → text + button titles - send_list → text + option count - collect_input → prompt + target var - condition → "var.email == 'support@…'" - set_tag → "Add tag 1f8c…" - handoff → first 80 chars of note Empty nodes (just added, no fields set) keep the old compact look so you can tell at a glance what still needs configuration. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/flows/flow-builder.tsx | 110 ++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx index 095d097..8fcb2af 100644 --- a/src/components/flows/flow-builder.tsx +++ b/src/components/flows/flow-builder.tsx @@ -173,6 +173,110 @@ function uniqueNodeKey(base: string, existing: BuilderNode[]): string { return `${base}_${i}`; } +// Short, single-line content summary used in the collapsed NodeCard +// header — lets users scan a 10-node flow without expanding every card. +// Returns null when there's nothing meaningful to show (start/end, or +// a freshly-added node with no fields filled in). +function truncate(s: string, max = 80): string { + const clean = s.replace(/\s+/g, " ").trim(); + if (clean.length <= max) return clean; + return clean.slice(0, max - 1) + "…"; +} + +function summarizeNode(node: BuilderNode): string | null { + const cfg = node.config; + switch (node.node_type) { + case "start": + case "end": + return null; + case "send_message": { + const text = typeof cfg.text === "string" ? cfg.text : ""; + return text.length > 0 ? truncate(text) : null; + } + case "send_buttons": { + const text = typeof cfg.text === "string" ? cfg.text : ""; + const buttons = Array.isArray(cfg.buttons) + ? (cfg.buttons as Array>) + : []; + const titles = buttons + .map((b) => (typeof b.title === "string" ? b.title : "")) + .filter(Boolean) + .join(" / "); + if (text.length > 0) { + return titles ? `${truncate(text, 40)} · ${truncate(titles, 35)}` : truncate(text); + } + return titles || null; + } + case "send_list": { + const text = typeof cfg.text === "string" ? cfg.text : ""; + const sections = Array.isArray(cfg.sections) + ? (cfg.sections as Array>) + : []; + const rowCount = sections.reduce((sum, s) => { + const rows = Array.isArray(s.rows) ? s.rows : []; + return sum + rows.length; + }, 0); + if (text.length > 0) { + return rowCount > 0 + ? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? "" : "s"}` + : truncate(text); + } + return rowCount > 0 + ? `${rowCount} option${rowCount === 1 ? "" : "s"} across ${sections.length} section${sections.length === 1 ? "" : "s"}` + : null; + } + case "collect_input": { + const prompt = typeof cfg.prompt_text === "string" ? cfg.prompt_text : ""; + const varKey = typeof cfg.var_key === "string" ? cfg.var_key : ""; + if (prompt.length > 0) { + return varKey ? `${truncate(prompt, 50)} → vars.${varKey}` : truncate(prompt); + } + return varKey ? `→ vars.${varKey}` : null; + } + case "condition": { + const subjectKey = + typeof cfg.subject_key === "string" ? cfg.subject_key : ""; + if (!subjectKey) return null; + const subject = + cfg.subject === "tag" + ? "tag" + : cfg.subject === "contact_field" + ? "field" + : "var"; + const subjectStr = + subject === "tag" ? `has tag ${truncate(subjectKey, 24)}` : `${subject}.${subjectKey}`; + const op = + cfg.operator === "equals" + ? "==" + : cfg.operator === "contains" + ? "contains" + : cfg.operator === "present" + ? "exists" + : cfg.operator === "absent" + ? "missing" + : ""; + const value = typeof cfg.value === "string" ? cfg.value : ""; + const valStr = + (cfg.operator === "equals" || cfg.operator === "contains") && value + ? ` "${truncate(value, 20)}"` + : ""; + return subject === "tag" ? subjectStr : `${subjectStr} ${op}${valStr}`; + } + case "set_tag": { + const mode = cfg.mode === "remove" ? "Remove" : "Add"; + const tagId = typeof cfg.tag_id === "string" ? cfg.tag_id : ""; + // No tag name available without an async lookup here; show a + // short prefix of the UUID so users can disambiguate between + // multiple set_tag nodes at a glance. + return tagId ? `${mode} tag ${tagId.slice(0, 8)}…` : `${mode} tag (none picked)`; + } + case "handoff": { + const note = typeof cfg.note === "string" ? cfg.note : ""; + return note.length > 0 ? truncate(note) : null; + } + } +} + function defaultConfigFor(type: NodeType): Record { switch (type) { case "start": @@ -774,6 +878,7 @@ function NodeCard({ }) { const meta = NODE_META[node.node_type]; const hasError = issues.some((i) => i.severity === "error"); + const preview = summarizeNode(node); return (
)}
+ {!expanded && preview && ( +

+ {preview} +

+ )}
{hasError && ( From edbbb894f6d14b395bd52f070c6f2327b5dc0dce Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 22 May 2026 15:05:44 +0300 Subject: [PATCH 4/4] feat(flows): make validation issues clickable to jump to node The validation panel sits at the bottom of the editor. When it surfaced an error ("node X is missing next_node_key") the user had to scroll up, find that node, expand it, find the broken field. Long feedback loop. Each node-scoped IssueLine becomes a button: click it and the editor expands the offending node, scrolls it into view, and briefly rings its border so the eye lands on the right card. Trigger-scoped issues stay non-interactive (the trigger panel is already at the top of the page). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/flows/flow-builder.tsx | 116 +++++++++++++++++++++----- 1 file changed, 96 insertions(+), 20 deletions(-) diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx index 095d097..85dcb1e 100644 --- a/src/components/flows/flow-builder.tsx +++ b/src/components/flows/flow-builder.tsx @@ -15,7 +15,7 @@ * the same file as small components rather than separate modules. */ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { ArrowLeft, @@ -247,6 +247,9 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { const [expanded, setExpanded] = useState>( () => new Set(initialNodes.map((n) => n.node_key)), ); + // Used by jumpToNode() to scroll the target into view + flash its border. + const nodeRefs = useRef>(new Map()); + const [flashedKey, setFlashedKey] = useState(null); // ---- Validation ---- const issues = useMemo( @@ -425,6 +428,36 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { }); }, []); + // Jump-to-node: invoked when a user clicks an issue in the validation + // panel. Expand the offending card (so the broken field is visible), + // scroll it into the viewport, then flash its border so the eye lands + // on it. requestAnimationFrame defers the scroll until after React + // commits the expanded layout. + const jumpToNode = useCallback((key: string) => { + setExpanded((prev) => { + if (prev.has(key)) return prev; + const next = new Set(prev); + next.add(key); + return next; + }); + setFlashedKey(key); + requestAnimationFrame(() => { + const el = nodeRefs.current.get(key); + el?.scrollIntoView({ behavior: "smooth", block: "center" }); + }); + window.setTimeout(() => { + setFlashedKey((cur) => (cur === key ? null : cur)); + }, 1600); + }, []); + + const setNodeRef = useCallback( + (key: string) => (el: HTMLDivElement | null) => { + if (el) nodeRefs.current.set(key, el); + else nodeRefs.current.delete(key); + }, + [], + ); + // ---- Render ---- return (
@@ -471,6 +504,8 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { allNodes={state.nodes} expanded={expanded.has(node.node_key)} isEntry={state.entry_node_id === node.node_key} + isFlashed={flashedKey === node.node_key} + cardRef={setNodeRef(node.node_key)} issues={issues.filter( (i) => i.scope === "node" && i.node_key === node.node_key, )} @@ -486,7 +521,7 @@ export function FlowBuilder({ initialFlow, initialNodes }: FlowBuilderProps) { )} - +
); } @@ -754,6 +789,8 @@ function NodeCard({ allNodes, expanded, isEntry, + isFlashed, + cardRef, issues, onToggle, onUpdate, @@ -765,6 +802,8 @@ function NodeCard({ allNodes: BuilderNode[]; expanded: boolean; isEntry: boolean; + isFlashed: boolean; + cardRef: (el: HTMLDivElement | null) => void; issues: ValidationIssue[]; onToggle: () => void; onUpdate: (patch: Partial) => void; @@ -776,13 +815,16 @@ function NodeCard({ const hasError = issues.some((i) => i.severity === "error"); return (
); } -function IssueLine({ issue }: { issue: ValidationIssue }) { - return ( -
- - +function IssueLine({ + issue, + onJump, +}: { + issue: ValidationIssue; + onJump?: (key: string) => void; +}) { + const tone = + issue.severity === "error" ? "text-red-300" : "text-amber-300"; + const iconTone = + issue.severity === "error" ? "text-red-400" : "text-amber-400"; + const body = ( + <> + + {issue.node_key && ( {issue.node_key} @@ -1793,6 +1841,34 @@ function IssueLine({ issue }: { issue: ValidationIssue }) { )} {issue.message} + + ); + + // Only node-scoped issues can jump; trigger-scoped issues have no + // destination (the trigger panel is already at the top of the page). + if (issue.node_key && onJump) { + return ( + + ); + } + return ( +
+ {body}
); }