@adatechnology/conversations-ui 0.1.0-rc.23 → 0.1.0-rc.24

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.
package/dist/index.d.ts CHANGED
@@ -1300,6 +1300,9 @@ interface ConversationsWorkspaceLabels {
1300
1300
  readonly copySelected: string;
1301
1301
  readonly composerPlaceholder: string;
1302
1302
  readonly attachFailure: string;
1303
+ /** Tira um arquivo da fila antes de enviar. */
1304
+ readonly attachmentRemove: string;
1305
+ readonly recordFailure: string;
1303
1306
  readonly sendFailure: string;
1304
1307
  readonly takeoverToReply: string;
1305
1308
  readonly signIn: string;
@@ -1428,20 +1431,28 @@ interface ConversationsWorkspaceProps {
1428
1431
  readonly messageSelection?: boolean;
1429
1432
  /** Texto já no campo ao abrir a conversa (deep link que sugere a resposta). */
1430
1433
  readonly initialComposerText?: string | undefined;
1434
+ /** `rich` troca o campo simples pelo texto com a formatação do WhatsApp desenhada ao escrever. */
1435
+ readonly composer?: 'simple' | 'rich';
1436
+ /** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
1437
+ readonly composerVariablesFor?: (conversation: ConversationSummary, context: Record<string, unknown> | undefined) => readonly RichComposerVariable[];
1438
+ /** Fila de anexos com legenda, como no WhatsApp. Ausente, o clipe manda cada arquivo na hora. */
1439
+ readonly onSendAttachments?: (conversation: ConversationSummary, files: readonly File[], caption: string) => Promise<void>;
1440
+ /** Nota de voz. Ausente, o microfone não aparece. */
1441
+ readonly onRecordAudio?: (conversation: ConversationSummary, file: File) => Promise<void>;
1431
1442
  readonly contextEntriesOf?: (context: Record<string, unknown> | undefined) => readonly ConversationContextEntry[];
1432
1443
  readonly onAttach?: (conversation: ConversationSummary, file: File) => Promise<void>;
1433
1444
  readonly extraUtilitiesFor?: (conversation: ConversationSummary) => readonly ConversationHeaderUtility[];
1434
1445
  readonly renderFilters?: (inbox: UseConversationsInboxResult) => ReactNode;
1435
1446
  readonly renderBulkActions?: (inbox: UseConversationsInboxResult) => ReactNode;
1436
1447
  readonly renderRow?: (conversation: ConversationSummary) => ReactNode;
1437
- readonly renderAboveTranscript?: (conversation: ConversationSummary) => ReactNode;
1448
+ readonly renderAboveTranscript?: (conversation: ConversationSummary, context: Record<string, unknown> | undefined) => ReactNode;
1438
1449
  readonly renderHeaderActions?: (inbox: UseConversationsInboxResult) => ReactNode;
1439
1450
  readonly onSendTemplateToSelected?: (inbox: UseConversationsInboxResult) => void;
1440
1451
  /** Destino do link de reentrar no painel, mostrado junto do aviso de sessão expirada. */
1441
1452
  readonly signInHref?: string;
1442
1453
  readonly className?: string;
1443
1454
  }
1444
- declare function ConversationsWorkspace({ labels: labelsOverride, filters, perPage, markReadOnOpen, serverPaginated, initialConversationId, initialWhatsappNumber, simulator, quickReplies, quickReplyVariablesFor, flowLabelOf, onDownload, requireTakeoverToReply, messageSelection, initialComposerText, contextEntriesOf, onAttach, extraUtilitiesFor, renderFilters, renderBulkActions, renderRow, renderAboveTranscript, renderHeaderActions, onSendTemplateToSelected, signInHref, className, }: ConversationsWorkspaceProps): react.JSX.Element;
1455
+ declare function ConversationsWorkspace({ labels: labelsOverride, filters, perPage, markReadOnOpen, serverPaginated, initialConversationId, initialWhatsappNumber, simulator, quickReplies, quickReplyVariablesFor, flowLabelOf, onDownload, requireTakeoverToReply, messageSelection, initialComposerText, composer, composerVariablesFor, onSendAttachments, onRecordAudio, contextEntriesOf, onAttach, extraUtilitiesFor, renderFilters, renderBulkActions, renderRow, renderAboveTranscript, renderHeaderActions, onSendTemplateToSelected, signInHref, className, }: ConversationsWorkspaceProps): react.JSX.Element;
1445
1456
 
1446
1457
  interface ConversationPaneProps {
1447
1458
  readonly conversation: ConversationSummary;
@@ -1480,11 +1491,30 @@ interface ConversationPaneProps {
1480
1491
  readonly messageSelection?: boolean;
1481
1492
  /** Texto já no campo ao abrir (deep link que sugere a resposta). */
1482
1493
  readonly initialComposerText?: string | undefined;
1483
- /** Peça do produto entre o contexto e o transcript (ex.: ficha do lead, resumo do pedido). */
1484
- readonly renderAboveTranscript?: (conversation: ConversationSummary) => ReactNode;
1494
+ /**
1495
+ * Peça do produto entre o contexto e o transcript (ex.: ficha do lead, resumo do pedido). Recebe o
1496
+ * contexto do fluxo junto: é dele que sai o resumo, e sem isso o host teria de buscá-lo de novo.
1497
+ */
1498
+ readonly renderAboveTranscript?: (conversation: ConversationSummary, context: Record<string, unknown> | undefined) => ReactNode;
1485
1499
  readonly onAttach?: ((file: File) => Promise<void>) | undefined;
1500
+ /**
1501
+ * `rich` troca o `textarea` pelo campo com a formatação do WhatsApp desenhada enquanto se escreve.
1502
+ * Vale onde o atendente manda texto longo e formatado; para responder "já vou ver" o campo simples
1503
+ * é menos coisa na tela.
1504
+ */
1505
+ readonly composer?: 'simple' | 'rich';
1506
+ /** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
1507
+ readonly composerVariablesFor?: (conversation: ConversationSummary, context: Record<string, unknown> | undefined) => readonly RichComposerVariable[];
1508
+ /**
1509
+ * Fila de anexos com legenda, como no WhatsApp: os arquivos escolhidos ficam visíveis acima da
1510
+ * barra e saem junto com o texto escrito. Ausente, o clipe manda cada arquivo na hora — o que
1511
+ * perde a legenda, e era a diferença entre as telas.
1512
+ */
1513
+ readonly onSendAttachments?: (files: readonly File[], caption: string) => Promise<void>;
1514
+ /** Grava e envia nota de voz. Ausente, o microfone não aparece. */
1515
+ readonly onRecordAudio?: (file: File) => Promise<void>;
1486
1516
  }
1487
- declare function ConversationPane({ conversation, now, busy, labels, onTakeover, onReturnToBot, onFinish, onBack, extraUtilities, contextEntriesOf, quickReplies, quickReplyVariablesFor, flowLabel, onDownload, requireTakeoverToReply, messageSelection, initialComposerText, renderAboveTranscript, onAttach, }: ConversationPaneProps): react.JSX.Element;
1517
+ declare function ConversationPane({ conversation, now, busy, labels, onTakeover, onReturnToBot, onFinish, onBack, extraUtilities, contextEntriesOf, quickReplies, quickReplyVariablesFor, flowLabel, onDownload, requireTakeoverToReply, messageSelection, initialComposerText, renderAboveTranscript, onAttach, composer, composerVariablesFor, onSendAttachments, onRecordAudio, }: ConversationPaneProps): react.JSX.Element;
1488
1518
 
1489
1519
  interface ConversationsInboxListProps {
1490
1520
  readonly inbox: UseConversationsInboxResult;
package/dist/index.js CHANGED
@@ -2699,7 +2699,11 @@ function ConversationPane({
2699
2699
  messageSelection,
2700
2700
  initialComposerText,
2701
2701
  renderAboveTranscript,
2702
- onAttach
2702
+ onAttach,
2703
+ composer = "simple",
2704
+ composerVariablesFor,
2705
+ onSendAttachments,
2706
+ onRecordAudio
2703
2707
  }) {
2704
2708
  const context = useConversations();
2705
2709
  if (!context) {
@@ -2712,9 +2716,11 @@ function ConversationPane({
2712
2716
  const [sendFailure, setSendFailure] = useState12(void 0);
2713
2717
  const [selectedMessageIds, setSelectedMessageIds] = useState12(/* @__PURE__ */ new Set());
2714
2718
  const [draft, setDraft] = useState12(initialComposerText ?? "");
2719
+ const [queuedFiles, setQueuedFiles] = useState12([]);
2715
2720
  useEffect8(() => {
2716
2721
  setSelectedMessageIds(/* @__PURE__ */ new Set());
2717
2722
  setDraft(initialComposerText ?? "");
2723
+ setQueuedFiles([]);
2718
2724
  }, [conversation.id, initialComposerText]);
2719
2725
  function toggleMessageSelected(messageId) {
2720
2726
  setSelectedMessageIds((current) => {
@@ -2750,6 +2756,19 @@ function ConversationPane({
2750
2756
  async function handleSend(text) {
2751
2757
  await runSend(() => api.sendMessage(conversation.id, text), labels.sendFailure);
2752
2758
  }
2759
+ async function handleRichSend() {
2760
+ if (onSendAttachments && queuedFiles.length > 0) {
2761
+ const files = queuedFiles;
2762
+ const caption = draft;
2763
+ await runSend(() => onSendAttachments(files, caption), labels.attachFailure);
2764
+ setQueuedFiles([]);
2765
+ setDraft("");
2766
+ return;
2767
+ }
2768
+ if (!draft.trim()) return;
2769
+ await handleSend(draft);
2770
+ setDraft("");
2771
+ }
2753
2772
  async function handleAttach(file) {
2754
2773
  if (!onAttach) return;
2755
2774
  await runSend(() => onAttach(file), labels.attachFailure);
@@ -2765,6 +2784,12 @@ function ConversationPane({
2765
2784
  );
2766
2785
  }
2767
2786
  const contextEntries = contextEntriesOf?.(conversationContext);
2787
+ const composerVariables = composerVariablesFor?.(conversation, conversationContext);
2788
+ const richQuickReplies = quickReplies?.map((reply) => ({
2789
+ id: reply.key,
2790
+ label: reply.label,
2791
+ text: typeof reply.text === "string" ? applyQuickReplyVariables(reply.text, quickReplyVariablesFor?.(conversation, conversationContext) ?? {}) : reply.text(quickReplyVariablesFor?.(conversation, conversationContext) ?? {})
2792
+ }));
2768
2793
  const botOwnsConversation = Boolean(requireTakeoverToReply) && conversation.mode !== "human";
2769
2794
  return /* @__PURE__ */ jsxs20("div", { className: "cv-workspace-pane", children: [
2770
2795
  /* @__PURE__ */ jsx22(
@@ -2784,7 +2809,7 @@ function ConversationPane({
2784
2809
  ),
2785
2810
  contextEntries && contextEntries.length > 0 || flowLabel ? /* @__PURE__ */ jsx22(ConversationContextPanel, { entries: contextEntries ?? [], ...flowLabel ? { flowLabel } : {} }) : null,
2786
2811
  /* @__PURE__ */ jsx22(ConversationDocumentsPanel, { conversationId: conversation.id, open: documentsOpen }),
2787
- renderAboveTranscript?.(conversation),
2812
+ renderAboveTranscript?.(conversation, conversationContext),
2788
2813
  /* @__PURE__ */ jsx22(
2789
2814
  ConversationWallpaper,
2790
2815
  {
@@ -2830,6 +2855,45 @@ function ConversationPane({
2830
2855
  ) : botOwnsConversation ? (
2831
2856
  // Responder com a conversa no bot atropelaria o fluxo automático no meio de uma pergunta.
2832
2857
  /* @__PURE__ */ jsx22("p", { className: "cv-workspace-notice", children: labels.takeoverToReply })
2858
+ ) : composer === "rich" ? /* @__PURE__ */ jsx22(
2859
+ RichMessageComposer,
2860
+ {
2861
+ value: draft,
2862
+ onChange: setDraft,
2863
+ onSend: () => void handleRichSend(),
2864
+ ...onSendAttachments ? { onAttachFiles: (files) => setQueuedFiles((current) => [...current, ...Array.from(files)]) } : onAttach ? {
2865
+ onAttachFiles: (files) => {
2866
+ for (const file of Array.from(files)) void handleAttach(file);
2867
+ }
2868
+ } : {},
2869
+ placeholder: labels.composerPlaceholder,
2870
+ isSending: busy,
2871
+ ...onRecordAudio ? {
2872
+ idleAction: /* @__PURE__ */ jsx22(
2873
+ AudioRecorderButton,
2874
+ {
2875
+ onRecorded: (file) => void runSend(() => onRecordAudio(file), labels.recordFailure),
2876
+ onFailure: (failureMessage) => setSendFailure(failureMessage)
2877
+ }
2878
+ )
2879
+ } : {},
2880
+ ...queuedFiles.length > 0 ? {
2881
+ attachmentsPreview: /* @__PURE__ */ jsx22("ul", { className: "cv-workspace-attachments", children: queuedFiles.map((file, index) => /* @__PURE__ */ jsxs20("li", { children: [
2882
+ /* @__PURE__ */ jsx22("span", { children: file.name }),
2883
+ /* @__PURE__ */ jsx22(
2884
+ "button",
2885
+ {
2886
+ type: "button",
2887
+ "aria-label": labels.attachmentRemove,
2888
+ onClick: () => setQueuedFiles((current) => current.filter((_, position) => position !== index)),
2889
+ children: "\u2715"
2890
+ }
2891
+ )
2892
+ ] }, `${file.name}-${index}`)) })
2893
+ } : {},
2894
+ ...richQuickReplies ? { quickReplies: [...richQuickReplies] } : {},
2895
+ ...composerVariables ? { variables: [...composerVariables] } : {}
2896
+ }
2833
2897
  ) : /* @__PURE__ */ jsx22(
2834
2898
  MessageComposer,
2835
2899
  {
@@ -3021,6 +3085,8 @@ var DEFAULT_CONVERSATIONS_WORKSPACE_LABELS = {
3021
3085
  copySelected: "Copiar",
3022
3086
  composerPlaceholder: "Responder como atendente\u2026",
3023
3087
  attachFailure: "Falha ao enviar o arquivo.",
3088
+ attachmentRemove: "Remover anexo",
3089
+ recordFailure: "Falha ao gravar o \xE1udio.",
3024
3090
  sendFailure: "Falha ao enviar a mensagem.",
3025
3091
  takeoverToReply: "Assuma o atendimento para responder diretamente ao cliente.",
3026
3092
  signIn: "Entrar no painel",
@@ -3225,6 +3291,10 @@ function ConversationsWorkspace({
3225
3291
  requireTakeoverToReply,
3226
3292
  messageSelection,
3227
3293
  initialComposerText,
3294
+ composer,
3295
+ composerVariablesFor,
3296
+ onSendAttachments,
3297
+ onRecordAudio,
3228
3298
  contextEntriesOf,
3229
3299
  onAttach,
3230
3300
  extraUtilitiesFor,
@@ -3403,6 +3473,12 @@ function ConversationsWorkspace({
3403
3473
  ...requireTakeoverToReply ? { requireTakeoverToReply } : {},
3404
3474
  ...messageSelection ? { messageSelection } : {},
3405
3475
  ...initialComposerText ? { initialComposerText } : {},
3476
+ ...composer ? { composer } : {},
3477
+ ...composerVariablesFor ? { composerVariablesFor } : {},
3478
+ ...onSendAttachments ? {
3479
+ onSendAttachments: (files, caption) => onSendAttachments(selected, files, caption)
3480
+ } : {},
3481
+ ...onRecordAudio ? { onRecordAudio: (file) => onRecordAudio(selected, file) } : {},
3406
3482
  onBack: inbox.clearSelection,
3407
3483
  ...paneUtilities ? { extraUtilities: paneUtilities } : {},
3408
3484
  ...contextEntriesOf ? { contextEntriesOf } : {},
package/dist/styles.css CHANGED
@@ -796,3 +796,38 @@
796
796
  border-color: #4b5563;
797
797
  color: #f3f4f6;
798
798
  }
799
+ .cv-workspace-attachments {
800
+ display: flex;
801
+ flex-wrap: wrap;
802
+ gap: 0.5rem;
803
+ margin: 0 0 0.5rem;
804
+ padding: 0;
805
+ list-style: none;
806
+ }
807
+ .cv-workspace-attachments li {
808
+ display: flex;
809
+ align-items: center;
810
+ gap: 0.5rem;
811
+ max-width: 14rem;
812
+ padding: 0.25rem 0.5rem;
813
+ border: 1px solid rgb(226 232 240);
814
+ border-radius: 0.5rem;
815
+ font-size: 0.75rem;
816
+ }
817
+ .cv-workspace-attachments li span {
818
+ overflow: hidden;
819
+ text-overflow: ellipsis;
820
+ white-space: nowrap;
821
+ }
822
+ .cv-workspace-attachments li button {
823
+ flex: none;
824
+ min-width: 1.5rem;
825
+ min-height: 1.5rem;
826
+ cursor: pointer;
827
+ background: none;
828
+ border: 0;
829
+ color: inherit;
830
+ }
831
+ .dark .cv-workspace-attachments li {
832
+ border-color: rgb(51 65 85);
833
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.23",
3
+ "version": "0.1.0-rc.24",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/styles.css CHANGED
@@ -734,3 +734,41 @@
734
734
  border-color: #4b5563;
735
735
  color: #f3f4f6;
736
736
  }
737
+
738
+ /* Fila de anexos acima da barra: nomes visíveis para conferir antes de mandar, e um X por item —
739
+ escolher seis arquivos e não poder tirar o errado obrigava a recomeçar. */
740
+ .cv-workspace-attachments {
741
+ display: flex;
742
+ flex-wrap: wrap;
743
+ gap: 0.5rem;
744
+ margin: 0 0 0.5rem;
745
+ padding: 0;
746
+ list-style: none;
747
+ }
748
+ .cv-workspace-attachments li {
749
+ display: flex;
750
+ align-items: center;
751
+ gap: 0.5rem;
752
+ max-width: 14rem;
753
+ padding: 0.25rem 0.5rem;
754
+ border: 1px solid rgb(226 232 240);
755
+ border-radius: 0.5rem;
756
+ font-size: 0.75rem;
757
+ }
758
+ .cv-workspace-attachments li span {
759
+ overflow: hidden;
760
+ text-overflow: ellipsis;
761
+ white-space: nowrap;
762
+ }
763
+ .cv-workspace-attachments li button {
764
+ flex: none;
765
+ min-width: 1.5rem;
766
+ min-height: 1.5rem;
767
+ cursor: pointer;
768
+ background: none;
769
+ border: 0;
770
+ color: inherit;
771
+ }
772
+ .dark .cv-workspace-attachments li {
773
+ border-color: rgb(51 65 85);
774
+ }
@@ -7,13 +7,15 @@
7
7
 
8
8
  import { useEffect, useState, type ReactNode } from 'react'
9
9
 
10
+ import { AudioRecorderButton } from '../AudioRecorderButton'
10
11
  import { ConversationContextPanel, type ConversationContextEntry } from '../ConversationContextPanel'
11
12
  import { ConversationDocumentsPanel } from '../ConversationDocumentsPanel'
12
13
  import { ConversationHeader, type ConversationHeaderUtility } from '../ConversationHeader'
13
14
  import { ConversationWallpaper } from '../Wallpaper'
14
15
  import { DateDivider } from '../DateDivider'
15
16
  import { MessageBubble } from '../MessageBubble'
16
- import { MessageComposer, type QuickReply } from '../MessageComposer'
17
+ import { MessageComposer, applyQuickReplyVariables, type QuickReply } from '../MessageComposer'
18
+ import { RichMessageComposer, type RichComposerVariable } from '../RichMessageComposer'
17
19
  import { WindowExpiredNotice, isWindowBlocking } from '../WindowExpiredNotice'
18
20
  import { windowOf } from '../conversationWindow'
19
21
  import {
@@ -69,9 +71,34 @@ export interface ConversationPaneProps {
69
71
  readonly messageSelection?: boolean
70
72
  /** Texto já no campo ao abrir (deep link que sugere a resposta). */
71
73
  readonly initialComposerText?: string | undefined
72
- /** Peça do produto entre o contexto e o transcript (ex.: ficha do lead, resumo do pedido). */
73
- readonly renderAboveTranscript?: (conversation: ConversationSummary) => ReactNode
74
+ /**
75
+ * Peça do produto entre o contexto e o transcript (ex.: ficha do lead, resumo do pedido). Recebe o
76
+ * contexto do fluxo junto: é dele que sai o resumo, e sem isso o host teria de buscá-lo de novo.
77
+ */
78
+ readonly renderAboveTranscript?: (
79
+ conversation: ConversationSummary,
80
+ context: Record<string, unknown> | undefined,
81
+ ) => ReactNode
74
82
  readonly onAttach?: ((file: File) => Promise<void>) | undefined
83
+ /**
84
+ * `rich` troca o `textarea` pelo campo com a formatação do WhatsApp desenhada enquanto se escreve.
85
+ * Vale onde o atendente manda texto longo e formatado; para responder "já vou ver" o campo simples
86
+ * é menos coisa na tela.
87
+ */
88
+ readonly composer?: 'simple' | 'rich'
89
+ /** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
90
+ readonly composerVariablesFor?: (
91
+ conversation: ConversationSummary,
92
+ context: Record<string, unknown> | undefined,
93
+ ) => readonly RichComposerVariable[]
94
+ /**
95
+ * Fila de anexos com legenda, como no WhatsApp: os arquivos escolhidos ficam visíveis acima da
96
+ * barra e saem junto com o texto escrito. Ausente, o clipe manda cada arquivo na hora — o que
97
+ * perde a legenda, e era a diferença entre as telas.
98
+ */
99
+ readonly onSendAttachments?: (files: readonly File[], caption: string) => Promise<void>
100
+ /** Grava e envia nota de voz. Ausente, o microfone não aparece. */
101
+ readonly onRecordAudio?: (file: File) => Promise<void>
75
102
  }
76
103
 
77
104
  export function ConversationPane({
@@ -94,6 +121,10 @@ export function ConversationPane({
94
121
  initialComposerText,
95
122
  renderAboveTranscript,
96
123
  onAttach,
124
+ composer = 'simple',
125
+ composerVariablesFor,
126
+ onSendAttachments,
127
+ onRecordAudio,
97
128
  }: ConversationPaneProps) {
98
129
  const context = useConversations()
99
130
  if (!context) {
@@ -107,12 +138,14 @@ export function ConversationPane({
107
138
  const [sendFailure, setSendFailure] = useState<string | undefined>(undefined)
108
139
  const [selectedMessageIds, setSelectedMessageIds] = useState<ReadonlySet<string>>(new Set())
109
140
  const [draft, setDraft] = useState(initialComposerText ?? '')
141
+ const [queuedFiles, setQueuedFiles] = useState<readonly File[]>([])
110
142
 
111
143
  // Trocar de conversa zera as duas coisas: seleção de mensagem e rascunho pertencem à thread, e
112
144
  // levá-los adiante faria copiar o trecho errado ou responder ao cliente errado.
113
145
  useEffect(() => {
114
146
  setSelectedMessageIds(new Set())
115
147
  setDraft(initialComposerText ?? '')
148
+ setQueuedFiles([])
116
149
  }, [conversation.id, initialComposerText])
117
150
 
118
151
  function toggleMessageSelected(messageId: string): void {
@@ -168,6 +201,24 @@ export function ConversationPane({
168
201
  await runSend(() => api.sendMessage(conversation.id, text), labels.sendFailure)
169
202
  }
170
203
 
204
+ /**
205
+ * O texto escrito é a legenda do anexo, não uma segunda mensagem: no WhatsApp a foto chega com a
206
+ * frase embaixo, e mandar as duas separadas invertia a ordem quando a mídia demorava a subir.
207
+ */
208
+ 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
216
+ }
217
+ if (!draft.trim()) return
218
+ await handleSend(draft)
219
+ setDraft('')
220
+ }
221
+
171
222
  async function handleAttach(file: File): Promise<void> {
172
223
  if (!onAttach) return
173
224
  await runSend(() => onAttach(file), labels.attachFailure)
@@ -185,6 +236,19 @@ export function ConversationPane({
185
236
  }
186
237
 
187
238
  const contextEntries = contextEntriesOf?.(conversationContext)
239
+ const composerVariables = composerVariablesFor?.(conversation, conversationContext)
240
+ /**
241
+ * As mesmas `quickReplies` do composer simples, com as variáveis já resolvidas — o campo rico
242
+ * recebe texto pronto. Uma segunda lista, só de formato diferente, é como as telas divergiam.
243
+ */
244
+ const richQuickReplies = quickReplies?.map((reply) => ({
245
+ id: reply.key,
246
+ label: reply.label,
247
+ text:
248
+ typeof reply.text === 'string'
249
+ ? applyQuickReplyVariables(reply.text, quickReplyVariablesFor?.(conversation, conversationContext) ?? {})
250
+ : reply.text(quickReplyVariablesFor?.(conversation, conversationContext) ?? {}),
251
+ }))
188
252
  const botOwnsConversation = Boolean(requireTakeoverToReply) && conversation.mode !== 'human'
189
253
 
190
254
  return (
@@ -207,7 +271,7 @@ export function ConversationPane({
207
271
  ) : null}
208
272
  <ConversationDocumentsPanel conversationId={conversation.id} open={documentsOpen} />
209
273
 
210
- {renderAboveTranscript?.(conversation)}
274
+ {renderAboveTranscript?.(conversation, conversationContext)}
211
275
 
212
276
  {/* Mesmo wallpaper do preview do cliente: atendente e cliente devem ver a conversa com a
213
277
  mesma aparência, senão o preview deixa de ser referência confiável. */}
@@ -269,6 +333,57 @@ export function ConversationPane({
269
333
  ) : botOwnsConversation ? (
270
334
  // Responder com a conversa no bot atropelaria o fluxo automático no meio de uma pergunta.
271
335
  <p className="cv-workspace-notice">{labels.takeoverToReply}</p>
336
+ ) : composer === 'rich' ? (
337
+ <RichMessageComposer
338
+ value={draft}
339
+ onChange={setDraft}
340
+ onSend={() => void handleRichSend()}
341
+ {...(onSendAttachments
342
+ ? { onAttachFiles: (files: FileList) => setQueuedFiles((current) => [...current, ...Array.from(files)]) }
343
+ : onAttach
344
+ ? {
345
+ onAttachFiles: (files: FileList) => {
346
+ for (const file of Array.from(files)) void handleAttach(file)
347
+ },
348
+ }
349
+ : {})}
350
+ placeholder={labels.composerPlaceholder}
351
+ isSending={busy}
352
+ {...(onRecordAudio
353
+ ? {
354
+ idleAction: (
355
+ <AudioRecorderButton
356
+ onRecorded={(file) => void runSend(() => onRecordAudio(file), labels.recordFailure)}
357
+ onFailure={(failureMessage) => setSendFailure(failureMessage)}
358
+ />
359
+ ),
360
+ }
361
+ : {})}
362
+ {...(queuedFiles.length > 0
363
+ ? {
364
+ attachmentsPreview: (
365
+ <ul className="cv-workspace-attachments">
366
+ {queuedFiles.map((file, index) => (
367
+ <li key={`${file.name}-${index}`}>
368
+ <span>{file.name}</span>
369
+ <button
370
+ type="button"
371
+ aria-label={labels.attachmentRemove}
372
+ onClick={() =>
373
+ setQueuedFiles((current) => current.filter((_, position) => position !== index))
374
+ }
375
+ >
376
+
377
+ </button>
378
+ </li>
379
+ ))}
380
+ </ul>
381
+ ),
382
+ }
383
+ : {})}
384
+ {...(richQuickReplies ? { quickReplies: [...richQuickReplies] } : {})}
385
+ {...(composerVariables ? { variables: [...composerVariables] } : {})}
386
+ />
272
387
  ) : (
273
388
  <MessageComposer
274
389
  value={draft}
@@ -16,6 +16,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'
16
16
  import type { ConversationContextEntry } from '../ConversationContextPanel'
17
17
  import type { ConversationHeaderUtility } from '../ConversationHeader'
18
18
  import type { QuickReply } from '../MessageComposer'
19
+ import type { RichComposerVariable } from '../RichMessageComposer'
19
20
  import type { ConversationSummary } from '../providers/types'
20
21
  import { BulkTemplateModal } from './BulkTemplateModal'
21
22
  import { ConversationPane } from './ConversationPane'
@@ -62,13 +63,31 @@ export interface ConversationsWorkspaceProps {
62
63
  readonly messageSelection?: boolean
63
64
  /** Texto já no campo ao abrir a conversa (deep link que sugere a resposta). */
64
65
  readonly initialComposerText?: string | undefined
66
+ /** `rich` troca o campo simples pelo texto com a formatação do WhatsApp desenhada ao escrever. */
67
+ readonly composer?: 'simple' | 'rich'
68
+ /** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
69
+ readonly composerVariablesFor?: (
70
+ conversation: ConversationSummary,
71
+ context: Record<string, unknown> | undefined,
72
+ ) => readonly RichComposerVariable[]
73
+ /** Fila de anexos com legenda, como no WhatsApp. Ausente, o clipe manda cada arquivo na hora. */
74
+ readonly onSendAttachments?: (
75
+ conversation: ConversationSummary,
76
+ files: readonly File[],
77
+ caption: string,
78
+ ) => Promise<void>
79
+ /** Nota de voz. Ausente, o microfone não aparece. */
80
+ readonly onRecordAudio?: (conversation: ConversationSummary, file: File) => Promise<void>
65
81
  readonly contextEntriesOf?: (context: Record<string, unknown> | undefined) => readonly ConversationContextEntry[]
66
82
  readonly onAttach?: (conversation: ConversationSummary, file: File) => Promise<void>
67
83
  readonly extraUtilitiesFor?: (conversation: ConversationSummary) => readonly ConversationHeaderUtility[]
68
84
  readonly renderFilters?: (inbox: UseConversationsInboxResult) => ReactNode
69
85
  readonly renderBulkActions?: (inbox: UseConversationsInboxResult) => ReactNode
70
86
  readonly renderRow?: (conversation: ConversationSummary) => ReactNode
71
- readonly renderAboveTranscript?: (conversation: ConversationSummary) => ReactNode
87
+ readonly renderAboveTranscript?: (
88
+ conversation: ConversationSummary,
89
+ context: Record<string, unknown> | undefined,
90
+ ) => ReactNode
72
91
  readonly renderHeaderActions?: (inbox: UseConversationsInboxResult) => ReactNode
73
92
  readonly onSendTemplateToSelected?: (inbox: UseConversationsInboxResult) => void
74
93
  /** Destino do link de reentrar no painel, mostrado junto do aviso de sessão expirada. */
@@ -92,6 +111,10 @@ export function ConversationsWorkspace({
92
111
  requireTakeoverToReply,
93
112
  messageSelection,
94
113
  initialComposerText,
114
+ composer,
115
+ composerVariablesFor,
116
+ onSendAttachments,
117
+ onRecordAudio,
95
118
  contextEntriesOf,
96
119
  onAttach,
97
120
  extraUtilitiesFor,
@@ -268,6 +291,15 @@ export function ConversationsWorkspace({
268
291
  {...(requireTakeoverToReply ? { requireTakeoverToReply } : {})}
269
292
  {...(messageSelection ? { messageSelection } : {})}
270
293
  {...(initialComposerText ? { initialComposerText } : {})}
294
+ {...(composer ? { composer } : {})}
295
+ {...(composerVariablesFor ? { composerVariablesFor } : {})}
296
+ {...(onSendAttachments
297
+ ? {
298
+ onSendAttachments: (files: readonly File[], caption: string) =>
299
+ onSendAttachments(selected, files, caption),
300
+ }
301
+ : {})}
302
+ {...(onRecordAudio ? { onRecordAudio: (file: File) => onRecordAudio(selected, file) } : {})}
271
303
  onBack={inbox.clearSelection}
272
304
  {...(paneUtilities ? { extraUtilities: paneUtilities } : {})}
273
305
  {...(contextEntriesOf ? { contextEntriesOf } : {})}
@@ -34,6 +34,9 @@ export interface ConversationsWorkspaceLabels {
34
34
  readonly copySelected: string
35
35
  readonly composerPlaceholder: string
36
36
  readonly attachFailure: string
37
+ /** Tira um arquivo da fila antes de enviar. */
38
+ readonly attachmentRemove: string
39
+ readonly recordFailure: string
37
40
  readonly sendFailure: string
38
41
  readonly takeoverToReply: string
39
42
  readonly signIn: string
@@ -72,6 +75,8 @@ export const DEFAULT_CONVERSATIONS_WORKSPACE_LABELS: ConversationsWorkspaceLabel
72
75
  copySelected: 'Copiar',
73
76
  composerPlaceholder: 'Responder como atendente…',
74
77
  attachFailure: 'Falha ao enviar o arquivo.',
78
+ attachmentRemove: 'Remover anexo',
79
+ recordFailure: 'Falha ao gravar o áudio.',
75
80
  sendFailure: 'Falha ao enviar a mensagem.',
76
81
  takeoverToReply: 'Assuma o atendimento para responder diretamente ao cliente.',
77
82
  signIn: 'Entrar no painel',