@adatechnology/conversations-ui 0.1.0-rc.8 → 0.1.0

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 (132) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/chunk-BJNRLLDO.js +2708 -0
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-OGRRHQQW.js → chunk-WCBDXZ3X.js} +68 -4
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2502 -678
  7. package/dist/index.d.ts +921 -17
  8. package/dist/index.js +3677 -678
  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.test.tsx +21 -0
  47. package/src/Wallpaper.tsx +67 -7
  48. package/src/WhatsAppMessageEditor.tsx +10 -7
  49. package/src/WindowExpiredNotice.tsx +12 -4
  50. package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
  51. package/src/buildOutput.test.ts +79 -0
  52. package/src/composer.constant.ts +33 -0
  53. package/src/conversationTranscript.test.ts +57 -0
  54. package/src/conversationTranscript.ts +29 -4
  55. package/src/conversationWindow.ts +7 -5
  56. package/src/documentTypeLabel.test.ts +57 -0
  57. package/src/documents/DocumentsWorkspace.tsx +550 -0
  58. package/src/documents/index.ts +8 -0
  59. package/src/documents/labels.ts +92 -0
  60. package/src/flows/FlowConnectionEdge.tsx +104 -0
  61. package/src/flows/FlowGroupHeader.tsx +12 -2
  62. package/src/flows/FlowLegend.tsx +125 -0
  63. package/src/flows/FlowMapCanvas.tsx +15 -12
  64. package/src/flows/FlowMapNode.tsx +4 -1
  65. package/src/flows/FlowNodeCard.tsx +219 -34
  66. package/src/flows/FlowNodePanel.tsx +153 -39
  67. package/src/flows/FlowPalette.tsx +156 -70
  68. package/src/flows/FlowPortalNode.tsx +1 -1
  69. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  70. package/src/flows/FlowsWorkspace.tsx +1255 -0
  71. package/src/flows/flowCanvasModel.test.ts +456 -0
  72. package/src/flows/flowCanvasModel.ts +378 -0
  73. package/src/flows/flowEditorOps.test.ts +276 -0
  74. package/src/flows/flowEditorOps.ts +202 -0
  75. package/src/flows/flowGraph.ts +78 -53
  76. package/src/flows/flowMenuPlacement.test.ts +130 -0
  77. package/src/flows/flowMenuPlacement.ts +86 -0
  78. package/src/flows/index.ts +51 -2
  79. package/src/flows/labels.ts +180 -0
  80. package/src/flows/workspaceContract.test.ts +126 -0
  81. package/src/hooks/useContainerWidth.ts +35 -0
  82. package/src/hooks/useConversationRealtime.ts +10 -8
  83. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  84. package/src/hooks/useUrlFilterState.ts +107 -0
  85. package/src/icon.constant.ts +12 -0
  86. package/src/index.ts +100 -0
  87. package/src/lib/composer-formatting.test.ts +78 -0
  88. package/src/lib/composer-formatting.ts +145 -0
  89. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  90. package/src/lib/whatsapp-formatting.tsx +28 -3
  91. package/src/listing/index.tsx +202 -0
  92. package/src/pagination.constant.ts +10 -0
  93. package/src/preview/ConversationPreview.tsx +84 -45
  94. package/src/preview/ConversationSimulatorClient.ts +143 -0
  95. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  96. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  97. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  98. package/src/preview/createPreviewBridgeClient.ts +124 -0
  99. package/src/preview/createPreviewMediaUploader.ts +82 -0
  100. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  101. package/src/preview/createPreviewWebhookClient.ts +99 -3
  102. package/src/preview/index.ts +36 -2
  103. package/src/preview/previewMediaUploader.test.ts +61 -0
  104. package/src/providers/ConversationsProvider.tsx +8 -6
  105. package/src/providers/types.ts +59 -2
  106. package/src/quickReply.test.ts +58 -0
  107. package/src/replyLatency.test.ts +71 -0
  108. package/src/replyLatency.ts +57 -0
  109. package/src/settings/MessagesWorkspace.tsx +571 -0
  110. package/src/settings/TopicsForm.tsx +2 -0
  111. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  112. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  113. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  114. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  115. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  116. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  117. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  118. package/src/styles.css +858 -0
  119. package/src/theme.ts +13 -0
  120. package/src/types.ts +26 -0
  121. package/src/workspace/BulkTemplateModal.tsx +132 -0
  122. package/src/workspace/ConversationPane.tsx +432 -0
  123. package/src/workspace/ConversationsInboxList.tsx +194 -0
  124. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  125. package/src/workspace/index.ts +17 -0
  126. package/src/workspace/labels.test.ts +17 -0
  127. package/src/workspace/labels.ts +85 -0
  128. package/src/workspace/useConversationsInbox.ts +332 -0
  129. package/dist/chunk-73MW5HNT.js +0 -1717
  130. package/dist/chunk-NV2RZ5KT.js +0 -56
  131. package/dist/types-B5C1DLu1.d.ts +0 -365
  132. package/src/preview/AudioRecorderButton.tsx +0 -117
@@ -3,6 +3,7 @@ import type { FlowConditionOperator, FlowNodeType, FlowQuestionType } from './fl
3
3
  export interface FlowEditorLabels {
4
4
  legend: Record<FlowNodeType, string>
5
5
  startNodeTooltip: string
6
+ detachedNodeTooltip: string
6
7
  liveCountTooltip: (count: number) => string
7
8
  edgeFallbackLabel: string
8
9
  // Rótulo por `actionKind` — o host estende esse mapa para registrar seus próprios kinds
@@ -13,10 +14,15 @@ export interface FlowEditorLabels {
13
14
  nodePanel: {
14
15
  title: string
15
16
  contextKey: string
17
+ nodeName: string
18
+ nodeNamePlaceholder: string
19
+ nodeNameHint: string
16
20
  questionType: string
17
21
  question: string
18
22
  options: string
19
23
  addOption: string
24
+ removeOption: string
25
+ close: string
20
26
  optionId: string
21
27
  optionLabel: string
22
28
  next: string
@@ -47,6 +53,27 @@ export interface FlowEditorLabels {
47
53
  conditionTrue: string
48
54
  conditionFalse: string
49
55
  conditionVariableMissing: string
56
+ media: string
57
+ mediaUnavailable: string
58
+ }
59
+ quickAdd: {
60
+ fromHandle: string
61
+ title: string
62
+ disconnect: string
63
+ }
64
+ /** Legenda do canvas: o que cada traço e cada contorno querem dizer. */
65
+ legendPanel: {
66
+ title: string
67
+ nodes: string
68
+ connections: string
69
+ linear: string
70
+ branch: string
71
+ fallback: string
72
+ crossFlow: string
73
+ live: string
74
+ selfLoop: string
75
+ detached: string
76
+ startNode: string
50
77
  }
51
78
  palette: {
52
79
  title: string
@@ -59,6 +86,8 @@ export interface FlowEditorLabels {
59
86
  flowMap: {
60
87
  nodeCount: (count: number) => string
61
88
  openFlow: string
89
+ toggleToMap: string
90
+ toggleToDetail: string
62
91
  }
63
92
  flowGroup: {
64
93
  focus: string
@@ -68,6 +97,67 @@ export interface FlowEditorLabels {
68
97
  tooltip: string
69
98
  goesTo: (label: string) => string
70
99
  }
100
+ collectionChain: {
101
+ feeds: (label: string) => string
102
+ }
103
+ /**
104
+ * Texto dos problemas encontrados por `validateGraph`. Vive aqui, e não no host, porque a tela
105
+ * composta é quem valida — deixar de fora obrigaria todo produto a repassar o mesmo mapa de
106
+ * funções só para a barra de erros aparecer.
107
+ */
108
+ validation: FlowValidationLabels
109
+ /** Barra de cima, estados de carregamento e ações do editor inteiro. */
110
+ workspace: {
111
+ title: string
112
+ subtitle: string
113
+ loading: string
114
+ loadError: string
115
+ saveGraph: string
116
+ saving: string
117
+ saveSuccess: string
118
+ saveError: string
119
+ organize: string
120
+ organizeTooltip: string
121
+ discardChanges: string
122
+ discardTooltip: string
123
+ discardConfirm: string
124
+ unsavedChangesConfirm: string
125
+ }
126
+ flowManager: {
127
+ newFlow: string
128
+ createTitle: string
129
+ key: string
130
+ keyHint: string
131
+ keyInvalid: string
132
+ label: string
133
+ showInMenu: string
134
+ menuOptionLabel: string
135
+ create: string
136
+ creating: string
137
+ deleteFlow: string
138
+ deleteConfirm: (label: string) => string
139
+ createError: string
140
+ deleteError: string
141
+ }
142
+ }
143
+
144
+ export interface FlowValidationLabels {
145
+ title: string
146
+ errors: (count: number) => string
147
+ warnings: (count: number) => string
148
+ noStart: string
149
+ brokenRef: (from: string, to: string) => string
150
+ choiceWithoutOptions: (id: string) => string
151
+ duplicatedOptionId: (id: string, optionId: string) => string
152
+ optionWithoutTarget: (id: string, optionLabel: string) => string
153
+ tooManyOptions: (id: string, count: number) => string
154
+ buttonTitleTooLong: (id: string, label: string) => string
155
+ listTitleTooLong: (id: string, label: string) => string
156
+ bodyTooLong: (id: string) => string
157
+ unreachable: (id: string) => string
158
+ deadEndQuestion: (id: string) => string
159
+ conditionIncomplete: (id: string) => string
160
+ conditionBranchMissing: (id: string, branch: string) => string
71
161
  }
72
162
 
73
163
  // Paridade de texto com financiamento-imobiliario-bot/apps/web/src/locales/modules/flows.ts —
@@ -81,12 +171,14 @@ export const DEFAULT_FLOW_EDITOR_LABELS: FlowEditorLabels = {
81
171
  condition: 'Condição',
82
172
  },
83
173
  startNodeTooltip: 'Início do fluxo',
174
+ detachedNodeTooltip: 'Sem ligação de entrada — o bot não chega neste nó. Puxe um fio de outro card até ele.',
84
175
  liveCountTooltip: (count) => `${count} conversa(s) ativa(s) aqui agora`,
85
176
  edgeFallbackLabel: 'outro',
86
177
  actionKindLabels: {
87
178
  handoff: 'Encaminhar para atendimento',
88
179
  rate_limited_handoff: 'Encaminhar (limite de simulações atingido)',
89
180
  send_product_list: 'Enviar catálogo de produtos',
181
+ send_media: 'Enviar arquivos da biblioteca',
90
182
  },
91
183
  conditionOperatorLabels: {
92
184
  '>': 'maior que',
@@ -108,10 +200,15 @@ export const DEFAULT_FLOW_EDITOR_LABELS: FlowEditorLabels = {
108
200
  nodePanel: {
109
201
  title: 'Editar nó',
110
202
  contextKey: 'Chave (contexto)',
203
+ nodeName: 'Nome do nó (opcional)',
204
+ nodeNamePlaceholder: 'Ex.: Enviar tabela de preços',
205
+ nodeNameHint: 'Só aparece no editor — o cliente não vê.',
111
206
  questionType: 'Tipo de resposta',
112
207
  question: 'Texto da pergunta',
113
208
  options: 'Opções (choice)',
114
209
  addOption: 'Adicionar opção',
210
+ removeOption: 'Remover opção',
211
+ close: 'Fechar painel',
115
212
  optionId: 'Valor',
116
213
  optionLabel: 'Texto exibido',
117
214
  next: 'Próximo nó',
@@ -143,6 +240,26 @@ export const DEFAULT_FLOW_EDITOR_LABELS: FlowEditorLabels = {
143
240
  conditionTrue: 'Se verdadeiro →',
144
241
  conditionFalse: 'Se falso →',
145
242
  conditionVariableMissing: 'Se a variável ainda não foi coletada →',
243
+ media: 'Arquivos enviados neste ponto',
244
+ mediaUnavailable: 'A biblioteca de arquivos não está disponível neste painel.',
245
+ },
246
+ quickAdd: {
247
+ fromHandle: 'Criar o próximo nó já ligado aqui',
248
+ title: 'Ligar em um nó novo',
249
+ disconnect: 'Desligar este fio (o nó continua no fluxo)',
250
+ },
251
+ legendPanel: {
252
+ title: 'Legenda',
253
+ nodes: 'Cards',
254
+ connections: 'Ligações',
255
+ linear: 'Segue direto para o próximo',
256
+ branch: 'Caminho de uma opção escolhida',
257
+ fallback: 'Quando a resposta não casa com nenhuma opção',
258
+ crossFlow: 'Salta para outro fluxo',
259
+ live: 'Tem conversa passando por aqui agora',
260
+ selfLoop: 'Volta ao mesmo card — repete a pergunta',
261
+ detached: 'Ninguém aponta para este card: o bot não chega nele',
262
+ startNode: 'Onde o fluxo começa',
146
263
  },
147
264
  palette: {
148
265
  title: 'Adicionar ao fluxo',
@@ -155,6 +272,8 @@ export const DEFAULT_FLOW_EDITOR_LABELS: FlowEditorLabels = {
155
272
  flowMap: {
156
273
  nodeCount: (count) => `${count} nó(s)`,
157
274
  openFlow: 'Abrir fluxo',
275
+ toggleToMap: 'Mapa de fluxos',
276
+ toggleToDetail: 'Voltar ao editor',
158
277
  },
159
278
  flowGroup: {
160
279
  focus: 'Focar neste fluxo',
@@ -164,6 +283,61 @@ export const DEFAULT_FLOW_EDITOR_LABELS: FlowEditorLabels = {
164
283
  tooltip: 'Clique para abrir esse fluxo aqui do lado, ligado ao ponto de onde ele é chamado',
165
284
  goesTo: (label) => `↪ Vai para: ${label}`,
166
285
  },
286
+ collectionChain: {
287
+ feeds: (label) => `Alimenta: ${label}`,
288
+ },
289
+ validation: {
290
+ title: 'Antes de publicar',
291
+ errors: (count) => `${count} erro(s) — corrija antes de salvar`,
292
+ warnings: (count) => `${count} aviso(s)`,
293
+ noStart: 'O fluxo precisa de um nó inicial válido.',
294
+ brokenRef: (from, to) => `Nó "${from}": ligação aponta para "${to}", que não existe.`,
295
+ choiceWithoutOptions: (id) => `Nó "${id}": escolha sem nenhuma opção.`,
296
+ duplicatedOptionId: (id, optionId) => `Nó "${id}": valor de opção "${optionId}" duplicado.`,
297
+ optionWithoutTarget: (id, label) => `Nó "${id}": opção "${label}" não tem destino definido.`,
298
+ tooManyOptions: (id, count) => `Nó "${id}": ${count} opções — o WhatsApp aceita no máximo 10 em lista.`,
299
+ buttonTitleTooLong: (id, label) => `Nó "${id}": botão "${label}" passa de 20 caracteres.`,
300
+ listTitleTooLong: (id, label) => `Nó "${id}": item de lista "${label}" passa de 24 caracteres.`,
301
+ bodyTooLong: (id) => `Nó "${id}": texto passa de 1024 caracteres.`,
302
+ unreachable: (id) => `Nó "${id}" é inalcançável a partir do início do fluxo.`,
303
+ deadEndQuestion: (id) => `Nó "${id}": pergunta sem próximo passo definido.`,
304
+ conditionIncomplete: (id) => `Nó "${id}": condição incompleta — defina variável, operador e valor.`,
305
+ conditionBranchMissing: (id, branch) =>
306
+ `Nó "${id}": ramo "${branch === 'true' ? 'Verdadeiro' : 'Falso'}" sem destino definido.`,
307
+ },
308
+ workspace: {
309
+ title: 'Fluxos do Bot',
310
+ subtitle: 'Blueprint visual dos fluxos de conversa, sincronizado com o que está em produção.',
311
+ loading: 'Carregando fluxos…',
312
+ loadError: 'Não foi possível carregar os fluxos.',
313
+ saveGraph: 'Publicar alterações',
314
+ saving: 'Publicando…',
315
+ saveSuccess: 'Fluxo publicado! O bot já está usando a versão nova.',
316
+ saveError: 'Não foi possível salvar — verifique se todos os destinos apontam para nós existentes.',
317
+ organize: 'Organizar',
318
+ organizeTooltip: 'Reorganiza os nós automaticamente e salva as novas posições',
319
+ discardChanges: 'Desfazer alterações',
320
+ discardTooltip: 'Devolve os fluxos abertos ao que está publicado, descartando o que não foi salvo',
321
+ discardConfirm: 'Descartar todas as alterações não publicadas e voltar ao fluxo que está no ar?',
322
+ unsavedChangesConfirm:
323
+ 'Você tem alterações não publicadas neste fluxo. Trocar de fluxo agora descarta essas edições. Continuar?',
324
+ },
325
+ flowManager: {
326
+ newFlow: 'Novo fluxo',
327
+ createTitle: 'Criar novo fluxo',
328
+ key: 'Identificador único',
329
+ keyHint: 'letras minúsculas, números e _ (ex.: promocoes_semana)',
330
+ keyInvalid: 'Use apenas letras minúsculas, números e _ (2 a 40 caracteres)',
331
+ label: 'Nome exibido',
332
+ showInMenu: 'Exibir como opção no menu principal do bot',
333
+ menuOptionLabel: 'Texto da opção no menu',
334
+ create: 'Criar fluxo',
335
+ creating: 'Criando…',
336
+ deleteFlow: 'Excluir fluxo',
337
+ deleteConfirm: (label) => `Excluir o fluxo "${label}"? Esta ação não pode ser desfeita.`,
338
+ createError: 'Não foi possível criar o fluxo.',
339
+ deleteError: 'Não foi possível excluir o fluxo.',
340
+ },
167
341
  }
168
342
 
169
343
  export function mergeFlowEditorLabels(override?: Partial<FlowEditorLabels>): FlowEditorLabels {
@@ -179,9 +353,15 @@ export function mergeFlowEditorLabels(override?: Partial<FlowEditorLabels>): Flo
179
353
  },
180
354
  questionTypeLabels: { ...DEFAULT_FLOW_EDITOR_LABELS.questionTypeLabels, ...override.questionTypeLabels },
181
355
  nodePanel: { ...DEFAULT_FLOW_EDITOR_LABELS.nodePanel, ...override.nodePanel },
356
+ quickAdd: { ...DEFAULT_FLOW_EDITOR_LABELS.quickAdd, ...override.quickAdd },
357
+ legendPanel: { ...DEFAULT_FLOW_EDITOR_LABELS.legendPanel, ...override.legendPanel },
182
358
  palette: { ...DEFAULT_FLOW_EDITOR_LABELS.palette, ...override.palette },
183
359
  flowMap: { ...DEFAULT_FLOW_EDITOR_LABELS.flowMap, ...override.flowMap },
184
360
  flowGroup: { ...DEFAULT_FLOW_EDITOR_LABELS.flowGroup, ...override.flowGroup },
185
361
  crossFlowPortal: { ...DEFAULT_FLOW_EDITOR_LABELS.crossFlowPortal, ...override.crossFlowPortal },
362
+ collectionChain: { ...DEFAULT_FLOW_EDITOR_LABELS.collectionChain, ...override.collectionChain },
363
+ validation: { ...DEFAULT_FLOW_EDITOR_LABELS.validation, ...override.validation },
364
+ workspace: { ...DEFAULT_FLOW_EDITOR_LABELS.workspace, ...override.workspace },
365
+ flowManager: { ...DEFAULT_FLOW_EDITOR_LABELS.flowManager, ...override.flowManager },
186
366
  }
187
367
  }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Copyright (c) 2026 Ada Technology. MIT License.
3
+ *
4
+ * O subpath `/flows` precisa exportar a TELA, não só as peças.
5
+ *
6
+ * Existe porque a ausência disso já custou: exportando apenas `FlowMapCanvas`, `FlowPalette` e
7
+ * `FlowNodePanel`, o financiamento montou a tela por conta — 973 linhas de página mais um fork local
8
+ * dos componentes, que ficou atrás do pacote. O quickcart, para ter a mesma tela, teria que copiar o
9
+ * arquivo. É exatamente a divergência que `pluggable-module.md` §4 proíbe.
10
+ *
11
+ * O teste é de superfície, não de comportamento: não prova que o canvas desenha certo, prova que
12
+ * existe UM lugar onde a tela mora, e que ela aceita customização por contrato em vez de por fork.
13
+ */
14
+
15
+ import { describe, expect, it } from 'bun:test'
16
+
17
+ import * as flows from './index'
18
+ import { DEFAULT_FLOW_EDITOR_LABELS, mergeFlowEditorLabels } from './labels'
19
+
20
+ const WORKSPACE_SOURCE = `${import.meta.dir}/FlowsWorkspace.tsx`
21
+
22
+ describe('superfície composta', () => {
23
+ it('exporta a tela inteira', () => {
24
+ expect(typeof flows.FlowsWorkspace).toBe('function')
25
+ })
26
+
27
+ it('exporta as peças também — quem precisa de layout próprio não fica sem saída', () => {
28
+ // Workspace é o caminho recomendado, não uma prisão: um produto com layout radicalmente
29
+ // diferente compõe as peças, e isso é melhor que forkar o pacote.
30
+ for (const piece of ['FlowMapCanvas', 'FlowPalette', 'FlowNodePanel', 'FlowWhatsAppPreview']) {
31
+ expect(typeof (flows as Record<string, unknown>)[piece], piece).toBe('function')
32
+ }
33
+ })
34
+
35
+ it('as operações de grafo saem puras, sem passar pela tela', () => {
36
+ // São as que a tela consome de verdade (ver os imports em `FlowsWorkspace.tsx`). Exportá-las sem
37
+ // usá-las seria pior que não ter teste: teste verde sobre código que não roda em produção.
38
+ for (const operation of ['resolveConnection', 'applyConnection', 'removeNodeAndCleanRefs', 'mergedFlowKeysFrom']) {
39
+ expect(typeof (flows as Record<string, unknown>)[operation], operation).toBe('function')
40
+ }
41
+ })
42
+ })
43
+
44
+ describe('contrato de customização', () => {
45
+ it('nenhuma capacidade em forma de flag booleana `hasX`', async () => {
46
+ /**
47
+ * Capacidade opcional é por AUSÊNCIA de prop. `hasDelete` seria um segundo jeito de dizer o que
48
+ * `deletableFlowKeys` já diz, e dois jeitos divergem — alguém liga a flag sem a lista e a tela
49
+ * desenha um botão que não exclui nada.
50
+ */
51
+ const content = await Bun.file(WORKSPACE_SOURCE).text()
52
+
53
+ expect(content).not.toMatch(/readonly has[A-Z]/)
54
+ })
55
+
56
+ it('aceita labels, className e os slots de render', async () => {
57
+ const content = await Bun.file(WORKSPACE_SOURCE).text()
58
+
59
+ expect(content).toContain('labels?: Partial<')
60
+ // `className` é o que deixa o produto posicionar a tela no layout dele sem tocar no pacote.
61
+ expect(content).toContain('className?: string')
62
+ expect(content).toContain('renderMediaPicker?:')
63
+ })
64
+
65
+ it('nenhum texto visível escrito no componente — tudo passa por labels', async () => {
66
+ const content = await Bun.file(WORKSPACE_SOURCE).text()
67
+ /**
68
+ * Texto entre tags JSX que não seja `{...}`, que é o que `web.md` §6 proíbe.
69
+ *
70
+ * O `\s*` nas pontas não é detalhe: a primeira versão deste regex no notification-ui exigia o
71
+ * texto colado nas tags, e o Prettier põe o conteúdo em linha própria — o teste passava com
72
+ * `>\n Configurações\n<` no meio do componente, provando nada.
73
+ */
74
+ const hardcoded = content.match(/>\s*[A-Za-zÀ-ÿ][A-Za-zÀ-ÿ ]{3,}\s*</g)
75
+
76
+ expect(hardcoded, `texto fixo: ${hardcoded?.join(' | ')}`).toBeNull()
77
+ })
78
+
79
+ it('o produto sobrescreve UM texto sem perder os outros do mesmo grupo', () => {
80
+ const merged = mergeFlowEditorLabels({ workspace: { ...DEFAULT_FLOW_EDITOR_LABELS.workspace, title: 'Jornadas' } })
81
+
82
+ expect(merged.workspace.title).toBe('Jornadas')
83
+ expect(merged.workspace.saveGraph).toBe(DEFAULT_FLOW_EDITOR_LABELS.workspace.saveGraph)
84
+ })
85
+
86
+ it('grupo novo de label entra no merge profundo', () => {
87
+ // Esquecer o grupo no `mergeFlowEditorLabels` deixa o override apagar os irmãos dele, e o
88
+ // sintoma é texto sumindo da tela — não erro.
89
+ for (const group of ['workspace', 'flowManager', 'validation', 'collectionChain'] as const) {
90
+ const merged = mergeFlowEditorLabels({ [group]: {} })
91
+
92
+ expect(Object.keys(merged[group]).length, group).toBe(Object.keys(DEFAULT_FLOW_EDITOR_LABELS[group]).length)
93
+ }
94
+ })
95
+ })
96
+
97
+ describe('focar um fluxo leva a câmera até ele', () => {
98
+ /**
99
+ * `focusFlow` trocava o fluxo primário e remesclava o canvas, mas nunca movia a viewport: a aba
100
+ * acendia, a tela continuava onde estava e o fluxo escolhido ficava fora do campo de visão — em
101
+ * "Consórcio", parado no início com o fluxo lá embaixo, clicar parecia não fazer nada.
102
+ *
103
+ * Teste de fonte, como os demais deste arquivo: não prova o enquadramento na tela, prova que o
104
+ * clique pede o enquadramento e que ele é restrito ao fluxo clicado.
105
+ */
106
+ it('focar um fluxo agenda o enquadramento dele', async () => {
107
+ const content = await Bun.file(WORKSPACE_SOURCE).text()
108
+
109
+ expect(content).toContain('setPendingFocusFlowKey(key)')
110
+ })
111
+
112
+ it('enquadra apenas os cards do fluxo focado, não o canvas inteiro', async () => {
113
+ // O canvas mostra o fecho transitivo inteiro. Um `fitView()` sem `nodes` devolveria a mesma
114
+ // visão de sempre — que é exatamente o defeito relatado.
115
+ const content = await Bun.file(WORKSPACE_SOURCE).text()
116
+
117
+ expect(content).toMatch(/fitView\(\{[\s\S]*?nodes: flowNodes\.map/)
118
+ expect(content).toMatch(/flowKey === pendingFocusFlowKey/)
119
+ })
120
+
121
+ it('limita o zoom, para um fluxo de dois nós não encher a tela', async () => {
122
+ const content = await Bun.file(WORKSPACE_SOURCE).text()
123
+
124
+ expect(content).toContain('maxZoom: FOCUS_MAX_ZOOM')
125
+ })
126
+ })
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Largura do próprio elemento, para decidir layout pelo espaço que ele tem — e não pelo tamanho da
3
+ * janela.
4
+ *
5
+ * Os breakpoints do Tailwind (`sm:`, `lg:`) leem a janela, e é aí que erram nestas telas: abrir a
6
+ * prévia do simulador, ou a lista de conversas ao lado, estreita a coluna sem a janela mudar de
7
+ * tamanho. O layout continua achando que está no desktop e espreme o conteúdo flexível — o campo de
8
+ * texto do composer e o nome do cliente no cabeçalho — para caber os botões, que não cedem.
9
+ *
10
+ * `undefined` enquanto não mediu (SSR e teste de markup incluídos): quem consome trata ausência de
11
+ * medida como espaçoso, que é o layout completo.
12
+ */
13
+
14
+ import { useEffect, useState, type RefObject } from 'react'
15
+
16
+ export function useContainerWidth(ref: RefObject<HTMLElement | null>): number | undefined {
17
+ const [width, setWidth] = useState<number | undefined>(undefined)
18
+
19
+ useEffect(() => {
20
+ const element = ref.current
21
+ if (!element || typeof ResizeObserver === 'undefined') return
22
+
23
+ // Sempre a mesma medida nos dois caminhos: o `contentRect` do observer desconta o padding e o
24
+ // `getBoundingClientRect` não. Misturar os dois desloca o limiar pela largura do padding — no
25
+ // cabeçalho, 32px — e o layout decide uma coisa ao montar e outra ao redimensionar.
26
+ const measure = () => setWidth(element.getBoundingClientRect().width)
27
+
28
+ measure()
29
+ const observer = new ResizeObserver(measure)
30
+ observer.observe(element)
31
+ return () => observer.disconnect()
32
+ }, [ref])
33
+
34
+ return width
35
+ }
@@ -10,14 +10,16 @@ export function useConversationRealtime(
10
10
  conversationId: string | undefined,
11
11
  onEvent: ConversationRealtimeHandler,
12
12
  ): void {
13
- const context = useConversations()
13
+ const sse = useConversations()?.sse
14
14
  const onEventRef = useRef(onEvent)
15
15
  onEventRef.current = onEvent
16
16
 
17
+ // Depende da porta SSE, não do objeto de contexto inteiro: reabrir o stream é caro (um ticket por
18
+ // abertura) e o contexto é o que mais muda de identidade quando o host renderiza.
17
19
  useEffect(() => {
18
- if (!context || !conversationId) return
20
+ if (!sse || !conversationId) return
19
21
 
20
- const source = context.sse.connectConversationStream(conversationId)
22
+ const source = sse.connectConversationStream(conversationId)
21
23
  const handler = (event: MessageEvent) => onEventRef.current(event)
22
24
  source.addEventListener('message', handler)
23
25
 
@@ -25,20 +27,20 @@ export function useConversationRealtime(
25
27
  source.removeEventListener('message', handler)
26
28
  source.close()
27
29
  }
28
- }, [context, conversationId])
30
+ }, [sse, conversationId])
29
31
  }
30
32
 
31
33
  // Assina o stream global (ex: novas conversas entrando na fila, notificações cross-conversa)
32
34
  // — mesma porta SSEProvider, sem conversationId.
33
35
  export function useGlobalRealtime(onEvent: ConversationRealtimeHandler): void {
34
- const context = useConversations()
36
+ const sse = useConversations()?.sse
35
37
  const onEventRef = useRef(onEvent)
36
38
  onEventRef.current = onEvent
37
39
 
38
40
  useEffect(() => {
39
- if (!context) return
41
+ if (!sse) return
40
42
 
41
- const source = context.sse.connectGlobalStream()
43
+ const source = sse.connectGlobalStream()
42
44
  const handler = (event: MessageEvent) => onEventRef.current(event)
43
45
  source.addEventListener('message', handler)
44
46
 
@@ -46,5 +48,5 @@ export function useGlobalRealtime(onEvent: ConversationRealtimeHandler): void {
46
48
  source.removeEventListener('message', handler)
47
49
  source.close()
48
50
  }
49
- }, [context])
51
+ }, [sse])
50
52
  }
@@ -0,0 +1,127 @@
1
+ import { useCallback, useLayoutEffect, useRef, useState, type RefObject, type UIEvent } from 'react'
2
+
3
+ /**
4
+ * Mantém a conversa no fim, como todo mensageiro.
5
+ *
6
+ * Abrir uma conversa no topo do histórico é errado por um motivo simples: o que interessa ao
7
+ * atendente é a última mensagem, e ele teria que rolar por semanas de conversa para chegar nela.
8
+ *
9
+ * Existe no pacote, e não em cada host, porque a regra tem duas sutilezas que se descobre só
10
+ * errando — o salto instantâneo na troca de conversa e o respeito a quem rolou para trás — e
11
+ * reimplementá-las em cada inbox garante que uma delas fique de fora.
12
+ */
13
+
14
+ /**
15
+ * Distância do fim, em pixels, dentro da qual ainda consideramos o operador "acompanhando".
16
+ *
17
+ * Não é zero porque o navegador arredonda `scrollTop` fracionário em telas com zoom ou densidade
18
+ * alta: exigir o fim exato faria o painel achar que o operador rolou para trás sem ele ter tocado
19
+ * em nada, e a próxima mensagem não apareceria.
20
+ */
21
+ const NEAR_BOTTOM_THRESHOLD_PX = 120
22
+
23
+ /**
24
+ * Toda rolagem aqui é `'auto'` — nunca `'smooth'`.
25
+ *
26
+ * Tentamos suave para mensagem nova e medimos: em ambiente onde a rolagem suave está desligada, o
27
+ * `scrollTo({ behavior: 'smooth' })` **não faz nada e não avisa** — a mensagem nova simplesmente não
28
+ * entra na vista. E não dá para detectar isso pelo `prefers-reduced-motion`: no navegador em que
29
+ * reproduzimos, a media query respondia `false` e o smooth continuava sendo no-op.
30
+ *
31
+ * Trocar uma animação cosmética por garantia de que o operador vê a mensagem é barato: seguir uma
32
+ * mensagem nova salta a altura de uma bolha, que é quase imperceptível de qualquer forma.
33
+ */
34
+ const SCROLL_BEHAVIOR: ScrollBehavior = 'auto'
35
+
36
+ export type UseScrollToLatestMessageParams = {
37
+ /** Troca de conversa. Muda ⇒ salto instantâneo para o fim. */
38
+ readonly conversationId: string | undefined
39
+ /** Quantidade de mensagens carregadas. Cresce ⇒ acompanha o fim, se o operador estiver lá. */
40
+ readonly messageCount: number
41
+ }
42
+
43
+ export type UseScrollToLatestMessageResult = {
44
+ /** Vai no elemento que rola — tipicamente o `ConversationWallpaper`. */
45
+ readonly containerRef: RefObject<HTMLDivElement | null>
46
+ /** Ligue no `onScroll` do mesmo elemento: é o que detecta o operador lendo o histórico. */
47
+ readonly handleScroll: (event: UIEvent<HTMLDivElement>) => void
48
+ /** `true` quando o operador rolou para trás — serve a um botão "ir para a última". */
49
+ readonly isAwayFromBottom: boolean
50
+ readonly scrollToBottom: (behavior?: ScrollBehavior) => void
51
+ }
52
+
53
+ export function useScrollToLatestMessage({
54
+ conversationId,
55
+ messageCount,
56
+ }: UseScrollToLatestMessageParams): UseScrollToLatestMessageResult {
57
+ const containerRef = useRef<HTMLDivElement | null>(null)
58
+ const [isAwayFromBottom, setIsAwayFromBottom] = useState(false)
59
+
60
+ /**
61
+ * Espelha `isAwayFromBottom` para o efeito de mensagens novas ler o valor atual sem depender dele.
62
+ *
63
+ * Se o efeito dependesse do estado, cada rolagem do operador o re-disparava e o puxava de volta
64
+ * para o fim — exatamente o que a checagem existe para evitar.
65
+ */
66
+ const isAwayFromBottomRef = useRef(false)
67
+
68
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = SCROLL_BEHAVIOR) => {
69
+ const container = containerRef.current
70
+ if (!container) return
71
+ container.scrollTo({ top: container.scrollHeight, behavior })
72
+ }, [])
73
+
74
+ const handleScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
75
+ const target = event.currentTarget
76
+ const distanceFromBottom = target.scrollHeight - target.scrollTop - target.clientHeight
77
+ const away = distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX
78
+
79
+ isAwayFromBottomRef.current = away
80
+ setIsAwayFromBottom((current) => (current === away ? current : away))
81
+ }, [])
82
+
83
+ /**
84
+ * Qual conversa já recebeu o salto de abertura.
85
+ *
86
+ * É o que separa "abriu a conversa" de "chegou mensagem", e não dá para usar só `conversationId`
87
+ * num efeito próprio: quando a conversa troca, a lista de mensagens ainda está vazia, então um
88
+ * salto ali rola um container sem conteúdo e não faz nada. O salto real precisa esperar a primeira
89
+ * leva de mensagens — e foi exatamente isso que deixou a conversa abrindo no topo.
90
+ */
91
+ const jumpedForConversationRef = useRef<string | undefined>(undefined)
92
+
93
+ useLayoutEffect(() => {
94
+ const isNewConversation = jumpedForConversationRef.current !== conversationId
95
+
96
+ if (isNewConversation) {
97
+ // Zera antes de qualquer coisa: "rolado para trás" da conversa anterior não pode bloquear o
98
+ // salto de abertura desta.
99
+ isAwayFromBottomRef.current = false
100
+ setIsAwayFromBottom(false)
101
+
102
+ // Sem mensagens ainda — o salto acontece quando a primeira leva chegar.
103
+ if (messageCount === 0) return
104
+
105
+ jumpedForConversationRef.current = conversationId
106
+ /**
107
+ * `'auto'`, sempre. Animar a rolagem por meses de histórico demora, mostra um borrão de
108
+ * mensagens antigas que ninguém pediu, e some por completo onde a rolagem suave está desligada
109
+ * (`prefers-reduced-motion`, alguns navegadores automatizados) — a conversa simplesmente
110
+ * abriria no topo. Abertura é salto, não animação.
111
+ */
112
+ scrollToBottom(SCROLL_BEHAVIOR)
113
+ return
114
+ }
115
+
116
+ /**
117
+ * Mensagem nova: acompanha, mas só se o operador já estava no fim.
118
+ *
119
+ * Puxar a rolagem de quem está lendo o histórico é pior do que não mostrar a mensagem — ele perde
120
+ * a posição e não sabe por quê. Quem rolou para trás recebe `isAwayFromBottom` e decide.
121
+ */
122
+ if (isAwayFromBottomRef.current) return
123
+ scrollToBottom(SCROLL_BEHAVIOR)
124
+ }, [conversationId, messageCount, scrollToBottom])
125
+
126
+ return { containerRef, handleScroll, isAwayFromBottom, scrollToBottom }
127
+ }