@memori.ai/memori-react 8.17.3 → 8.18.1

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 (117) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -0
  3. package/dist/components/Chat/Chat.d.ts +1 -0
  4. package/dist/components/Chat/Chat.js +2 -2
  5. package/dist/components/Chat/Chat.js.map +1 -1
  6. package/dist/components/ChatBubble/ChatBubble.js +2 -2
  7. package/dist/components/ChatBubble/ChatBubble.js.map +1 -1
  8. package/dist/components/ChatInputs/ChatInputs.css +9 -3
  9. package/dist/components/ChatInputs/ChatInputs.d.ts +1 -0
  10. package/dist/components/ChatInputs/ChatInputs.js +69 -3
  11. package/dist/components/ChatInputs/ChatInputs.js.map +1 -1
  12. package/dist/components/ChatTextArea/ChatTextArea.d.ts +1 -0
  13. package/dist/components/ChatTextArea/ChatTextArea.js +2 -2
  14. package/dist/components/ChatTextArea/ChatTextArea.js.map +1 -1
  15. package/dist/components/ContentPreviewModal/ContentPreviewModal.css +15 -0
  16. package/dist/components/FilePreview/FilePreview.css +17 -9
  17. package/dist/components/FilePreview/FilePreview.js +1 -2
  18. package/dist/components/FilePreview/FilePreview.js.map +1 -1
  19. package/dist/components/MediaWidget/MediaItemWidget.js +7 -1
  20. package/dist/components/MediaWidget/MediaItemWidget.js.map +1 -1
  21. package/dist/components/MemoriWidget/MemoriWidget.d.ts +2 -1
  22. package/dist/components/MemoriWidget/MemoriWidget.js +2 -1
  23. package/dist/components/MemoriWidget/MemoriWidget.js.map +1 -1
  24. package/dist/components/UploadButton/UploadButton.d.ts +1 -0
  25. package/dist/components/UploadButton/UploadButton.js +50 -24
  26. package/dist/components/UploadButton/UploadButton.js.map +1 -1
  27. package/dist/components/UploadButton/UploadDocuments/UploadDocuments.d.ts +5 -1
  28. package/dist/components/UploadButton/UploadDocuments/UploadDocuments.js +76 -21
  29. package/dist/components/UploadButton/UploadDocuments/UploadDocuments.js.map +1 -1
  30. package/dist/components/UploadButton/UploadImages/UploadImages.js +23 -4
  31. package/dist/components/UploadButton/UploadImages/UploadImages.js.map +1 -1
  32. package/dist/components/layouts/fullpage.css +1 -0
  33. package/dist/helpers/constants.d.ts +3 -1
  34. package/dist/helpers/constants.js +4 -2
  35. package/dist/helpers/constants.js.map +1 -1
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.js +3 -2
  38. package/dist/index.js.map +1 -1
  39. package/dist/locales/de.json +12 -1
  40. package/dist/locales/en.json +15 -1
  41. package/dist/locales/es.json +12 -1
  42. package/dist/locales/fr.json +12 -1
  43. package/dist/locales/it.json +15 -1
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/esm/components/Chat/Chat.d.ts +1 -0
  47. package/esm/components/Chat/Chat.js +2 -2
  48. package/esm/components/Chat/Chat.js.map +1 -1
  49. package/esm/components/ChatBubble/ChatBubble.js +2 -2
  50. package/esm/components/ChatBubble/ChatBubble.js.map +1 -1
  51. package/esm/components/ChatInputs/ChatInputs.css +9 -3
  52. package/esm/components/ChatInputs/ChatInputs.d.ts +1 -0
  53. package/esm/components/ChatInputs/ChatInputs.js +70 -4
  54. package/esm/components/ChatInputs/ChatInputs.js.map +1 -1
  55. package/esm/components/ChatTextArea/ChatTextArea.d.ts +1 -0
  56. package/esm/components/ChatTextArea/ChatTextArea.js +2 -2
  57. package/esm/components/ChatTextArea/ChatTextArea.js.map +1 -1
  58. package/esm/components/ContentPreviewModal/ContentPreviewModal.css +15 -0
  59. package/esm/components/FilePreview/FilePreview.css +17 -9
  60. package/esm/components/FilePreview/FilePreview.js +1 -2
  61. package/esm/components/FilePreview/FilePreview.js.map +1 -1
  62. package/esm/components/MediaWidget/MediaItemWidget.js +7 -1
  63. package/esm/components/MediaWidget/MediaItemWidget.js.map +1 -1
  64. package/esm/components/MemoriWidget/MemoriWidget.d.ts +2 -1
  65. package/esm/components/MemoriWidget/MemoriWidget.js +2 -1
  66. package/esm/components/MemoriWidget/MemoriWidget.js.map +1 -1
  67. package/esm/components/UploadButton/UploadButton.d.ts +1 -0
  68. package/esm/components/UploadButton/UploadButton.js +50 -24
  69. package/esm/components/UploadButton/UploadButton.js.map +1 -1
  70. package/esm/components/UploadButton/UploadDocuments/UploadDocuments.d.ts +5 -1
  71. package/esm/components/UploadButton/UploadDocuments/UploadDocuments.js +77 -22
  72. package/esm/components/UploadButton/UploadDocuments/UploadDocuments.js.map +1 -1
  73. package/esm/components/UploadButton/UploadImages/UploadImages.js +23 -4
  74. package/esm/components/UploadButton/UploadImages/UploadImages.js.map +1 -1
  75. package/esm/components/layouts/fullpage.css +1 -0
  76. package/esm/helpers/constants.d.ts +3 -1
  77. package/esm/helpers/constants.js +3 -1
  78. package/esm/helpers/constants.js.map +1 -1
  79. package/esm/index.d.ts +1 -0
  80. package/esm/index.js +3 -2
  81. package/esm/index.js.map +1 -1
  82. package/esm/locales/de.json +12 -1
  83. package/esm/locales/en.json +15 -1
  84. package/esm/locales/es.json +12 -1
  85. package/esm/locales/fr.json +12 -1
  86. package/esm/locales/it.json +15 -1
  87. package/esm/version.d.ts +1 -1
  88. package/esm/version.js +1 -1
  89. package/package.json +1 -1
  90. package/src/__snapshots__/index.test.tsx.snap +5 -5
  91. package/src/components/Chat/Chat.tsx +4 -0
  92. package/src/components/Chat/__snapshots__/Chat.test.tsx.snap +27 -81
  93. package/src/components/ChatBubble/ChatBubble.tsx +2 -2
  94. package/src/components/ChatBubble/__snapshots__/ChatBubble.test.tsx.snap +7 -21
  95. package/src/components/ChatInputs/ChatInputs.css +9 -3
  96. package/src/components/ChatInputs/ChatInputs.test.tsx +328 -1
  97. package/src/components/ChatInputs/ChatInputs.tsx +130 -8
  98. package/src/components/ChatTextArea/ChatTextArea.tsx +3 -0
  99. package/src/components/ContentPreviewModal/ContentPreviewModal.css +15 -0
  100. package/src/components/FilePreview/FilePreview.css +17 -9
  101. package/src/components/FilePreview/FilePreview.tsx +1 -7
  102. package/src/components/FilePreview/__snapshots__/FilePreview.test.tsx.snap +3 -3
  103. package/src/components/MediaWidget/MediaItemWidget.tsx +20 -2
  104. package/src/components/MemoriWidget/MemoriWidget.tsx +4 -0
  105. package/src/components/UploadButton/UploadButton.tsx +58 -31
  106. package/src/components/UploadButton/UploadDocuments/UploadDocuments.tsx +98 -30
  107. package/src/components/UploadButton/UploadImages/UploadImages.tsx +26 -5
  108. package/src/components/layouts/layouts.stories.tsx +1 -31
  109. package/src/helpers/constants.ts +16 -4
  110. package/src/index.stories.tsx +3 -2
  111. package/src/index.tsx +5 -0
  112. package/src/locales/de.json +12 -1
  113. package/src/locales/en.json +15 -1
  114. package/src/locales/es.json +12 -1
  115. package/src/locales/fr.json +12 -1
  116. package/src/locales/it.json +15 -1
  117. package/src/version.ts +1 -1
@@ -1,7 +1,69 @@
1
1
  import React from 'react';
2
- import { render } from '@testing-library/react';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
3
  import ChatInputs from './ChatInputs';
4
4
  import { dialogState } from '../../mocks/data';
5
+ import {
6
+ PASTE_AS_CARD_CHAR_THRESHOLD,
7
+ PASTE_AS_CARD_LINE_THRESHOLD,
8
+ } from '../../helpers/constants';
9
+
10
+ jest.mock('react-hot-toast', () => ({
11
+ __esModule: true,
12
+ default: {
13
+ success: jest.fn(),
14
+ error: jest.fn(),
15
+ },
16
+ }));
17
+
18
+ // jsdom does not define DataTransfer; UploadButton's paste handler uses it when clipboard has files
19
+ if (typeof globalThis.DataTransfer === 'undefined') {
20
+ (globalThis as any).DataTransfer = class DataTransfer {
21
+ _files: File[] = [];
22
+ get files(): File[] {
23
+ return this._files;
24
+ }
25
+ items = {
26
+ add: (file: File) => {
27
+ this._files.push(file);
28
+ },
29
+ };
30
+ };
31
+ }
32
+
33
+ // jsdom does not define speechSynthesis/SpeechSynthesisUtterance; ChatInputs uses them in onSendMessage
34
+ if (typeof (globalThis as any).speechSynthesis === 'undefined') {
35
+ (globalThis as any).speechSynthesis = { speak: jest.fn() };
36
+ }
37
+ if (typeof (globalThis as any).SpeechSynthesisUtterance === 'undefined') {
38
+ (globalThis as any).SpeechSynthesisUtterance = function () {};
39
+ }
40
+
41
+ const defaultProps = {
42
+ dialogState,
43
+ userMessage: '',
44
+ onChangeUserMessage: jest.fn(),
45
+ sendMessage: jest.fn(),
46
+ onTextareaFocus: jest.fn(),
47
+ onTextareaBlur: jest.fn(),
48
+ setAttachmentsMenuOpen: jest.fn(),
49
+ setSendOnEnter: jest.fn(),
50
+ listening: false,
51
+ isPlayingAudio: false,
52
+ stopAudio: jest.fn(),
53
+ startListening: jest.fn(),
54
+ stopListening: jest.fn(),
55
+ showMicrophone: true,
56
+ };
57
+
58
+ function createPasteEvent(plainText: string, files: File[] = []) {
59
+ return {
60
+ clipboardData: {
61
+ files,
62
+ getData: (type: string) => (type === 'text/plain' ? plainText : ''),
63
+ },
64
+ preventDefault: jest.fn(),
65
+ };
66
+ }
5
67
 
6
68
  it('renders ChatInputs unchanged', () => {
7
69
  const { container } = render(
@@ -141,3 +203,268 @@ it('renders ChatInputs disabled unchanged', () => {
141
203
  );
142
204
  expect(container).toMatchSnapshot();
143
205
  });
206
+
207
+ describe('paste as card (long text becomes attachment)', () => {
208
+ beforeEach(() => {
209
+ jest.clearAllMocks();
210
+ });
211
+
212
+ it('adds pasted long text (by chars) as attachment when showUpload is true', async () => {
213
+ const longText = 'x'.repeat(PASTE_AS_CARD_CHAR_THRESHOLD + 1);
214
+ render(
215
+ <ChatInputs
216
+ {...defaultProps}
217
+ showUpload={true}
218
+ dialogState={{ ...dialogState, acceptsMedia: true }}
219
+ />
220
+ );
221
+ const textarea = document.querySelector('textarea');
222
+ expect(textarea).toBeTruthy();
223
+
224
+ const paste = createPasteEvent(longText);
225
+ fireEvent.paste(textarea!, paste);
226
+
227
+ await waitFor(() => {
228
+ expect(screen.getByText('upload.pastedText')).toBeTruthy();
229
+ });
230
+ });
231
+
232
+ it('adds pasted long text (by lines) as attachment when showUpload is true', async () => {
233
+ const lines = Array(PASTE_AS_CARD_LINE_THRESHOLD + 1)
234
+ .fill('line')
235
+ .join('\n');
236
+ render(
237
+ <ChatInputs
238
+ {...defaultProps}
239
+ showUpload={true}
240
+ dialogState={{ ...dialogState, acceptsMedia: true }}
241
+ />
242
+ );
243
+ const textarea = document.querySelector('textarea');
244
+ const paste = createPasteEvent(lines);
245
+ fireEvent.paste(textarea!, paste);
246
+
247
+ await waitFor(() => {
248
+ expect(screen.getByText('upload.pastedText')).toBeTruthy();
249
+ });
250
+ });
251
+
252
+ it('adds card for short paste (all paste treated as document)', async () => {
253
+ const shortText = 'hello';
254
+ render(
255
+ <ChatInputs
256
+ {...defaultProps}
257
+ showUpload={true}
258
+ dialogState={{ ...dialogState, acceptsMedia: true }}
259
+ />
260
+ );
261
+ const textarea = document.querySelector('textarea');
262
+ const paste = createPasteEvent(shortText);
263
+ fireEvent.paste(textarea!, paste);
264
+
265
+ await waitFor(() => {
266
+ expect(screen.getByText('upload.pastedText')).toBeTruthy();
267
+ });
268
+ });
269
+
270
+ it('adds card even when showUpload is false (paste-as-card always enabled)', async () => {
271
+ const longText = 'x'.repeat(PASTE_AS_CARD_CHAR_THRESHOLD + 1);
272
+ render(
273
+ <ChatInputs
274
+ {...defaultProps}
275
+ showUpload={false}
276
+ />
277
+ );
278
+ const textarea = document.querySelector('textarea');
279
+ fireEvent.paste(textarea!, createPasteEvent(longText));
280
+
281
+ await waitFor(() => {
282
+ expect(screen.getByText('upload.pastedText')).toBeTruthy();
283
+ });
284
+ });
285
+
286
+ it('calls toast.error when max attachments reached', async () => {
287
+ const { MAX_DOCUMENTS_PER_MESSAGE } = require('../../helpers/constants');
288
+ const longText = 'x'.repeat(PASTE_AS_CARD_CHAR_THRESHOLD + 1);
289
+ render(
290
+ <ChatInputs
291
+ {...defaultProps}
292
+ showUpload={true}
293
+ dialogState={{ ...dialogState, acceptsMedia: true }}
294
+ />
295
+ );
296
+ const textarea = document.querySelector('textarea');
297
+ for (let i = 0; i < MAX_DOCUMENTS_PER_MESSAGE; i++) {
298
+ fireEvent.paste(textarea!, createPasteEvent(longText + i));
299
+ await waitFor(() => {
300
+ expect(document.querySelectorAll('.memori--preview-filename').length).toBe(i + 1);
301
+ });
302
+ }
303
+
304
+ fireEvent.paste(textarea!, createPasteEvent(longText + ' over'));
305
+
306
+ await waitFor(() => {
307
+ const toast = require('react-hot-toast').default;
308
+ expect(toast.error).toHaveBeenCalled();
309
+ });
310
+ expect(document.querySelectorAll('.memori--preview-filename').length).toBe(
311
+ MAX_DOCUMENTS_PER_MESSAGE
312
+ );
313
+ });
314
+
315
+ it('calls toast.error when pasted content exceeds size limit', async () => {
316
+ const { MAX_DOCUMENT_CONTENT_LENGTH } = require('../../helpers/constants');
317
+ const tooLongText = 'x'.repeat(MAX_DOCUMENT_CONTENT_LENGTH + 1);
318
+ render(
319
+ <ChatInputs
320
+ {...defaultProps}
321
+ showUpload={true}
322
+ dialogState={{ ...dialogState, acceptsMedia: true }}
323
+ />
324
+ );
325
+ const textarea = document.querySelector('textarea');
326
+ const paste = createPasteEvent(tooLongText);
327
+ fireEvent.paste(textarea!, paste);
328
+
329
+ await waitFor(() => {
330
+ const toast = require('react-hot-toast').default;
331
+ expect(toast.error).toHaveBeenCalled();
332
+ });
333
+ expect(screen.queryByText('upload.pastedText')).toBeNull();
334
+ });
335
+
336
+ it('adds card when paste has few lines', async () => {
337
+ const fewLines = Array(5).fill('line').join('\n');
338
+ render(
339
+ <ChatInputs
340
+ {...defaultProps}
341
+ showUpload={true}
342
+ dialogState={{ ...dialogState, acceptsMedia: true }}
343
+ />
344
+ );
345
+ const textarea = document.querySelector('textarea');
346
+ fireEvent.paste(textarea!, createPasteEvent(fewLines));
347
+ await waitFor(() => {
348
+ expect(screen.getByText('upload.pastedText')).toBeTruthy();
349
+ });
350
+ });
351
+
352
+ it('adds card when paste has few chars', async () => {
353
+ const fewChars = 'hi';
354
+ render(
355
+ <ChatInputs
356
+ {...defaultProps}
357
+ showUpload={true}
358
+ dialogState={{ ...dialogState, acceptsMedia: true }}
359
+ />
360
+ );
361
+ const textarea = document.querySelector('textarea');
362
+ fireEvent.paste(textarea!, createPasteEvent(fewChars));
363
+ await waitFor(() => {
364
+ expect(screen.getByText('upload.pastedText')).toBeTruthy();
365
+ });
366
+ });
367
+
368
+ it('does not add card when clipboard has files (handler returns early)', () => {
369
+ const longText = 'x'.repeat(PASTE_AS_CARD_CHAR_THRESHOLD + 1);
370
+ const file = new File(['a'], 'a.txt', { type: 'text/plain' });
371
+ const paste = createPasteEvent(longText, [file]);
372
+ render(
373
+ <ChatInputs
374
+ {...defaultProps}
375
+ showUpload={true}
376
+ dialogState={{ ...dialogState, acceptsMedia: true }}
377
+ />
378
+ );
379
+ const textarea = document.querySelector('textarea');
380
+ fireEvent.paste(textarea!, paste);
381
+ expect(screen.queryByText('upload.pastedText')).toBeNull();
382
+ });
383
+
384
+ it('does not add card for empty or whitespace-only paste', () => {
385
+ render(
386
+ <ChatInputs
387
+ {...defaultProps}
388
+ showUpload={true}
389
+ dialogState={{ ...dialogState, acceptsMedia: true }}
390
+ />
391
+ );
392
+ const textarea = document.querySelector('textarea');
393
+ fireEvent.paste(textarea!, createPasteEvent(''));
394
+ fireEvent.paste(textarea!, createPasteEvent(' \n\t '));
395
+ expect(screen.queryByText('upload.pastedText')).toBeNull();
396
+ });
397
+
398
+ it('adds two cards when same long text is pasted twice (duplicate paste)', async () => {
399
+ const longText = 'x'.repeat(PASTE_AS_CARD_CHAR_THRESHOLD + 1);
400
+ render(
401
+ <ChatInputs
402
+ {...defaultProps}
403
+ showUpload={true}
404
+ dialogState={{ ...dialogState, acceptsMedia: true }}
405
+ />
406
+ );
407
+ const textarea = document.querySelector('textarea');
408
+ fireEvent.paste(textarea!, createPasteEvent(longText));
409
+ await waitFor(() => {
410
+ expect(screen.getByText('upload.pastedText')).toBeTruthy();
411
+ });
412
+ fireEvent.paste(textarea!, createPasteEvent(longText));
413
+ await waitFor(() => {
414
+ expect(document.querySelectorAll('.memori--preview-filename').length).toBe(2);
415
+ });
416
+ });
417
+
418
+ it('shows FilePreview when showUpload is false but pasted card was added', async () => {
419
+ const longText = 'x'.repeat(PASTE_AS_CARD_CHAR_THRESHOLD + 1);
420
+ render(
421
+ <ChatInputs
422
+ {...defaultProps}
423
+ showUpload={false}
424
+ />
425
+ );
426
+ expect(document.querySelector('.memori--preview-container')).toBeNull();
427
+ const textarea = document.querySelector('textarea');
428
+ fireEvent.paste(textarea!, createPasteEvent(longText));
429
+ await waitFor(() => {
430
+ expect(screen.getByText('upload.pastedText')).toBeTruthy();
431
+ });
432
+ expect(document.querySelector('.memori--preview-container')).toBeTruthy();
433
+ });
434
+
435
+ it('calls sendMessage with pasted card in media when user sends', async () => {
436
+ const sendMessageMock = jest.fn();
437
+ const pastedText = 'pasted content here';
438
+ const { container } = render(
439
+ <ChatInputs
440
+ {...defaultProps}
441
+ showUpload={true}
442
+ userMessage="hello"
443
+ sendMessage={sendMessageMock}
444
+ dialogState={{ ...dialogState, acceptsMedia: true }}
445
+ />
446
+ );
447
+ const textarea = document.querySelector('textarea');
448
+ fireEvent.paste(textarea!, createPasteEvent(pastedText));
449
+ await waitFor(() => {
450
+ expect(screen.getByText('upload.pastedText')).toBeTruthy();
451
+ });
452
+ const sendButton = container.querySelector('.memori-chat-inputs--send-btn');
453
+ expect(sendButton).toBeTruthy();
454
+ fireEvent.click(sendButton!);
455
+
456
+ expect(sendMessageMock).toHaveBeenCalledTimes(1);
457
+ const [, media] = sendMessageMock.mock.calls[0];
458
+ expect(media).toHaveLength(1);
459
+ expect(media![0]).toMatchObject({
460
+ mimeType: 'text/plain',
461
+ title: 'upload.pastedText',
462
+ type: 'document',
463
+ properties: expect.objectContaining({ isAttachedFile: true }),
464
+ });
465
+ expect(media![0].mediumID).toBeDefined();
466
+ expect(media![0].content).toContain('<document_attachment');
467
+ expect(media![0].content).toContain('</document_attachment>');
468
+ expect(media![0].content).toContain(pastedText);
469
+ });
470
+ });
@@ -1,8 +1,9 @@
1
- import React, { useState } from 'react';
1
+ import React, { useCallback, useState } from 'react';
2
2
  import { DialogState, Medium } from '@memori.ai/memori-api-client/dist/types';
3
+ import { useTranslation } from 'react-i18next';
4
+ import toast from 'react-hot-toast';
3
5
  import ChatTextArea from '../ChatTextArea/ChatTextArea';
4
6
  import Button from '../ui/Button';
5
- import { useTranslation } from 'react-i18next';
6
7
  import Send from '../icons/Send';
7
8
  import MicrophoneButton from '../MicrophoneButton/MicrophoneButton';
8
9
  import cx from 'classnames';
@@ -11,6 +12,11 @@ import UploadButton from '../UploadButton/UploadButton';
11
12
  import FilePreview from '../FilePreview/FilePreview';
12
13
  import memoriApiClient from '@memori.ai/memori-api-client';
13
14
  import Plus from '../icons/Plus';
15
+ import {
16
+ MAX_DOCUMENTS_PER_MESSAGE,
17
+ MAX_DOCUMENT_CONTENT_LENGTH,
18
+ MAX_TOTAL_MESSAGE_PAYLOAD,
19
+ } from '../../helpers/constants';
14
20
  export interface Props {
15
21
  dialogState?: DialogState;
16
22
  instruct?: boolean;
@@ -37,6 +43,8 @@ export interface Props {
37
43
  memoriID?: string;
38
44
  client?: ReturnType<typeof memoriApiClient>;
39
45
  onTextareaExpanded?: (expanded: boolean) => void;
46
+ /** Override total document payload limit (character count). */
47
+ maxTotalMessagePayload?: number;
40
48
  }
41
49
 
42
50
  const ChatInputs: React.FC<Props> = ({
@@ -60,6 +68,7 @@ const ChatInputs: React.FC<Props> = ({
60
68
  memoriID,
61
69
  client,
62
70
  onTextareaExpanded,
71
+ maxTotalMessagePayload,
63
72
  }) => {
64
73
  const { t } = useTranslation();
65
74
 
@@ -84,6 +93,8 @@ const ChatInputs: React.FC<Props> = ({
84
93
  dialog: { postMediumDeselectedEvent: null },
85
94
  };
86
95
 
96
+ const totalPayloadLimit = maxTotalMessagePayload ?? MAX_TOTAL_MESSAGE_PAYLOAD;
97
+
87
98
  /**
88
99
  * Handles sending a message, including any attached files
89
100
  */
@@ -100,6 +111,17 @@ const ChatInputs: React.FC<Props> = ({
100
111
  ) => {
101
112
  if (isTyping) return;
102
113
 
114
+ const totalContentLength = files.reduce((sum, f) => sum + f.content.length, 0);
115
+ if (totalContentLength > totalPayloadLimit) {
116
+ toast.error(
117
+ t('upload.contextSizeExceedsLimit', {
118
+ defaultValue:
119
+ 'Context size exceeds the limit. Try reducing the number of files or content in the conversation.',
120
+ })
121
+ );
122
+ return;
123
+ }
124
+
103
125
  const mediaWithIds = files.map((file, index) => {
104
126
  const generatedMediumID =
105
127
  file.mediumID ||
@@ -142,6 +164,19 @@ const ChatInputs: React.FC<Props> = ({
142
164
 
143
165
  if (sendOnEnter === 'keypress' && userMessage?.length > 0) {
144
166
  stopListening();
167
+ const totalContentLength = documentPreviewFiles.reduce(
168
+ (sum, f) => sum + f.content.length,
169
+ 0
170
+ );
171
+ if (totalContentLength > totalPayloadLimit) {
172
+ toast.error(
173
+ t('upload.contextSizeExceedsLimit', {
174
+ defaultValue:
175
+ 'Context size exceeds the limit. Try reducing the number of files or content in the conversation.',
176
+ })
177
+ );
178
+ return;
179
+ }
145
180
  const mediaWithIds = documentPreviewFiles.map((file, index) => {
146
181
  const generatedMediumID =
147
182
  file.mediumID ||
@@ -199,6 +234,89 @@ const ChatInputs: React.FC<Props> = ({
199
234
  }
200
235
  };
201
236
 
237
+ /**
238
+ * All pasted text is treated as a document attachment: wrapped in <document_attachment>
239
+ * and added to the document preview, then sent as media via sendMessage (same as other media).
240
+ */
241
+ const handleTextareaPaste = useCallback(
242
+ (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
243
+ if (e.clipboardData.files?.length) return;
244
+ const text = e.clipboardData.getData('text/plain');
245
+ if (!text?.trim()) return;
246
+
247
+ // Critical: max attachments reached – prevent dumping long text into textarea, show feedback
248
+ if (documentPreviewFiles.length >= MAX_DOCUMENTS_PER_MESSAGE) {
249
+ e.preventDefault();
250
+ toast.error(
251
+ t('upload.pasteMaxAttachmentsReached', {
252
+ max: MAX_DOCUMENTS_PER_MESSAGE,
253
+ defaultValue: `Maximum ${MAX_DOCUMENTS_PER_MESSAGE} attachments. Remove one to add this as a file.`,
254
+ })
255
+ );
256
+ return;
257
+ }
258
+
259
+ // Critical: pasted content exceeds single-document size limit – reject and inform (same prop as total limit)
260
+ const perDocumentLimit = maxTotalMessagePayload ?? MAX_DOCUMENT_CONTENT_LENGTH;
261
+ if (text.length > perDocumentLimit) {
262
+ e.preventDefault();
263
+ toast.error(
264
+ t('upload.contextSizeExceedsLimit', {
265
+ defaultValue:
266
+ 'Context size exceeds the limit. Try reducing the number of files or content in the conversation.',
267
+ })
268
+ );
269
+ return;
270
+ }
271
+
272
+ const totalPayloadLimit = maxTotalMessagePayload ?? MAX_TOTAL_MESSAGE_PAYLOAD;
273
+ const currentTotal = documentPreviewFiles.reduce(
274
+ (sum, f) => sum + f.content.length,
275
+ 0
276
+ );
277
+ if (currentTotal + text.length > totalPayloadLimit) {
278
+ e.preventDefault();
279
+ toast.error(
280
+ t('upload.contextSizeExceedsLimit', {
281
+ defaultValue:
282
+ 'Context size exceeds the limit. Try reducing the number of files or content in the conversation.',
283
+ })
284
+ );
285
+ return;
286
+ }
287
+
288
+ e.preventDefault();
289
+ const displayName = t('upload.pastedText') || 'pasted-text';
290
+ const wrappedContent = `<document_attachment filename="pasted-text.txt" type="text/plain">
291
+
292
+ ${text}
293
+
294
+ </document_attachment>`;
295
+ const newFile = {
296
+ name: displayName,
297
+ id: `paste_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
298
+ content: wrappedContent,
299
+ mediumID: undefined as string | undefined,
300
+ mimeType: 'text/plain',
301
+ type: 'document',
302
+ };
303
+ setDocumentPreviewFiles(
304
+ (
305
+ prev: {
306
+ name: string;
307
+ id: string;
308
+ content: string;
309
+ mediumID: string | undefined;
310
+ mimeType: string;
311
+ type: string;
312
+ url?: string;
313
+ }[]
314
+ ) => [...prev, newFile]
315
+ );
316
+ },
317
+ [documentPreviewFiles, maxTotalMessagePayload, t]
318
+ );
319
+
202
320
  const isDisabled =
203
321
  dialogState?.state === 'X2a' || dialogState?.state === 'X3';
204
322
  const textareaDisabled = ['R2', 'R3', 'R4', 'R5', 'G3', 'X3'].includes(
@@ -214,12 +332,14 @@ const ChatInputs: React.FC<Props> = ({
214
332
  })}
215
333
  disabled={isDisabled}
216
334
  >
217
- {/* Preview for document files */}
218
- {showUpload && (
219
- <FilePreview
220
- previewFiles={documentPreviewFiles}
221
- removeFile={removeFile}
222
- />
335
+ {/* Preview for document files (show when upload enabled or when paste added cards) */}
336
+ {(showUpload || documentPreviewFiles.length > 0) && (
337
+ <div className="memori-chat-inputs--preview-wrapper">
338
+ <FilePreview
339
+ previewFiles={documentPreviewFiles}
340
+ removeFile={removeFile}
341
+ />
342
+ </div>
223
343
  )}
224
344
  <div className="memori-chat-inputs--container">
225
345
  {/* Leading area - Plus button */}
@@ -234,6 +354,7 @@ const ChatInputs: React.FC<Props> = ({
234
354
  setDocumentPreviewFiles={setDocumentPreviewFiles}
235
355
  documentPreviewFiles={documentPreviewFiles}
236
356
  memoriID={memoriID}
357
+ maxTotalMessagePayload={maxTotalMessagePayload}
237
358
  />
238
359
  </div>
239
360
  )}
@@ -245,6 +366,7 @@ const ChatInputs: React.FC<Props> = ({
245
366
  value={userMessage}
246
367
  onChange={onChangeUserMessage}
247
368
  onPressEnter={onTextareaPressEnter}
369
+ onPaste={handleTextareaPaste}
248
370
  onFocus={onTextareaFocus}
249
371
  onBlur={onTextareaBlur}
250
372
  onExpandedChange={handleTextareaExpanded}
@@ -11,6 +11,7 @@ export interface Props {
11
11
  value: string;
12
12
  onChange: (value: string) => void;
13
13
  onPressEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
14
+ onPaste?: (e: React.ClipboardEvent<HTMLTextAreaElement>) => void;
14
15
  onFocus?: (e: React.FocusEvent) => void;
15
16
  onBlur?: (e: React.FocusEvent) => void;
16
17
  onExpandedChange?: (expanded: boolean) => void;
@@ -21,6 +22,7 @@ const ChatTextArea: React.FC<Props> = ({
21
22
  value,
22
23
  onChange,
23
24
  onPressEnter,
25
+ onPaste,
24
26
  onFocus,
25
27
  onBlur,
26
28
  onExpandedChange,
@@ -125,6 +127,7 @@ const ChatTextArea: React.FC<Props> = ({
125
127
  onPressEnter(e);
126
128
  }
127
129
  }}
130
+ onPaste={onPaste}
128
131
  onFocus={onFocus}
129
132
  onBlur={onBlur}
130
133
  maxLength={100000}
@@ -1,12 +1,27 @@
1
1
  /* ContentPreviewModal – modern preview modal for files and media */
2
2
 
3
+ /* Stack above Chat History Drawer (z-index: 10000) and other drawers */
4
+ .memori-content-preview-modal.memori-modal {
5
+ z-index: 10001;
6
+ }
7
+
8
+ .memori-content-preview-modal.memori-modal .memori-modal--backdrop,
9
+ .memori-content-preview-modal.memori-modal .memori-modal--container {
10
+ z-index: 10001;
11
+ }
12
+
3
13
  .memori-content-preview-modal.memori-modal .memori-modal--panel {
14
+ z-index: 10001;
4
15
  overflow: hidden;
16
+ min-width: 500px;
17
+ min-height: 500px;
18
+ max-height: 80vh;
5
19
  border-radius: 16px;
6
20
  background: var(--memori-content-preview-bg, #fafafa);
7
21
  box-shadow:
8
22
  0 25px 50px -12px rgba(0, 0, 0, 0.25),
9
23
  0 0 0 1px rgba(0, 0, 0, 0.05);
24
+ overflow-y: auto;
10
25
  }
11
26
 
12
27
  /* Image variant: modal at least 600px wide (capped by viewport on small screens) */
@@ -2,13 +2,13 @@
2
2
 
3
3
  .memori--preview-container {
4
4
  z-index: 10;
5
- min-width: 100%;
6
- /* padding: 12px; */
5
+ overflow: hidden;
6
+ width: 100%;
7
+ min-width: 0;
8
+ max-width: 100%;
7
9
  border-radius: 8px;
8
- /* margin-bottom: 12px; */
9
10
  animation: slide-in 0.3s ease;
10
11
  background: white;
11
- /* box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); */
12
12
  transition: all 0.3s ease;
13
13
  }
14
14
 
@@ -114,11 +114,16 @@
114
114
 
115
115
  .memori--preview-list {
116
116
  display: flex;
117
+ width: 100%;
118
+ min-width: 0;
117
119
  flex-direction: row;
120
+ flex-wrap: nowrap;
118
121
  padding: 0.875rem;
119
122
  padding-bottom: 0.625rem;
120
123
  gap: 8px;
124
+ -webkit-overflow-scrolling: touch;
121
125
  overflow-x: auto;
126
+ overflow-y: hidden;
122
127
  scrollbar-color: hsla(var(--border-300) / 35%) transparent;
123
128
  scrollbar-width: thin;
124
129
  }
@@ -127,6 +132,7 @@
127
132
  position: relative;
128
133
  display: flex;
129
134
  max-width: fit-content;
135
+ flex-shrink: 0;
130
136
  align-items: center;
131
137
  padding: 8px;
132
138
  border-radius: 8px;
@@ -217,16 +223,18 @@
217
223
  transition: all 0.2s ease;
218
224
  }
219
225
 
220
- .memori--remove-button.visible {
221
- opacity: 1;
222
- transform: scale(1);
223
- }
224
-
225
226
  .memori--remove-button:hover {
226
227
  background-color: #c92a2a;
227
228
  transform: scale(1.1);
228
229
  }
229
230
 
231
+
232
+ /* Show remove button only when user hovers the preview item */
233
+ .memori--preview-item:hover .memori--remove-button {
234
+ opacity: 1;
235
+ transform: scale(1);
236
+ }
237
+
230
238
  .memori--remove-icon {
231
239
  width: 12px;
232
240
  height: 12px;
@@ -26,8 +26,6 @@ FilePreviewProps) => {
26
26
  type?: string;
27
27
  } | null>(null);
28
28
 
29
- const [hoveredId, setHoveredId] = useState<string | null>(null);
30
-
31
29
  const getFileType = (filename: string, type?: string) => {
32
30
  // If type is explicitly provided, use it first
33
31
  if (type === 'image') {
@@ -136,8 +134,6 @@ FilePreviewProps) => {
136
134
  ? 'memori--preview-item--image'
137
135
  : 'memori--preview-item--document'
138
136
  }`}
139
- onMouseEnter={() => setHoveredId(file.id)}
140
- onMouseLeave={() => setHoveredId(null)}
141
137
  onClick={() => setSelectedFile(file)}
142
138
  >
143
139
  {isImageContent(file.content, file.type) ? (
@@ -160,9 +156,7 @@ FilePreviewProps) => {
160
156
  shape="rounded"
161
157
  icon={<CloseIcon />}
162
158
  danger
163
- className={`memori--remove-button ${
164
- hoveredId === file.id ? 'visible' : ''
165
- }`}
159
+ className="memori--remove-button"
166
160
  onClick={e => {
167
161
  e.stopPropagation();
168
162
  removeFile(file.id, file?.mediumID);