feat(mentions): add sidebar mention event atom and enhance document mention handling in the editor

This commit is contained in:
Anish Sarkar 2026-04-28 17:50:21 +05:30
parent f607636ba6
commit 960f761c6c
4 changed files with 227 additions and 27 deletions

View file

@ -44,7 +44,10 @@ export interface InlineMentionEditorRef {
setText: (text: string) => void;
getText: () => string;
getMentionedDocuments: () => MentionedDocument[];
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
insertDocumentChip: (
doc: Pick<Document, "id" | "title" | "document_type">,
options?: { removeTriggerText?: boolean }
) => void;
removeDocumentChip: (docId: number, docType?: string) => void;
setDocumentChipStatus: (
docId: number,
@ -129,6 +132,40 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
() => new Map(initialDocuments.map((d) => [`${d.document_type ?? "UNKNOWN"}:${d.id}`, d]))
);
const isComposingRef = useRef(false);
const lastSelectionRangeRef = useRef<Range | null>(null);
const isSelectionInsideEditor = useCallback(
(selection: Selection | null): selection is Selection => {
if (!selection || selection.rangeCount === 0 || !editorRef.current) return false;
const range = selection.getRangeAt(0);
return editorRef.current.contains(range.startContainer);
},
[]
);
const rememberSelection = useCallback(() => {
const selection = window.getSelection();
if (!isSelectionInsideEditor(selection)) return;
lastSelectionRangeRef.current = selection.getRangeAt(0).cloneRange();
}, [isSelectionInsideEditor]);
const restoreRememberedSelection = useCallback((): Selection | null => {
const selection = window.getSelection();
if (!selection) return null;
if (!lastSelectionRangeRef.current) return selection;
selection.removeAllRanges();
selection.addRange(lastSelectionRangeRef.current.cloneRange());
return selection;
}, []);
useEffect(() => {
const handleSelectionChange = () => {
if (document.activeElement !== editorRef.current) return;
rememberSelection();
};
document.addEventListener("selectionchange", handleSelectionChange);
return () => document.removeEventListener("selectionchange", handleSelectionChange);
}, [rememberSelection]);
// Sync initial documents
useEffect(() => {
@ -157,7 +194,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
range.insertNode(anchor);
anchor.scrollIntoView({ block: "end" });
anchor.remove();
}, [initialText]); // eslint-disable-line react-hooks/exhaustive-deps
}, [initialText]);
// Focus at the end of the editor
const focusAtEnd = useCallback(() => {
@ -299,8 +336,12 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
// Insert a document chip at the current cursor position
const insertDocumentChip = useCallback(
(doc: Pick<Document, "id" | "title" | "document_type">) => {
(
doc: Pick<Document, "id" | "title" | "document_type">,
options?: { removeTriggerText?: boolean }
) => {
if (!editorRef.current) return;
const removeTriggerText = options?.removeTriggerText ?? true;
// Validate required fields for type safety
if (typeof doc.id !== "number" || typeof doc.title !== "string") {
@ -320,20 +361,23 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
// Find and remove the @query text
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
const hasActiveSelection = isSelectionInsideEditor(selection);
const resolvedSelection = hasActiveSelection ? selection : restoreRememberedSelection();
if (!resolvedSelection || resolvedSelection.rangeCount === 0) {
// No selection, just append
const chip = createChipElement(mentionDoc);
editorRef.current.appendChild(chip);
editorRef.current.appendChild(document.createTextNode(" "));
focusAtEnd();
rememberSelection();
return;
}
// Find the @ symbol before the cursor and remove it along with any query text
const range = selection.getRangeAt(0);
const range = resolvedSelection.getRangeAt(0);
const textNode = range.startContainer;
if (textNode.nodeType === Node.TEXT_NODE) {
if (textNode.nodeType === Node.TEXT_NODE && removeTriggerText) {
const text = textNode.textContent || "";
const cursorPos = range.startOffset;
@ -369,8 +413,9 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const newRange = document.createRange();
newRange.setStart(afterNode, 1);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
resolvedSelection.removeAllRanges();
resolvedSelection.addRange(newRange);
rememberSelection();
}
} else {
// No @ found, just insert at cursor
@ -384,13 +429,23 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
range.insertNode(space);
range.setStartAfter(space);
range.collapse(true);
resolvedSelection.removeAllRanges();
resolvedSelection.addRange(range);
rememberSelection();
}
} else {
// Not in a text node, append to editor
// Either explicit non-trigger insertion or no @query present.
const chip = createChipElement(mentionDoc);
editorRef.current.appendChild(chip);
editorRef.current.appendChild(document.createTextNode(" "));
focusAtEnd();
range.insertNode(chip);
range.setStartAfter(chip);
range.collapse(true);
const space = document.createTextNode(" ");
range.insertNode(space);
range.setStartAfter(space);
range.collapse(true);
resolvedSelection.removeAllRanges();
resolvedSelection.addRange(range);
rememberSelection();
}
// Update empty state
@ -403,7 +458,16 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
}, 0);
}
},
[createChipElement, focusAtEnd, getText, getMentionedDocuments, onChange]
[
createChipElement,
focusAtEnd,
getText,
getMentionedDocuments,
isSelectionInsideEditor,
onChange,
rememberSelection,
restoreRememberedSelection,
]
);
// Clear the editor
@ -594,6 +658,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
// Notify parent of change
onChange?.(text, Array.from(mentionedDocs.values()));
rememberSelection();
}, [
getText,
mentionedDocs,
@ -602,6 +667,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
onMentionClose,
onActionTrigger,
onActionClose,
rememberSelection,
]);
// Handle keydown
@ -713,7 +779,6 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
return (
<div className="relative w-full">
{/** biome-ignore lint/a11y/useSemanticElements: <not important> */}
<div
ref={editorRef}
contentEditable={!disabled}
@ -724,6 +789,9 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyUp={rememberSelection}
onMouseUp={rememberSelection}
onBlur={rememberSelection}
className={cn(
"min-h-[24px] max-h-32 overflow-y-auto",
"text-sm outline-none",

View file

@ -38,6 +38,7 @@ 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";
@ -336,6 +337,7 @@ 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("");
@ -660,6 +662,42 @@ const Composer: FC = () => {
[mentionedDocuments, setMentionedDocuments]
);
useEffect(() => {
if (!sidebarMentionEvent) return;
const eventDocs = sidebarMentionEvent.docs;
if (eventDocs.length === 0) {
setSidebarMentionEvent(null);
return;
}
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 });
}
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]);
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
<ChatSessionStatus