@adatechnology/conversations-ui 0.1.0-rc.29 → 0.1.0-rc.30

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.
@@ -26,6 +26,18 @@ import { FlowMapCanvas } from './FlowMapCanvas'
26
26
  import { mergeFlowEditorLabels, type FlowEditorLabels } from './labels'
27
27
  // Operações puras do grafo, com teste próprio. As decisões que elas tomam não dão erro quando estão
28
28
  // erradas: dão aresta apontando para nó apagado, ou salto que o motor do bot ignora.
29
+ import {
30
+ buildFlowEdges,
31
+ chainFrameBounds,
32
+ chainFrameNodeId,
33
+ computeMergedLayout,
34
+ countLiveByNode,
35
+ newNodeFromSpec,
36
+ portalNodeId,
37
+ GROUP_HEADER_NODE_ID,
38
+ type FlowEdgeSpec,
39
+ type FlowLivePosition,
40
+ } from './flowCanvasModel'
29
41
  import {
30
42
  applyConnection,
31
43
  mergedFlowKeysFrom,
@@ -36,16 +48,13 @@ import {
36
48
  } from './flowEditorOps'
37
49
  import {
38
50
  computeAutoLayout,
39
- slugifyNodeId,
40
51
  targetsOf,
41
52
  validateGraph,
42
53
  isCrossFlowTarget,
43
54
  crossFlowKey,
44
- crossFlowTargetsOf,
45
55
  findCollectionChains,
46
56
  estimateNodeHeight,
47
57
  NODE_CARD_WIDTH,
48
- CROSS_FLOW_PREFIX,
49
58
  type FlowGraphData,
50
59
  type FlowNodeData,
51
60
  type GraphIssue,
@@ -60,9 +69,6 @@ const RF_NODE_TYPES = {
60
69
  }
61
70
 
62
71
  const CHAIN_FRAME_PADDING = 36
63
- const COLUMN_GAP = 360
64
- const ROW_GAP = 40
65
- const NS_SEP = '::'
66
72
  const FLOW_KEY_PATTERN = /^[a-z0-9_]{2,40}$/
67
73
 
68
74
  const EDGE_COLOR_LINEAR = '#94a3b8'
@@ -73,13 +79,9 @@ const EDGE_COLOR_CROSS_FLOW = '#06b6d4'
73
79
  const BACKGROUND_COLOR_LIGHT = '#cbd5e1'
74
80
  const BACKGROUND_COLOR_DARK = '#334155'
75
81
 
76
- /** Onde cada conversa viva está parada agora, para o card pulsar com a contagem. */
77
- export interface FlowLivePosition {
78
- currentState: string
79
- flow: string | null
80
- nodeId: string | null
81
- menuNodeId: string | null
82
- }
82
+ // `FlowLivePosition` mora no modelo, que é quem conta — e é reexportado aqui porque faz parte da api
83
+ // que o produto implementa. Declarar nos dois lugares faria as duas formas divergirem em silêncio.
84
+ export type { FlowLivePosition } from './flowCanvasModel'
83
85
 
84
86
  export interface CreateFlowInput {
85
87
  key: string
@@ -123,234 +125,43 @@ export interface FlowsWorkspaceProps {
123
125
  readonly className?: string
124
126
  }
125
127
 
126
- // Um portal por (nó de origem, fluxo alvo) — se duas opções do mesmo nó apontarem pro mesmo
127
- // fluxo, compartilham um único portal (menos poluição visual, ainda uma ligação por opção).
128
- function portalNodeId(sourceId: string, target: string): string {
129
- return `__portal__${sourceId}__${target}`
130
- }
131
-
132
- // Um fluxo aberto no canvas de detalhe — o primeiro da lista é o "primário" (dono da paleta,
133
- // organizar, publicar e excluir-fluxo); os demais chegaram por fusão editável (clique num portal)
134
- // e ficam com um cabeçalho flutuante pra focar neles sozinhos ou fechar.
135
- type OpenFlow = { key: string; offset: { x: number; y: number } }
136
-
137
- // Fecho transitivo de saltos flow:<key> a partir de rootKey — "o fluxo completo": abrir um
138
- // fluxo já traz junto tudo que ele referencia (e o que essas referências referenciam), sem
139
- // precisar clicar em cada portal manualmente.
140
- function autoMergeAll(rootKey: string, graphsSource: Record<string, FlowGraphData>): OpenFlow[] {
141
- // O fecho em si vem de `flowEditorOps`, que é onde ele tem teste — inclusive para o caso mais banal
142
- // que existe, o fluxo que volta a si mesmo.
143
- return mergedFlowKeysFrom(rootKey, graphsSource).map((key) => ({ key, offset: { x: 0, y: 0 } }))
144
- }
145
-
146
- // Layout único pra TODOS os nós de TODOS os fluxos abertos juntos — ao contrário de posicionar
147
- // cada fluxo independente e só deslocar (que não evita sobreposição entre fluxos nem considera a
148
- // altura real de cada card), aqui o ranqueamento por BFS roda sobre o grafo mesclado inteiro,
149
- // usando ligações reais (inclusive saltos flow:<key> já resolvidos pro nó inicial do fluxo
150
- // alvo). Mesma orientação do computeAutoLayout: da esquerda para a direita, um rank por coluna.
151
- function computeMergedLayout(
152
- openFlows: readonly OpenFlow[],
153
- workingGraphs: Record<string, FlowGraphData>,
154
- primaryFlowKey: string,
155
- ): Map<string, { x: number; y: number }> {
156
- const openKeys = new Set(openFlows.map((flow) => flow.key))
157
-
158
- const nodeByNsId = new Map<string, FlowNodeData>()
159
- for (const { key } of openFlows) {
160
- const graph = workingGraphs[key]
161
- if (!graph) continue
162
- for (const node of Object.values(graph.nodes)) nodeByNsId.set(namespaceNodeId(key, node.id), node)
163
- }
164
-
165
- function forwardEdges(flowKey: string, node: FlowNodeData): string[] {
166
- const result: string[] = []
167
- for (const { target } of targetsOf(node)) {
168
- if (isCrossFlowTarget(target)) {
169
- const targetFlowKey = crossFlowKey(target)
170
- const targetGraph = openKeys.has(targetFlowKey) ? workingGraphs[targetFlowKey] : undefined
171
- if (targetGraph) result.push(namespaceNodeId(targetFlowKey, targetGraph.startNodeId))
172
- } else if (workingGraphs[flowKey]?.nodes[target]) {
173
- result.push(namespaceNodeId(flowKey, target))
174
- }
175
- }
176
- return result
177
- }
178
-
179
- const rank = new Map<string, number>()
180
- const primaryGraph = workingGraphs[primaryFlowKey]
181
- const rootId = primaryGraph ? namespaceNodeId(primaryFlowKey, primaryGraph.startNodeId) : undefined
182
- if (rootId && nodeByNsId.has(rootId)) {
183
- rank.set(rootId, 0)
184
- const queue = [rootId]
185
- while (queue.length > 0) {
186
- const id = queue.shift()!
187
- const node = nodeByNsId.get(id)
188
- if (!node) continue
189
- for (const nextId of forwardEdges(parseNamespacedId(id).flowKey, node)) {
190
- if (!rank.has(nextId)) {
191
- rank.set(nextId, rank.get(id)! + 1)
192
- queue.push(nextId)
193
- }
194
- }
195
- }
196
- }
197
-
198
- // Nós não alcançados a partir do início do fluxo primário (outro fluxo mesclado sem ligação
199
- // de volta pro primário, ou nó órfão) vão TODOS numa única camada extra abaixo de tudo — uma
200
- // camada por órfão empurrava cada um para uma linha própria, cada vez mais longe da área
201
- // visível, e desligar um fio dava a impressão de ter apagado o card.
202
- const strayRank = Math.max(0, ...rank.values()) + 1
203
- for (const nsId of nodeByNsId.keys()) {
204
- if (!rank.has(nsId)) rank.set(nsId, strayRank)
205
- }
206
-
207
- const layers = new Map<number, string[]>()
208
- for (const [nsId, nodeRank] of rank) {
209
- if (!layers.has(nodeRank)) layers.set(nodeRank, [])
210
- layers.get(nodeRank)!.push(nsId)
211
- }
212
-
213
- const positions = new Map<string, { x: number; y: number }>()
214
- for (const nodeRank of [...layers.keys()].sort((a, b) => a - b)) {
215
- // Alinhado pelo topo pelo mesmo motivo de `computeAutoLayout`: coluna centralizada saltava
216
- // inteira a cada nó a mais num ramo.
217
- let cursorY = 0
218
- for (const nsId of layers.get(nodeRank)!) {
219
- positions.set(nsId, { x: nodeRank * COLUMN_GAP, y: cursorY })
220
- cursorY += estimateNodeHeight(nodeByNsId.get(nsId)!) + ROW_GAP
221
- }
222
- }
223
- return positions
224
- }
225
-
226
- function computeLiveCounts(
227
- flowKey: string,
228
- rootFlowKey: string,
229
- livePositions: readonly FlowLivePosition[] | undefined,
230
- ): Record<string, number> {
231
- const counts: Record<string, number> = {}
232
- for (const position of livePositions ?? []) {
233
- if (position.flow !== flowKey && flowKey !== rootFlowKey) continue
234
- const nodeId = flowKey === rootFlowKey ? position.menuNodeId : position.nodeId
235
- if (!nodeId) continue
236
- counts[nodeId] = (counts[nodeId] ?? 0) + 1
128
+ /**
129
+ * Traduz o papel da aresta em traço, cor e seta.
130
+ *
131
+ * O estilo fica aqui e a topologia fica no modelo, de propósito: destino errado é invisível até a
132
+ * conversa do cliente parar; cor errada aparece na primeira olhada.
133
+ */
134
+ function styleEdge(spec: FlowEdgeSpec): Edge {
135
+ const color = spec.crossFlow
136
+ ? EDGE_COLOR_CROSS_FLOW
137
+ : spec.kind === 'fallback'
138
+ ? EDGE_COLOR_FALLBACK
139
+ : spec.live
140
+ ? EDGE_COLOR_LIVE
141
+ : spec.kind === 'branch'
142
+ ? EDGE_COLOR_BRANCH
143
+ : EDGE_COLOR_LINEAR
144
+ const baseWidth = spec.kind === 'branch' ? 1.75 : 1.5
145
+ const dash = spec.crossFlow ? '3 3' : spec.kind === 'fallback' ? '5 4' : undefined
146
+ const markerSize = spec.kind === 'fallback' ? 16 : 18
147
+
148
+ return {
149
+ id: spec.id,
150
+ source: spec.source,
151
+ target: spec.target,
152
+ ...(spec.sourceHandle === undefined ? {} : { sourceHandle: spec.sourceHandle }),
153
+ type: 'bezier',
154
+ animated: spec.live,
155
+ style: {
156
+ stroke: color,
157
+ strokeWidth: spec.live && !spec.crossFlow ? 2.5 : baseWidth,
158
+ ...(dash === undefined ? {} : { strokeDasharray: dash }),
159
+ },
160
+ markerEnd: { type: MarkerType.ArrowClosed, color, width: markerSize, height: markerSize },
237
161
  }
238
- return counts
239
162
  }
240
163
 
241
- // Constrói as ligações de TODOS os fluxos abertos juntos. Um salto "flow:<key>" vira ligação
242
- // real até o nó inicial do fluxo alvo quando esse fluxo já está mesclado no canvas; caso
243
- // contrário, continua indo até o portal (pseudo-nó) daquele fluxo, como antes da fusão.
244
- function buildAllEdges(
245
- openFlows: readonly OpenFlow[],
246
- workingGraphs: Record<string, FlowGraphData>,
247
- rootFlowKey: string,
248
- livePositions: readonly FlowLivePosition[] | undefined,
249
- ): Edge[] {
250
- const openKeys = new Set(openFlows.map((flow) => flow.key))
251
- const edges: Edge[] = []
252
-
253
- for (const { key: flowKey } of openFlows) {
254
- const graph = workingGraphs[flowKey]
255
- if (!graph) continue
256
- const liveCounts = computeLiveCounts(flowKey, rootFlowKey, livePositions)
257
-
258
- for (const [id, node] of Object.entries(graph.nodes)) {
259
- const isLive = (liveCounts[id] ?? 0) > 0
260
- for (const { target, optionId, isDefault } of targetsOf(node)) {
261
- // Destino vazio é estado NORMAL, não anomalia: apagar um nó zera quem apontava para ele, e
262
- // `targetsOf` devolve o `default` mesmo em branco. Sem esta linha a aresta ia para um nó que
263
- // não existe, o React Flow a descartava em silêncio, e a opção parecia ligada sem estar — a
264
- // conversa do cliente para ali e quem editou não vê nada de errado.
265
- if (!target) continue
266
-
267
- const crossFlow = isCrossFlowTarget(target)
268
- let targetFlowKey = flowKey
269
- let rawTargetId = target
270
- if (crossFlow) {
271
- const wantedKey = crossFlowKey(target)
272
- const targetGraph = openKeys.has(wantedKey) ? workingGraphs[wantedKey] : undefined
273
- if (targetGraph) {
274
- targetFlowKey = wantedKey
275
- rawTargetId = targetGraph.startNodeId
276
- } else {
277
- rawTargetId = portalNodeId(id, target)
278
- }
279
- }
280
- const source = namespaceNodeId(flowKey, id)
281
- const edgeTarget = namespaceNodeId(targetFlowKey, rawTargetId)
282
-
283
- if (optionId === undefined && !isDefault) {
284
- const color = crossFlow ? EDGE_COLOR_CROSS_FLOW : isLive ? EDGE_COLOR_LIVE : EDGE_COLOR_LINEAR
285
- edges.push({
286
- id: `${source}->${edgeTarget}`,
287
- source,
288
- target: edgeTarget,
289
- type: 'bezier',
290
- animated: isLive,
291
- style: crossFlow
292
- ? { stroke: color, strokeWidth: 1.5, strokeDasharray: '3 3' }
293
- : { stroke: color, strokeWidth: isLive ? 2.5 : 1.5 },
294
- markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
295
- })
296
- } else if (isDefault) {
297
- const color = crossFlow ? EDGE_COLOR_CROSS_FLOW : EDGE_COLOR_FALLBACK
298
- edges.push({
299
- id: `${source}->${edgeTarget}-default`,
300
- source,
301
- sourceHandle: '__default',
302
- target: edgeTarget,
303
- type: 'bezier',
304
- style: { stroke: color, strokeWidth: 1.5, strokeDasharray: '5 4' },
305
- markerEnd: { type: MarkerType.ArrowClosed, color, width: 16, height: 16 },
306
- })
307
- } else {
308
- const color = crossFlow ? EDGE_COLOR_CROSS_FLOW : isLive ? EDGE_COLOR_LIVE : EDGE_COLOR_BRANCH
309
- edges.push({
310
- id: `${source}->${edgeTarget}-${optionId}`,
311
- source,
312
- sourceHandle: optionId,
313
- target: edgeTarget,
314
- type: 'bezier',
315
- animated: isLive,
316
- style: crossFlow
317
- ? { stroke: color, strokeWidth: 1.75, strokeDasharray: '3 3' }
318
- : { stroke: color, strokeWidth: isLive ? 2.5 : 1.75 },
319
- markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
320
- })
321
- }
322
- }
323
- }
324
- }
325
- return edges
326
- }
327
164
 
328
- function newNodeFromSpec(spec: NewNodeSpec, existingIds: Set<string>): FlowNodeData {
329
- if (spec.kind === 'question') {
330
- const id = slugifyNodeId('nova_pergunta', existingIds)
331
- return { id, type: 'question', questionType: spec.questionType, contextKey: id, question: '' }
332
- }
333
- if (spec.kind === 'decision') {
334
- const id = slugifyNodeId('nova_decisao', existingIds)
335
- return {
336
- id,
337
- type: 'question',
338
- questionType: 'choice',
339
- contextKey: id,
340
- question: '',
341
- options: [
342
- ['1', 'Opção 1'],
343
- ['2', 'Opção 2'],
344
- ],
345
- }
346
- }
347
- if (spec.kind === 'condition') {
348
- const id = slugifyNodeId('nova_condicao', existingIds)
349
- return { id, type: 'condition', conditionOperator: '>' }
350
- }
351
- const id = slugifyNodeId('nova_acao', existingIds)
352
- return { id, type: 'action', actionKind: spec.actionKind }
353
- }
354
165
 
355
166
  function extractErrorMessage(error: unknown): string | undefined {
356
167
  if (error instanceof Error) return error.message
@@ -388,7 +199,7 @@ export function FlowsWorkspace({
388
199
  const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
389
200
  const [livePositions, setLivePositions] = useState<FlowLivePosition[] | undefined>(undefined)
390
201
  const [viewMode, setViewMode] = useState<'detail' | 'map'>('detail')
391
- const [openFlows, setOpenFlows] = useState<OpenFlow[]>([{ key: rootFlowKey, offset: { x: 0, y: 0 } }])
202
+ const [openFlowKeys, setOpenFlowKeys] = useState<readonly string[]>([rootFlowKey])
392
203
  const [hasAutoMerged, setHasAutoMerged] = useState(false)
393
204
  const [workingGraphs, setWorkingGraphs] = useState<Record<string, FlowGraphData>>({})
394
205
  const [editingRef, setEditingRef] = useState<{ flowKey: string; nodeId: string } | null>(null)
@@ -440,7 +251,7 @@ export function FlowsWorkspace({
440
251
  }
441
252
  }, [api, livePollIntervalMs])
442
253
 
443
- const primaryFlowKey = openFlows[0]?.key ?? rootFlowKey
254
+ const primaryFlowKey = openFlowKeys[0] ?? rootFlowKey
444
255
  const primaryGraph = workingGraphs[primaryFlowKey]
445
256
 
446
257
  // Assim que os fluxos carregam pela primeira vez, mescla automaticamente todo o fecho
@@ -449,7 +260,7 @@ export function FlowsWorkspace({
449
260
  // fica inteiramente sob controle do usuário.
450
261
  useEffect(() => {
451
262
  if (!graphs || hasAutoMerged) return
452
- setOpenFlows(autoMergeAll(rootFlowKey, graphs))
263
+ setOpenFlowKeys(mergedFlowKeysFrom(rootFlowKey, graphs))
453
264
  setHasAutoMerged(true)
454
265
  }, [graphs, hasAutoMerged, rootFlowKey])
455
266
 
@@ -461,7 +272,7 @@ export function FlowsWorkspace({
461
272
  setWorkingGraphs((prev) => {
462
273
  let changed = false
463
274
  const next = { ...prev }
464
- for (const { key } of openFlows) {
275
+ for (const key of openFlowKeys) {
465
276
  if (!next[key] && graphs[key]) {
466
277
  next[key] = graphs[key]
467
278
  changed = true
@@ -469,7 +280,7 @@ export function FlowsWorkspace({
469
280
  }
470
281
  return changed ? next : prev
471
282
  })
472
- }, [graphs, openFlows])
283
+ }, [graphs, openFlowKeys])
473
284
 
474
285
  const isFlowDirty = useCallback(
475
286
  (key: string): boolean => {
@@ -483,12 +294,12 @@ export function FlowsWorkspace({
483
294
 
484
295
  const issuesByFlow = useMemo<Record<string, GraphIssue[]>>(() => {
485
296
  const map: Record<string, GraphIssue[]> = {}
486
- for (const { key } of openFlows) {
297
+ for (const key of openFlowKeys) {
487
298
  const graph = workingGraphs[key]
488
299
  if (graph) map[key] = validateGraph(graph, labels.validation)
489
300
  }
490
301
  return map
491
- }, [openFlows, workingGraphs, labels])
302
+ }, [openFlowKeys, workingGraphs, labels])
492
303
 
493
304
  const errorCount = Object.values(issuesByFlow).reduce(
494
305
  (sum, list) => sum + list.filter((issue) => issue.severity === 'error').length,
@@ -498,7 +309,7 @@ export function FlowsWorkspace({
498
309
  (sum, list) => sum + list.filter((issue) => issue.severity === 'warning').length,
499
310
  0,
500
311
  )
501
- const dirtyKeys = openFlows.map((flow) => flow.key).filter(isFlowDirty)
312
+ const dirtyKeys = openFlowKeys.filter(isFlowDirty)
502
313
  const isDirty = dirtyKeys.length > 0
503
314
 
504
315
  const updateFlow = useCallback((flowKey: string, updater: (graph: FlowGraphData) => FlowGraphData) => {
@@ -514,19 +325,19 @@ export function FlowsWorkspace({
514
325
  // agora é sempre mostrar tudo que está conectado.
515
326
  const focusFlow = useCallback(
516
327
  (key: string) => {
517
- const others = openFlows.filter((flow) => flow.key !== key)
518
- if (others.some((flow) => isFlowDirty(flow.key)) && !window.confirm(labels.workspace.unsavedChangesConfirm)) return
519
- setOpenFlows(graphs ? autoMergeAll(key, graphs) : [{ key, offset: { x: 0, y: 0 } }])
328
+ const others = openFlowKeys.filter((each) => each !== key)
329
+ if (others.some(isFlowDirty) && !window.confirm(labels.workspace.unsavedChangesConfirm)) return
330
+ setOpenFlowKeys(graphs ? mergedFlowKeysFrom(key, graphs) : [key])
520
331
  if (editingRef && editingRef.flowKey !== key) setEditingRef(null)
521
332
  },
522
- [openFlows, isFlowDirty, editingRef, graphs, labels],
333
+ [openFlowKeys, isFlowDirty, editingRef, graphs, labels],
523
334
  )
524
335
 
525
336
  // "Fechar": remove um fluxo mesclado sem trocar o foco do primário.
526
337
  const closeFlow = useCallback(
527
338
  (key: string) => {
528
339
  if (isFlowDirty(key) && !window.confirm(labels.workspace.unsavedChangesConfirm)) return
529
- setOpenFlows((prev) => prev.filter((flow) => flow.key !== key))
340
+ setOpenFlowKeys((prev) => prev.filter((each) => each !== key))
530
341
  setWorkingGraphs((prev) => {
531
342
  const { [key]: _removed, ...rest } = prev
532
343
  return rest
@@ -536,40 +347,24 @@ export function FlowsWorkspace({
536
347
  [isFlowDirty, editingRef, labels],
537
348
  )
538
349
 
539
- // Fusão editável: mescla o fluxo alvo no mesmo canvas, posicionado à direita do nó de
540
- // origem que o referenciou, com os nós de verdade (editáveis ali mesmo) em vez de um portal.
541
- const mergeFlow = useCallback(
542
- (targetFlowKey: string, source: { flowKey: string; nodeId: string }) => {
543
- setOpenFlows((prev) => {
544
- if (prev.some((flow) => flow.key === targetFlowKey)) return prev
545
- const sourceOpen = prev.find((flow) => flow.key === source.flowKey)
546
- const sourceGraph = workingGraphs[source.flowKey]
547
- const sourceLocalPosition =
548
- sourceGraph?.nodes[source.nodeId]?.position ??
549
- (sourceGraph ? computeAutoLayout(sourceGraph)[source.nodeId] : undefined) ?? { x: 0, y: 0 }
550
- const baseOffset = sourceOpen?.offset ?? { x: 0, y: 0 }
551
- return [
552
- ...prev,
553
- {
554
- key: targetFlowKey,
555
- offset: {
556
- x: baseOffset.x + sourceLocalPosition.x + 400,
557
- y: baseOffset.y + sourceLocalPosition.y,
558
- },
559
- },
560
- ]
561
- })
562
- },
563
- [workingGraphs],
564
- )
350
+ /**
351
+ * Fusão editável: traz os nós de verdade do fluxo alvo para o mesmo canvas, no lugar do portal.
352
+ *
353
+ * Não calcula posição. Abrir um segundo fluxo liga o layout mesclado, que posiciona TODOS os nós
354
+ * junto e em coordenadas absolutas — um deslocamento calculado aqui seria descartado no desenho e
355
+ * ainda assim subtraído ao gravar, que era como a posição do card ia parar errada no grafo.
356
+ */
357
+ const mergeFlow = useCallback((targetFlowKey: string) => {
358
+ setOpenFlowKeys((prev) => (prev.includes(targetFlowKey) ? prev : [...prev, targetFlowKey]))
359
+ }, [])
565
360
 
566
361
  // Mais de um fluxo aberto = layout global (ignora node.position individual, recalcula tudo
567
362
  // junto pra nunca sobrepor); um só fluxo aberto = comportamento de sempre (respeita posição
568
363
  // salva/arrastada, com fallback pro auto-layout daquele fluxo isolado).
569
- const isMerged = openFlows.length > 1
364
+ const isMerged = openFlowKeys.length > 1
570
365
  const mergedPositions = useMemo(
571
- () => (isMerged ? computeMergedLayout(openFlows, workingGraphs, primaryFlowKey) : null),
572
- [isMerged, openFlows, workingGraphs, primaryFlowKey],
366
+ () => (isMerged ? computeMergedLayout({ openKeys: openFlowKeys, graphs: workingGraphs, primaryFlowKey }) : null),
367
+ [isMerged, openFlowKeys, workingGraphs, primaryFlowKey],
573
368
  )
574
369
 
575
370
  // Última posição em que cada nó foi desenhado, e ela manda sobre o layout calculado.
@@ -583,11 +378,11 @@ export function FlowsWorkspace({
583
378
 
584
379
  const derivedNodes = useMemo<Node[]>(() => {
585
380
  const allNodes: Node[] = []
586
- for (const { key: flowKey, offset } of openFlows) {
381
+ for (const flowKey of openFlowKeys) {
587
382
  const graph = workingGraphs[flowKey]
588
383
  if (!graph) continue
589
384
  const fallbackPositions = mergedPositions ? {} : computeAutoLayout(graph)
590
- const liveCounts = computeLiveCounts(flowKey, rootFlowKey, livePositions)
385
+ const liveCounts = countLiveByNode({ flowKey, rootFlowKey, positions: livePositions })
591
386
  const flowIssues = issuesByFlow[flowKey] ?? []
592
387
  const isPrimary = flowKey === primaryFlowKey
593
388
 
@@ -606,8 +401,8 @@ export function FlowsWorkspace({
606
401
  const nsId = namespaceNodeId(flowKey, nodeId)
607
402
  return renderedPositionsRef.current.get(nsId) ?? mergedPositions.get(nsId) ?? { x: 0, y: 0 }
608
403
  }
609
- const local = graph!.nodes[nodeId]?.position ?? fallbackPositions[nodeId] ?? { x: 0, y: 0 }
610
- return { x: local.x + offset.x, y: local.y + offset.y }
404
+ // Fluxo sozinho no canvas: a posição salva no grafo é a do card, sem tradução no meio.
405
+ return graph!.nodes[nodeId]?.position ?? fallbackPositions[nodeId] ?? { x: 0, y: 0 }
611
406
  }
612
407
 
613
408
  for (const node of Object.values(graph.nodes)) {
@@ -636,7 +431,7 @@ export function FlowsWorkspace({
636
431
  const crossFlowTargets = [...new Set(targetsOf(node).map((edge) => edge.target).filter(isCrossFlowTarget))]
637
432
  crossFlowTargets.forEach((target, index) => {
638
433
  const targetFlowKey = crossFlowKey(target)
639
- if (openFlows.some((flow) => flow.key === targetFlowKey)) return
434
+ if (openFlowKeys.includes(targetFlowKey)) return
640
435
  allNodes.push({
641
436
  id: namespaceNodeId(flowKey, portalNodeId(node.id, target)),
642
437
  type: 'flowPortal',
@@ -645,7 +440,7 @@ export function FlowsWorkspace({
645
440
  position: { x: position.x + 320, y: position.y + index * 70 },
646
441
  data: {
647
442
  label: graphs?.[targetFlowKey]?.label ?? targetFlowKey,
648
- onNavigate: () => mergeFlow(targetFlowKey, { flowKey, nodeId: node.id }),
443
+ onNavigate: () => mergeFlow(targetFlowKey),
649
444
  } satisfies FlowPortalNodeData,
650
445
  })
651
446
  })
@@ -698,7 +493,7 @@ export function FlowsWorkspace({
698
493
  }
699
494
  return allNodes
700
495
  }, [
701
- openFlows,
496
+ openFlowKeys,
702
497
  workingGraphs,
703
498
  mergedPositions,
704
499
  livePositions,
@@ -714,8 +509,9 @@ export function FlowsWorkspace({
714
509
  ])
715
510
 
716
511
  const edges = useMemo(
717
- () => buildAllEdges(openFlows, workingGraphs, rootFlowKey, livePositions),
718
- [openFlows, workingGraphs, rootFlowKey, livePositions],
512
+ () =>
513
+ buildFlowEdges({ openKeys: openFlowKeys, graphs: workingGraphs, rootFlowKey, livePositions }).map(styleEdge),
514
+ [openFlowKeys, workingGraphs, rootFlowKey, livePositions],
719
515
  )
720
516
 
721
517
  useEffect(() => {
@@ -744,27 +540,16 @@ export function FlowsWorkspace({
744
540
  (_event: unknown, node: Node) => {
745
541
  renderedPositionsRef.current.set(node.id, node.position)
746
542
  const { flowKey, nodeId } = parseNamespacedId(node.id)
747
- const openFlow = openFlows.find((flow) => flow.key === flowKey)
748
- if (!openFlow) return
749
-
750
- /**
751
- * O deslocamento só entra na conta quando ele foi usado para DESENHAR.
752
- *
753
- * Com mais de um fluxo aberto o layout mesclado posiciona tudo em coordenadas absolutas e
754
- * ignora `offset` — subtrair aqui gravava no grafo uma posição que nunca foi a do card. O
755
- * sintoma aparecia só depois: recarregar a tela e achar o nó deslocado sozinho.
756
- */
757
- const drawnWithOffset = openFlows.length === 1
758
- const localPosition = drawnWithOffset
759
- ? { x: node.position.x - openFlow.offset.x, y: node.position.y - openFlow.offset.y }
760
- : node.position
543
+ if (!openFlowKeys.includes(flowKey)) return
544
+
545
+ // A posição do card É a posição do grafo: não há mais deslocamento por fluxo a desfazer aqui.
761
546
  updateFlow(flowKey, (graph) =>
762
547
  graph.nodes[nodeId]
763
- ? { ...graph, nodes: { ...graph.nodes, [nodeId]: { ...graph.nodes[nodeId]!, position: localPosition } } }
548
+ ? { ...graph, nodes: { ...graph.nodes, [nodeId]: { ...graph.nodes[nodeId]!, position: node.position } } }
764
549
  : graph,
765
550
  )
766
551
  },
767
- [openFlows, updateFlow],
552
+ [openFlowKeys, updateFlow],
768
553
  )
769
554
 
770
555
  const onConnect = useCallback(
@@ -815,17 +600,14 @@ export function FlowsWorkspace({
815
600
  renderedPositionsRef.current.clear()
816
601
 
817
602
  if (isMerged) {
818
- const positions = computeMergedLayout(openFlows, workingGraphs, primaryFlowKey)
819
- for (const { key, offset } of openFlows) {
603
+ const positions = computeMergedLayout({ openKeys: openFlowKeys, graphs: workingGraphs, primaryFlowKey })
604
+ for (const key of openFlowKeys) {
820
605
  updateFlow(key, (graph) => ({
821
606
  ...graph,
822
607
  nodes: Object.fromEntries(
823
608
  Object.entries(graph.nodes).map(([id, node]) => {
824
609
  const position = positions.get(namespaceNodeId(key, id))
825
- return [
826
- id,
827
- position ? { ...node, position: { x: position.x - offset.x, y: position.y - offset.y } } : node,
828
- ]
610
+ return [id, position ? { ...node, position } : node]
829
611
  }),
830
612
  ),
831
613
  }))
@@ -886,7 +668,7 @@ export function FlowsWorkspace({
886
668
  ...(newFlow.showInMenu ? { menuOptionLabel: newFlow.menuOptionLabel || newFlow.label } : {}),
887
669
  })
888
670
  await reloadGraphs()
889
- setOpenFlows([{ key: newFlow.key, offset: { x: 0, y: 0 } }])
671
+ setOpenFlowKeys([newFlow.key])
890
672
  setShowCreateDialog(false)
891
673
  setNewFlow({ key: '', label: '', showInMenu: false, menuOptionLabel: '' })
892
674
  setFlowMutationState({ pending: false })
@@ -902,7 +684,7 @@ export function FlowsWorkspace({
902
684
  try {
903
685
  await deleteFlow(primaryGraph.key)
904
686
  const reloaded = await reloadGraphs()
905
- setOpenFlows(reloaded ? autoMergeAll(rootFlowKey, reloaded) : [{ key: rootFlowKey, offset: { x: 0, y: 0 } }])
687
+ setOpenFlowKeys(reloaded ? mergedFlowKeysFrom(rootFlowKey, reloaded) : [rootFlowKey])
906
688
  setShowDeleteDialog(false)
907
689
  setFlowMutationState({ pending: false })
908
690
  } catch (error) {