@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2502 -676
  7. package/dist/index.d.ts +919 -17
  8. package/dist/index.js +3676 -675
  9. package/dist/preview/index.d.ts +62 -105
  10. package/dist/preview/index.js +162 -284
  11. package/dist/styles.css +893 -0
  12. package/package.json +9 -8
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +1 -1
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +11 -6
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +147 -47
  23. package/src/ConversationListItem.tsx +8 -6
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +53 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +67 -7
  29. package/src/EmojiPicker.tsx +2 -1
  30. package/src/InteractiveMessage.tsx +3 -0
  31. package/src/Lightbox.tsx +1 -1
  32. package/src/MediaRenderer.tsx +88 -15
  33. package/src/MessageBubble.test.tsx +41 -0
  34. package/src/MessageBubble.tsx +47 -5
  35. package/src/MessageComposer.test.tsx +35 -0
  36. package/src/MessageComposer.tsx +122 -17
  37. package/src/MessageText.tsx +2 -1
  38. package/src/MessageTimestamp.tsx +2 -1
  39. package/src/RichMessageComposer.test.tsx +113 -0
  40. package/src/RichMessageComposer.tsx +551 -0
  41. package/src/SimpleEmojiPicker.tsx +5 -3
  42. package/src/StatusTicks.tsx +1 -1
  43. package/src/Toast.tsx +4 -0
  44. package/src/Tooltip.test.ts +42 -0
  45. package/src/Tooltip.tsx +167 -0
  46. package/src/Wallpaper.tsx +27 -13
  47. package/src/WhatsAppMessageEditor.tsx +10 -7
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +550 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/flows/FlowConnectionEdge.tsx +104 -0
  60. package/src/flows/FlowGroupHeader.tsx +12 -2
  61. package/src/flows/FlowLegend.tsx +125 -0
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +219 -34
  65. package/src/flows/FlowNodePanel.tsx +153 -39
  66. package/src/flows/FlowPalette.tsx +156 -70
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1255 -0
  70. package/src/flows/flowCanvasModel.test.ts +456 -0
  71. package/src/flows/flowCanvasModel.ts +378 -0
  72. package/src/flows/flowEditorOps.test.ts +276 -0
  73. package/src/flows/flowEditorOps.ts +202 -0
  74. package/src/flows/flowGraph.ts +78 -53
  75. package/src/flows/flowMenuPlacement.test.ts +130 -0
  76. package/src/flows/flowMenuPlacement.ts +86 -0
  77. package/src/flows/index.ts +51 -2
  78. package/src/flows/labels.ts +180 -0
  79. package/src/flows/workspaceContract.test.ts +126 -0
  80. package/src/hooks/useContainerWidth.ts +35 -0
  81. package/src/hooks/useConversationRealtime.ts +10 -8
  82. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  83. package/src/hooks/useUrlFilterState.ts +107 -0
  84. package/src/icon.constant.ts +12 -0
  85. package/src/index.ts +100 -0
  86. package/src/lib/composer-formatting.test.ts +78 -0
  87. package/src/lib/composer-formatting.ts +145 -0
  88. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  89. package/src/lib/whatsapp-formatting.tsx +28 -3
  90. package/src/listing/index.tsx +202 -0
  91. package/src/pagination.constant.ts +10 -0
  92. package/src/preview/ConversationPreview.tsx +84 -45
  93. package/src/preview/ConversationSimulatorClient.ts +143 -0
  94. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  95. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  96. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  97. package/src/preview/createPreviewBridgeClient.ts +124 -0
  98. package/src/preview/createPreviewMediaUploader.ts +82 -0
  99. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  100. package/src/preview/createPreviewWebhookClient.ts +99 -3
  101. package/src/preview/index.ts +36 -2
  102. package/src/preview/previewMediaUploader.test.ts +61 -0
  103. package/src/providers/ConversationsProvider.tsx +8 -6
  104. package/src/providers/types.ts +59 -2
  105. package/src/quickReply.test.ts +58 -0
  106. package/src/replyLatency.test.ts +71 -0
  107. package/src/replyLatency.ts +57 -0
  108. package/src/settings/MessagesWorkspace.tsx +571 -0
  109. package/src/settings/TopicsForm.tsx +2 -0
  110. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  111. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  112. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  113. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  114. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  115. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  116. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  117. package/src/styles.css +858 -0
  118. package/src/theme.ts +13 -0
  119. package/src/types.ts +26 -0
  120. package/src/workspace/BulkTemplateModal.tsx +132 -0
  121. package/src/workspace/ConversationPane.tsx +432 -0
  122. package/src/workspace/ConversationsInboxList.tsx +194 -0
  123. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  124. package/src/workspace/index.ts +17 -0
  125. package/src/workspace/labels.test.ts +17 -0
  126. package/src/workspace/labels.ts +85 -0
  127. package/src/workspace/useConversationsInbox.ts +332 -0
  128. package/dist/types-B5C1DLu1.d.ts +0 -365
  129. package/src/preview/AudioRecorderButton.tsx +0 -117
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ *
4
+ * Operações puras de grafo que o editor de fluxo precisa, e que viviam soltas dentro da página de
5
+ * 973 linhas do financiamento.
6
+ *
7
+ * Puras e separadas do hook de propósito: são a parte que dá para testar sem navegador, sem React e
8
+ * sem estado — e três delas (`removeNodeAndCleanRefs`, `resolveConnection`, `mergedFlowKeysFrom`)
9
+ * decidem o que acontece com o fluxo que alguém desenhou. Errar ali não dá erro; dá aresta apontando
10
+ * para nó que não existe mais, ou fluxo que some do canvas.
11
+ */
12
+
13
+ // Os TIPOS do grafo vêm do meta-whatsapp-contracts, fonte única da verdade do trio
14
+ // (`pluggable-module.md` §2) — mesmo motivo documentado em `flowGraph.ts`. `import type` puro: nada
15
+ // do contracts entra por aqui em tempo de execução.
16
+ import type { FlowGraphData, FlowNodeData, FlowNodeNext } from '@adatechnology/meta-whatsapp-contracts'
17
+
18
+ // A constante vem do `flowGraph`, que já a define, e não do contracts: duas fontes do mesmo prefixo
19
+ // divergem em silêncio, e o sintoma seria salto entre fluxos que o motor do bot não reconhece.
20
+ import { CROSS_FLOW_PREFIX, crossFlowTargetsOf } from './flowGraph'
21
+
22
+ const NAMESPACE_SEPARATOR = '::'
23
+
24
+ /**
25
+ * Id de nó no canvas mesclado: vários fluxos dividem o mesmo espaço, e `boas-vindas` pode existir em
26
+ * dois deles. Sem o prefixo, arrastar um card moveria o homônimo do outro fluxo.
27
+ */
28
+ export function namespaceNodeId(flowKey: string, nodeId: string): string {
29
+ return `${flowKey}${NAMESPACE_SEPARATOR}${nodeId}`
30
+ }
31
+
32
+ export function parseNamespacedId(value: string): { flowKey: string; nodeId: string } {
33
+ const index = value.indexOf(NAMESPACE_SEPARATOR)
34
+ // Sem separador é id de fluxo único — trata como do fluxo vazio para o chamador decidir.
35
+ if (index === -1) return { flowKey: '', nodeId: value }
36
+ return { flowKey: value.slice(0, index), nodeId: value.slice(index + NAMESPACE_SEPARATOR.length) }
37
+ }
38
+
39
+ /**
40
+ * Apaga o nó E as referências a ele.
41
+ *
42
+ * A limpeza não é cortesia: uma aresta apontando para nó inexistente faz o motor do bot parar a
43
+ * conversa no meio, e o sintoma aparece para o cliente, não para quem editou.
44
+ */
45
+ export function removeNodeAndCleanRefs(
46
+ nodes: Readonly<Record<string, FlowNodeData>>,
47
+ removedId: string,
48
+ ): Record<string, FlowNodeData> {
49
+ const remaining: Record<string, FlowNodeData> = {}
50
+
51
+ for (const [id, node] of Object.entries(nodes)) {
52
+ if (id === removedId) continue
53
+ remaining[id] = { ...node, next: cleanNext(node.next, removedId) }
54
+ }
55
+
56
+ return remaining
57
+ }
58
+
59
+ function cleanNext(next: FlowNodeNext | undefined, removedId: string): FlowNodeNext | undefined {
60
+ if (next === undefined) return undefined
61
+ if (typeof next === 'string') return next === removedId ? '' : next
62
+
63
+ const byAnswer: Record<string, string> = {}
64
+ for (const [answer, target] of Object.entries(next.byAnswer ?? {})) {
65
+ // Resposta que levava ao nó apagado fica com destino vazio, e não é removida: apagar a chave
66
+ // esconderia da tela que aquela opção existe e não vai a lugar nenhum.
67
+ byAnswer[answer] = target === removedId ? '' : target
68
+ }
69
+
70
+ return { byAnswer, default: next.default === removedId ? '' : (next.default ?? '') }
71
+ }
72
+
73
+ /**
74
+ * O fecho transitivo dos fluxos alcançáveis a partir de um — é o conjunto que o canvas abre junto.
75
+ *
76
+ * BFS e não recursão: fluxo que referencia a si mesmo (menu que volta ao menu) é comum, e recursão
77
+ * ingênua estouraria a pilha no caso mais banal que existe.
78
+ */
79
+ export function mergedFlowKeysFrom(
80
+ rootKey: string,
81
+ graphs: Readonly<Record<string, FlowGraphData>>,
82
+ ): readonly string[] {
83
+ const visited = new Set<string>()
84
+ const queue = [rootKey]
85
+
86
+ while (queue.length > 0) {
87
+ const key = queue.shift()!
88
+ if (visited.has(key) || !graphs[key]) continue
89
+ visited.add(key)
90
+ for (const target of crossFlowTargetsOf(graphs[key]!)) {
91
+ if (!visited.has(target) && graphs[target]) queue.push(target)
92
+ }
93
+ }
94
+
95
+ return [...visited]
96
+ }
97
+
98
+ export type ConnectionRequest = {
99
+ readonly source: string
100
+ readonly target: string
101
+ readonly sourceHandle?: string | null | undefined
102
+ }
103
+
104
+ export type ResolvedConnection = {
105
+ readonly flowKey: string
106
+ readonly nodeId: string
107
+ readonly handle: string
108
+ /** O que gravar no `next`: id de nó local, ou `flow:<key>` quando atravessa fluxo. */
109
+ readonly targetValue: string
110
+ }
111
+
112
+ /**
113
+ * Traduz um arraste de aresta no valor que vai para o `next` — e recusa o que o motor do bot não
114
+ * sabe executar.
115
+ *
116
+ * A regra que não é óbvia: conectar num nó de OUTRO fluxo só funciona se for o nó inicial dele,
117
+ * porque o motor só sabe pular para o começo de um fluxo, não para um nó do meio. Conectar no meio
118
+ * devolve `undefined` — recusa silenciosa é melhor que gravar um salto que o bot vai ignorar em
119
+ * produção, deixando a conversa parada sem ninguém entender por quê.
120
+ */
121
+ export function resolveConnection(params: {
122
+ readonly connection: ConnectionRequest
123
+ readonly graphs: Readonly<Record<string, FlowGraphData>>
124
+ }): ResolvedConnection | undefined {
125
+ const { source, target, sourceHandle } = params.connection
126
+ if (!source || !target || source === target) return undefined
127
+
128
+ const sourceRef = parseNamespacedId(source)
129
+ const targetRef = parseNamespacedId(target)
130
+
131
+ let targetValue: string
132
+ if (targetRef.flowKey === sourceRef.flowKey) {
133
+ targetValue = targetRef.nodeId
134
+ } else {
135
+ const targetGraph = params.graphs[targetRef.flowKey]
136
+ if (!targetGraph || targetGraph.startNodeId !== targetRef.nodeId) return undefined
137
+ targetValue = `${CROSS_FLOW_PREFIX}${targetRef.flowKey}`
138
+ }
139
+
140
+ return {
141
+ flowKey: sourceRef.flowKey,
142
+ nodeId: sourceRef.nodeId,
143
+ handle: sourceHandle ?? 'next',
144
+ targetValue,
145
+ }
146
+ }
147
+
148
+ /** Aplica a conexão resolvida no nó. `next` string para saída única, objeto para ramificação. */
149
+ export function applyConnection(node: FlowNodeData, resolved: ResolvedConnection): FlowNodeData {
150
+ const currentNext = typeof node.next === 'object' && node.next ? node.next : undefined
151
+
152
+ if (resolved.handle === 'next') return { ...node, next: resolved.targetValue }
153
+
154
+ if (resolved.handle === '__default') {
155
+ return { ...node, next: { byAnswer: currentNext?.byAnswer ?? {}, default: resolved.targetValue } }
156
+ }
157
+
158
+ return {
159
+ ...node,
160
+ next: {
161
+ byAnswer: { ...(currentNext?.byAnswer ?? {}), [resolved.handle]: resolved.targetValue },
162
+ default: currentNext?.default ?? '',
163
+ },
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Um fluxo está sujo quando o rascunho difere do publicado.
169
+ *
170
+ * Comparação estrutural por JSON: é grosseira, e é suficiente porque o grafo é dado serializável sem
171
+ * ordem significativa de chave — o servidor devolve o que gravou. Comparar campo a campo daria a
172
+ * mesma resposta com mais código para errar.
173
+ */
174
+ export function isGraphDirty(working: FlowGraphData | undefined, published: FlowGraphData | undefined): boolean {
175
+ if (!working || !published) return false
176
+ return JSON.stringify(working) !== JSON.stringify(published)
177
+ }
178
+
179
+ /**
180
+ * Desliga UMA saída do nó, preservando as outras.
181
+ *
182
+ * O que não é óbvio: desligar é gravar destino vazio, nunca apagar a chave. Numa ramificação, a
183
+ * opção sem `byAnswer` some do card — e some junto o botão que o cliente via no WhatsApp, o que se
184
+ * lê como "o editor apagou minha opção". Vazio mantém a linha visível, cobrando a religação.
185
+ */
186
+ export function clearConnection(node: FlowNodeData, handle: string): FlowNodeData {
187
+ if (handle === 'next' || typeof node.next === 'string' || node.next === undefined) {
188
+ return { ...node, next: '' }
189
+ }
190
+
191
+ if (handle === '__default') {
192
+ return { ...node, next: { byAnswer: node.next.byAnswer ?? {}, default: '' } }
193
+ }
194
+
195
+ return {
196
+ ...node,
197
+ next: {
198
+ byAnswer: { ...(node.next.byAnswer ?? {}), [handle]: '' },
199
+ default: node.next.default ?? '',
200
+ },
201
+ }
202
+ }
@@ -13,6 +13,7 @@ import type {
13
13
  FlowNodeData,
14
14
  FlowGraphData,
15
15
  } from '@adatechnology/meta-whatsapp-contracts'
16
+ import { FLOW_ACTION_KIND } from '@adatechnology/meta-whatsapp-contracts'
16
17
 
17
18
  export type {
18
19
  FlowNodeType,
@@ -28,16 +29,25 @@ export const CONDITION_OPERATORS: FlowConditionOperator[] = ['>', '>=', '<', '<=
28
29
 
29
30
  // Kinds de ação genéricos que o pacote conhece de fábrica — o host pode registrar quaisquer
30
31
  // outros via `actionKindLabels`/`actionKinds` nos componentes (ver FlowPalette, labels.ts).
31
- export const BUILT_IN_ACTION_KINDS = {
32
- HANDOFF: 'handoff',
33
- RATE_LIMITED_HANDOFF: 'rate_limited_handoff',
34
- SEND_PRODUCT_LIST: 'send_product_list',
35
- } as const
32
+ // Reexporta o vocabulário do contrato em vez de redeclarar os literais: o editor oferece na
33
+ // paleta exatamente os `actionKind` que o backend sabe interpretar, e duas listas separadas
34
+ // divergiriam em silêncio (um nó publicável que nenhum handler atende).
35
+ export const BUILT_IN_ACTION_KINDS = FLOW_ACTION_KIND
36
36
 
37
37
  // Destinos "flow:<key>" são saltos para outro fluxo, resolvidos pelo motor do host. Duplicado
38
38
  // (em vez de importado do contracts) de propósito: são três linhas triviais e importá-las como
39
39
  // valor puxaria o runtime do contracts para o bundle do frontend só por causa disso. O contracts
40
40
  // exporta as mesmas funções para o backend; a convenção "flow:" é o contrato de fato.
41
+ /**
42
+ * Ações depois das quais a conversa CONTINUA no grafo, em vez de terminar.
43
+ *
44
+ * A distinção não é estética: o motor do bot só anda para o `next` depois de `send_media` — as
45
+ * demais ações (handoff, encerrar, simular, catálogo) encerram o passo ali. Dar saída a uma ação
46
+ * que o motor não atravessa desenharia um fio que o bot ignora em produção, deixando a conversa
47
+ * parada sem ninguém entender por quê — o mesmo erro que `resolveConnection` recusa cometer.
48
+ */
49
+ export const PASS_THROUGH_ACTION_KINDS: readonly string[] = ['send_media']
50
+
41
51
  export const CROSS_FLOW_PREFIX = 'flow:'
42
52
  export const isCrossFlowTarget = (target: string): boolean => target.startsWith(CROSS_FLOW_PREFIX)
43
53
  export const crossFlowKey = (target: string): string => target.slice(CROSS_FLOW_PREFIX.length)
@@ -54,7 +64,10 @@ export function estimateNodeHeight(node: FlowNodeData): number {
54
64
  const ROW_HEIGHT = 34
55
65
  const rowCount =
56
66
  node.type === 'action'
57
- ? 0
67
+ ? // Ação de passagem desenha uma linha de saída; terminal não desenha nenhuma.
68
+ node.actionKind && PASS_THROUGH_ACTION_KINDS.includes(node.actionKind)
69
+ ? 1
70
+ : 0
58
71
  : node.type === 'condition'
59
72
  ? 2
60
73
  : node.type === 'menu' || node.questionType === 'choice'
@@ -206,59 +219,71 @@ function findUnreachable(graph: FlowGraphData): string[] {
206
219
  return Object.keys(graph.nodes).filter((id) => !reachable.has(id))
207
220
  }
208
221
 
209
- // Auto-layout hierárquico (sem dependência externa): ranqueia por BFS a partir do início,
210
- // distribui cada camada horizontalmente e centraliza. Suficiente para fluxos de conversa
211
- // (grafos rasos, quase-árvores) sem puxar uma lib de layout inteira.
212
- export function computeAutoLayout(graph: FlowGraphData): Record<string, { x: number; y: number }> {
213
- const H_GAP = 300
214
- // Espaço extra entre a camada mais alta de uma linha e a próxima linha, além da altura real
215
- // do card mais alto dela sem isso, um menu com várias opções (card bem mais alto) encostaria
216
- // na camada de baixo mesmo com um gap fixo pensado pra cards curtos.
217
- const V_GAP = 90
218
- const rank: Record<string, number> = {}
219
- const queue: string[] = [graph.startNodeId]
220
- rank[graph.startNodeId] = 0
221
-
222
- while (queue.length > 0) {
223
- const id = queue.shift()!
224
- const node = graph.nodes[id]
225
- if (!node) continue
226
- for (const { target } of targetsOf(node)) {
227
- if (isCrossFlowTarget(target) || !graph.nodes[target]) continue
228
- if (rank[target] === undefined) {
229
- rank[target] = rank[id]! + 1
230
- queue.push(target)
231
- }
232
- }
222
+ /** Distância horizontal entre um card e o seguinte na cascata. */
223
+ export const LAYOUT_COLUMN_GAP = 300
224
+ /** Folga vertical entre um card e o de baixo, somada à altura real do de cima. */
225
+ export const LAYOUT_ROW_GAP = 40
226
+
227
+ /**
228
+ * Ordem de leitura do grafo: profundidade primeiro, seguindo as saídas na ordem em que o card as
229
+ * mostra.
230
+ *
231
+ * Profundidade, e não largura, porque é ela que mantém um caminho de conversa junto na tela — com
232
+ * BFS, os dois ramos de uma decisão se intercalam linha a linha e o olho perde qual leva a qual.
233
+ */
234
+ export function cascadeOrder(params: {
235
+ readonly rootId: string
236
+ readonly allIds: readonly string[]
237
+ readonly forwardEdges: (id: string) => readonly string[]
238
+ }): Map<string, { depth: number; order: number }> {
239
+ const placed = new Map<string, { depth: number; order: number }>()
240
+
241
+ function visit(id: string, depth: number): void {
242
+ if (placed.has(id)) return
243
+ placed.set(id, { depth, order: placed.size })
244
+ for (const next of params.forwardEdges(id)) visit(next, depth + 1)
233
245
  }
234
246
 
235
- // Nós não alcançáveis vão para uma camada abaixo da última, na ordem em que aparecem.
236
- const maxRank = Math.max(0, ...Object.values(rank))
237
- let strayRank = maxRank + 1
238
- for (const id of Object.keys(graph.nodes)) {
239
- if (rank[id] === undefined) rank[id] = strayRank++
240
- }
247
+ if (params.allIds.includes(params.rootId)) visit(params.rootId, 0)
241
248
 
242
- const layers: Record<number, string[]> = {}
243
- for (const [id, r] of Object.entries(rank)) {
244
- layers[r] = [...(layers[r] ?? []), id]
249
+ // Órfãos entram depois, todos na mesma coluna extra: uma coluna por órfão empurrava cada um para
250
+ // mais longe da área visível, e desligar um fio se lia como "o card sumiu".
251
+ const strayDepth = Math.max(0, ...[...placed.values()].map((each) => each.depth)) + 1
252
+ for (const id of params.allIds) {
253
+ if (!placed.has(id)) placed.set(id, { depth: strayDepth, order: placed.size })
245
254
  }
246
255
 
256
+ return placed
257
+ }
258
+
259
+ /**
260
+ * Auto-layout em cascata: avança para a direita a cada passo do fluxo e desce a cada card.
261
+ *
262
+ * **Um card por linha, sempre.** Empilhar por camada — todos do mesmo nível na mesma coluna, todas
263
+ * as colunas começando na mesma altura — deixava os fios correndo na horizontal, e um fio horizontal
264
+ * passa por trás de qualquer card que esteja entre a origem e o destino. Descendo um degrau por
265
+ * card, toda ligação vira uma diagonal curta e visível, e o caminho da conversa se lê de cima para
266
+ * baixo enquanto avança da esquerda para a direita.
267
+ */
268
+ export function computeAutoLayout(graph: FlowGraphData): Record<string, { x: number; y: number }> {
269
+ const placed = cascadeOrder({
270
+ rootId: graph.startNodeId,
271
+ allIds: Object.keys(graph.nodes),
272
+ forwardEdges: (id) =>
273
+ targetsOf(graph.nodes[id] ?? { id, type: 'action' })
274
+ .map((edge) => edge.target)
275
+ .filter((target) => !isCrossFlowTarget(target) && Boolean(graph.nodes[target])),
276
+ })
277
+
278
+ const byOrder = [...placed.entries()].sort((a, b) => a[1].order - b[1].order)
247
279
  const positions: Record<string, { x: number; y: number }> = {}
248
- const sortedRanks = Object.keys(layers)
249
- .map(Number)
250
- .sort((a, b) => a - b)
251
- let cumulativeY = 0
252
- for (const r of sortedRanks) {
253
- const ids = layers[r]!
254
- const width = (ids.length - 1) * H_GAP
255
- let maxHeight = 0
256
- ids.forEach((id, index) => {
257
- maxHeight = Math.max(maxHeight, estimateNodeHeight(graph.nodes[id]!))
258
- positions[id] = { x: index * H_GAP - width / 2, y: cumulativeY }
259
- })
260
- cumulativeY += maxHeight + V_GAP
280
+ let cursorY = 0
281
+
282
+ for (const [id, { depth }] of byOrder) {
283
+ positions[id] = { x: depth * LAYOUT_COLUMN_GAP, y: cursorY }
284
+ cursorY += estimateNodeHeight(graph.nodes[id]!) + LAYOUT_ROW_GAP
261
285
  }
286
+
262
287
  return positions
263
288
  }
264
289
 
@@ -372,7 +397,7 @@ export function findCollectionChains(graph: FlowGraphData): CollectionChain[] {
372
397
  }
373
398
 
374
399
  // Gera um id de nó único e legível a partir do rótulo (ex.: "Qual sua renda?" → "qual_sua_renda").
375
- export function slugifyNodeId(label: string, existing: Set<string>): string {
400
+ export function slugifyNodeId(label: string, existing: ReadonlySet<string>): string {
376
401
  const base =
377
402
  label
378
403
  .toLowerCase()
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ *
4
+ * O caso que originou isto: o submenu "Ação" aberto perto do rodapé mostrava metade da lista, com
5
+ * "Enviar catálogo de produtos" cortado pela borda — e sem rolagem, o item era inalcançável.
6
+ */
7
+
8
+ import { describe, expect, it } from 'bun:test'
9
+
10
+ import { placeFloatingPanel } from './flowMenuPlacement'
11
+
12
+ const VIEWPORT = { width: 1200, height: 800 }
13
+ const rect = (left: number, top: number, width = 0, height = 0) => ({
14
+ left,
15
+ top,
16
+ right: left + width,
17
+ bottom: top + height,
18
+ })
19
+
20
+ describe('menu abaixo do ponto clicado', () => {
21
+ it('fica onde foi pedido quando há espaço', () => {
22
+ const placement = placeFloatingPanel({
23
+ anchor: rect(100, 100),
24
+ panel: { width: 256, height: 300 },
25
+ viewport: VIEWPORT,
26
+ prefer: 'below',
27
+ })
28
+
29
+ expect(placement.left).toBeGreaterThanOrEqual(100)
30
+ expect(placement.top).toBe(100)
31
+ expect(placement.maxHeight).toBe(300)
32
+ })
33
+
34
+ it('encosta na borda direita em vez de sair dela', () => {
35
+ const placement = placeFloatingPanel({
36
+ anchor: rect(1150, 100),
37
+ panel: { width: 256, height: 300 },
38
+ viewport: VIEWPORT,
39
+ prefer: 'below',
40
+ })
41
+
42
+ expect(placement.left + 256).toBeLessThanOrEqual(VIEWPORT.width)
43
+ })
44
+
45
+ it('sobe quando não cabe para baixo, em vez de vazar pelo rodapé', () => {
46
+ const placement = placeFloatingPanel({
47
+ anchor: rect(100, 700),
48
+ panel: { width: 256, height: 300 },
49
+ viewport: VIEWPORT,
50
+ prefer: 'below',
51
+ })
52
+
53
+ expect(placement.top + placement.maxHeight).toBeLessThanOrEqual(VIEWPORT.height)
54
+ expect(placement.top).toBeLessThan(700)
55
+ })
56
+ })
57
+
58
+ describe('submenu ao lado do item', () => {
59
+ it('abre à direita quando cabe', () => {
60
+ const placement = placeFloatingPanel({
61
+ anchor: rect(300, 200, 256, 36),
62
+ panel: { width: 256, height: 240 },
63
+ viewport: VIEWPORT,
64
+ prefer: 'side',
65
+ })
66
+
67
+ expect(placement.left).toBeGreaterThanOrEqual(300 + 256)
68
+ })
69
+
70
+ it('vira para a esquerda quando não cabe à direita', () => {
71
+ const placement = placeFloatingPanel({
72
+ anchor: rect(900, 200, 256, 36),
73
+ panel: { width: 256, height: 240 },
74
+ viewport: VIEWPORT,
75
+ prefer: 'side',
76
+ })
77
+
78
+ expect(placement.left).toBeLessThan(900)
79
+ expect(placement.left).toBeGreaterThanOrEqual(0)
80
+ })
81
+
82
+ it('lista longa perto do rodapé ganha rolagem em vez de ser cortada', () => {
83
+ // O caso da captura: submenu de ações mais alto que o espaço abaixo do item.
84
+ const placement = placeFloatingPanel({
85
+ anchor: rect(300, 620, 256, 36),
86
+ panel: { width: 256, height: 520 },
87
+ viewport: VIEWPORT,
88
+ prefer: 'side',
89
+ })
90
+
91
+ expect(placement.top + placement.maxHeight).toBeLessThanOrEqual(VIEWPORT.height)
92
+ expect(placement.maxHeight).toBeLessThanOrEqual(520)
93
+ })
94
+
95
+ it('painel mais alto que a tela cabe inteiro na tela, rolando por dentro', () => {
96
+ const placement = placeFloatingPanel({
97
+ anchor: rect(300, 400, 256, 36),
98
+ panel: { width: 256, height: 2000 },
99
+ viewport: VIEWPORT,
100
+ prefer: 'side',
101
+ })
102
+
103
+ expect(placement.top).toBeGreaterThanOrEqual(0)
104
+ expect(placement.maxHeight).toBeLessThan(VIEWPORT.height)
105
+ expect(placement.top + placement.maxHeight).toBeLessThanOrEqual(VIEWPORT.height)
106
+ })
107
+ })
108
+
109
+ describe('os menus do editor usam a conta acima', () => {
110
+ /**
111
+ * A função pura pode estar correta e ninguém chamá-la. Estes dois amarram o uso: sem eles a
112
+ * correção some no primeiro refactor e o menu volta a encostar na borda.
113
+ */
114
+ it('o menu do "+" mede antes de posicionar, em vez de usar a âncora crua', async () => {
115
+ const content = await Bun.file(`${import.meta.dir}/FlowsWorkspace.tsx`).text()
116
+
117
+ expect(content).toContain('placeFloatingPanel')
118
+ expect(content).not.toContain('left: quickAddFrom.anchor.x + 12')
119
+ })
120
+
121
+ it('o submenu não fica mais preso ao item por CSS', async () => {
122
+ const content = await Bun.file(`${import.meta.dir}/FlowPalette.tsx`).text()
123
+
124
+ expect(content).toContain('placeFloatingPanel')
125
+ // Verifica o uso em JSX, não a menção: o comentário do arquivo cita a classe antiga para
126
+ // explicar o que mudou, e casar com o texto solto reprovaria a própria explicação.
127
+ expect(content).not.toContain('className="absolute left-full')
128
+ expect(content).toContain('overflowY')
129
+ })
130
+ })
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ *
4
+ * Onde um menu flutuante do editor pode aparecer sem sair da tela.
5
+ *
6
+ * Existe porque tanto o menu do "+" quanto os submenus dele eram posicionados por uma âncora crua:
7
+ * o menu em `fixed` na coordenada do clique, os submenus em `left-full top-0`. Card perto da borda
8
+ * direita jogava o menu para fora; item de ação perto do rodapé cortava a lista no meio, e não havia
9
+ * como rolar nem alcançar o resto — o menu ficava preso contra a borda.
10
+ *
11
+ * A conta é pura de propósito: é ela que decide se o operador consegue clicar na opção, e testá-la
12
+ * exige apenas números.
13
+ */
14
+
15
+ /** Retângulo do gatilho. Um clique é um retângulo de tamanho zero. */
16
+ export type AnchorRect = {
17
+ readonly left: number
18
+ readonly top: number
19
+ readonly right: number
20
+ readonly bottom: number
21
+ }
22
+
23
+ export type PanelSize = {
24
+ readonly width: number
25
+ readonly height: number
26
+ }
27
+
28
+ export type ViewportSize = {
29
+ readonly width: number
30
+ readonly height: number
31
+ }
32
+
33
+ export type FloatingPlacement = {
34
+ readonly left: number
35
+ readonly top: number
36
+ /** Teto de altura: acima disso o painel rola por dentro em vez de vazar pelo rodapé. */
37
+ readonly maxHeight: number
38
+ }
39
+
40
+ export type PlaceFloatingPanelParams = {
41
+ readonly anchor: AnchorRect
42
+ readonly panel: PanelSize
43
+ readonly viewport: ViewportSize
44
+ /** `side`: submenu sai ao lado do item; `below`: menu sai abaixo do ponto clicado. */
45
+ readonly prefer: 'side' | 'below'
46
+ readonly gap?: number
47
+ readonly margin?: number
48
+ }
49
+
50
+ const DEFAULT_GAP = 4
51
+ const DEFAULT_MARGIN = 8
52
+
53
+ function clamp(value: number, minimum: number, maximum: number): number {
54
+ // `maximum` menor que `minimum` acontece com painel maior que a viewport: a margem de cima manda,
55
+ // porque cortar em cima esconde o começo da lista.
56
+ return Math.max(minimum, Math.min(value, Math.max(minimum, maximum)))
57
+ }
58
+
59
+ export function placeFloatingPanel({
60
+ anchor,
61
+ panel,
62
+ viewport,
63
+ prefer,
64
+ gap = DEFAULT_GAP,
65
+ margin = DEFAULT_MARGIN,
66
+ }: PlaceFloatingPanelParams): FloatingPlacement {
67
+ const maxHeight = Math.min(panel.height, viewport.height - margin * 2)
68
+
69
+ let left: number
70
+ if (prefer === 'side') {
71
+ // Vira para a esquerda quando não cabe à direita — o inverso de escolher sempre um lado.
72
+ const toTheRight = anchor.right + gap
73
+ const toTheLeft = anchor.left - gap - panel.width
74
+ left = toTheRight + panel.width <= viewport.width - margin ? toTheRight : toTheLeft
75
+ } else {
76
+ left = anchor.left + gap
77
+ }
78
+ left = clamp(left, margin, viewport.width - margin - panel.width)
79
+
80
+ const top = prefer === 'side' ? anchor.top : anchor.bottom
81
+ return {
82
+ left,
83
+ top: clamp(top, margin, viewport.height - margin - maxHeight),
84
+ maxHeight,
85
+ }
86
+ }
@@ -6,15 +6,19 @@ export { FlowMapCanvas } from './FlowMapCanvas'
6
6
  export { FlowGroupFrame, flowGroupFrameNodeTypes } from './FlowGroupFrame'
7
7
  export { FlowGroupHeader, flowGroupHeaderNodeTypes } from './FlowGroupHeader'
8
8
  export { FlowPortalNode, flowPortalNodeTypes } from './FlowPortalNode'
9
- export { FlowPalette } from './FlowPalette'
9
+ export { FlowPalette, FlowPaletteMenu } from './FlowPalette'
10
+ export { FlowLegend } from './FlowLegend'
11
+ export { FlowConnectionEdge, flowEdgeTypes } from './FlowConnectionEdge'
10
12
  export { FlowNodePanel } from './FlowNodePanel'
11
13
  export { FlowWhatsAppPreview } from './FlowWhatsAppPreview'
14
+ export { FlowsWorkspace } from './FlowsWorkspace'
12
15
 
13
16
  export { DEFAULT_FLOW_EDITOR_LABELS, mergeFlowEditorLabels } from './labels'
14
17
 
15
18
  export {
16
19
  CONDITION_OPERATORS,
17
20
  BUILT_IN_ACTION_KINDS,
21
+ PASS_THROUGH_ACTION_KINDS,
18
22
  NODE_CARD_WIDTH,
19
23
  WHATSAPP_LIMITS,
20
24
  CROSS_FLOW_PREFIX,
@@ -43,7 +47,15 @@ export type {
43
47
  CollectionChain,
44
48
  } from './flowGraph'
45
49
 
46
- export type { FlowEditorLabels } from './labels'
50
+ export type { FlowEditorLabels, FlowValidationLabels } from './labels'
51
+ export type {
52
+ FlowsWorkspaceProps,
53
+ FlowsWorkspaceApi,
54
+ FlowLivePosition,
55
+ FlowLiveNodeCount,
56
+ FlowLivePositionInput,
57
+ CreateFlowInput,
58
+ } from './FlowsWorkspace'
47
59
  export type { FlowNodeCardData } from './FlowNodeCard'
48
60
  export type { FlowMapNodeData } from './FlowMapNode'
49
61
  export type { FlowMapCanvasProps } from './FlowMapCanvas'
@@ -53,3 +65,40 @@ export type { FlowPortalNodeData } from './FlowPortalNode'
53
65
  export type { FlowPaletteProps, FlowPaletteActionOption, NewNodeSpec } from './FlowPalette'
54
66
  export type { FlowNodePanelProps } from './FlowNodePanel'
55
67
  export type { FlowWhatsAppPreviewProps } from './FlowWhatsAppPreview'
68
+
69
+ /**
70
+ * Operações do EDITOR de fluxo, puras. Vinham soltas dentro da página de 973 linhas do
71
+ * financiamento; são a base do `FlowsWorkspace` (ADR 0002) e a parte que carrega o risco de perder
72
+ * trabalho de quem edita.
73
+ */
74
+ export {
75
+ applyConnection,
76
+ isGraphDirty,
77
+ mergedFlowKeysFrom,
78
+ namespaceNodeId,
79
+ parseNamespacedId,
80
+ removeNodeAndCleanRefs,
81
+ resolveConnection,
82
+ } from './flowEditorOps'
83
+ export type { ConnectionRequest, ResolvedConnection } from './flowEditorOps'
84
+
85
+ /**
86
+ * Modelo do canvas: posições, arestas, contagem ao vivo e o nó novo da paleta.
87
+ *
88
+ * Exportado porque é consumido pelo `FlowsWorkspace` e tem teste próprio — as decisões daqui não dão
89
+ * erro quando erram, dão conversa parada e card fora da tela.
90
+ */
91
+ export {
92
+ buildFlowEdges,
93
+ chainFrameBounds,
94
+ chainFrameNodeId,
95
+ computeMergedLayout,
96
+ countLiveByNode,
97
+ detachedNodeIds,
98
+ newNodeFromSpec,
99
+ portalNodeId,
100
+ GROUP_HEADER_NODE_ID,
101
+ } from './flowCanvasModel'
102
+ // Os formatos de posição viva já saem pelo `FlowsWorkspace`, que é onde o produto os encontra ao
103
+ // implementar a api — exportar de novo aqui daria dois caminhos para o mesmo tipo.
104
+ export type { FlowEdgeKind, FlowEdgeSpec, FlowNodePosition } from './flowCanvasModel'