diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx
index ff1d38f..6c8ae4c 100644
--- a/frontend/src/app/AppLayout.jsx
+++ b/frontend/src/app/AppLayout.jsx
@@ -2,6 +2,7 @@ import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { AppShellContext } from '../appShellContext';
import DropOverlay from './DropOverlay';
+import UploadQueueOverlay from './UploadQueueOverlay';
import useDocumentsWorkspace from '../hooks/documents/useDocumentsWorkspace';
import { useDocumentsPreferences } from './useDocumentsPreferences';
@@ -54,6 +55,10 @@ const AppLayout = () => {
active={dropOverlayState.active}
folderName={dropOverlayState.folderName}
/>
+
{managementModals}
diff --git a/frontend/src/app/UploadQueueOverlay.jsx b/frontend/src/app/UploadQueueOverlay.jsx
new file mode 100644
index 0000000..40af83c
--- /dev/null
+++ b/frontend/src/app/UploadQueueOverlay.jsx
@@ -0,0 +1,205 @@
+import React, { useMemo, useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ CloseIcon,
+ LoaderIcon,
+ CheckIcon,
+ InfoIcon,
+ WarningIcon,
+ BottombarCollapseIcon,
+ BottombarExpandIcon,
+ ClearAllIcon,
+} from '../ui/icons';
+import PanelHeader from '../ui/PanelHeader';
+
+const STATUS_META = {
+ pending: {
+ label: 'Queued',
+ tone: 'muted',
+ icon: ,
+ },
+ uploading: {
+ label: 'Uploading',
+ tone: 'accent',
+ icon: ,
+ },
+ success: {
+ label: 'Uploaded',
+ tone: 'success',
+ icon: ,
+ },
+ duplicate: {
+ label: 'Duplicate',
+ tone: 'info',
+ icon: ,
+ },
+ error: {
+ label: 'Failed',
+ tone: 'danger',
+ icon: ,
+ },
+};
+
+const UploadQueueOverlay = ({ queue = [], onClearQueue }) => {
+ const navigate = useNavigate();
+ const [collapsed, setCollapsed] = useState(false);
+ const [dismissed, setDismissed] = useState(false);
+
+ useEffect(() => {
+ if (queue.length > 0) {
+ setDismissed(false);
+ }
+ }, [queue.length]);
+
+ const summary = useMemo(() => {
+ if (!queue.length) {
+ return 'No uploads';
+ }
+ const uploadingCount = queue.filter((item) => item.status === 'uploading').length;
+ const pendingCount = queue.filter((item) => item.status === 'pending').length;
+ const errorCount = queue.filter((item) => item.status === 'error').length;
+ if (uploadingCount > 0 || pendingCount > 0) {
+ return `${uploadingCount} uploading · ${pendingCount} queued`;
+ }
+ if (errorCount > 0) {
+ return `${errorCount} failed · ${queue.length} total`;
+ }
+ return `${queue.length} completed`;
+ }, [queue]);
+
+ const hasActiveUploads = queue.some((item) => item.status === 'uploading' || item.status === 'pending');
+
+ const handleClearQueue = () => {
+ if (!onClearQueue || hasActiveUploads) {
+ return;
+ }
+ onClearQueue();
+ };
+
+ if (!queue.length || dismissed) {
+ return null;
+ }
+
+ return (
+
+
+ Uploads
+ {summary}
+
+ )}
+ actions={(
+
+
+
+
+
+ )}
+ />
+ {!collapsed ? (
+
+ {[...queue]
+ .slice()
+ .reverse()
+ .map((item) => {
+ const meta = STATUS_META[item.status] || STATUS_META.pending;
+ const fileLabel = item.name;
+ const duplicateLabel = item.status === 'duplicate' ? item.document?.title || null : null;
+ const documentId = item.document?.id || item.conflictDocumentId || null;
+ const hasLink = Boolean(documentId);
+ const handleNavigate = () => {
+ if (!documentId) {
+ return;
+ }
+ navigate(`/documents/${documentId}`);
+ };
+ return (
+ -
+
+ {meta.icon}
+
+
+ {item.status === 'success' && hasLink ? (
+
+ ) : (
+
+ {fileLabel}
+
+ )}
+
+ {item.status === 'duplicate' && duplicateLabel ? (
+
+ Duplicate of{' '}
+
+
+ ) : item.status === 'error' && item.error ? (
+
+ {item.error}
+
+ ) : (
+ <>
+ {meta.label}
+ {documentId ? (
+
+ (
+
+ )
+
+ ) : null}
+ >
+ )}
+
+
+
+ );
+ })}
+
+ ) : null}
+
+ );
+};
+
+export default UploadQueueOverlay;
diff --git a/frontend/src/hooks/documents/useDocumentUploads.js b/frontend/src/hooks/documents/useDocumentUploads.js
index 373e40f..50b6d2d 100644
--- a/frontend/src/hooks/documents/useDocumentUploads.js
+++ b/frontend/src/hooks/documents/useDocumentUploads.js
@@ -29,8 +29,6 @@ const useDocumentUploads = ({
currentFolderName,
ensureFolderData,
refreshCurrentFolder,
- notifyApiError,
- setStatusMessage,
setLoading,
shellRef,
}) => {
@@ -40,12 +38,13 @@ const useDocumentUploads = ({
});
const dragCounterRef = useRef(0);
const folderPathCacheRef = useRef(new Map());
+ const queueIdRef = useRef(0);
+ const [uploadQueue, setUploadQueue] = useState([]);
const uploadFile = useCallback(
async (file, targetFolderId) => {
if (!file || file.size === 0) {
- setStatusMessage('Skipped empty file.', 'error');
- return null;
+ return { document: null, duplicate: false, statusCode: null, conflictDocumentId: null };
}
const formData = new FormData();
@@ -54,29 +53,74 @@ const useDocumentUploads = ({
formData.append('folder_id', targetFolderId);
}
- try {
- const { data, status } = await apiClient.post('/documents', formData);
- const duplicate = data?.reused || status === 200;
- setStatusMessage(
- duplicate
- ? `${file.name} already exists; reused existing document.`
- : `Uploaded ${file.name}`,
- duplicate ? 'info' : 'success',
- );
- return data;
- } catch (error) {
- if (error.response?.status === 409) {
- setStatusMessage(`${file.name} already exists on the server.`, 'info');
- return null;
+ try {
+ const { data, status } = await apiClient.post('/documents', formData);
+ const duplicate = data?.reused || status === 200;
+ const document = data?.document ?? data ?? null;
+ return {
+ document,
+ duplicate,
+ statusCode: status ?? (duplicate ? 200 : 201),
+ conflictDocumentId: null,
+ };
+ } catch (error) {
+ if (error.response?.status === 409) {
+ const conflictId = error.response?.data?.details?.conflict_document_id ?? null;
+ let conflictDocument = null;
+ if (conflictId) {
+ try {
+ const { data } = await apiClient.get(`/documents/${conflictId}`);
+ conflictDocument = data?.document ?? data ?? null;
+ } catch (fetchError) {
+ console.warn('[Uploads] failed to fetch conflict document', fetchError);
+ }
+ }
+ return {
+ document: conflictDocument,
+ duplicate: true,
+ statusCode: 409,
+ conflictDocumentId: conflictId,
+ };
+ }
+ const message = error.response?.data?.error || `Failed to upload ${file.name}.`;
+ const wrapped = Object.assign(new Error(message), { response: error.response });
+ throw wrapped;
}
- const message = error.response?.data?.error || `Failed to upload ${file.name}.`;
- notifyApiError(error, message);
- throw error;
- }
- },
- [apiClient, notifyApiError, setStatusMessage],
+ },
+ [apiClient],
);
+ const appendQueueItems = useCallback((entries, targetFolderId) => {
+ const baseId = Date.now();
+ const items = entries.map(({ file }) => {
+ queueIdRef.current += 1;
+ return {
+ id: `upload-${baseId}-${queueIdRef.current}`,
+ name: file?.name || 'Unnamed file',
+ size: file?.size ?? null,
+ folderId: targetFolderId ?? selectedFolder ?? 'root',
+ status: 'pending',
+ error: null,
+ code: null,
+ document: null,
+ conflictDocumentId: null,
+ };
+ });
+ if (items.length) {
+ setUploadQueue((current) => [...current, ...items]);
+ }
+ return items;
+ }, [selectedFolder]);
+
+ const updateQueueItem = useCallback((id, patch) => {
+ if (!id) {
+ return;
+ }
+ setUploadQueue((current) =>
+ current.map((item) => (item.id === id ? { ...item, ...patch } : item)),
+ );
+ }, []);
+
const ensureFolderPathOnServer = useCallback(
async (baseFolderId, segments) => {
const trimmedSegments = segments.map((segment) => segment.trim()).filter(Boolean);
@@ -226,13 +270,23 @@ const useDocumentUploads = ({
const uploadFileEntries = useCallback(
async (entries, targetFolderId) => {
- if (!token) {
- setStatusMessage('Please log in before uploading.', 'error');
+ if (!entries || !entries.length) {
+ console.warn('[Uploads] No files to upload.');
return;
}
- if (!entries || !entries.length) {
- setStatusMessage('No files to upload.', 'info');
+ const queueItems = appendQueueItems(entries, targetFolderId);
+
+ if (!token) {
+ queueItems.forEach((item) => {
+ const patch = {
+ status: 'error',
+ error: 'Please log in before uploading.',
+ code: null,
+ };
+ updateQueueItem(item.id, patch);
+ Object.assign(item, patch);
+ });
return;
}
@@ -244,7 +298,14 @@ const useDocumentUploads = ({
const baseFolderId =
targetFolderId && targetFolderId !== 'root' ? targetFolderId : null;
- for (const { file, segments } of entries) {
+ for (let index = 0; index < entries.length; index += 1) {
+ const { file, segments } = entries[index];
+ const queueItem = queueItems[index];
+ if (queueItem) {
+ const patch = { status: 'uploading', error: null, code: null };
+ updateQueueItem(queueItem.id, patch);
+ Object.assign(queueItem, patch);
+ }
// eslint-disable-next-line no-await-in-loop
const destinationId = segments.length
? await ensureFolderPathOnServer(baseFolderId, segments)
@@ -254,8 +315,34 @@ const useDocumentUploads = ({
destinationId ??
(targetFolderId && targetFolderId !== 'root' ? targetFolderId : 'root');
- // eslint-disable-next-line no-await-in-loop
- await uploadFile(file, uploadTarget);
+ try {
+ // eslint-disable-next-line no-await-in-loop
+ const { duplicate, statusCode, document, conflictDocumentId } = await uploadFile(
+ file,
+ uploadTarget,
+ );
+ if (queueItem) {
+ const patch = {
+ status: duplicate ? 'duplicate' : 'success',
+ code: statusCode ?? null,
+ document: document || queueItem.document,
+ conflictDocumentId: conflictDocumentId ?? queueItem.conflictDocumentId,
+ };
+ updateQueueItem(queueItem.id, patch);
+ Object.assign(queueItem, patch);
+ }
+ } catch (error) {
+ if (queueItem) {
+ const patch = {
+ status: 'error',
+ error: error.response?.data?.error || error.message || 'Upload failed.',
+ code: error.response?.status ?? null,
+ };
+ updateQueueItem(queueItem.id, patch);
+ Object.assign(queueItem, patch);
+ }
+ continue;
+ }
}
await refreshCurrentFolder();
@@ -268,7 +355,20 @@ const useDocumentUploads = ({
await ensureFolderData(targetFolderId, { force: true, prefetchDepth: 1 });
}
} catch (error) {
- notifyApiError(error, 'Failed to upload files.');
+ const message = error.message || 'Failed to upload files.';
+ queueItems.forEach((item) => {
+ if (item.status === 'success' || item.status === 'duplicate' || item.status === 'error') {
+ return;
+ }
+ const patch = {
+ status: 'error',
+ error: message,
+ code: error.response?.status ?? null,
+ };
+ updateQueueItem(item.id, patch);
+ Object.assign(item, patch);
+ });
+ console.error('[Uploads] batch failed', error);
} finally {
setLoading(false);
}
@@ -280,9 +380,9 @@ const useDocumentUploads = ({
refreshCurrentFolder,
selectedFolder,
ensureFolderData,
- notifyApiError,
- setStatusMessage,
setLoading,
+ appendQueueItems,
+ updateQueueItem,
],
);
@@ -292,14 +392,13 @@ const useDocumentUploads = ({
try {
extracted = await extractFilesFromDataTransfer(dataTransfer);
} catch (error) {
- const message = error.message || 'Failed to process dropped files.';
- notifyApiError(error, message);
+ console.error('[Uploads] Failed to process dropped files.', error);
return;
}
await uploadFileEntries(extracted, targetFolderId);
},
- [extractFilesFromDataTransfer, uploadFileEntries, notifyApiError],
+ [extractFilesFromDataTransfer, uploadFileEntries],
);
const handleFileSelection = useCallback(
@@ -326,6 +425,11 @@ const useDocumentUploads = ({
const resetUploadsState = useCallback(() => {
dragCounterRef.current = 0;
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
+ setUploadQueue([]);
+ }, []);
+
+ const clearUploadQueue = useCallback(() => {
+ setUploadQueue([]);
}, []);
return {
@@ -337,6 +441,8 @@ const useDocumentUploads = ({
uploadFile,
extractFilesFromDataTransfer,
resetUploadsState,
+ uploadQueue,
+ clearUploadQueue,
};
};
diff --git a/frontend/src/hooks/documents/useDocumentsWorkspace.js b/frontend/src/hooks/documents/useDocumentsWorkspace.js
index 1c7878e..aeac0f8 100644
--- a/frontend/src/hooks/documents/useDocumentsWorkspace.js
+++ b/frontend/src/hooks/documents/useDocumentsWorkspace.js
@@ -534,6 +534,8 @@ const useDocumentsWorkspace = ({
dropOverlayState,
handleFileDrop,
handleFileSelection,
+ uploadQueue,
+ clearUploadQueue,
resetUploadsState,
} = useDocumentUploads({
apiClient: api,
@@ -624,6 +626,7 @@ const useDocumentsWorkspace = ({
assetManager.reset();
resetPreviewState();
resetUploadsState();
+ clearUploadQueue();
breadcrumbFetchRef.current = new Set();
detailFolderFetchRef.current = new Set();
bootstrapInitializedRef.current = false;
@@ -654,6 +657,7 @@ const useDocumentsWorkspace = ({
setActivePreviewId,
resetPreviewState,
resetUploadsState,
+ clearUploadQueue,
]);
useEffect(() => {
@@ -1373,6 +1377,7 @@ const useDocumentsWorkspace = ({
handleTenantSelect,
openSettings,
handleFileSelection,
+ uploadQueue,
});
@@ -1478,6 +1483,8 @@ const useDocumentsWorkspace = ({
openSettings,
detailPanelOpen,
openDetailPanel,
+ uploadQueue,
+ clearUploadQueue,
}),
[
token,
@@ -1528,6 +1535,8 @@ const useDocumentsWorkspace = ({
openSettings,
detailPanelOpen,
openDetailPanel,
+ uploadQueue,
+ clearUploadQueue,
],
);
diff --git a/frontend/src/sidebar/useSidebarProps.js b/frontend/src/sidebar/useSidebarProps.js
index 86cb84e..d305b18 100644
--- a/frontend/src/sidebar/useSidebarProps.js
+++ b/frontend/src/sidebar/useSidebarProps.js
@@ -36,6 +36,7 @@ const useSidebarProps = ({
handleTenantSelect,
openSettings,
handleFileSelection,
+ uploadQueue,
}) =>
useMemo(
() => ({
@@ -87,9 +88,11 @@ const useSidebarProps = ({
const folderId = targetFolderId ?? selectedFolder ?? 'root';
handleFileSelection(files, folderId);
},
+ uploadQueue,
}),
[
handleFileSelection,
+ uploadQueue,
activeCorrespondentFilters,
activeTagFilters,
appStatus,
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 5687227..edfed4a 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -4271,3 +4271,195 @@ fieldset.settings-form__field legend {
width: 1.41rem;
height: 1.41rem;
}
+
+.upload-queue-overlay {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ width: min(360px, calc(100vw - 32px));
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ box-shadow: 0 18px 45px var(--shadow-pop);
+ z-index: 400;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 0.9rem;
+ color: var(--fg);
+}
+
+
+.upload-queue-overlay__header {
+ padding: 0;
+ border: none;
+}
+
+.upload-queue-overlay__title {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-weight: 600;
+ font-size: 0.95rem;
+ min-width: 0;
+}
+
+.upload-queue-overlay__summary {
+ font-size: 0.8rem;
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.upload-queue-overlay__controls {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.upload-queue-overlay__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 260px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+}
+
+.upload-queue-overlay--collapsed .upload-queue-overlay__list {
+ display: none;
+}
+
+.upload-queue-overlay__item {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 0.6rem;
+ padding: 0.15rem 0;
+}
+
+.upload-queue-overlay__status {
+ width: 28px;
+ height: 28px;
+ border-radius: 999px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--surface-subtle);
+ color: var(--muted);
+}
+
+.upload-queue-overlay__status--muted {
+ background: var(--surface-subtle);
+ color: var(--muted);
+}
+
+.upload-queue-overlay__status--accent {
+ background: var(--accent-soft);
+ color: var(--accent);
+}
+
+.upload-queue-overlay__status--success {
+ background: var(--success-subtle);
+ color: var(--success);
+}
+
+.upload-queue-overlay__status--info {
+ background: var(--selection-soft);
+ color: var(--accent);
+}
+
+.upload-queue-overlay__status--danger {
+ background: var(--danger-subtle);
+ color: var(--danger);
+}
+
+.upload-queue-overlay__details {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+}
+
+.upload-queue-overlay__name {
+ font-size: 0.88rem;
+ font-weight: 500;
+ color: var(--fg);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.upload-queue-overlay__name-link {
+ font-size: 0.88rem;
+ font-weight: 500;
+ color: inherit;
+ background: none;
+ border: none;
+ padding: 0;
+ text-align: left;
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.upload-queue-overlay__name-link:hover {
+ color: var(--accent);
+ text-decoration: underline;
+}
+
+.upload-queue-overlay__meta-line {
+ font-size: 0.72rem;
+ color: var(--muted);
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ flex-wrap: wrap;
+}
+
+.upload-queue-overlay__meta-id,
+.upload-queue-overlay__meta-secondary {
+ color: inherit;
+}
+
+.upload-queue-overlay__meta-link {
+ border: none;
+ background: none;
+ font: inherit;
+ cursor: pointer;
+ padding: 0;
+ color: inherit;
+ text-decoration: none;
+}
+
+.upload-queue-overlay__meta-link:hover {
+ color: var(--accent);
+ text-decoration: underline;
+}
+
+.upload-queue-overlay__meta-link:disabled {
+ cursor: default;
+ opacity: 0.5;
+}
+
+.upload-queue-overlay__meta-error {
+ color: var(--danger);
+}
+
+.upload-queue-overlay__item--error .upload-queue-overlay__name {
+ color: var(--danger);
+}
+
+.text-button {
+ background: none;
+ border: none;
+ color: var(--accent);
+ font-weight: 500;
+ font-size: 0.8rem;
+ cursor: pointer;
+ padding: 0.1rem 0.25rem;
+}
+
+.text-button:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
diff --git a/frontend/src/ui/icons.js b/frontend/src/ui/icons.js
index 2e7cba8..d8a8dfc 100644
--- a/frontend/src/ui/icons.js
+++ b/frontend/src/ui/icons.js
@@ -33,6 +33,9 @@ import {
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand,
IconLayoutSidebarRightCollapse,
+ IconLayoutBottombarCollapse,
+ IconLayoutBottombarExpand,
+ IconClearAll,
IconInfoCircle,
IconCircleDashedCheck,
IconFile,
@@ -40,6 +43,7 @@ import {
IconSortAscendingLetters,
IconSortDescendingLetters,
IconFileInfo,
+ IconAlertTriangle,
} from '@tabler/icons-react';
import FolderSvg from '../assets/folder.svg';
import composeClassName from './classNames';
@@ -204,6 +208,33 @@ export const DetailPanelCollapseIcon = ({ className, size = '1em', stroke = 1.6,
/>
);
+export const BottombarCollapseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
+
+);
+
+export const BottombarExpandIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
+
+);
+
+export const ClearAllIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
+
+);
+
export const FolderPlusIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
);
+export const WarningIcon = ({ className, size = '1em', stroke = 1.8, ...rest }) => (
+
+);
+
export const WindowMaximizeIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (