feat(flows): group the add-step menu by node category

The canvas and list add-node menus both listed all 10 node types in
one flat dropdown. As the type list grows — especially for forks
adding their own node types (see #312) — a flat list gets hard to
scan. Bucket the types into Messaging / Logic & data / Flow control,
driven by a new `category` field on NODE_META so a new type can't go
uncategorized, and a `groupNodeTypesByCategory` helper shared by both
menus so they stay in lockstep.

No triggers/events category: in wacrm a flow is triggered by
flow-level config (trigger_type), not a canvas node, so `start` lives
under Flow control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-29 15:21:07 +03:00
parent 39aeac8c0c
commit ce0d04ba80
No known key found for this signature in database
4 changed files with 429 additions and 299 deletions

View File

@ -1,4 +1,4 @@
"use client";
'use client';
/**
* Linear-list flow editor.
@ -17,7 +17,7 @@
* are list-only and have no canvas analogue.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CircleAlert,
Plus,
@ -25,41 +25,41 @@ import {
ChevronDown,
ChevronUp,
CornerDownRight,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
} from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { type ValidationIssue } from "@/lib/flows/validate";
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { type ValidationIssue } from '@/lib/flows/validate';
import {
NODE_META,
NodeIconChip,
groupNodeTypesByCategory,
nodeColors,
slugify,
summarizeNode,
type BuilderNode,
type NodeType,
} from "./shared";
import { NodeConfigForm } from "./forms/node-config-form";
import { NodeKeySelect } from "./forms/fields";
import { IssueLine } from "./validation-panel";
import {
useFlowEditor,
type BuilderState,
} from "./flow-editor-state";
} from './shared';
import { NodeConfigForm } from './forms/node-config-form';
import { NodeKeySelect } from './forms/fields';
import { IssueLine } from './validation-panel';
import { useFlowEditor, type BuilderState } from './flow-editor-state';
// ============================================================
// Local state shape — mirrors the DB but the configs are typed
@ -87,7 +87,7 @@ export function FlowBuilder() {
// jump-to-node. The flash itself is read from context (flashKey)
// so canvas + list share the same source of truth.
const [expanded, setExpanded] = useState<Set<string>>(
() => new Set(state.nodes.map((n) => n.node_key)),
() => new Set(state.nodes.map((n) => n.node_key))
);
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());
@ -99,7 +99,7 @@ export function FlowBuilder() {
const key = addNodeCtx(type);
setExpanded((prev) => new Set([...prev, key]));
},
[addNodeCtx],
[addNodeCtx]
);
const removeNode = useCallback(
@ -111,7 +111,7 @@ export function FlowBuilder() {
return next;
});
},
[removeNodeCtx],
[removeNodeCtx]
);
const toggleExpanded = useCallback((key: string) => {
@ -128,7 +128,7 @@ export function FlowBuilder() {
if (el) nodeRefs.current.set(key, el);
else nodeRefs.current.delete(key);
},
[],
[]
);
// React to validator jumps via the shared flashKey. We DERIVE the
@ -147,7 +147,7 @@ export function FlowBuilder() {
// committed any expand-induced layout shift.
requestAnimationFrame(() => {
const el = nodeRefs.current.get(flashKey);
el?.scrollIntoView({ behavior: "smooth", block: "center" });
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}, [flashKey]);
@ -156,24 +156,24 @@ export function FlowBuilder() {
<TriggerPanel
state={state}
setState={setState}
triggerIssues={issues.filter((i) => i.scope === "trigger")}
triggerIssues={issues.filter((i) => i.scope === 'trigger')}
/>
<EntryPicker state={state} setState={setState} />
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-foreground">
<h2 className="text-foreground text-sm font-semibold">
Nodes ({state.nodes.length})
</h2>
<AddNodeButton onAdd={addNode} />
</div>
{state.nodes.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-card/50 p-8 text-center text-sm text-muted-foreground">
Add a <strong>Start</strong> node, then a <strong>Send buttons</strong>
{" "}node, then a <strong>Handoff</strong> — that&apos;s the welcome-menu
shape from the brief.
<div className="border-border bg-card/50 text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm">
Add a <strong>Start</strong> node, then a{' '}
<strong>Send buttons</strong> node, then a <strong>Handoff</strong>{' '}
— that&apos;s the welcome-menu shape from the brief.
</div>
) : (
state.nodes.map((node) => (
@ -186,7 +186,7 @@ export function FlowBuilder() {
isFlashed={flashKey === node.node_key}
cardRef={setNodeRef(node.node_key)}
issues={issues.filter(
(i) => i.scope === "node" && i.node_key === node.node_key,
(i) => i.scope === 'node' && i.node_key === node.node_key
)}
onToggle={() => toggleExpanded(node.node_key)}
onUpdate={(patch) => updateNode(node.node_key, patch)}
@ -203,7 +203,6 @@ export function FlowBuilder() {
);
}
// ============================================================
// Keyword trigger input
// ============================================================
@ -225,14 +224,14 @@ function KeywordsInput({
keywords: string[];
onChange: (keywords: string[]) => void;
}) {
const [draft, setDraft] = useState(keywords.join(", "));
const [draft, setDraft] = useState(keywords.join(', '));
function commit() {
const parsed = draft
.split(",")
.split(',')
.map((k) => k.trim())
.filter(Boolean);
setDraft(parsed.join(", "));
setDraft(parsed.join(', '));
onChange(parsed);
}
@ -242,7 +241,7 @@ function KeywordsInput({
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (e.key === 'Enter') {
e.preventDefault();
commit();
}
@ -267,19 +266,21 @@ function TriggerPanel({
triggerIssues: ValidationIssue[];
}) {
return (
<section className="rounded-lg border border-border bg-card p-4">
<h2 className="mb-3 text-sm font-semibold text-foreground">Trigger</h2>
<section className="border-border bg-card rounded-lg border p-4">
<h2 className="text-foreground mb-3 text-sm font-semibold">Trigger</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-muted-foreground">When…</label>
<label className="text-muted-foreground mb-1 block text-xs">
When…
</label>
<Select
value={state.trigger_type}
onValueChange={(v) =>
setState((s) => ({
...s,
trigger_type: v as BuilderState["trigger_type"],
trigger_type: v as BuilderState['trigger_type'],
trigger_config:
v === "keyword" ? { keywords: [] } : v === "manual" ? {} : {},
v === 'keyword' ? { keywords: [] } : v === 'manual' ? {} : {},
}))
}
>
@ -299,9 +300,9 @@ function TriggerPanel({
</SelectContent>
</Select>
</div>
{state.trigger_type === "keyword" && (
{state.trigger_type === 'keyword' && (
<div>
<label className="mb-1 block text-xs text-muted-foreground">
<label className="text-muted-foreground mb-1 block text-xs">
Keywords (comma-separated)
</label>
<KeywordsInput
@ -344,17 +345,15 @@ function EntryPicker({
}) {
if (state.nodes.length === 0) return null;
return (
<section className="flex items-center gap-3 rounded-lg border border-border bg-card p-3">
<CornerDownRight className="h-4 w-4 shrink-0 text-primary" />
<span className="text-xs text-muted-foreground">Entry node:</span>
<section className="border-border bg-card flex items-center gap-3 rounded-lg border p-3">
<CornerDownRight className="text-primary h-4 w-4 shrink-0" />
<span className="text-muted-foreground text-xs">Entry node:</span>
<NodeKeySelect
value={state.entry_node_id}
nodes={state.nodes}
onChange={(key) =>
setState((s) => ({ ...s, entry_node_id: key }))
}
onChange={(key) => setState((s) => ({ ...s, entry_node_id: key }))}
placeholder="Pick the first node…"
className="flex-1 max-w-xs"
className="max-w-xs flex-1"
/>
</section>
);
@ -393,20 +392,19 @@ function NodeCard({
}) {
const meta = NODE_META[node.node_type];
const c = nodeColors(node.node_type);
const hasError = issues.some((i) => i.severity === "error");
const hasError = issues.some((i) => i.severity === 'error');
const preview = summarizeNode(node);
return (
<div
ref={cardRef}
className={cn(
"relative overflow-hidden rounded-xl border bg-card transition-shadow duration-500",
'bg-card relative overflow-hidden rounded-xl border transition-shadow duration-500',
hasError
? "border-red-500/40"
? 'border-red-500/40'
: isEntry
? "border-primary/50"
: "border-border",
isFlashed &&
"ring-2 ring-primary ring-offset-2 ring-offset-background",
? 'border-primary/50'
: 'border-border',
isFlashed && 'ring-primary ring-offset-background ring-2 ring-offset-2'
)}
>
{/* type-colored left rail, ties the list row to the canvas hue */}
@ -423,25 +421,25 @@ function NodeCard({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className="truncate text-[11px] font-semibold uppercase tracking-wider"
className="truncate text-[11px] font-semibold tracking-wider uppercase"
style={{ color: c.text }}
>
{meta.label}
</span>
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
<code className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">
{node.node_key}
</code>
{isEntry && (
<Badge
variant="outline"
className="border-primary/40 bg-primary/10 text-[10px] text-primary"
className="border-primary/40 bg-primary/10 text-primary text-[10px]"
>
Entry
</Badge>
)}
</div>
{!expanded && preview && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
<p className="text-muted-foreground mt-0.5 truncate text-xs">
{preview}
</p>
)}
@ -450,20 +448,20 @@ function NodeCard({
<CircleAlert className="h-3.5 w-3.5 shrink-0 text-red-400" />
)}
{expanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
<ChevronUp className="text-muted-foreground h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
<ChevronDown className="text-muted-foreground h-4 w-4" />
)}
</button>
{expanded && (
<div className="border-t border-border px-4 py-4">
<div className="border-border border-t px-4 py-4">
<NodeConfigWithAdvanced
node={node}
allNodes={allNodes}
onUpdate={onUpdate}
onUpdateConfig={onUpdateConfig}
/>
<div className="mt-4 flex items-center justify-between border-t border-border pt-3">
<div className="border-border mt-4 flex items-center justify-between border-t pt-3">
<div className="flex items-center gap-2">
{!isEntry && (
<Button variant="ghost" size="sm" onClick={onSetEntry}>
@ -513,7 +511,7 @@ function NodeConfigWithAdvanced({
}) {
const [showAdvanced, setShowAdvanced] = useState(false);
const hasReplyIds =
node.node_type === "send_buttons" || node.node_type === "send_list";
node.node_type === 'send_buttons' || node.node_type === 'send_list';
return (
<div className="flex flex-col gap-3">
<NodeConfigForm
@ -522,23 +520,23 @@ function NodeConfigWithAdvanced({
showAdvanced={showAdvanced}
onUpdateConfig={onUpdateConfig}
/>
<div className="border-t border-border pt-3">
<div className="border-border border-t pt-3">
<button
type="button"
onClick={() => setShowAdvanced((v) => !v)}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
{showAdvanced ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
{showAdvanced ? "Hide" : "Show"} advanced
{showAdvanced ? 'Hide' : 'Show'} advanced
</button>
{showAdvanced && (
<div className="mt-3 flex flex-col gap-3">
<div>
<label className="mb-1 block text-xs text-muted-foreground">
<label className="text-muted-foreground mb-1 block text-xs">
Node key (internal identifier — keep stable for analytics)
</label>
<Input
@ -550,10 +548,10 @@ function NodeConfigWithAdvanced({
/>
</div>
{hasReplyIds && (
<p className="text-[10px] text-muted-foreground">
<p className="text-muted-foreground text-[10px]">
Reply IDs for each option are shown inline above. They&apos;re
returned by WhatsApp when a customer taps; you usually don&apos;t
need to touch them.
returned by WhatsApp when a customer taps; you usually
don&apos;t need to touch them.
</p>
)}
</div>
@ -563,45 +561,51 @@ function NodeConfigWithAdvanced({
);
}
// ============================================================
// Add-node menu
// ============================================================
function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
const types: NodeType[] = [
"start",
"send_buttons",
"send_list",
"send_message",
"send_media",
"collect_input",
"condition",
"set_tag",
"handoff",
"end",
'start',
'send_buttons',
'send_list',
'send_message',
'send_media',
'collect_input',
'condition',
'set_tag',
'handoff',
'end',
];
return (
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
className="border-border bg-card text-foreground hover:bg-muted inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors"
aria-label="Add node"
>
<Plus className="h-3.5 w-3.5" />
Add node
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="border-border bg-popover">
{types.map((t) => {
const meta = NODE_META[t];
return (
<DropdownMenuItem key={t} onClick={() => onAdd(t)}>
<meta.icon className={cn("h-3.5 w-3.5", meta.color)} />
{meta.label}
</DropdownMenuItem>
);
})}
{groupNodeTypesByCategory(types).map((group, i) => (
<div key={group.id}>
{i > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase">
{group.label}
</DropdownMenuLabel>
{group.types.map((t) => {
const meta = NODE_META[t];
return (
<DropdownMenuItem key={t} onClick={() => onAdd(t)}>
<meta.icon className={cn('h-3.5 w-3.5', meta.color)} />
{meta.label}
</DropdownMenuItem>
);
})}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,4 +1,4 @@
"use client";
'use client';
/**
* Canvas / mind-map view of a flow. Editable, in parity with the
@ -36,7 +36,7 @@
* list view reads.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
applyNodeChanges,
Background,
@ -55,12 +55,12 @@ import {
type NodeChange,
type NodeProps,
type OnNodeDrag,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { Plus, Trash2 } from "lucide-react";
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Plus, Trash2 } from 'lucide-react';
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
@ -68,29 +68,32 @@ import {
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
} from '@/components/ui/sheet';
import {
applyEdgeConnection,
deriveCanvasEdges,
outgoingSlots,
} from "@/lib/flows/edges";
import { autoLayout, shouldAutoLayout } from "@/lib/flows/layout";
} from '@/lib/flows/edges';
import { autoLayout, shouldAutoLayout } from '@/lib/flows/layout';
import {
NODE_META,
NodeIconChip,
groupNodeTypesByCategory,
nodeColors,
summarizeNode,
type BuilderNode,
type NodeType,
} from "./shared";
} from './shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useFlowEditor } from "./flow-editor-state";
import { NodeConfigForm } from "./forms/node-config-form";
} from '@/components/ui/dropdown-menu';
import { useFlowEditor } from './flow-editor-state';
import { NodeConfigForm } from './forms/node-config-form';
// React-Flow node `data` payload — the bits our custom renderer needs.
interface NodeData extends Record<string, unknown> {
@ -118,11 +121,11 @@ const NODE_HEIGHT = 90;
// (rose) node colors so all branch/port colors stay single-sourced in
// NODE_HUE — a palette tweak there can't leave these stale.
function slotColor(nodeType: NodeType, slotId: string, fallback: string) {
if (nodeType === "condition" && slotId === "true") {
return nodeColors("start").solid;
if (nodeType === 'condition' && slotId === 'true') {
return nodeColors('start').solid;
}
if (nodeType === "condition" && slotId === "false") {
return nodeColors("handoff").solid;
if (nodeType === 'condition' && slotId === 'false') {
return nodeColors('handoff').solid;
}
return fallback;
}
@ -137,7 +140,7 @@ function FlowNodeCard({ data, selected }: NodeProps) {
// don't need an incoming Handle. Every other node type accepts
// incoming edges (including terminal handoff / end — they're the
// common targets).
const hasTarget = node.node_type !== "start";
const hasTarget = node.node_type !== 'start';
// Single-slot nodes get a single source handle floated on the right
// edge of the card. Multi-slot nodes (condition, send_buttons,
// send_list) render slot rows inline so each handle visually sits
@ -147,10 +150,10 @@ function FlowNodeCard({ data, selected }: NodeProps) {
<div
style={
{
"--nc": c.solid,
"--nc-soft": c.soft,
"--nc-ring": c.ring,
"--nc-text": c.text,
'--nc': c.solid,
'--nc-soft': c.soft,
'--nc-ring': c.ring,
'--nc-text': c.text,
borderColor: selected ? c.solid : undefined,
boxShadow: selected
? `0 0 0 1px ${c.solid}, 0 14px 36px -12px ${c.ring}`
@ -158,21 +161,21 @@ function FlowNodeCard({ data, selected }: NodeProps) {
} as React.CSSProperties
}
className={cn(
"relative min-w-[220px] max-w-[260px] rounded-xl border bg-card px-3.5 py-3 text-left shadow-[0_2px_6px_rgba(0,0,0,0.18)] transition-[box-shadow,border-color]",
'bg-card relative max-w-[260px] min-w-[220px] rounded-xl border px-3.5 py-3 text-left shadow-[0_2px_6px_rgba(0,0,0,0.18)] transition-[box-shadow,border-color]',
selected
? "border-[var(--nc)]"
: "border-border hover:border-[var(--nc-ring)]",
? 'border-[var(--nc)]'
: 'border-border hover:border-[var(--nc-ring)]',
// Flash overrides hover/selected colors briefly. Tailwind's
// built-in `animate-pulse` is too gentle; a ring with the
// amber accent matches the list view's flash semantics.
isFlashed && "!border-amber-400 ring-2 ring-amber-400/60",
isFlashed && '!border-amber-400 ring-2 ring-amber-400/60'
)}
>
{hasTarget && (
<Handle
type="target"
position={Position.Left}
className="!h-2.5 !w-2.5 !border-2 !border-[var(--nc-ring)] !bg-card"
className="!bg-card !h-2.5 !w-2.5 !border-2 !border-[var(--nc-ring)]"
/>
)}
@ -184,32 +187,32 @@ function FlowNodeCard({ data, selected }: NodeProps) {
className="rounded-md"
/>
<span
className="truncate text-[10.5px] font-semibold uppercase tracking-wider"
className="truncate text-[10.5px] font-semibold tracking-wider uppercase"
style={{ color: c.text }}
>
{meta.label}
</span>
{isEntry && (
<span className="ml-auto rounded border border-border px-1.5 py-0.5 text-[8.5px] font-bold uppercase tracking-[0.1em] text-muted-foreground">
<span className="border-border text-muted-foreground ml-auto rounded border px-1.5 py-0.5 text-[8.5px] font-bold tracking-[0.1em] uppercase">
Entry
</span>
)}
</div>
<div className="mt-2 truncate font-mono text-[11px] text-muted-foreground">
<div className="text-muted-foreground mt-2 truncate font-mono text-[11px]">
{node.node_key}
</div>
{summary && (
<div className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
<div className="text-muted-foreground mt-1 line-clamp-2 text-xs leading-relaxed">
{summary}
</div>
)}
{isMultiSlot && (
<div className="mt-2.5 flex flex-col gap-1 border-t border-border pt-2.5">
<div className="border-border mt-2.5 flex flex-col gap-1 border-t pt-2.5">
{slots.map((slot) => (
<div
key={slot.id}
className="relative flex items-center justify-between gap-2 rounded px-1 py-0.5 text-[11px] text-muted-foreground"
className="text-muted-foreground relative flex items-center justify-between gap-2 rounded px-1 py-0.5 text-[11px]"
>
<span className="truncate" title={slot.label}>
{slot.label}
@ -225,7 +228,7 @@ function FlowNodeCard({ data, selected }: NodeProps) {
// sits flush with the right edge of the card instead
// of floating at vertical center. The negative offset
// matches the card's px-3 + the handle's own radius.
className="!relative !right-auto !top-auto !h-2.5 !w-2.5 !translate-x-[14px] !transform-none !border-2 !bg-card"
className="!bg-card !relative !top-auto !right-auto !h-2.5 !w-2.5 !translate-x-[14px] !transform-none !border-2"
/>
</div>
))}
@ -238,7 +241,7 @@ function FlowNodeCard({ data, selected }: NodeProps) {
id={slots[0].id}
position={Position.Right}
style={{ borderColor: c.solid }}
className="!h-2.5 !w-2.5 !border-2 !bg-card"
className="!bg-card !h-2.5 !w-2.5 !border-2"
/>
)}
</div>
@ -286,9 +289,9 @@ function FlowCanvasInner() {
const selectedNode = useMemo(
() =>
selectedNodeKey
? builderNodes.find((n) => n.node_key === selectedNodeKey) ?? null
? (builderNodes.find((n) => n.node_key === selectedNodeKey) ?? null)
: null,
[selectedNodeKey, builderNodes],
[selectedNodeKey, builderNodes]
);
const autoLayoutPositions = useMemo(() => {
@ -302,7 +305,7 @@ function FlowCanvasInner() {
height: NODE_HEIGHT,
})),
canvasEdges.map((e) => ({ source: e.source, target: e.target })),
{ direction: "TB" },
{ direction: 'TB' }
)
: null;
}, [builderNodes]);
@ -317,8 +320,8 @@ function FlowCanvasInner() {
persistedAutoLayoutRef.current = true;
updateNodePositions(
Object.fromEntries(
[...autoLayoutPositions].map(([key, pos]) => [key, pos]),
),
[...autoLayoutPositions].map(([key, pos]) => [key, pos])
)
);
}, [autoLayoutPositions, updateNodePositions]);
@ -327,7 +330,7 @@ function FlowCanvasInner() {
const fallback = autoLayoutPositions?.get(n.node_key);
return {
id: n.node_key,
type: "flow",
type: 'flow',
position: {
x: fallback?.x ?? n.position_x ?? 0,
y: fallback?.y ?? n.position_y ?? 0,
@ -362,11 +365,11 @@ function FlowCanvasInner() {
sourceHandle: e.sourceHandle,
label: e.label,
// Mode-aware via CSS tokens so edge chrome flips with light/dark.
labelStyle: { fill: "var(--muted-foreground)", fontSize: 11 },
labelBgStyle: { fill: "var(--card)" },
labelStyle: { fill: 'var(--muted-foreground)', fontSize: 11 },
labelBgStyle: { fill: 'var(--card)' },
labelBgPadding: [4, 2] as [number, number],
labelBgBorderRadius: 4,
style: { stroke: "var(--border)", strokeWidth: 1.5 },
style: { stroke: 'var(--border)', strokeWidth: 1.5 },
}));
return rfEdges;
@ -376,7 +379,7 @@ function FlowCanvasInner() {
(changes: NodeChange<RfNode<NodeData>>[]) => {
setRfNodes((nodes) => applyNodeChanges(changes, nodes));
},
[],
[]
);
// Drag-to-position: React-Flow tracks the visual drag internally and
@ -390,7 +393,7 @@ function FlowCanvasInner() {
(_event, node) => {
updateNodePosition(node.id, node.position.x, node.position.y);
},
[updateNodePosition],
[updateNodePosition]
);
// Pan to the flashed node when the validator panel requests one.
@ -412,7 +415,7 @@ function FlowCanvasInner() {
(_event: React.MouseEvent, node: RfNode<NodeData>) => {
setSelectedNodeKey(node.id);
},
[],
[]
);
// Drag-to-connect: React-Flow fires onConnect when the user drops a
@ -423,11 +426,15 @@ function FlowCanvasInner() {
// the next render — no need to maintain a separate edge list.
const handleConnect = useCallback(
(connection: Connection) => {
if (!connection.source || !connection.target || !connection.sourceHandle) {
if (
!connection.source ||
!connection.target ||
!connection.sourceHandle
) {
return;
}
const sourceNode = builderNodes.find(
(n) => n.node_key === connection.source,
(n) => n.node_key === connection.source
);
if (!sourceNode) return;
// Self-loops are a footgun (a button whose target is its own
@ -437,11 +444,11 @@ function FlowCanvasInner() {
const patch = applyEdgeConnection(
sourceNode,
connection.sourceHandle,
connection.target,
connection.target
);
if (patch) updateNodeConfig(connection.source, patch);
},
[builderNodes, updateNodeConfig],
[builderNodes, updateNodeConfig]
);
// Keyboard delete (Backspace / Delete) + drag-to-trash. React-Flow
@ -457,7 +464,7 @@ function FlowCanvasInner() {
if (selectedNodeKey === n.id) setSelectedNodeKey(null);
}
},
[removeNode, selectedNodeKey],
[removeNode, selectedNodeKey]
);
// Edge delete: clear the source node's slot rather than removing
@ -469,11 +476,11 @@ function FlowCanvasInner() {
if (!e.sourceHandle) continue;
const sourceNode = builderNodes.find((n) => n.node_key === e.source);
if (!sourceNode) continue;
const patch = applyEdgeConnection(sourceNode, e.sourceHandle, "");
const patch = applyEdgeConnection(sourceNode, e.sourceHandle, '');
if (patch) updateNodeConfig(e.source, patch);
}
},
[builderNodes, updateNodeConfig],
[builderNodes, updateNodeConfig]
);
// Wrapped mutators that target the currently-selected node — pass to
@ -483,7 +490,7 @@ function FlowCanvasInner() {
(patch: Record<string, unknown>) => {
if (selectedNodeKey) updateNodeConfig(selectedNodeKey, patch);
},
[selectedNodeKey, updateNodeConfig],
[selectedNodeKey, updateNodeConfig]
);
const handleDeleteSelected = useCallback(() => {
@ -499,7 +506,7 @@ function FlowCanvasInner() {
if (rfNodes.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 text-sm">
<p>No nodes yet.</p>
<CanvasAddNodeButton />
</div>
@ -524,7 +531,7 @@ function FlowCanvasInner() {
onEdgesDelete={handleEdgesDelete}
// Default is "Backspace" only — accept both so Mac users
// hitting Delete (Fn+Backspace) get the same behavior.
deleteKeyCode={["Backspace", "Delete"]}
deleteKeyCode={['Backspace', 'Delete']}
nodesConnectable={true}
edgesFocusable={true}
elementsSelectable={true}
@ -542,7 +549,7 @@ function FlowCanvasInner() {
color="var(--border)"
/>
<Controls
className="!overflow-hidden !rounded-xl !border !border-border !bg-card !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] [&_button]:!border-border [&_button]:!bg-card [&_button:hover]:!bg-muted [&_button_svg]:!fill-foreground"
className="!border-border !bg-card [&_button]:!border-border [&_button]:!bg-card [&_button:hover]:!bg-muted [&_button_svg]:!fill-foreground !overflow-hidden !rounded-xl !border !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]"
showInteractive={false}
/>
<MiniMap
@ -554,9 +561,9 @@ function FlowCanvasInner() {
nodeStrokeWidth={0}
nodeBorderRadius={3}
maskColor="color-mix(in oklch, var(--background) 70%, transparent)"
className="!rounded-xl !border !border-border !bg-card !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]"
className="!border-border !bg-card !rounded-xl !border !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]"
/>
<Panel position="top-left" className="!left-4 !top-4">
<Panel position="top-left" className="!top-4 !left-4">
<CanvasAddNodeButton />
</Panel>
</ReactFlow>
@ -614,24 +621,24 @@ function NodeEditSheet({
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 border-l border-border bg-popover p-0 sm:max-w-md"
className="border-border bg-popover flex w-full flex-col gap-0 border-l p-0 sm:max-w-md"
>
<SheetHeader className="flex-row items-center gap-3 space-y-0 border-b border-border px-5 py-4">
<SheetHeader className="border-border flex-row items-center gap-3 space-y-0 border-b px-5 py-4">
<NodeIconChip type={node.node_type} size={36} iconSize={18} />
<div className="min-w-0 flex-1">
<SheetTitle className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider">
<SheetTitle className="flex items-center gap-2 text-[11px] font-semibold tracking-wider uppercase">
<span style={{ color: c.text }}>{meta.label}</span>
{isEntry && (
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-emerald-300">
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-emerald-300 uppercase">
Entry
</span>
)}
</SheetTitle>
<SheetDescription className="mt-0.5 text-xs text-muted-foreground">
<SheetDescription className="text-muted-foreground mt-0.5 text-xs">
{meta.blurb}
</SheetDescription>
</div>
<code className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
<code className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]">
{node.node_key}
</code>
</SheetHeader>
@ -645,7 +652,7 @@ function NodeEditSheet({
/>
</div>
<SheetFooter className="border-t border-border px-5 py-3 sm:flex-row sm:justify-between">
<SheetFooter className="border-border border-t px-5 py-3 sm:flex-row sm:justify-between">
{!isEntry ? (
<Button variant="ghost" size="sm" onClick={onSetEntry}>
Set as entry
@ -676,16 +683,16 @@ function NodeEditSheet({
// ============================================================
const ADD_NODE_TYPES: NodeType[] = [
"start",
"send_buttons",
"send_list",
"send_message",
"send_media",
"collect_input",
"condition",
"set_tag",
"handoff",
"end",
'start',
'send_buttons',
'send_list',
'send_message',
'send_media',
'collect_input',
'condition',
'set_tag',
'handoff',
'end',
];
function CanvasAddNodeButton() {
@ -699,7 +706,7 @@ function CanvasAddNodeButton() {
// .react-flow root and read its bounding rect. If we can't find
// it (test envs, etc.), addNode's default (0, 0) is the fallback
// and the user can drag the node into view.
const root = document.querySelector(".react-flow") as HTMLElement | null;
const root = document.querySelector('.react-flow') as HTMLElement | null;
if (!root) return;
const rect = root.getBoundingClientRect();
const center = reactFlow.screenToFlowPosition({
@ -709,13 +716,17 @@ function CanvasAddNodeButton() {
// NODE_WIDTH / NODE_HEIGHT are the dagre layout defaults; offset
// so the card sits visually centered rather than top-left at the
// viewport center.
updateNodePosition(key, center.x - NODE_WIDTH / 2, center.y - NODE_HEIGHT / 2);
updateNodePosition(
key,
center.x - NODE_WIDTH / 2,
center.y - NODE_HEIGHT / 2
);
};
return (
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3.5 py-2 text-[13px] font-medium text-primary-foreground shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] transition-colors hover:bg-primary-hover"
className="bg-primary text-primary-foreground hover:bg-primary-hover inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-[13px] font-medium shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] transition-colors"
aria-label="Add node"
>
<Plus className="h-4 w-4" />
@ -723,31 +734,41 @@ function CanvasAddNodeButton() {
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-[268px] border-border bg-popover p-1.5"
className="border-border bg-popover w-[268px] p-1.5"
>
<div className="px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Add a step
</div>
{ADD_NODE_TYPES.map((t) => {
const meta = NODE_META[t];
return (
<DropdownMenuItem
key={t}
onClick={() => handleAdd(t)}
className="gap-3 py-2"
>
<NodeIconChip type={t} size={28} iconSize={16} className="rounded-md" />
<span className="flex flex-col">
<span className="text-[13px] font-semibold text-popover-foreground">
{meta.label}
</span>
<span className="text-[11.5px] text-muted-foreground">
{meta.blurb}
</span>
</span>
</DropdownMenuItem>
);
})}
{groupNodeTypesByCategory(ADD_NODE_TYPES).map((group, i) => (
<div key={group.id}>
{i > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="text-muted-foreground px-2 py-1.5 text-[11px] font-semibold tracking-wider uppercase">
{group.label}
</DropdownMenuLabel>
{group.types.map((t) => {
const meta = NODE_META[t];
return (
<DropdownMenuItem
key={t}
onClick={() => handleAdd(t)}
className="gap-3 py-2"
>
<NodeIconChip
type={t}
size={28}
iconSize={16}
className="rounded-md"
/>
<span className="flex flex-col">
<span className="text-popover-foreground text-[13px] font-semibold">
{meta.label}
</span>
<span className="text-muted-foreground text-[11.5px]">
{meta.blurb}
</span>
</span>
</DropdownMenuItem>
);
})}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
);

View File

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
NODE_CATEGORIES,
NODE_META,
groupNodeTypesByCategory,
type NodeType,
} from './shared';
const ALL_TYPES = Object.keys(NODE_META) as NodeType[];
describe('node categories', () => {
it('assigns every node type to a known category', () => {
const known = new Set(NODE_CATEGORIES.map((c) => c.id));
for (const type of ALL_TYPES) {
expect(known.has(NODE_META[type].category)).toBe(true);
}
});
});
describe('groupNodeTypesByCategory', () => {
it('keeps the categories in NODE_CATEGORIES order and drops empty ones', () => {
// Only messaging + flow types — the logic group must not appear.
const groups = groupNodeTypesByCategory(['send_message', 'start', 'end']);
expect(groups.map((g) => g.id)).toEqual(['messaging', 'flow']);
});
it('preserves the input order within a category', () => {
const groups = groupNodeTypesByCategory([
'send_media',
'send_message',
'send_buttons',
]);
expect(groups).toHaveLength(1);
expect(groups[0].types).toEqual([
'send_media',
'send_message',
'send_buttons',
]);
});
it('partitions the full type list without losing or duplicating a type', () => {
const grouped = groupNodeTypesByCategory(ALL_TYPES).flatMap((g) => g.types);
expect([...grouped].sort()).toEqual([...ALL_TYPES].sort());
});
});

View File

@ -28,9 +28,9 @@ import {
Tag,
UserPlus,
Workflow,
} from "lucide-react";
} from 'lucide-react';
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
// ============================================================
// Node-type union — single source of truth for every place the UI
@ -41,16 +41,16 @@ import { cn } from "@/lib/utils";
// ============================================================
export type NodeType =
| "start"
| "send_message"
| "send_buttons"
| "send_list"
| "send_media"
| "collect_input"
| "condition"
| "set_tag"
| "handoff"
| "end";
| 'start'
| 'send_message'
| 'send_buttons'
| 'send_list'
| 'send_media'
| 'collect_input'
| 'condition'
| 'set_tag'
| 'handoff'
| 'end';
export interface BuilderNode {
node_key: string;
@ -67,72 +67,123 @@ export interface BuilderNode {
// the user sees a node summary.
// ============================================================
// ------------------------------------------------------------
// Node categories — buckets the add-step menu groups types under so
// the picker stays scannable as the type list grows, and so a fork
// adding its own node types has an obvious place to slot them.
//
// Note there's no "Events / Triggers" category: in wacrm a flow is
// triggered by flow-level config (`trigger_type`), not by a node on
// the canvas, so `start` is just the entry point under Flow control.
// ------------------------------------------------------------
export type NodeCategory = 'messaging' | 'logic' | 'flow';
/** Category labels + the order they render in the add-step menu. */
export const NODE_CATEGORIES: { id: NodeCategory; label: string }[] = [
{ id: 'messaging', label: 'Messaging' },
{ id: 'logic', label: 'Logic & data' },
{ id: 'flow', label: 'Flow control' },
];
export const NODE_META: Record<
NodeType,
{ label: string; icon: typeof Workflow; color: string; blurb: string }
{
label: string;
icon: typeof Workflow;
color: string;
blurb: string;
category: NodeCategory;
}
> = {
start: {
label: "Start",
label: 'Start',
icon: PlayCircle,
color: "text-emerald-400",
blurb: "Entry point of the flow",
color: 'text-emerald-400',
blurb: 'Entry point of the flow',
category: 'flow',
},
send_message: {
label: "Send message",
label: 'Send message',
icon: MessageCircle,
color: "text-sky-400",
blurb: "Sends a WhatsApp text message",
color: 'text-sky-400',
blurb: 'Sends a WhatsApp text message',
category: 'messaging',
},
send_buttons: {
label: "Send buttons",
label: 'Send buttons',
icon: ListChecks,
color: "text-primary",
blurb: "Sends quick-reply buttons",
color: 'text-primary',
blurb: 'Sends quick-reply buttons',
category: 'messaging',
},
send_list: {
label: "Send list",
label: 'Send list',
icon: ListPlus,
color: "text-indigo-400",
blurb: "Sends a tappable list of options",
color: 'text-indigo-400',
blurb: 'Sends a tappable list of options',
category: 'messaging',
},
send_media: {
label: "Send media",
label: 'Send media',
icon: Paperclip,
color: "text-cyan-400",
blurb: "Sends an image, video, or document",
color: 'text-cyan-400',
blurb: 'Sends an image, video, or document',
category: 'messaging',
},
collect_input: {
label: "Collect input",
label: 'Collect input',
icon: Inbox,
color: "text-teal-400",
blurb: "Asks a question, saves the reply",
color: 'text-teal-400',
blurb: 'Asks a question, saves the reply',
category: 'logic',
},
condition: {
label: "If / else",
label: 'If / else',
icon: GitFork,
color: "text-fuchsia-400",
blurb: "Branches on a rule",
color: 'text-fuchsia-400',
blurb: 'Branches on a rule',
category: 'logic',
},
set_tag: {
label: "Tag contact",
label: 'Tag contact',
icon: Tag,
color: "text-pink-400",
blurb: "Adds or removes a contact tag",
color: 'text-pink-400',
blurb: 'Adds or removes a contact tag',
category: 'logic',
},
handoff: {
label: "Handoff to agent",
label: 'Handoff to agent',
icon: UserPlus,
color: "text-amber-400",
blurb: "Hands the conversation to a human",
color: 'text-amber-400',
blurb: 'Hands the conversation to a human',
category: 'flow',
},
end: {
label: "End",
label: 'End',
icon: Flag,
color: "text-muted-foreground",
blurb: "Ends the flow",
color: 'text-muted-foreground',
blurb: 'Ends the flow',
category: 'flow',
},
};
/**
* Bucket an ordered list of node types by category, preserving both
* the category order (NODE_CATEGORIES) and the within-category order
* of the input list. Empty categories are dropped. Used by both the
* canvas and list add-step menus so they stay in lockstep.
*/
export function groupNodeTypesByCategory(
types: NodeType[]
): { id: NodeCategory; label: string; types: NodeType[] }[] {
return NODE_CATEGORIES.map(({ id, label }) => ({
id,
label,
types: types.filter((t) => NODE_META[t].category === id),
})).filter((group) => group.types.length > 0);
}
// ============================================================
// Per-node-type color system.
//
@ -212,8 +263,8 @@ export function NodeIconChip({
return (
<span
className={cn(
"flex shrink-0 items-center justify-center rounded-lg",
className,
'flex shrink-0 items-center justify-center rounded-lg',
className
)}
style={{ width: size, height: size, background: c.soft, color: c.solid }}
>
@ -236,8 +287,8 @@ export function slugify(s: string, fallback: string): string {
const cleaned = s
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return cleaned || fallback;
}
@ -249,37 +300,39 @@ export function slugify(s: string, fallback: string): string {
// ============================================================
export function truncate(s: string, max = 80): string {
const clean = s.replace(/\s+/g, " ").trim();
const clean = s.replace(/\s+/g, ' ').trim();
if (clean.length <= max) return clean;
return clean.slice(0, max - 1) + "…";
return clean.slice(0, max - 1) + '…';
}
export function summarizeNode(node: BuilderNode): string | null {
const cfg = node.config;
switch (node.node_type) {
case "start":
case "end":
case 'start':
case 'end':
return null;
case "send_message": {
const text = typeof cfg.text === "string" ? cfg.text : "";
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 : "";
case 'send_buttons': {
const text = typeof cfg.text === 'string' ? cfg.text : '';
const buttons = Array.isArray(cfg.buttons)
? (cfg.buttons as Array<Record<string, unknown>>)
: [];
const titles = buttons
.map((b) => (typeof b.title === "string" ? b.title : ""))
.map((b) => (typeof b.title === 'string' ? b.title : ''))
.filter(Boolean)
.join(" / ");
.join(' / ');
if (text.length > 0) {
return titles ? `${truncate(text, 40)} · ${truncate(titles, 35)}` : truncate(text);
return titles
? `${truncate(text, 40)} · ${truncate(titles, 35)}`
: truncate(text);
}
return titles || null;
}
case "send_list": {
const text = typeof cfg.text === "string" ? cfg.text : "";
case 'send_list': {
const text = typeof cfg.text === 'string' ? cfg.text : '';
const sections = Array.isArray(cfg.sections)
? (cfg.sections as Array<Record<string, unknown>>)
: [];
@ -289,75 +342,81 @@ export function summarizeNode(node: BuilderNode): string | null {
}, 0);
if (text.length > 0) {
return rowCount > 0
? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? "" : "s"}`
? `${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"}`
? `${rowCount} option${rowCount === 1 ? '' : 's'} across ${sections.length} section${sections.length === 1 ? '' : 's'}`
: null;
}
case "send_media": {
case 'send_media': {
const mediaType =
typeof cfg.media_type === "string" ? cfg.media_type : "";
const filename = typeof cfg.filename === "string" ? cfg.filename : "";
const url = typeof cfg.media_url === "string" ? cfg.media_url : "";
const caption = typeof cfg.caption === "string" ? cfg.caption : "";
typeof cfg.media_type === 'string' ? cfg.media_type : '';
const filename = typeof cfg.filename === 'string' ? cfg.filename : '';
const url = typeof cfg.media_url === 'string' ? cfg.media_url : '';
const caption = typeof cfg.caption === 'string' ? cfg.caption : '';
const label = mediaType
? mediaType.charAt(0).toUpperCase() + mediaType.slice(1)
: "Media";
: 'Media';
if (!url) return `${label} (no file uploaded)`;
const name = filename || url.split("/").pop() || "file";
const name = filename || url.split('/').pop() || 'file';
return caption
? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}`
: `${label}: ${truncate(name, 60)}`;
}
case "collect_input": {
const prompt = typeof cfg.prompt_text === "string" ? cfg.prompt_text : "";
const varKey = typeof cfg.var_key === "string" ? cfg.var_key : "";
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
? `${truncate(prompt, 50)} → vars.${varKey}`
: truncate(prompt);
}
return varKey ? `→ vars.${varKey}` : null;
}
case "condition": {
case 'condition': {
const subjectKey =
typeof cfg.subject_key === "string" ? cfg.subject_key : "";
typeof cfg.subject_key === 'string' ? cfg.subject_key : '';
if (!subjectKey) return null;
const subject =
cfg.subject === "tag"
? "tag"
: cfg.subject === "contact_field"
? "field"
: "var";
cfg.subject === 'tag'
? 'tag'
: cfg.subject === 'contact_field'
? 'field'
: 'var';
const subjectStr =
subject === "tag" ? `has tag ${truncate(subjectKey, 24)}` : `${subject}.${subjectKey}`;
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 : "";
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
(cfg.operator === 'equals' || cfg.operator === 'contains') && value
? ` "${truncate(value, 20)}"`
: "";
return subject === "tag" ? subjectStr : `${subjectStr} ${op}${valStr}`;
: '';
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 : "";
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)`;
return tagId
? `${mode} tag ${tagId.slice(0, 8)}…`
: `${mode} tag (none picked)`;
}
case "handoff": {
const note = typeof cfg.note === "string" ? cfg.note : "";
case 'handoff': {
const note = typeof cfg.note === 'string' ? cfg.note : '';
return note.length > 0 ? truncate(note) : null;
}
}