diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index dae3b4bc..f3eefb6d 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -1,4 +1,6 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useDropzone } from 'react-dropzone'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; @@ -6,6 +8,7 @@ import endpoints from '../api/endpoints'; import userService from '../api/services/userService'; import AlertIcon from '../assets/alert.svg'; import ClipIcon from '../assets/clip.svg'; +import DragFileUpload from '../assets/DragFileUpload.svg'; import ExitIcon from '../assets/exit.svg'; import SendArrowIcon from './SendArrowIcon'; import SourceIcon from '../assets/source.svg'; @@ -17,6 +20,7 @@ import { selectAttachments, updateAttachment, } from '../upload/uploadSlice'; +import { reorderAttachments } from '../upload/uploadSlice'; import { ActiveState } from '../models/misc'; import { @@ -53,6 +57,7 @@ export default function MessageInput({ const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false); const [uploadModalState, setUploadModalState] = useState('INACTIVE'); + const [handleDragActive, setHandleDragActive] = useState(false); const selectedDocs = useSelector(selectSelectedDocs); const token = useSelector(selectToken); @@ -82,79 +87,134 @@ export default function MessageInput({ }; }, [browserOS]); - const handleFileAttachment = (e: React.ChangeEvent) => { - if (!e.target.files || e.target.files.length === 0) return; + const uploadFiles = useCallback( + (files: File[]) => { + const apiHost = import.meta.env.VITE_API_HOST; - const file = e.target.files[0]; - const formData = new FormData(); - formData.append('file', file); + files.forEach((file) => { + const formData = new FormData(); + formData.append('file', file); + const xhr = new XMLHttpRequest(); + const uniqueId = crypto.randomUUID(); - const apiHost = import.meta.env.VITE_API_HOST; - const xhr = new XMLHttpRequest(); - const uniqueId = crypto.randomUUID(); + const newAttachment = { + id: uniqueId, + fileName: file.name, + progress: 0, + status: 'uploading' as const, + taskId: '', + }; - const newAttachment = { - id: uniqueId, - fileName: file.name, - progress: 0, - status: 'uploading' as const, - taskId: '', - }; + dispatch(addAttachment(newAttachment)); - dispatch(addAttachment(newAttachment)); + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + const progress = Math.round((event.loaded / event.total) * 100); + dispatch( + updateAttachment({ + id: uniqueId, + updates: { progress }, + }), + ); + } + }); - xhr.upload.addEventListener('progress', (event) => { - if (event.lengthComputable) { - const progress = Math.round((event.loaded / event.total) * 100); - dispatch( - updateAttachment({ - id: uniqueId, - updates: { progress }, - }), - ); - } - }); + xhr.onload = () => { + if (xhr.status === 200) { + const response = JSON.parse(xhr.responseText); + if (response.task_id) { + dispatch( + updateAttachment({ + id: uniqueId, + updates: { + taskId: response.task_id, + status: 'processing', + progress: 10, + }, + }), + ); + } + } else { + dispatch( + updateAttachment({ + id: uniqueId, + updates: { status: 'failed' }, + }), + ); + } + }; - xhr.onload = () => { - if (xhr.status === 200) { - const response = JSON.parse(xhr.responseText); - if (response.task_id) { + xhr.onerror = () => { dispatch( updateAttachment({ id: uniqueId, - updates: { - taskId: response.task_id, - status: 'processing', - progress: 10, - }, + updates: { status: 'failed' }, }), ); - } - } else { - dispatch( - updateAttachment({ - id: uniqueId, - updates: { status: 'failed' }, - }), - ); - } - }; + }; - xhr.onerror = () => { - dispatch( - updateAttachment({ - id: uniqueId, - updates: { status: 'failed' }, - }), - ); - }; + xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`); + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + xhr.send(formData); + }); + }, + [dispatch, token], + ); - xhr.open('POST', `${apiHost}${endpoints.USER.STORE_ATTACHMENT}`); - xhr.setRequestHeader('Authorization', `Bearer ${token}`); - xhr.send(formData); + const handleFileAttachment = (e: React.ChangeEvent) => { + if (!e.target.files || e.target.files.length === 0) return; + + const files = Array.from(e.target.files); + uploadFiles(files); + + // clear input so same file can be selected again e.target.value = ''; }; + // Drag and drop handler + const onDrop = useCallback( + (acceptedFiles: File[]) => { + uploadFiles(acceptedFiles); + setHandleDragActive(false); + }, + [uploadFiles], + ); + + const { getRootProps, getInputProps } = useDropzone({ + onDrop, + noClick: true, + noKeyboard: true, + multiple: true, + onDragEnter: () => { + setHandleDragActive(true); + }, + onDragLeave: () => { + setHandleDragActive(false); + }, + maxSize: 25000000, + accept: { + 'application/pdf': ['.pdf'], + 'text/plain': ['.txt'], + 'text/x-rst': ['.rst'], + 'text/x-markdown': ['.md'], + 'application/zip': ['.zip'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + ['.docx'], + 'application/json': ['.json'], + 'text/csv': ['.csv'], + 'text/html': ['.html'], + 'application/epub+zip': ['.epub'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [ + '.xlsx', + ], + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': + ['.pptx'], + 'image/png': ['.png'], + 'image/jpeg': ['.jpeg'], + 'image/jpg': ['.jpg'], + }, + }); + useEffect(() => { const checkTaskStatus = () => { const processingAttachments = attachments.filter( @@ -261,86 +321,131 @@ export default function MessageInput({ handleAbort(); }; + // Drag state for reordering + const [draggingId, setDraggingId] = useState(null); + + // no preview object URLs to revoke (preview removed per reviewer request) + + const findIndexById = (id: string) => + attachments.findIndex((a) => a.id === id); + + const handleDragStart = (e: React.DragEvent, id: string) => { + setDraggingId(id); + try { + e.dataTransfer.setData('text/plain', id); + e.dataTransfer.effectAllowed = 'move'; + } catch (err) { + // ignore + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + }; + + const handleDropOn = (e: React.DragEvent, targetId: string) => { + e.preventDefault(); + const sourceId = e.dataTransfer.getData('text/plain'); + if (!sourceId || sourceId === targetId) return; + + const sourceIndex = findIndexById(sourceId); + const destIndex = findIndexById(targetId); + if (sourceIndex === -1 || destIndex === -1) return; + + dispatch(reorderAttachments({ sourceIndex, destinationIndex: destIndex })); + setDraggingId(null); + }; + return ( -
+
+
- {attachments.map((attachment) => ( -
-
- {attachment.status === 'completed' && ( - Attachment - )} - - {attachment.status === 'failed' && ( - Failed - )} - - {(attachment.status === 'uploading' || - attachment.status === 'processing') && ( -
- - - - -
- )} -
- - - {attachment.fileName} - - - -
- ))} +
+ {attachment.status === 'completed' && ( + Attachment + )} + + {attachment.status === 'failed' && ( + Failed + )} + + {(attachment.status === 'uploading' || + attachment.status === 'processing') && ( +
+ + + + +
+ )} +
+ + + {attachment.fileName} + + + +
+ ); + })}
@@ -422,6 +527,7 @@ export default function MessageInput({ @@ -481,6 +587,20 @@ export default function MessageInput({ close={() => setUploadModalState('INACTIVE')} /> )} + + {handleDragActive && + createPortal( +
+ + + {t('modals.uploadDoc.drag.title')} + + + {t('modals.uploadDoc.drag.description')} + +
, + document.body, + )}
); } diff --git a/frontend/src/conversation/Conversation.tsx b/frontend/src/conversation/Conversation.tsx index aa11bbde..71787b60 100644 --- a/frontend/src/conversation/Conversation.tsx +++ b/frontend/src/conversation/Conversation.tsx @@ -1,20 +1,16 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { useDropzone } from 'react-dropzone'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import SharedAgentCard from '../agents/SharedAgentCard'; -import DragFileUpload from '../assets/DragFileUpload.svg'; import MessageInput from '../components/MessageInput'; import { useMediaQuery } from '../hooks'; -import { ActiveState } from '../models/misc'; import { selectConversationId, selectSelectedAgent, selectToken, } from '../preferences/preferenceSlice'; import { AppDispatch } from '../store'; -import Upload from '../upload/Upload'; import { handleSendFeedback } from './conversationHandlers'; import ConversationMessages from './ConversationMessages'; import { FEEDBACK, Query } from './conversationModels'; @@ -45,53 +41,12 @@ export default function Conversation() { const selectedAgent = useSelector(selectSelectedAgent); const completedAttachments = useSelector(selectCompletedAttachments); - const [uploadModalState, setUploadModalState] = - useState('INACTIVE'); - const [files, setFiles] = useState([]); const [lastQueryReturnedErr, setLastQueryReturnedErr] = useState(false); const [isShareModalOpen, setShareModalState] = useState(false); - const [handleDragActive, setHandleDragActive] = useState(false); const fetchStream = useRef(null); - const onDrop = useCallback((acceptedFiles: File[]) => { - setUploadModalState('ACTIVE'); - setFiles(acceptedFiles); - setHandleDragActive(false); - }, []); - - const { getRootProps, getInputProps } = useDropzone({ - onDrop, - noClick: true, - multiple: true, - onDragEnter: () => { - setHandleDragActive(true); - }, - onDragLeave: () => { - setHandleDragActive(false); - }, - maxSize: 25000000, - accept: { - 'application/pdf': ['.pdf'], - 'text/plain': ['.txt'], - 'text/x-rst': ['.rst'], - 'text/x-markdown': ['.md'], - 'application/zip': ['.zip'], - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': - ['.docx'], - 'application/json': ['.json'], - 'text/csv': ['.csv'], - 'text/html': ['.html'], - 'application/epub+zip': ['.epub'], - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [ - '.xlsx', - ], - 'application/vnd.openxmlformats-officedocument.presentationml.presentation': - ['.pptx'], - }, - }); - const handleFetchAnswer = useCallback( ({ question, index }: { question: string; index?: number }) => { fetchStream.current = dispatch(fetchAnswer({ question, indx: index })); @@ -222,14 +177,7 @@ export default function Conversation() { />
-
- - +
{ handleQuestionSubmission(text); @@ -244,26 +192,6 @@ export default function Conversation() { {t('tagline')}

- {handleDragActive && ( -
- - - {t('modals.uploadDoc.drag.title')} - - - {t('modals.uploadDoc.drag.description')} - -
- )} - {uploadModalState === 'ACTIVE' && ( - setUploadModalState('INACTIVE')} - > - )}
); } diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 37dec5f9..36131fc3 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -255,8 +255,8 @@ "addQuery": "Add Query" }, "drag": { - "title": "Upload a source file", - "description": "Drop your file here to add it as a source" + "title": "Drop attachments here", + "description": "Release to upload your attachments" }, "progress": { "upload": "Upload is in progress", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 44e8d29f..ba5e6e55 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -218,8 +218,8 @@ "addQuery": "Agregar Consulta" }, "drag": { - "title": "Subir archivo fuente", - "description": "Arrastra tu archivo aquí para agregarlo como fuente" + "title": "Suelta los archivos adjuntos aquí", + "description": "Suelta para subir tus archivos adjuntos" }, "progress": { "upload": "Subida en progreso", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index f26d70d9..482b56cf 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -218,8 +218,8 @@ "addQuery": "クエリを追加" }, "drag": { - "title": "ソースファイルをアップロード", - "description": "ファイルをここにドロップしてソースとして追加してください" + "title": "添付ファイルをここにドロップ", + "description": "リリースして添付ファイルをアップロード" }, "progress": { "upload": "アップロード中", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 45bfc914..96f7213d 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -218,8 +218,8 @@ "addQuery": "Добавить запрос" }, "drag": { - "title": "Загрузить исходный файл", - "description": "Перетащите файл сюда, чтобы добавить его как источник" + "title": "Перетащите вложения сюда", + "description": "Отпустите, чтобы загрузить ваши вложения" }, "progress": { "upload": "Идет загрузка", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 85014e0f..5565f32b 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -218,8 +218,8 @@ "addQuery": "新增查詢" }, "drag": { - "title": "上傳來源檔案", - "description": "將檔案拖放到此處以新增為來源" + "title": "將附件拖放到此處", + "description": "釋放以上傳您的附件" }, "progress": { "upload": "正在上傳", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index f0044b38..46e834fd 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -218,8 +218,8 @@ "addQuery": "添加查询" }, "drag": { - "title": "上传源文件", - "description": "将文件拖放到此处以添加为源" + "title": "将附件拖放到此处", + "description": "释放以上传您的附件" }, "progress": { "upload": "正在上传", diff --git a/frontend/src/upload/uploadSlice.ts b/frontend/src/upload/uploadSlice.ts index d0b99efe..d997b1fb 100644 --- a/frontend/src/upload/uploadSlice.ts +++ b/frontend/src/upload/uploadSlice.ts @@ -66,6 +66,23 @@ export const uploadSlice = createSlice({ (att) => att.id !== action.payload, ); }, + // Reorder attachments array by moving item from sourceIndex to destinationIndex + reorderAttachments: ( + state, + action: PayloadAction<{ sourceIndex: number; destinationIndex: number }>, + ) => { + const { sourceIndex, destinationIndex } = action.payload; + if ( + sourceIndex < 0 || + destinationIndex < 0 || + sourceIndex >= state.attachments.length || + destinationIndex >= state.attachments.length + ) + return; + + const [moved] = state.attachments.splice(sourceIndex, 1); + state.attachments.splice(destinationIndex, 0, moved); + }, clearAttachments: (state) => { state.attachments = state.attachments.filter( (att) => att.status === 'uploading' || att.status === 'processing', @@ -121,6 +138,7 @@ export const { addAttachment, updateAttachment, removeAttachment, + reorderAttachments, clearAttachments, addUploadTask, updateUploadTask,