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;
};
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<InlineMentionEditorRef, InlineMentionEditorProps>(
(
{
@ -360,14 +401,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
return;
}
const triggerInfo: SuggestionTriggerInfo = {
query: trigger.query,
anchorRect: getSelectionAnchorRect(editableRef.current),
};
if (trigger.triggerChar === "@") {
onMentionTrigger?.(trigger.query);
onActionClose?.();
onMentionTrigger?.(triggerInfo);
return;
}
onActionTrigger?.(trigger.query);
onMentionClose?.();
onActionTrigger?.(triggerInfo);
},
[editor.selection, onActionClose, onActionTrigger, onChange, onMentionClose, onMentionTrigger]
);

View file

@ -62,6 +62,8 @@ import {
InlineMentionEditor,
type InlineMentionEditorRef,
type MentionedDocument,
type SuggestionAnchorRect,
type SuggestionTriggerInfo,
} from "@/components/assistant-ui/inline-mention-editor";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
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";
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 = () => {
return <ThreadContent />;
};
@ -411,6 +441,8 @@ const Composer: FC = () => {
const [showPromptPicker, setShowPromptPicker] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const [actionQuery, setActionQuery] = useState("");
const [suggestionAnchorPoint, setSuggestionAnchorPoint] =
useState<ComposerSuggestionAnchorPoint | null>(null);
const editorRef = useRef<InlineMentionEditorRef>(null);
const prevMentionedDocsRef = useRef<Map<string, MentionedDocumentInfo>>(new Map());
const documentPickerRef = useRef<DocumentMentionPickerRef>(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 ?? []}
/>
<Popover open={showDocumentPopover} onOpenChange={handleDocumentPopoverOpenChange}>
<PopoverAnchor className="pointer-events-none absolute inset-0" />
<ComposerSuggestionPopoverContent side="top">
<DocumentMentionPicker
ref={documentPickerRef}
searchSpaceId={Number(search_space_id)}
onSelectionChange={handleDocumentsMention}
onDone={() => {
setShowDocumentPopover(false);
setMentionQuery("");
}}
initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery}
/>
</ComposerSuggestionPopoverContent>
{suggestionAnchorPoint ? (
<>
<ComposerSuggestionAnchor point={suggestionAnchorPoint} />
<ComposerSuggestionPopoverContent side="top">
<DocumentMentionPicker
ref={documentPickerRef}
searchSpaceId={Number(search_space_id)}
onSelectionChange={handleDocumentsMention}
onDone={() => {
setShowDocumentPopover(false);
setMentionQuery("");
setSuggestionAnchorPoint(null);
}}
initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery}
/>
</ComposerSuggestionPopoverContent>
</>
) : null}
</Popover>
<Popover open={showPromptPicker} onOpenChange={handlePromptPickerOpenChange}>
<PopoverAnchor className="pointer-events-none absolute inset-0" />
<ComposerSuggestionPopoverContent side={clipboardInitialText ? "bottom" : "top"}>
<PromptPicker
ref={promptPickerRef}
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
onDone={() => {
setShowPromptPicker(false);
setActionQuery("");
}}
externalSearch={actionQuery}
/>
</ComposerSuggestionPopoverContent>
{suggestionAnchorPoint ? (
<>
<ComposerSuggestionAnchor point={suggestionAnchorPoint} />
<ComposerSuggestionPopoverContent side={clipboardInitialText ? "bottom" : "top"}>
<PromptPicker
ref={promptPickerRef}
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
onDone={() => {
setShowPromptPicker(false);
setActionQuery("");
setSuggestionAnchorPoint(null);
}}
externalSearch={actionQuery}
/>
</ComposerSuggestionPopoverContent>
</>
) : null}
</Popover>
<div className="flex w-full flex-col">
<div