@adatechnology/conversations-ui 0.1.1 → 0.2.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 (49) hide show
  1. package/dist/{ConversationSimulatorPanel--5fIzXWY.d.ts → ConversationSimulatorPanel-ikgmlciE.d.ts} +103 -1
  2. package/dist/{chunk-BJNRLLDO.js → chunk-NHQDQJL2.js} +769 -244
  3. package/dist/index.d.ts +304 -9
  4. package/dist/index.js +2166 -509
  5. package/dist/preview/index.d.ts +2 -2
  6. package/dist/preview/index.js +147 -1
  7. package/dist/styles.css +135 -0
  8. package/package.json +1 -1
  9. package/src/MessageComposer.test.tsx +14 -0
  10. package/src/MessageComposer.tsx +327 -73
  11. package/src/RichMessageComposer.test.tsx +22 -4
  12. package/src/RichMessageComposer.tsx +674 -370
  13. package/src/index.ts +28 -1
  14. package/src/preview/createMockConversationsApi.ts +184 -0
  15. package/src/providers/types.ts +34 -0
  16. package/src/quickReplies/AttachmentsFormSection.tsx +160 -0
  17. package/src/quickReplies/QuickRepliesPicker.test.tsx +114 -0
  18. package/src/quickReplies/QuickRepliesPicker.tsx +188 -0
  19. package/src/quickReplies/QuickRepliesWorkspace.test.tsx +32 -0
  20. package/src/quickReplies/QuickRepliesWorkspace.tsx +395 -0
  21. package/src/quickReplies/createUploadQueue.test.ts +106 -0
  22. package/src/quickReplies/createUploadQueue.ts +69 -0
  23. package/src/quickReplies/index.ts +5 -0
  24. package/src/quickReplies/labels.ts +124 -0
  25. package/src/quickReplies/quickReply.types.ts +74 -0
  26. package/src/quickReplies/quickReplyAttachmentUpload.test.ts +76 -0
  27. package/src/quickReplies/quickReplyAttachmentUpload.ts +81 -0
  28. package/src/quickReplies/quickReplyAttachments.test.ts +396 -0
  29. package/src/quickReplies/quickReplyAttachments.ts +289 -0
  30. package/src/quickReplies/quickReplySearch.test.ts +88 -0
  31. package/src/quickReplies/quickReplySearch.ts +104 -0
  32. package/src/quickReplies/quickReplyShortcut.test.ts +38 -0
  33. package/src/quickReplies/quickReplyShortcut.ts +46 -0
  34. package/src/quickReplies/resolveConversationVariables.test.ts +63 -0
  35. package/src/quickReplies/resolveConversationVariables.ts +41 -0
  36. package/src/quickReplies/useQuickRepliesPicker.test.ts +20 -0
  37. package/src/quickReplies/useQuickRepliesPicker.ts +121 -0
  38. package/src/quickReplies/useQuickRepliesWorkspace.test.ts +222 -0
  39. package/src/quickReplies/useQuickRepliesWorkspace.ts +426 -0
  40. package/src/quickReplies/useQuickReplyAttachmentUploads.ts +159 -0
  41. package/src/styles.css +87 -0
  42. package/src/workspace/ConversationPane.tsx +137 -73
  43. package/src/workspace/ConversationsWorkspace.tsx +16 -1
  44. package/src/workspace/QueuedAttachmentsList.test.ts +27 -0
  45. package/src/workspace/QueuedAttachmentsList.tsx +198 -0
  46. package/src/workspace/index.ts +1 -0
  47. package/src/workspace/labels.ts +14 -0
  48. package/src/workspace/useComposerAttachmentRetry.ts +155 -0
  49. package/src/workspace/useComposerQueue.ts +220 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Fila de upload de anexo do formulário de cadastro (QR-31/M1), extraída de
3
+ * `useQuickRepliesWorkspace`: até 3 uploads em voo por vez, progresso real por arquivo, e
4
+ * cancelamento de tudo ao trocar de registro (M2) sem `setState` órfão depois do unmount.
5
+ */
6
+
7
+ import { useCallback, useEffect, useRef, useState } from 'react'
8
+ import { createUploadQueue } from './createUploadQueue'
9
+ import { validateAttachmentFiles, type AttachmentFileRejection } from './quickReplyAttachmentUpload'
10
+ import type { MaxAttachmentSizeBytes } from './quickReplyAttachments'
11
+ import type { QuickReplyAttachment } from './quickReply.types'
12
+ import type { QuickRepliesWorkspaceLabels } from './labels'
13
+
14
+ /** Item em upload no formulário: estado local, nunca persistido — some ao terminar ou ser removido. */
15
+ export type PendingAttachmentUpload = {
16
+ readonly localId: string
17
+ readonly file: File
18
+ readonly status: 'uploading' | 'error'
19
+ readonly progress: number
20
+ readonly error?: string
21
+ }
22
+
23
+ export type UseQuickReplyAttachmentUploadsParams = {
24
+ readonly upload?: (
25
+ file: File,
26
+ options?: { onProgress?: (fraction: number) => void; signal?: AbortSignal },
27
+ ) => Promise<QuickReplyAttachment>
28
+ readonly labels: QuickRepliesWorkspaceLabels
29
+ /** Sobrescreve o teto por tipo de arquivo. Ausente, usa `DEFAULT_MAX_ATTACHMENT_SIZE_BYTES`. */
30
+ readonly attachmentSizeLimits?: MaxAttachmentSizeBytes
31
+ /** Quantos anexos já confirmados (`editing.attachments.length`) — entra no teto de 10 (QR-31). */
32
+ readonly attachmentsCount: number
33
+ /** Onde o item entra assim que o upload resolve — o hook não sabe de `editing`, só devolve o resultado. */
34
+ readonly onUploaded: (attachment: QuickReplyAttachment) => void
35
+ }
36
+
37
+ export type UseQuickReplyAttachmentUploadsResult = {
38
+ readonly pendingUploads: readonly PendingAttachmentUpload[]
39
+ readonly attachmentRejections: readonly AttachmentFileRejection[]
40
+ readonly addAttachmentFiles: (files: FileList | readonly File[]) => void
41
+ readonly retryAttachmentUpload: (localId: string) => void
42
+ readonly cancelAttachmentUpload: (localId: string) => void
43
+ readonly dismissAttachmentRejections: () => void
44
+ /** Cancela todo upload em voo — trocar de registro sem isso deixaria um `fetch` órfão terminando
45
+ * sozinho e tentando atualizar um estado que já não existe mais. */
46
+ readonly abortAllUploads: () => void
47
+ }
48
+
49
+ /** Estado e orquestração do upload de anexo — sem saber de `editing`, só de arquivos e resultado. */
50
+ export function useQuickReplyAttachmentUploads({
51
+ upload,
52
+ labels,
53
+ attachmentSizeLimits,
54
+ attachmentsCount,
55
+ onUploaded,
56
+ }: UseQuickReplyAttachmentUploadsParams): UseQuickReplyAttachmentUploadsResult {
57
+ const [pendingUploads, setPendingUploads] = useState<readonly PendingAttachmentUpload[]>([])
58
+ const [attachmentRejections, setAttachmentRejections] = useState<readonly AttachmentFileRejection[]>([])
59
+ /** Fila compartilhada (M1): no máximo 3 uploads em voo ao mesmo tempo, somando o que
60
+ * `addAttachmentFiles` e `retryAttachmentUpload` enfileiram — nenhum dos dois abre janela própria. */
61
+ const uploadQueueRef = useRef(createUploadQueue(3))
62
+ /** M2: depois do unmount, nenhuma promessa de upload em voo pode chamar setState — só aborta. */
63
+ const isMountedRef = useRef(true)
64
+
65
+ useEffect(
66
+ () => () => {
67
+ isMountedRef.current = false
68
+ uploadQueueRef.current.abortAll()
69
+ },
70
+ [],
71
+ )
72
+
73
+ const abortAllUploads = useCallback(() => {
74
+ uploadQueueRef.current.abortAll()
75
+ setPendingUploads([])
76
+ }, [])
77
+
78
+ const dismissAttachmentRejections = useCallback(() => setAttachmentRejections([]), [])
79
+
80
+ const cancelAttachmentUpload = useCallback((localId: string) => {
81
+ uploadQueueRef.current.abort(localId)
82
+ setPendingUploads((current) => current.filter((item) => item.localId !== localId))
83
+ }, [])
84
+
85
+ const updatePendingUpload = useCallback((localId: string, patch: Partial<PendingAttachmentUpload>) => {
86
+ setPendingUploads((current) => current.map((item) => (item.localId === localId ? { ...item, ...patch } : item)))
87
+ }, [])
88
+
89
+ /** Sobe um arquivo já validado, através da fila compartilhada (M1): progresso real por
90
+ * `onProgress`, e o resultado vira `onUploaded` na ordem de chegada assim que a promessa
91
+ * resolve. Enfileira e retorna — quem chama não espera. */
92
+ const uploadOneFile = useCallback(
93
+ (localId: string, file: File) => {
94
+ if (!upload) return
95
+ uploadQueueRef.current.enqueue(localId, async (signal) => {
96
+ try {
97
+ const attachment = await upload(file, {
98
+ onProgress: (fraction) => {
99
+ if (isMountedRef.current) updatePendingUpload(localId, { progress: fraction })
100
+ },
101
+ signal,
102
+ })
103
+ if (!isMountedRef.current) return
104
+ onUploaded(attachment)
105
+ setPendingUploads((current) => current.filter((item) => item.localId !== localId))
106
+ } catch (caught: unknown) {
107
+ if (signal.aborted || !isMountedRef.current) return
108
+ updatePendingUpload(localId, {
109
+ status: 'error',
110
+ error: caught instanceof Error ? caught.message : labels.saveError,
111
+ })
112
+ }
113
+ })
114
+ },
115
+ [upload, updatePendingUpload, onUploaded, labels.saveError],
116
+ )
117
+
118
+ const retryAttachmentUpload = useCallback(
119
+ (localId: string) => {
120
+ const item = pendingUploads.find((pending) => pending.localId === localId)
121
+ if (!item) return
122
+ updatePendingUpload(localId, { status: 'uploading', progress: 0, error: undefined })
123
+ uploadOneFile(localId, item.file)
124
+ },
125
+ [pendingUploads, updatePendingUpload, uploadOneFile],
126
+ )
127
+
128
+ /** Valida (teto de 10, tamanho por tipo) ANTES de subir (QR-31) — só o aceito vira upload; o
129
+ * recusado fica em `attachmentRejections` para a tela explicar por quê, sem gastar rede nele. */
130
+ const addAttachmentFiles = useCallback(
131
+ (files: FileList | readonly File[]) => {
132
+ if (!upload) return
133
+ const currentCount = attachmentsCount + pendingUploads.length
134
+ const { accepted, rejected } = validateAttachmentFiles(Array.from(files), currentCount, attachmentSizeLimits)
135
+ setAttachmentRejections(rejected)
136
+ if (accepted.length === 0) return
137
+ const newItems: PendingAttachmentUpload[] = accepted.map((file) => ({
138
+ localId: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(36).slice(2)}`,
139
+ file,
140
+ status: 'uploading',
141
+ progress: 0,
142
+ }))
143
+ setPendingUploads((current) => [...current, ...newItems])
144
+ // Até 3 em paralelo (QR-31), somando com retries em voo — a fila compartilhada decide (M1).
145
+ for (const item of newItems) uploadOneFile(item.localId, item.file)
146
+ },
147
+ [upload, attachmentsCount, pendingUploads.length, uploadOneFile, attachmentSizeLimits],
148
+ )
149
+
150
+ return {
151
+ pendingUploads,
152
+ attachmentRejections,
153
+ addAttachmentFiles,
154
+ retryAttachmentUpload,
155
+ cancelAttachmentUpload,
156
+ dismissAttachmentRejections,
157
+ abortAllUploads,
158
+ }
159
+ }
package/src/styles.css CHANGED
@@ -818,6 +818,93 @@
818
818
  border-color: rgb(51 65 85);
819
819
  }
820
820
 
821
+ /* Item da fila com miniatura, estado de envio (QR-47) e saída com transição curta. */
822
+ .cv-attachment-item {
823
+ max-width: 16rem;
824
+ }
825
+ .cv-attachment-item__thumbnail {
826
+ display: block;
827
+ flex: none;
828
+ width: 2rem;
829
+ height: 2rem;
830
+ overflow: hidden;
831
+ border-radius: 0.375rem;
832
+ background: rgb(226 232 240);
833
+ }
834
+ .dark .cv-attachment-item__thumbnail { background: rgb(51 65 85); }
835
+ .cv-attachment-item__thumbnail img { width: 100%; height: 100%; object-fit: cover; }
836
+ .cv-attachment-item__info {
837
+ display: flex;
838
+ flex-direction: column;
839
+ overflow: hidden;
840
+ min-width: 0;
841
+ }
842
+ .cv-attachment-item__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
843
+ .cv-attachment-item__meta { font-size: 0.6875rem; color: rgb(100 116 139); }
844
+ .dark .cv-attachment-item__meta { color: rgb(148 163 184); }
845
+ .cv-attachment-item--failed .cv-attachment-item__meta { color: rgb(220 38 38); }
846
+ .dark .cv-attachment-item--failed .cv-attachment-item__meta { color: rgb(248 113 113); }
847
+ .cv-attachment-item--sent .cv-attachment-item__meta { color: rgb(22 163 74); }
848
+ .dark .cv-attachment-item--sent .cv-attachment-item__meta { color: rgb(74 222 128); }
849
+ .cv-attachment-item--skipped .cv-attachment-item__meta { color: rgb(180 83 9); }
850
+ .dark .cv-attachment-item--skipped .cv-attachment-item__meta { color: rgb(251 191 36); }
851
+ .cv-attachment-item__retry {
852
+ flex: none;
853
+ border: 0;
854
+ background: none;
855
+ padding: 0;
856
+ font-size: 0.6875rem;
857
+ text-decoration: underline;
858
+ color: rgb(37 99 235);
859
+ cursor: pointer;
860
+ }
861
+ .cv-attachment-item {
862
+ transition: opacity 200ms ease, transform 200ms ease;
863
+ opacity: 1;
864
+ transform: scale(1);
865
+ }
866
+ .cv-attachment-item--departing {
867
+ opacity: 0;
868
+ transform: scale(0.92);
869
+ }
870
+ @media (prefers-reduced-motion: reduce) {
871
+ .cv-attachment-item { transition: none; }
872
+ }
873
+
874
+ /* Esqueleto de linha — usado no painel de mensagens prontas e na tabela de cadastro (QR-47): nunca
875
+ "Carregando…" solto, e o conteúdo nunca aparece de uma vez. */
876
+ .cv-skeleton-line {
877
+ border-radius: 0.25rem;
878
+ background: linear-gradient(90deg, rgb(226 232 240) 25%, rgb(241 245 249) 50%, rgb(226 232 240) 75%);
879
+ background-size: 200% 100%;
880
+ animation: cv-skeleton-shimmer 1.4s ease-in-out infinite;
881
+ }
882
+ .dark .cv-skeleton-line {
883
+ background: linear-gradient(90deg, rgb(51 65 85) 25%, rgb(71 85 105) 50%, rgb(51 65 85) 75%);
884
+ background-size: 200% 100%;
885
+ }
886
+ @keyframes cv-skeleton-shimmer {
887
+ 0% { background-position: 200% 0; }
888
+ 100% { background-position: -200% 0; }
889
+ }
890
+ @media (prefers-reduced-motion: reduce) {
891
+ .cv-skeleton-line { animation: none; }
892
+ }
893
+ /* Larguras da linha de esqueleto da tabela de mensagens prontas: uma por coluna, porque colunas
894
+ diferentes leem melhor com proporções diferentes em vez de todas do mesmo tamanho. */
895
+ .cv-skeleton-line--title { width: 70%; height: 0.75rem; }
896
+ .cv-skeleton-line--shortcut { width: 50%; height: 0.75rem; }
897
+ .cv-skeleton-line--body { width: 90%; height: 0.75rem; }
898
+
899
+ /* Linha excluída sai com transição curta em vez de sumir de repente (QR-47). */
900
+ .cv-row-departing {
901
+ transition: opacity 200ms ease;
902
+ opacity: 0;
903
+ }
904
+ @media (prefers-reduced-motion: reduce) {
905
+ .cv-row-departing { transition: none; }
906
+ }
907
+
821
908
  /* Peças de listagem (documentos, mensagens): tabela com filtros, seleção em lote e paginação. */
822
909
  .cv-filter-count {
823
910
  display: inline-flex;
@@ -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, useRef, useState, type ReactNode } from 'react'
8
+ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
9
9
 
10
10
  import { AudioRecorderButton } from '../AudioRecorderButton'
11
11
  import { ConversationContextPanel, type ConversationContextEntry } from '../ConversationContextPanel'
@@ -18,11 +18,7 @@ import { MessageComposer, applyQuickReplyVariables, type QuickReply } from '../M
18
18
  import { RichMessageComposer, type RichComposerVariable } from '../RichMessageComposer'
19
19
  import { WindowExpiredNotice, isWindowBlocking } from '../WindowExpiredNotice'
20
20
  import { windowOf } from '../conversationWindow'
21
- import {
22
- buildTranscriptFilename,
23
- buildTranscriptText,
24
- downloadTextFile,
25
- } from '../conversationTranscript'
21
+ import { buildTranscriptFilename, buildTranscriptText, downloadTextFile } from '../conversationTranscript'
26
22
  import { useConversationContext } from '../hooks/useConversationContext'
27
23
  import { useConversationMessages } from '../hooks/useConversationMessages'
28
24
  import { useConversationRealtime } from '../hooks/useConversationRealtime'
@@ -30,6 +26,15 @@ import { useScrollToLatestMessage } from '../hooks/useScrollToLatestMessage'
30
26
  import { useConversations } from '../providers/ConversationsProvider'
31
27
  import type { ConversationSummary } from '../providers/types'
32
28
  import type { ConversationsWorkspaceLabels } from './labels'
29
+ import type {
30
+ ConversationVariable,
31
+ QueuedAttachment,
32
+ QuickReply as SavedQuickReply,
33
+ } from '../quickReplies/quickReply.types'
34
+ import { queuedAttachmentsFromQuickReply } from '../quickReplies/quickReplyAttachments'
35
+ import { resolveConversationVariables } from '../quickReplies/resolveConversationVariables'
36
+ import { QueuedAttachmentsList } from './QueuedAttachmentsList'
37
+ import { useComposerQueue } from './useComposerQueue'
33
38
 
34
39
  export interface ConversationPaneProps {
35
40
  readonly conversation: ConversationSummary
@@ -49,6 +54,7 @@ export interface ConversationPaneProps {
49
54
  * Recebe o contexto junto porque o dado que interessa à variável (o nome que o bot perguntou,
50
55
  * por exemplo) vive no contexto do fluxo, não no resumo da listagem.
51
56
  */
57
+ /** @deprecated Use `conversationVariablesFor`, que alimenta os dois composers com uma lista só. */
52
58
  readonly quickReplyVariablesFor?: (
53
59
  conversation: ConversationSummary,
54
60
  context: Record<string, unknown> | undefined,
@@ -87,11 +93,22 @@ export interface ConversationPaneProps {
87
93
  * é menos coisa na tela.
88
94
  */
89
95
  readonly composer?: 'simple' | 'rich'
90
- /** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
96
+ /**
97
+ * Valores que o operador insere sem digitar. Só o composer `rich` os oferece.
98
+ * @deprecated Use `conversationVariablesFor`.
99
+ */
91
100
  readonly composerVariablesFor?: (
92
101
  conversation: ConversationSummary,
93
102
  context: Record<string, unknown> | undefined,
94
103
  ) => readonly RichComposerVariable[]
104
+ /**
105
+ * Dados da conversa que o texto pode citar, numa lista só. Presente, manda sobre
106
+ * `quickReplyVariablesFor` e `composerVariablesFor`.
107
+ */
108
+ readonly conversationVariablesFor?: (
109
+ conversation: ConversationSummary,
110
+ context: Record<string, unknown> | undefined,
111
+ ) => readonly ConversationVariable[]
95
112
  /**
96
113
  * Fila de anexos com legenda, como no WhatsApp: os arquivos escolhidos ficam visíveis acima da
97
114
  * barra e saem junto com o texto escrito. Ausente, o clipe manda cada arquivo na hora — o que
@@ -124,6 +141,7 @@ export function ConversationPane({
124
141
  onAttach,
125
142
  composer = 'simple',
126
143
  composerVariablesFor,
144
+ conversationVariablesFor,
127
145
  onSendAttachments,
128
146
  onRecordAudio,
129
147
  }: ConversationPaneProps) {
@@ -139,17 +157,51 @@ export function ConversationPane({
139
157
  const [sendFailure, setSendFailure] = useState<string | undefined>(undefined)
140
158
  const [selectedMessageIds, setSelectedMessageIds] = useState<ReadonlySet<string>>(new Set())
141
159
  const [draft, setDraft] = useState(initialComposerText ?? '')
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)
146
160
 
147
- // Trocar de conversa zera as duas coisas: seleção de mensagem e rascunho pertencem à thread, e
148
- // levá-los adiante faria copiar o trecho errado ou responder ao cliente errado.
161
+ const {
162
+ queue,
163
+ enqueueAttachments,
164
+ attachmentStatus,
165
+ isSendingDraft,
166
+ handleRichSend,
167
+ removeQueuedAttachment,
168
+ retryQueuedAttachment,
169
+ retryingKeys,
170
+ } = useComposerQueue({
171
+ conversationId: conversation.id,
172
+ draft,
173
+ setDraft,
174
+ api,
175
+ labels: { sendFailure: labels.sendFailure, attachFailure: labels.attachFailure },
176
+ ...(onSendAttachments ? { onSendAttachments } : {}),
177
+ refetch,
178
+ setSendFailure,
179
+ })
180
+
181
+ /**
182
+ * Uma promessa por `uploadId`, nunca duas: sem o cache, cada render da lista de anexos disparava
183
+ * de novo a URL assinada do mesmo arquivo (M4) — a função abaixo é estável, mas o item pode
184
+ * remontar por causa do estado de envio.
185
+ */
186
+ const thumbnailUrlCacheRef = useRef(new Map<string, Promise<string>>())
187
+ const getQueuedAttachmentThumbnailUrl = useCallback(
188
+ (uploadId: string): Promise<string> => {
189
+ const cached = thumbnailUrlCacheRef.current.get(uploadId)
190
+ if (cached) return cached
191
+ const pending = api.getDocumentUrl(uploadId, 'inline')
192
+ thumbnailUrlCacheRef.current.set(uploadId, pending)
193
+ pending.catch(() => thumbnailUrlCacheRef.current.delete(uploadId))
194
+ return pending
195
+ },
196
+ [api],
197
+ )
198
+
199
+ // Trocar de conversa zera seleção de mensagem e rascunho — pertencem à thread, e levá-los adiante
200
+ // faria copiar o trecho errado ou responder ao cliente errado. A fila de anexos se reseta sozinha
201
+ // dentro de `useComposerQueue`, keyed pelo mesmo `conversation.id`.
149
202
  useEffect(() => {
150
203
  setSelectedMessageIds(new Set())
151
204
  setDraft(initialComposerText ?? '')
152
- setQueuedFiles([])
153
205
  }, [conversation.id, initialComposerText])
154
206
 
155
207
  function toggleMessageSelected(messageId: string): void {
@@ -218,35 +270,6 @@ export function ConversationPane({
218
270
  await runSend(() => api.sendTemplate(conversation.id, {}), labels.sendFailure)
219
271
  }
220
272
 
221
- /**
222
- * O texto escrito é a legenda do anexo, não uma segunda mensagem: no WhatsApp a foto chega com a
223
- * frase embaixo, e mandar as duas separadas invertia a ordem quando a mídia demorava a subir.
224
- */
225
- async function handleRichSend(): Promise<void> {
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)
247
- }
248
- }
249
-
250
273
  async function handleAttach(file: File): Promise<void> {
251
274
  if (!onAttach) return
252
275
  await runSend(() => onAttach(file), labels.attachFailure)
@@ -264,7 +287,11 @@ export function ConversationPane({
264
287
  }
265
288
 
266
289
  const contextEntries = contextEntriesOf?.(conversationContext)
267
- const composerVariables = composerVariablesFor?.(conversation, conversationContext)
290
+ const { quickReplyVariables, composerVariables } = resolveConversationVariables({
291
+ conversationVariables: conversationVariablesFor?.(conversation, conversationContext),
292
+ quickReplyVariables: quickReplyVariablesFor?.(conversation, conversationContext),
293
+ composerVariables: composerVariablesFor?.(conversation, conversationContext),
294
+ })
268
295
  /**
269
296
  * As mesmas `quickReplies` do composer simples, com as variáveis já resolvidas — o campo rico
270
297
  * recebe texto pronto. Uma segunda lista, só de formato diferente, é como as telas divergiam.
@@ -274,9 +301,29 @@ export function ConversationPane({
274
301
  label: reply.label,
275
302
  text:
276
303
  typeof reply.text === 'string'
277
- ? applyQuickReplyVariables(reply.text, quickReplyVariablesFor?.(conversation, conversationContext) ?? {})
278
- : reply.text(quickReplyVariablesFor?.(conversation, conversationContext) ?? {}),
304
+ ? applyQuickReplyVariables(reply.text, quickReplyVariables ?? {})
305
+ : reply.text(quickReplyVariables ?? {}),
279
306
  }))
307
+ /**
308
+ * Botão de raio e atalho `/` dos dois composers. `listQuickReplies` é a capacidade — sem ela na
309
+ * porta do host, nenhum dos dois aparece, em vez de um botão que abre uma lista sempre vazia.
310
+ */
311
+ const savedQuickReplies = api.listQuickReplies
312
+ ? {
313
+ // Arrow em vez de repassar o método direto: `api.listQuickReplies` solto perde o `this` do
314
+ // objeto que o implementa, e um cliente HTTP real costuma depender dele internamente.
315
+ listQuickReplies: (params?: { search?: string }) => api.listQuickReplies!(params),
316
+ conversationId: conversation.id,
317
+ variables: quickReplyVariables,
318
+ hasAttachmentsCapability: Boolean(api.sendStoredAttachments),
319
+ // Empurra os anexos da mensagem escolhida como itens guardados (QR-32) — sem a porta, a
320
+ // linha do picker já avisou e o texto entra sozinho, sem silenciosamente perder o anexo.
321
+ onSelect: (quickReply: SavedQuickReply) => {
322
+ const attachments = queuedAttachmentsFromQuickReply(quickReply, Boolean(api.sendStoredAttachments))
323
+ enqueueAttachments(attachments)
324
+ },
325
+ }
326
+ : undefined
280
327
  const botOwnsConversation = Boolean(requireTakeoverToReply) && conversation.mode !== 'human'
281
328
 
282
329
  return (
@@ -337,10 +384,20 @@ export function ConversationPane({
337
384
  <div className="cv-workspace-selection">
338
385
  <span>{labels.messagesSelected(selectedMessageIds.size)}</span>
339
386
  <div className="cv-workspace-selection__actions">
340
- <button data-cv-tooltip={labels.bulkClear} aria-label={labels.bulkClear} type="button" onClick={() => setSelectedMessageIds(new Set())}>
387
+ <button
388
+ data-cv-tooltip={labels.bulkClear}
389
+ aria-label={labels.bulkClear}
390
+ type="button"
391
+ onClick={() => setSelectedMessageIds(new Set())}
392
+ >
341
393
  {labels.bulkClear}
342
394
  </button>
343
- <button data-cv-tooltip={labels.copySelected} aria-label={labels.copySelected} type="button" onClick={copySelectedMessages}>
395
+ <button
396
+ data-cv-tooltip={labels.copySelected}
397
+ aria-label={labels.copySelected}
398
+ type="button"
399
+ onClick={copySelectedMessages}
400
+ >
344
401
  {labels.copySelected}
345
402
  </button>
346
403
  </div>
@@ -354,10 +411,7 @@ export function ConversationPane({
354
411
  ) : null}
355
412
 
356
413
  {blocked ? (
357
- <WindowExpiredNotice
358
- disabled={busy}
359
- onSendTemplate={() => void handleSendTemplate()}
360
- />
414
+ <WindowExpiredNotice disabled={busy} onSendTemplate={() => void handleSendTemplate()} />
361
415
  ) : botOwnsConversation ? (
362
416
  // Responder com a conversa no bot atropelaria o fluxo automático no meio de uma pergunta.
363
417
  <p className="cv-workspace-notice">{labels.takeoverToReply}</p>
@@ -367,7 +421,14 @@ export function ConversationPane({
367
421
  onChange={setDraft}
368
422
  onSend={() => void handleRichSend()}
369
423
  {...(onSendAttachments
370
- ? { onAttachFiles: (files: FileList) => setQueuedFiles((current) => [...current, ...Array.from(files)]) }
424
+ ? {
425
+ onAttachFiles: (files: FileList) =>
426
+ enqueueAttachments(
427
+ Array.from(files).map(
428
+ (file): QueuedAttachment => ({ kind: 'local', localId: crypto.randomUUID(), file }),
429
+ ),
430
+ ),
431
+ }
371
432
  : onAttach
372
433
  ? {
373
434
  onAttachFiles: (files: FileList) => {
@@ -377,7 +438,7 @@ export function ConversationPane({
377
438
  : {})}
378
439
  placeholder={labels.composerPlaceholder}
379
440
  isSending={busy || isSendingDraft}
380
- hasQueuedAttachments={queuedFiles.length > 0}
441
+ hasQueuedAttachments={queue.length > 0}
381
442
  {...(onRecordAudio
382
443
  ? {
383
444
  idleAction: (
@@ -388,31 +449,33 @@ export function ConversationPane({
388
449
  ),
389
450
  }
390
451
  : {})}
391
- {...(queuedFiles.length > 0
452
+ {...(queue.length > 0
392
453
  ? {
393
454
  attachmentsPreview: (
394
- <ul className="cv-workspace-attachments">
395
- {queuedFiles.map((file, index) => (
396
- <li key={`${file.name}-${index}`}>
397
- <span>{file.name}</span>
398
- <button
399
- data-cv-tooltip={labels.attachmentRemove}
400
- type="button"
401
- aria-label={labels.attachmentRemove}
402
- onClick={() =>
403
- setQueuedFiles((current) => current.filter((_, position) => position !== index))
404
- }
405
- >
406
-
407
- </button>
408
- </li>
409
- ))}
410
- </ul>
455
+ <QueuedAttachmentsList
456
+ items={queue}
457
+ statusOf={(key) => attachmentStatus[key] ?? 'waiting'}
458
+ onRemove={removeQueuedAttachment}
459
+ onRetry={retryQueuedAttachment}
460
+ retryingKeys={retryingKeys}
461
+ getThumbnailUrl={getQueuedAttachmentThumbnailUrl}
462
+ busy={isSendingDraft}
463
+ labels={{
464
+ remove: labels.attachmentRemove,
465
+ waiting: labels.attachmentWaiting,
466
+ sending: labels.attachmentSending,
467
+ sent: labels.attachmentSent,
468
+ failed: labels.attachmentFailed,
469
+ skipped: labels.attachmentSkipped,
470
+ retry: labels.attachmentRetry,
471
+ }}
472
+ />
411
473
  ),
412
474
  }
413
475
  : {})}
414
476
  {...(richQuickReplies ? { quickReplies: [...richQuickReplies] } : {})}
415
477
  {...(composerVariables ? { variables: [...composerVariables] } : {})}
478
+ {...(savedQuickReplies ? { savedQuickReplies } : {})}
416
479
  />
417
480
  ) : (
418
481
  <MessageComposer
@@ -424,7 +487,8 @@ export function ConversationPane({
424
487
  {...(onAttach ? { onAttach: (file: File) => void handleAttach(file) } : {})}
425
488
  placeholder={labels.composerPlaceholder}
426
489
  {...(quickReplies ? { quickReplies } : {})}
427
- {...(quickReplyVariablesFor ? { quickReplyVariables: quickReplyVariablesFor(conversation, conversationContext) } : {})}
490
+ {...(quickReplyVariables ? { quickReplyVariables } : {})}
491
+ {...(savedQuickReplies ? { savedQuickReplies } : {})}
428
492
  />
429
493
  )}
430
494
  </div>
@@ -39,6 +39,7 @@ import { ConversationsInboxList } from './ConversationsInboxList'
39
39
  import { DEFAULT_CONVERSATIONS_WORKSPACE_LABELS, type ConversationsWorkspaceLabels } from './labels'
40
40
  import { useConversationsInbox, type UseConversationsInboxResult } from './useConversationsInbox'
41
41
  import { TooltipLayer } from '../Tooltip'
42
+ import type { ConversationVariable } from '../quickReplies/quickReply.types'
42
43
 
43
44
  export type SimulatorTransportParams = {
44
45
  readonly conversationId: string
@@ -98,6 +99,7 @@ export interface ConversationsWorkspaceProps {
98
99
  readonly initialWhatsappNumber?: string | undefined
99
100
  readonly simulator?: ConversationsWorkspaceSimulator
100
101
  readonly quickReplies?: readonly QuickReply[]
102
+ /** @deprecated Use `conversationVariablesFor`, que alimenta os dois composers com uma lista só. */
101
103
  readonly quickReplyVariablesFor?: (
102
104
  conversation: ConversationSummary,
103
105
  context: Record<string, unknown> | undefined,
@@ -114,11 +116,22 @@ export interface ConversationsWorkspaceProps {
114
116
  readonly initialComposerText?: string | undefined
115
117
  /** `rich` troca o campo simples pelo texto com a formatação do WhatsApp desenhada ao escrever. */
116
118
  readonly composer?: 'simple' | 'rich'
117
- /** Valores que o operador insere sem digitar. Só o composer `rich` os oferece. */
119
+ /**
120
+ * Valores que o operador insere sem digitar. Só o composer `rich` os oferece.
121
+ * @deprecated Use `conversationVariablesFor`.
122
+ */
118
123
  readonly composerVariablesFor?: (
119
124
  conversation: ConversationSummary,
120
125
  context: Record<string, unknown> | undefined,
121
126
  ) => readonly RichComposerVariable[]
127
+ /**
128
+ * Dados da conversa que o texto pode citar, numa lista só. Presente, manda sobre
129
+ * `quickReplyVariablesFor` e `composerVariablesFor`.
130
+ */
131
+ readonly conversationVariablesFor?: (
132
+ conversation: ConversationSummary,
133
+ context: Record<string, unknown> | undefined,
134
+ ) => readonly ConversationVariable[]
122
135
  /** Fila de anexos com legenda, como no WhatsApp. Ausente, o clipe manda cada arquivo na hora. */
123
136
  readonly onSendAttachments?: (
124
137
  conversation: ConversationSummary,
@@ -162,6 +175,7 @@ export function ConversationsWorkspace({
162
175
  initialComposerText,
163
176
  composer,
164
177
  composerVariablesFor,
178
+ conversationVariablesFor,
165
179
  onSendAttachments,
166
180
  onRecordAudio,
167
181
  contextEntriesOf,
@@ -360,6 +374,7 @@ export function ConversationsWorkspace({
360
374
  {...(initialComposerText ? { initialComposerText } : {})}
361
375
  {...(composer ? { composer } : {})}
362
376
  {...(composerVariablesFor ? { composerVariablesFor } : {})}
377
+ {...(conversationVariablesFor ? { conversationVariablesFor } : {})}
363
378
  {...(onSendAttachments
364
379
  ? {
365
380
  onSendAttachments: (files: readonly File[], caption: string) =>
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { departedItemsOf } from './QueuedAttachmentsList'
4
+ import type { QueuedAttachment } from '../quickReplies/quickReply.types'
5
+
6
+ function storedItem(uploadId: string): QueuedAttachment {
7
+ return { kind: 'stored', uploadId, filename: `${uploadId}.pdf`, mimeType: 'application/pdf', sizeBytes: 100 }
8
+ }
9
+
10
+ describe('departedItemsOf', () => {
11
+ it('não aponta saída quando a fila não muda', () => {
12
+ const items = [storedItem('a'), storedItem('b')]
13
+ expect(departedItemsOf(items, items)).toEqual([])
14
+ })
15
+
16
+ it('aponta só o item que saiu da fila', () => {
17
+ const previouslyRendered = [storedItem('a'), storedItem('b')]
18
+ const items = [storedItem('a')]
19
+ expect(departedItemsOf(previouslyRendered, items)).toEqual([storedItem('b')])
20
+ })
21
+
22
+ it('não aponta saída quando um item entra na fila', () => {
23
+ const previouslyRendered = [storedItem('a')]
24
+ const items = [storedItem('a'), storedItem('b')]
25
+ expect(departedItemsOf(previouslyRendered, items)).toEqual([])
26
+ })
27
+ })