@adatechnology/conversations-ui 0.1.0-rc.26 → 0.1.0-rc.27

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 (75) hide show
  1. package/dist/chunk-DXPSPUWF.js +110 -0
  2. package/dist/{chunk-TV4OQRGH.js → chunk-UZJBYD5O.js} +123 -45
  3. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  4. package/dist/flows/index.d.ts +2 -0
  5. package/dist/flows/index.js +61 -18
  6. package/dist/index.d.ts +37 -10
  7. package/dist/index.js +694 -297
  8. package/dist/preview/index.js +3 -3
  9. package/dist/styles.css +119 -6
  10. package/package.json +1 -1
  11. package/src/AudioPlayer.tsx +8 -0
  12. package/src/AudioRecorderButton.test.tsx +3 -3
  13. package/src/AudioRecorderButton.tsx +3 -3
  14. package/src/AudioTranscription.tsx +4 -1
  15. package/src/Avatar.tsx +1 -1
  16. package/src/ConversationContextPanel.tsx +2 -2
  17. package/src/ConversationDocumentsPanel.tsx +11 -6
  18. package/src/ConversationHeader.test.tsx +66 -0
  19. package/src/ConversationHeader.tsx +147 -47
  20. package/src/ConversationListItem.tsx +3 -2
  21. package/src/ConversationRow.tsx +31 -7
  22. package/src/DocumentsLibrary.tsx +9 -4
  23. package/src/EmojiPicker.tsx +2 -1
  24. package/src/InteractiveMessage.tsx +3 -0
  25. package/src/Lightbox.tsx +1 -1
  26. package/src/MediaRenderer.tsx +2 -0
  27. package/src/MessageBubble.test.tsx +41 -0
  28. package/src/MessageBubble.tsx +27 -8
  29. package/src/MessageComposer.tsx +11 -8
  30. package/src/RichMessageComposer.test.tsx +42 -12
  31. package/src/RichMessageComposer.tsx +216 -45
  32. package/src/SimpleEmojiPicker.tsx +5 -3
  33. package/src/StatusTicks.tsx +1 -1
  34. package/src/Toast.tsx +4 -0
  35. package/src/Tooltip.test.ts +42 -0
  36. package/src/Tooltip.tsx +164 -0
  37. package/src/WhatsAppMessageEditor.tsx +4 -4
  38. package/src/WindowExpiredNotice.tsx +12 -4
  39. package/src/composer.constant.ts +33 -0
  40. package/src/conversationWindow.ts +7 -5
  41. package/src/documents/DocumentsWorkspace.tsx +10 -6
  42. package/src/flows/FlowGroupHeader.tsx +2 -2
  43. package/src/flows/FlowMapNode.tsx +2 -1
  44. package/src/flows/FlowNodeCard.tsx +3 -3
  45. package/src/flows/FlowNodePanel.tsx +6 -5
  46. package/src/flows/FlowPalette.tsx +7 -1
  47. package/src/flows/FlowPortalNode.tsx +1 -1
  48. package/src/flows/FlowsWorkspace.tsx +13 -4
  49. package/src/flows/labels.ts +4 -0
  50. package/src/hooks/useContainerWidth.ts +35 -0
  51. package/src/hooks/useConversationRealtime.ts +10 -8
  52. package/src/icon.constant.ts +12 -0
  53. package/src/index.ts +1 -0
  54. package/src/lib/composer-formatting.test.ts +78 -0
  55. package/src/lib/composer-formatting.ts +145 -0
  56. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  57. package/src/lib/whatsapp-formatting.tsx +28 -3
  58. package/src/listing/index.tsx +5 -1
  59. package/src/pagination.constant.ts +10 -0
  60. package/src/preview/ConversationSimulatorPanel.tsx +1 -1
  61. package/src/providers/ConversationsProvider.tsx +8 -6
  62. package/src/settings/MessagesWorkspace.tsx +3 -0
  63. package/src/settings/TopicsForm.tsx +2 -0
  64. package/src/settings/TranscriptionSettingsForm.test.tsx +1 -1
  65. package/src/settings/TranscriptionSettingsForm.tsx +1 -0
  66. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  67. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  68. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  69. package/src/settings/WhatsAppTemplatesSettings.tsx +2 -0
  70. package/src/styles.css +100 -4
  71. package/src/workspace/BulkTemplateModal.tsx +4 -2
  72. package/src/workspace/ConversationPane.tsx +49 -19
  73. package/src/workspace/ConversationsInboxList.tsx +17 -8
  74. package/src/workspace/ConversationsWorkspace.tsx +24 -16
  75. package/src/workspace/useConversationsInbox.ts +5 -2
@@ -132,6 +132,7 @@ export function WhatsAppTemplateSettingsForm({
132
132
  <div className="flex items-center justify-between mb-1.5">
133
133
  <label className="text-sm font-medium text-gray-700 dark:text-gray-300">{labels.templateLabel}</label>
134
134
  <button
135
+ data-cv-tooltip={labels.refreshList} aria-label={labels.refreshList}
135
136
  type="button"
136
137
  onClick={onRefreshTemplates}
137
138
  disabled={loadingTemplates}
@@ -180,6 +181,7 @@ export function WhatsAppTemplateSettingsForm({
180
181
  <div className="flex items-center justify-between mb-1.5">
181
182
  <label className="text-sm font-medium text-gray-700 dark:text-gray-300">{labels.variablesLabel}</label>
182
183
  <button
184
+ data-cv-tooltip={labels.addVariable} aria-label={labels.addVariable}
183
185
  type="button"
184
186
  onClick={() => onVariablesChange([...variables, ''])}
185
187
  className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline"
@@ -198,7 +200,7 @@ export function WhatsAppTemplateSettingsForm({
198
200
  key={available.token}
199
201
  type="button"
200
202
  onClick={() => insertVariable(available.token)}
201
- title={`Inserir ${available.token}`}
203
+ data-cv-tooltip={`Inserir ${available.token}`} aria-label={`Inserir ${available.token}`}
202
204
  className="inline-flex items-center gap-1 rounded-md border border-gray-200 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs hover:border-blue-400 dark:hover:border-blue-500 transition-colors"
203
205
  >
204
206
  <code className="text-blue-600 dark:text-blue-300">{available.token}</code>
@@ -233,7 +235,7 @@ export function WhatsAppTemplateSettingsForm({
233
235
  type="button"
234
236
  onClick={() => removeVariable(index)}
235
237
  className="text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition-colors flex-shrink-0"
236
- title={labels.removeVariable}
238
+ data-cv-tooltip={labels.removeVariable} aria-label={labels.removeVariable}
237
239
  >
238
240
  <Trash2 size={14} />
239
241
  </button>
@@ -250,6 +252,7 @@ export function WhatsAppTemplateSettingsForm({
250
252
 
251
253
  <div className="flex justify-end">
252
254
  <button
255
+ data-cv-tooltip={saving ? labels.saving : labels.save} aria-label={saving ? labels.saving : labels.save}
253
256
  type="submit"
254
257
  disabled={saving || !selectedTemplateName.trim()}
255
258
  className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-semibold px-5 py-2.5 rounded-xl transition-colors text-sm"
@@ -78,6 +78,7 @@ export function WhatsAppTemplatesSettings({
78
78
  <div className="space-y-4">
79
79
  <nav className="flex gap-1 border-b" role="tablist">
80
80
  <button
81
+ data-cv-tooltip={labels.selectTab} aria-label={labels.selectTab}
81
82
  type="button"
82
83
  role="tab"
83
84
  aria-selected={tab === TEMPLATE_SETTINGS_TAB.SELECT}
@@ -91,6 +92,7 @@ export function WhatsAppTemplatesSettings({
91
92
  que não leva a nada. */}
92
93
  {create ? (
93
94
  <button
95
+ data-cv-tooltip={labels.createTab} aria-label={labels.createTab}
94
96
  type="button"
95
97
  role="tab"
96
98
  aria-selected={tab === TEMPLATE_SETTINGS_TAB.CREATE}
package/src/styles.css CHANGED
@@ -22,7 +22,12 @@
22
22
 
23
23
  /* Botão de ação do cabeçalho de conversa e do aviso de janela. Vive no stylesheet, como o
24
24
  .cv-wallpaper, para não depender da configuração de Tailwind do consumidor. */
25
+ /* `inline-flex` com gap em todo controle com ícone: o ícone é SVG e, como elemento inline, assentava
26
+ na baseline do texto — o traço ficava um fio abaixo do rótulo em cada botão. */
25
27
  .cv-header-action {
28
+ display: inline-flex;
29
+ align-items: center;
30
+ gap: 0.375rem;
26
31
  border: 1px solid rgb(203 213 225);
27
32
  border-radius: 0.375rem;
28
33
  padding: 0.375rem 0.75rem;
@@ -34,6 +39,9 @@
34
39
  .dark .cv-header-action { border-color: rgb(71 85 105); color: rgb(226 232 240); }
35
40
 
36
41
  .cv-header-icon {
42
+ display: inline-flex;
43
+ align-items: center;
44
+ justify-content: center;
37
45
  border: 1px solid rgb(203 213 225);
38
46
  border-radius: 0.5rem;
39
47
  padding: 0.375rem 0.5rem;
@@ -100,6 +108,9 @@
100
108
 
101
109
  /* Voltar do master/detail: existe só onde lista e conversa não cabem lado a lado. */
102
110
  .cv-back {
111
+ display: inline-flex;
112
+ align-items: center;
113
+ justify-content: center;
103
114
  border: 1px solid rgb(203 213 225);
104
115
  border-radius: 0.5rem;
105
116
  padding: 0.25rem 0.5rem;
@@ -110,6 +121,13 @@
110
121
  @media (min-width: 1024px) { .cv-back { display: none; } }
111
122
  .dark .cv-back { border-color: rgb(71 85 105); color: rgb(226 232 240); }
112
123
 
124
+ /* Contador do cabeçalho da inbox: ícone e número na mesma linha de base ótica. */
125
+ .cv-stat {
126
+ display: inline-flex;
127
+ align-items: center;
128
+ gap: 0.25rem;
129
+ }
130
+
113
131
  /* Utilitários de densidade: em tela estreita o texto das ações vira ícone, porque rótulo longo
114
132
  empilha e come a altura útil da conversa. */
115
133
  .cv-only-wide { display: none; }
@@ -328,6 +346,9 @@
328
346
  }
329
347
 
330
348
  .cv-workspace-toggle {
349
+ display: inline-flex;
350
+ align-items: center;
351
+ gap: 0.375rem;
331
352
  border: 1px solid rgb(229 231 235);
332
353
  border-radius: 0.375rem;
333
354
  padding: 0.5rem 0.75rem;
@@ -495,24 +516,48 @@
495
516
  }
496
517
  .dark .cv-workspace-search { border-color: rgb(71 85 105); color: rgb(226 232 240); }
497
518
 
519
+ /* Linha única com rolagem, e não wrap: quebrando, a fila de janelas empurrava a categoria e a
520
+ lista para baixo toda vez que a coluna estreitava. */
498
521
  .cv-workspace-chips {
499
522
  display: flex;
500
- flex-wrap: wrap;
523
+ flex-wrap: nowrap;
501
524
  align-items: center;
502
- gap: 0.5rem;
525
+ gap: 0.375rem;
503
526
  font-size: 0.75rem;
527
+ overflow-x: auto;
528
+ scrollbar-width: none;
504
529
  }
505
- .cv-workspace-chips__legend { color: rgb(107 114 128); }
530
+ .cv-workspace-chips::-webkit-scrollbar { display: none; }
531
+ .cv-workspace-chips__legend { color: rgb(107 114 128); flex-shrink: 0; }
506
532
  .cv-workspace-chip {
507
533
  display: flex;
534
+ flex-shrink: 0;
508
535
  align-items: center;
509
536
  gap: 0.25rem;
537
+ white-space: nowrap;
510
538
  border-radius: 0.375rem;
511
539
  padding: 0.25rem 0.5rem;
512
540
  }
513
541
  .cv-workspace-chip--on { background: rgb(229 231 235); }
514
542
  .dark .cv-workspace-chip--on { background: rgb(51 65 85); }
515
- .cv-workspace-dot { width: 0.5rem; height: 0.5rem; border-radius: 9999px; }
543
+
544
+ /* A cor do próprio rótulo é o estado da janela; selecionado, o mesmo tom vira o fundo. */
545
+ .cv-workspace-chip--fresh { color: rgb(22 163 74); }
546
+ .cv-workspace-chip--warning { color: rgb(202 138 4); }
547
+ .cv-workspace-chip--critical { color: rgb(220 38 38); }
548
+ .cv-workspace-chip--expired { color: rgb(107 114 128); }
549
+ .cv-workspace-chip--fresh.cv-workspace-chip--on { background: rgb(220 252 231); }
550
+ .cv-workspace-chip--warning.cv-workspace-chip--on { background: rgb(254 249 195); }
551
+ .cv-workspace-chip--critical.cv-workspace-chip--on { background: rgb(254 226 226); }
552
+ .cv-workspace-chip--expired.cv-workspace-chip--on { background: rgb(229 231 235); }
553
+ .dark .cv-workspace-chip--fresh { color: rgb(74 222 128); }
554
+ .dark .cv-workspace-chip--warning { color: rgb(250 204 21); }
555
+ .dark .cv-workspace-chip--critical { color: rgb(248 113 113); }
556
+ .dark .cv-workspace-chip--expired { color: rgb(148 163 184); }
557
+ .dark .cv-workspace-chip--fresh.cv-workspace-chip--on { background: rgb(20 83 45); }
558
+ .dark .cv-workspace-chip--warning.cv-workspace-chip--on { background: rgb(113 63 18); }
559
+ .dark .cv-workspace-chip--critical.cv-workspace-chip--on { background: rgb(127 29 29); }
560
+ .dark .cv-workspace-chip--expired.cv-workspace-chip--on { background: rgb(51 65 85); }
516
561
 
517
562
  .cv-workspace-selectall {
518
563
  display: flex;
@@ -842,3 +887,54 @@
842
887
  font-size: 0.75rem;
843
888
  }
844
889
  .dark .cv-listing-perpage { border-color: rgb(71 85 105); }
890
+
891
+ /* Dica de ação (ver `Tooltip.tsx`). Impressa em `document.body`, então a posição é da janela e o
892
+ `z-index` fica acima de qualquer painel do produto. */
893
+ .cv-tooltip {
894
+ position: fixed;
895
+ z-index: 9999;
896
+ max-width: min(16rem, 90vw);
897
+ transform: translate(-50%, -100%);
898
+ border-radius: 0.375rem;
899
+ background: rgb(17 24 39 / 95%);
900
+ padding: 0.3125rem 0.5rem;
901
+ font-size: 0.75rem;
902
+ line-height: 1.2;
903
+ color: #fff;
904
+ text-align: center;
905
+ box-shadow: 0 4px 12px rgb(0 0 0 / 18%);
906
+ pointer-events: none;
907
+ animation: cv-tooltip-in 90ms ease-out;
908
+ }
909
+
910
+ .cv-tooltip--bottom {
911
+ transform: translate(-50%, 0);
912
+ }
913
+
914
+ .dark .cv-tooltip {
915
+ background: rgb(243 244 246 / 96%);
916
+ color: rgb(17 24 39);
917
+ }
918
+
919
+ @keyframes cv-tooltip-in {
920
+ from { opacity: 0; }
921
+ to { opacity: 1; }
922
+ }
923
+
924
+ @media (prefers-reduced-motion: reduce) {
925
+ .cv-tooltip { animation: none; }
926
+ }
927
+
928
+ /* Barra de composição: fundo neutro para separar da conversa e para os atalhos e o campo de texto
929
+ terem contraste próprio. Sobre branco, régua branca e campo cinza-claro se confundiam num bloco
930
+ só, sem hierarquia. */
931
+ .cv-composer-bar {
932
+ border-top: 1px solid rgb(229 231 235);
933
+ background: rgb(240 242 245);
934
+ padding: 0.625rem 0.75rem;
935
+ }
936
+
937
+ .dark .cv-composer-bar {
938
+ border-top-color: rgb(51 65 85);
939
+ background: rgb(17 24 39);
940
+ }
@@ -70,7 +70,7 @@ export function BulkTemplateModal({
70
70
  <h3>{labels.templateModalTitle}</h3>
71
71
  <p>{labels.templateModalAvailable(templates.length)}</p>
72
72
  </div>
73
- <button type="button" onClick={onClose} aria-label={labels.templateModalCancel}>
73
+ <button data-cv-tooltip={labels.templateModalCancel} type="button" onClick={onClose} aria-label={labels.templateModalCancel}>
74
74
  ✕
75
75
  </button>
76
76
  </header>
@@ -89,6 +89,7 @@ export function BulkTemplateModal({
89
89
  {filtered.length === 0 ? <p className="cv-workspace-empty">{labels.templateModalEmpty}</p> : null}
90
90
  {filtered.map((template) => (
91
91
  <button
92
+ data-cv-tooltip={template.name} aria-label={template.name}
92
93
  key={template.name}
93
94
  type="button"
94
95
  // Reclicar desmarca: sem isso não havia como voltar ao template padrão depois de
@@ -113,10 +114,11 @@ export function BulkTemplateModal({
113
114
  ) : null}
114
115
 
115
116
  <footer className="cv-workspace-modal__footer">
116
- <button type="button" onClick={onClose}>
117
+ <button data-cv-tooltip={labels.templateModalCancel} aria-label={labels.templateModalCancel} type="button" onClick={onClose}>
117
118
  {labels.templateModalCancel}
118
119
  </button>
119
120
  <button
121
+ data-cv-tooltip={sending ? labels.templateModalSending : labels.templateModalSend(expiredCount)} aria-label={sending ? labels.templateModalSending : labels.templateModalSend(expiredCount)}
120
122
  type="button"
121
123
  disabled={sending || expiredCount === 0}
122
124
  onClick={() => onSend(selected || undefined)}
@@ -5,7 +5,7 @@
5
5
  * última mensagem, outro engolia falha de anexo, outro não abria a biblioteca de arquivos.
6
6
  */
7
7
 
8
- import { useEffect, useState, type ReactNode } from 'react'
8
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
9
9
 
10
10
  import { AudioRecorderButton } from '../AudioRecorderButton'
11
11
  import { ConversationContextPanel, type ConversationContextEntry } from '../ConversationContextPanel'
@@ -36,7 +36,8 @@ export interface ConversationPaneProps {
36
36
  readonly now: number
37
37
  readonly busy: boolean
38
38
  readonly labels: ConversationsWorkspaceLabels
39
- readonly onTakeover?: (() => void) | undefined
39
+ /** Devolve a promessa quando o host a tem: o envio de template espera a tomada antes de sair. */
40
+ readonly onTakeover?: (() => void | Promise<void>) | undefined
40
41
  readonly onReturnToBot?: (() => void) | undefined
41
42
  readonly onFinish?: (() => void) | undefined
42
43
  readonly onBack: () => void
@@ -139,6 +140,9 @@ export function ConversationPane({
139
140
  const [selectedMessageIds, setSelectedMessageIds] = useState<ReadonlySet<string>>(new Set())
140
141
  const [draft, setDraft] = useState(initialComposerText ?? '')
141
142
  const [queuedFiles, setQueuedFiles] = useState<readonly File[]>([])
143
+ const [isSendingDraft, setIsSendingDraft] = useState(false)
144
+ /** Ref, não estado: entre dois cliques seguidos o React ainda não teria repintado a trava. */
145
+ const sendInFlightRef = useRef(false)
142
146
 
143
147
  // Trocar de conversa zera as duas coisas: seleção de mensagem e rascunho pertencem à thread, e
144
148
  // levá-los adiante faria copiar o trecho errado ou responder ao cliente errado.
@@ -187,18 +191,31 @@ export function ConversationPane({
187
191
  * Falha vira aviso na tela em vez de exceção silenciosa: o atendente escreveu ou gravou, achou
188
192
  * que mandou, e sem retorno não teria como saber que o cliente não recebeu nada.
189
193
  */
190
- async function runSend(action: () => Promise<unknown>, fallback: string): Promise<void> {
194
+ /** Devolve se o envio passou: limpar o rascunho depois de uma falha apagaria o texto do operador. */
195
+ async function runSend(action: () => Promise<unknown>, fallback: string): Promise<boolean> {
191
196
  setSendFailure(undefined)
192
197
  try {
193
198
  await action()
194
199
  await refetch()
200
+ return true
195
201
  } catch (error: unknown) {
196
202
  setSendFailure(error instanceof Error ? error.message : fallback)
203
+ return false
197
204
  }
198
205
  }
199
206
 
200
- async function handleSend(text: string): Promise<void> {
201
- await runSend(() => api.sendMessage(conversation.id, text), labels.sendFailure)
207
+ async function handleSend(text: string): Promise<boolean> {
208
+ return runSend(() => api.sendMessage(conversation.id, text), labels.sendFailure)
209
+ }
210
+
211
+ /**
212
+ * Reabrir a janela é ato de atendente: quem manda o template quer conversar. A tomada vai antes do
213
+ * envio — se ficasse depois, a resposta do cliente chegaria com a conversa ainda no bot e o fluxo
214
+ * automático responderia por cima de quem acabou de reabri-la.
215
+ */
216
+ async function handleSendTemplate(): Promise<void> {
217
+ if (conversation.mode !== 'human' && onTakeover) await onTakeover()
218
+ await runSend(() => api.sendTemplate(conversation.id, {}), labels.sendFailure)
202
219
  }
203
220
 
204
221
  /**
@@ -206,17 +223,28 @@ export function ConversationPane({
206
223
  * frase embaixo, e mandar as duas separadas invertia a ordem quando a mídia demorava a subir.
207
224
  */
208
225
  async function handleRichSend(): Promise<void> {
209
- if (onSendAttachments && queuedFiles.length > 0) {
210
- const files = queuedFiles
211
- const caption = draft
212
- await runSend(() => onSendAttachments(files, caption), labels.attachFailure)
213
- setQueuedFiles([])
214
- setDraft('')
215
- return
226
+ // O upload da mídia demora e não dá retorno na tela; sem esta trava o segundo clique — ou o
227
+ // Enter impaciente — mandava o mesmo arquivo outra vez.
228
+ if (sendInFlightRef.current) return
229
+ sendInFlightRef.current = true
230
+ setIsSendingDraft(true)
231
+ try {
232
+ if (onSendAttachments && queuedFiles.length > 0) {
233
+ const files = queuedFiles
234
+ const caption = draft
235
+ const didSend = await runSend(() => onSendAttachments(files, caption), labels.attachFailure)
236
+ if (didSend) {
237
+ setQueuedFiles([])
238
+ setDraft('')
239
+ }
240
+ return
241
+ }
242
+ if (!draft.trim()) return
243
+ if (await handleSend(draft)) setDraft('')
244
+ } finally {
245
+ sendInFlightRef.current = false
246
+ setIsSendingDraft(false)
216
247
  }
217
- if (!draft.trim()) return
218
- await handleSend(draft)
219
- setDraft('')
220
248
  }
221
249
 
222
250
  async function handleAttach(file: File): Promise<void> {
@@ -309,10 +337,10 @@ export function ConversationPane({
309
337
  <div className="cv-workspace-selection">
310
338
  <span>{labels.messagesSelected(selectedMessageIds.size)}</span>
311
339
  <div className="cv-workspace-selection__actions">
312
- <button type="button" onClick={() => setSelectedMessageIds(new Set())}>
340
+ <button data-cv-tooltip={labels.bulkClear} aria-label={labels.bulkClear} type="button" onClick={() => setSelectedMessageIds(new Set())}>
313
341
  {labels.bulkClear}
314
342
  </button>
315
- <button type="button" onClick={copySelectedMessages}>
343
+ <button data-cv-tooltip={labels.copySelected} aria-label={labels.copySelected} type="button" onClick={copySelectedMessages}>
316
344
  {labels.copySelected}
317
345
  </button>
318
346
  </div>
@@ -328,7 +356,7 @@ export function ConversationPane({
328
356
  {blocked ? (
329
357
  <WindowExpiredNotice
330
358
  disabled={busy}
331
- onSendTemplate={() => void api.sendTemplate(conversation.id, {}).then(() => refetch())}
359
+ onSendTemplate={() => void handleSendTemplate()}
332
360
  />
333
361
  ) : botOwnsConversation ? (
334
362
  // Responder com a conversa no bot atropelaria o fluxo automático no meio de uma pergunta.
@@ -348,7 +376,8 @@ export function ConversationPane({
348
376
  }
349
377
  : {})}
350
378
  placeholder={labels.composerPlaceholder}
351
- isSending={busy}
379
+ isSending={busy || isSendingDraft}
380
+ hasQueuedAttachments={queuedFiles.length > 0}
352
381
  {...(onRecordAudio
353
382
  ? {
354
383
  idleAction: (
@@ -367,6 +396,7 @@ export function ConversationPane({
367
396
  <li key={`${file.name}-${index}`}>
368
397
  <span>{file.name}</span>
369
398
  <button
399
+ data-cv-tooltip={labels.attachmentRemove}
370
400
  type="button"
371
401
  aria-label={labels.attachmentRemove}
372
402
  onClick={() =>
@@ -14,6 +14,7 @@ import { WINDOW_FILTERS } from '../conversationWindow'
14
14
  import type { ConversationSummary } from '../providers/types'
15
15
  import type { ConversationsWorkspaceLabels } from './labels'
16
16
  import type { UseConversationsInboxResult } from './useConversationsInbox'
17
+ import { PAGINATION_LABELS } from '../pagination.constant'
17
18
 
18
19
  export interface ConversationsInboxListProps {
19
20
  readonly inbox: UseConversationsInboxResult
@@ -44,16 +45,17 @@ export function ConversationsInboxList({
44
45
  <div className="cv-workspace-bulk">
45
46
  <span className="cv-workspace-bulk__count">{labels.bulkSelected(inbox.selectedIds.size)}</span>
46
47
  <div className="cv-workspace-bulk__actions">
47
- <button type="button" onClick={inbox.clearBulkSelection}>
48
+ <button data-cv-tooltip={labels.bulkClear} aria-label={labels.bulkClear} type="button" onClick={inbox.clearBulkSelection}>
48
49
  {labels.bulkClear}
49
50
  </button>
50
51
  {onSendTemplateToSelected ? (
51
- <button type="button" onClick={onSendTemplateToSelected} disabled={inbox.busy}>
52
+ <button data-cv-tooltip={labels.bulkTemplate} aria-label={labels.bulkTemplate} type="button" onClick={onSendTemplateToSelected} disabled={inbox.busy}>
52
53
  {labels.bulkTemplate}
53
54
  </button>
54
55
  ) : null}
55
56
  {inbox.canFinalize ? (
56
57
  <button
58
+ data-cv-tooltip={labels.bulkFinalize} aria-label={labels.bulkFinalize}
57
59
  type="button"
58
60
  disabled={inbox.busy}
59
61
  onClick={() => {
@@ -89,9 +91,15 @@ export function ConversationsInboxList({
89
91
  type="button"
90
92
  onClick={() => inbox.setWindowFilter(filter.value)}
91
93
  aria-pressed={inbox.windowFilter === filter.value}
92
- className={`cv-workspace-chip${inbox.windowFilter === filter.value ? ' cv-workspace-chip--on' : ''}`}
94
+ data-cv-tooltip={filter.label} aria-label={filter.label}
95
+ className={[
96
+ 'cv-workspace-chip',
97
+ filter.tone ? `cv-workspace-chip--${filter.tone}` : '',
98
+ inbox.windowFilter === filter.value ? 'cv-workspace-chip--on' : '',
99
+ ]
100
+ .filter(Boolean)
101
+ .join(' ')}
93
102
  >
94
- {filter.dotClass ? <span className={`cv-workspace-dot ${filter.dotClass}`} /> : null}
95
103
  {filter.label}
96
104
  </button>
97
105
  ))}
@@ -103,6 +111,7 @@ export function ConversationsInboxList({
103
111
  <span className="cv-workspace-chips__legend">{labels.channelLegend}</span>
104
112
  {inbox.channelFilters.map((filter) => (
105
113
  <button
114
+ data-cv-tooltip={filter.label} aria-label={filter.label}
106
115
  key={filter.value}
107
116
  type="button"
108
117
  onClick={() => inbox.setChannelFilter(filter.value)}
@@ -150,14 +159,14 @@ export function ConversationsInboxList({
150
159
  <div className="cv-workspace-pager">
151
160
  <span>{labels.rangeOf(inbox.firstOnPage, inbox.lastOnPage, inbox.filteredCount)}</span>
152
161
  <div className="cv-workspace-pager__buttons">
153
- <button type="button" onClick={() => inbox.goToPage(1)} disabled={inbox.page === 1} aria-label="Primeira página">
162
+ <button type="button" onClick={() => inbox.goToPage(1)} disabled={inbox.page === 1} aria-label={PAGINATION_LABELS.first} data-cv-tooltip={PAGINATION_LABELS.first}>
154
163
  «
155
164
  </button>
156
165
  <button
157
166
  type="button"
158
167
  onClick={() => inbox.goToPage(inbox.page - 1)}
159
168
  disabled={inbox.page === 1}
160
- aria-label="Página anterior"
169
+ aria-label={PAGINATION_LABELS.previous} data-cv-tooltip={PAGINATION_LABELS.previous}
161
170
  >
162
171
  ‹
163
172
  </button>
@@ -166,7 +175,7 @@ export function ConversationsInboxList({
166
175
  type="button"
167
176
  onClick={() => inbox.goToPage(inbox.page + 1)}
168
177
  disabled={inbox.page === inbox.pageCount}
169
- aria-label="Próxima página"
178
+ aria-label={PAGINATION_LABELS.next} data-cv-tooltip={PAGINATION_LABELS.next}
170
179
  >
171
180
  ›
172
181
  </button>
@@ -174,7 +183,7 @@ export function ConversationsInboxList({
174
183
  type="button"
175
184
  onClick={() => inbox.goToPage(inbox.pageCount)}
176
185
  disabled={inbox.page === inbox.pageCount}
177
- aria-label="Última página"
186
+ aria-label={PAGINATION_LABELS.last} data-cv-tooltip={PAGINATION_LABELS.last}
178
187
  >
179
188
  »
180
189
  </button>
@@ -12,7 +12,9 @@
12
12
  */
13
13
 
14
14
  import { useEffect, useMemo, useState, type ReactNode } from 'react'
15
+ import { Check, CheckCheck, FlaskConical, Hourglass, Mail, MessagesSquare } from 'lucide-react'
15
16
 
17
+ import { ICON_SIZE_ACTION, ICON_SIZE_INLINE } from '../icon.constant'
16
18
  import type { ConversationContextEntry } from '../ConversationContextPanel'
17
19
  import type { ConversationHeaderUtility } from '../ConversationHeader'
18
20
  import type { QuickReply } from '../MessageComposer'
@@ -23,6 +25,7 @@ import { ConversationPane } from './ConversationPane'
23
25
  import { ConversationsInboxList } from './ConversationsInboxList'
24
26
  import { DEFAULT_CONVERSATIONS_WORKSPACE_LABELS, type ConversationsWorkspaceLabels } from './labels'
25
27
  import { useConversationsInbox, type UseConversationsInboxResult } from './useConversationsInbox'
28
+ import { TooltipLayer } from '../Tooltip'
26
29
 
27
30
  export interface ConversationsWorkspaceSimulator {
28
31
  /**
@@ -32,7 +35,8 @@ export interface ConversationsWorkspaceSimulator {
32
35
  render(params: { conversationId: string; close: () => void }): ReactNode
33
36
  /** Ausente = ligado. Serve para esconder fora de desenvolvimento sem condicionar o JSX. */
34
37
  readonly enabled?: boolean
35
- readonly icon?: string
38
+ /** Ícone da biblioteca (lucide) no utilitário do cabeçalho. Ausente, entra o frasco de teste. */
39
+ readonly icon?: ReactNode
36
40
  readonly label?: string
37
41
  }
38
42
 
@@ -166,7 +170,7 @@ export function ConversationsWorkspace({
166
170
  ...fromProduct,
167
171
  {
168
172
  key: 'simulator',
169
- icon: simulator?.icon ?? '🧪',
173
+ icon: simulator?.icon ?? <FlaskConical size={ICON_SIZE_ACTION} />,
170
174
  label: simulator?.label ?? 'Simular cliente',
171
175
  active: simulatorOpen,
172
176
  run: () => setSimulatorOpen((open) => !open),
@@ -176,22 +180,23 @@ export function ConversationsWorkspace({
176
180
 
177
181
  return (
178
182
  <div className={`cv-workspace${className ? ` ${className}` : ''}`}>
183
+ <TooltipLayer />
179
184
  {/* Cabeçalho denso em tela estreita: ícone + número. O rótulo escrito ocupava três linhas em
180
185
  375px e empurrava a lista para fora da tela. */}
181
186
  <header className="cv-workspace-header">
182
187
  <div className="cv-workspace-header__titles">
183
188
  <h1>{labels.title}</h1>
184
189
  <p>
185
- <span title={labels.conversations}>
186
- 💬 {inbox.totalCount}
190
+ <span data-cv-tooltip={labels.conversations} className="cv-stat">
191
+ <MessagesSquare size={ICON_SIZE_INLINE} aria-hidden="true" /> {inbox.totalCount}
187
192
  <span className="cv-only-wide"> {labels.conversations}</span>
188
193
  </span>
189
- <span title={labels.waiting} className="cv-workspace-header__waiting">
190
- ⏳ {inbox.waitingCount}
194
+ <span data-cv-tooltip={labels.waiting} className="cv-stat cv-workspace-header__waiting">
195
+ <Hourglass size={ICON_SIZE_INLINE} aria-hidden="true" /> {inbox.waitingCount}
191
196
  <span className="cv-only-wide"> {labels.waiting}</span>
192
197
  </span>
193
- <span title={labels.unread}>
194
- ✉️ {inbox.unreadCount}
198
+ <span data-cv-tooltip={labels.unread} className="cv-stat">
199
+ <Mail size={ICON_SIZE_INLINE} aria-hidden="true" /> {inbox.unreadCount}
195
200
  <span className="cv-only-wide"> {labels.unread}</span>
196
201
  </span>
197
202
  </p>
@@ -202,22 +207,24 @@ export function ConversationsWorkspace({
202
207
  type="button"
203
208
  onClick={() => inbox.setWaitingOnly(!inbox.waitingOnly)}
204
209
  aria-pressed={inbox.waitingOnly}
205
- title={labels.waitingOnly}
210
+ data-cv-tooltip={labels.waitingOnly} aria-label={labels.waitingOnly}
206
211
  className={inbox.waitingOnly ? 'cv-workspace-toggle cv-workspace-toggle--on' : 'cv-workspace-toggle'}
207
212
  >
208
- ⏳<span className="cv-only-wide"> {labels.waitingOnly}</span>
213
+ <Hourglass size={ICON_SIZE_ACTION} aria-hidden="true" />
214
+ <span className="cv-only-wide">{labels.waitingOnly}</span>
209
215
  </button>
210
216
  {/* O contador só aparece com seleção: " (0)" era texto morto ao lado do ícone. */}
211
217
  <button
212
218
  type="button"
213
219
  onClick={() => void inbox.markSelectedAsRead()}
214
220
  disabled={inbox.selectedIds.size === 0 || inbox.busy}
215
- title={labels.markSelectedAsRead}
221
+ data-cv-tooltip={labels.markSelectedAsRead}
216
222
  aria-label={labels.markSelectedAsRead}
217
223
  className="cv-workspace-toggle"
218
224
  >
219
- ✓<span className="cv-only-wide"> {labels.markSelectedAsRead}</span>
220
- {inbox.selectedIds.size > 0 ? ` (${inbox.selectedIds.size})` : ''}
225
+ <Check size={ICON_SIZE_ACTION} aria-hidden="true" />
226
+ <span className="cv-only-wide">{labels.markSelectedAsRead}</span>
227
+ {inbox.selectedIds.size > 0 ? <span>({inbox.selectedIds.size})</span> : null}
221
228
  </button>
222
229
  {/* Só com não lida na tela e só onde o host implementa a rota: sem isso o botão zerava
223
230
  nada e ainda assim ocupava o cabeçalho. */}
@@ -226,10 +233,11 @@ export function ConversationsWorkspace({
226
233
  type="button"
227
234
  onClick={() => void inbox.markAllAsRead()}
228
235
  disabled={inbox.busy}
229
- title={labels.markAllAsRead}
236
+ data-cv-tooltip={labels.markAllAsRead} aria-label={labels.markAllAsRead}
230
237
  className="cv-workspace-toggle"
231
238
  >
232
- ✓✓<span className="cv-only-wide"> {labels.markAllAsRead}</span>
239
+ <CheckCheck size={ICON_SIZE_ACTION} aria-hidden="true" />
240
+ <span className="cv-only-wide">{labels.markAllAsRead}</span>
233
241
  </button>
234
242
  ) : null}
235
243
  {renderHeaderActions?.(inbox)}
@@ -283,7 +291,7 @@ export function ConversationsWorkspace({
283
291
  now={inbox.now}
284
292
  busy={inbox.busy}
285
293
  labels={labels}
286
- {...(inbox.canTakeover ? { onTakeover: () => void inbox.takeover(selected.id) } : {})}
294
+ {...(inbox.canTakeover ? { onTakeover: () => inbox.takeover(selected.id) } : {})}
287
295
  {...(inbox.canTakeover ? { onReturnToBot: () => void inbox.releaseToBot(selected.id) } : {})}
288
296
  {...(inbox.canFinalize ? { onFinish: () => void inbox.finalize(selected.id) } : {})}
289
297
  {...(flowLabelOf ? { flowLabel: flowLabelOf(selected) } : {})}
@@ -265,8 +265,11 @@ export function useConversationsInbox(params: UseConversationsInboxParams = {}):
265
265
  selectedConversation: conversations.find((conversation) => conversation.id === selectedId),
266
266
  loading,
267
267
  now,
268
- totalCount: conversations.length,
269
- unreadCount: conversations.reduce((total, conversation) => total + conversation.unread, 0),
268
+ // Com paginação no servidor `conversations` é só a página, então contar o array mostrava o
269
+ // tamanho da página no lugar do tamanho da base. Não lidas e aguardando seguem sendo da página:
270
+ // somar a base inteira exige agregado do servidor, que a listagem não devolve.
271
+ totalCount: params.serverPaginated ? total : conversations.length,
272
+ unreadCount: conversations.reduce((sum, conversation) => sum + conversation.unread, 0),
270
273
  waitingCount: conversations.filter((conversation) => conversation.waitingHuman).length,
271
274
  filteredCount: totalForPaging,
272
275
  expiredSelectedCount: conversations.filter(