@adatechnology/conversations-ui 0.4.1 → 0.5.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 (93) hide show
  1. package/README.md +169 -0
  2. package/dist/{ConversationSimulatorPanel-DnXElfY8.d.ts → ConversationSimulatorPanel-CrfFjiWh.d.ts} +2 -268
  3. package/dist/{chunk-PJNR6UMN.js → chunk-CBYEHPX6.js} +1 -63
  4. package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
  5. package/dist/{chunk-4P5KMWOI.js → chunk-JXJCPN34.js} +327 -522
  6. package/dist/chunk-MRVFFJEH.js +64 -0
  7. package/dist/chunk-RJMSM3YA.js +249 -0
  8. package/dist/chunk-WSFGNR5R.js +55 -0
  9. package/dist/flows/index.js +5 -3
  10. package/dist/index.d.ts +9 -3
  11. package/dist/index.js +783 -807
  12. package/dist/participant/index.d.ts +162 -0
  13. package/dist/participant/index.js +1448 -0
  14. package/dist/preview/index.d.ts +4 -2
  15. package/dist/preview/index.js +4 -2
  16. package/dist/quickReply.types-B4gmZsSr.d.ts +274 -0
  17. package/dist/styles.css +591 -0
  18. package/package.json +9 -5
  19. package/src/ChannelIcon.tsx +2 -0
  20. package/src/MessageBubble.test.tsx +2 -2
  21. package/src/MessageText.test.tsx +59 -0
  22. package/src/MessageText.tsx +20 -12
  23. package/src/MessageTextCopiedBadge.tsx +7 -0
  24. package/src/StatusTicks.test.tsx +30 -0
  25. package/src/StatusTicks.tsx +24 -9
  26. package/src/buildOutput.test.ts +41 -0
  27. package/src/channelCapability.test.ts +17 -0
  28. package/src/conversationChannel.test.ts +30 -1
  29. package/src/conversationChannel.ts +19 -1
  30. package/src/participant/ParticipantAttachmentItem.tsx +77 -0
  31. package/src/participant/ParticipantComposer.test.tsx +97 -0
  32. package/src/participant/ParticipantComposer.tsx +167 -0
  33. package/src/participant/ParticipantConversations.test.tsx +90 -0
  34. package/src/participant/ParticipantConversations.tsx +42 -0
  35. package/src/participant/ParticipantConversationsScreen.tsx +67 -0
  36. package/src/participant/ParticipantInbox.test.tsx +147 -0
  37. package/src/participant/ParticipantInbox.tsx +168 -0
  38. package/src/participant/ParticipantLoadState.tsx +27 -0
  39. package/src/participant/ParticipantMessageBubble.test.tsx +165 -0
  40. package/src/participant/ParticipantMessageBubble.tsx +185 -0
  41. package/src/participant/ParticipantThread.test.tsx +132 -0
  42. package/src/participant/ParticipantThread.tsx +174 -0
  43. package/src/participant/ParticipantThreadScreen.tsx +135 -0
  44. package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
  45. package/src/participant/bindAttachmentUrlResolver.ts +7 -0
  46. package/src/participant/index.ts +25 -0
  47. package/src/participant/participant.types.ts +43 -0
  48. package/src/participant/participantApi.types.ts +59 -0
  49. package/src/participant/participantAttachmentUrl.test.ts +32 -0
  50. package/src/participant/participantAttachmentUrl.ts +10 -0
  51. package/src/participant/participantBubbleActions.test.ts +44 -0
  52. package/src/participant/participantBubbleActions.ts +40 -0
  53. package/src/participant/participantDrafts.test.ts +65 -0
  54. package/src/participant/participantDrafts.ts +50 -0
  55. package/src/participant/participantFileValidation.test.ts +86 -0
  56. package/src/participant/participantFileValidation.ts +72 -0
  57. package/src/participant/participantFixtures.test-helper.ts +41 -0
  58. package/src/participant/participantGrouping.test.ts +138 -0
  59. package/src/participant/participantGrouping.ts +77 -0
  60. package/src/participant/participantInboxState.test.ts +69 -0
  61. package/src/participant/participantInboxState.ts +74 -0
  62. package/src/participant/participantLabels.ts +86 -0
  63. package/src/participant/participantLoadView.test.ts +23 -0
  64. package/src/participant/participantLoadView.ts +21 -0
  65. package/src/participant/participantMessages.test.ts +128 -0
  66. package/src/participant/participantMessages.ts +73 -0
  67. package/src/participant/participantNewMessages.test.ts +54 -0
  68. package/src/participant/participantNewMessages.ts +29 -0
  69. package/src/participant/participantQuickReply.test.ts +23 -0
  70. package/src/participant/participantQuickReply.ts +6 -0
  71. package/src/participant/participantScroll.test.ts +69 -0
  72. package/src/participant/participantScroll.ts +43 -0
  73. package/src/participant/participantSendController.test.ts +201 -0
  74. package/src/participant/participantSendController.ts +134 -0
  75. package/src/participant/participantSendStates.ts +31 -0
  76. package/src/participant/participantTheme.ts +20 -0
  77. package/src/participant/participantThreadState.test.ts +75 -0
  78. package/src/participant/participantThreadState.ts +59 -0
  79. package/src/participant/shouldMarkRead.test.ts +24 -0
  80. package/src/participant/shouldMarkRead.ts +16 -0
  81. package/src/participant/useDocumentVisibility.ts +18 -0
  82. package/src/participant/useParticipantConversationView.ts +60 -0
  83. package/src/participant/useParticipantDraft.ts +45 -0
  84. package/src/participant/useParticipantInbox.ts +57 -0
  85. package/src/participant/useParticipantMarkRead.ts +26 -0
  86. package/src/participant/useParticipantRevalidation.ts +36 -0
  87. package/src/participant/useParticipantSend.ts +84 -0
  88. package/src/participant/useParticipantThread.ts +116 -0
  89. package/src/participant/useParticipantThreadScroll.ts +26 -0
  90. package/src/participant/useStickToBottom.ts +77 -0
  91. package/src/preview/ConversationSimulatorPanel.tsx +8 -0
  92. package/src/styles.css +160 -0
  93. package/src/types.ts +3 -1
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import type { ParticipantMessage } from '@adatechnology/conversation-contracts'
4
+
5
+ import type { ParticipantLocalPendingMessage, ParticipantPendingMessage } from './participantApi.types'
6
+ import { isOwnMessage, mergeParticipantMessages, resolveOwnMessageStatus } from './participantMessages'
7
+
8
+ const SUBJECT = { subjectType: 'trip', subjectId: 'a' }
9
+ const OTHER_SUBJECT = { subjectType: 'trip', subjectId: 'b' }
10
+
11
+ function server(id: string, createdAt: string, overrides: Partial<ParticipantMessage> = {}): ParticipantMessage {
12
+ return { id, direction: 'inbound', attachments: [], createdAt, ...overrides }
13
+ }
14
+ function host(clientMessageId: string, createdAt: string, overrides: Partial<ParticipantPendingMessage> = {}): ParticipantPendingMessage {
15
+ return { clientMessageId, subject: SUBJECT, text: 'x', createdAt, state: 'queued', ...overrides }
16
+ }
17
+ function local(clientMessageId: string, createdAt: string, overrides: Partial<ParticipantLocalPendingMessage> = {}): ParticipantLocalPendingMessage {
18
+ return { clientMessageId, subject: SUBJECT, text: 'x', createdAt, state: 'sending', ...overrides }
19
+ }
20
+
21
+ describe('mergeParticipantMessages', () => {
22
+ it('orders everything by createdAt ascending', () => {
23
+ const items = mergeParticipantMessages({
24
+ serverMessages: [server('s2', '2026-10-01T10:02:00.000Z'), server('s1', '2026-10-01T10:00:00.000Z')],
25
+ hostPending: [host('h1', '2026-10-01T10:03:00.000Z')],
26
+ localPending: [local('l1', '2026-10-01T10:01:00.000Z')],
27
+ subject: SUBJECT,
28
+ })
29
+ expect(items.map((item) => (item.kind === 'server' ? item.message.id : item.pending.clientMessageId))).toEqual(['s1', 'l1', 's2', 'h1'])
30
+ })
31
+
32
+ it('hides a host pending when a server message has the same clientMessageId', () => {
33
+ const items = mergeParticipantMessages({
34
+ serverMessages: [server('s1', '2026-10-01T10:00:00.000Z', { clientMessageId: 'c1' })],
35
+ hostPending: [host('c1', '2026-10-01T09:59:00.000Z')],
36
+ localPending: [],
37
+ subject: SUBJECT,
38
+ })
39
+ expect(items).toHaveLength(1)
40
+ expect(items[0]?.kind).toBe('server')
41
+ })
42
+
43
+ it('ignores host pending of another subject', () => {
44
+ const items = mergeParticipantMessages({
45
+ serverMessages: [],
46
+ hostPending: [host('c1', '2026-10-01T09:59:00.000Z', { subject: OTHER_SUBJECT })],
47
+ localPending: [],
48
+ subject: SUBJECT,
49
+ })
50
+ expect(items).toHaveLength(0)
51
+ })
52
+
53
+ it('maps host pending states and tags the origin', () => {
54
+ const items = mergeParticipantMessages({
55
+ serverMessages: [],
56
+ hostPending: [host('q', '2026-10-01T10:00:00.000Z'), host('f', '2026-10-01T10:01:00.000Z', { state: 'failed' })],
57
+ localPending: [],
58
+ subject: SUBJECT,
59
+ })
60
+ expect(items.map((item) => (item.kind === 'pending' ? [item.origin, item.displayState] : []))).toEqual([
61
+ ['host', 'queued'],
62
+ ['host', 'failed'],
63
+ ])
64
+ })
65
+
66
+ it('shows local pending as sending or failed when no other source has it', () => {
67
+ const items = mergeParticipantMessages({
68
+ serverMessages: [],
69
+ hostPending: [],
70
+ localPending: [local('a', '2026-10-01T10:00:00.000Z'), local('b', '2026-10-01T10:01:00.000Z', { state: 'failed' })],
71
+ subject: SUBJECT,
72
+ })
73
+ expect(items.map((item) => (item.kind === 'pending' ? [item.origin, item.displayState] : []))).toEqual([
74
+ ['local', 'sending'],
75
+ ['local', 'failed'],
76
+ ])
77
+ })
78
+
79
+ it('hides a local pending already present on the server or at the host', () => {
80
+ const items = mergeParticipantMessages({
81
+ serverMessages: [server('s1', '2026-10-01T10:00:00.000Z', { clientMessageId: 'a' })],
82
+ hostPending: [host('b', '2026-10-01T10:01:00.000Z')],
83
+ localPending: [local('a', '2026-10-01T09:59:00.000Z'), local('b', '2026-10-01T09:59:30.000Z')],
84
+ subject: SUBJECT,
85
+ })
86
+ expect(items.map((item) => item.kind)).toEqual(['server', 'pending'])
87
+ expect(items[1]).toMatchObject({ kind: 'pending', origin: 'host' })
88
+ })
89
+
90
+ it('keeps a failed local pending with its original clientMessageId for resend', () => {
91
+ const items = mergeParticipantMessages({
92
+ serverMessages: [],
93
+ hostPending: [],
94
+ localPending: [local('keep-me', '2026-10-01T10:00:00.000Z', { state: 'failed' })],
95
+ subject: SUBJECT,
96
+ })
97
+ expect(items[0]).toMatchObject({ kind: 'pending', pending: { clientMessageId: 'keep-me' } })
98
+ })
99
+ })
100
+
101
+ describe('isOwnMessage', () => {
102
+ it('treats inbound (company perspective) as the participant own message', () => {
103
+ expect(isOwnMessage(server('s', '2026-10-01T10:00:00.000Z', { direction: 'inbound' }))).toBe(true)
104
+ expect(isOwnMessage(server('s', '2026-10-01T10:00:00.000Z', { direction: 'outbound' }))).toBe(false)
105
+ })
106
+ })
107
+
108
+ describe('resolveOwnMessageStatus', () => {
109
+ it('uses the local display state for pending messages', () => {
110
+ expect(resolveOwnMessageStatus({ displayState: 'sending', confirmsRead: true })).toBe('sending')
111
+ expect(resolveOwnMessageStatus({ displayState: 'queued', confirmsRead: true })).toBe('queued')
112
+ expect(resolveOwnMessageStatus({ displayState: 'failed', confirmsRead: true })).toBe('failed')
113
+ })
114
+ it('defaults to sent without server status', () => {
115
+ expect(resolveOwnMessageStatus({ confirmsRead: true })).toBe('sent')
116
+ })
117
+ it('maps server statuses', () => {
118
+ expect(resolveOwnMessageStatus({ serverStatus: 'queued', confirmsRead: true })).toBe('queued')
119
+ expect(resolveOwnMessageStatus({ serverStatus: 'sent', confirmsRead: true })).toBe('sent')
120
+ expect(resolveOwnMessageStatus({ serverStatus: 'delivered', confirmsRead: true })).toBe('delivered')
121
+ expect(resolveOwnMessageStatus({ serverStatus: 'failed', confirmsRead: true })).toBe('failed')
122
+ expect(resolveOwnMessageStatus({ serverStatus: 'bounced', confirmsRead: true })).toBe('failed')
123
+ })
124
+ it('reports read only when the channel confirms it', () => {
125
+ expect(resolveOwnMessageStatus({ serverStatus: 'read', confirmsRead: true })).toBe('read')
126
+ expect(resolveOwnMessageStatus({ serverStatus: 'read', confirmsRead: false })).toBe('delivered')
127
+ })
128
+ })
@@ -0,0 +1,73 @@
1
+ import type { MessageDeliveryStatus, ParticipantMessage, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
2
+
3
+ import type { ParticipantLocalPendingMessage, ParticipantPendingMessage } from './participantApi.types'
4
+
5
+ export type ParticipantPendingDisplayState = 'sending' | 'queued' | 'failed' | 'sent'
6
+ export type ParticipantOwnMessageStatus = 'sending' | 'queued' | 'sent' | 'delivered' | 'read' | 'failed'
7
+
8
+ export type ParticipantTimelineItem =
9
+ | { readonly kind: 'server'; readonly message: ParticipantMessage }
10
+ | {
11
+ readonly kind: 'pending'
12
+ readonly pending: ParticipantPendingMessage | ParticipantLocalPendingMessage
13
+ readonly origin: 'host' | 'local'
14
+ readonly displayState: ParticipantPendingDisplayState
15
+ }
16
+
17
+ export type MergeParticipantMessagesParams = {
18
+ readonly serverMessages: readonly ParticipantMessage[]
19
+ readonly hostPending: readonly ParticipantPendingMessage[]
20
+ readonly localPending: readonly ParticipantLocalPendingMessage[]
21
+ readonly subject: ParticipantSubjectRef
22
+ }
23
+
24
+ export type ResolveOwnMessageStatusParams = {
25
+ readonly displayState?: ParticipantPendingDisplayState
26
+ readonly serverStatus?: MessageDeliveryStatus
27
+ readonly confirmsRead: boolean
28
+ }
29
+
30
+ function isSameSubject(left: ParticipantSubjectRef, right: ParticipantSubjectRef): boolean {
31
+ return left.subjectType === right.subjectType && left.subjectId === right.subjectId
32
+ }
33
+
34
+ function createdAtOf(item: ParticipantTimelineItem): number {
35
+ return Date.parse(item.kind === 'server' ? item.message.createdAt : item.pending.createdAt)
36
+ }
37
+
38
+ export function mergeParticipantMessages(params: MergeParticipantMessagesParams): readonly ParticipantTimelineItem[] {
39
+ const { serverMessages, hostPending, localPending, subject } = params
40
+ const knownClientIds = new Set<string>()
41
+ for (const message of serverMessages) {
42
+ if (message.clientMessageId) knownClientIds.add(message.clientMessageId)
43
+ }
44
+
45
+ const items: ParticipantTimelineItem[] = serverMessages.map((message) => ({ kind: 'server', message }))
46
+
47
+ for (const pending of hostPending) {
48
+ if (!isSameSubject(pending.subject, subject) || knownClientIds.has(pending.clientMessageId)) continue
49
+ knownClientIds.add(pending.clientMessageId)
50
+ items.push({ kind: 'pending', pending, origin: 'host', displayState: pending.state })
51
+ }
52
+
53
+ for (const pending of localPending) {
54
+ if (!isSameSubject(pending.subject, subject) || knownClientIds.has(pending.clientMessageId)) continue
55
+ items.push({ kind: 'pending', pending, origin: 'local', displayState: pending.state })
56
+ }
57
+
58
+ return items.sort((left, right) => createdAtOf(left) - createdAtOf(right))
59
+ }
60
+
61
+ /** Direction is from the company perspective: the participant is the inbound side. */
62
+ export function isOwnMessage(message: ParticipantMessage): boolean {
63
+ return message.direction === 'inbound'
64
+ }
65
+
66
+ export function resolveOwnMessageStatus(params: ResolveOwnMessageStatusParams): ParticipantOwnMessageStatus {
67
+ const { displayState, serverStatus, confirmsRead } = params
68
+ if (displayState) return displayState
69
+ if (serverStatus === undefined) return 'sent'
70
+ if (serverStatus === 'failed' || serverStatus === 'bounced') return 'failed'
71
+ if (serverStatus === 'read') return confirmsRead ? 'read' : 'delivered'
72
+ return serverStatus
73
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { buildMessage } from './participantFixtures.test-helper'
4
+ import { collectServerMessageIds, countNewIncomingMessages } from './participantNewMessages'
5
+ import type { ParticipantTimelineItem } from './participantMessages'
6
+
7
+ function server(id: string, direction: 'inbound' | 'outbound', createdAt = '2026-10-01T10:00:00.000Z'): ParticipantTimelineItem {
8
+ return { kind: 'server', message: buildMessage({ id, direction, createdAt }) }
9
+ }
10
+
11
+ describe('countNewIncomingMessages', () => {
12
+ it('counts only unseen messages from the other side (outbound)', () => {
13
+ const items = [
14
+ server('a', 'outbound', '2026-10-01T10:00:00.000Z'),
15
+ server('b', 'outbound', '2026-10-01T10:01:00.000Z'),
16
+ server('c', 'inbound', '2026-10-01T10:02:00.000Z'),
17
+ server('d', 'outbound', '2026-10-01T10:03:00.000Z'),
18
+ ]
19
+ expect(countNewIncomingMessages(new Set(['a']), items)).toBe(2)
20
+ })
21
+
22
+ it('does not count outbound history loaded above the messages already seen', () => {
23
+ const seen = [server('m30', 'outbound', '2026-10-01T10:30:00.000Z'), server('m31', 'inbound', '2026-10-01T10:31:00.000Z')]
24
+ const older = [server('m1', 'outbound', '2026-10-01T10:01:00.000Z'), server('m2', 'outbound', '2026-10-01T10:02:00.000Z')]
25
+ expect(countNewIncomingMessages(collectServerMessageIds(seen), [...older, ...seen])).toBe(0)
26
+ })
27
+
28
+ it('counts a reply that arrives after the messages already seen', () => {
29
+ const seen = [server('m30', 'outbound', '2026-10-01T10:30:00.000Z'), server('m31', 'inbound', '2026-10-01T10:31:00.000Z')]
30
+ const reply = server('m32', 'outbound', '2026-10-01T10:32:00.000Z')
31
+ expect(countNewIncomingMessages(collectServerMessageIds(seen), [...seen, reply])).toBe(1)
32
+ })
33
+
34
+ it('is zero when everything was seen', () => {
35
+ const items = [server('a', 'outbound')]
36
+ expect(countNewIncomingMessages(collectServerMessageIds(items), items)).toBe(0)
37
+ })
38
+
39
+ it('ignores the pending messages of the participant', () => {
40
+ const pending: ParticipantTimelineItem = {
41
+ kind: 'pending',
42
+ origin: 'local',
43
+ displayState: 'sending',
44
+ pending: { clientMessageId: 'x', subject: { subjectType: 'invoice', subjectId: '1' }, createdAt: '2026-10-01T10:00:00.000Z', state: 'sending', text: 'hi' },
45
+ }
46
+ expect(countNewIncomingMessages(new Set(), [pending])).toBe(0)
47
+ })
48
+ })
49
+
50
+ describe('collectServerMessageIds', () => {
51
+ it('collects the ids of server items only', () => {
52
+ expect([...collectServerMessageIds([server('a', 'inbound'), server('b', 'outbound')])]).toEqual(['a', 'b'])
53
+ })
54
+ })
@@ -0,0 +1,29 @@
1
+ import type { ParticipantTimelineItem } from './participantMessages'
2
+
3
+ export function collectServerMessageIds(items: readonly ParticipantTimelineItem[]): ReadonlySet<string> {
4
+ const ids = new Set<string>()
5
+ for (const item of items) {
6
+ if (item.kind === 'server') ids.add(item.message.id)
7
+ }
8
+ return ids
9
+ }
10
+
11
+ function latestSeenTime(previousIds: ReadonlySet<string>, items: readonly ParticipantTimelineItem[]): number {
12
+ let latest = Number.NEGATIVE_INFINITY
13
+ for (const item of items) {
14
+ if (item.kind === 'server' && previousIds.has(item.message.id)) latest = Math.max(latest, Date.parse(item.message.createdAt))
15
+ }
16
+ return latest
17
+ }
18
+
19
+ /** Messages from the other side ('outbound') newer than what the participant has seen; older history loaded above is not news. */
20
+ export function countNewIncomingMessages(previousIds: ReadonlySet<string>, currentItems: readonly ParticipantTimelineItem[]): number {
21
+ const latestSeen = latestSeenTime(previousIds, currentItems)
22
+ return currentItems.filter(
23
+ (item) =>
24
+ item.kind === 'server' &&
25
+ item.message.direction === 'outbound' &&
26
+ !previousIds.has(item.message.id) &&
27
+ Date.parse(item.message.createdAt) > latestSeen,
28
+ ).length
29
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { applyQuickReply } from './participantQuickReply'
4
+
5
+ describe('applyQuickReply', () => {
6
+ it('fills an empty text with the reply', () => {
7
+ expect(applyQuickReply('', 'On my way')).toBe('On my way')
8
+ expect(applyQuickReply(' ', 'On my way')).toBe('On my way')
9
+ })
10
+
11
+ it('appends to existing text separated by a space', () => {
12
+ expect(applyQuickReply('Hello', 'On my way')).toBe('Hello On my way')
13
+ })
14
+
15
+ it('does not double the separator when the text already ends in whitespace', () => {
16
+ expect(applyQuickReply('Hello ', 'On my way')).toBe('Hello On my way')
17
+ expect(applyQuickReply('Hello\n', 'On my way')).toBe('Hello\nOn my way')
18
+ })
19
+
20
+ it('returns the text untouched for an empty reply', () => {
21
+ expect(applyQuickReply('Hello', '')).toBe('Hello')
22
+ })
23
+ })
@@ -0,0 +1,6 @@
1
+ /** Fills the composer text with a quick reply; the caller must never send because of it. */
2
+ export function applyQuickReply(currentText: string, replyText: string): string {
3
+ if (replyText === '') return currentText
4
+ if (currentText.trim() === '') return replyText
5
+ return /\s$/.test(currentText) ? `${currentText}${replyText}` : `${currentText} ${replyText}`
6
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import {
4
+ NEAR_BOTTOM_THRESHOLD_PX,
5
+ resolveScrollAction,
6
+ scrollAnchorAfterPrepend,
7
+ shouldStickToBottom,
8
+ } from './participantScroll'
9
+
10
+ describe('shouldStickToBottom', () => {
11
+ const base = { clientHeight: 400, scrollHeight: 1000, threshold: 80 }
12
+
13
+ it('is true at the very bottom', () => {
14
+ expect(shouldStickToBottom({ ...base, scrollTop: 600 })).toBe(true)
15
+ })
16
+
17
+ it('is true within the threshold and false beyond it', () => {
18
+ expect(shouldStickToBottom({ ...base, scrollTop: 520 })).toBe(true)
19
+ expect(shouldStickToBottom({ ...base, scrollTop: 519 })).toBe(false)
20
+ })
21
+
22
+ it('is true when the content fits without scrolling', () => {
23
+ expect(shouldStickToBottom({ scrollTop: 0, clientHeight: 400, scrollHeight: 300, threshold: 80 })).toBe(true)
24
+ })
25
+
26
+ it('uses an 80px default threshold', () => {
27
+ expect(NEAR_BOTTOM_THRESHOLD_PX).toBe(80)
28
+ })
29
+ })
30
+
31
+ describe('scrollAnchorAfterPrepend', () => {
32
+ it('keeps the same message in view by adding the height that was prepended', () => {
33
+ expect(scrollAnchorAfterPrepend({ previousScrollTop: 10, previousScrollHeight: 1000, scrollHeight: 1600 })).toBe(610)
34
+ })
35
+
36
+ it('does not move when nothing was prepended', () => {
37
+ expect(scrollAnchorAfterPrepend({ previousScrollTop: 10, previousScrollHeight: 1000, scrollHeight: 1000 })).toBe(10)
38
+ })
39
+ })
40
+
41
+ describe('resolveScrollAction', () => {
42
+ const settled = { hasPositioned: true, previousFirstKey: 'a', previousLastKey: 'c', firstKey: 'a', lastKey: 'c', wasNearBottom: true }
43
+
44
+ it('does nothing while there are no items', () => {
45
+ expect(resolveScrollAction({ ...settled, hasPositioned: false, firstKey: undefined, lastKey: undefined })).toBe('none')
46
+ })
47
+
48
+ it('jumps to the bottom on the first load even when the user was not near the bottom', () => {
49
+ expect(resolveScrollAction({ ...settled, hasPositioned: false, previousFirstKey: undefined, previousLastKey: undefined, wasNearBottom: false })).toBe('bottom')
50
+ })
51
+
52
+ it('follows a new last message only when the user was near the bottom', () => {
53
+ expect(resolveScrollAction({ ...settled, lastKey: 'd' })).toBe('bottom')
54
+ expect(resolveScrollAction({ ...settled, lastKey: 'd', wasNearBottom: false })).toBe('none')
55
+ })
56
+
57
+ it('anchors when older messages are prepended, whatever the position', () => {
58
+ expect(resolveScrollAction({ ...settled, firstKey: 'z' })).toBe('anchor')
59
+ expect(resolveScrollAction({ ...settled, firstKey: 'z', wasNearBottom: false })).toBe('anchor')
60
+ })
61
+
62
+ it('anchors when both ends changed but the user was reading', () => {
63
+ expect(resolveScrollAction({ ...settled, firstKey: 'z', lastKey: 'd', wasNearBottom: false })).toBe('anchor')
64
+ })
65
+
66
+ it('does nothing when the keys are the same', () => {
67
+ expect(resolveScrollAction(settled)).toBe('none')
68
+ })
69
+ })
@@ -0,0 +1,43 @@
1
+ export const NEAR_BOTTOM_THRESHOLD_PX = 80
2
+
3
+ export type ShouldStickToBottomParams = {
4
+ readonly scrollTop: number
5
+ readonly clientHeight: number
6
+ readonly scrollHeight: number
7
+ readonly threshold: number
8
+ }
9
+
10
+ export type ScrollAnchorAfterPrependParams = {
11
+ readonly previousScrollTop: number
12
+ readonly previousScrollHeight: number
13
+ readonly scrollHeight: number
14
+ }
15
+
16
+ export type ResolveScrollActionParams = {
17
+ readonly hasPositioned: boolean
18
+ readonly previousFirstKey: string | undefined
19
+ readonly previousLastKey: string | undefined
20
+ readonly firstKey: string | undefined
21
+ readonly lastKey: string | undefined
22
+ readonly wasNearBottom: boolean
23
+ }
24
+
25
+ export type ScrollAction = 'none' | 'bottom' | 'anchor'
26
+
27
+ export function shouldStickToBottom(params: ShouldStickToBottomParams): boolean {
28
+ const { scrollTop, clientHeight, scrollHeight, threshold } = params
29
+ return scrollHeight - scrollTop - clientHeight <= threshold
30
+ }
31
+
32
+ export function scrollAnchorAfterPrepend(params: ScrollAnchorAfterPrependParams): number {
33
+ return params.previousScrollTop + (params.scrollHeight - params.previousScrollHeight)
34
+ }
35
+
36
+ export function resolveScrollAction(params: ResolveScrollActionParams): ScrollAction {
37
+ const { firstKey, lastKey } = params
38
+ if (firstKey === undefined || lastKey === undefined) return 'none'
39
+ if (!params.hasPositioned) return 'bottom'
40
+ if (lastKey !== params.previousLastKey && params.wasNearBottom) return 'bottom'
41
+ if (firstKey !== params.previousFirstKey) return 'anchor'
42
+ return 'none'
43
+ }
@@ -0,0 +1,201 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import type { ParticipantMessage } from '@adatechnology/conversation-contracts'
4
+
5
+ import {
6
+ appendEntryToDraft,
7
+ collectKnownClientMessageIds,
8
+ createParticipantSendEntry,
9
+ findFailedEntry,
10
+ hasSendingEntry,
11
+ participantSendReducer,
12
+ toLocalPending,
13
+ type ParticipantSendEntry,
14
+ } from './participantSendController'
15
+ import { participantSendStatesReducer, selectSendEntries } from './participantSendStates'
16
+ import { takeDraftForSend, setDraft } from './participantDrafts'
17
+
18
+ const TRIP_A = { subjectType: 'trip', subjectId: 'a' }
19
+ const TRIP_B = { subjectType: 'trip', subjectId: 'b' }
20
+
21
+ function entry(clientMessageId: string, overrides: Partial<ParticipantSendEntry> = {}): ParticipantSendEntry {
22
+ return createParticipantSendEntry({
23
+ clientMessageId,
24
+ subject: TRIP_A,
25
+ content: { text: 'hello', files: [] },
26
+ createdAt: '2026-10-01T10:00:00.000Z',
27
+ ...overrides,
28
+ })
29
+ }
30
+
31
+ function serverMessage(id: string, clientMessageId?: string): ParticipantMessage {
32
+ return { id, direction: 'inbound', attachments: [], createdAt: '2026-10-01T10:00:00.000Z', ...(clientMessageId ? { clientMessageId } : {}) }
33
+ }
34
+
35
+ function states(entries: readonly ParticipantSendEntry[]) {
36
+ return entries.map((item) => [item.clientMessageId, item.state])
37
+ }
38
+
39
+ describe('participantSendReducer', () => {
40
+ it('starting a send registers a sending entry holding text and files', () => {
41
+ const file = new File(['x'], 'a.png', { type: 'image/png' })
42
+ const started = participantSendReducer([], {
43
+ type: 'started',
44
+ entry: entry('c1', { files: [file] }),
45
+ })
46
+
47
+ expect(states(started)).toEqual([['c1', 'sending']])
48
+ expect(started[0]?.files).toEqual([file])
49
+ expect(started[0]?.text).toBe('hello')
50
+ })
51
+
52
+ it('a sent send stays visible as sent, holding the server message, until the echo is reflected', () => {
53
+ const sending = participantSendReducer([], { type: 'started', entry: entry('c1') })
54
+ const message = serverMessage('m1', 'c1')
55
+ const sent = participantSendReducer(sending, { type: 'sent', clientMessageId: 'c1', message })
56
+
57
+ expect(states(sent)).toEqual([['c1', 'sent']])
58
+ expect(sent[0]?.sentMessage).toBe(message)
59
+ expect(toLocalPending(sent)[0]?.state).toBe('sent')
60
+ })
61
+
62
+ it('a sent entry survives leaving and coming back, and reflected removes it once the echo exists', () => {
63
+ const subjectStates = participantSendStatesReducer(new Map(), { subject: TRIP_A, action: { type: 'started', entry: entry('c1') } })
64
+ const afterSent = participantSendStatesReducer(subjectStates, {
65
+ subject: TRIP_A,
66
+ action: { type: 'sent', clientMessageId: 'c1', message: serverMessage('m1', 'c1') },
67
+ })
68
+ expect(states(selectSendEntries(afterSent, TRIP_A))).toEqual([['c1', 'sent']])
69
+
70
+ const notYet = participantSendStatesReducer(afterSent, { subject: TRIP_A, action: { type: 'reflected', knownClientMessageIds: new Set() } })
71
+ expect(states(selectSendEntries(notYet, TRIP_A))).toEqual([['c1', 'sent']])
72
+ const echoed = participantSendStatesReducer(afterSent, { subject: TRIP_A, action: { type: 'reflected', knownClientMessageIds: new Set(['c1']) } })
73
+ expect(selectSendEntries(echoed, TRIP_A)).toEqual([])
74
+ })
75
+
76
+ it('queued stays visible as queued until the host or the server reflect the id', () => {
77
+ const sending = participantSendReducer([], { type: 'started', entry: entry('c1') })
78
+ const queued = participantSendReducer(sending, { type: 'queued', clientMessageId: 'c1' })
79
+
80
+ expect(states(queued)).toEqual([['c1', 'queued']])
81
+ expect(states(participantSendReducer(queued, { type: 'reflected', knownClientMessageIds: new Set() }))).toEqual([['c1', 'queued']])
82
+ expect(participantSendReducer(queued, { type: 'reflected', knownClientMessageIds: new Set(['c1']) })).toEqual([])
83
+ })
84
+
85
+ it('reflecting an unknown id returns the same reference (no rerender)', () => {
86
+ const queued = participantSendReducer([], { type: 'started', entry: entry('c1') })
87
+ expect(participantSendReducer(queued, { type: 'reflected', knownClientMessageIds: new Set(['other']) })).toBe(queued)
88
+ })
89
+
90
+ it('a throw marks failed keeping text and files; retry reuses the same id and goes back to sending', () => {
91
+ const file = new File(['x'], 'a.png')
92
+ const failed = participantSendReducer(participantSendReducer([], { type: 'started', entry: entry('c1', { files: [file] }) }), {
93
+ type: 'failed',
94
+ clientMessageId: 'c1',
95
+ })
96
+
97
+ expect(findFailedEntry(failed, 'c1')?.files).toEqual([file])
98
+ const retried = participantSendReducer(failed, { type: 'retried', clientMessageId: 'c1' })
99
+ expect(states(retried)).toEqual([['c1', 'sending']])
100
+ expect(retried[0]?.text).toBe('hello')
101
+ })
102
+
103
+ it('retry only applies to failed entries', () => {
104
+ const sending = participantSendReducer([], { type: 'started', entry: entry('c1') })
105
+ expect(participantSendReducer(sending, { type: 'retried', clientMessageId: 'c1' })).toBe(sending)
106
+ expect(findFailedEntry(sending, 'c1')).toBeUndefined()
107
+ })
108
+
109
+ it('discard removes the pending', () => {
110
+ const failed = participantSendReducer(participantSendReducer([], { type: 'started', entry: entry('c1') }), { type: 'failed', clientMessageId: 'c1' })
111
+ expect(participantSendReducer(failed, { type: 'discarded', clientMessageId: 'c1' })).toEqual([])
112
+ })
113
+
114
+ it('edit returns text and files to the draft, with the current field content kept after them', () => {
115
+ const file = new File(['x'], 'a.png')
116
+ const other = new File(['y'], 'b.png')
117
+ const failed = entry('c1', { files: [file], state: 'failed' })
118
+
119
+ expect(appendEntryToDraft(failed, { text: '', files: [] })).toEqual({ text: 'hello', files: [file] })
120
+ expect(appendEntryToDraft(failed, { text: 'typed', files: [other] })).toEqual({ text: 'hello\ntyped', files: [file, other] })
121
+ })
122
+
123
+ it('maps entries to local pending messages without the files', () => {
124
+ const file = new File(['x'], 'a.png', { type: 'image/png' })
125
+ const [pending] = toLocalPending([entry('c1', { files: [file], state: 'queued' })])
126
+
127
+ expect(pending).toMatchObject({ clientMessageId: 'c1', state: 'queued', text: 'hello' })
128
+ expect(pending?.attachments).toEqual([{ filename: 'a.png', mimeType: 'image/png', sizeBytes: 1 }])
129
+ expect(pending).not.toHaveProperty('files')
130
+ })
131
+
132
+ it('collects the ids known by the server and by the host queue', () => {
133
+ const known = collectKnownClientMessageIds({
134
+ messages: [serverMessage('m1', 'c1'), serverMessage('m2')],
135
+ hostPending: [{ clientMessageId: 'c2', subject: TRIP_A, createdAt: '2026-10-01T10:00:00.000Z', state: 'queued' }],
136
+ })
137
+ expect([...known].sort()).toEqual(['c1', 'c2'])
138
+ })
139
+ })
140
+
141
+ describe('send flow with the double tap', () => {
142
+ it('the second tap finds the field already empty and sends nothing', () => {
143
+ const drafts = setDraft(new Map(), TRIP_A, { text: ' hello ', files: [] })
144
+
145
+ const first = takeDraftForSend(drafts, TRIP_A)
146
+ const second = takeDraftForSend(first.drafts, TRIP_A)
147
+
148
+ expect(first.content).toEqual({ text: 'hello' })
149
+ expect(second.content).toBeUndefined()
150
+ })
151
+
152
+ it('an empty or whitespace-only field sends nothing and keeps the drafts reference', () => {
153
+ const drafts = setDraft(new Map(), TRIP_A, { text: ' ', files: [] })
154
+ const taken = takeDraftForSend(drafts, TRIP_A)
155
+
156
+ expect(taken.content).toBeUndefined()
157
+ expect(taken.drafts).toBe(drafts)
158
+ })
159
+
160
+ it('failure then edit hands the content back to the draft and leaves a single owner', () => {
161
+ const drafts = setDraft(new Map(), TRIP_A, { text: 'hello', files: [] })
162
+ const { drafts: cleared, content } = takeDraftForSend(drafts, TRIP_A)
163
+ const started = participantSendReducer([], {
164
+ type: 'started',
165
+ entry: createParticipantSendEntry({ clientMessageId: 'c1', subject: TRIP_A, content: content ?? { files: [] }, createdAt: '2026-10-01T10:00:00.000Z' }),
166
+ })
167
+ const failed = participantSendReducer(started, { type: 'failed', clientMessageId: 'c1' })
168
+ const failedEntry = findFailedEntry(failed, 'c1')
169
+
170
+ expect(cleared.get('trip:a')).toBeUndefined()
171
+ expect(failedEntry && appendEntryToDraft(failedEntry, { text: '', files: [] })).toEqual({ text: 'hello', files: [] })
172
+ expect(participantSendReducer(failed, { type: 'discarded', clientMessageId: 'c1' })).toEqual([])
173
+ })
174
+ })
175
+
176
+ describe('participantSendStatesReducer', () => {
177
+ it('keeps the entries per subject so leaving and coming back does not lose a failed message', () => {
178
+ const afterA = participantSendStatesReducer(new Map(), { subject: TRIP_A, action: { type: 'started', entry: entry('c1') } })
179
+ const afterFailed = participantSendStatesReducer(afterA, { subject: TRIP_A, action: { type: 'failed', clientMessageId: 'c1' } })
180
+
181
+ expect(states(selectSendEntries(afterFailed, TRIP_A))).toEqual([['c1', 'failed']])
182
+ expect(selectSendEntries(afterFailed, TRIP_B)).toEqual([])
183
+ })
184
+
185
+ it('drops the subject key when its last entry goes away and ignores no-ops by reference', () => {
186
+ const started = participantSendStatesReducer(new Map(), { subject: TRIP_A, action: { type: 'started', entry: entry('c1') } })
187
+ const noop = participantSendStatesReducer(started, { subject: TRIP_A, action: { type: 'reflected', knownClientMessageIds: new Set() } })
188
+ const gone = participantSendStatesReducer(started, { subject: TRIP_A, action: { type: 'discarded', clientMessageId: 'c1' } })
189
+
190
+ expect(noop).toBe(started)
191
+ expect(gone.size).toBe(0)
192
+ })
193
+ })
194
+
195
+ describe('hasSendingEntry', () => {
196
+ it('is true only while some send is in the sending state', () => {
197
+ expect(hasSendingEntry([])).toBe(false)
198
+ expect(hasSendingEntry([entry('a', { state: 'failed' }), entry('b', { state: 'queued' })])).toBe(false)
199
+ expect(hasSendingEntry([entry('a', { state: 'failed' }), entry('b', { state: 'sending' })])).toBe(true)
200
+ })
201
+ })