From 1427809119d82c0335eb5f5e600acff816d9d3f4 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:20:53 +0530 Subject: [PATCH] refactor(mentions): consolidate sidebar document handling into mentionedDocumentsAtom and remove sidebarSelectedDocumentsAtom references --- .../new-chat/[[...chat_id]]/page.tsx | 21 ++--- .../atoms/chat/mentioned-documents.atom.ts | 43 +++++----- .../assistant-ui/inline-mention-editor.tsx | 24 ++---- .../components/assistant-ui/thread.tsx | 65 ++++----------- .../layout/ui/sidebar/DocumentsSidebar.tsx | 82 +------------------ 5 files changed, 54 insertions(+), 181 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 06f3bf79f..c363d69d3 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -24,7 +24,6 @@ import { mentionedDocumentIdsAtom, mentionedDocumentsAtom, messageDocumentsMapAtom, - sidebarSelectedDocumentsAtom, } from "@/atoms/chat/mentioned-documents.atom"; import { clearPlanOwnerRegistry, @@ -216,12 +215,10 @@ export default function NewChatPage() { // Get disabled tools from the tool toggle UI const disabledTools = useAtomValue(disabledToolsAtom); - // Get mentioned document IDs from the composer (derived from @ mentions + sidebar selections) + // Get mentioned document IDs from the composer. const mentionedDocumentIds = useAtomValue(mentionedDocumentIdsAtom); const mentionedDocuments = useAtomValue(mentionedDocumentsAtom); - const sidebarDocuments = useAtomValue(sidebarSelectedDocumentsAtom); const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom); - const setSidebarDocuments = useSetAtom(sidebarSelectedDocumentsAtom); const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom); const setCurrentThreadState = useSetAtom(currentThreadAtom); const setTargetCommentId = useSetAtom(setTargetCommentIdAtom); @@ -319,7 +316,6 @@ export default function NewChatPage() { setCurrentThread(null); setMentionedDocuments([]); tokenUsageStore.clear(); - setSidebarDocuments([]); setMessageDocumentsMap({}); clearPlanOwnerRegistry(); closeReportPanel(); @@ -387,7 +383,6 @@ export default function NewChatPage() { urlChatId, setMessageDocumentsMap, setMentionedDocuments, - setSidebarDocuments, closeReportPanel, closeEditorPanel, removeChatTab, @@ -578,15 +573,14 @@ export default function NewChatPage() { messageLength: userQuery.length, }); - // Combine @-mention chips + sidebar selections for display & persistence + // Collect unique mentioned docs for display & persistence const allMentionedDocs: MentionedDocumentInfo[] = []; const seenDocKeys = new Set(); - for (const doc of [...mentionedDocuments, ...sidebarDocuments]) { + for (const doc of mentionedDocuments) { const key = `${doc.document_type}:${doc.id}`; - if (!seenDocKeys.has(key)) { - seenDocKeys.add(key); - allMentionedDocs.push({ id: doc.id, title: doc.title, document_type: doc.document_type }); - } + if (seenDocKeys.has(key)) continue; + seenDocKeys.add(key); + allMentionedDocs.push({ id: doc.id, title: doc.title, document_type: doc.document_type }); } if (allMentionedDocs.length > 0) { @@ -689,7 +683,6 @@ export default function NewChatPage() { // Clear mentioned documents after capturing them if (hasDocumentIds || hasSurfsenseDocIds) { setMentionedDocuments([]); - setSidebarDocuments([]); } const response = await fetch(`${backendUrl}/api/v1/new_chat`, { @@ -979,9 +972,7 @@ export default function NewChatPage() { messages, mentionedDocumentIds, mentionedDocuments, - sidebarDocuments, setMentionedDocuments, - setSidebarDocuments, setMessageDocumentsMap, setAgentCreatedDocuments, queryClient, diff --git a/surfsense_web/atoms/chat/mentioned-documents.atom.ts b/surfsense_web/atoms/chat/mentioned-documents.atom.ts index 27a205af2..47401995d 100644 --- a/surfsense_web/atoms/chat/mentioned-documents.atom.ts +++ b/surfsense_web/atoms/chat/mentioned-documents.atom.ts @@ -10,33 +10,34 @@ import type { Document } from "@/contracts/types/document.types"; export const mentionedDocumentsAtom = atom[]>([]); /** - * Atom to store documents selected via the sidebar checkboxes / row clicks. - * These power the selected-sources badge and backend doc filters. + * Back-compat alias for sidebar checkbox selection. + * This now points to mentionedDocumentsAtom so the app has a single source + * of truth for mentioned/selected documents. */ export const sidebarSelectedDocumentsAtom = atom< - Pick[] ->([]); - -export interface SidebarMentionEvent { - kind: "add" | "remove"; - docs: Pick[]; - nonce: number; -} + Pick[], + [ + | Pick[] + | (( + prev: Pick[] + ) => Pick[]), + ], + void +>( + (get) => get(mentionedDocumentsAtom), + (get, set, update) => { + const prev = get(mentionedDocumentsAtom); + const next = typeof update === "function" ? update(prev) : update; + set(mentionedDocumentsAtom, next); + } +); /** - * Event atom used to tell the composer that documents were selected/unselected - * from sidebar checkboxes, so chips can be inserted/removed in-editor. - */ -export const sidebarMentionEventAtom = atom(null); - -/** - * Derived read-only atom that merges @-mention chips and sidebar selections - * into a single deduplicated set of document IDs for the backend. + * Derived read-only atom that maps deduplicated mentioned docs + * into backend payload fields. */ export const mentionedDocumentIdsAtom = atom((get) => { - const chipDocs = get(mentionedDocumentsAtom); - const sidebarDocs = get(sidebarSelectedDocumentsAtom); - const allDocs = [...chipDocs, ...sidebarDocs]; + const allDocs = get(mentionedDocumentsAtom); const seen = new Set(); const deduped = allDocs.filter((d) => { const key = `${d.document_type}:${d.id}`; diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 31b2a0566..81d6cbd77 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -11,25 +11,13 @@ import { useRef, useState, } from "react"; -import { flushSync } from "react-dom"; -import { createRoot } from "react-dom/client"; +import { renderToStaticMarkup } from "react-dom/server"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { Document } from "@/contracts/types/document.types"; import { cn } from "@/lib/utils"; -// Render a React element to an HTML string on the client without pulling -// `react-dom/server` into the bundle. `createRoot` + `flushSync` use the -// same `react-dom` package React itself imports, so this adds zero new -// runtime weight. function renderElementToHTML(element: ReactElement): string { - const container = document.createElement("div"); - const root = createRoot(container); - flushSync(() => { - root.render(element); - }); - const html = container.innerHTML; - root.unmount(); - return html; + return renderToStaticMarkup(element); } export interface MentionedDocument { @@ -182,7 +170,7 @@ export const InlineMentionEditor = forwardRef { @@ -779,6 +767,7 @@ export const InlineMentionEditor = forwardRef + {/* biome-ignore lint/a11y/noStaticElementInteractions: contenteditable mention editor requires a div for inline chips */}
{/* Placeholder with fade animation on change */} {isEmpty && ( diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 111b5f3cc..dcc068bd1 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -38,12 +38,9 @@ import { import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom"; import { mentionedDocumentsAtom, - sidebarMentionEventAtom, - sidebarSelectedDocumentsAtom, } from "@/atoms/chat/mentioned-documents.atom"; import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; -import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { membersAtom } from "@/atoms/members/members-query.atoms"; import { globalNewLLMConfigsAtom, @@ -336,8 +333,6 @@ const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDi const Composer: FC = () => { // Document mention state (atoms persist across component remounts) const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); - const setSidebarDocs = useSetAtom(sidebarSelectedDocumentsAtom); - const [sidebarMentionEvent, setSidebarMentionEvent] = useAtom(sidebarMentionEventAtom); const [showDocumentPopover, setShowDocumentPopover] = useState(false); const [showPromptPicker, setShowPromptPicker] = useState(false); const [mentionQuery, setMentionQuery] = useState(""); @@ -578,7 +573,6 @@ const Composer: FC = () => { aui.composer().send(); editorRef.current?.clear(); setMentionedDocuments([]); - setSidebarDocs([]); // With turnAnchor="top", ViewportSlack adds min-height to the last // assistant message so that scrolling-to-bottom actually positions the @@ -625,7 +619,6 @@ const Composer: FC = () => { clipboardInitialText, aui, setMentionedDocuments, - setSidebarDocs, threadViewportStore, ]); @@ -663,40 +656,29 @@ const Composer: FC = () => { ); useEffect(() => { - if (!sidebarMentionEvent) return; + const editor = editorRef.current; + if (!editor) return; - const eventDocs = sidebarMentionEvent.docs; - if (eventDocs.length === 0) { - setSidebarMentionEvent(null); - return; + const toKey = (doc: { id: number; document_type?: string }) => + `${doc.document_type ?? "UNKNOWN"}:${doc.id}`; + + const atomDocs = mentionedDocuments; + const editorDocs = editor.getMentionedDocuments(); + const atomKeys = new Set(atomDocs.map(toKey)); + const editorKeys = new Set(editorDocs.map(toKey)); + + for (const doc of atomDocs) { + if (!editorKeys.has(toKey(doc))) { + editor.insertDocumentChip(doc, { removeTriggerText: false }); + } } - const docKey = (doc: Pick) => - `${doc.document_type}:${doc.id}`; - const mentionedKeys = new Set(mentionedDocuments.map(docKey)); - - if (sidebarMentionEvent.kind === "add") { - const docsToAdd = eventDocs.filter((doc) => !mentionedKeys.has(docKey(doc))); - for (const doc of docsToAdd) { - editorRef.current?.insertDocumentChip(doc, { removeTriggerText: false }); + for (const doc of editorDocs) { + if (!atomKeys.has(toKey(doc))) { + editor.removeDocumentChip(doc.id, doc.document_type); } - if (docsToAdd.length > 0) { - setMentionedDocuments((prev) => { - const existing = new Set(prev.map(docKey)); - const uniqueAdds = docsToAdd.filter((doc) => !existing.has(docKey(doc))); - return uniqueAdds.length > 0 ? [...prev, ...uniqueAdds] : prev; - }); - } - } else { - const removeKeys = new Set(eventDocs.map(docKey)); - for (const doc of eventDocs) { - editorRef.current?.removeDocumentChip(doc.id, doc.document_type); - } - setMentionedDocuments((prev) => prev.filter((doc) => !removeKeys.has(docKey(doc)))); } - - setSidebarMentionEvent(null); - }, [sidebarMentionEvent, mentionedDocuments, setMentionedDocuments, setSidebarMentionEvent]); + }, [mentionedDocuments]); return ( @@ -775,8 +757,6 @@ interface ComposerActionProps { const ComposerAction: FC = ({ isBlockedByOtherUser = false }) => { const mentionedDocuments = useAtomValue(mentionedDocumentsAtom); - const sidebarDocs = useAtomValue(sidebarSelectedDocumentsAtom); - const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom); const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom); const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false); const isDesktop = useMediaQuery("(min-width: 640px)"); @@ -1222,15 +1202,6 @@ const ComposerAction: FC = ({ isBlockedByOtherUser = false )} - {sidebarDocs.length > 0 && ( - - )}
{!hasModelConfigured && (
diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 00cc2d4ef..3c5a64b0e 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -24,7 +24,6 @@ import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { - sidebarMentionEventAtom, sidebarSelectedDocumentsAtom, } from "@/atoms/chat/mentioned-documents.atom"; import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; @@ -416,7 +415,6 @@ function AuthenticatedDocumentsSidebarBase({ const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom); - const setSidebarMentionEvent = useSetAtom(sidebarMentionEventAtom); const mentionedDocIds = useMemo(() => new Set(sidebarDocs.map((d) => d.id)), [sidebarDocs]); // Folder state @@ -864,17 +862,6 @@ function AuthenticatedDocumentsSidebarBase({ const key = `${doc.document_type}:${doc.id}`; if (isMentioned) { setSidebarDocs((prev) => prev.filter((d) => `${d.document_type}:${d.id}` !== key)); - setSidebarMentionEvent({ - kind: "remove", - docs: [ - { - id: doc.id, - title: doc.title, - document_type: doc.document_type as DocumentTypeEnum, - }, - ], - nonce: Date.now(), - }); } else { setSidebarDocs((prev) => { if (prev.some((d) => `${d.document_type}:${d.id}` === key)) return prev; @@ -883,20 +870,9 @@ function AuthenticatedDocumentsSidebarBase({ { id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum }, ]; }); - setSidebarMentionEvent({ - kind: "add", - docs: [ - { - id: doc.id, - title: doc.title, - document_type: doc.document_type as DocumentTypeEnum, - }, - ], - nonce: Date.now(), - }); } }, - [setSidebarDocs, setSidebarMentionEvent] + [setSidebarDocs] ); const handleToggleFolderSelect = useCallback( @@ -918,14 +894,6 @@ function AuthenticatedDocumentsSidebarBase({ if (subtreeDocs.length === 0) return; if (selectAll) { - const existingKeys = new Set(sidebarDocs.map((d) => `${d.document_type}:${d.id}`)); - const docsToAdd = subtreeDocs - .filter((d) => !existingKeys.has(`${d.document_type}:${d.id}`)) - .map((d) => ({ - id: d.id, - title: d.title, - document_type: d.document_type as DocumentTypeEnum, - })); setSidebarDocs((prev) => { const existingDocKeys = new Set(prev.map((d) => `${d.document_type}:${d.id}`)); const newDocs = subtreeDocs @@ -937,35 +905,14 @@ function AuthenticatedDocumentsSidebarBase({ })); return newDocs.length > 0 ? [...prev, ...newDocs] : prev; }); - if (docsToAdd.length > 0) { - setSidebarMentionEvent({ - kind: "add", - docs: docsToAdd, - nonce: Date.now(), - }); - } } else { const keysToRemove = new Set(subtreeDocs.map((d) => `${d.document_type}:${d.id}`)); - const docsToRemove = sidebarDocs - .filter((d) => keysToRemove.has(`${d.document_type}:${d.id}`)) - .map((d) => ({ - id: d.id, - title: d.title, - document_type: d.document_type as DocumentTypeEnum, - })); setSidebarDocs((prev) => prev.filter((d) => !keysToRemove.has(`${d.document_type}:${d.id}`)) ); - if (docsToRemove.length > 0) { - setSidebarMentionEvent({ - kind: "remove", - docs: docsToRemove, - nonce: Date.now(), - }); - } } }, - [treeDocuments, foldersByParent, sidebarDocs, setSidebarDocs, setSidebarMentionEvent] + [treeDocuments, foldersByParent, setSidebarDocs] ); const searchFilteredDocuments = useMemo(() => { @@ -1626,7 +1573,6 @@ function AnonymousDocumentsSidebar({ const [search, setSearch] = useState(""); const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom); - const setSidebarMentionEvent = useSetAtom(sidebarMentionEventAtom); const mentionedDocIds = useMemo(() => new Set(sidebarDocs.map((d) => d.id)), [sidebarDocs]); const handleToggleChatMention = useCallback( @@ -1634,17 +1580,6 @@ function AnonymousDocumentsSidebar({ const key = `${doc.document_type}:${doc.id}`; if (isMentioned) { setSidebarDocs((prev) => prev.filter((d) => `${d.document_type}:${d.id}` !== key)); - setSidebarMentionEvent({ - kind: "remove", - docs: [ - { - id: doc.id, - title: doc.title, - document_type: doc.document_type as DocumentTypeEnum, - }, - ], - nonce: Date.now(), - }); } else { setSidebarDocs((prev) => { if (prev.some((d) => `${d.document_type}:${d.id}` === key)) return prev; @@ -1653,20 +1588,9 @@ function AnonymousDocumentsSidebar({ { id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum }, ]; }); - setSidebarMentionEvent({ - kind: "add", - docs: [ - { - id: doc.id, - title: doc.title, - document_type: doc.document_type as DocumentTypeEnum, - }, - ], - nonce: Date.now(), - }); } }, - [setSidebarDocs, setSidebarMentionEvent] + [setSidebarDocs] ); const uploadedDoc = anonMode.isAnonymous ? anonMode.uploadedDoc : null;