diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 3994de1d5..23a7430af 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -1,6 +1,6 @@ "use client"; -import { X } from "lucide-react"; +import { Sparkles, X } from "lucide-react"; import { createElement, forwardRef, @@ -34,6 +34,8 @@ export interface InlineMentionEditorRef { statusLabel: string | null, statusKind?: "pending" | "processing" | "ready" | "failed" ) => void; + insertActionChip: (name: string) => void; + getSelectedAction: () => string | null; } interface InlineMentionEditorProps { @@ -42,6 +44,7 @@ interface InlineMentionEditorProps { onMentionClose?: () => void; onActionTrigger?: (query: string) => void; onActionClose?: () => void; + onActionRemove?: () => void; onSubmit?: () => void; onChange?: (text: string, docs: MentionedDocument[]) => void; onDocumentRemove?: (docId: number, docType?: string) => void; @@ -54,6 +57,7 @@ interface InlineMentionEditorProps { // Unique data attribute to identify chip elements const CHIP_DATA_ATTR = "data-mention-chip"; +const ACTION_CHIP_ATTR = "data-action-chip"; const CHIP_ID_ATTR = "data-mention-id"; const CHIP_DOCTYPE_ATTR = "data-mention-doctype"; const CHIP_STATUS_ATTR = "data-mention-status"; @@ -94,6 +98,7 @@ export const InlineMentionEditor = forwardRef { + const chip = document.createElement("span"); + chip.setAttribute(ACTION_CHIP_ATTR, name); + chip.contentEditable = "false"; + chip.className = + "inline-flex items-center gap-1 mx-0.5 px-1.5 py-0.5 rounded-md bg-accent border text-xs font-medium text-foreground select-none cursor-default"; + chip.style.userSelect = "none"; + chip.style.verticalAlign = "baseline"; + + const iconSpan = document.createElement("span"); + iconSpan.className = "flex items-center text-muted-foreground"; + iconSpan.innerHTML = ReactDOMServer.renderToString( + createElement(Sparkles, { className: "h-3 w-3" }) + ); + + const titleSpan = document.createElement("span"); + titleSpan.textContent = name; + + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = + "ml-0.5 flex items-center text-muted-foreground hover:text-foreground transition-colors"; + removeBtn.innerHTML = ReactDOMServer.renderToString( + createElement(X, { className: "h-3 w-3", strokeWidth: 2.5 }) + ); + removeBtn.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + chip.remove(); + onActionRemove?.(); + focusAtEnd(); + }; + + chip.appendChild(iconSpan); + chip.appendChild(titleSpan); + chip.appendChild(removeBtn); + + return chip; + }, + [focusAtEnd, onActionRemove] + ); + + const insertActionChip = useCallback( + (name: string) => { + if (!editorRef.current) return; + + // Remove any existing action chip + const existing = editorRef.current.querySelector(`[${ACTION_CHIP_ATTR}]`); + if (existing) existing.remove(); + + // Find and remove the /query text before cursor + 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 text = textNode.textContent || ""; + const cursorPos = range.startOffset; + + let slashIndex = -1; + for (let i = cursorPos - 1; i >= 0; i--) { + if (text[i] === "/") { + slashIndex = i; + break; + } + } + + if (slashIndex !== -1) { + const beforeSlash = text.slice(0, slashIndex); + const afterCursor = text.slice(cursorPos); + const chip = createActionChipElement(name); + const parent = textNode.parentNode; + + if (parent) { + const beforeNode = document.createTextNode(beforeSlash); + const afterNode = document.createTextNode(` ${afterCursor}`); + parent.insertBefore(beforeNode, textNode); + parent.insertBefore(chip, textNode); + parent.insertBefore(afterNode, textNode); + parent.removeChild(textNode); + + const newRange = document.createRange(); + newRange.setStart(afterNode, 1); + newRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(newRange); + } + return; + } + } + } + + // Fallback: insert at beginning + const chip = createActionChipElement(name); + editorRef.current.insertBefore(chip, editorRef.current.firstChild); + editorRef.current.insertBefore(document.createTextNode(" "), chip.nextSibling); + focusAtEnd(); + }, + [createActionChipElement, focusAtEnd] + ); + + const getSelectedAction = useCallback((): string | null => { + if (!editorRef.current) return null; + const chip = editorRef.current.querySelector(`[${ACTION_CHIP_ATTR}]`); + return chip?.getAttribute(ACTION_CHIP_ATTR) ?? null; + }, []); + // Insert a document chip at the current cursor position const insertDocumentChip = useCallback( (doc: Pick) => { @@ -477,6 +596,8 @@ export const InlineMentionEditor = forwardRef { } }, [showActionPicker]); - // Handle action selection: prepend prompt template and auto-submit + // Pending action prompt stored when user picks an action + const pendingActionRef = useRef<{ name: string; prompt: string; mode: "transform" | "explore" } | null>(null); + const handleActionSelect = useCallback( (action: { name: string; prompt: string; mode: "transform" | "explore" }) => { setShowActionPicker(false); setActionQuery(""); - - if (editorRef.current) { - const text = editorRef.current.getText(); - // Remove the /query from the text - const slashIndex = text.lastIndexOf("/"); - const userText = slashIndex !== -1 ? text.substring(0, slashIndex).trim() : text; - const finalPrompt = action.prompt.replace("{selection}", userText); - - aui.composer().setText(finalPrompt); - aui.composer().send(); - editorRef.current.clear(); - setMentionedDocuments([]); - setSidebarDocs([]); - } + pendingActionRef.current = action; + editorRef.current?.insertActionChip(action.name); }, - [aui, setMentionedDocuments, setSidebarDocs] + [] ); + const handleActionRemove = useCallback(() => { + pendingActionRef.current = null; + }, []); + // Keyboard navigation for document/action picker (arrow keys, Enter, Escape) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -520,7 +514,13 @@ const Composer: FC = () => { if (isThreadRunning || isBlockedByOtherUser) { return; } - if (!showDocumentPopover) { + if (!showDocumentPopover && !showActionPicker) { + if (pendingActionRef.current) { + const userText = editorRef.current?.getText() ?? ""; + const finalPrompt = pendingActionRef.current.prompt.replace("{selection}", userText); + aui.composer().setText(finalPrompt); + pendingActionRef.current = null; + } aui.composer().send(); editorRef.current?.clear(); setMentionedDocuments([]); @@ -528,6 +528,7 @@ const Composer: FC = () => { } }, [ showDocumentPopover, + showActionPicker, isThreadRunning, isBlockedByOtherUser, aui, @@ -586,6 +587,7 @@ const Composer: FC = () => { onMentionClose={handleMentionClose} onActionTrigger={handleActionTrigger} onActionClose={handleActionClose} + onActionRemove={handleActionRemove} onChange={handleEditorChange} onDocumentRemove={handleDocumentRemove} onSubmit={handleSubmit} @@ -633,7 +635,7 @@ const Composer: FC = () => { containerStyle={{ position: "fixed", bottom: editorContainerRef.current - ? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px` + ? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 12}px` : "200px", left: editorContainerRef.current ? `${editorContainerRef.current.getBoundingClientRect().left}px`