@adatechnology/conversations-ui 0.3.0 → 0.3.2

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.
@@ -1,5 +1,5 @@
1
- import { V as MessagePayload, u as ConversationSummary, m as ConversationEventSource, x as ConversationsApi, R as ListConversationsParams, n as ConversationPage, ab as SSEProvider, k as ConversationDocument, ac as SendPreviewMediaParams, _ as PreviewUploadedMedia, a0 as PreviewWebhookClient, a9 as ResolveMediaUrl } from '../ConversationSimulatorPanel-ikgmlciE.js';
2
- export { A as AudioRecorderButton, a as AudioRecorderButtonLabels, b as AudioRecorderButtonProps, o as ConversationPreview, p as ConversationPreviewProps, q as ConversationSimulatorClient, r as ConversationSimulatorPanel, s as ConversationSimulatorPanelLabels, t as ConversationSimulatorPanelProps, D as CreatePreviewMediaUploaderParams, E as CreatePreviewWebhookClientParams, F as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS, H as DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS, J as DEFAULT_MEDIA_UPLOAD_PATH, X as PreviewInProductionError, Y as PreviewMediaUploadRejectedError, Z as PreviewMediaUploadRequest, $ as PreviewUploadedMedia, a1 as PreviewWebhookRejectedError, aa as SIMULATOR_FILE_MEDIA_KINDS, ad as SendSimulatorMediaParams, ae as SimulatorMediaKind, ag as ToSimulatorClientParams, aj as acceptsMediaKind, ak as assertPreviewEnvironment, ao as createPreviewMediaPoster, ap as createPreviewMediaUploader, aq as createPreviewWebhookClient, as as isConversationSimulatorClient, at as mediaKindOf, au as mediaTypeOf, av as signPreviewPayload, aw as simulatorPanelLabelsOf, ax as toConversationSimulatorClient } from '../ConversationSimulatorPanel-ikgmlciE.js';
1
+ import { M as MessagePayload, C as ConversationSummary, a as ConversationEventSource, b as ConversationsApi, L as ListConversationsParams, c as ConversationPage, S as SSEProvider, d as ConversationDocument, e as SendPreviewMediaParams, P as PreviewUploadedMedia, f as PreviewWebhookClient, R as ResolveMediaUrl } from '../ConversationSimulatorPanel-DmV8uaFE.js';
2
+ export { A as AudioRecorderButton, g as AudioRecorderButtonLabels, h as AudioRecorderButtonProps, i as ConversationPreview, j as ConversationPreviewProps, k as ConversationSimulatorClient, l as ConversationSimulatorPanel, m as ConversationSimulatorPanelLabels, n as ConversationSimulatorPanelProps, o as CreatePreviewMediaUploaderParams, p as CreatePreviewWebhookClientParams, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS, q as DEFAULT_CONVERSATION_SIMULATOR_PANEL_LABELS, r as DEFAULT_MEDIA_UPLOAD_PATH, s as PreviewInProductionError, t as PreviewMediaUploadRejectedError, u as PreviewMediaUploadRequest, v as PreviewUploadedMedia, w as PreviewWebhookRejectedError, x as SIMULATOR_FILE_MEDIA_KINDS, y as SendSimulatorMediaParams, z as SimulatorMediaKind, T as ToSimulatorClientParams, B as acceptsMediaKind, E as assertPreviewEnvironment, F as createPreviewMediaPoster, G as createPreviewMediaUploader, H as createPreviewWebhookClient, I as isConversationSimulatorClient, J as mediaKindOf, K as mediaTypeOf, N as signPreviewPayload, O as simulatorPanelLabelsOf, Q as toConversationSimulatorClient } from '../ConversationSimulatorPanel-DmV8uaFE.js';
3
3
  import { InteractiveReplyOption } from '@adatechnology/meta-whatsapp-contracts/testing';
4
4
  import * as react from 'react';
5
5
  export { PREVIEW_MEDIA_ID_PREFIX } from '@adatechnology/meta-whatsapp-contracts';
@@ -705,7 +705,9 @@ function createMockConversationsApi(params) {
705
705
  },
706
706
  fetchMessages(conversationId, fetchParams) {
707
707
  return withLatency(() => {
708
- const messages = params.store.listMessages(conversationId);
708
+ const allMessages = params.store.listMessages(conversationId);
709
+ const before = fetchParams?.before;
710
+ const messages = before ? allMessages.filter((message) => message.timestamp < before) : allMessages;
709
711
  const limit = fetchParams?.limit;
710
712
  return limit ? messages.slice(-limit) : messages;
711
713
  });
package/dist/styles.css CHANGED
@@ -526,6 +526,22 @@
526
526
  font-size: 0.875rem;
527
527
  color: #6b7280;
528
528
  }
529
+ .cv-workspace-load-older {
530
+ display: flex;
531
+ justify-content: center;
532
+ padding: 0.5rem 0;
533
+ }
534
+ .cv-workspace-load-older button {
535
+ min-height: 2.75rem;
536
+ border-radius: 0.375rem;
537
+ border: 1px solid var(--cv-border, #e5e7eb);
538
+ padding: 0 0.75rem;
539
+ font-size: 0.8125rem;
540
+ color: #6b7280;
541
+ }
542
+ .cv-workspace-load-older button:disabled {
543
+ opacity: 0.6;
544
+ }
529
545
  .dark .cv-workspace-notice {
530
546
  border-top-color: #374151;
531
547
  background: #1f2937;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -31,7 +31,7 @@
31
31
  "clsx": "^2.1.1",
32
32
  "lucide-react": "^1.21.0",
33
33
  "tailwind-merge": "^3.6.0",
34
- "@adatechnology/meta-whatsapp-contracts": "0.3.0"
34
+ "@adatechnology/meta-whatsapp-contracts": "0.4.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@xyflow/react": "^12",
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import {
4
+ dedupeMessagesById,
5
+ pageIndicatesMoreOlderMessages,
6
+ prependOlderMessages,
7
+ shouldFetchOlderMessages,
8
+ } from './useConversationMessages'
9
+ import type { MessagePayload } from '../types'
10
+
11
+ function buildMessage(id: string, timestamp: string): MessagePayload {
12
+ return { id, type: 'text', content: id, direction: 'inbound', sender: 'customer', timestamp }
13
+ }
14
+
15
+ describe('dedupeMessagesById', () => {
16
+ it('mantém a primeira ocorrência de cada id e descarta as repetidas', () => {
17
+ const first = buildMessage('1', '2026-09-01T10:00:00.000Z')
18
+ const duplicate = buildMessage('1', '2026-09-01T10:00:00.000Z')
19
+ const second = buildMessage('2', '2026-09-01T10:01:00.000Z')
20
+
21
+ expect(dedupeMessagesById([first, duplicate, second])).toEqual([first, second])
22
+ })
23
+ })
24
+
25
+ describe('prependOlderMessages', () => {
26
+ it('prepende a página anterior descartando o que já estava carregado', () => {
27
+ const accumulated = [buildMessage('3', '2026-09-01T10:02:00.000Z')]
28
+ const olderPage = [buildMessage('1', '2026-09-01T10:00:00.000Z'), buildMessage('2', '2026-09-01T10:01:00.000Z')]
29
+
30
+ expect(prependOlderMessages(accumulated, olderPage)).toEqual([...olderPage, ...accumulated])
31
+ })
32
+
33
+ it('não duplica quando a mesma página anterior chega de novo', () => {
34
+ const message = buildMessage('1', '2026-09-01T10:00:00.000Z')
35
+ const accumulated = [message]
36
+
37
+ expect(prependOlderMessages(accumulated, [message])).toEqual([message])
38
+ })
39
+ })
40
+
41
+ describe('shouldFetchOlderMessages', () => {
42
+ const oldestMessage = buildMessage('1', '2026-09-01T10:00:00.000Z')
43
+
44
+ it('busca quando não há requisição em voo, ainda há mais páginas e existe uma mensagem mais antiga', () => {
45
+ expect(shouldFetchOlderMessages({ isLoading: false, hasMore: true, oldestMessage })).toBe(true)
46
+ })
47
+
48
+ it('não dispara requisição concorrente enquanto uma já está em voo', () => {
49
+ expect(shouldFetchOlderMessages({ isLoading: true, hasMore: true, oldestMessage })).toBe(false)
50
+ })
51
+
52
+ it('para de buscar quando uma página anterior já veio incompleta (hasMore=false)', () => {
53
+ expect(shouldFetchOlderMessages({ isLoading: false, hasMore: false, oldestMessage })).toBe(false)
54
+ })
55
+
56
+ it('não busca sem mensagem mais antiga para servir de before', () => {
57
+ expect(shouldFetchOlderMessages({ isLoading: false, hasMore: true, oldestMessage: undefined })).toBe(false)
58
+ })
59
+ })
60
+
61
+ describe('pageIndicatesMoreOlderMessages', () => {
62
+ it('página completa (igual ao limite) indica que pode haver mais', () => {
63
+ expect(pageIndicatesMoreOlderMessages(50, 50)).toBe(true)
64
+ })
65
+
66
+ it('página menor que o limite indica que não há mais mensagens antigas', () => {
67
+ expect(pageIndicatesMoreOlderMessages(12, 50)).toBe(false)
68
+ })
69
+
70
+ it('página vazia indica que não há mais mensagens antigas', () => {
71
+ expect(pageIndicatesMoreOlderMessages(0, 50)).toBe(false)
72
+ })
73
+ })
74
+
75
+ describe('preservação do histórico ao chegar mensagem nova (SSE/polling)', () => {
76
+ it('combina o acumulado antigo com a janela recente sem perder páginas já carregadas', () => {
77
+ const olderMessages = [buildMessage('1', '2026-09-01T10:00:00.000Z'), buildMessage('2', '2026-09-01T10:01:00.000Z')]
78
+ const recentWindowBeforeNewMessage = [buildMessage('3', '2026-09-01T10:02:00.000Z')]
79
+ const recentWindowAfterNewMessage = [
80
+ buildMessage('3', '2026-09-01T10:02:00.000Z'),
81
+ buildMessage('4', '2026-09-01T10:03:00.000Z'),
82
+ ]
83
+
84
+ const before = dedupeMessagesById([...olderMessages, ...recentWindowBeforeNewMessage])
85
+ const after = dedupeMessagesById([...olderMessages, ...recentWindowAfterNewMessage])
86
+
87
+ expect(before.map((message) => message.id)).toEqual(['1', '2', '3'])
88
+ expect(after.map((message) => message.id)).toEqual(['1', '2', '3', '4'])
89
+ })
90
+ })
@@ -1,8 +1,11 @@
1
- import { useCallback } from 'react'
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { useConversations } from '../providers/ConversationsProvider'
3
3
  import { useAsyncResource } from './useAsyncResource'
4
4
  import type { MessagePayload } from '../types'
5
5
 
6
+ /** Tamanho da página ao buscar mensagens anteriores — mesmo padrão do `before` sem `limit`. */
7
+ const OLDER_MESSAGES_PAGE_LIMIT = 50
8
+
6
9
  export interface UseConversationMessagesResult {
7
10
  messages: MessagePayload[]
8
11
  loading: boolean
@@ -12,6 +15,59 @@ export interface UseConversationMessagesResult {
12
15
  sendMedia: (data: { base64: string; mimeType: string; filename: string; caption?: string }) => Promise<MessagePayload>
13
16
  sendTemplate: (data: { templateName?: string; languageCode?: string; bodyParams?: string[] }) => Promise<void>
14
17
  markRead: () => Promise<void>
18
+ /** Busca a página anterior de mensagens e prepende ao histórico já carregado. */
19
+ loadOlderMessages: () => Promise<void>
20
+ loadingOlderMessages: boolean
21
+ /** `false` assim que uma página vier menor que o limite — não há mais o que buscar antes dela. */
22
+ hasMoreOlderMessages: boolean
23
+ }
24
+
25
+ /** Descarta qualquer mensagem cujo `id` já apareceu antes, mantendo a primeira ocorrência. */
26
+ export function dedupeMessagesById(messages: readonly MessagePayload[]): MessagePayload[] {
27
+ const seenIds = new Set<string>()
28
+ const deduped: MessagePayload[] = []
29
+ for (const message of messages) {
30
+ if (seenIds.has(message.id)) continue
31
+ seenIds.add(message.id)
32
+ deduped.push(message)
33
+ }
34
+ return deduped
35
+ }
36
+
37
+ /**
38
+ * Soma uma página de mensagens antigas ao acumulado já carregado, sem duplicar.
39
+ *
40
+ * Duplicar aconteceria numa corrida onde a mesma página chegasse duas vezes — o `id` é a única
41
+ * garantia de identidade da mensagem, o timestamp usado no `before` não basta sozinho.
42
+ */
43
+ export function prependOlderMessages(
44
+ accumulated: readonly MessagePayload[],
45
+ olderPage: readonly MessagePayload[],
46
+ ): MessagePayload[] {
47
+ const existingIds = new Set(accumulated.map((message) => message.id))
48
+ const newMessages = olderPage.filter((message) => !existingIds.has(message.id))
49
+ return [...newMessages, ...accumulated]
50
+ }
51
+
52
+ export type LoadOlderMessagesGuardState = {
53
+ readonly isLoading: boolean
54
+ readonly hasMore: boolean
55
+ readonly oldestMessage: MessagePayload | undefined
56
+ }
57
+
58
+ /**
59
+ * Decide se vale a pena buscar a página anterior: nunca com uma busca já em voo (evita requisição
60
+ * concorrente), nunca depois que uma página já veio incompleta, e nunca sem uma mensagem mais
61
+ * antiga para servir de `before`.
62
+ */
63
+ export function shouldFetchOlderMessages(state: LoadOlderMessagesGuardState): boolean {
64
+ if (state.isLoading || !state.hasMore) return false
65
+ return Boolean(state.oldestMessage)
66
+ }
67
+
68
+ /** A página veio menor que o limite pedido: não há mais nada antes dela para buscar. */
69
+ export function pageIndicatesMoreOlderMessages(pageLength: number, limit: number): boolean {
70
+ return pageLength >= limit
15
71
  }
16
72
 
17
73
  // Camada headless: dados + ações de uma conversa, sem nenhuma tela acoplada — o produto
@@ -32,6 +88,52 @@ export function useConversationMessages(
32
88
  [conversationId, params?.limit, params?.before],
33
89
  )
34
90
 
91
+ const [olderMessages, setOlderMessages] = useState<MessagePayload[]>([])
92
+ const [hasMoreOlderMessages, setHasMoreOlderMessages] = useState(true)
93
+ const [loadingOlderMessages, setLoadingOlderMessages] = useState(false)
94
+ /**
95
+ * Espelha `loadingOlderMessages`: o guarda de concorrência precisa ler o valor atual na hora do
96
+ * clique/scroll, e o estado do React só reflete depois do próximo render.
97
+ */
98
+ const loadingOlderMessagesRef = useRef(false)
99
+
100
+ // Trocar de conversa zera a paginação de mensagens antigas — a anterior podia já ter esgotado o
101
+ // histórico, e isso não pode vazar para a conversa que acabou de abrir.
102
+ useEffect(() => {
103
+ setOlderMessages([])
104
+ setHasMoreOlderMessages(true)
105
+ }, [conversationId])
106
+
107
+ const messages = useMemo(() => dedupeMessagesById([...olderMessages, ...(data ?? [])]), [olderMessages, data])
108
+
109
+ const loadOlderMessages = useCallback(async () => {
110
+ const oldestMessage = messages[0]
111
+ if (
112
+ !shouldFetchOlderMessages({
113
+ isLoading: loadingOlderMessagesRef.current,
114
+ hasMore: hasMoreOlderMessages,
115
+ oldestMessage,
116
+ })
117
+ )
118
+ return
119
+ // `shouldFetchOlderMessages` já garante isto — repetido aqui só para o compilador estreitar o tipo.
120
+ if (!oldestMessage) return
121
+
122
+ loadingOlderMessagesRef.current = true
123
+ setLoadingOlderMessages(true)
124
+ try {
125
+ const olderPage = await api.fetchMessages(conversationId, {
126
+ limit: OLDER_MESSAGES_PAGE_LIMIT,
127
+ before: oldestMessage.timestamp,
128
+ })
129
+ if (!pageIndicatesMoreOlderMessages(olderPage.length, OLDER_MESSAGES_PAGE_LIMIT)) setHasMoreOlderMessages(false)
130
+ setOlderMessages((current) => prependOlderMessages(current, olderPage))
131
+ } finally {
132
+ loadingOlderMessagesRef.current = false
133
+ setLoadingOlderMessages(false)
134
+ }
135
+ }, [api, conversationId, hasMoreOlderMessages, messages])
136
+
35
137
  const sendMessage = useCallback(
36
138
  async (text: string) => {
37
139
  const message = await api.sendMessage(conversationId, text)
@@ -60,5 +162,17 @@ export function useConversationMessages(
60
162
 
61
163
  const markRead = useCallback(() => api.markRead(conversationId), [api, conversationId])
62
164
 
63
- return { messages: data ?? [], loading, error, refetch, sendMessage, sendMedia, sendTemplate, markRead }
165
+ return {
166
+ messages,
167
+ loading,
168
+ error,
169
+ refetch,
170
+ sendMessage,
171
+ sendMedia,
172
+ sendTemplate,
173
+ markRead,
174
+ loadOlderMessages,
175
+ loadingOlderMessages,
176
+ hasMoreOlderMessages,
177
+ }
64
178
  }
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { computeScrollTopAfterPrepend } from './useLoadOlderMessagesScroll'
4
+
5
+ describe('computeScrollTopAfterPrepend', () => {
6
+ it('mantém o mesmo ponto visual somando ao scrollTop a altura que o conteúdo antigo acrescentou', () => {
7
+ // Container tinha 2000px de conteúdo, operador em 300px do topo; a página anterior somou 800px.
8
+ const before = { scrollHeight: 2000, scrollTop: 300 }
9
+ expect(computeScrollTopAfterPrepend(before, 2800)).toBe(1100)
10
+ })
11
+
12
+ it('não desloca a rolagem quando a altura não muda', () => {
13
+ const before = { scrollHeight: 2000, scrollTop: 300 }
14
+ expect(computeScrollTopAfterPrepend(before, 2000)).toBe(300)
15
+ })
16
+
17
+ it('funciona a partir do topo absoluto (scrollTop=0)', () => {
18
+ const before = { scrollHeight: 2000, scrollTop: 0 }
19
+ expect(computeScrollTopAfterPrepend(before, 2500)).toBe(500)
20
+ })
21
+ })
@@ -0,0 +1,76 @@
1
+ import { useCallback, useLayoutEffect, useRef, type RefObject, type UIEvent } from 'react'
2
+
3
+ /**
4
+ * Chegar perto do topo do transcript dispara a busca da página anterior, e a posição de rolagem é
5
+ * recalculada depois que o DOM cresce por cima do ponto atual — sem isso, prepender mensagens
6
+ * empurra visualmente tudo para baixo e o operador perde a linha onde estava lendo.
7
+ */
8
+ const NEAR_TOP_THRESHOLD_PX = 200
9
+
10
+ export type ScrollHeightSnapshot = {
11
+ readonly scrollHeight: number
12
+ readonly scrollTop: number
13
+ }
14
+
15
+ /** Novo `scrollTop` que mantém o mesmo ponto visual depois que o conteúdo cresceu por cima. */
16
+ export function computeScrollTopAfterPrepend(before: ScrollHeightSnapshot, scrollHeightAfter: number): number {
17
+ return before.scrollTop + (scrollHeightAfter - before.scrollHeight)
18
+ }
19
+
20
+ export type UseLoadOlderMessagesScrollParams = {
21
+ readonly containerRef: RefObject<HTMLDivElement | null>
22
+ /** `id` da mensagem mais antiga já carregada — muda quando uma página nova é prependida. */
23
+ readonly oldestMessageId: string | undefined
24
+ readonly hasMore: boolean
25
+ readonly loading: boolean
26
+ readonly onLoadOlder: () => void
27
+ }
28
+
29
+ export type UseLoadOlderMessagesScrollResult = {
30
+ /** Ligue no `onScroll` do mesmo elemento que `containerRef`. */
31
+ readonly handleScroll: (event: UIEvent<HTMLDivElement>) => void
32
+ /** Para o botão "Carregar mensagens anteriores" — mesma lógica do gatilho por scroll. */
33
+ readonly triggerLoadOlder: () => void
34
+ }
35
+
36
+ export function useLoadOlderMessagesScroll({
37
+ containerRef,
38
+ oldestMessageId,
39
+ hasMore,
40
+ loading,
41
+ onLoadOlder,
42
+ }: UseLoadOlderMessagesScrollParams): UseLoadOlderMessagesScrollResult {
43
+ const pendingSnapshotRef = useRef<ScrollHeightSnapshot | null>(null)
44
+ const previousOldestMessageIdRef = useRef(oldestMessageId)
45
+
46
+ const triggerLoadOlder = useCallback(() => {
47
+ if (loading || !hasMore) return
48
+ const container = containerRef.current
49
+ if (!container) return
50
+
51
+ pendingSnapshotRef.current = { scrollHeight: container.scrollHeight, scrollTop: container.scrollTop }
52
+ onLoadOlder()
53
+ }, [containerRef, hasMore, loading, onLoadOlder])
54
+
55
+ const handleScroll = useCallback(
56
+ (event: UIEvent<HTMLDivElement>) => {
57
+ if (event.currentTarget.scrollTop > NEAR_TOP_THRESHOLD_PX) return
58
+ triggerLoadOlder()
59
+ },
60
+ [triggerLoadOlder],
61
+ )
62
+
63
+ useLayoutEffect(() => {
64
+ const idChanged = previousOldestMessageIdRef.current !== oldestMessageId
65
+ previousOldestMessageIdRef.current = oldestMessageId
66
+
67
+ const container = containerRef.current
68
+ const pending = pendingSnapshotRef.current
69
+ if (!idChanged || !container || !pending) return
70
+
71
+ pendingSnapshotRef.current = null
72
+ container.scrollTop = computeScrollTopAfterPrepend(pending, container.scrollHeight)
73
+ }, [oldestMessageId, containerRef])
74
+
75
+ return { handleScroll, triggerLoadOlder }
76
+ }
@@ -108,7 +108,11 @@ export function createMockConversationsApi(params: CreateMockConversationsApiPar
108
108
 
109
109
  fetchMessages(conversationId, fetchParams): Promise<MessagePayload[]> {
110
110
  return withLatency(() => {
111
- const messages = params.store.listMessages(conversationId)
111
+ const allMessages = params.store.listMessages(conversationId)
112
+ const before = fetchParams?.before
113
+ // Mesma semântica do backend real: com `before`, corta tudo a partir dessa mensagem (ela
114
+ // não volta) para simular a paginação de "carregar mensagens anteriores" no preview.
115
+ const messages = before ? allMessages.filter((message) => message.timestamp < before) : allMessages
112
116
  const limit = fetchParams?.limit
113
117
  return limit ? messages.slice(-limit) : messages
114
118
  })
package/src/styles.css CHANGED
@@ -482,6 +482,25 @@
482
482
  color: #6b7280;
483
483
  }
484
484
 
485
+ .cv-workspace-load-older {
486
+ display: flex;
487
+ justify-content: center;
488
+ padding: 0.5rem 0;
489
+ }
490
+
491
+ .cv-workspace-load-older button {
492
+ min-height: 2.75rem;
493
+ border-radius: 0.375rem;
494
+ border: 1px solid var(--cv-border, #e5e7eb);
495
+ padding: 0 0.75rem;
496
+ font-size: 0.8125rem;
497
+ color: #6b7280;
498
+ }
499
+
500
+ .cv-workspace-load-older button:disabled {
501
+ opacity: 0.6;
502
+ }
503
+
485
504
  .dark .cv-workspace-notice {
486
505
  border-top-color: #374151;
487
506
  background: #1f2937;
@@ -22,6 +22,7 @@ import { buildTranscriptFilename, buildTranscriptText, downloadTextFile } from '
22
22
  import { useConversationContext } from '../hooks/useConversationContext'
23
23
  import { useConversationMessages } from '../hooks/useConversationMessages'
24
24
  import { useConversationRealtime } from '../hooks/useConversationRealtime'
25
+ import { useLoadOlderMessagesScroll } from '../hooks/useLoadOlderMessagesScroll'
25
26
  import { useScrollToLatestMessage } from '../hooks/useScrollToLatestMessage'
26
27
  import { useConversations } from '../providers/ConversationsProvider'
27
28
  import type { ConversationSummary } from '../providers/types'
@@ -151,7 +152,9 @@ export function ConversationPane({
151
152
  }
152
153
  const { api } = context
153
154
 
154
- const { messages, refetch } = useConversationMessages(conversation.id)
155
+ const { messages, refetch, loadOlderMessages, loadingOlderMessages, hasMoreOlderMessages } = useConversationMessages(
156
+ conversation.id,
157
+ )
155
158
  const { context: conversationContext } = useConversationContext(conversation.id)
156
159
  const [documentsOpen, setDocumentsOpen] = useState(false)
157
160
  const [sendFailure, setSendFailure] = useState<string | undefined>(undefined)
@@ -230,6 +233,16 @@ export function ConversationPane({
230
233
  // interessa. O hook salta ao trocar de conversa sem arrastar quem estiver lendo o histórico.
231
234
  const scroll = useScrollToLatestMessage({ conversationId: conversation.id, messageCount: messages.length })
232
235
 
236
+ // Chegar perto do topo busca a página anterior e devolve a rolagem ao mesmo ponto visual — sem
237
+ // isso, prepender mensagens antigas empurraria o operador para o meio do histórico.
238
+ const loadOlder = useLoadOlderMessagesScroll({
239
+ containerRef: scroll.containerRef,
240
+ oldestMessageId: messages[0]?.id,
241
+ hasMore: hasMoreOlderMessages,
242
+ loading: loadingOlderMessages,
243
+ onLoadOlder: () => void loadOlderMessages(),
244
+ })
245
+
233
246
  // O evento traz só `{ direction, sender }` — quem tem o conteúdo é a query.
234
247
  useConversationRealtime(conversation.id, () => {
235
248
  void refetch()
@@ -352,9 +365,19 @@ export function ConversationPane({
352
365
  mesma aparência, senão o preview deixa de ser referência confiável. */}
353
366
  <ConversationWallpaper
354
367
  ref={scroll.containerRef}
355
- onScroll={scroll.handleScroll}
368
+ onScroll={(event) => {
369
+ scroll.handleScroll(event)
370
+ loadOlder.handleScroll(event)
371
+ }}
356
372
  className="cv-workspace-transcript"
357
373
  >
374
+ {hasMoreOlderMessages ? (
375
+ <div className="cv-workspace-load-older">
376
+ <button type="button" onClick={loadOlder.triggerLoadOlder} disabled={loadingOlderMessages}>
377
+ {loadingOlderMessages ? labels.loadingOlderMessages : labels.loadOlderMessages}
378
+ </button>
379
+ </div>
380
+ ) : null}
358
381
  {messages.map((message, index) => {
359
382
  const previous = index > 0 ? messages[index - 1] : undefined
360
383
  const startsNewDay =
@@ -424,9 +447,11 @@ export function ConversationPane({
424
447
  ? {
425
448
  onAttachFiles: (files: FileList) =>
426
449
  enqueueAttachments(
427
- Array.from(files).map(
428
- (file): QueuedAttachment => ({ kind: 'local', localId: crypto.randomUUID(), file }),
429
- ),
450
+ Array.from(files).map((file): QueuedAttachment => ({
451
+ kind: 'local',
452
+ localId: crypto.randomUUID(),
453
+ file,
454
+ })),
430
455
  ),
431
456
  }
432
457
  : onAttach
@@ -32,6 +32,8 @@ export interface ConversationsWorkspaceLabels {
32
32
  readonly templateModalSend: (count: number) => string
33
33
  readonly messagesSelected: (count: number) => string
34
34
  readonly copySelected: string
35
+ readonly loadOlderMessages: string
36
+ readonly loadingOlderMessages: string
35
37
  readonly composerPlaceholder: string
36
38
  readonly attachFailure: string
37
39
  /** Tira um arquivo da fila antes de enviar. */
@@ -81,6 +83,8 @@ export const DEFAULT_CONVERSATIONS_WORKSPACE_LABELS: ConversationsWorkspaceLabel
81
83
  templateModalSend: (count) => `Enviar para ${count}`,
82
84
  messagesSelected: (count) => `${count} mensagem${count === 1 ? '' : 's'} selecionada${count === 1 ? '' : 's'}`,
83
85
  copySelected: 'Copiar',
86
+ loadOlderMessages: 'Carregar mensagens anteriores',
87
+ loadingOlderMessages: 'Carregando…',
84
88
  composerPlaceholder: 'Responder como atendente…',
85
89
  attachFailure: 'Falha ao enviar o arquivo.',
86
90
  attachmentRemove: 'Remover anexo',