@adatechnology/conversations-ui 0.1.0-rc.39 → 0.1.0-rc.40

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.
@@ -28,21 +28,117 @@ const QUESTION_TYPES: FlowQuestionType[] = ['text', 'money', 'date', 'int', 'cpf
28
28
  // Paridade com financiamento-imobiliario-bot/apps/web/src/components/flows/FlowPalette.tsx —
29
29
  // botão "Adicionar" com submenus por categoria (Pergunta/Decisão/Condição/Ação). Sem
30
30
  // dependência de dropdown externa — menu simples com fechamento por clique fora.
31
- export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: FlowPaletteProps) {
32
- const labels = { ...DEFAULT_FLOW_EDITOR_LABELS, ...labelsOverride }
31
+ export interface FlowPaletteMenuProps {
32
+ onSelect: (spec: NewNodeSpec) => void
33
+ labels: FlowEditorLabels
34
+ actionOptions?: FlowPaletteActionOption[]
35
+ }
36
+
37
+ /**
38
+ * A lista de tipos de nó, sem o botão que a abre.
39
+ *
40
+ * Separada porque o "+" do card abre exatamente esta lista: duplicar os itens era garantir que um
41
+ * tipo novo aparecesse num caminho e faltasse no outro.
42
+ */
43
+ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPaletteMenuProps) {
33
44
  const resolvedActionOptions = actionOptions ?? [
34
45
  { actionKind: 'handoff', label: labels.actionKindLabels.handoff ?? 'Encaminhar para atendimento' },
35
46
  ]
47
+ const [submenu, setSubmenu] = useState<'question' | 'action' | null>(null)
48
+
49
+ function select(spec: NewNodeSpec) {
50
+ onSelect(spec)
51
+ setSubmenu(null)
52
+ }
53
+
54
+ return (
55
+ <>
56
+ <div className="relative">
57
+ <button
58
+ data-cv-tooltip={labels.palette.question} aria-label={labels.palette.question}
59
+ onMouseEnter={() => setSubmenu('question')}
60
+ onClick={() => setSubmenu(submenu === 'question' ? null : 'question')}
61
+ className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
62
+ >
63
+ <span className="flex items-center gap-2">
64
+ <MessageCircleQuestion size={15} className="text-blue-500" /> {labels.palette.question}
65
+ </span>
66
+ <ChevronRight size={13} className="text-gray-400" />
67
+ </button>
68
+ {submenu === 'question' && (
69
+ <div className="absolute left-full top-0 ml-1 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
70
+ {QUESTION_TYPES.map((qt) => (
71
+ <button
72
+ data-cv-tooltip={labels.questionTypeLabels[qt]} aria-label={labels.questionTypeLabels[qt]}
73
+ key={qt}
74
+ onClick={() => select({ kind: 'question', questionType: qt })}
75
+ className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
76
+ >
77
+ {labels.questionTypeLabels[qt]}
78
+ </button>
79
+ ))}
80
+ </div>
81
+ )}
82
+ </div>
83
+
84
+ <button
85
+ data-cv-tooltip={labels.palette.decision} aria-label={labels.palette.decision}
86
+ onMouseEnter={() => setSubmenu(null)}
87
+ onClick={() => select({ kind: 'decision' })}
88
+ className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
89
+ >
90
+ <GitBranch size={15} className="text-purple-500" /> {labels.palette.decision}
91
+ </button>
92
+
93
+ <button
94
+ onMouseEnter={() => setSubmenu(null)}
95
+ onClick={() => select({ kind: 'condition' })}
96
+ className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
97
+ data-cv-tooltip={labels.palette.conditionHint} aria-label={labels.palette.conditionHint}
98
+ >
99
+ <Diamond size={15} className="text-cyan-500" /> {labels.palette.condition}
100
+ </button>
101
+
102
+ <div className="relative">
103
+ <button
104
+ data-cv-tooltip={labels.palette.action} aria-label={labels.palette.action}
105
+ onMouseEnter={() => setSubmenu('action')}
106
+ onClick={() => setSubmenu(submenu === 'action' ? null : 'action')}
107
+ className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
108
+ >
109
+ <span className="flex items-center gap-2">
110
+ <Zap size={15} className="text-orange-500" /> {labels.palette.action}
111
+ </span>
112
+ <ChevronRight size={13} className="text-gray-400" />
113
+ </button>
114
+ {submenu === 'action' && (
115
+ <div className="absolute left-full top-0 ml-1 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
116
+ {resolvedActionOptions.map((option) => (
117
+ <button
118
+ data-cv-tooltip={option.label} aria-label={option.label}
119
+ key={option.actionKind}
120
+ onClick={() => select({ kind: 'action', actionKind: option.actionKind })}
121
+ className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
122
+ >
123
+ {option.label}
124
+ </button>
125
+ ))}
126
+ </div>
127
+ )}
128
+ </div>
129
+ </>
130
+ )
131
+ }
36
132
 
133
+ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: FlowPaletteProps) {
134
+ const labels = { ...DEFAULT_FLOW_EDITOR_LABELS, ...labelsOverride }
37
135
  const [open, setOpen] = useState(false)
38
- const [submenu, setSubmenu] = useState<'question' | 'action' | null>(null)
39
136
  const containerRef = useRef<HTMLDivElement>(null)
40
137
 
41
138
  useEffect(() => {
42
139
  function handleClickOutside(event: MouseEvent) {
43
140
  if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
44
141
  setOpen(false)
45
- setSubmenu(null)
46
142
  }
47
143
  }
48
144
  document.addEventListener('mousedown', handleClickOutside)
@@ -52,7 +148,6 @@ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: Fl
52
148
  function select(spec: NewNodeSpec) {
53
149
  onAdd(spec)
54
150
  setOpen(false)
55
- setSubmenu(null)
56
151
  }
57
152
 
58
153
  return (
@@ -67,79 +162,11 @@ export function FlowPalette({ onAdd, labels: labelsOverride, actionOptions }: Fl
67
162
 
68
163
  {open && (
69
164
  <div className="absolute left-0 top-full mt-1.5 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg z-50 py-1">
70
- <div className="relative">
71
- <button
72
- data-cv-tooltip={labels.palette.question} aria-label={labels.palette.question}
73
- onMouseEnter={() => setSubmenu('question')}
74
- onClick={() => setSubmenu(submenu === 'question' ? null : 'question')}
75
- className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
76
- >
77
- <span className="flex items-center gap-2">
78
- <MessageCircleQuestion size={15} className="text-blue-500" /> {labels.palette.question}
79
- </span>
80
- <ChevronRight size={13} className="text-gray-400" />
81
- </button>
82
- {submenu === 'question' && (
83
- <div className="absolute left-full top-0 ml-1 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
84
- {QUESTION_TYPES.map((qt) => (
85
- <button
86
- data-cv-tooltip={labels.questionTypeLabels[qt]} aria-label={labels.questionTypeLabels[qt]}
87
- key={qt}
88
- onClick={() => select({ kind: 'question', questionType: qt })}
89
- className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
90
- >
91
- {labels.questionTypeLabels[qt]}
92
- </button>
93
- ))}
94
- </div>
95
- )}
96
- </div>
97
-
98
- <button
99
- data-cv-tooltip={labels.palette.decision} aria-label={labels.palette.decision}
100
- onMouseEnter={() => setSubmenu(null)}
101
- onClick={() => select({ kind: 'decision' })}
102
- className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
103
- >
104
- <GitBranch size={15} className="text-purple-500" /> {labels.palette.decision}
105
- </button>
106
-
107
- <button
108
- onMouseEnter={() => setSubmenu(null)}
109
- onClick={() => select({ kind: 'condition' })}
110
- className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
111
- data-cv-tooltip={labels.palette.conditionHint} aria-label={labels.palette.conditionHint}
112
- >
113
- <Diamond size={15} className="text-cyan-500" /> {labels.palette.condition}
114
- </button>
115
-
116
- <div className="relative">
117
- <button
118
- data-cv-tooltip={labels.palette.action} aria-label={labels.palette.action}
119
- onMouseEnter={() => setSubmenu('action')}
120
- onClick={() => setSubmenu(submenu === 'action' ? null : 'action')}
121
- className="w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
122
- >
123
- <span className="flex items-center gap-2">
124
- <Zap size={15} className="text-orange-500" /> {labels.palette.action}
125
- </span>
126
- <ChevronRight size={13} className="text-gray-400" />
127
- </button>
128
- {submenu === 'action' && (
129
- <div className="absolute left-full top-0 ml-1 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1">
130
- {resolvedActionOptions.map((option) => (
131
- <button
132
- data-cv-tooltip={option.label} aria-label={option.label}
133
- key={option.actionKind}
134
- onClick={() => select({ kind: 'action', actionKind: option.actionKind })}
135
- className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
136
- >
137
- {option.label}
138
- </button>
139
- ))}
140
- </div>
141
- )}
142
- </div>
165
+ <FlowPaletteMenu
166
+ onSelect={select}
167
+ labels={labels}
168
+ {...(actionOptions ? { actionOptions } : {})}
169
+ />
143
170
  </div>
144
171
  )}
145
172
  </div>
@@ -5,6 +5,7 @@ import {
5
5
  Controls,
6
6
  MiniMap,
7
7
  MarkerType,
8
+ Panel,
8
9
  applyNodeChanges,
9
10
  type Node,
10
11
  type NodeChange,
@@ -16,12 +17,14 @@ import '@xyflow/react/dist/style.css'
16
17
  import { Plus, Trash2, LayoutGrid, AlertTriangle, AlertCircle, Save, Undo2, Map as MapIcon, Workflow } from 'lucide-react'
17
18
 
18
19
  import { useIsDarkTheme } from '../useDarkMode'
19
- import { flowNodeTypes, nodeLabel, type FlowNodeCardData } from './FlowNodeCard'
20
+ import { NODE_TYPE_COLOR, flowNodeTypes, nodeLabel, type FlowNodeCardData } from './FlowNodeCard'
21
+ import { FlowLegend, type FlowLegendEdgeSample } from './FlowLegend'
20
22
  import { flowPortalNodeTypes, type FlowPortalNodeData } from './FlowPortalNode'
21
23
  import { flowGroupHeaderNodeTypes, type FlowGroupHeaderData } from './FlowGroupHeader'
22
24
  import { flowGroupFrameNodeTypes, type FlowGroupFrameData } from './FlowGroupFrame'
23
25
  import { FlowNodePanel } from './FlowNodePanel'
24
- import { FlowPalette, type FlowPaletteActionOption, type NewNodeSpec } from './FlowPalette'
26
+ import { FlowPalette, FlowPaletteMenu, type FlowPaletteActionOption, type NewNodeSpec } from './FlowPalette'
27
+ import { flowEdgeTypes, type FlowConnectionEdgeData } from './FlowConnectionEdge'
25
28
  import { FlowMapCanvas } from './FlowMapCanvas'
26
29
  import { mergeFlowEditorLabels, type FlowEditorLabels } from './labels'
27
30
  // Operações puras do grafo, com teste próprio. As decisões que elas tomam não dão erro quando estão
@@ -32,6 +35,7 @@ import {
32
35
  chainFrameNodeId,
33
36
  computeMergedLayout,
34
37
  countLiveByNode,
38
+ findFreeSlot,
35
39
  newNodeFromSpec,
36
40
  portalNodeId,
37
41
  GROUP_HEADER_NODE_ID,
@@ -40,6 +44,7 @@ import {
40
44
  } from './flowCanvasModel'
41
45
  import {
42
46
  applyConnection,
47
+ clearConnection,
43
48
  mergedFlowKeysFrom,
44
49
  namespaceNodeId,
45
50
  parseNamespacedId,
@@ -68,7 +73,28 @@ const RF_NODE_TYPES = {
68
73
  ...flowGroupFrameNodeTypes,
69
74
  }
70
75
 
76
+ /**
77
+ * A legenda lê as mesmas constantes que pintam as arestas — chave que diverge do desenho é pior que
78
+ * chave nenhuma, porque ensina errado com ar de autoridade.
79
+ */
80
+ function legendEdgeSamples(labels: FlowEditorLabels): FlowLegendEdgeSample[] {
81
+ return [
82
+ { color: EDGE_COLOR_LINEAR, label: labels.legendPanel.linear },
83
+ { color: EDGE_COLOR_BRANCH, label: labels.legendPanel.branch },
84
+ { color: EDGE_COLOR_FALLBACK, dash: '5 4', label: labels.legendPanel.fallback },
85
+ { color: EDGE_COLOR_CROSS_FLOW, dash: '3 3', label: labels.legendPanel.crossFlow },
86
+ { color: EDGE_COLOR_LIVE, label: labels.legendPanel.live },
87
+ ]
88
+ }
89
+
90
+ const LEGEND_NODE_SWATCHES = (Object.keys(NODE_TYPE_COLOR) as (keyof typeof NODE_TYPE_COLOR)[]).map((type) => ({
91
+ type,
92
+ className: NODE_TYPE_COLOR[type],
93
+ }))
94
+
71
95
  const CHAIN_FRAME_PADDING = 36
96
+ /** Coluna à direita do card de origem em que o nó criado pelo "+" nasce. */
97
+ const QUICK_ADD_COLUMN_GAP = 320
72
98
  const FLOW_KEY_PATTERN = /^[a-z0-9_]{2,40}$/
73
99
 
74
100
  const EDGE_COLOR_LINEAR = '#94a3b8'
@@ -145,7 +171,7 @@ export interface FlowsWorkspaceProps {
145
171
  * O estilo fica aqui e a topologia fica no modelo, de propósito: destino errado é invisível até a
146
172
  * conversa do cliente parar; cor errada aparece na primeira olhada.
147
173
  */
148
- function styleEdge(spec: FlowEdgeSpec): Edge {
174
+ function styleEdge(spec: FlowEdgeSpec, params: { disconnectLabel: string; onDisconnect: (spec: FlowEdgeSpec) => void }): Edge {
149
175
  const color = spec.crossFlow
150
176
  ? EDGE_COLOR_CROSS_FLOW
151
177
  : spec.kind === 'fallback'
@@ -164,7 +190,12 @@ function styleEdge(spec: FlowEdgeSpec): Edge {
164
190
  source: spec.source,
165
191
  target: spec.target,
166
192
  ...(spec.sourceHandle === undefined ? {} : { sourceHandle: spec.sourceHandle }),
167
- type: 'bezier',
193
+ type: 'flowConnection',
194
+ reconnectable: 'target',
195
+ data: {
196
+ disconnectLabel: params.disconnectLabel,
197
+ onDisconnect: () => params.onDisconnect(spec),
198
+ } satisfies FlowConnectionEdgeData,
168
199
  animated: spec.live,
169
200
  style: {
170
201
  stroke: color,
@@ -222,6 +253,10 @@ export function FlowsWorkspace({
222
253
  const [saveErrorMessage, setSaveErrorMessage] = useState<string | undefined>(undefined)
223
254
  const [showCreateDialog, setShowCreateDialog] = useState(false)
224
255
  const [showDeleteDialog, setShowDeleteDialog] = useState(false)
256
+ /** Saída de onde o "+" foi clicado, e o ponto da tela onde ancorar o menu. */
257
+ const [quickAddFrom, setQuickAddFrom] = useState<
258
+ { flowKey: string; nodeId: string; handle: string; anchor: { x: number; y: number } } | null
259
+ >(null)
225
260
  const [newFlow, setNewFlow] = useState({ key: '', label: '', showInMenu: false, menuOptionLabel: '' })
226
261
  const [flowMutationState, setFlowMutationState] = useState<{ pending: boolean; error?: string }>({ pending: false })
227
262
  const [rfNodes, setRfNodes] = useState<Node[]>([])
@@ -417,7 +452,19 @@ export function FlowsWorkspace({
417
452
  return renderedPositionsRef.current.get(nsId) ?? mergedPositions.get(nsId) ?? { x: 0, y: 0 }
418
453
  }
419
454
  // Fluxo sozinho no canvas: a posição salva no grafo é a do card, sem tradução no meio.
420
- return graph!.nodes[nodeId]?.position ?? fallbackPositions[nodeId] ?? { x: 0, y: 0 }
455
+ //
456
+ // O `renderedPositionsRef` no meio é o que impede o card de sumir ao desligar um fio. Nó
457
+ // vindo do seed não tem posição salva, então quem manda nele é o auto-layout — e o
458
+ // auto-layout joga todo nó sem ligação de entrada para uma faixa ABAIXO de tudo. Desligar
459
+ // a última ação a mandava para fora da área visível no mesmo instante, o que se lê como
460
+ // "o editor apagou meu card". Congelando o lugar em que ele já foi desenhado, desligar o
461
+ // fio passa a mudar só o fio.
462
+ const nsId = namespaceNodeId(flowKey, nodeId)
463
+ return (
464
+ graph!.nodes[nodeId]?.position ??
465
+ renderedPositionsRef.current.get(nsId) ??
466
+ fallbackPositions[nodeId] ?? { x: 0, y: 0 }
467
+ )
421
468
  }
422
469
 
423
470
  for (const node of Object.values(graph.nodes)) {
@@ -436,6 +483,7 @@ export function FlowsWorkspace({
436
483
  issues: flowIssues,
437
484
  labels,
438
485
  onSelect: (nodeId: string) => setEditingRef({ flowKey, nodeId }),
486
+ onQuickAdd: ({ nodeId, handle, anchor }) => setQuickAddFrom({ flowKey, nodeId, handle, anchor }),
439
487
  } satisfies FlowNodeCardData,
440
488
  })
441
489
 
@@ -523,10 +571,52 @@ export function FlowsWorkspace({
523
571
  labels,
524
572
  ])
525
573
 
574
+ // Desligar um fio é gravar destino vazio no nó de origem — o nó do outro lado NÃO se mexe. Era
575
+ // isso que faltava: sem caminho para desligar, trocar o destino da última ação passava por
576
+ // apagar o card e refazê-lo.
577
+ const disconnectEdge = useCallback(
578
+ (spec: FlowEdgeSpec) => {
579
+ const { flowKey, nodeId } = parseNamespacedId(spec.source)
580
+ updateFlow(flowKey, (graph) => {
581
+ const node = graph.nodes[nodeId]
582
+ if (!node) return graph
583
+ return { ...graph, nodes: { ...graph.nodes, [nodeId]: clearConnection(node, spec.sourceHandle ?? 'next') } }
584
+ })
585
+ },
586
+ [updateFlow],
587
+ )
588
+
526
589
  const edges = useMemo(
527
590
  () =>
528
- buildFlowEdges({ openKeys: openFlowKeys, graphs: workingGraphs, rootFlowKey, livePositions }).map(styleEdge),
529
- [openFlowKeys, workingGraphs, rootFlowKey, livePositions],
591
+ buildFlowEdges({ openKeys: openFlowKeys, graphs: workingGraphs, rootFlowKey, livePositions }).map((spec) =>
592
+ // Salto entre fluxos desenhado como portal não se desliga daqui: quem manda nele é o `next`
593
+ // do nó de origem, e o portal é só a caixa que representa o fluxo alvo ausente.
594
+ styleEdge(spec, { disconnectLabel: labels.quickAdd.disconnect, onDisconnect: disconnectEdge }),
595
+ ),
596
+ [openFlowKeys, workingGraphs, rootFlowKey, livePositions, labels, disconnectEdge],
597
+ )
598
+
599
+ // Arrastar a ponta de um fio para outro card: religa em UMA edição, sem passar por um estado
600
+ // intermediário em que o fluxo está quebrado.
601
+ const onReconnect = useCallback(
602
+ (oldEdge: Edge, connection: Connection) => {
603
+ const resolved = resolveConnection({
604
+ connection: {
605
+ source: connection.source,
606
+ target: connection.target,
607
+ sourceHandle: connection.sourceHandle ?? oldEdge.sourceHandle,
608
+ },
609
+ graphs: workingGraphs,
610
+ })
611
+ if (!resolved) return
612
+
613
+ updateFlow(resolved.flowKey, (graph) => {
614
+ const node = graph.nodes[resolved.nodeId]
615
+ if (!node) return graph
616
+ return { ...graph, nodes: { ...graph.nodes, [resolved.nodeId]: applyConnection(node, resolved) } }
617
+ })
618
+ },
619
+ [workingGraphs, updateFlow],
530
620
  )
531
621
 
532
622
  useEffect(() => {
@@ -596,6 +686,56 @@ export function FlowsWorkspace({
596
686
  setPendingFocusNodeId(namespaceNodeId(primaryFlowKey, newNode.id))
597
687
  }
598
688
 
689
+ /**
690
+ * Cria o próximo nó e liga o fio na MESMA edição.
691
+ *
692
+ * Uma edição só, e não duas, porque o desfazer é por passo: criar e ligar separados fariam um
693
+ * "desfazer" deixar o card novo solto no canvas, que é justamente o estado que ninguém quer.
694
+ * A posição sai do card de origem, à direita dele — o nó nasce onde a pessoa estava olhando,
695
+ * em vez de na faixa de órfãos abaixo de tudo.
696
+ */
697
+ function handleQuickAdd(spec: NewNodeSpec) {
698
+ const origin = quickAddFrom
699
+ setQuickAddFrom(null)
700
+ if (!origin) return
701
+
702
+ const originGraph = workingGraphs[origin.flowKey]
703
+ if (!originGraph) return
704
+
705
+ const newNode = newNodeFromSpec(spec, new Set(Object.keys(originGraph.nodes)))
706
+ const originPosition =
707
+ renderedPositionsRef.current.get(namespaceNodeId(origin.flowKey, origin.nodeId)) ??
708
+ originGraph.nodes[origin.nodeId]?.position ?? { x: 0, y: 0 }
709
+ // À direita de quem criou, e descendo se aquele lugar já tiver dono — tipicamente o próprio nó
710
+ // que acabou de perder a ligação, que é exatamente quem está naquela coluna.
711
+ const taken = openFlowKeys.flatMap((key) =>
712
+ Object.entries(workingGraphs[key]?.nodes ?? {}).map(
713
+ ([id, node]) => renderedPositionsRef.current.get(namespaceNodeId(key, id)) ?? node.position ?? { x: 0, y: 0 },
714
+ ),
715
+ )
716
+ newNode.position = findFreeSlot({
717
+ desired: { x: originPosition.x + QUICK_ADD_COLUMN_GAP, y: originPosition.y },
718
+ taken,
719
+ })
720
+ // Com fluxos mesclados quem decide o lugar é o layout, não o `position` do nó — sem semear
721
+ // aqui, o card nascia na coluna calculada, em cima do nó que acabou de ficar solto.
722
+ renderedPositionsRef.current.set(namespaceNodeId(origin.flowKey, newNode.id), newNode.position)
723
+
724
+ updateFlow(origin.flowKey, (graph) => {
725
+ const sourceNode = graph.nodes[origin.nodeId]
726
+ if (!sourceNode) return graph
727
+ const connected = applyConnection(sourceNode, {
728
+ flowKey: origin.flowKey,
729
+ nodeId: origin.nodeId,
730
+ handle: origin.handle,
731
+ targetValue: newNode.id,
732
+ })
733
+ return { ...graph, nodes: { ...graph.nodes, [origin.nodeId]: connected, [newNode.id]: newNode } }
734
+ })
735
+
736
+ setEditingRef({ flowKey: origin.flowKey, nodeId: newNode.id })
737
+ }
738
+
599
739
  function handleNodePanelChange(updated: FlowNodeData) {
600
740
  if (!editingRef) return
601
741
  updateFlow(editingRef.flowKey, (graph) => ({ ...graph, nodes: { ...graph.nodes, [updated.id]: updated } }))
@@ -875,9 +1015,11 @@ export function FlowsWorkspace({
875
1015
  nodes={rfNodes}
876
1016
  edges={edges}
877
1017
  nodeTypes={RF_NODE_TYPES}
1018
+ edgeTypes={flowEdgeTypes}
878
1019
  onNodesChange={onNodesChange}
879
1020
  onNodeDragStop={onNodeDragStop}
880
1021
  onConnect={onConnect}
1022
+ onReconnect={onReconnect}
881
1023
  onInit={setFlowInstance}
882
1024
  fitView
883
1025
  proOptions={{ hideAttribution: true }}
@@ -885,11 +1027,39 @@ export function FlowsWorkspace({
885
1027
  >
886
1028
  <Background color={isDark ? BACKGROUND_COLOR_DARK : BACKGROUND_COLOR_LIGHT} />
887
1029
  <Controls />
1030
+ <Panel position="top-right">
1031
+ <FlowLegend
1032
+ labels={labels}
1033
+ edgeSamples={legendEdgeSamples(labels)}
1034
+ nodeSwatches={LEGEND_NODE_SWATCHES}
1035
+ />
1036
+ </Panel>
888
1037
  <MiniMap pannable zoomable className="!bg-white dark:!bg-gray-800" />
889
1038
  </ReactFlow>
890
1039
  )}
891
1040
  </div>
892
1041
 
1042
+ {/* Menu do "+": ancorado no ponto clicado e em coordenadas de tela (`fixed`), porque o canvas
1043
+ tem pan e zoom próprios — posicionar dentro dele faria o menu escorregar junto. */}
1044
+ {quickAddFrom && (
1045
+ <>
1046
+ <div className="fixed inset-0 z-40" onClick={() => setQuickAddFrom(null)} />
1047
+ <div
1048
+ className="fixed z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1"
1049
+ style={{ left: quickAddFrom.anchor.x + 12, top: quickAddFrom.anchor.y }}
1050
+ >
1051
+ <p className="px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
1052
+ {labels.quickAdd.title}
1053
+ </p>
1054
+ <FlowPaletteMenu
1055
+ onSelect={handleQuickAdd}
1056
+ labels={labels}
1057
+ {...(actionOptions ? { actionOptions: [...actionOptions] } : {})}
1058
+ />
1059
+ </div>
1060
+ </>
1061
+ )}
1062
+
893
1063
  {/* `key` por nó: o painel guarda um rascunho local em estado, e sem remontar ao trocar de nó
894
1064
  selecionado ele seguia mostrando (e salvando) os campos do nó anterior. */}
895
1065
  {editingNode && editingGraph && (
@@ -17,8 +17,9 @@ import {
17
17
  newNodeFromSpec,
18
18
  portalNodeId,
19
19
  type FlowLivePosition,
20
+ findFreeSlot,
20
21
  } from './flowCanvasModel'
21
- import type { FlowGraphData, FlowNodeData } from './flowGraph'
22
+ import { estimateNodeHeight, type FlowGraphData, type FlowNodeData } from './flowGraph'
22
23
 
23
24
  const ROOT = 'menu'
24
25
 
@@ -310,3 +311,146 @@ describe('nó novo da paleta', () => {
310
311
  expect(newNodeFromSpec({ kind: 'action', actionKind: 'handoff' }, new Set()).actionKind).toBe('handoff')
311
312
  })
312
313
  })
314
+
315
+ /**
316
+ * O "+" cria o nó à direita de quem o criou — que é onde costuma estar o card que acabou de perder
317
+ * a ligação. Dois cards no mesmo pixel se leem como um card só, e "sumiu" de novo.
318
+ */
319
+ describe('findFreeSlot', () => {
320
+ it('lugar vazio é usado como está', () => {
321
+ expect(findFreeSlot({ desired: { x: 100, y: 0 }, taken: [] })).toEqual({ x: 100, y: 0 })
322
+ })
323
+
324
+ it('lugar ocupado desce até sobrar espaço, sem sair da coluna', () => {
325
+ const slot = findFreeSlot({ desired: { x: 100, y: 0 }, taken: [{ x: 100, y: 0 }] })
326
+
327
+ expect(slot.x).toBe(100)
328
+ expect(slot.y).toBeGreaterThan(0)
329
+ })
330
+
331
+ it('desce quantas vezes precisar quando a coluna está empilhada', () => {
332
+ const taken = [
333
+ { x: 100, y: 0 },
334
+ { x: 100, y: 120 },
335
+ { x: 100, y: 240 },
336
+ ]
337
+
338
+ expect(findFreeSlot({ desired: { x: 100, y: 0 }, taken }).y).toBe(360)
339
+ })
340
+
341
+ it('card em outra coluna não empurra ninguém', () => {
342
+ expect(findFreeSlot({ desired: { x: 100, y: 0 }, taken: [{ x: 900, y: 0 }] })).toEqual({ x: 100, y: 0 })
343
+ })
344
+ })
345
+
346
+ /**
347
+ * A altura do card decide o espaçamento do layout. `send_media` ganhou uma linha de saída (o motor
348
+ * do bot anda para o `next` depois dela), e a altura tem de acompanhar — senão a camada de baixo
349
+ * encosta no card.
350
+ */
351
+ describe('estimateNodeHeight em nó de ação', () => {
352
+ it('ação de passagem é mais alta que ação terminal, pela linha de saída', () => {
353
+ const passThrough = estimateNodeHeight({ id: 'a', type: 'action', actionKind: 'send_media' })
354
+ const terminal = estimateNodeHeight({ id: 'b', type: 'action', actionKind: 'handoff' })
355
+
356
+ expect(passThrough).toBeGreaterThan(terminal)
357
+ })
358
+
359
+ it('ação sem kind conta como terminal, em vez de estourar', () => {
360
+ expect(estimateNodeHeight({ id: 'c', type: 'action' })).toBe(
361
+ estimateNodeHeight({ id: 'd', type: 'action', actionKind: 'handoff' }),
362
+ )
363
+ })
364
+ })
365
+
366
+ /**
367
+ * A regra que o empilhamento por camada quebrava: dois cards na mesma altura fazem o fio entre eles
368
+ * correr na horizontal, e um fio horizontal passa POR TRÁS de qualquer card que esteja no caminho.
369
+ * Um card por linha transforma toda ligação numa diagonal curta e visível.
370
+ */
371
+ describe('cascata: um card por linha', () => {
372
+ it('cada card tem altura própria — ninguém divide linha com ninguém', () => {
373
+ const graphs = {
374
+ menu: graph({
375
+ key: 'menu',
376
+ start: 'a',
377
+ nodes: [node('a', { byAnswer: { sim: 'b', nao: 'c' }, default: 'd' }), node('b'), node('c'), node('d')],
378
+ }),
379
+ }
380
+
381
+ const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
382
+ const ys = [...positions.values()].map((each) => each.y)
383
+
384
+ expect(new Set(ys).size).toBe(ys.length)
385
+ })
386
+
387
+ it('o card seguinte fica à direita E abaixo de quem o alimenta', () => {
388
+ const graphs = { menu: graph({ key: 'menu', start: 'a', nodes: [node('a', 'b'), node('b', 'c'), node('c')] }) }
389
+
390
+ const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
391
+ const a = positions.get('menu::a')!
392
+ const b = positions.get('menu::b')!
393
+ const c = positions.get('menu::c')!
394
+
395
+ expect(b.x).toBeGreaterThan(a.x)
396
+ expect(b.y).toBeGreaterThan(a.y)
397
+ expect(c.x).toBeGreaterThan(b.x)
398
+ expect(c.y).toBeGreaterThan(b.y)
399
+ })
400
+
401
+ it('um ramo inteiro sai antes do próximo começar — caminho de conversa não se intercala', () => {
402
+ const graphs = {
403
+ menu: graph({
404
+ key: 'menu',
405
+ start: 'a',
406
+ nodes: [
407
+ node('a', { byAnswer: { sim: 'b1', nao: 'c1' }, default: '' }),
408
+ node('b1', 'b2'),
409
+ node('b2'),
410
+ node('c1'),
411
+ ],
412
+ }),
413
+ }
414
+
415
+ const positions = computeMergedLayout({ openKeys: ['menu'], graphs, primaryFlowKey: 'menu' })
416
+
417
+ // b1 e b2 são o mesmo ramo: c1 (o outro ramo) só aparece depois dos dois.
418
+ expect(positions.get('menu::b2')!.y).toBeLessThan(positions.get('menu::c1')!.y)
419
+ })
420
+ })
421
+
422
+ /**
423
+ * Um `next` apontando para o próprio nó não vira aresta: de A para A não há trajeto que caiba —
424
+ * por baixo some atrás do card, por cima o cobre. Quem mostra é o ícone de repetição na linha de
425
+ * saída. O teste existe porque "não desenhar" é fácil de perder numa refatoração de arestas.
426
+ */
427
+ describe('laço no próprio card', () => {
428
+ it('saída que volta ao mesmo nó não gera aresta', () => {
429
+ const graphs = {
430
+ menu: graph({
431
+ key: 'menu',
432
+ start: 'a',
433
+ nodes: [node('a', { byAnswer: { sim: 'b' }, default: 'a' }), node('b')],
434
+ }),
435
+ }
436
+
437
+ const edges = buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: 'menu' })
438
+
439
+ expect(edges.map((each) => each.id)).toEqual(['menu::a->menu::b-sim'])
440
+ })
441
+
442
+ it('as demais saídas do mesmo card continuam desenhadas', () => {
443
+ const graphs = {
444
+ menu: graph({
445
+ key: 'menu',
446
+ start: 'a',
447
+ nodes: [node('a', { byAnswer: { sim: 'b', repete: 'a' }, default: 'b' }), node('b')],
448
+ }),
449
+ }
450
+
451
+ const edges = buildFlowEdges({ openKeys: ['menu'], graphs, rootFlowKey: 'menu' })
452
+
453
+ expect(edges).toHaveLength(2)
454
+ expect(edges.every((each) => each.target === 'menu::b')).toBe(true)
455
+ })
456
+ })