feat(web): enhance inline mention editor and thread components with suggestion trigger info and anchor rect

This commit is contained in:
Anish Sarkar 2026-05-26 14:36:46 +05:30
parent 0d65a2e4e3
commit d445974838
3 changed files with 162 additions and 39 deletions

View file

@ -47,6 +47,17 @@ export type MentionChipInput = {
kind?: MentionKind; kind?: MentionKind;
}; };
export type SuggestionAnchorRect = {
left: number;
top: number;
bottom: number;
};
export type SuggestionTriggerInfo = {
query: string;
anchorRect: SuggestionAnchorRect | null;
};
export interface InlineMentionEditorRef { export interface InlineMentionEditorRef {
focus: () => void; focus: () => void;
clear: () => void; clear: () => void;
@ -73,9 +84,9 @@ export interface InlineMentionEditorRef {
interface InlineMentionEditorProps { interface InlineMentionEditorProps {
placeholder?: string; placeholder?: string;
onMentionTrigger?: (query: string) => void; onMentionTrigger?: (trigger: SuggestionTriggerInfo) => void;
onMentionClose?: () => void; onMentionClose?: () => void;
onActionTrigger?: (query: string) => void; onActionTrigger?: (trigger: SuggestionTriggerInfo) => void;
onActionClose?: () => void; onActionClose?: () => void;
onSubmit?: () => void; onSubmit?: () => void;
onChange?: (text: string, docs: MentionedDocument[]) => void; onChange?: (text: string, docs: MentionedDocument[]) => void;
@ -299,6 +310,36 @@ function scanActiveTrigger(text: string, cursor: number) {
return { triggerChar, query }; 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<InlineMentionEditorRef, InlineMentionEditorProps>( export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMentionEditorProps>(
( (
{ {
@ -360,14 +401,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
return; return;
} }
const triggerInfo: SuggestionTriggerInfo = {
query: trigger.query,
anchorRect: getSelectionAnchorRect(editableRef.current),
};
if (trigger.triggerChar === "@") { if (trigger.triggerChar === "@") {
onMentionTrigger?.(trigger.query);
onActionClose?.(); onActionClose?.();
onMentionTrigger?.(triggerInfo);
return; return;
} }
onActionTrigger?.(trigger.query);
onMentionClose?.(); onMentionClose?.();
onActionTrigger?.(triggerInfo);
}, },
[editor.selection, onActionClose, onActionTrigger, onChange, onMentionClose, onMentionTrigger] [editor.selection, onActionClose, onActionTrigger, onChange, onMentionClose, onMentionTrigger]
); );

View file

@ -62,6 +62,8 @@ import {
InlineMentionEditor, InlineMentionEditor,
type InlineMentionEditorRef, type InlineMentionEditorRef,
type MentionedDocument, type MentionedDocument,
type SuggestionAnchorRect,
type SuggestionTriggerInfo,
} from "@/components/assistant-ui/inline-mention-editor"; } from "@/components/assistant-ui/inline-mention-editor";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { UserMessage } from "@/components/assistant-ui/user-message"; import { UserMessage } from "@/components/assistant-ui/user-message";
@ -112,6 +114,34 @@ import { cn } from "@/lib/utils";
const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs"; const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs";
type ComposerSuggestionAnchorPoint = {
left: number;
top: number;
};
function ComposerSuggestionAnchor({ point }: { point: ComposerSuggestionAnchorPoint }) {
return (
<PopoverAnchor
className="pointer-events-none fixed size-0"
style={{
left: point.left,
top: point.top,
}}
/>
);
}
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 = () => { export const Thread: FC = () => {
return <ThreadContent />; return <ThreadContent />;
}; };
@ -411,6 +441,8 @@ const Composer: FC = () => {
const [showPromptPicker, setShowPromptPicker] = useState(false); const [showPromptPicker, setShowPromptPicker] = useState(false);
const [mentionQuery, setMentionQuery] = useState(""); const [mentionQuery, setMentionQuery] = useState("");
const [actionQuery, setActionQuery] = useState(""); const [actionQuery, setActionQuery] = useState("");
const [suggestionAnchorPoint, setSuggestionAnchorPoint] =
useState<ComposerSuggestionAnchorPoint | null>(null);
const editorRef = useRef<InlineMentionEditorRef>(null); const editorRef = useRef<InlineMentionEditorRef>(null);
const prevMentionedDocsRef = useRef<Map<string, MentionedDocumentInfo>>(new Map()); const prevMentionedDocsRef = useRef<Map<string, MentionedDocumentInfo>>(new Map());
const documentPickerRef = useRef<DocumentMentionPickerRef>(null); const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
@ -491,6 +523,7 @@ const Composer: FC = () => {
lastSeenSlideoutTickRef.current = slideoutOpenedTick; lastSeenSlideoutTickRef.current = slideoutOpenedTick;
setShowDocumentPopover(false); setShowDocumentPopover(false);
setMentionQuery(""); setMentionQuery("");
setSuggestionAnchorPoint(null);
}, [slideoutOpenedTick]); }, [slideoutOpenedTick]);
// Sync editor text into assistant-ui's composer and mirror the chip // Sync editor text into assistant-ui's composer and mirror the chip
@ -523,38 +556,65 @@ const Composer: FC = () => {
[aui, setMentionedDocuments] [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); setShowDocumentPopover(true);
setMentionQuery(query); setMentionQuery(trigger.query);
}, []); }, []);
const handleMentionClose = useCallback(() => { const handleMentionClose = useCallback(() => {
if (showDocumentPopover) { if (showDocumentPopover) {
setShowDocumentPopover(false); setShowDocumentPopover(false);
setMentionQuery(""); setMentionQuery("");
setSuggestionAnchorPoint(null);
} }
}, [showDocumentPopover]); }, [showDocumentPopover]);
const handleDocumentPopoverOpenChange = useCallback((open: boolean) => { const handleDocumentPopoverOpenChange = useCallback((open: boolean) => {
setShowDocumentPopover(open); 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); setShowPromptPicker(true);
setActionQuery(query); setActionQuery(trigger.query);
}, []); }, [clipboardInitialText]);
const handleActionClose = useCallback(() => { const handleActionClose = useCallback(() => {
if (showPromptPicker) { if (showPromptPicker) {
setShowPromptPicker(false); setShowPromptPicker(false);
setActionQuery(""); setActionQuery("");
setSuggestionAnchorPoint(null);
} }
}, [showPromptPicker]); }, [showPromptPicker]);
const handlePromptPickerOpenChange = useCallback((open: boolean) => { const handlePromptPickerOpenChange = useCallback((open: boolean) => {
setShowPromptPicker(open); setShowPromptPicker(open);
if (!open) setActionQuery(""); if (!open) {
setActionQuery("");
setSuggestionAnchorPoint(null);
}
}, []); }, []);
const handleActionSelect = useCallback( const handleActionSelect = useCallback(
@ -573,6 +633,7 @@ const Composer: FC = () => {
aui.composer().setText(finalPrompt); aui.composer().setText(finalPrompt);
setShowPromptPicker(false); setShowPromptPicker(false);
setActionQuery(""); setActionQuery("");
setSuggestionAnchorPoint(null);
}, },
[actionQuery, aui] [actionQuery, aui]
); );
@ -588,6 +649,7 @@ const Composer: FC = () => {
aui.composer().setText(finalPrompt); aui.composer().setText(finalPrompt);
setShowPromptPicker(false); setShowPromptPicker(false);
setActionQuery(""); setActionQuery("");
setSuggestionAnchorPoint(null);
setClipboardInitialText(undefined); setClipboardInitialText(undefined);
}, },
[clipboardInitialText, electronAPI, aui] [clipboardInitialText, electronAPI, aui]
@ -616,6 +678,7 @@ const Composer: FC = () => {
e.preventDefault(); e.preventDefault();
setShowPromptPicker(false); setShowPromptPicker(false);
setActionQuery(""); setActionQuery("");
setSuggestionAnchorPoint(null);
return; return;
} }
} }
@ -639,6 +702,7 @@ const Composer: FC = () => {
e.preventDefault(); e.preventDefault();
setShowDocumentPopover(false); setShowDocumentPopover(false);
setMentionQuery(""); setMentionQuery("");
setSuggestionAnchorPoint(null);
return; return;
} }
} }
@ -699,6 +763,7 @@ const Composer: FC = () => {
// Atom is reconciled by ``handleEditorChange`` via the editor's // Atom is reconciled by ``handleEditorChange`` via the editor's
// onChange — no second write path here. // onChange — no second write path here.
setMentionQuery(""); setMentionQuery("");
setSuggestionAnchorPoint(null);
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -736,7 +801,9 @@ const Composer: FC = () => {
members={members ?? []} members={members ?? []}
/> />
<Popover open={showDocumentPopover} onOpenChange={handleDocumentPopoverOpenChange}> <Popover open={showDocumentPopover} onOpenChange={handleDocumentPopoverOpenChange}>
<PopoverAnchor className="pointer-events-none absolute inset-0" /> {suggestionAnchorPoint ? (
<>
<ComposerSuggestionAnchor point={suggestionAnchorPoint} />
<ComposerSuggestionPopoverContent side="top"> <ComposerSuggestionPopoverContent side="top">
<DocumentMentionPicker <DocumentMentionPicker
ref={documentPickerRef} ref={documentPickerRef}
@ -745,14 +812,19 @@ const Composer: FC = () => {
onDone={() => { onDone={() => {
setShowDocumentPopover(false); setShowDocumentPopover(false);
setMentionQuery(""); setMentionQuery("");
setSuggestionAnchorPoint(null);
}} }}
initialSelectedDocuments={mentionedDocuments} initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery} externalSearch={mentionQuery}
/> />
</ComposerSuggestionPopoverContent> </ComposerSuggestionPopoverContent>
</>
) : null}
</Popover> </Popover>
<Popover open={showPromptPicker} onOpenChange={handlePromptPickerOpenChange}> <Popover open={showPromptPicker} onOpenChange={handlePromptPickerOpenChange}>
<PopoverAnchor className="pointer-events-none absolute inset-0" /> {suggestionAnchorPoint ? (
<>
<ComposerSuggestionAnchor point={suggestionAnchorPoint} />
<ComposerSuggestionPopoverContent side={clipboardInitialText ? "bottom" : "top"}> <ComposerSuggestionPopoverContent side={clipboardInitialText ? "bottom" : "top"}>
<PromptPicker <PromptPicker
ref={promptPickerRef} ref={promptPickerRef}
@ -760,10 +832,13 @@ const Composer: FC = () => {
onDone={() => { onDone={() => {
setShowPromptPicker(false); setShowPromptPicker(false);
setActionQuery(""); setActionQuery("");
setSuggestionAnchorPoint(null);
}} }}
externalSearch={actionQuery} externalSearch={actionQuery}
/> />
</ComposerSuggestionPopoverContent> </ComposerSuggestionPopoverContent>
</>
) : null}
</Popover> </Popover>
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
<div <div

View file

@ -10,7 +10,8 @@ import { cn } from "@/lib/utils";
function ComposerSuggestionPopoverContent({ function ComposerSuggestionPopoverContent({
className, className,
align = "start", align = "start",
sideOffset = 8, sideOffset = 6,
collisionPadding = 12,
onOpenAutoFocus, onOpenAutoFocus,
onCloseAutoFocus, onCloseAutoFocus,
...props ...props
@ -19,6 +20,7 @@ function ComposerSuggestionPopoverContent({
<PopoverContent <PopoverContent
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
collisionPadding={collisionPadding}
onOpenAutoFocus={(event) => { onOpenAutoFocus={(event) => {
event.preventDefault(); event.preventDefault();
onOpenAutoFocus?.(event); onOpenAutoFocus?.(event);
@ -28,7 +30,7 @@ function ComposerSuggestionPopoverContent({
onCloseAutoFocus?.(event); onCloseAutoFocus?.(event);
}} }}
className={cn( 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 className
)} )}
{...props} {...props}