refactor(mentions): consolidate sidebar document handling into mentionedDocumentsAtom and remove sidebarSelectedDocumentsAtom references

This commit is contained in:
Anish Sarkar 2026-04-28 18:20:53 +05:30
parent 960f761c6c
commit 1427809119
5 changed files with 54 additions and 181 deletions

View file

@ -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<string>();
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,

View file

@ -10,33 +10,34 @@ import type { Document } from "@/contracts/types/document.types";
export const mentionedDocumentsAtom = atom<Pick<Document, "id" | "title" | "document_type">[]>([]);
/**
* 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<Document, "id" | "title" | "document_type">[]
>([]);
export interface SidebarMentionEvent {
kind: "add" | "remove";
docs: Pick<Document, "id" | "title" | "document_type">[];
nonce: number;
}
Pick<Document, "id" | "title" | "document_type">[],
[
| Pick<Document, "id" | "title" | "document_type">[]
| ((
prev: Pick<Document, "id" | "title" | "document_type">[]
) => Pick<Document, "id" | "title" | "document_type">[]),
],
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<SidebarMentionEvent | null>(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<string>();
const deduped = allDocs.filter((d) => {
const key = `${d.document_type}:${d.id}`;

View file

@ -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<InlineMentionEditorRef, InlineMent
editorRef.current.appendChild(document.createElement("br"));
editorRef.current.appendChild(document.createElement("br"));
setIsEmpty(false);
onChange?.(initialText, Array.from(mentionedDocs.values()));
onChange?.(initialText, initialDocuments);
editorRef.current.focus();
const sel = window.getSelection();
const range = document.createRange();
@ -194,7 +182,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
range.insertNode(anchor);
anchor.scrollIntoView({ block: "end" });
anchor.remove();
}, [initialText]);
}, [initialText, initialDocuments, onChange]);
// Focus at the end of the editor
const focusAtEnd = useCallback(() => {
@ -779,6 +767,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
return (
<div className="relative w-full">
{/* biome-ignore lint/a11y/noStaticElementInteractions: contenteditable mention editor requires a div for inline chips */}
<div
ref={editorRef}
contentEditable={!disabled}
@ -801,9 +790,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
)}
style={{ wordBreak: "break-word" }}
data-placeholder={placeholder}
aria-label="Message input with inline mentions"
role="textbox"
aria-multiline="true"
/>
{/* Placeholder with fade animation on change */}
{isEmpty && (

View file

@ -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<Document, "id" | "title" | "document_type">) =>
`${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 (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
@ -775,8 +757,6 @@ interface ComposerActionProps {
const ComposerAction: FC<ComposerActionProps> = ({ 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<ComposerActionProps> = ({ isBlockedByOtherUser = false
</AnimatePresence>
</button>
)}
{sidebarDocs.length > 0 && (
<button
type="button"
onClick={() => setDocumentsSidebarOpen(true)}
className="rounded-full border border-border/60 bg-accent/50 px-2.5 py-1 text-xs font-medium text-foreground/80 transition-colors hover:bg-accent"
>
{sidebarDocs.length} {sidebarDocs.length === 1 ? "source" : "sources"} selected
</button>
)}
</div>
{!hasModelConfigured && (
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">

View file

@ -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;