diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 1b1687480..52bfc6054 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -33,7 +33,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { quickAskWindow = new BrowserWindow({ width: 450, - height: 550, + height: 750, x, y, ...(process.platform === 'darwin' @@ -92,7 +92,7 @@ export function registerQuickAsk(): void { pendingText = text; const cursor = screen.getCursorScreenPoint(); - const pos = clampToScreen(cursor.x, cursor.y, 450, 550); + const pos = clampToScreen(cursor.x, cursor.y, 450, 750); createQuickAskWindow(pos.x, pos.y); }); @@ -101,15 +101,20 @@ export function registerQuickAsk(): void { } ipcMain.handle(IPC_CHANNELS.QUICK_ASK_TEXT, () => { - return pendingText; + const text = pendingText; + pendingText = ''; + return text; }); ipcMain.handle(IPC_CHANNELS.SET_QUICK_ASK_MODE, (_event, mode: string) => { pendingMode = mode; }); - ipcMain.handle(IPC_CHANNELS.GET_QUICK_ASK_MODE, () => { - return pendingMode; + ipcMain.handle(IPC_CHANNELS.GET_QUICK_ASK_MODE, (event) => { + if (quickAskWindow && !quickAskWindow.isDestroyed() && event.sender.id === quickAskWindow.webContents.id) { + return pendingMode; + } + return ''; }); ipcMain.handle(IPC_CHANNELS.REPLACE_TEXT, async (_event, text: string) => { diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx index 4b7f1d9a7..38ccafa94 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx @@ -139,10 +139,13 @@ export function PromptsContent() { id="prompt-template" value={formData.prompt} onChange={(e) => setFormData((p) => ({ ...p, prompt: e.target.value }))} - placeholder="e.g. Fix the grammar in the following text. Return only the corrected text." + placeholder="e.g. Fix the grammar in the following text:\n\n{selection}" rows={4} className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-ring" /> +

+ Use {"{selection}"} to insert the input text. If omitted, the text is appended automatically. +

diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 23a7430af..b8a0febbe 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 { Sparkles, X } from "lucide-react"; +import { X } from "lucide-react"; import { createElement, forwardRef, @@ -34,8 +34,6 @@ export interface InlineMentionEditorRef { statusLabel: string | null, statusKind?: "pending" | "processing" | "ready" | "failed" ) => void; - insertActionChip: (name: string) => void; - getSelectedAction: () => string | null; } interface InlineMentionEditorProps { @@ -44,7 +42,6 @@ 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; @@ -57,7 +54,6 @@ 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"; @@ -98,7 +94,6 @@ export const InlineMentionEditor = forwardRef { if (!initialText || !editorRef.current) return; - // Insert the text and add trailing line breaks for typing space editorRef.current.innerText = initialText; editorRef.current.appendChild(document.createElement("br")); editorRef.current.appendChild(document.createElement("br")); setIsEmpty(false); onChange?.(initialText, Array.from(mentionedDocs.values())); - // Place cursor at the end of the content editorRef.current.focus(); const sel = window.getSelection(); const range = document.createRange(); @@ -142,7 +135,6 @@ 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) => { @@ -596,8 +474,6 @@ export const InlineMentionEditor = forwardRef = ({ isThreadEmpty }) = ); }; +const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDismiss }) => { + const [expanded, setExpanded] = useState(false); + const isLong = text.length > 120; + const preview = isLong ? `${text.slice(0, 120)}…` : text; + + return ( +
+
+ + From clipboard +
+ {isLong && ( + + )} + +
+
+

+ {expanded ? text : preview} +

+
+
+ ); +}; + const Composer: FC = () => { // Document mention state (atoms persist across component remounts) const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); @@ -304,6 +344,7 @@ const Composer: FC = () => { const [actionQuery, setActionQuery] = useState(""); const editorRef = useRef(null); const editorContainerRef = useRef(null); + const composerBoxRef = useRef(null); const documentPickerRef = useRef(null); const promptPickerRef = useRef(null); const { search_space_id, chat_id } = useParams(); @@ -440,34 +481,46 @@ const Composer: FC = () => { } }, [showPromptPicker]); - // 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" }) => { + let userText = editorRef.current?.getText() ?? ""; + const trigger = `/${actionQuery}`; + if (userText.endsWith(trigger)) { + userText = userText.slice(0, -trigger.length).trimEnd(); + } + const finalPrompt = action.prompt.includes("{selection}") + ? action.prompt.replace("{selection}", () => userText) + : userText ? `${action.prompt}\n\n${userText}` : action.prompt; + aui.composer().setText(finalPrompt); + aui.composer().send(); + editorRef.current?.clear(); setShowPromptPicker(false); setActionQuery(""); + setMentionedDocuments([]); + setSidebarDocs([]); + }, + [actionQuery, aui, setMentionedDocuments, setSidebarDocs] + ); - if (clipboardInitialText) { - const finalPrompt = action.prompt.replace("{selection}", clipboardInitialText); - window.electronAPI?.setQuickAskMode(action.mode); - aui.composer().setText(finalPrompt); - aui.composer().send(); - editorRef.current?.clear(); - setMentionedDocuments([]); - setSidebarDocs([]); - } else { - pendingActionRef.current = action; - editorRef.current?.insertActionChip(action.name); - } + const handleQuickAskSelect = useCallback( + (action: { name: string; prompt: string; mode: "transform" | "explore" }) => { + if (!clipboardInitialText) return; + window.electronAPI?.setQuickAskMode(action.mode); + const finalPrompt = action.prompt.includes("{selection}") + ? action.prompt.replace("{selection}", () => clipboardInitialText) + : `${action.prompt}\n\n${clipboardInitialText}`; + aui.composer().setText(finalPrompt); + aui.composer().send(); + editorRef.current?.clear(); + setShowPromptPicker(false); + setActionQuery(""); + setClipboardInitialText(undefined); + setMentionedDocuments([]); + setSidebarDocs([]); }, [clipboardInitialText, 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) => { @@ -527,11 +580,13 @@ const Composer: FC = () => { return; } if (!showDocumentPopover && !showPromptPicker) { - if (pendingActionRef.current) { + if (clipboardInitialText) { const userText = editorRef.current?.getText() ?? ""; - const finalPrompt = pendingActionRef.current.prompt.replace("{selection}", userText); - aui.composer().setText(finalPrompt); - pendingActionRef.current = null; + const combined = userText + ? `${userText}\n\n${clipboardInitialText}` + : clipboardInitialText; + aui.composer().setText(combined); + setClipboardInitialText(undefined); } aui.composer().send(); editorRef.current?.clear(); @@ -543,6 +598,7 @@ const Composer: FC = () => { showPromptPicker, isThreadRunning, isBlockedByOtherUser, + clipboardInitialText, aui, setMentionedDocuments, setSidebarDocs, @@ -582,14 +638,23 @@ const Composer: FC = () => { ); return ( - + -
+
+ {clipboardInitialText && ( + setClipboardInitialText(undefined)} + /> + )} {/* Inline editor with @mention support */}
{ onMentionClose={handleMentionClose} onActionTrigger={handleActionTrigger} onActionClose={handleActionClose} - onActionRemove={handleActionRemove} onChange={handleEditorChange} onDocumentRemove={handleDocumentRemove} onSubmit={handleSubmit} onKeyDown={handleKeyDown} - initialText={clipboardInitialText} className="min-h-[24px]" />
@@ -638,7 +701,7 @@ const Composer: FC = () => { createPortal( { setShowPromptPicker(false); setActionQuery(""); @@ -646,9 +709,12 @@ const Composer: FC = () => { externalSearch={actionQuery} containerStyle={{ position: "fixed", - bottom: editorContainerRef.current - ? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 12}px` - : "200px", + ...(clipboardInitialText && composerBoxRef.current + ? { top: `${composerBoxRef.current.getBoundingClientRect().bottom + 8}px` } + : { bottom: editorContainerRef.current + ? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px` + : "200px" } + ), left: editorContainerRef.current ? `${editorContainerRef.current.getBoundingClientRect().left}px` : "50%", diff --git a/surfsense_web/components/new-chat/prompt-picker.tsx b/surfsense_web/components/new-chat/prompt-picker.tsx index ba07e8c87..dee3eae32 100644 --- a/surfsense_web/components/new-chat/prompt-picker.tsx +++ b/surfsense_web/components/new-chat/prompt-picker.tsx @@ -108,9 +108,8 @@ export const PromptPicker = forwardRef( const action = filtered[index]; if (!action) return; onSelect({ name: action.name, prompt: action.prompt, mode: action.mode }); - onDone(); }, - [filtered, onSelect, onDone] + [filtered, onSelect] ); // Auto-scroll highlighted item into view