Repository: epam/ai-dial-chat
Pull request #3809: feat(chat): add paste files feature for editable user message, fix drop area issues (Issue #3786)
Author: login: Gimir
Labels: name: bug
description: Something isn't working
name: enhancement
description: New feature or request
name: _priority_1
Created: 2025-05-12T18:51:06Z | Merged: 2025-05-13T14:18:49Z

**Description:**

- change default path for uploading files from device
- add success message for quick attachments
- fix drop area icon
- add workaround if drop area is stuck on dragging over view
- support paste files feature in editable user messages

Issues:

- Issue #3786 

**Checklist:**

- [x] the pull request name complies with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
- [x] the pull request name starts with `fix(<scope>):`, `feat(<scope>):`, `feature(<scope>):`, `chore(<scope>):`, `hotfix(<scope>):` or `e2e(<scope>):`. If contains breaking changes then the pull request name must start with `fix(<scope>)!:`, `feat(<scope>)!:`, `feature(<scope>)!:`, `chore(<scope>)!:`, `hotfix(<scope>)!:` or `e2e(<scope>)!:` where `<scope>` is name of affected project: `chat`, `chat-e2e`, `overlay`, `shared`, `sandbox-overlay`, etc.
- [x] the pull request name ends with `(Issue #<TICKET_ID>)` (comma-separated list of issues)
- [x] I confirm that do not share any confidential information like API keys or any other secrets and private URLs



Diff:
diff --git a/apps/chat/src/components/Chat/ChatMessage/ChatMessageContent/UserMessage.tsx b/apps/chat/src/components/Chat/ChatMessage/ChatMessageContent/UserMessage.tsx
index f09a1c600c..454603ade8 100644
--- a/apps/chat/src/components/Chat/ChatMessage/ChatMessageContent/UserMessage.tsx
+++ b/apps/chat/src/components/Chat/ChatMessage/ChatMessageContent/UserMessage.tsx
@@ -3,9 +3,12 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
 
 import classNames from 'classnames';
 
+import { useChatUploadFiles } from '@/src/hooks/useChatUploadFiles';
+import { useFilePaste } from '@/src/hooks/useFilePaste';
 import { useTranslation } from '@/src/hooks/useTranslation';
 
 import { isEntityNameOrPathInvalid } from '@/src/utils/app/common';
+import { getQuickAttachmentsSavingPath } from '@/src/utils/app/conversation';
 import {
   getDialFilesFromAttachments,
   getDialFoldersFromAttachments,
@@ -87,6 +90,7 @@ export const UserMessage = memo(function UserMessage({
   const dispatch = useAppDispatch();
 
   const anchorRef = useRef<HTMLDivElement>(null);
+  const textareaRef = useRef<HTMLTextAreaElement>(null);
 
   const isReplay = useAppSelector(
     ConversationsSelectors.selectIsReplaySelectedConversations,
@@ -112,6 +116,7 @@ export const UserMessage = memo(function UserMessage({
   const isMessageTemplatesEnabled = useAppSelector((state) =>
     SettingsSelectors.isFeatureEnabled(state, Feature.MessageTemplates),
   );
+
   const isChatFullWidth = useAppSelector(UISelectors.selectIsChatFullWidth);
 
   const isMobileOrOverlay = isSmallScreen() || isOverlay;
@@ -420,6 +425,25 @@ export const UserMessage = memo(function UserMessage({
     }
   }, [shouldScroll]);
 
+  const uploadPastedFiles = useChatUploadFiles(
+    getQuickAttachmentsSavingPath(),
+    newEditableAttachments.length,
+    true,
+  );
+
+  const handleUploadPastedFiles = useCallback(
+    (files: File[]) => {
+      uploadPastedFiles(files)?.then((newFiles) => {
+        setNewEditableAttachmentsIds((ids) =>
+          uniq(ids.concat(newFiles.map(({ id }) => id))),
+        );
+      });
+    },
+    [uploadPastedFiles],
+  );
+
+  useFilePaste(textareaRef, handleUploadPastedFiles);
+
   if (isEditing)
     return (
       <div className="flex w-full flex-col gap-3">
@@ -442,6 +466,7 @@ export const UserMessage = memo(function UserMessage({
             )}
           >
             <AdjustedTextarea
+              ref={textareaRef}
               className="w-full grow resize-none whitespace-pre-wrap bg-transparent focus-visible:outline-none"
               value={messageContent}
               onChange={handleInputChange}
diff --git a/apps/chat/src/components/Files/AttachButton.tsx b/apps/chat/src/components/Files/AttachButton.tsx
index e3ff6e87f1..fdafeea481 100644
--- a/apps/chat/src/components/Files/AttachButton.tsx
+++ b/apps/chat/src/components/Files/AttachButton.tsx
@@ -10,6 +10,8 @@ import { useCallback, useMemo, useState } from 'react';
 
 import { useTranslation } from '@/src/hooks/useTranslation';
 
+import { getQuickAttachmentsSavingPath } from '@/src/utils/app/conversation';
+
 import { FeatureType } from '@/src/types/common';
 import { DialFile, DialLink } from '@/src/types/files';
 import { DisplayMenuItemProps } from '@/src/types/menu';
@@ -164,6 +166,7 @@ export const AttachButton = ({
           onClose={() => {
             setIsPreUploadDialogOpened(false);
           }}
+          uploadFolderId={getQuickAttachmentsSavingPath()}
         />
       )}
       {isAttachLinkDialogOpened && (
diff --git a/apps/chat/src/components/Files/FileDropArea.tsx b/apps/chat/src/components/Files/FileDropArea.tsx
index 8fc682a334..8b6059f9cb 100644
--- a/apps/chat/src/components/Files/FileDropArea.tsx
+++ b/apps/chat/src/components/Files/FileDropArea.tsx
@@ -1,4 +1,4 @@
-import { IconFileFilled, IconFileXFilled } from '@tabler/icons-react';
+import { IconFileTextFilled, IconFileXFilled } from '@tabler/icons-react';
 import { DragEvent, ReactNode, useCallback, useState } from 'react';
 
 import classNames from 'classnames';
@@ -71,10 +71,15 @@ export const FileDropArea = ({
     [droppable, onDrop],
   );
 
+  // reset drag state if drag leave or drag over events were not emitted
+  const handleResetDragOver = useCallback(() => {
+    setIsDraggingOver(false);
+  }, []);
+
   return (
     <div
       id={containerId}
-      onDragOverCapture={handleDragOver}
+      onDragOver={handleDragOver}
       onDrop={handleDrop}
       className={classNames('relative', className)}
     >
@@ -90,11 +95,14 @@ export const FileDropArea = ({
               droppable ? 'cursor-copy' : 'cursor-not-allowed',
             )}
             onDragLeave={handleDragLeave}
+            onBlur={handleResetDragOver}
+            onFocus={handleResetDragOver}
+            onMouseLeave={handleResetDragOver}
           />
           <div className="flex flex-col items-center">
             {droppable ? (
               <>
-                <IconFileFilled
+                <IconFileTextFilled
                   size="100px"
                   className="mb-5 text-accent-primary"
                 />
diff --git a/apps/chat/src/components/Files/PreUploadModal.tsx b/apps/chat/src/components/Files/PreUploadModal.tsx
index 47175797d2..b3d58134a5 100644
--- a/apps/chat/src/components/Files/PreUploadModal.tsx
+++ b/apps/chat/src/components/Files/PreUploadModal.tsx
@@ -15,12 +15,12 @@ import {
   constructPath,
   getFileNameExtension,
   getFileNameWithoutExtension,
+  getRelativePath,
   getShortExtensionsListFromMimeType,
   prepareFileName,
   validatePreUploadFiles,
   validateUploadFiles,
 } from '@/src/utils/app/file';
-import { getParentAndCurrentFoldersById } from '@/src/utils/app/folders';
 import { getFileRootId, isMyBucket } from '@/src/utils/app/id';
 import { splitEntityId } from '@/src/utils/app/shared-utils';
 
@@ -72,7 +72,6 @@ export const PreUploadDialog = ({
   const { t } = useTranslation(Translation.Chat);
   const files = useAppSelector(FilesSelectors.selectFiles);
   const attachments = useAppSelector(FilesSelectors.selectSelectedFiles);
-  const folders = useAppSelector(FilesSelectors.selectFolders);
 
   const uploadInputRef = useRef<HTMLInputElement>(null);
   const [errorMessage, setErrorMessage] = useState('');
@@ -97,14 +96,7 @@ export const PreUploadDialog = ({
     [rootFolderId],
   );
 
-  const folderPath = useMemo(() => {
-    return (
-      getParentAndCurrentFoldersById(folders, selectedFolderId)
-        .map((folder) => folder.name)
-        .reverse()
-        .join('/') || undefined
-    );
-  }, [folders, selectedFolderId]);
+  const folderPath = getRelativePath(selectedFolderId);
   const allowedExtensions = useMemo(() => {
     if (allowedTypes.includes('*/*')) {
       return [t('all')];
diff --git a/apps/chat/src/hooks/useChatUploadFiles.ts b/apps/chat/src/hooks/useChatUploadFiles.ts
index 7c2bad27f4..ce3d23bef2 100644
--- a/apps/chat/src/hooks/useChatUploadFiles.ts
+++ b/apps/chat/src/hooks/useChatUploadFiles.ts
@@ -3,10 +3,13 @@ import { useUploadFilesHandler } from '@/src/hooks/useUploadFilesHandler';
 import { getQuickAttachmentsSavingPath } from '@/src/utils/app/conversation';
 
 import { ConversationsSelectors } from '@/src/store/conversations/conversations.selectors';
+import { FilesSelectors } from '@/src/store/files/files.selectors';
 import { useAppSelector } from '@/src/store/hooks';
 
 export const useChatUploadFiles = (
   path: string = getQuickAttachmentsSavingPath(),
+  selectedAttachmentsAmount?: number,
+  skipSelect = false,
 ) => {
   const availableAttachmentsTypes = useAppSelector(
     ConversationsSelectors.selectAvailableAttachmentsTypes,
@@ -14,10 +17,15 @@ export const useChatUploadFiles = (
   const maximumAttachmentsAmount = useAppSelector(
     ConversationsSelectors.selectMaximumAttachmentsAmount,
   );
+  const selectedAttachments = useAppSelector(
+    FilesSelectors.selectSelectedFiles,
+  );
 
   return useUploadFilesHandler(
     path,
+    selectedAttachmentsAmount ?? selectedAttachments.length,
     maximumAttachmentsAmount,
     availableAttachmentsTypes,
+    skipSelect,
   );
 };
diff --git a/apps/chat/src/hooks/useUploadFilesHandler.ts b/apps/chat/src/hooks/useUploadFilesHandler.ts
index 0538144185..bf3c795bef 100644
--- a/apps/chat/src/hooks/useUploadFilesHandler.ts
+++ b/apps/chat/src/hooks/useUploadFilesHandler.ts
@@ -37,14 +37,15 @@ const validateFiles = (
 
 export const useUploadFilesHandler = (
   folderId: string,
+  selectedAttachmentsAmount = 0,
   maximumAttachmentsAmount = 0,
   allowedTypes: string[] = [],
+  skipSelect?: boolean,
 ) => {
   const { t } = useTranslation(Translation.Chat);
   const dispatch = useAppDispatch();
 
   const allFiles = useAppSelector(FilesSelectors.selectFiles);
-  const attachments = useAppSelector(FilesSelectors.selectSelectedFiles);
 
   const { bucket } = useMemo(
     () =>
@@ -64,7 +65,7 @@ export const useUploadFilesHandler = (
 
   const handleUpload = useCallback(
     (files: File[]) => {
-      const attachmentsAmount = attachments.length + files.length;
+      const attachmentsAmount = selectedAttachmentsAmount + files.length;
       if (attachmentsAmount > maximumAttachmentsAmount) {
         dispatch(
           UIActions.showErrorToast(
@@ -109,23 +110,30 @@ export const useUploadFilesHandler = (
             id: file.id,
             relativePath: folderPath,
             name: file.name,
+
+            showSuccessMessage: true,
           }),
         );
       });
-      dispatch(
-        FilesActions.selectFiles({ ids: preparedFiles.map(({ id }) => id) }),
-      );
+      if (!skipSelect) {
+        dispatch(
+          FilesActions.selectFiles({ ids: preparedFiles.map(({ id }) => id) }),
+        );
+      }
+
+      return Promise.resolve(preparedFiles);
     },
     [
       allFiles,
       allowedTypes,
-      attachments.length,
+      selectedAttachmentsAmount,
       bucket,
       dispatch,
       folderId,
       folderPath,
       maximumAttachmentsAmount,
       t,
+      skipSelect,
     ],
   );
 
diff --git a/apps/chat/src/store/files/files.epics.ts b/apps/chat/src/store/files/files.epics.ts
index 488cc9c2bc..37278995ea 100644
--- a/apps/chat/src/store/files/files.epics.ts
+++ b/apps/chat/src/store/files/files.epics.ts
@@ -24,6 +24,7 @@ import {
   updateMovedEntityId,
 } from '@/src/utils/app/folders';
 import { getFileRootId } from '@/src/utils/app/id';
+import { splitEntityId } from '@/src/utils/app/shared-utils';
 import { translate } from '@/src/utils/app/translation';
 import { ApiUtils } from '@/src/utils/server/api';
 
@@ -78,6 +79,7 @@ const uploadFileEpic: AppEpic = (action$) =>
           if (result) {
             return FilesActions.uploadFileSuccess({
               apiResult: result,
+              showSuccessMessage: payload.showSuccessMessage,
             });
           }
 
@@ -99,6 +101,30 @@ const uploadFileEpic: AppEpic = (action$) =>
     }),
   );
 
+const uploadFilesSuccessEpic: AppEpic = (action$) =>
+  action$.pipe(
+    ofType(FilesActions.uploadFileSuccess.type),
+    switchMap(({ payload }) => {
+      if (payload.showSuccessMessage) {
+        const { parentPath, name } = splitEntityId(payload.apiResult.id);
+
+        return of(
+          UIActions.showSuccessToast(
+            translate(
+              'The file "{{name}}" has been uploaded successfully to "{{parentPath}}"',
+              {
+                name,
+                parentPath,
+              },
+            ),
+          ),
+        );
+      }
+
+      return EMPTY;
+    }),
+  );
+
 const reuploadFileEpic: AppEpic = (action$, state$) =>
   action$.pipe(
     ofType(FilesActions.reuploadFile.type),
@@ -367,6 +393,7 @@ export const FilesEpics = combineEpics(
   initEpic,
 
   uploadFileEpic,
+  uploadFilesSuccessEpic,
   getFileFoldersEpic,
   getFilesEpic,
   reuploadFileEpic,
diff --git a/apps/chat/src/store/files/files.reducers.ts b/apps/chat/src/store/files/files.reducers.ts
index d6b2ac97c1..450bdfb885 100644
--- a/apps/chat/src/store/files/files.reducers.ts
+++ b/apps/chat/src/store/files/files.reducers.ts
@@ -49,6 +49,8 @@ export const filesSlice = createSlice({
         relativePath?: string;
         name: string;
         bucket?: string;
+
+        showSuccessMessage?: boolean;
       }>,
     ) => {
       state.files = state.files.filter((file) => file.id !== payload.id);
@@ -97,6 +99,7 @@ export const filesSlice = createSlice({
         payload,
       }: PayloadAction<{
         apiResult: DialFile;
+        showSuccessMessage?: boolean;
       }>,
     ) => {
       state.files = state.files.map((file) => {


Reviews:
user: login: IlyaBondar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T19:08:57Z
user: login: IlyaBondar
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T14:09:50Z

Review comments:
path: apps/chat/public/images/icons/file-text-filled.svg
user: login: IlyaBondar
body: Maybe it makes sense to update Tabler to [v3.29](https://tabler.io/icons/changelog) where there is `IconFileTextFilled`

UPD: see https://github.com/epam/ai-dial-chat/pull/3810
created_at: 2025-05-12T19:03:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -99,6 +101,29 @@ const uploadFileEpic: AppEpic = (action$) =>
     }),
   );
 
+const uploadFilesSuccessEpic: AppEpic = (action$) =>
+  action$.pipe(
+    ofType(FilesActions.uploadFileSuccess.type),
+    switchMap(({ payload }) => {
+      if (payload.showSuccessMessage) {
+        const { parentPath, name } = splitEntityId(payload.apiResult.id);
+
+        return of(
+          UIActions.showSuccessToast(
+            translate(
+              'File uploaded successfully to storage at path: {{path}}',
path: apps/chat/src/store/files/files.epics.ts
user: login: IlyaBondar
body: ```suggestion
              'The file "{{name}}" has been uploaded successfully to "{{parentPath}}"',
```
created_at: 2025-05-12T19:07:10Z
author_association: CONTRIBUTOR

PR comments:
user: login: Gimir
created_at: 2025-05-12T18:51:15Z
body: /deploy-review
>Environment: [review-environment](https://chat-ai-dial-chat-pr-3809.nightly-test.deltixhub.io) | [pipeline](https://gitlab.deltixhub.com/Clients/deltix/nightly-test/dial/review/-/pipelines/608563)
>Overlay E2E tests status: [success](https://gitlab.deltixhub.com/Clients/deltix/nightly-test/dial/review/-/jobs/1842548)
>Chat E2E tests status: [success](https://gitlab.deltixhub.com/Clients/deltix/nightly-test/dial/review/-/jobs/1842547)
author_association: CONTRIBUTOR
user: login: Gimir
created_at: 2025-05-13T11:51:35Z
body: /deploy-review
>Environment: [review-environment](https://chat-ai-dial-chat-pr-3809.nightly-test.deltixhub.io) | [pipeline](https://gitlab.deltixhub.com/Clients/deltix/nightly-test/dial/review/-/pipelines/609083)
>Overlay E2E tests status: [success](https://gitlab.deltixhub.com/Clients/deltix/nightly-test/dial/review/-/jobs/1845361)
>Chat E2E tests status: [success](https://gitlab.deltixhub.com/Clients/deltix/nightly-test/dial/review/-/jobs/1845360)
author_association: CONTRIBUTOR