diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx
index 2ffcce5..8a5049b 100644
--- a/frontend/src/index.jsx
+++ b/frontend/src/index.jsx
@@ -208,6 +208,7 @@ const computeStackAngle = (docId, index) => {
};
const MAX_PREVIEW_STACK_ITEMS = 15;
+const CORRESPONDENT_ROLES = ['sender', 'receiver', 'other'];
const PreviewStack = ({
items = [],
@@ -305,6 +306,9 @@ const DetailPanel = ({
onUpdateTitle = async () => false,
ensureAssetUrl = null,
getDocumentAsset = () => null,
+ correspondents = [],
+ onCorrespondentAdd,
+ onCorrespondentRemove,
}) => {
const selectedCount = selectedDocuments.length;
const singleDoc = selectedCount === 1 ? selectedDocuments[0] : null;
@@ -471,6 +475,26 @@ const DetailPanel = ({
}, 0);
}, [stackPreviews, selectedDocuments]);
+ const availableCorrespondents = useMemo(
+ () => (Array.isArray(correspondents) ? correspondents : []),
+ [correspondents],
+ );
+
+ const correspondentOptions = useMemo(() => {
+ const seen = new Set();
+ return availableCorrespondents
+ .map((entry) => (entry?.name || '').trim())
+ .filter((name) => {
+ if (!name) return false;
+ const lower = name.toLowerCase();
+ if (seen.has(lower)) {
+ return false;
+ }
+ seen.add(lower);
+ return true;
+ });
+ }, [availableCorrespondents]);
+
const renderSingle = () => {
if (!singleDoc) {
return
Select a document to view metadata, tags and actions.
;
@@ -489,6 +513,20 @@ const DetailPanel = ({
const tagsForDoc = Array.isArray(singleDoc.tags) ? singleDoc.tags : [];
const metadata =
singleDoc.metadata && Object.keys(singleDoc.metadata).length > 0 ? singleDoc.metadata : null;
+ const docCorrespondents = Array.isArray(singleDoc.correspondents)
+ ? singleDoc.correspondents
+ : [];
+ const sortedCorrespondents = docCorrespondents.slice().sort((a, b) => {
+ const roleA = (a.role || '').toLowerCase();
+ const roleB = (b.role || '').toLowerCase();
+ const indexA = CORRESPONDENT_ROLES.indexOf(roleA);
+ const indexB = CORRESPONDENT_ROLES.indexOf(roleB);
+ if (indexA !== indexB) {
+ return (indexA === -1 ? Number.MAX_SAFE_INTEGER : indexA) -
+ (indexB === -1 ? Number.MAX_SAFE_INTEGER : indexB);
+ }
+ return (a.name || '').localeCompare(b.name || '');
+ });
return (
<>
@@ -642,6 +680,80 @@ const DetailPanel = ({
+
+
Correspondents
+
+ {sortedCorrespondents.length ? (
+ sortedCorrespondents.map((entry) => {
+ const roleLabel = entry.role
+ ? entry.role.charAt(0).toUpperCase() + entry.role.slice(1)
+ : 'Other';
+ return (
+
+
+ {roleLabel}
+ {entry.name}
+
+
+
+ );
+ })
+ ) : (
+ No correspondents yet.
+ )}
+
+
+
{metadata && (
Metadata
@@ -842,7 +954,7 @@ const PreviewWorkspace = ({
);
};
-const TagsPanel = ({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) => {
+function TagsPanel({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) {
const [editingId, setEditingId] = useState(null);
const [draftLabel, setDraftLabel] = useState('');
const [draftColor, setDraftColor] = useState('');
@@ -1091,7 +1203,244 @@ const TagsPanel = ({ tags, onRefresh, onUpdateTag, onDeleteTag, onNotify }) => {
);
-};
+}
+
+function CorrespondentsPanel({
+ correspondents = [],
+ onRefresh,
+ onCreate,
+ onUpdate,
+ onDelete,
+ onNotify,
+}) {
+ const [editingId, setEditingId] = useState(null);
+ const [draftName, setDraftName] = useState('');
+ const [createName, setCreateName] = useState('');
+ const [saving, setSaving] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [deletingId, setDeletingId] = useState(null);
+
+ const startEdit = useCallback((correspondent) => {
+ setEditingId(correspondent.id);
+ setDraftName(correspondent.name || '');
+ }, []);
+
+ const cancelEdit = useCallback(() => {
+ setEditingId(null);
+ setDraftName('');
+ setSaving(false);
+ }, []);
+
+ const handleSave = useCallback(async () => {
+ if (!editingId) return;
+ const trimmed = draftName.trim();
+ if (!trimmed) {
+ onNotify?.('Correspondent name cannot be empty.', 'error');
+ return;
+ }
+
+ setSaving(true);
+ try {
+ await onUpdate(editingId, { name: trimmed });
+ cancelEdit();
+ } catch (
+ // eslint-disable-next-line no-empty
+ error
+ ) {}
+ }, [editingId, draftName, onUpdate, cancelEdit, onNotify]);
+
+ const handleDelete = useCallback(
+ async (correspondent) => {
+ if (!correspondent?.id) return;
+ setDeletingId(correspondent.id);
+ try {
+ await onDelete(correspondent.id);
+ if (editingId === correspondent.id) {
+ cancelEdit();
+ }
+ } catch (
+ // eslint-disable-next-line no-empty
+ error
+ ) {
+ return;
+ } finally {
+ setDeletingId(null);
+ }
+ },
+ [onDelete, editingId, cancelEdit],
+ );
+
+ const handleCreate = useCallback(
+ async (event) => {
+ event.preventDefault();
+ const trimmed = createName.trim();
+ if (!trimmed) {
+ onNotify?.('Correspondent name cannot be empty.', 'error');
+ return;
+ }
+ setCreating(true);
+ try {
+ await onCreate({ name: trimmed });
+ setCreateName('');
+ } catch (
+ // eslint-disable-next-line no-empty
+ error
+ ) {
+ return;
+ } finally {
+ setCreating(false);
+ }
+ },
+ [createName, onCreate, onNotify],
+ );
+
+ const handleKeyDown = useCallback(
+ (event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ handleSave();
+ } else if (event.key === 'Escape') {
+ event.preventDefault();
+ cancelEdit();
+ }
+ },
+ [handleSave, cancelEdit],
+ );
+
+ const renderUsage = useCallback((usage) => {
+ if (!usage) {
+ return '0';
+ }
+ const total = typeof usage.total === 'number' ? usage.total : 0;
+ const entries = usage.by_role ? Object.entries(usage.by_role) : [];
+ if (!entries.length) {
+ return total.toString();
+ }
+ const roleSummary = entries
+ .map(([role, count]) => `${role}: ${count}`)
+ .join(', ');
+ return `${total} (${roleSummary})`;
+ }, []);
+
+ return (
+
+
+
+
Correspondents
+
{correspondents.length} total
+
+
+
+
+
+
+
+ {correspondents.length === 0 ? (
+
No correspondents created yet.
+ ) : (
+
+ )}
+
+
+ );
+}
const MainLayout = ({ sidebarProps, children, className = 'app-main' }) => (
@@ -1175,6 +1524,7 @@ const AppLayout = () => {
}, [appDispatch]);
const [searchResults, setSearchResults] = useState(null);
const [tags, setTags] = useState([]);
+ const [correspondents, setCorrespondents] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [activeTagFilters, setActiveTagFilters] = useState([]);
const [draggedDocumentIds, setDraggedDocumentIds] = useState([]);
@@ -1282,6 +1632,7 @@ const AppLayout = () => {
setDraggedFolderId(null);
setSearchResults(null);
setTags([]);
+ setCorrespondents([]);
setSearchQuery('');
setActiveTagFilters([]);
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
@@ -1318,6 +1669,16 @@ const AppLayout = () => {
return map;
}, [tags]);
+ const correspondentLookupByName = useMemo(() => {
+ const map = new Map();
+ correspondents.forEach((correspondent) => {
+ if (correspondent?.name) {
+ map.set(correspondent.name.toLowerCase(), correspondent);
+ }
+ });
+ return map;
+ }, [correspondents]);
+
const updateSelectionOrder = useCallback((nextSelection, interactedIds = []) => {
const nextSet = new Set(nextSelection);
const previousOrder = selectionOrderRef.current.filter((id) => nextSet.has(id));
@@ -2010,6 +2371,15 @@ const AppLayout = () => {
}
}, [api, notifyApiError]);
+ const refreshCorrespondents = useCallback(async () => {
+ try {
+ const { data } = await api.get('/correspondents');
+ setCorrespondents(data || []);
+ } catch (error) {
+ notifyApiError(error, 'Unable to load correspondents.');
+ }
+ }, [api, notifyApiError]);
+
const handleTagUpdate = useCallback(
async (tagId, changes) => {
if (!tagId) {
@@ -2062,6 +2432,213 @@ const AppLayout = () => {
[api, refreshTags, notifyApiError],
);
+ const handleCorrespondentUpdate = useCallback(
+ async (correspondentId, changes) => {
+ if (!correspondentId) {
+ throw new Error('Missing correspondent identifier.');
+ }
+
+ const payload = {};
+ if (typeof changes.name === 'string') {
+ const trimmed = changes.name.trim();
+ if (!trimmed) {
+ throw new Error('Correspondent name cannot be empty.');
+ }
+ payload.name = trimmed;
+ }
+
+ if (Object.keys(payload).length === 0) {
+ return false;
+ }
+
+ try {
+ await api.patch(`/correspondents/${correspondentId}`, payload);
+ await refreshCorrespondents();
+ setStatusMessage('Correspondent updated.', 'success');
+ return true;
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to update correspondent.';
+ notifyApiError(error, message);
+ throw new Error(message);
+ }
+ },
+ [api, refreshCorrespondents, notifyApiError, setStatusMessage],
+ );
+
+ const handleCorrespondentCreate = useCallback(
+ async ({ name }) => {
+ const trimmed = (name || '').trim();
+ if (!trimmed) {
+ throw new Error('Correspondent name is required.');
+ }
+ try {
+ const { data } = await api.post('/correspondents', { name: trimmed });
+ await refreshCorrespondents();
+ setStatusMessage('Correspondent created.', 'success');
+ return data;
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to create correspondent.';
+ notifyApiError(error, message);
+ throw new Error(message);
+ }
+ },
+ [api, refreshCorrespondents, notifyApiError, setStatusMessage],
+ );
+
+ const handleCorrespondentDelete = useCallback(
+ async (correspondentId) => {
+ if (!correspondentId) {
+ throw new Error('Missing correspondent identifier.');
+ }
+
+ const stripFromDoc = (doc) => {
+ if (!doc || !Array.isArray(doc.correspondents)) {
+ return doc;
+ }
+ const next = doc.correspondents.filter((entry) => entry.id !== correspondentId);
+ if (next.length === doc.correspondents.length) {
+ return doc;
+ }
+ return { ...doc, correspondents: next };
+ };
+
+ try {
+ await api.delete(`/correspondents/${correspondentId}`);
+ await refreshCorrespondents();
+
+ setDocuments((prev) => prev.map((doc) => stripFromDoc(doc)));
+ setSearchResults((prev) =>
+ Array.isArray(prev) ? prev.map((doc) => stripFromDoc(doc)) : prev,
+ );
+ setFolderContents((prev) => {
+ if (!prev.size) {
+ return prev;
+ }
+ let changed = false;
+ const next = new Map();
+ prev.forEach((contents, key) => {
+ if (!Array.isArray(contents?.documents)) {
+ next.set(key, contents);
+ return;
+ }
+ const updatedDocs = contents.documents.map((doc) => stripFromDoc(doc));
+ const mutated = updatedDocs.some((doc, index) => doc !== contents.documents[index]);
+ if (mutated) {
+ changed = true;
+ next.set(key, { ...contents, documents: updatedDocs });
+ } else {
+ next.set(key, contents);
+ }
+ });
+ return changed ? next : prev;
+ });
+
+ setStatusMessage('Correspondent deleted.', 'success');
+ return true;
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to delete correspondent.';
+ notifyApiError(error, message);
+ throw new Error(message);
+ }
+ },
+ [
+ api,
+ refreshCorrespondents,
+ notifyApiError,
+ setStatusMessage,
+ setDocuments,
+ setSearchResults,
+ setFolderContents,
+ ],
+ );
+
+ async function handleDocumentCorrespondentAttach({ documentId, correspondentId, role }) {
+ if (!documentId || !correspondentId || !role) {
+ throw new Error('Missing document, correspondent or role.');
+ }
+ try {
+ await api.post(`/documents/${documentId}/correspondents`, {
+ assignments: [{ correspondent_id: correspondentId, role }],
+ replace: false,
+ });
+ await refreshCurrentFolder();
+ setStatusMessage('Correspondent assigned.', 'success');
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to assign correspondent.';
+ notifyApiError(error, message);
+ throw new Error(message);
+ }
+ }
+
+ async function handleCorrespondentRemove({ documentId, correspondentId, role }) {
+ if (!documentId || !correspondentId || !role) {
+ throw new Error('Missing document, correspondent or role.');
+ }
+ try {
+ await api.delete(`/documents/${documentId}/correspondents/${correspondentId}`, {
+ params: { role },
+ });
+ await refreshCurrentFolder();
+ setStatusMessage('Correspondent removed.', 'success');
+ } catch (error) {
+ const message = error.response?.data?.error || 'Failed to remove correspondent.';
+ notifyApiError(error, message);
+ throw new Error(message);
+ }
+ }
+
+ const handleCorrespondentAdd = useCallback(
+ async ({ document, name, role, input }) => {
+ if (!document?.id) {
+ throw new Error('Missing document for correspondent assignment.');
+ }
+ const trimmed = (name || '').trim();
+ if (!trimmed) {
+ setStatusMessage('Correspondent name is required.', 'error');
+ return;
+ }
+ const normalizedRole = (role || '').trim().toLowerCase();
+ if (!CORRESPONDENT_ROLES.includes(normalizedRole)) {
+ setStatusMessage('Select a valid correspondent role.', 'error');
+ return;
+ }
+
+ let target = correspondentLookupByName.get(trimmed.toLowerCase()) || null;
+ if (!target) {
+ try {
+ target = await handleCorrespondentCreate({ name: trimmed });
+ } catch (error) {
+ return;
+ }
+ }
+
+ if (!target?.id) {
+ setStatusMessage('Unable to resolve correspondent.', 'error');
+ return;
+ }
+
+ try {
+ await handleDocumentCorrespondentAttach({
+ documentId: document.id,
+ correspondentId: target.id,
+ role: normalizedRole,
+ });
+ if (input) {
+ input.value = '';
+ }
+ } catch (
+ // eslint-disable-next-line no-empty
+ error
+ ) {}
+ },
+ [
+ handleCorrespondentCreate,
+ handleDocumentCorrespondentAttach,
+ correspondentLookupByName,
+ setStatusMessage,
+ ],
+ );
+
const handleTagDelete = useCallback(
async (tagId) => {
if (!tagId) {
@@ -2197,7 +2774,7 @@ const AppLayout = () => {
const initializeAfterLogin = useCallback(async () => {
setLoading(true);
try {
- await refreshTags();
+ await Promise.all([refreshTags(), refreshCorrespondents()]);
const initialFolder = routeFolderId && routeFolderId !== 'root' ? routeFolderId : 'root';
await loadFolder(initialFolder, { showLoading: false });
} catch (error) {
@@ -2206,7 +2783,7 @@ const AppLayout = () => {
} finally {
setLoading(false);
}
- }, [refreshTags, routeFolderId, loadFolder, notifyApiError]);
+ }, [refreshTags, refreshCorrespondents, routeFolderId, loadFolder, notifyApiError]);
useEffect(() => {
if (!token) {
@@ -4265,6 +4842,8 @@ const AppLayout = () => {
draggedFolderId,
onShowTags: () => navigate('/tags'),
tags,
+ onShowCorrespondents: () => navigate('/correspondents'),
+ correspondents,
};
const resolveThumbnailUrlForDoc = useCallback(
@@ -4341,6 +4920,9 @@ const AppLayout = () => {
onUpdateTitle: handleDocumentTitleUpdate,
ensureAssetUrl,
getDocumentAsset,
+ correspondents,
+ onCorrespondentAdd: handleCorrespondentAdd,
+ onCorrespondentRemove: handleCorrespondentRemove,
};
const skeuoWorkspaceProps = useMemo(
@@ -4394,6 +4976,14 @@ const AppLayout = () => {
handleTagCreate,
handleTagDelete,
handleDocumentTagAttach,
+ correspondents,
+ refreshCorrespondents,
+ handleCorrespondentUpdate,
+ handleCorrespondentCreate,
+ handleCorrespondentDelete,
+ handleDocumentCorrespondentAttach,
+ handleCorrespondentRemove,
+ handleCorrespondentAdd,
previewActive,
previewWorkspaceDocument,
previewWorkspaceEntry,
@@ -4421,6 +5011,14 @@ const AppLayout = () => {
handleTagCreate,
handleTagDelete,
handleDocumentTagAttach,
+ correspondents,
+ refreshCorrespondents,
+ handleCorrespondentUpdate,
+ handleCorrespondentCreate,
+ handleCorrespondentDelete,
+ handleDocumentCorrespondentAttach,
+ handleCorrespondentRemove,
+ handleCorrespondentAdd,
previewActive,
previewWorkspaceDocument,
previewWorkspaceEntry,
@@ -4657,7 +5255,7 @@ const LoginRoute = () => {
);
};
-const TagsRoute = () => {
+function TagsRoute() {
const {
sidebarProps,
tags,
@@ -4677,7 +5275,32 @@ const TagsRoute = () => {
/>
);
-};
+}
+
+function CorrespondentsRoute() {
+ const {
+ sidebarProps,
+ correspondents,
+ refreshCorrespondents,
+ handleCorrespondentCreate,
+ handleCorrespondentUpdate,
+ handleCorrespondentDelete,
+ setStatusMessage,
+ } = useAppShell();
+
+ return (
+
+
+
+ );
+}
const AppRouter = () => (
@@ -4692,6 +5315,7 @@ const AppRouter = () => (
element={}
/>
} />
+ } />
} />
diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx
index af7e85f..aa7c81c 100644
--- a/frontend/src/sidebar/Sidebar.jsx
+++ b/frontend/src/sidebar/Sidebar.jsx
@@ -1,6 +1,12 @@
import React, { useCallback } from 'react';
import { useMatch } from 'react-router-dom';
-import { ChevronIcon, FolderIcon, TagIcon, TrashIcon } from '../ui/icons';
+import {
+ ChevronIcon,
+ FolderIcon,
+ TagIcon,
+ TrashIcon,
+ CorrespondentIcon,
+} from '../ui/icons';
const FolderNode = ({
node,
@@ -106,10 +112,15 @@ const Sidebar = ({
draggedFolderId,
onShowTags,
tags = [],
+ onShowCorrespondents,
+ correspondents = [],
}) => {
const handleShowTags = onShowTags || (() => {});
const tagsRouteMatch = useMatch('/tags');
const isTagsRoute = Boolean(tagsRouteMatch);
+ const handleShowCorrespondents = onShowCorrespondents || (() => {});
+ const correspondentsRouteMatch = useMatch('/correspondents');
+ const isCorrespondentsRoute = Boolean(correspondentsRouteMatch);
const renderNodes = useCallback(
(ids, depth) =>
@@ -183,6 +194,27 @@ const Sidebar = ({
All tags
+
+
+
Correspondents
+ {correspondents.length}
+
+
{
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ handleShowCorrespondents();
+ }
+ }}
+ >
+
+ All correspondents
+
+
);
};
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index fbcb57a..be59efb 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -430,6 +430,74 @@ button.icon-button.ghost:hover:not([disabled]) {
gap: 0.4rem;
}
+.correspondents-panel .header-actions {
+ gap: 0.5rem;
+}
+
+.correspondents-actions__form {
+ display: flex;
+ gap: 0.4rem;
+}
+
+.correspondents-actions__form input {
+ min-width: 14rem;
+}
+
+.correspondent-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ margin-bottom: 0.5rem;
+}
+
+.correspondent-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ background: var(--surface-subtle);
+ border: 1px solid var(--border-subtle);
+ border-radius: 999px;
+ padding: 0.15rem 0.5rem;
+ font-size: 0.9rem;
+}
+
+.correspondent-pill__label {
+ display: flex;
+ flex-direction: column;
+ line-height: 1.2;
+}
+
+.correspondent-pill__label strong {
+ font-size: 0.8rem;
+ text-transform: capitalize;
+ color: var(--muted);
+}
+
+.correspondent-pill__remove {
+ border: none;
+ background: none;
+ cursor: pointer;
+ font-size: 1rem;
+ line-height: 1;
+ color: var(--muted);
+ padding: 0;
+}
+
+.correspondent-pill__remove:hover {
+ color: var(--danger);
+}
+
+.correspondent-form {
+ display: flex;
+ gap: 0.4rem;
+ align-items: center;
+}
+
+.correspondent-form input,
+.correspondent-form select {
+ min-height: 32px;
+}
+
.preview-workspace__message {
color: var(--muted);
font-size: 0.95rem;
diff --git a/frontend/src/ui/icons.js b/frontend/src/ui/icons.js
index d6c7044..568636f 100644
--- a/frontend/src/ui/icons.js
+++ b/frontend/src/ui/icons.js
@@ -4,6 +4,7 @@ import {
IconFolderFilled,
IconPencil,
IconTagFilled,
+ IconUserFilled,
IconTrash,
} from '@tabler/icons-react';
@@ -54,6 +55,15 @@ export const TagIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
/>
);
+export const CorrespondentIcon = ({ className, size = '1em', stroke = 0, ...rest }) => (
+
+);
+
export const DownloadIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (