From d4459748384dd6ce9c5e9bce80d9ffa5ac6673c3 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 26 May 2026 14:36:46 +0530 Subject: [PATCH] feat(web): enhance inline mention editor and thread components with suggestion trigger info and anchor rect --- .../assistant-ui/inline-mention-editor.tsx | 54 ++++++- .../components/assistant-ui/thread.tsx | 141 ++++++++++++++---- .../new-chat/composer-suggestion-popup.tsx | 6 +- 3 files changed, 162 insertions(+), 39 deletions(-) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 2c8ad6263..f67fb36dc 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -47,6 +47,17 @@ export type MentionChipInput = { kind?: MentionKind; }; +export type SuggestionAnchorRect = { + left: number; + top: number; + bottom: number; +}; + +export type SuggestionTriggerInfo = { + query: string; + anchorRect: SuggestionAnchorRect | null; +}; + export interface InlineMentionEditorRef { focus: () => void; clear: () => void; @@ -73,9 +84,9 @@ export interface InlineMentionEditorRef { interface InlineMentionEditorProps { placeholder?: string; - onMentionTrigger?: (query: string) => void; + onMentionTrigger?: (trigger: SuggestionTriggerInfo) => void; onMentionClose?: () => void; - onActionTrigger?: (query: string) => void; + onActionTrigger?: (trigger: SuggestionTriggerInfo) => void; onActionClose?: () => void; onSubmit?: () => void; onChange?: (text: string, docs: MentionedDocument[]) => void; @@ -299,6 +310,36 @@ function scanActiveTrigger(text: string, cursor: number) { return { triggerChar, query }; } +function rectToAnchor(rect: DOMRect): SuggestionAnchorRect { + return { + left: rect.left, + top: rect.top, + bottom: rect.bottom, + }; +} + +function getSelectionAnchorRect(root: HTMLElement | null): SuggestionAnchorRect | null { + if (!root || typeof window === "undefined") return null; + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || !selection.anchorNode) return null; + if (!root.contains(selection.anchorNode)) return null; + + const range = selection.getRangeAt(0).cloneRange(); + const rect = range.getClientRects()[0] ?? range.getBoundingClientRect(); + if (rect.width > 0 || rect.height > 0) return rectToAnchor(rect); + + if (range.collapsed && range.startContainer.nodeType === Node.TEXT_NODE && range.startOffset > 0) { + const fallbackRange = range.cloneRange(); + fallbackRange.setStart(range.startContainer, range.startOffset - 1); + fallbackRange.setEnd(range.startContainer, range.startOffset); + const fallbackRect = fallbackRange.getClientRects()[0] ?? fallbackRange.getBoundingClientRect(); + if (fallbackRect.width > 0 || fallbackRect.height > 0) return rectToAnchor(fallbackRect); + } + + return null; +} + export const InlineMentionEditor = forwardRef( ( { @@ -360,14 +401,19 @@ export const InlineMentionEditor = forwardRef + ); +} + +function getComposerSuggestionAnchorPoint( + triggerRect: SuggestionAnchorRect | null, + side: "top" | "bottom" +): ComposerSuggestionAnchorPoint | null { + if (!triggerRect) return null; + return { + left: triggerRect.left, + top: side === "bottom" ? triggerRect.bottom : triggerRect.top, + }; +} + export const Thread: FC = () => { return ; }; @@ -411,6 +441,8 @@ const Composer: FC = () => { const [showPromptPicker, setShowPromptPicker] = useState(false); const [mentionQuery, setMentionQuery] = useState(""); const [actionQuery, setActionQuery] = useState(""); + const [suggestionAnchorPoint, setSuggestionAnchorPoint] = + useState(null); const editorRef = useRef(null); const prevMentionedDocsRef = useRef>(new Map()); const documentPickerRef = useRef(null); @@ -491,6 +523,7 @@ const Composer: FC = () => { lastSeenSlideoutTickRef.current = slideoutOpenedTick; setShowDocumentPopover(false); setMentionQuery(""); + setSuggestionAnchorPoint(null); }, [slideoutOpenedTick]); // Sync editor text into assistant-ui's composer and mirror the chip @@ -523,38 +556,65 @@ const Composer: FC = () => { [aui, setMentionedDocuments] ); - const handleMentionTrigger = useCallback((query: string) => { + const handleMentionTrigger = useCallback((trigger: SuggestionTriggerInfo) => { + const anchorPoint = getComposerSuggestionAnchorPoint(trigger.anchorRect, "top"); + if (!anchorPoint) { + setShowDocumentPopover(false); + setMentionQuery(""); + setSuggestionAnchorPoint(null); + return; + } + setSuggestionAnchorPoint(anchorPoint); setShowDocumentPopover(true); - setMentionQuery(query); + setMentionQuery(trigger.query); }, []); const handleMentionClose = useCallback(() => { if (showDocumentPopover) { setShowDocumentPopover(false); setMentionQuery(""); + setSuggestionAnchorPoint(null); } }, [showDocumentPopover]); const handleDocumentPopoverOpenChange = useCallback((open: boolean) => { setShowDocumentPopover(open); - if (!open) setMentionQuery(""); + if (!open) { + setMentionQuery(""); + setSuggestionAnchorPoint(null); + } }, []); - const handleActionTrigger = useCallback((query: string) => { + const handleActionTrigger = useCallback((trigger: SuggestionTriggerInfo) => { + const anchorPoint = getComposerSuggestionAnchorPoint( + trigger.anchorRect, + clipboardInitialText ? "bottom" : "top" + ); + if (!anchorPoint) { + setShowPromptPicker(false); + setActionQuery(""); + setSuggestionAnchorPoint(null); + return; + } + setSuggestionAnchorPoint(anchorPoint); setShowPromptPicker(true); - setActionQuery(query); - }, []); + setActionQuery(trigger.query); + }, [clipboardInitialText]); const handleActionClose = useCallback(() => { if (showPromptPicker) { setShowPromptPicker(false); setActionQuery(""); + setSuggestionAnchorPoint(null); } }, [showPromptPicker]); const handlePromptPickerOpenChange = useCallback((open: boolean) => { setShowPromptPicker(open); - if (!open) setActionQuery(""); + if (!open) { + setActionQuery(""); + setSuggestionAnchorPoint(null); + } }, []); const handleActionSelect = useCallback( @@ -573,6 +633,7 @@ const Composer: FC = () => { aui.composer().setText(finalPrompt); setShowPromptPicker(false); setActionQuery(""); + setSuggestionAnchorPoint(null); }, [actionQuery, aui] ); @@ -588,6 +649,7 @@ const Composer: FC = () => { aui.composer().setText(finalPrompt); setShowPromptPicker(false); setActionQuery(""); + setSuggestionAnchorPoint(null); setClipboardInitialText(undefined); }, [clipboardInitialText, electronAPI, aui] @@ -616,6 +678,7 @@ const Composer: FC = () => { e.preventDefault(); setShowPromptPicker(false); setActionQuery(""); + setSuggestionAnchorPoint(null); return; } } @@ -639,6 +702,7 @@ const Composer: FC = () => { e.preventDefault(); setShowDocumentPopover(false); setMentionQuery(""); + setSuggestionAnchorPoint(null); return; } } @@ -699,6 +763,7 @@ const Composer: FC = () => { // Atom is reconciled by ``handleEditorChange`` via the editor's // onChange — no second write path here. setMentionQuery(""); + setSuggestionAnchorPoint(null); }, []); useEffect(() => { @@ -736,34 +801,44 @@ const Composer: FC = () => { members={members ?? []} /> - - - { - setShowDocumentPopover(false); - setMentionQuery(""); - }} - initialSelectedDocuments={mentionedDocuments} - externalSearch={mentionQuery} - /> - + {suggestionAnchorPoint ? ( + <> + + + { + setShowDocumentPopover(false); + setMentionQuery(""); + setSuggestionAnchorPoint(null); + }} + initialSelectedDocuments={mentionedDocuments} + externalSearch={mentionQuery} + /> + + + ) : null} - - - { - setShowPromptPicker(false); - setActionQuery(""); - }} - externalSearch={actionQuery} - /> - + {suggestionAnchorPoint ? ( + <> + + + { + setShowPromptPicker(false); + setActionQuery(""); + setSuggestionAnchorPoint(null); + }} + externalSearch={actionQuery} + /> + + + ) : null}
{ event.preventDefault(); onOpenAutoFocus?.(event); @@ -28,7 +30,7 @@ function ComposerSuggestionPopoverContent({ onCloseAutoFocus?.(event); }} className={cn( - "w-[280px] overflow-hidden rounded-xl border border-popover-border bg-popover p-0 text-popover-foreground shadow-2xl sm:w-[320px]", + "w-[280px] overflow-hidden rounded-md border border-popover-border bg-popover p-0 text-popover-foreground shadow-md sm:w-[320px]", className )} {...props}