@servicetitan/titan-chat-ui-common 3.0.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 (75) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/index.d.ts +5 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +5 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/models/__mocks__/support-chat.mock.d.ts +9 -0
  7. package/dist/models/__mocks__/support-chat.mock.d.ts.map +1 -0
  8. package/dist/models/__mocks__/support-chat.mock.js +82 -0
  9. package/dist/models/__mocks__/support-chat.mock.js.map +1 -0
  10. package/dist/models/chat-customizations.d.ts +42 -0
  11. package/dist/models/chat-customizations.d.ts.map +1 -0
  12. package/dist/models/chat-customizations.js +2 -0
  13. package/dist/models/chat-customizations.js.map +1 -0
  14. package/dist/models/file-descriptor.d.ts +7 -0
  15. package/dist/models/file-descriptor.d.ts.map +1 -0
  16. package/dist/models/file-descriptor.js +2 -0
  17. package/dist/models/file-descriptor.js.map +1 -0
  18. package/dist/models/index.d.ts +4 -0
  19. package/dist/models/index.d.ts.map +1 -0
  20. package/dist/models/index.js +4 -0
  21. package/dist/models/index.js.map +1 -0
  22. package/dist/models/support-chat.d.ts +76 -0
  23. package/dist/models/support-chat.d.ts.map +1 -0
  24. package/dist/models/support-chat.js +75 -0
  25. package/dist/models/support-chat.js.map +1 -0
  26. package/dist/stores/__tests__/chat-ui.store.test.d.ts +2 -0
  27. package/dist/stores/__tests__/chat-ui.store.test.d.ts.map +1 -0
  28. package/dist/stores/__tests__/chat-ui.store.test.js +424 -0
  29. package/dist/stores/__tests__/chat-ui.store.test.js.map +1 -0
  30. package/dist/stores/chat-ui-backend-echo.store.d.ts +13 -0
  31. package/dist/stores/chat-ui-backend-echo.store.d.ts.map +1 -0
  32. package/dist/stores/chat-ui-backend-echo.store.js +115 -0
  33. package/dist/stores/chat-ui-backend-echo.store.js.map +1 -0
  34. package/dist/stores/chat-ui-backend.store.d.ts +6 -0
  35. package/dist/stores/chat-ui-backend.store.d.ts.map +1 -0
  36. package/dist/stores/chat-ui-backend.store.js +3 -0
  37. package/dist/stores/chat-ui-backend.store.js.map +1 -0
  38. package/dist/stores/chat-ui.store.d.ts +153 -0
  39. package/dist/stores/chat-ui.store.d.ts.map +1 -0
  40. package/dist/stores/chat-ui.store.js +683 -0
  41. package/dist/stores/chat-ui.store.js.map +1 -0
  42. package/dist/stores/index.d.ts +4 -0
  43. package/dist/stores/index.d.ts.map +1 -0
  44. package/dist/stores/index.js +4 -0
  45. package/dist/stores/index.js.map +1 -0
  46. package/dist/utils/__tests__/text-utils.test.d.ts +2 -0
  47. package/dist/utils/__tests__/text-utils.test.d.ts.map +1 -0
  48. package/dist/utils/__tests__/text-utils.test.js +59 -0
  49. package/dist/utils/__tests__/text-utils.test.js.map +1 -0
  50. package/dist/utils/test-utils.d.ts +5 -0
  51. package/dist/utils/test-utils.d.ts.map +1 -0
  52. package/dist/utils/test-utils.js +17 -0
  53. package/dist/utils/test-utils.js.map +1 -0
  54. package/dist/utils/text-utils.d.ts +6 -0
  55. package/dist/utils/text-utils.d.ts.map +1 -0
  56. package/dist/utils/text-utils.js +33 -0
  57. package/dist/utils/text-utils.js.map +1 -0
  58. package/package.json +36 -0
  59. package/src/cypress.d.ts +10 -0
  60. package/src/index.ts +4 -0
  61. package/src/models/__mocks__/support-chat.mock.ts +105 -0
  62. package/src/models/chat-customizations.ts +50 -0
  63. package/src/models/file-descriptor.ts +6 -0
  64. package/src/models/index.ts +3 -0
  65. package/src/models/support-chat.ts +117 -0
  66. package/src/stores/__tests__/chat-ui.store.test.ts +531 -0
  67. package/src/stores/chat-ui-backend-echo.store.ts +98 -0
  68. package/src/stores/chat-ui-backend.store.ts +10 -0
  69. package/src/stores/chat-ui.store.ts +539 -0
  70. package/src/stores/index.ts +11 -0
  71. package/src/utils/__tests__/text-utils.test.ts +70 -0
  72. package/src/utils/test-utils.ts +22 -0
  73. package/src/utils/text-utils.ts +36 -0
  74. package/tsconfig.json +15 -0
  75. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,531 @@
1
+ import { expect } from '@jest/globals';
2
+ import { FileDescriptor } from '@servicetitan/form';
3
+ import { Container } from '@servicetitan/react-ioc';
4
+ import {
5
+ ChatCustomizations,
6
+ ChatEndReason,
7
+ ChatError,
8
+ ChatMessageModelBase,
9
+ ChatMessageModelText,
10
+ ChatMessageModelWelcome,
11
+ ChatMessageState,
12
+ ChatParticipantIcon,
13
+ ChatRunState,
14
+ } from '../../models';
15
+ import { initTestContainer } from '../../utils/test-utils';
16
+ import { ChatUiEvent, ChatUiStore, symbolAgent, symbolUser } from '../chat-ui.store';
17
+
18
+ const mockAudio = {
19
+ Audio: {
20
+ play: jest.fn().mockResolvedValue(undefined),
21
+ },
22
+ };
23
+ const mockEventEmitter = {
24
+ on: jest.fn(),
25
+ off: jest.fn(),
26
+ emit: jest.fn(),
27
+ listeners: jest.fn(),
28
+ listenerCount: jest.fn(),
29
+ };
30
+ jest.mock('events', () => ({
31
+ EventEmitter: class {
32
+ on = mockEventEmitter.on;
33
+ off = mockEventEmitter.off;
34
+ emit = mockEventEmitter.emit;
35
+ listeners = mockEventEmitter.listeners;
36
+ listenerCount = mockEventEmitter.listenerCount;
37
+ },
38
+ }));
39
+
40
+ const initContainer = initTestContainer(ChatUiStore, () => {});
41
+
42
+ describe('[ChatUiStore]', () => {
43
+ let container: Container;
44
+ let store: ChatUiStore<ChatCustomizations>;
45
+ let audioBackup: any;
46
+
47
+ beforeEach(() => {
48
+ container = initContainer();
49
+ store = container.get(ChatUiStore);
50
+ jest.useFakeTimers();
51
+ jest.setSystemTime(Date.parse('2021-01-01T00:00:00.000Z'));
52
+ audioBackup = global.Audio;
53
+ global.Audio = jest.fn().mockImplementation(() => ({
54
+ play: mockAudio.Audio.play,
55
+ }));
56
+ });
57
+
58
+ afterEach(() => {
59
+ if (audioBackup) {
60
+ global.Audio = audioBackup;
61
+ }
62
+ jest.useRealTimers();
63
+ jest.clearAllMocks();
64
+ mockEventEmitter.on.mockClear();
65
+ mockEventEmitter.off.mockClear();
66
+ mockEventEmitter.emit.mockClear();
67
+ mockEventEmitter.listeners.mockClear();
68
+ mockEventEmitter.listenerCount.mockClear();
69
+ });
70
+
71
+ test('should initialize with default values', () => {
72
+ expect(store.isAgentTyping).toBe(false);
73
+ expect(store.status).toBe(ChatRunState.Offline);
74
+ expect(store.isFilePickerEnabled).toBe(false);
75
+ expect(store.file).toBeUndefined();
76
+ expect(store.currentFileMessage).toBeUndefined();
77
+ expect(store.error).toBeUndefined();
78
+ expect(store.endReason).toBeUndefined();
79
+ expect(store.messages).toEqual([]);
80
+ expect(store.scrollCounter).toBe(1);
81
+ expect(store.timer).toBeUndefined();
82
+ expect(store.participants[symbolAgent].isAgent).toBe(true);
83
+ expect(store.participants[symbolAgent].name).toBe('Agent');
84
+ expect(store.participants[symbolAgent].icon).toBe(ChatParticipantIcon.Initials);
85
+ expect(store.participants[symbolUser].isAgent).toBe(false);
86
+ expect(store.participants[symbolUser].name).toBe('User');
87
+ expect(store.participants[symbolUser].icon).toBe(ChatParticipantIcon.Initials);
88
+ // Getters
89
+ expect(store.agent).toBe(store.participants[symbolAgent]);
90
+ expect(store.user).toBe(store.participants[symbolUser]);
91
+ expect(store.isStarted).toBe(false);
92
+ expect(store.isError).toBe(false);
93
+ expect(store.isEnded).toBe(false);
94
+ expect(store.isStarting).toBe(false);
95
+ expect(store.customizations).toEqual({});
96
+ });
97
+
98
+ test('should subscribe and unsubscribe to events', () => {
99
+ const mockCallback = jest.fn();
100
+ store.on('testEvent', mockCallback);
101
+ expect(mockEventEmitter.on).toHaveBeenCalledWith('testEvent', mockCallback);
102
+
103
+ store.off('testEvent', mockCallback);
104
+ expect(mockEventEmitter.off).toHaveBeenCalledWith('testEvent', mockCallback);
105
+ });
106
+
107
+ test('should set customizations context', async () => {
108
+ const customizations = { abc: '123' } as ChatCustomizations;
109
+ store.setCustomizationContext(customizations);
110
+ expect(store.customizations).toEqual(customizations);
111
+ await checkScrollTriggered();
112
+ });
113
+
114
+ test('should getLastFailedMessages', () => {
115
+ store.addMessage(false, 'message1');
116
+ const m2 = store.addMessage(false, 'message2');
117
+ const m3 = store.addMessage(false, 'message3');
118
+ expect(store.getLastFailedMessages()).toEqual([]);
119
+
120
+ store.setMessageState(m3, ChatMessageState.Failed);
121
+ let failedMessages = store.getLastFailedMessages();
122
+ expect(failedMessages).toEqual([m3]);
123
+
124
+ store.setMessageState(m2, ChatMessageState.Failed);
125
+ failedMessages = store.getLastFailedMessages();
126
+ expect(failedMessages).toEqual([m3, m2]);
127
+ });
128
+
129
+ describe('with file messages', () => {
130
+ test('should set file', () => {
131
+ const file = mockFile();
132
+ store.setFile(file);
133
+ expect(store.file).toBe(file);
134
+
135
+ store.setFile(undefined);
136
+ expect(store.file).toBeUndefined();
137
+ });
138
+
139
+ test('should set file picker enabled state', async () => {
140
+ store.setFile(mockFile());
141
+ store.setFilePickerEnabled(true);
142
+ expect(store.isFilePickerEnabled).toBe(true);
143
+ expect(store.file).toBeUndefined();
144
+ await checkScrollTriggered();
145
+
146
+ store.setFile(mockFile());
147
+ store.setFilePickerEnabled(false);
148
+ expect(store.isFilePickerEnabled).toBe(false);
149
+ expect(store.file).toBeUndefined();
150
+ await checkScrollTriggered();
151
+ });
152
+ });
153
+
154
+ describe('with chat errors', () => {
155
+ test('should set error', async () => {
156
+ store.setError('Test error', {
157
+ title: 'Test error title',
158
+ recoverStrategy: { recoverButtonTitle: 'Test error button' },
159
+ data: { some: 'data' },
160
+ });
161
+ expect(store.error?.toJSON()).toEqual({
162
+ data: { some: 'data' },
163
+ message: 'Test error',
164
+ name: 'ChatError',
165
+ recoverStrategy: {
166
+ recoverButtonTitle: 'Test error button',
167
+ },
168
+ title: 'Test error title',
169
+ });
170
+ expect(store.status).toBe(ChatRunState.Error);
171
+ expect(store.isError).toBe(true);
172
+ await checkScrollTriggered();
173
+ });
174
+
175
+ test('should reset error', async () => {
176
+ store.setError('Test error');
177
+ store.resetError(ChatRunState.Started);
178
+ expect(store.error).toBeUndefined();
179
+ expect(store.status).toBe(ChatRunState.Started);
180
+ await checkScrollTriggered();
181
+ });
182
+ });
183
+
184
+ describe('with messages', () => {
185
+ function expectText(message: ChatMessageModelBase, expected: string) {
186
+ expect(message.type).toBe('message');
187
+ expect((message as ChatMessageModelText).message).toBe(expected);
188
+ }
189
+
190
+ test('should add message without sound', async () => {
191
+ store.addMessageWelcome('welcome');
192
+ await jest.runOnlyPendingTimersAsync();
193
+ expect(mockAudio.Audio.play).toHaveBeenCalledTimes(0);
194
+ });
195
+
196
+ test('should add message', async () => {
197
+ store.setIncomingSound('non-existing-sound.mp3');
198
+ store.setIncomingSound('non-existing-sound2.mp3');
199
+
200
+ // Welcome message
201
+ store.addMessageWelcome('welcome');
202
+ await jest.runOnlyPendingTimersAsync();
203
+ expect(mockAudio.Audio.play).toHaveBeenCalledTimes(1);
204
+
205
+ // Incoming message
206
+ store.addMessage(true, 'message1');
207
+ await jest.runOnlyPendingTimersAsync();
208
+ expect(mockAudio.Audio.play).toHaveBeenCalledTimes(2);
209
+
210
+ // Outgoing message
211
+ store.addMessage(false, 'message2');
212
+ await jest.runOnlyPendingTimersAsync();
213
+ expect(mockAudio.Audio.play).toHaveBeenCalledTimes(2); // No sound for outgoing messages
214
+
215
+ // File message
216
+ store.addMessageFile({
217
+ file: new File([], 'fileName'),
218
+ displayName: 'displayName',
219
+ });
220
+ await jest.runOnlyPendingTimersAsync();
221
+ expect(mockAudio.Audio.play).toHaveBeenCalledTimes(2); // No sound for outgoing messages
222
+
223
+ expect(store.messages.length).toBe(4);
224
+ expect(store.messages[0].participant.isAgent).toBe(true);
225
+ expect((store.messages[0] as ChatMessageModelWelcome).message).toBe('welcome');
226
+ expect(store.messages[1].participant.isAgent).toBe(true);
227
+ expectText(store.messages[1], 'message1');
228
+ expect(store.messages[2].participant.isAgent).toBe(false);
229
+ expectText(store.messages[2], 'message2');
230
+ expect(store.messages[3].participant.isAgent).toBe(false);
231
+ expect(store.messages[3].type).toBe('file');
232
+ });
233
+
234
+ test('should addMessageTimeout', async () => {
235
+ // Adding timeout message should not happen when no messages are present
236
+ store.addMessageTimeout();
237
+ expect(store.messages.length).toBe(0);
238
+
239
+ // Adding timeout message should not happen when no non-welcome messages are present
240
+ store.addMessageWelcome('welcome');
241
+ store.addMessageTimeout();
242
+ expect(store.messages.length).toBe(1);
243
+ expect(store.messages[0].type).toBe('welcome');
244
+
245
+ // Adding timeout message should happen when at least one message is present
246
+ store.addMessage(true, 'message1');
247
+ store.addMessageTimeout();
248
+ expect(store.messages.length).toBe(3);
249
+ expect(store.messages.at(-1)?.type).toBe('timeout');
250
+
251
+ // Adding new message should dismiss the timeout message
252
+ store.addMessage(true, 'message2');
253
+ expect(store.messages.some(message => message.type === 'timeout')).toBe(false);
254
+ await checkScrollTriggered();
255
+ });
256
+ });
257
+
258
+ test('should configure', () => {
259
+ store.configure({
260
+ agentIcon: ChatParticipantIcon.Bot,
261
+ agentName: 'Test Agent',
262
+ });
263
+ expect(store.participants[symbolAgent].icon).toBe(ChatParticipantIcon.Bot);
264
+ expect(store.participants[symbolAgent].name).toBe('Test Agent');
265
+ });
266
+
267
+ describe('with chat events', () => {
268
+ test('should run', async () => {
269
+ // Without subscription: should throw an error
270
+ mockEventEmitter.listeners.mockImplementation(() => []);
271
+ await expect(store.run()).rejects.toThrow(
272
+ "No listeners for ChatUiEvent.eventRun ('eventRun')"
273
+ );
274
+
275
+ const spyConfigure = jest.spyOn(store, 'configure');
276
+ mockEmit();
277
+
278
+ await store.run(
279
+ {
280
+ agentIcon: ChatParticipantIcon.Bot,
281
+ agentName: 'Test Agent',
282
+ },
283
+ 'chat data'
284
+ );
285
+
286
+ expect(spyConfigure).toHaveBeenCalled();
287
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
288
+ ChatUiEvent.eventRun,
289
+ expect.any(Function),
290
+ expect.any(Function),
291
+ 'chat data'
292
+ );
293
+ });
294
+
295
+ test('should recover', async () => {
296
+ mockEmit();
297
+ store.setError('Test error');
298
+ await store.recover();
299
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
300
+ ChatUiEvent.eventRecover,
301
+ expect.any(Function),
302
+ expect.any(Function),
303
+ new ChatError('Test error')
304
+ );
305
+ });
306
+
307
+ test('should destroy', async () => {
308
+ const spyReset = jest.spyOn(store, 'reset');
309
+ mockEmit();
310
+ await store.destroy();
311
+ expect(spyReset).toHaveBeenCalledWith();
312
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
313
+ ChatUiEvent.eventDestroy,
314
+ expect.any(Function),
315
+ expect.any(Function)
316
+ );
317
+ });
318
+
319
+ test('should restart', async () => {
320
+ const spyReset = jest.spyOn(store, 'reset');
321
+ mockEmit();
322
+ await store.restart();
323
+ expect(spyReset).toHaveBeenCalledWith();
324
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
325
+ ChatUiEvent.eventRestart,
326
+ expect.any(Function),
327
+ expect.any(Function)
328
+ );
329
+ });
330
+
331
+ test('should cancelChat', async () => {
332
+ mockEmit();
333
+ await store.cancelChat();
334
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
335
+ ChatUiEvent.eventCancel,
336
+ expect.any(Function),
337
+ expect.any(Function)
338
+ );
339
+ });
340
+
341
+ test('should endChat', async () => {
342
+ mockEmit();
343
+ await store.endChat(ChatEndReason.ByAgent);
344
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
345
+ ChatUiEvent.eventEndChat,
346
+ expect.any(Function),
347
+ expect.any(Function),
348
+ ChatEndReason.ByAgent
349
+ );
350
+ });
351
+
352
+ test('should chasitorTyping', async () => {
353
+ mockEmit();
354
+ await store.chasitorTyping(false);
355
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
356
+ ChatUiEvent.eventChasitorTyping,
357
+ expect.any(Function),
358
+ expect.any(Function),
359
+ false
360
+ );
361
+
362
+ await store.chasitorTyping(true);
363
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
364
+ ChatUiEvent.eventChasitorTyping,
365
+ expect.any(Function),
366
+ expect.any(Function),
367
+ true
368
+ );
369
+ });
370
+
371
+ test('should sendMessageText', async () => {
372
+ mockEmit();
373
+ await store.sendMessageText('Hello, world!');
374
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
375
+ ChatUiEvent.eventMessageSend,
376
+ expect.any(Function),
377
+ expect.any(Function),
378
+ expect.objectContaining({
379
+ message: 'Hello, world!',
380
+ type: 'message',
381
+ state: ChatMessageState.Delivering,
382
+ })
383
+ );
384
+ expect(store.messages.length).toBe(1);
385
+ expect(store.messages[0].type).toBe('message');
386
+ expect((store.messages[0] as ChatMessageModelText).message).toBe('Hello, world!');
387
+ await checkScrollTriggered();
388
+ });
389
+
390
+ test('should sendMessageText with file', async () => {
391
+ mockEmit();
392
+ const file = mockFile();
393
+ store.setFilePickerEnabled(true);
394
+ store.setFile(file);
395
+ await store.sendMessageText('Hello, world!');
396
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
397
+ ChatUiEvent.eventMessageSendFile,
398
+ expect.any(Function),
399
+ expect.any(Function),
400
+ store.currentFileMessage,
401
+ store.file,
402
+ expect.objectContaining({
403
+ message: 'Hello, world!',
404
+ type: 'message',
405
+ state: ChatMessageState.Delivering,
406
+ })
407
+ );
408
+ expect(store.messages.length).toBe(2);
409
+ expect(store.messages[0].type).toBe('file');
410
+ expect(store.messages[1].type).toBe('message');
411
+ expect((store.messages[1] as ChatMessageModelText).message).toBe('Hello, world!');
412
+ expect(store.currentFileMessage).toBeDefined();
413
+ await checkScrollTriggered();
414
+ });
415
+
416
+ test('should sendMessageText empty', async () => {
417
+ mockEmit();
418
+ await store.sendMessageText(' ');
419
+ expect(mockEventEmitter.emit).not.toHaveBeenCalled();
420
+ });
421
+
422
+ test('should sendMessageRetry', async () => {
423
+ mockEmit();
424
+
425
+ store.setError('Test error');
426
+ const message = store.addMessage(true, 'message1');
427
+ store.setMessageState(message, ChatMessageState.Failed);
428
+
429
+ const p = store.sendMessageRetry(message);
430
+
431
+ expect(message.state).toBe(ChatMessageState.Delivering);
432
+ await p;
433
+ expect(store.isError).toBe(false);
434
+ expect(store.status).toBe(ChatRunState.Started);
435
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
436
+ ChatUiEvent.eventMessageSendRetry,
437
+ expect.any(Function),
438
+ expect.any(Function),
439
+ message
440
+ );
441
+ expect(message.state).toBe(ChatMessageState.Delivering);
442
+ await checkScrollTriggered();
443
+ });
444
+
445
+ test('should sendMessageFileRetry', async () => {
446
+ mockEmit();
447
+
448
+ store.setError('Test error');
449
+ const fileMessage = store.addMessageFile({
450
+ file: new File([], 'fileName'),
451
+ displayName: 'displayName',
452
+ });
453
+ store.setMessageState(fileMessage, ChatMessageState.Failed);
454
+
455
+ const p = store.sendMessageFileRetry(fileMessage);
456
+
457
+ expect(fileMessage.state).toBe(ChatMessageState.Delivering);
458
+ await p;
459
+ expect(store.isError).toBe(false);
460
+ expect(store.status).toBe(ChatRunState.Started);
461
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
462
+ ChatUiEvent.eventMessageSendFileRetry,
463
+ expect.any(Function),
464
+ expect.any(Function),
465
+ fileMessage
466
+ );
467
+ expect(fileMessage.state).toBe(ChatMessageState.Delivering);
468
+ await checkScrollTriggered();
469
+ });
470
+ });
471
+
472
+ test('should setTimer', () => {
473
+ store.setTimer({ secondsLeft: 10, secondsTotal: 20 });
474
+ expect(store.timer).toEqual({
475
+ secondsLeft: 10,
476
+ secondsTotal: 20,
477
+ });
478
+ });
479
+
480
+ test('should setAgentTyping', async () => {
481
+ store.setAgentTyping(true);
482
+ expect(store.isAgentTyping).toBe(true);
483
+ await checkScrollTriggered();
484
+
485
+ store.setAgentTyping(false);
486
+ expect(store.isAgentTyping).toBe(false);
487
+ await checkScrollTriggered();
488
+ });
489
+
490
+ test('should setEndReason', async () => {
491
+ store.setEndReason(ChatEndReason.ByTimeout);
492
+ expect(store.endReason).toBe(ChatEndReason.ByTimeout);
493
+ await checkScrollTriggered();
494
+ });
495
+
496
+ test('should setEndedChatState', async () => {
497
+ store.isFilePickerEnabled = true;
498
+ store.setFile(mockFile());
499
+ store.setEndedChatState(ChatEndReason.ByTimeout);
500
+ expect(store.endReason).toBe(ChatEndReason.ByTimeout);
501
+ expect(store.status).toBe(ChatRunState.Ended);
502
+ expect(store.isAgentTyping).toBe(false);
503
+ expect(store.currentFileMessage).toBeUndefined();
504
+ expect(store.file).toBeUndefined();
505
+ expect(store.isFilePickerEnabled).toBe(false);
506
+ await checkScrollTriggered();
507
+ });
508
+
509
+ function mockFile() {
510
+ const file: FileDescriptor = {
511
+ file: new File(['content'], 'file.txt', { type: 'text/plain' }),
512
+ displayName: 'file.txt',
513
+ };
514
+ return file;
515
+ }
516
+
517
+ function mockEmit() {
518
+ mockEventEmitter.listeners.mockImplementation(() => ['1']);
519
+ mockEventEmitter.listenerCount.mockReturnValue(1);
520
+ mockEventEmitter.emit.mockImplementation(async (eventName: string, resolve: () => void) => {
521
+ await Promise.resolve();
522
+ resolve();
523
+ });
524
+ }
525
+
526
+ async function checkScrollTriggered() {
527
+ const counter = store.scrollCounter;
528
+ await jest.runOnlyPendingTimersAsync();
529
+ expect(counter < store.scrollCounter).toBe(true);
530
+ }
531
+ });
@@ -0,0 +1,98 @@
1
+ import { inject, injectable } from '@servicetitan/react-ioc';
2
+ import {
3
+ ChatMessageModelBase,
4
+ ChatMessageModelText,
5
+ ChatMessageState,
6
+ ChatRunState,
7
+ } from '../models';
8
+ import { IChatUiBackendStore } from './chat-ui-backend.store';
9
+ import {
10
+ CHAT_UI_STORE_TOKEN,
11
+ ChatUiEvent,
12
+ ChatUiEventListener,
13
+ IChatUiStore,
14
+ } from './chat-ui.store';
15
+
16
+ @injectable()
17
+ export class ChatUiBackendEchoStore implements IChatUiBackendStore {
18
+ constructor(@inject(CHAT_UI_STORE_TOKEN) readonly chatUiStore: IChatUiStore) {}
19
+
20
+ subscribe() {
21
+ this.chatUiStore.on(ChatUiEvent.eventRun, this.handleRun);
22
+ this.chatUiStore.on(ChatUiEvent.eventMessageSend, this.handleTextMessageSend);
23
+ this.chatUiStore.on(ChatUiEvent.eventMessageSendRetry, this.handleMessageSendRetry);
24
+ }
25
+
26
+ unsubscribe() {
27
+ this.chatUiStore.off(ChatUiEvent.eventRun, this.handleRun);
28
+ this.chatUiStore.off(ChatUiEvent.eventMessageSend, this.handleTextMessageSend);
29
+ this.chatUiStore.off(ChatUiEvent.eventMessageSendRetry, this.handleMessageSendRetry);
30
+ }
31
+
32
+ private handleRun: ChatUiEventListener = async (resolve, _) => {
33
+ this.chatUiStore.setStatus(ChatRunState.Initializing);
34
+ await new Promise(resolve => setTimeout(resolve, 1000));
35
+ this.chatUiStore.addMessageWelcome(
36
+ "Hello! I'm generic echo bot. I can echo your messages. Try it out!"
37
+ );
38
+ this.chatUiStore.setStatus(ChatRunState.Started);
39
+ resolve();
40
+ };
41
+
42
+ private handleTextMessageSend: ChatUiEventListener = async (
43
+ resolve,
44
+ _,
45
+ message: ChatMessageModelText
46
+ ) => {
47
+ const isErrorSimulated = message.message.startsWith('[Error]');
48
+ try {
49
+ this.chatUiStore.setAgentTyping(true);
50
+ await new Promise(resolve => setTimeout(resolve, 1000));
51
+ if (isErrorSimulated) {
52
+ throw new Error(message.message.split('[Error]')[1]);
53
+ }
54
+ this.chatUiStore.setMessageState(message, ChatMessageState.Delivered);
55
+ this.chatUiStore.addMessage(true, this.getEchoAnswer(message));
56
+ this.chatUiStore.resetError(ChatRunState.Started);
57
+ resolve();
58
+ } catch (error: any) {
59
+ this.chatUiStore.setMessageState(message, ChatMessageState.Failed);
60
+ this.chatUiStore.setError(error?.message ?? error, {
61
+ title: 'Custom Error Title',
62
+ });
63
+ } finally {
64
+ this.chatUiStore.setAgentTyping(false);
65
+ }
66
+ };
67
+
68
+ private handleMessageSendRetry: ChatUiEventListener = async (
69
+ resolve,
70
+ _,
71
+ message: ChatMessageModelBase
72
+ ) => {
73
+ try {
74
+ this.chatUiStore.setMessageState(message, ChatMessageState.Delivering);
75
+ await new Promise(resolve => setTimeout(resolve, 1000));
76
+ this.chatUiStore.setMessageState(message, ChatMessageState.Delivered);
77
+ this.chatUiStore.addMessage(true, this.getEchoAnswer(message));
78
+ this.chatUiStore.resetError(ChatRunState.Started);
79
+ resolve();
80
+ } catch (error: any) {
81
+ this.chatUiStore.setMessageState(message, ChatMessageState.Failed);
82
+ this.chatUiStore.setError(error?.message ?? error, {
83
+ title: 'Custom Error Title',
84
+ });
85
+ }
86
+ };
87
+
88
+ private getEchoAnswer(message: ChatMessageModelBase) {
89
+ if (message.type === 'message') {
90
+ let messageText = (message as ChatMessageModelText).message;
91
+ if (messageText.startsWith('[Error]')) {
92
+ messageText = messageText.split('[Error]')[1];
93
+ }
94
+ return `Echo: ${messageText}`;
95
+ }
96
+ return `Echo bot cannot answer to message type ${message.type} yet`;
97
+ }
98
+ }
@@ -0,0 +1,10 @@
1
+ import { symbolToken } from '@servicetitan/react-ioc';
2
+
3
+ export const CHAT_UI_BACKEND_STORE_TOKEN = symbolToken<IChatUiBackendStore>(
4
+ 'CHAT_UI_BACKEND_STORE_TOKEN'
5
+ );
6
+
7
+ export interface IChatUiBackendStore {
8
+ subscribe(): void;
9
+ unsubscribe(): void;
10
+ }