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