mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-26 17:26:23 +02:00
feat: add InlineMentionEditor component for document mentions
- Introduced InlineMentionEditor to allow users to mention documents inline using '@'. - Integrated the new editor into the Composer component, replacing the previous textarea input. - Implemented functionality for handling document chips, mention triggers, and document removal. - Enhanced user experience with real-time updates and improved keyboard navigation for mentions.
This commit is contained in:
parent
bf22156664
commit
533084b433
2 changed files with 606 additions and 153 deletions
487
surfsense_web/components/assistant-ui/inline-mention-editor.tsx
Normal file
487
surfsense_web/components/assistant-ui/inline-mention-editor.tsx
Normal file
|
|
@ -0,0 +1,487 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import type { Document } from "@/contracts/types/document.types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface MentionedDocument {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
document_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InlineMentionEditorRef {
|
||||||
|
focus: () => void;
|
||||||
|
clear: () => void;
|
||||||
|
getText: () => string;
|
||||||
|
getMentionedDocuments: () => MentionedDocument[];
|
||||||
|
insertDocumentChip: (doc: Document) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InlineMentionEditorProps {
|
||||||
|
placeholder?: string;
|
||||||
|
onMentionTrigger?: (query: string) => void;
|
||||||
|
onMentionClose?: () => void;
|
||||||
|
onSubmit?: () => void;
|
||||||
|
onChange?: (text: string, docs: MentionedDocument[]) => void;
|
||||||
|
onDocumentRemove?: (docId: number) => void;
|
||||||
|
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
initialDocuments?: MentionedDocument[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unique data attribute to identify chip elements
|
||||||
|
const CHIP_DATA_ATTR = "data-mention-chip";
|
||||||
|
const CHIP_ID_ATTR = "data-mention-id";
|
||||||
|
|
||||||
|
export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMentionEditorProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
placeholder = "Type @ to mention documents...",
|
||||||
|
onMentionTrigger,
|
||||||
|
onMentionClose,
|
||||||
|
onSubmit,
|
||||||
|
onChange,
|
||||||
|
onDocumentRemove,
|
||||||
|
onKeyDown,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
initialDocuments = [],
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isEmpty, setIsEmpty] = useState(true);
|
||||||
|
const [mentionedDocs, setMentionedDocs] = useState<Map<number, MentionedDocument>>(
|
||||||
|
() => new Map(initialDocuments.map((d) => [d.id, d]))
|
||||||
|
);
|
||||||
|
const isComposingRef = useRef(false);
|
||||||
|
const lastCaretPositionRef = useRef<{ node: Node; offset: number } | null>(null);
|
||||||
|
|
||||||
|
// Sync initial documents
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialDocuments.length > 0) {
|
||||||
|
setMentionedDocs(new Map(initialDocuments.map((d) => [d.id, d])));
|
||||||
|
}
|
||||||
|
}, [initialDocuments]);
|
||||||
|
|
||||||
|
// Save caret position before any operations that might lose it
|
||||||
|
const saveCaretPosition = useCallback(() => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.rangeCount > 0) {
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
lastCaretPositionRef.current = {
|
||||||
|
node: range.startContainer,
|
||||||
|
offset: range.startOffset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Restore caret position
|
||||||
|
const restoreCaretPosition = useCallback(() => {
|
||||||
|
if (lastCaretPositionRef.current && editorRef.current) {
|
||||||
|
const { node, offset } = lastCaretPositionRef.current;
|
||||||
|
try {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(node, offset);
|
||||||
|
range.collapse(true);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
} catch {
|
||||||
|
// Node might not exist anymore, focus at end
|
||||||
|
focusAtEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Focus at the end of the editor
|
||||||
|
const focusAtEnd = useCallback(() => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
editorRef.current.focus();
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(editorRef.current);
|
||||||
|
range.collapse(false);
|
||||||
|
selection?.removeAllRanges();
|
||||||
|
selection?.addRange(range);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get plain text content (excluding chips)
|
||||||
|
const getText = useCallback((): string => {
|
||||||
|
if (!editorRef.current) return "";
|
||||||
|
|
||||||
|
let text = "";
|
||||||
|
const walker = document.createTreeWalker(
|
||||||
|
editorRef.current,
|
||||||
|
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
|
||||||
|
{
|
||||||
|
acceptNode: (node) => {
|
||||||
|
// Skip chip elements entirely
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const el = node as Element;
|
||||||
|
if (el.hasAttribute(CHIP_DATA_ATTR)) {
|
||||||
|
return NodeFilter.FILTER_REJECT; // Skip this subtree
|
||||||
|
}
|
||||||
|
return NodeFilter.FILTER_SKIP; // Continue into children
|
||||||
|
}
|
||||||
|
return NodeFilter.FILTER_ACCEPT;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let node: Node | null;
|
||||||
|
while ((node = walker.nextNode())) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
text += node.textContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.trim();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get all mentioned documents
|
||||||
|
const getMentionedDocuments = useCallback((): MentionedDocument[] => {
|
||||||
|
return Array.from(mentionedDocs.values());
|
||||||
|
}, [mentionedDocs]);
|
||||||
|
|
||||||
|
// Create a chip element for a document
|
||||||
|
const createChipElement = useCallback((doc: MentionedDocument): HTMLSpanElement => {
|
||||||
|
const chip = document.createElement("span");
|
||||||
|
chip.setAttribute(CHIP_DATA_ATTR, "true");
|
||||||
|
chip.setAttribute(CHIP_ID_ATTR, String(doc.id));
|
||||||
|
chip.contentEditable = "false";
|
||||||
|
chip.className =
|
||||||
|
"inline-flex items-center gap-0.5 mx-0.5 px-1 rounded bg-primary/10 text-xs font-medium text-primary border border-primary/20 select-none";
|
||||||
|
chip.style.userSelect = "none";
|
||||||
|
chip.style.verticalAlign = "baseline";
|
||||||
|
|
||||||
|
const titleSpan = document.createElement("span");
|
||||||
|
titleSpan.className = "max-w-[80px] truncate";
|
||||||
|
titleSpan.textContent = doc.title;
|
||||||
|
titleSpan.title = doc.title;
|
||||||
|
|
||||||
|
const removeBtn = document.createElement("button");
|
||||||
|
removeBtn.type = "button";
|
||||||
|
removeBtn.className =
|
||||||
|
"size-3 flex items-center justify-center rounded-full hover:bg-primary/20 transition-colors ml-0.5";
|
||||||
|
removeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`;
|
||||||
|
removeBtn.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
chip.remove();
|
||||||
|
setMentionedDocs((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(doc.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Notify parent that a document was removed
|
||||||
|
onDocumentRemove?.(doc.id);
|
||||||
|
focusAtEnd();
|
||||||
|
};
|
||||||
|
|
||||||
|
chip.appendChild(titleSpan);
|
||||||
|
chip.appendChild(removeBtn);
|
||||||
|
|
||||||
|
return chip;
|
||||||
|
}, [focusAtEnd, onDocumentRemove]);
|
||||||
|
|
||||||
|
// Insert a document chip at the current cursor position
|
||||||
|
const insertDocumentChip = useCallback(
|
||||||
|
(doc: Document) => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
|
const mentionDoc: MentionedDocument = {
|
||||||
|
id: doc.id,
|
||||||
|
title: doc.title,
|
||||||
|
document_type: doc.document_type,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to mentioned docs map
|
||||||
|
setMentionedDocs((prev) => new Map(prev).set(doc.id, mentionDoc));
|
||||||
|
|
||||||
|
// Find and remove the @query text
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) {
|
||||||
|
// No selection, just append
|
||||||
|
const chip = createChipElement(mentionDoc);
|
||||||
|
editorRef.current.appendChild(chip);
|
||||||
|
editorRef.current.appendChild(document.createTextNode(" "));
|
||||||
|
focusAtEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the @ symbol before the cursor and remove it along with any query text
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const textNode = range.startContainer;
|
||||||
|
|
||||||
|
if (textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = textNode.textContent || "";
|
||||||
|
const cursorPos = range.startOffset;
|
||||||
|
|
||||||
|
// Find the @ symbol before cursor
|
||||||
|
let atIndex = -1;
|
||||||
|
for (let i = cursorPos - 1; i >= 0; i--) {
|
||||||
|
if (text[i] === "@") {
|
||||||
|
atIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (atIndex !== -1) {
|
||||||
|
// Remove @query and insert chip
|
||||||
|
const beforeAt = text.slice(0, atIndex);
|
||||||
|
const afterCursor = text.slice(cursorPos);
|
||||||
|
|
||||||
|
// Create chip
|
||||||
|
const chip = createChipElement(mentionDoc);
|
||||||
|
|
||||||
|
// Replace text node content
|
||||||
|
const parent = textNode.parentNode;
|
||||||
|
if (parent) {
|
||||||
|
const beforeNode = document.createTextNode(beforeAt);
|
||||||
|
const afterNode = document.createTextNode(" " + afterCursor);
|
||||||
|
|
||||||
|
parent.insertBefore(beforeNode, textNode);
|
||||||
|
parent.insertBefore(chip, textNode);
|
||||||
|
parent.insertBefore(afterNode, textNode);
|
||||||
|
parent.removeChild(textNode);
|
||||||
|
|
||||||
|
// Set cursor after the chip
|
||||||
|
const newRange = document.createRange();
|
||||||
|
newRange.setStart(afterNode, 1);
|
||||||
|
newRange.collapse(true);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(newRange);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No @ found, just insert at cursor
|
||||||
|
const chip = createChipElement(mentionDoc);
|
||||||
|
range.insertNode(chip);
|
||||||
|
range.setStartAfter(chip);
|
||||||
|
range.collapse(true);
|
||||||
|
|
||||||
|
// Add space after chip
|
||||||
|
const space = document.createTextNode(" ");
|
||||||
|
range.insertNode(space);
|
||||||
|
range.setStartAfter(space);
|
||||||
|
range.collapse(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not in a text node, append to editor
|
||||||
|
const chip = createChipElement(mentionDoc);
|
||||||
|
editorRef.current.appendChild(chip);
|
||||||
|
editorRef.current.appendChild(document.createTextNode(" "));
|
||||||
|
focusAtEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update empty state
|
||||||
|
setIsEmpty(false);
|
||||||
|
|
||||||
|
// Trigger onChange
|
||||||
|
if (onChange) {
|
||||||
|
setTimeout(() => {
|
||||||
|
onChange(getText(), getMentionedDocuments());
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[createChipElement, focusAtEnd, getText, getMentionedDocuments, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear the editor
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
if (editorRef.current) {
|
||||||
|
editorRef.current.innerHTML = "";
|
||||||
|
setIsEmpty(true);
|
||||||
|
setMentionedDocs(new Map());
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Expose methods via ref
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
focus: () => editorRef.current?.focus(),
|
||||||
|
clear,
|
||||||
|
getText,
|
||||||
|
getMentionedDocuments,
|
||||||
|
insertDocumentChip,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Handle input changes
|
||||||
|
const handleInput = useCallback(() => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
|
const text = getText();
|
||||||
|
const empty = text.length === 0 && mentionedDocs.size === 0;
|
||||||
|
setIsEmpty(empty);
|
||||||
|
|
||||||
|
// Check for @ mentions
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.rangeCount > 0) {
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const textNode = range.startContainer;
|
||||||
|
|
||||||
|
if (textNode.nodeType === Node.TEXT_NODE) {
|
||||||
|
const textContent = textNode.textContent || "";
|
||||||
|
const cursorPos = range.startOffset;
|
||||||
|
|
||||||
|
// Look for @ before cursor
|
||||||
|
let atIndex = -1;
|
||||||
|
for (let i = cursorPos - 1; i >= 0; i--) {
|
||||||
|
if (textContent[i] === "@") {
|
||||||
|
atIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Stop if we hit a space (@ must be at word boundary)
|
||||||
|
if (textContent[i] === " " || textContent[i] === "\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (atIndex !== -1) {
|
||||||
|
const query = textContent.slice(atIndex + 1, cursorPos);
|
||||||
|
// Only trigger if query doesn't start with space
|
||||||
|
if (!query.startsWith(" ")) {
|
||||||
|
onMentionTrigger?.(query);
|
||||||
|
} else {
|
||||||
|
onMentionClose?.();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onMentionClose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify parent of change
|
||||||
|
onChange?.(text, Array.from(mentionedDocs.values()));
|
||||||
|
}, [getText, mentionedDocs, onChange, onMentionTrigger, onMentionClose]);
|
||||||
|
|
||||||
|
// Handle keydown
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
// Let parent handle navigation keys when mention popover is open
|
||||||
|
if (onKeyDown) {
|
||||||
|
onKeyDown(e);
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Enter for submit (without shift)
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle backspace on chips
|
||||||
|
if (e.key === "Backspace") {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.rangeCount > 0) {
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
if (range.collapsed) {
|
||||||
|
// Check if cursor is right after a chip
|
||||||
|
const node = range.startContainer;
|
||||||
|
const offset = range.startOffset;
|
||||||
|
|
||||||
|
if (node.nodeType === Node.TEXT_NODE && offset === 0) {
|
||||||
|
// Check previous sibling
|
||||||
|
const prevSibling = node.previousSibling;
|
||||||
|
if (prevSibling && (prevSibling as Element).hasAttribute?.(CHIP_DATA_ATTR)) {
|
||||||
|
e.preventDefault();
|
||||||
|
const chipId = Number((prevSibling as Element).getAttribute(CHIP_ID_ATTR));
|
||||||
|
prevSibling.parentNode?.removeChild(prevSibling);
|
||||||
|
setMentionedDocs((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(chipId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Notify parent that a document was removed
|
||||||
|
onDocumentRemove?.(chipId);
|
||||||
|
}
|
||||||
|
} else if (node.nodeType === Node.ELEMENT_NODE && offset > 0) {
|
||||||
|
// Check if previous child is a chip
|
||||||
|
const prevChild = (node as Element).childNodes[offset - 1];
|
||||||
|
if (prevChild && (prevChild as Element).hasAttribute?.(CHIP_DATA_ATTR)) {
|
||||||
|
e.preventDefault();
|
||||||
|
const chipId = Number((prevChild as Element).getAttribute(CHIP_ID_ATTR));
|
||||||
|
prevChild.parentNode?.removeChild(prevChild);
|
||||||
|
setMentionedDocs((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(chipId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Notify parent that a document was removed
|
||||||
|
onDocumentRemove?.(chipId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onKeyDown, onSubmit, onDocumentRemove]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle paste - strip formatting
|
||||||
|
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = e.clipboardData.getData("text/plain");
|
||||||
|
document.execCommand("insertText", false, text);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle composition (for IME input)
|
||||||
|
const handleCompositionStart = useCallback(() => {
|
||||||
|
isComposingRef.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCompositionEnd = useCallback(() => {
|
||||||
|
isComposingRef.current = false;
|
||||||
|
handleInput();
|
||||||
|
}, [handleInput]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<div
|
||||||
|
ref={editorRef}
|
||||||
|
contentEditable={!disabled}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
onInput={handleInput}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onCompositionStart={handleCompositionStart}
|
||||||
|
onCompositionEnd={handleCompositionEnd}
|
||||||
|
className={cn(
|
||||||
|
"min-h-[24px] max-h-32 overflow-y-auto",
|
||||||
|
"text-sm outline-none",
|
||||||
|
"whitespace-pre-wrap break-words",
|
||||||
|
disabled && "opacity-50 cursor-not-allowed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ wordBreak: "break-word" }}
|
||||||
|
data-placeholder={placeholder}
|
||||||
|
aria-label="Message input with inline mentions"
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
/>
|
||||||
|
{/* Placeholder */}
|
||||||
|
{isEmpty && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 pointer-events-none text-muted-foreground text-sm"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
InlineMentionEditor.displayName = "InlineMentionEditor";
|
||||||
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
MessagePrimitive,
|
MessagePrimitive,
|
||||||
ThreadPrimitive,
|
ThreadPrimitive,
|
||||||
useAssistantState,
|
useAssistantState,
|
||||||
useMessage,
|
useComposerRuntime,
|
||||||
useThreadViewport,
|
useThreadViewport,
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
|
|
@ -31,7 +31,6 @@ import {
|
||||||
Search,
|
Search,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
SquareIcon,
|
SquareIcon,
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
|
@ -65,6 +64,10 @@ import {
|
||||||
ComposerAttachments,
|
ComposerAttachments,
|
||||||
UserMessageAttachments,
|
UserMessageAttachments,
|
||||||
} from "@/components/assistant-ui/attachment";
|
} from "@/components/assistant-ui/attachment";
|
||||||
|
import {
|
||||||
|
InlineMentionEditor,
|
||||||
|
type InlineMentionEditorRef,
|
||||||
|
} from "@/components/assistant-ui/inline-mention-editor";
|
||||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
|
|
@ -240,7 +243,7 @@ const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolea
|
||||||
* Uses useThreadViewport to scroll to bottom when thinking steps change,
|
* Uses useThreadViewport to scroll to bottom when thinking steps change,
|
||||||
* ensuring the user always sees the latest content during streaming.
|
* ensuring the user always sees the latest content during streaming.
|
||||||
*/
|
*/
|
||||||
const ThinkingStepsScrollHandler: FC = () => {
|
const _ThinkingStepsScrollHandler: FC = () => {
|
||||||
const thinkingStepsMap = useContext(ThinkingStepsContext);
|
const thinkingStepsMap = useContext(ThinkingStepsContext);
|
||||||
const viewport = useThreadViewport();
|
const viewport = useThreadViewport();
|
||||||
const isRunning = useAssistantState(({ thread }) => thread.isRunning);
|
const isRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||||
|
|
@ -412,177 +415,140 @@ const Composer: FC = () => {
|
||||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||||
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
||||||
const [mentionQuery, setMentionQuery] = useState("");
|
const [mentionQuery, setMentionQuery] = useState("");
|
||||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||||
|
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const documentPickerRef = useRef<DocumentsDataTableRef>(null);
|
const documentPickerRef = useRef<DocumentsDataTableRef>(null);
|
||||||
const { search_space_id } = useParams();
|
const { search_space_id } = useParams();
|
||||||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
||||||
|
const composerRuntime = useComposerRuntime();
|
||||||
|
|
||||||
// Sync mentioned document IDs to atom for use in chat request
|
// Sync mentioned document IDs to atom for use in chat request
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id));
|
setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id));
|
||||||
}, [mentionedDocuments, setMentionedDocumentIds]);
|
}, [mentionedDocuments, setMentionedDocumentIds]);
|
||||||
|
|
||||||
// Extract mention query (text after @)
|
// Handle text change from inline editor - sync with assistant-ui composer
|
||||||
const extractMentionQuery = useCallback((value: string): string => {
|
const handleEditorChange = useCallback(
|
||||||
const atIndex = value.lastIndexOf("@");
|
(text: string) => {
|
||||||
if (atIndex === -1) return "";
|
composerRuntime.setText(text);
|
||||||
return value.slice(atIndex + 1);
|
},
|
||||||
|
[composerRuntime]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle @ mention trigger from inline editor
|
||||||
|
const handleMentionTrigger = useCallback((query: string) => {
|
||||||
|
setShowDocumentPopover(true);
|
||||||
|
setMentionQuery(query);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
// Handle mention close
|
||||||
const textarea = e.currentTarget;
|
const handleMentionClose = useCallback(() => {
|
||||||
const value = textarea.value;
|
|
||||||
|
|
||||||
// Open document picker when user types '@'
|
|
||||||
if (e.key === "@" || (e.key === "2" && e.shiftKey)) {
|
|
||||||
setShowDocumentPopover(true);
|
|
||||||
setMentionQuery("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if value contains @ and extract query
|
|
||||||
if (value.includes("@")) {
|
|
||||||
const query = extractMentionQuery(value);
|
|
||||||
|
|
||||||
// Close popup if query starts with space (user typed "@ ")
|
|
||||||
if (query.startsWith(" ")) {
|
|
||||||
setShowDocumentPopover(false);
|
|
||||||
setMentionQuery("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reopen popup if @ is present and query doesn't start with space
|
|
||||||
// (handles case where user deleted the space after @)
|
|
||||||
if (!showDocumentPopover) {
|
|
||||||
setShowDocumentPopover(true);
|
|
||||||
}
|
|
||||||
setMentionQuery(query);
|
|
||||||
} else {
|
|
||||||
// Close popover if '@' is no longer in the input (user deleted it)
|
|
||||||
if (showDocumentPopover) {
|
|
||||||
setShowDocumentPopover(false);
|
|
||||||
setMentionQuery("");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
// When popup is open, handle navigation keys
|
|
||||||
if (showDocumentPopover) {
|
if (showDocumentPopover) {
|
||||||
if (e.key === "ArrowDown") {
|
setShowDocumentPopover(false);
|
||||||
e.preventDefault();
|
setMentionQuery("");
|
||||||
documentPickerRef.current?.moveDown();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
documentPickerRef.current?.moveUp();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
documentPickerRef.current?.selectHighlighted();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
setShowDocumentPopover(false);
|
|
||||||
setMentionQuery("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, [showDocumentPopover]);
|
||||||
|
|
||||||
// Remove last document chip when pressing backspace at the beginning of input
|
// Handle keyboard navigation when popover is open
|
||||||
if (e.key === "Backspace" && mentionedDocuments.length > 0) {
|
const handleKeyDown = useCallback(
|
||||||
const textarea = e.currentTarget;
|
(e: React.KeyboardEvent) => {
|
||||||
const selectionStart = textarea.selectionStart;
|
if (showDocumentPopover) {
|
||||||
const selectionEnd = textarea.selectionEnd;
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
// Only remove chip if cursor is at position 0 and nothing is selected
|
documentPickerRef.current?.moveDown();
|
||||||
if (selectionStart === 0 && selectionEnd === 0) {
|
return;
|
||||||
e.preventDefault();
|
}
|
||||||
// Remove the last document chip
|
if (e.key === "ArrowUp") {
|
||||||
setMentionedDocuments((prev) => prev.slice(0, -1));
|
e.preventDefault();
|
||||||
}
|
documentPickerRef.current?.moveUp();
|
||||||
}
|
return;
|
||||||
};
|
}
|
||||||
|
if (e.key === "Enter") {
|
||||||
const handleDocumentsMention = (documents: Document[]) => {
|
e.preventDefault();
|
||||||
// Update mentioned documents (merge with existing, avoid duplicates)
|
documentPickerRef.current?.selectHighlighted();
|
||||||
setMentionedDocuments((prev) => {
|
return;
|
||||||
const existingIds = new Set(prev.map((d) => d.id));
|
}
|
||||||
const newDocs = documents.filter((doc) => !existingIds.has(doc.id));
|
if (e.key === "Escape") {
|
||||||
return [...prev, ...newDocs];
|
e.preventDefault();
|
||||||
});
|
setShowDocumentPopover(false);
|
||||||
|
setMentionQuery("");
|
||||||
// Clean up the '@...' mention text from input
|
return;
|
||||||
if (inputRef.current) {
|
|
||||||
const input = inputRef.current;
|
|
||||||
const currentValue = input.value;
|
|
||||||
const atIndex = currentValue.lastIndexOf("@");
|
|
||||||
|
|
||||||
if (atIndex !== -1) {
|
|
||||||
// Remove @ and everything after it
|
|
||||||
const newValue = currentValue.slice(0, atIndex);
|
|
||||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
|
||||||
window.HTMLTextAreaElement.prototype,
|
|
||||||
"value"
|
|
||||||
)?.set;
|
|
||||||
if (nativeInputValueSetter) {
|
|
||||||
nativeInputValueSetter.call(input, newValue);
|
|
||||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Focus the input so user can continue typing
|
},
|
||||||
input.focus();
|
[showDocumentPopover]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle submit from inline editor (Enter key)
|
||||||
|
const handleSubmit = useCallback(() => {
|
||||||
|
if (!showDocumentPopover) {
|
||||||
|
composerRuntime.send();
|
||||||
|
// Clear the editor after sending
|
||||||
|
editorRef.current?.clear();
|
||||||
|
setMentionedDocuments([]);
|
||||||
|
setMentionedDocumentIds([]);
|
||||||
}
|
}
|
||||||
|
}, [showDocumentPopover, composerRuntime, setMentionedDocuments, setMentionedDocumentIds]);
|
||||||
|
|
||||||
// Reset mention query
|
// Handle document removal from inline editor
|
||||||
setMentionQuery("");
|
const handleDocumentRemove = useCallback(
|
||||||
};
|
(docId: number) => {
|
||||||
|
setMentionedDocuments((prev) => {
|
||||||
|
const updated = prev.filter((doc) => doc.id !== docId);
|
||||||
|
// Immediately sync document IDs to avoid race conditions
|
||||||
|
setMentionedDocumentIds(updated.map((doc) => doc.id));
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setMentionedDocuments, setMentionedDocumentIds]
|
||||||
|
);
|
||||||
|
|
||||||
const handleRemoveDocument = (docId: number) => {
|
// Handle document selection from picker
|
||||||
setMentionedDocuments((prev) => prev.filter((doc) => doc.id !== docId));
|
const handleDocumentsMention = useCallback(
|
||||||
};
|
(documents: Document[]) => {
|
||||||
|
// Insert chips into the inline editor for each new document
|
||||||
|
const existingIds = new Set(mentionedDocuments.map((d) => d.id));
|
||||||
|
const newDocs = documents.filter((doc) => !existingIds.has(doc.id));
|
||||||
|
|
||||||
|
for (const doc of newDocs) {
|
||||||
|
editorRef.current?.insertDocumentChip(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mentioned documents state
|
||||||
|
setMentionedDocuments((prev) => {
|
||||||
|
const existingIdSet = new Set(prev.map((d) => d.id));
|
||||||
|
const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id));
|
||||||
|
const updated = [...prev, ...uniqueNewDocs];
|
||||||
|
// Immediately sync document IDs to avoid race conditions
|
||||||
|
setMentionedDocumentIds(updated.map((doc) => doc.id));
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset mention query but keep popover open for more selections
|
||||||
|
setMentionQuery("");
|
||||||
|
},
|
||||||
|
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
|
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
|
||||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
|
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
|
||||||
<ComposerAttachments />
|
<ComposerAttachments />
|
||||||
{/* -------- Input field with inline document chips -------- */}
|
{/* -------- Inline Mention Editor -------- */}
|
||||||
<div className="aui-composer-input-wrapper flex flex-wrap items-center gap-1.5 px-3 pt-2 pb-6">
|
<div
|
||||||
{/* Inline document chips */}
|
ref={editorContainerRef}
|
||||||
{mentionedDocuments.map((doc) => (
|
className="aui-composer-input-wrapper px-3 pt-3 pb-6"
|
||||||
<span
|
>
|
||||||
key={doc.id}
|
<InlineMentionEditor
|
||||||
className="inline-flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20 shrink-0"
|
ref={editorRef}
|
||||||
title={doc.title}
|
placeholder="Ask SurfSense (type @ to mention docs)"
|
||||||
>
|
onMentionTrigger={handleMentionTrigger}
|
||||||
<span className="max-w-[120px] truncate">{doc.title}</span>
|
onMentionClose={handleMentionClose}
|
||||||
<button
|
onChange={handleEditorChange}
|
||||||
type="button"
|
onDocumentRemove={handleDocumentRemove}
|
||||||
onClick={() => handleRemoveDocument(doc.id)}
|
onSubmit={handleSubmit}
|
||||||
className="size-4 flex items-center justify-center rounded-full hover:bg-primary/20 transition-colors"
|
|
||||||
aria-label={`Remove ${doc.title}`}
|
|
||||||
>
|
|
||||||
<X className="size-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{/* Text input */}
|
|
||||||
<ComposerPrimitive.Input
|
|
||||||
ref={inputRef}
|
|
||||||
onKeyUp={handleKeyUp}
|
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={
|
className="min-h-[24px]"
|
||||||
mentionedDocuments.length > 0
|
|
||||||
? "Ask about these documents..."
|
|
||||||
: "Ask SurfSense (type @ to mention docs)"
|
|
||||||
}
|
|
||||||
className="aui-composer-input flex-1 min-w-[120px] max-h-32 resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-0 py-1"
|
|
||||||
rows={1}
|
|
||||||
autoFocus
|
|
||||||
aria-label="Message input"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -605,11 +571,11 @@ const Composer: FC = () => {
|
||||||
style={{
|
style={{
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
backgroundColor: "#18181b",
|
backgroundColor: "#18181b",
|
||||||
bottom: inputRef.current
|
bottom: editorContainerRef.current
|
||||||
? `${window.innerHeight - inputRef.current.getBoundingClientRect().top + 8}px`
|
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
|
||||||
: "200px",
|
: "200px",
|
||||||
left: inputRef.current
|
left: editorContainerRef.current
|
||||||
? `${inputRef.current.getBoundingClientRect().left}px`
|
? `${editorContainerRef.current.getBoundingClientRect().left}px`
|
||||||
: "50%",
|
: "50%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue