@adatechnology/conversations-ui 0.4.2 → 0.6.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 (110) hide show
  1. package/README.md +231 -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-4P5KMWOI.js → chunk-FAQLC3TX.js} +327 -522
  5. package/dist/{chunk-HMCOTZAX.js → chunk-HOKNKXIT.js} +1 -1
  6. package/dist/chunk-MRVFFJEH.js +64 -0
  7. package/dist/chunk-O6457XHK.js +255 -0
  8. package/dist/chunk-OVFCLRQQ.js +84 -0
  9. package/dist/flows/index.js +5 -3
  10. package/dist/index.d.ts +21 -5
  11. package/dist/index.js +783 -807
  12. package/dist/participant/index.d.ts +186 -0
  13. package/dist/participant/index.js +1708 -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 +689 -0
  18. package/package.json +8 -4
  19. package/src/ChannelIcon.tsx +2 -0
  20. package/src/MessageText.test.tsx +76 -0
  21. package/src/MessageText.tsx +28 -12
  22. package/src/MessageTextCopiedBadge.tsx +15 -0
  23. package/src/StatusTicks.test.tsx +51 -0
  24. package/src/StatusTicks.tsx +36 -6
  25. package/src/buildOutput.test.ts +41 -0
  26. package/src/channelCapability.test.ts +17 -0
  27. package/src/conversationChannel.test.ts +30 -1
  28. package/src/conversationChannel.ts +19 -1
  29. package/src/legacyChannels.golden.test.ts +89 -0
  30. package/src/legacyDefaults.golden.test.tsx +127 -0
  31. package/src/legacyStyles.fixture.css +1121 -0
  32. package/src/legacyStyles.golden.test.ts +121 -0
  33. package/src/messageTextCopyInteraction.test.ts +75 -0
  34. package/src/messageTextCopyInteraction.ts +37 -0
  35. package/src/participant/ParticipantAttachmentItem.tsx +77 -0
  36. package/src/participant/ParticipantChannelBadges.test.tsx +59 -0
  37. package/src/participant/ParticipantChannelBadges.tsx +75 -0
  38. package/src/participant/ParticipantComposer.test.tsx +97 -0
  39. package/src/participant/ParticipantComposer.tsx +167 -0
  40. package/src/participant/ParticipantConversations.test.tsx +90 -0
  41. package/src/participant/ParticipantConversations.tsx +49 -0
  42. package/src/participant/ParticipantConversationsScreen.tsx +78 -0
  43. package/src/participant/ParticipantInbox.test.tsx +244 -0
  44. package/src/participant/ParticipantInbox.tsx +149 -0
  45. package/src/participant/ParticipantInboxRow.tsx +75 -0
  46. package/src/participant/ParticipantInboxSearchField.tsx +34 -0
  47. package/src/participant/ParticipantLoadState.tsx +27 -0
  48. package/src/participant/ParticipantMessageBubble.test.tsx +167 -0
  49. package/src/participant/ParticipantMessageBubble.tsx +185 -0
  50. package/src/participant/ParticipantProtocolBadge.tsx +74 -0
  51. package/src/participant/ParticipantProtocolView.test.tsx +208 -0
  52. package/src/participant/ParticipantThread.test.tsx +149 -0
  53. package/src/participant/ParticipantThread.tsx +184 -0
  54. package/src/participant/ParticipantThreadScreen.tsx +135 -0
  55. package/src/participant/bindAttachmentUrlResolver.test.ts +25 -0
  56. package/src/participant/bindAttachmentUrlResolver.ts +7 -0
  57. package/src/participant/index.ts +26 -0
  58. package/src/participant/participant.types.ts +52 -0
  59. package/src/participant/participantApi.types.ts +59 -0
  60. package/src/participant/participantAttachmentUrl.test.ts +32 -0
  61. package/src/participant/participantAttachmentUrl.ts +10 -0
  62. package/src/participant/participantBubbleActions.test.ts +44 -0
  63. package/src/participant/participantBubbleActions.ts +40 -0
  64. package/src/participant/participantChannels.test.ts +42 -0
  65. package/src/participant/participantChannels.ts +33 -0
  66. package/src/participant/participantDrafts.test.ts +65 -0
  67. package/src/participant/participantDrafts.ts +50 -0
  68. package/src/participant/participantFileValidation.test.ts +86 -0
  69. package/src/participant/participantFileValidation.ts +72 -0
  70. package/src/participant/participantFixtures.test-helper.ts +41 -0
  71. package/src/participant/participantGrouping.test.ts +236 -0
  72. package/src/participant/participantGrouping.ts +90 -0
  73. package/src/participant/participantInboxState.test.ts +69 -0
  74. package/src/participant/participantInboxState.ts +74 -0
  75. package/src/participant/participantLabels.ts +115 -0
  76. package/src/participant/participantLoadView.test.ts +23 -0
  77. package/src/participant/participantLoadView.ts +21 -0
  78. package/src/participant/participantMessages.test.ts +128 -0
  79. package/src/participant/participantMessages.ts +73 -0
  80. package/src/participant/participantNewMessages.test.ts +54 -0
  81. package/src/participant/participantNewMessages.ts +29 -0
  82. package/src/participant/participantProtocol.test.ts +104 -0
  83. package/src/participant/participantProtocol.ts +52 -0
  84. package/src/participant/participantProtocolCopy.test.ts +45 -0
  85. package/src/participant/participantProtocolCopy.ts +32 -0
  86. package/src/participant/participantQuickReply.test.ts +23 -0
  87. package/src/participant/participantQuickReply.ts +6 -0
  88. package/src/participant/participantScroll.test.ts +69 -0
  89. package/src/participant/participantScroll.ts +43 -0
  90. package/src/participant/participantSendController.test.ts +201 -0
  91. package/src/participant/participantSendController.ts +134 -0
  92. package/src/participant/participantSendStates.ts +31 -0
  93. package/src/participant/participantTheme.ts +20 -0
  94. package/src/participant/participantThreadState.test.ts +75 -0
  95. package/src/participant/participantThreadState.ts +59 -0
  96. package/src/participant/shouldMarkRead.test.ts +24 -0
  97. package/src/participant/shouldMarkRead.ts +16 -0
  98. package/src/participant/useDocumentVisibility.ts +18 -0
  99. package/src/participant/useParticipantConversationView.ts +60 -0
  100. package/src/participant/useParticipantDraft.ts +45 -0
  101. package/src/participant/useParticipantInbox.ts +57 -0
  102. package/src/participant/useParticipantMarkRead.ts +26 -0
  103. package/src/participant/useParticipantRevalidation.ts +36 -0
  104. package/src/participant/useParticipantSend.ts +84 -0
  105. package/src/participant/useParticipantThread.ts +116 -0
  106. package/src/participant/useParticipantThreadScroll.ts +26 -0
  107. package/src/participant/useStickToBottom.ts +77 -0
  108. package/src/preview/ConversationSimulatorPanel.tsx +8 -0
  109. package/src/styles.css +178 -0
  110. package/src/types.ts +3 -1
@@ -0,0 +1,32 @@
1
+ export type ProtocolClipboard = {
2
+ readonly writeText: (text: string) => Promise<void>
3
+ }
4
+
5
+ /** Failure is silent by design: the protocol stays visible and selectable. */
6
+ export async function copyProtocolToClipboard(
7
+ protocol: string,
8
+ clipboard: ProtocolClipboard | undefined,
9
+ ): Promise<boolean> {
10
+ if (clipboard === undefined) return false
11
+ try {
12
+ await clipboard.writeText(protocol)
13
+ return true
14
+ } catch {
15
+ return false
16
+ }
17
+ }
18
+
19
+ export type ProtocolCopyState = {
20
+ readonly copyCount: number
21
+ readonly isCopied: boolean
22
+ }
23
+
24
+ export type ProtocolCopyAction = { readonly type: 'copied' } | { readonly type: 'expired' }
25
+
26
+ export const INITIAL_PROTOCOL_COPY_STATE: ProtocolCopyState = { copyCount: 0, isCopied: false }
27
+
28
+ /** The counter changes on every copy so a repeat inside the window restarts the timer. */
29
+ export function protocolCopyReducer(state: ProtocolCopyState, action: ProtocolCopyAction): ProtocolCopyState {
30
+ if (action.type === 'copied') return { copyCount: state.copyCount + 1, isCopied: true }
31
+ return { ...state, isCopied: false }
32
+ }
@@ -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
+ })
@@ -0,0 +1,134 @@
1
+ import type { ParticipantMessage, ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
2
+
3
+ import type { ParticipantLocalPendingMessage, ParticipantPendingMessage } from './participantApi.types'
4
+ import type { ParticipantDraft, ParticipantSendContent } from './participantDrafts'
5
+
6
+ /** 'sent': the server accepted it; the bubble stays until the echo with the clientMessageId shows up in the messages. */
7
+ export type ParticipantSendEntryState = 'sending' | 'queued' | 'failed' | 'sent'
8
+
9
+ /** The bubble owns the content of a send: text and files live here until the server (or the host queue) reflects the id. */
10
+ export type ParticipantSendEntry = {
11
+ readonly clientMessageId: string
12
+ readonly subject: ParticipantSubjectRef
13
+ readonly text?: string
14
+ readonly files: readonly File[]
15
+ readonly createdAt: string
16
+ readonly state: ParticipantSendEntryState
17
+ readonly sentMessage?: ParticipantMessage
18
+ }
19
+
20
+ export type ParticipantSendAction =
21
+ | { readonly type: 'started'; readonly entry: ParticipantSendEntry }
22
+ | { readonly type: 'sent'; readonly clientMessageId: string; readonly message: ParticipantMessage }
23
+ | { readonly type: 'queued'; readonly clientMessageId: string }
24
+ | { readonly type: 'failed'; readonly clientMessageId: string }
25
+ | { readonly type: 'retried'; readonly clientMessageId: string }
26
+ | { readonly type: 'discarded'; readonly clientMessageId: string }
27
+ | { readonly type: 'reflected'; readonly knownClientMessageIds: ReadonlySet<string> }
28
+
29
+ export type CreateParticipantSendEntryParams = {
30
+ readonly clientMessageId: string
31
+ readonly subject: ParticipantSubjectRef
32
+ readonly content: ParticipantSendContent
33
+ readonly createdAt: string
34
+ readonly files?: readonly File[]
35
+ readonly state?: ParticipantSendEntryState
36
+ }
37
+
38
+ export type CollectKnownClientMessageIdsParams = {
39
+ readonly messages: readonly ParticipantMessage[]
40
+ readonly hostPending: readonly ParticipantPendingMessage[]
41
+ }
42
+
43
+ export function createParticipantSendEntry(params: CreateParticipantSendEntryParams): ParticipantSendEntry {
44
+ const { clientMessageId, subject, content, createdAt, state = 'sending' } = params
45
+ return {
46
+ clientMessageId,
47
+ subject,
48
+ ...(content.text ? { text: content.text } : {}),
49
+ files: params.files ?? content.files ?? [],
50
+ createdAt,
51
+ state,
52
+ }
53
+ }
54
+
55
+ function setState(
56
+ entries: readonly ParticipantSendEntry[],
57
+ clientMessageId: string,
58
+ allowedFrom: readonly ParticipantSendEntryState[],
59
+ next: ParticipantSendEntryState,
60
+ ): readonly ParticipantSendEntry[] {
61
+ const target = entries.find((item) => item.clientMessageId === clientMessageId)
62
+ if (!target || !allowedFrom.includes(target.state) || target.state === next) return entries
63
+ return entries.map((item) => (item === target ? { ...item, state: next } : item))
64
+ }
65
+
66
+ function markSent(entries: readonly ParticipantSendEntry[], clientMessageId: string, message: ParticipantMessage): readonly ParticipantSendEntry[] {
67
+ const target = entries.find((item) => item.clientMessageId === clientMessageId)
68
+ if (!target || target.state === 'sent') return entries
69
+ return entries.map((item) => (item === target ? { ...item, state: 'sent', sentMessage: message } : item))
70
+ }
71
+
72
+ function remove(entries: readonly ParticipantSendEntry[], shouldRemove: (item: ParticipantSendEntry) => boolean): readonly ParticipantSendEntry[] {
73
+ const kept = entries.filter((item) => !shouldRemove(item))
74
+ return kept.length === entries.length ? entries : kept
75
+ }
76
+
77
+ export function participantSendReducer(
78
+ entries: readonly ParticipantSendEntry[],
79
+ action: ParticipantSendAction,
80
+ ): readonly ParticipantSendEntry[] {
81
+ switch (action.type) {
82
+ case 'started':
83
+ return [...entries.filter((item) => item.clientMessageId !== action.entry.clientMessageId), action.entry]
84
+ case 'discarded':
85
+ return remove(entries, (item) => item.clientMessageId === action.clientMessageId)
86
+ case 'sent':
87
+ return markSent(entries, action.clientMessageId, action.message)
88
+ case 'queued':
89
+ return setState(entries, action.clientMessageId, ['sending'], 'queued')
90
+ case 'failed':
91
+ return setState(entries, action.clientMessageId, ['sending', 'queued'], 'failed')
92
+ case 'retried':
93
+ return setState(entries, action.clientMessageId, ['failed'], 'sending')
94
+ case 'reflected':
95
+ return remove(entries, (item) => action.knownClientMessageIds.has(item.clientMessageId))
96
+ }
97
+ }
98
+
99
+ export function findFailedEntry(entries: readonly ParticipantSendEntry[], clientMessageId: string): ParticipantSendEntry | undefined {
100
+ return entries.find((item) => item.clientMessageId === clientMessageId && item.state === 'failed')
101
+ }
102
+
103
+ export function hasSendingEntry(entries: readonly ParticipantSendEntry[]): boolean {
104
+ return entries.some((item) => item.state === 'sending')
105
+ }
106
+
107
+ export function collectKnownClientMessageIds(params: CollectKnownClientMessageIdsParams): ReadonlySet<string> {
108
+ const known = new Set<string>()
109
+ for (const message of params.messages) {
110
+ if (message.clientMessageId) known.add(message.clientMessageId)
111
+ }
112
+ for (const pending of params.hostPending) known.add(pending.clientMessageId)
113
+ return known
114
+ }
115
+
116
+ export function toLocalPending(entries: readonly ParticipantSendEntry[]): readonly ParticipantLocalPendingMessage[] {
117
+ return entries.map((item) => {
118
+ const attachments = item.files.map((file) => ({ filename: file.name, mimeType: file.type, sizeBytes: file.size }))
119
+ return {
120
+ clientMessageId: item.clientMessageId,
121
+ subject: item.subject,
122
+ createdAt: item.createdAt,
123
+ state: item.state,
124
+ ...(item.text ? { text: item.text } : {}),
125
+ ...(attachments.length > 0 ? { attachments } : {}),
126
+ }
127
+ })
128
+ }
129
+
130
+ /** Edit hands the content back to the field; whatever the user typed meanwhile stays after it. */
131
+ export function appendEntryToDraft(entry: ParticipantSendEntry, current: ParticipantDraft): ParticipantDraft {
132
+ const text = [entry.text, current.text].filter((part): part is string => Boolean(part)).join('\n')
133
+ return { text, files: [...entry.files, ...current.files] }
134
+ }
@@ -0,0 +1,31 @@
1
+ import type { ParticipantSubjectRef } from '@adatechnology/conversation-contracts'
2
+
3
+ import { draftKey } from './participantDrafts'
4
+ import { participantSendReducer, type ParticipantSendAction, type ParticipantSendEntry } from './participantSendController'
5
+
6
+ /** Entries of every subject, kept above the conversation so leaving and coming back does not lose a failed message. */
7
+ export type ParticipantSendStates = ReadonlyMap<string, readonly ParticipantSendEntry[]>
8
+
9
+ export type ParticipantSendStatesAction = {
10
+ readonly subject: ParticipantSubjectRef
11
+ readonly action: ParticipantSendAction
12
+ }
13
+
14
+ const NO_ENTRIES: readonly ParticipantSendEntry[] = []
15
+
16
+ export function selectSendEntries(states: ParticipantSendStates, subject: ParticipantSubjectRef): readonly ParticipantSendEntry[] {
17
+ return states.get(draftKey(subject)) ?? NO_ENTRIES
18
+ }
19
+
20
+ export function participantSendStatesReducer(
21
+ states: ParticipantSendStates,
22
+ { subject, action }: ParticipantSendStatesAction,
23
+ ): ParticipantSendStates {
24
+ const current = selectSendEntries(states, subject)
25
+ const next = participantSendReducer(current, action)
26
+ if (next === current) return states
27
+ const copy = new Map(states)
28
+ if (next.length === 0) copy.delete(draftKey(subject))
29
+ else copy.set(draftKey(subject), next)
30
+ return copy
31
+ }
@@ -0,0 +1,20 @@
1
+ import type { CSSProperties } from 'react'
2
+
3
+ import type { ConversationsTheme } from '../types'
4
+
5
+ const THEME_TO_VARIABLE = {
6
+ primaryColor: '--cv-p-accent',
7
+ backgroundColor: '--cv-p-surface',
8
+ textPrimary: '--cv-p-text',
9
+ textSecondary: '--cv-p-text-muted',
10
+ } as const
11
+
12
+ export function buildParticipantThemeStyle(theme: ConversationsTheme | undefined): CSSProperties | undefined {
13
+ if (!theme) return undefined
14
+ const style: Record<string, string> = {}
15
+ for (const [key, variable] of Object.entries(THEME_TO_VARIABLE)) {
16
+ const value = theme[key as keyof typeof THEME_TO_VARIABLE]
17
+ if (value) style[variable] = value
18
+ }
19
+ return Object.keys(style).length > 0 ? (style as CSSProperties) : undefined
20
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import type { ParticipantMessage } from '@adatechnology/conversation-contracts'
4
+
5
+ import { INITIAL_PARTICIPANT_THREAD_STATE, participantThreadReducer, type ParticipantThreadState } from './participantThreadState'
6
+
7
+
8
+ function message(id: string, createdAt: string, overrides: Partial<ParticipantMessage> = {}): ParticipantMessage {
9
+ return { id, direction: 'outbound', attachments: [], createdAt, ...overrides }
10
+ }
11
+ function ids(state: ParticipantThreadState): string[] {
12
+ return state.messages.map((item) => item.id)
13
+ }
14
+
15
+ describe('participantThreadReducer', () => {
16
+ it('loaded stores messages ascending and the hasMore flag', () => {
17
+ const state = participantThreadReducer(INITIAL_PARTICIPANT_THREAD_STATE, {
18
+ type: 'loaded',
19
+ messages: [message('m2', '2026-10-01T10:02:00.000Z'), message('m1', '2026-10-01T10:01:00.000Z')],
20
+ hasMore: true,
21
+ })
22
+ expect(ids(state)).toEqual(['m1', 'm2'])
23
+ expect(state).toMatchObject({ status: 'ready', hasMore: true })
24
+ })
25
+
26
+ it('olderLoaded prepends without duplicating by id', () => {
27
+ const first = participantThreadReducer(INITIAL_PARTICIPANT_THREAD_STATE, {
28
+ type: 'loaded',
29
+ messages: [message('m3', '2026-10-01T10:03:00.000Z'), message('m4', '2026-10-01T10:04:00.000Z')],
30
+ hasMore: true,
31
+ })
32
+ const next = participantThreadReducer(first, {
33
+ type: 'olderLoaded',
34
+ messages: [message('m1', '2026-10-01T10:01:00.000Z'), message('m2', '2026-10-01T10:02:00.000Z'), message('m3', '2026-10-01T10:03:00.000Z')],
35
+ hasMore: false,
36
+ })
37
+ expect(ids(next)).toEqual(['m1', 'm2', 'm3', 'm4'])
38
+ expect(next.hasMore).toBe(false)
39
+ })
40
+
41
+ it('a revalidation keeps older pages already fetched and the existing hasMore', () => {
42
+ const first = participantThreadReducer(INITIAL_PARTICIPANT_THREAD_STATE, {
43
+ type: 'loaded',
44
+ messages: [message('m2', '2026-10-01T10:02:00.000Z')],
45
+ hasMore: true,
46
+ })
47
+ const older = participantThreadReducer(first, { type: 'olderLoaded', messages: [message('m1', '2026-10-01T10:01:00.000Z')], hasMore: false })
48
+ const revalidated = participantThreadReducer(older, {
49
+ type: 'loaded',
50
+ messages: [message('m2', '2026-10-01T10:02:00.000Z', { status: 'read' }), message('m3', '2026-10-01T10:03:00.000Z')],
51
+ hasMore: true,
52
+ })
53
+ expect(ids(revalidated)).toEqual(['m1', 'm2', 'm3'])
54
+ expect(revalidated.messages[1]?.status).toBe('read')
55
+ expect(revalidated.hasMore).toBe(false)
56
+ })
57
+
58
+ it('sendConfirmed adds the server message once, ascending', () => {
59
+ const base = participantThreadReducer(INITIAL_PARTICIPANT_THREAD_STATE, {
60
+ type: 'loaded',
61
+ messages: [message('m1', '2026-10-01T10:00:00.000Z')],
62
+ hasMore: false,
63
+ })
64
+ const sent = message('m2', '2026-10-01T10:01:00.000Z', { clientMessageId: 'c1' })
65
+ const confirmed = participantThreadReducer(participantThreadReducer(base, { type: 'sendConfirmed', message: sent }), { type: 'sendConfirmed', message: sent })
66
+
67
+ expect(ids(confirmed)).toEqual(['m1', 'm2'])
68
+ })
69
+
70
+ it('reset returns to the initial state and failed records the error', () => {
71
+ const loaded = participantThreadReducer(INITIAL_PARTICIPANT_THREAD_STATE, { type: 'loaded', messages: [message('m1', '2026-10-01T10:00:00.000Z')], hasMore: false })
72
+ expect(participantThreadReducer(loaded, { type: 'reset' })).toEqual(INITIAL_PARTICIPANT_THREAD_STATE)
73
+ expect(participantThreadReducer(loaded, { type: 'failed', error: 'boom' })).toMatchObject({ status: 'error', error: 'boom' })
74
+ })
75
+ })