refactor: enhance document mention functionality in chat by introducing pending mentions and removals atoms, and updating DocumentsSidebar and Composer components for improved document management

This commit is contained in:
Anish Sarkar 2026-03-06 15:35:58 +05:30
parent 95a0e35393
commit f0e4aa6539
7 changed files with 261 additions and 237 deletions

View file

@ -27,6 +27,7 @@ export interface InlineMentionEditorRef {
getText: () => string;
getMentionedDocuments: () => MentionedDocument[];
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
removeDocumentChip: (docId: number, docType?: string) => void;
setDocumentChipStatus: (
docId: number,
docType: string | undefined,
@ -388,6 +389,33 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[]
);
const removeDocumentChip = useCallback(
(docId: number, docType?: string) => {
if (!editorRef.current) return;
const chipKey = `${docType ?? "UNKNOWN"}:${docId}`;
const chips = editorRef.current.querySelectorAll<HTMLSpanElement>(
`span[${CHIP_DATA_ATTR}="true"]`
);
for (const chip of chips) {
if (getChipId(chip) === docId && getChipDocType(chip) === (docType ?? "UNKNOWN")) {
chip.remove();
break;
}
}
setMentionedDocs((prev) => {
const next = new Map(prev);
next.delete(chipKey);
return next;
});
onDocumentRemove?.(docId, docType);
const text = getText();
const empty = text.length === 0 && mentionedDocs.size <= 1;
setIsEmpty(empty);
},
[getText, mentionedDocs.size, onDocumentRemove]
);
// Expose methods via ref
useImperativeHandle(ref, () => ({
focus: () => editorRef.current?.focus(),
@ -395,6 +423,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
getText,
getMentionedDocuments,
insertDocumentChip,
removeDocumentChip,
setDocumentChipStatus,
}));

View file

@ -32,6 +32,8 @@ import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
import {
mentionedDocumentIdsAtom,
mentionedDocumentsAtom,
pendingDocumentMentionsAtom,
pendingDocumentRemovalsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { membersAtom } from "@/atoms/members/members-query.atoms";
import {
@ -229,6 +231,7 @@ const ThreadWelcome: FC = () => {
const Composer: FC = () => {
// Document mention state (atoms persist across component remounts)
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const [pendingMentions, setPendingMentions] = useAtom(pendingDocumentMentionsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const editorRef = useRef<InlineMentionEditorRef>(null);
@ -450,6 +453,40 @@ const Composer: FC = () => {
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
);
// Process documents queued from the sidebar (additions)
useEffect(() => {
if (pendingMentions.length === 0) return;
handleDocumentsMention(pendingMentions);
setPendingMentions([]);
}, [pendingMentions, handleDocumentsMention, setPendingMentions]);
// Process documents queued from the sidebar (removals)
const [pendingRemovals, setPendingRemovals] = useAtom(pendingDocumentRemovalsAtom);
useEffect(() => {
if (pendingRemovals.length === 0) return;
for (const { id, document_type } of pendingRemovals) {
editorRef.current?.removeDocumentChip(id, document_type);
}
setMentionedDocuments((prev) => {
const removalKeys = new Set(
pendingRemovals.map((r) => `${r.document_type ?? "UNKNOWN"}:${r.id}`)
);
const updated = prev.filter(
(doc) => !removalKeys.has(`${doc.document_type ?? "UNKNOWN"}:${doc.id}`)
);
setMentionedDocumentIds({
surfsense_doc_ids: updated
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
.map((doc) => doc.id),
document_ids: updated
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
.map((doc) => doc.id),
});
return updated;
});
setPendingRemovals([]);
}, [pendingRemovals, setPendingRemovals, setMentionedDocuments, setMentionedDocumentIds]);
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
<ChatSessionStatus

View file

@ -1,11 +1,16 @@
"use client";
import { useAtomValue } from "jotai";
import { useAtomValue, useSetAtom } from "jotai";
import { ChevronLeft } from "lucide-react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
mentionedDocumentsAtom,
pendingDocumentMentionsAtom,
pendingDocumentRemovalsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { Button } from "@/components/ui/button";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
@ -37,9 +42,30 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
const [sortKey, setSortKey] = useState<SortKey>("created_at");
const [sortDesc, setSortDesc] = useState(true);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
const setPendingMentions = useSetAtom(pendingDocumentMentionsAtom);
const setPendingRemovals = useSetAtom(pendingDocumentRemovalsAtom);
const mentionedDocIds = useMemo(
() => new Set(mentionedDocuments.map((d) => d.id)),
[mentionedDocuments]
);
const handleToggleChatMention = useCallback(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
if (isMentioned) {
setPendingRemovals((prev) => [...prev, { id: doc.id, document_type: doc.document_type }]);
} else {
setPendingMentions((prev) => [
...prev,
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
]);
}
},
[setPendingMentions, setPendingRemovals]
);
const isSearchMode = !!debouncedSearch.trim();
const {
@ -76,59 +102,6 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
}
return prev.filter((t) => t !== type);
});
setSelectedIds(new Set());
};
const onBulkDelete = async () => {
if (selectedIds.size === 0) {
toast.error(t("no_rows_selected"));
return;
}
const selectedDocs = displayDocs.filter((doc) => selectedIds.has(doc.id));
const deletableIds = selectedDocs
.filter((doc) => doc.status?.state !== "pending" && doc.status?.state !== "processing")
.map((doc) => doc.id);
const inProgressCount = selectedIds.size - deletableIds.length;
if (inProgressCount > 0) {
toast.warning(t("delete_in_progress_warning", { count: inProgressCount }));
}
if (deletableIds.length === 0) return;
try {
let conflictCount = 0;
const results = await Promise.all(
deletableIds.map(async (id) => {
try {
await deleteDocumentMutation({ id });
return true;
} catch (error: unknown) {
const status =
(error as { response?: { status?: number } })?.response?.status ??
(error as { status?: number })?.status;
if (status === 409) conflictCount++;
return false;
}
})
);
const okCount = results.filter((r) => r === true).length;
if (okCount === deletableIds.length) {
toast.success(t("delete_success_count", { count: okCount }));
} else if (conflictCount > 0) {
toast.error(t("delete_conflict_error", { count: conflictCount }));
} else {
toast.error(t("delete_partial_failed"));
}
if (isSearchMode) {
searchRemoveItems(deletableIds);
}
setSelectedIds(new Set());
} catch (e) {
console.error(e);
toast.error(t("delete_error"));
}
};
const handleDeleteDocument = useCallback(
@ -203,10 +176,8 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
<div className="px-4 pb-2">
<DocumentsFilters
typeCounts={realtimeTypeCounts}
selectedIds={selectedIds}
onSearch={setSearch}
searchValue={search}
onBulkDelete={onBulkDelete}
onToggleType={onToggleType}
activeTypes={activeTypes}
/>
@ -216,8 +187,6 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
documents={displayDocs}
loading={!!loading}
error={!!error}
selectedIds={selectedIds}
setSelectedIds={setSelectedIds}
sortKey={sortKey}
sortDesc={sortDesc}
onSortChange={handleSortChange}
@ -227,6 +196,8 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps)
loadingMore={loadingMore}
onLoadMore={onLoadMore}
isSearchMode={isSearchMode}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
/>
</div>
</>

View file

@ -44,7 +44,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance pointer-events-none",
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance pointer-events-none select-none",
className
)}
{...props}