From 0f846cd9c4a2da279bc2f721cfc414dc92697809 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 26 Mar 2026 20:06:01 +0200 Subject: [PATCH 01/37] track frontmost app on shortcut trigger --- surfsense_desktop/src/modules/quick-ask.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 9009099a3..0779b514f 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -1,4 +1,5 @@ import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron'; +import { execSync } from 'child_process'; import path from 'path'; import { IPC_CHANNELS } from '../ipc/channels'; import { getServerPort } from './server'; @@ -6,6 +7,18 @@ import { getServerPort } from './server'; const SHORTCUT = 'CommandOrControl+Option+S'; let quickAskWindow: BrowserWindow | null = null; let pendingText = ''; +let sourceApp = ''; + +function getFrontmostApp(): string { + if (process.platform !== 'darwin') return ''; + try { + return execSync( + 'osascript -e \'tell application "System Events" to get name of first application process whose frontmost is true\'' + ).toString().trim(); + } catch { + return ''; + } +} function hideQuickAsk(): void { if (quickAskWindow && !quickAskWindow.isDestroyed()) { @@ -83,6 +96,8 @@ export function registerQuickAsk(): void { return; } + sourceApp = getFrontmostApp(); + const text = clipboard.readText().trim(); if (!text) return; From 0abbfbfe27e1c9fbc0621fb77575bdfcc7ccbf4c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 26 Mar 2026 20:08:23 +0200 Subject: [PATCH 02/37] save clipboard contents on shortcut trigger --- surfsense_desktop/src/modules/quick-ask.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 0779b514f..59246e946 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -8,6 +8,7 @@ const SHORTCUT = 'CommandOrControl+Option+S'; let quickAskWindow: BrowserWindow | null = null; let pendingText = ''; let sourceApp = ''; +let savedClipboard = ''; function getFrontmostApp(): string { if (process.platform !== 'darwin') return ''; @@ -97,8 +98,9 @@ export function registerQuickAsk(): void { } sourceApp = getFrontmostApp(); + savedClipboard = clipboard.readText(); - const text = clipboard.readText().trim(); + const text = savedClipboard.trim(); if (!text) return; pendingText = text; From 6597649fd19048403abd429c1ace21f9c98c7e3d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 26 Mar 2026 20:09:04 +0200 Subject: [PATCH 03/37] add REPLACE_TEXT IPC channel --- surfsense_desktop/src/ipc/channels.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 18002b520..1a2a9993e 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -3,4 +3,5 @@ export const IPC_CHANNELS = { GET_APP_VERSION: 'get-app-version', DEEP_LINK: 'deep-link', QUICK_ASK_TEXT: 'quick-ask-text', + REPLACE_TEXT: 'replace-text', } as const; From f931b4cf9dcaee683065de22c559699cc9b54173 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 26 Mar 2026 20:10:10 +0200 Subject: [PATCH 04/37] expose replaceText in preload --- surfsense_desktop/src/preload.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index 9c857de1b..fbb272108 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -18,4 +18,5 @@ contextBridge.exposeInMainWorld('electronAPI', { }; }, getQuickAskText: () => ipcRenderer.invoke(IPC_CHANNELS.QUICK_ASK_TEXT), + replaceText: (text: string) => ipcRenderer.invoke(IPC_CHANNELS.REPLACE_TEXT, text), }); From 6e74f462a2ab133d13a6c81365ce694523573197 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 26 Mar 2026 20:11:15 +0200 Subject: [PATCH 05/37] add replaceText type to ElectronAPI --- surfsense_web/types/window.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index c8b4c004a..65ab135ea 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -11,6 +11,7 @@ interface ElectronAPI { getAppVersion: () => Promise; onDeepLink: (callback: (url: string) => void) => () => void; getQuickAskText: () => Promise; + replaceText: (text: string) => Promise; } declare global { From 5bb4f5c08422260cbebd29d9d08ee78ba0c9d6ef Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 26 Mar 2026 20:12:33 +0200 Subject: [PATCH 06/37] implement replace handler with clipboard swap and paste-back --- surfsense_desktop/src/modules/quick-ask.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 59246e946..3bee2bbc2 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -118,6 +118,23 @@ export function registerQuickAsk(): void { pendingText = ''; return text; }); + + ipcMain.handle(IPC_CHANNELS.REPLACE_TEXT, async (_event, text: string) => { + if (process.platform !== 'darwin' || !sourceApp) return; + + clipboard.writeText(text); + hideQuickAsk(); + + try { + execSync(`osascript -e 'tell application "${sourceApp}" to activate'`); + await new Promise((r) => setTimeout(r, 100)); + execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\''); + await new Promise((r) => setTimeout(r, 100)); + clipboard.writeText(savedClipboard); + } catch { + clipboard.writeText(savedClipboard); + } + }); } export function unregisterQuickAsk(): void { From 2adffccd9283124e53af162e3b89143ce6ac644f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 26 Mar 2026 20:30:19 +0200 Subject: [PATCH 07/37] add paste-back button to assistant action bar --- .../assistant-ui/assistant-message.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 9fefecb1c..910e1fc89 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -3,10 +3,11 @@ import { AuiIf, ErrorPrimitive, MessagePrimitive, + useAui, useAuiState, } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; -import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; +import { CheckIcon, ClipboardPaste, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; import type { FC } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; @@ -272,6 +273,8 @@ export const AssistantMessage: FC = () => { const AssistantActionBar: FC = () => { const isLast = useAuiState((s) => s.message.isLast); + const aui = useAui(); + const canReplace = isLast && typeof window !== "undefined" && !!window.electronAPI?.replaceText; return ( { - {/* Only allow regenerating the last assistant message */} + {canReplace && ( + { + const text = aui.message().getCopyText(); + window.electronAPI?.replaceText(text); + }} + > + + + )} {isLast && ( From bc16c0362dce2ac399aca5f8383733f625eec973 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 26 Mar 2026 20:34:37 +0200 Subject: [PATCH 08/37] check accessibility permission before paste-back --- surfsense_desktop/src/modules/quick-ask.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 3bee2bbc2..3e8f29bc7 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -1,4 +1,4 @@ -import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron'; +import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell, systemPreferences } from 'electron'; import { execSync } from 'child_process'; import path from 'path'; import { IPC_CHANNELS } from '../ipc/channels'; @@ -122,6 +122,8 @@ export function registerQuickAsk(): void { ipcMain.handle(IPC_CHANNELS.REPLACE_TEXT, async (_event, text: string) => { if (process.platform !== 'darwin' || !sourceApp) return; + if (!systemPreferences.isTrustedAccessibilityClient(true)) return; + clipboard.writeText(text); hideQuickAsk(); From 2f08d401fa6184acaa84ce6d474eaa19850e681a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 26 Mar 2026 20:58:04 +0200 Subject: [PATCH 09/37] destroy panel on dismiss, remove activate to preserve selection --- surfsense_desktop/src/modules/quick-ask.ts | 23 +++++++++------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 3e8f29bc7..1f1d7f313 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -21,10 +21,11 @@ function getFrontmostApp(): string { } } -function hideQuickAsk(): void { +function destroyQuickAsk(): void { if (quickAskWindow && !quickAskWindow.isDestroyed()) { - quickAskWindow.hide(); + quickAskWindow.close(); } + quickAskWindow = null; } function clampToScreen(x: number, y: number, w: number, h: number): { x: number; y: number } { @@ -37,12 +38,7 @@ function clampToScreen(x: number, y: number, w: number, h: number): { x: number; } function createQuickAskWindow(x: number, y: number): BrowserWindow { - if (quickAskWindow && !quickAskWindow.isDestroyed()) { - quickAskWindow.setPosition(x, y); - quickAskWindow.show(); - quickAskWindow.focus(); - return quickAskWindow; - } + destroyQuickAsk(); quickAskWindow = new BrowserWindow({ width: 450, @@ -72,7 +68,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { }); quickAskWindow.webContents.on('before-input-event', (_event, input) => { - if (input.key === 'Escape') hideQuickAsk(); + if (input.key === 'Escape') destroyQuickAsk(); }); quickAskWindow.webContents.setWindowOpenHandler(({ url }) => { @@ -92,8 +88,8 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { export function registerQuickAsk(): void { const ok = globalShortcut.register(SHORTCUT, () => { - if (quickAskWindow && !quickAskWindow.isDestroyed() && quickAskWindow.isVisible()) { - hideQuickAsk(); + if (quickAskWindow && !quickAskWindow.isDestroyed()) { + destroyQuickAsk(); return; } @@ -125,11 +121,10 @@ export function registerQuickAsk(): void { if (!systemPreferences.isTrustedAccessibilityClient(true)) return; clipboard.writeText(text); - hideQuickAsk(); + destroyQuickAsk(); try { - execSync(`osascript -e 'tell application "${sourceApp}" to activate'`); - await new Promise((r) => setTimeout(r, 100)); + await new Promise((r) => setTimeout(r, 50)); execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\''); await new Promise((r) => setTimeout(r, 100)); clipboard.writeText(savedClipboard); From 1133a36fe2ce6a18b5fb7cae52c27e82fa51371b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 27 Mar 2026 15:59:41 +0200 Subject: [PATCH 10/37] extract keyboard module with macOS and Windows support --- surfsense_desktop/src/modules/keyboard.ts | 33 ++++++++++++++++++++++ surfsense_desktop/src/modules/quick-ask.ts | 21 ++++---------- 2 files changed, 38 insertions(+), 16 deletions(-) create mode 100644 surfsense_desktop/src/modules/keyboard.ts diff --git a/surfsense_desktop/src/modules/keyboard.ts b/surfsense_desktop/src/modules/keyboard.ts new file mode 100644 index 000000000..1fca34b79 --- /dev/null +++ b/surfsense_desktop/src/modules/keyboard.ts @@ -0,0 +1,33 @@ +import { execSync } from 'child_process'; +import { systemPreferences } from 'electron'; + +export function getFrontmostApp(): string { + try { + if (process.platform === 'darwin') { + return execSync( + 'osascript -e \'tell application "System Events" to get name of first application process whose frontmost is true\'' + ).toString().trim(); + } + if (process.platform === 'win32') { + return execSync( + 'powershell -command "Add-Type \'using System; using System.Runtime.InteropServices; public class W { [DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow(); }\'; (Get-Process | Where-Object { $_.MainWindowHandle -eq [W]::GetForegroundWindow() }).ProcessName"' + ).toString().trim(); + } + } catch { + return ''; + } + return ''; +} + +export function simulatePaste(): void { + if (process.platform === 'darwin') { + execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\''); + } else if (process.platform === 'win32') { + execSync('powershell -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^v\')"'); + } +} + +export function checkAccessibilityPermission(): boolean { + if (process.platform !== 'darwin') return true; + return systemPreferences.isTrustedAccessibilityClient(true); +} diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 1f1d7f313..b3aa10e3a 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -1,7 +1,7 @@ -import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell, systemPreferences } from 'electron'; -import { execSync } from 'child_process'; +import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron'; import path from 'path'; import { IPC_CHANNELS } from '../ipc/channels'; +import { checkAccessibilityPermission, getFrontmostApp, simulatePaste } from './keyboard'; import { getServerPort } from './server'; const SHORTCUT = 'CommandOrControl+Option+S'; @@ -10,17 +10,6 @@ let pendingText = ''; let sourceApp = ''; let savedClipboard = ''; -function getFrontmostApp(): string { - if (process.platform !== 'darwin') return ''; - try { - return execSync( - 'osascript -e \'tell application "System Events" to get name of first application process whose frontmost is true\'' - ).toString().trim(); - } catch { - return ''; - } -} - function destroyQuickAsk(): void { if (quickAskWindow && !quickAskWindow.isDestroyed()) { quickAskWindow.close(); @@ -116,16 +105,16 @@ export function registerQuickAsk(): void { }); ipcMain.handle(IPC_CHANNELS.REPLACE_TEXT, async (_event, text: string) => { - if (process.platform !== 'darwin' || !sourceApp) return; + if (!sourceApp) return; - if (!systemPreferences.isTrustedAccessibilityClient(true)) return; + if (!checkAccessibilityPermission()) return; clipboard.writeText(text); destroyQuickAsk(); try { await new Promise((r) => setTimeout(r, 50)); - execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\''); + simulatePaste(); await new Promise((r) => setTimeout(r, 100)); clipboard.writeText(savedClipboard); } catch { From f931f08f006db9bb645a095172e70da00ed4dd58 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 27 Mar 2026 16:53:09 +0200 Subject: [PATCH 11/37] rename keyboard to platform module, add getSelectedText --- .../src/modules/{keyboard.ts => platform.ts} | 22 +++++++++++++++++++ surfsense_desktop/src/modules/quick-ask.ts | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) rename surfsense_desktop/src/modules/{keyboard.ts => platform.ts} (62%) diff --git a/surfsense_desktop/src/modules/keyboard.ts b/surfsense_desktop/src/modules/platform.ts similarity index 62% rename from surfsense_desktop/src/modules/keyboard.ts rename to surfsense_desktop/src/modules/platform.ts index 1fca34b79..37e126799 100644 --- a/surfsense_desktop/src/modules/keyboard.ts +++ b/surfsense_desktop/src/modules/platform.ts @@ -19,6 +19,28 @@ export function getFrontmostApp(): string { return ''; } +export function getSelectedText(): string { + try { + if (process.platform === 'darwin') { + return execSync( + 'osascript -e \'tell application "System Events" to get value of attribute "AXSelectedText" of focused UI element of first application process whose frontmost is true\'' + ).toString().trim(); + } + // Windows: no reliable accessibility API for selected text across apps + } catch { + return ''; + } + return ''; +} + +export function simulateCopy(): void { + if (process.platform === 'darwin') { + execSync('osascript -e \'tell application "System Events" to keystroke "c" using command down\''); + } else if (process.platform === 'win32') { + execSync('powershell -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^c\')"'); + } +} + export function simulatePaste(): void { if (process.platform === 'darwin') { execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\''); diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index b3aa10e3a..4aa930d4f 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -1,7 +1,7 @@ import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron'; import path from 'path'; import { IPC_CHANNELS } from '../ipc/channels'; -import { checkAccessibilityPermission, getFrontmostApp, simulatePaste } from './keyboard'; +import { checkAccessibilityPermission, getFrontmostApp, simulatePaste } from './platform'; import { getServerPort } from './server'; const SHORTCUT = 'CommandOrControl+Option+S'; From 2a8f393cde4cc6337179539c40d7aa66c499e9fd Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 27 Mar 2026 17:13:37 +0200 Subject: [PATCH 12/37] add quick-ask action type definition --- .../contracts/types/quick-ask-actions.types.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 surfsense_web/contracts/types/quick-ask-actions.types.ts diff --git a/surfsense_web/contracts/types/quick-ask-actions.types.ts b/surfsense_web/contracts/types/quick-ask-actions.types.ts new file mode 100644 index 000000000..f7ee22c0b --- /dev/null +++ b/surfsense_web/contracts/types/quick-ask-actions.types.ts @@ -0,0 +1,10 @@ +export type QuickAskActionMode = "transform" | "explore"; + +export interface QuickAskAction { + id: string; + name: string; + prompt: string; + mode: QuickAskActionMode; + icon: string; + group: "transform" | "explore" | "knowledge" | "custom"; +} From d48f6aafce3fcc20572b439512c22d275be6eb3c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 27 Mar 2026 17:17:27 +0200 Subject: [PATCH 13/37] add quick-ask page with default action menu --- surfsense_web/app/quick-ask/actions.ts | 68 ++++++++++++++ surfsense_web/app/quick-ask/page.tsx | 117 +++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 surfsense_web/app/quick-ask/actions.ts create mode 100644 surfsense_web/app/quick-ask/page.tsx diff --git a/surfsense_web/app/quick-ask/actions.ts b/surfsense_web/app/quick-ask/actions.ts new file mode 100644 index 000000000..8d4ff0bb5 --- /dev/null +++ b/surfsense_web/app/quick-ask/actions.ts @@ -0,0 +1,68 @@ +import type { QuickAskAction } from "@/contracts/types/quick-ask-actions.types"; + +export const DEFAULT_ACTIONS: QuickAskAction[] = [ + { + id: "fix-grammar", + name: "Fix grammar", + prompt: "Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}", + mode: "transform", + icon: "check", + group: "transform", + }, + { + id: "make-shorter", + name: "Make shorter", + prompt: "Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}", + mode: "transform", + icon: "minimize", + group: "transform", + }, + { + id: "translate", + name: "Translate", + prompt: "Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}", + mode: "transform", + icon: "languages", + group: "transform", + }, + { + id: "rewrite", + name: "Rewrite", + prompt: "Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}", + mode: "transform", + icon: "pen-line", + group: "transform", + }, + { + id: "explain", + name: "Explain", + prompt: "Explain the following text in simple terms:\n\n{selection}", + mode: "explore", + icon: "book-open", + group: "explore", + }, + { + id: "summarize", + name: "Summarize", + prompt: "Summarize the following text:\n\n{selection}", + mode: "explore", + icon: "list", + group: "explore", + }, + { + id: "search-knowledge", + name: "Search my knowledge", + prompt: "Search my knowledge base for information related to:\n\n{selection}", + mode: "explore", + icon: "search", + group: "knowledge", + }, + { + id: "search-web", + name: "Search the web", + prompt: "Search the web for information about:\n\n{selection}", + mode: "explore", + icon: "globe", + group: "knowledge", + }, +]; diff --git a/surfsense_web/app/quick-ask/page.tsx b/surfsense_web/app/quick-ask/page.tsx new file mode 100644 index 000000000..22e29b4b7 --- /dev/null +++ b/surfsense_web/app/quick-ask/page.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { + BookOpen, + Check, + Globe, + Languages, + List, + MessageSquare, + Minimize2, + PenLine, + Search, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { DEFAULT_ACTIONS } from "./actions"; + +const ICONS: Record = { + check: , + minimize: , + languages: , + "pen-line": , + "book-open": , + list: , + search: , + globe: , +}; + +export default function QuickAskPage() { + const [clipboardText, setClipboardText] = useState(""); + + useEffect(() => { + window.electronAPI?.getQuickAskText().then((text) => { + if (text) setClipboardText(text); + }); + }, []); + + const handleAction = (actionId: string) => { + const action = DEFAULT_ACTIONS.find((a) => a.id === actionId); + if (!action || !clipboardText) return; + + const prompt = action.prompt.replace("{selection}", clipboardText); + const encoded = encodeURIComponent(prompt); + const mode = action.mode; + + window.location.href = `/dashboard?quickAskPrompt=${encoded}&quickAskMode=${mode}`; + }; + + const handleAskAnything = () => { + if (!clipboardText) return; + const encoded = encodeURIComponent(clipboardText); + window.location.href = `/dashboard?quickAskPrompt=${encoded}&quickAskMode=explore`; + }; + + const transformActions = DEFAULT_ACTIONS.filter((a) => a.group === "transform"); + const exploreActions = DEFAULT_ACTIONS.filter((a) => a.group === "explore"); + const knowledgeActions = DEFAULT_ACTIONS.filter((a) => a.group === "knowledge"); + + return ( +
+ + + + No actions found. + + + {transformActions.map((action) => ( + handleAction(action.id)}> + {ICONS[action.icon]} + {action.name} + + ))} + + + + + + {exploreActions.map((action) => ( + handleAction(action.id)}> + {ICONS[action.icon]} + {action.name} + + ))} + + + + + + {knowledgeActions.map((action) => ( + handleAction(action.id)}> + {ICONS[action.icon]} + {action.name} + + ))} + + + + + + + + Ask anything... + + + + +
+ ); +} From 98e12dd195b4073d77577266ae0048b63f624237 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 27 Mar 2026 17:19:13 +0200 Subject: [PATCH 14/37] load /quick-ask page in panel --- surfsense_desktop/src/modules/quick-ask.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 4aa930d4f..3ff108dd3 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -50,7 +50,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { skipTaskbar: true, }); - quickAskWindow.loadURL(`http://localhost:${getServerPort()}/dashboard`); + quickAskWindow.loadURL(`http://localhost:${getServerPort()}/quick-ask`); quickAskWindow.once('ready-to-show', () => { quickAskWindow?.show(); From 06f02fba0a8bc8ef4df3a1e753d9d505a56ef62e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 27 Mar 2026 17:38:34 +0200 Subject: [PATCH 15/37] navigate directly to chat with search space id --- surfsense_web/app/quick-ask/page.tsx | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/surfsense_web/app/quick-ask/page.tsx b/surfsense_web/app/quick-ask/page.tsx index 22e29b4b7..00d47ae48 100644 --- a/surfsense_web/app/quick-ask/page.tsx +++ b/surfsense_web/app/quick-ask/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useAtomValue } from "jotai"; import { BookOpen, Check, @@ -11,7 +12,9 @@ import { PenLine, Search, } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; +import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { Command, CommandEmpty, @@ -35,6 +38,8 @@ const ICONS: Record = { }; export default function QuickAskPage() { + const router = useRouter(); + const { data: searchSpaces = [] } = useAtomValue(searchSpacesAtom); const [clipboardText, setClipboardText] = useState(""); useEffect(() => { @@ -43,21 +48,24 @@ export default function QuickAskPage() { }); }, []); + const navigateToChat = (prompt: string, mode: string) => { + if (!searchSpaces.length) return; + const spaceId = searchSpaces[0].id; + const encoded = encodeURIComponent(prompt); + router.push(`/dashboard/${spaceId}/new-chat?quickAskPrompt=${encoded}&quickAskMode=${mode}`); + }; + const handleAction = (actionId: string) => { const action = DEFAULT_ACTIONS.find((a) => a.id === actionId); if (!action || !clipboardText) return; const prompt = action.prompt.replace("{selection}", clipboardText); - const encoded = encodeURIComponent(prompt); - const mode = action.mode; - - window.location.href = `/dashboard?quickAskPrompt=${encoded}&quickAskMode=${mode}`; + navigateToChat(prompt, action.mode); }; const handleAskAnything = () => { if (!clipboardText) return; - const encoded = encodeURIComponent(clipboardText); - window.location.href = `/dashboard?quickAskPrompt=${encoded}&quickAskMode=explore`; + navigateToChat(clipboardText, "explore"); }; const transformActions = DEFAULT_ACTIONS.filter((a) => a.group === "transform"); From 6c59b3ee95ff6be48e4eb8fa6c1a96c290aeb227 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 27 Mar 2026 17:43:32 +0200 Subject: [PATCH 16/37] auto-submit quick-ask prompt from URL param --- .../new-chat/[[...chat_id]]/page.tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 8578d2dcb..e91ad43e9 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -4,6 +4,7 @@ import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessageLike, + useAui, useExternalStoreRuntime, } from "@assistant-ui/react"; import { useQueryClient } from "@tanstack/react-query"; @@ -158,6 +159,27 @@ const TOOLS_WITH_UI = new Set([ // "write_todos", // Disabled for now ]); +function QuickAskAutoSubmit() { + const searchParams = useSearchParams(); + const aui = useAui(); + const submittedRef = useRef(false); + + useEffect(() => { + if (!window.electronAPI || submittedRef.current) return; + + const prompt = searchParams.get("quickAskPrompt"); + if (!prompt) return; + + submittedRef.current = true; + setTimeout(() => { + aui.composer().setText(prompt); + aui.composer().send(); + }, 500); + }, [searchParams, aui]); + + return null; +} + export default function NewChatPage() { const params = useParams(); const queryClient = useQueryClient(); @@ -1587,6 +1609,7 @@ export default function NewChatPage() { return ( +
From cc9cb3919ef7821844349828f683f1ebd60a111f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 27 Mar 2026 17:47:02 +0200 Subject: [PATCH 17/37] show paste-back button only for transform actions --- .../assistant-ui/assistant-message.tsx | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 910e1fc89..27ba08395 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -8,6 +8,7 @@ import { } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; import { CheckIcon, ClipboardPaste, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; +import { useSearchParams } from "next/navigation"; import type { FC } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; @@ -274,7 +275,12 @@ export const AssistantMessage: FC = () => { const AssistantActionBar: FC = () => { const isLast = useAuiState((s) => s.message.isLast); const aui = useAui(); - const canReplace = isLast && typeof window !== "undefined" && !!window.electronAPI?.replaceText; + const searchParams = useSearchParams(); + const isTransform = + isLast && + typeof window !== "undefined" && + !!window.electronAPI?.replaceText && + searchParams.get("quickAskMode") === "transform"; return ( { - {canReplace && ( - { - const text = aui.message().getCopyText(); - window.electronAPI?.replaceText(text); - }} - > - - - )} {isLast && ( @@ -316,6 +311,19 @@ const AssistantActionBar: FC = () => { )} + {isTransform && ( + + )} ); }; From 59e0579cc0b5311fc398e383a07b660c8ed8959a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 27 Mar 2026 18:24:34 +0200 Subject: [PATCH 18/37] simplify action menu to plain buttons, remove old quickAskText from thread --- surfsense_web/app/quick-ask/page.tsx | 105 +++++++++--------- .../components/assistant-ui/thread.tsx | 8 -- 2 files changed, 53 insertions(+), 60 deletions(-) diff --git a/surfsense_web/app/quick-ask/page.tsx b/surfsense_web/app/quick-ask/page.tsx index 00d47ae48..e8191c913 100644 --- a/surfsense_web/app/quick-ask/page.tsx +++ b/surfsense_web/app/quick-ask/page.tsx @@ -12,18 +12,8 @@ import { PenLine, Search, } from "lucide-react"; -import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from "@/components/ui/command"; import { DEFAULT_ACTIONS } from "./actions"; const ICONS: Record = { @@ -38,8 +28,7 @@ const ICONS: Record = { }; export default function QuickAskPage() { - const router = useRouter(); - const { data: searchSpaces = [] } = useAtomValue(searchSpacesAtom); + const { data: searchSpaces = [], isLoading } = useAtomValue(searchSpacesAtom); const [clipboardText, setClipboardText] = useState(""); useEffect(() => { @@ -49,77 +38,89 @@ export default function QuickAskPage() { }, []); const navigateToChat = (prompt: string, mode: string) => { - if (!searchSpaces.length) return; + if (!searchSpaces.length || !clipboardText) return; const spaceId = searchSpaces[0].id; const encoded = encodeURIComponent(prompt); - router.push(`/dashboard/${spaceId}/new-chat?quickAskPrompt=${encoded}&quickAskMode=${mode}`); + window.location.href = `/dashboard/${spaceId}/new-chat?quickAskPrompt=${encoded}&quickAskMode=${mode}`; }; const handleAction = (actionId: string) => { const action = DEFAULT_ACTIONS.find((a) => a.id === actionId); - if (!action || !clipboardText) return; - + if (!action) return; const prompt = action.prompt.replace("{selection}", clipboardText); navigateToChat(prompt, action.mode); }; - const handleAskAnything = () => { - if (!clipboardText) return; - navigateToChat(clipboardText, "explore"); - }; - const transformActions = DEFAULT_ACTIONS.filter((a) => a.group === "transform"); const exploreActions = DEFAULT_ACTIONS.filter((a) => a.group === "explore"); const knowledgeActions = DEFAULT_ACTIONS.filter((a) => a.group === "knowledge"); - return ( -
- - - - No actions found. + const ready = !isLoading && clipboardText; - + return ( +
+
+ {!ready && ( +
Loading...
+ )} + {ready && ( +
+
Transform
{transformActions.map((action) => ( - handleAction(action.id)}> + ))} - - +
- +
Explore
{exploreActions.map((action) => ( - handleAction(action.id)}> + ))} -
- +
- +
Knowledge
{knowledgeActions.map((action) => ( - handleAction(action.id)}> + ))} -
- +
- - + +
+ )} +
); } diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 1644b0163..195afc090 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -306,13 +306,6 @@ const Composer: FC = () => { const aui = useAui(); const hasAutoFocusedRef = useRef(false); - const [quickAskText, setQuickAskText] = useState(); - useEffect(() => { - window.electronAPI?.getQuickAskText().then((text) => { - if (text) setQuickAskText(text); - }); - }, []); - const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty); const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); @@ -519,7 +512,6 @@ const Composer: FC = () => { onDocumentRemove={handleDocumentRemove} onSubmit={handleSubmit} onKeyDown={handleKeyDown} - initialText={quickAskText} className="min-h-[24px]" />
From 8d60fc7279437753815fded1bd63cb37dca0c240 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 27 Mar 2026 18:41:58 +0200 Subject: [PATCH 19/37] remove searchSpacesAtom from quick-ask, forward params via dashboard --- surfsense_web/app/dashboard/page.tsx | 9 ++++++--- surfsense_web/app/quick-ask/page.tsx | 13 +++---------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx index 2bd8f4462..525060bed 100644 --- a/surfsense_web/app/dashboard/page.tsx +++ b/surfsense_web/app/dashboard/page.tsx @@ -3,7 +3,7 @@ import { useAtomValue } from "jotai"; import { AlertCircle, Plus, Search } from "lucide-react"; import { motion } from "motion/react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; @@ -89,6 +89,7 @@ function EmptyState({ onCreateClick }: { onCreateClick: () => void }) { export default function DashboardPage() { const router = useRouter(); + const searchParams = useSearchParams(); const [showCreateDialog, setShowCreateDialog] = useState(false); const t = useTranslations("dashboard"); @@ -98,9 +99,11 @@ export default function DashboardPage() { if (isLoading) return; if (searchSpaces.length > 0) { - router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`); + const params = searchParams.toString(); + const query = params ? `?${params}` : ""; + router.replace(`/dashboard/${searchSpaces[0].id}/new-chat${query}`); } - }, [isLoading, searchSpaces, router]); + }, [isLoading, searchSpaces, router, searchParams]); // Show loading while fetching or while we have spaces and are about to redirect const shouldShowLoading = isLoading || searchSpaces.length > 0; diff --git a/surfsense_web/app/quick-ask/page.tsx b/surfsense_web/app/quick-ask/page.tsx index e8191c913..0a304b3db 100644 --- a/surfsense_web/app/quick-ask/page.tsx +++ b/surfsense_web/app/quick-ask/page.tsx @@ -1,6 +1,5 @@ "use client"; -import { useAtomValue } from "jotai"; import { BookOpen, Check, @@ -13,7 +12,6 @@ import { Search, } from "lucide-react"; import { useEffect, useState } from "react"; -import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { DEFAULT_ACTIONS } from "./actions"; const ICONS: Record = { @@ -28,7 +26,6 @@ const ICONS: Record = { }; export default function QuickAskPage() { - const { data: searchSpaces = [], isLoading } = useAtomValue(searchSpacesAtom); const [clipboardText, setClipboardText] = useState(""); useEffect(() => { @@ -38,10 +35,8 @@ export default function QuickAskPage() { }, []); const navigateToChat = (prompt: string, mode: string) => { - if (!searchSpaces.length || !clipboardText) return; - const spaceId = searchSpaces[0].id; const encoded = encodeURIComponent(prompt); - window.location.href = `/dashboard/${spaceId}/new-chat?quickAskPrompt=${encoded}&quickAskMode=${mode}`; + window.location.href = `/dashboard?quickAskPrompt=${encoded}&quickAskMode=${mode}`; }; const handleAction = (actionId: string) => { @@ -55,15 +50,13 @@ export default function QuickAskPage() { const exploreActions = DEFAULT_ACTIONS.filter((a) => a.group === "explore"); const knowledgeActions = DEFAULT_ACTIONS.filter((a) => a.group === "knowledge"); - const ready = !isLoading && clipboardText; - return (
- {!ready && ( + {!clipboardText && (
Loading...
)} - {ready && ( + {clipboardText && (
Transform
{transformActions.map((action) => ( From af2129ebb65a825359b96042f826764f0e143555 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 27 Mar 2026 18:55:03 +0200 Subject: [PATCH 20/37] move quick-ask page into dashboard route for auth context --- surfsense_desktop/src/modules/quick-ask.ts | 2 +- surfsense_web/app/{ => dashboard}/quick-ask/actions.ts | 0 surfsense_web/app/{ => dashboard}/quick-ask/page.tsx | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename surfsense_web/app/{ => dashboard}/quick-ask/actions.ts (100%) rename surfsense_web/app/{ => dashboard}/quick-ask/page.tsx (100%) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 3ff108dd3..436281c2d 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -50,7 +50,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { skipTaskbar: true, }); - quickAskWindow.loadURL(`http://localhost:${getServerPort()}/quick-ask`); + quickAskWindow.loadURL(`http://localhost:${getServerPort()}/dashboard/quick-ask`); quickAskWindow.once('ready-to-show', () => { quickAskWindow?.show(); diff --git a/surfsense_web/app/quick-ask/actions.ts b/surfsense_web/app/dashboard/quick-ask/actions.ts similarity index 100% rename from surfsense_web/app/quick-ask/actions.ts rename to surfsense_web/app/dashboard/quick-ask/actions.ts diff --git a/surfsense_web/app/quick-ask/page.tsx b/surfsense_web/app/dashboard/quick-ask/page.tsx similarity index 100% rename from surfsense_web/app/quick-ask/page.tsx rename to surfsense_web/app/dashboard/quick-ask/page.tsx From f9a6e648cffdf765e4479209aaccb15cf3dff8c5 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 27 Mar 2026 19:10:15 +0200 Subject: [PATCH 21/37] fix: don't clear pendingText on read to survive auth remount --- surfsense_desktop/src/modules/quick-ask.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 436281c2d..6c7e7a711 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -99,9 +99,7 @@ export function registerQuickAsk(): void { } ipcMain.handle(IPC_CHANNELS.QUICK_ASK_TEXT, () => { - const text = pendingText; - pendingText = ''; - return text; + return pendingText; }); ipcMain.handle(IPC_CHANNELS.REPLACE_TEXT, async (_event, text: string) => { From 151d6a853e3c2fe1d1d44c6ee2453c2b60a5f96c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 27 Mar 2026 19:20:14 +0200 Subject: [PATCH 22/37] use sessionStorage for quickAskMode to survive route changes --- surfsense_web/app/dashboard/quick-ask/page.tsx | 3 ++- surfsense_web/components/assistant-ui/assistant-message.tsx | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/surfsense_web/app/dashboard/quick-ask/page.tsx b/surfsense_web/app/dashboard/quick-ask/page.tsx index 0a304b3db..b6194d0af 100644 --- a/surfsense_web/app/dashboard/quick-ask/page.tsx +++ b/surfsense_web/app/dashboard/quick-ask/page.tsx @@ -35,8 +35,9 @@ export default function QuickAskPage() { }, []); const navigateToChat = (prompt: string, mode: string) => { + sessionStorage.setItem("quickAskMode", mode); const encoded = encodeURIComponent(prompt); - window.location.href = `/dashboard?quickAskPrompt=${encoded}&quickAskMode=${mode}`; + window.location.href = `/dashboard?quickAskPrompt=${encoded}`; }; const handleAction = (actionId: string) => { diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 27ba08395..d9837b224 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -8,7 +8,6 @@ import { } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; import { CheckIcon, ClipboardPaste, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; -import { useSearchParams } from "next/navigation"; import type { FC } from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; @@ -275,12 +274,11 @@ export const AssistantMessage: FC = () => { const AssistantActionBar: FC = () => { const isLast = useAuiState((s) => s.message.isLast); const aui = useAui(); - const searchParams = useSearchParams(); const isTransform = isLast && typeof window !== "undefined" && !!window.electronAPI?.replaceText && - searchParams.get("quickAskMode") === "transform"; + sessionStorage.getItem("quickAskMode") === "transform"; return ( Date: Fri, 27 Mar 2026 19:47:02 +0200 Subject: [PATCH 23/37] redesign action menu: grid layout, search, Ask SurfSense, fix action groups --- .../new-chat/[[...chat_id]]/page.tsx | 25 +-- .../app/dashboard/quick-ask/actions.ts | 28 ++-- .../app/dashboard/quick-ask/page.tsx | 150 +++++++++++------- 3 files changed, 120 insertions(+), 83 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index e91ad43e9..5024c446c 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -162,19 +162,26 @@ const TOOLS_WITH_UI = new Set([ function QuickAskAutoSubmit() { const searchParams = useSearchParams(); const aui = useAui(); - const submittedRef = useRef(false); + const handledRef = useRef(false); useEffect(() => { - if (!window.electronAPI || submittedRef.current) return; + if (!window.electronAPI || handledRef.current) return; const prompt = searchParams.get("quickAskPrompt"); - if (!prompt) return; - - submittedRef.current = true; - setTimeout(() => { - aui.composer().setText(prompt); - aui.composer().send(); - }, 500); + const initialText = searchParams.get("quickAskInitialText"); + + if (prompt) { + handledRef.current = true; + setTimeout(() => { + aui.composer().setText(prompt); + aui.composer().send(); + }, 500); + } else if (initialText) { + handledRef.current = true; + setTimeout(() => { + aui.composer().setText(initialText); + }, 500); + } }, [searchParams, aui]); return null; diff --git a/surfsense_web/app/dashboard/quick-ask/actions.ts b/surfsense_web/app/dashboard/quick-ask/actions.ts index 8d4ff0bb5..984aef2b6 100644 --- a/surfsense_web/app/dashboard/quick-ask/actions.ts +++ b/surfsense_web/app/dashboard/quick-ask/actions.ts @@ -33,6 +33,14 @@ export const DEFAULT_ACTIONS: QuickAskAction[] = [ icon: "pen-line", group: "transform", }, + { + id: "summarize", + name: "Summarize", + prompt: "Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}", + mode: "transform", + icon: "list", + group: "transform", + }, { id: "explain", name: "Explain", @@ -42,27 +50,19 @@ export const DEFAULT_ACTIONS: QuickAskAction[] = [ group: "explore", }, { - id: "summarize", - name: "Summarize", - prompt: "Summarize the following text:\n\n{selection}", - mode: "explore", - icon: "list", - group: "explore", - }, - { - id: "search-knowledge", - name: "Search my knowledge", + id: "ask-knowledge-base", + name: "Ask my knowledge base", prompt: "Search my knowledge base for information related to:\n\n{selection}", mode: "explore", icon: "search", - group: "knowledge", + group: "explore", }, { - id: "search-web", - name: "Search the web", + id: "look-up-web", + name: "Look up on the web", prompt: "Search the web for information about:\n\n{selection}", mode: "explore", icon: "globe", - group: "knowledge", + group: "explore", }, ]; diff --git a/surfsense_web/app/dashboard/quick-ask/page.tsx b/surfsense_web/app/dashboard/quick-ask/page.tsx index b6194d0af..95395c14d 100644 --- a/surfsense_web/app/dashboard/quick-ask/page.tsx +++ b/surfsense_web/app/dashboard/quick-ask/page.tsx @@ -11,7 +11,7 @@ import { PenLine, Search, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { DEFAULT_ACTIONS } from "./actions"; const ICONS: Record = { @@ -27,6 +27,7 @@ const ICONS: Record = { export default function QuickAskPage() { const [clipboardText, setClipboardText] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); useEffect(() => { window.electronAPI?.getQuickAskText().then((text) => { @@ -40,80 +41,109 @@ export default function QuickAskPage() { window.location.href = `/dashboard?quickAskPrompt=${encoded}`; }; + const navigateWithInitialText = () => { + if (!clipboardText) return; + sessionStorage.setItem("quickAskMode", "explore"); + const encoded = encodeURIComponent(clipboardText); + window.location.href = `/dashboard?quickAskInitialText=${encoded}`; + }; + const handleAction = (actionId: string) => { const action = DEFAULT_ACTIONS.find((a) => a.id === actionId); - if (!action) return; + if (!action || !clipboardText) return; const prompt = action.prompt.replace("{selection}", clipboardText); navigateToChat(prompt, action.mode); }; const transformActions = DEFAULT_ACTIONS.filter((a) => a.group === "transform"); const exploreActions = DEFAULT_ACTIONS.filter((a) => a.group === "explore"); - const knowledgeActions = DEFAULT_ACTIONS.filter((a) => a.group === "knowledge"); - return ( -
-
- {!clipboardText && ( -
Loading...
- )} - {clipboardText && ( -
-
Transform
- {transformActions.map((action) => ( - - ))} + const filteredTransform = useMemo( + () => transformActions.filter((a) => a.name.toLowerCase().includes(searchQuery.toLowerCase())), + [searchQuery] + ); + const filteredExplore = useMemo( + () => exploreActions.filter((a) => a.name.toLowerCase().includes(searchQuery.toLowerCase())), + [searchQuery] + ); -
+ if (!clipboardText) { + return ( +
+
Loading...
+
+ ); + } -
Explore
- {exploreActions.map((action) => ( - - ))} + return ( +
+
+
+ + setSearchQuery(e.target.value)} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+
-
+
+ {filteredTransform.length > 0 && ( + <> +
Transform
+
+ {filteredTransform.map((action) => ( + + ))} +
+ + )} -
Knowledge
- {knowledgeActions.map((action) => ( - - ))} + {filteredExplore.length > 0 && ( + <> +
Explore
+
+ {filteredExplore.map((action) => ( + + ))} +
+ + )} -
+
My Actions
+
+ Custom actions coming soon +
+
- -
- )} +
+
); From 9f13da3fd12e76e0337c453cc4ec24ef270b8a50 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 27 Mar 2026 20:07:55 +0200 Subject: [PATCH 24/37] fix Ask SurfSense: pre-fill with initialText and cursor positioning --- .../new-chat/[[...chat_id]]/page.tsx | 22 +++++++------------ .../app/dashboard/quick-ask/page.tsx | 6 +++-- .../components/assistant-ui/thread.tsx | 13 +++++++++++ 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 5024c446c..b0928d9b2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -166,22 +166,16 @@ function QuickAskAutoSubmit() { useEffect(() => { if (!window.electronAPI || handledRef.current) return; + if (sessionStorage.getItem("quickAskAutoSubmit") === "false") return; const prompt = searchParams.get("quickAskPrompt"); - const initialText = searchParams.get("quickAskInitialText"); - - if (prompt) { - handledRef.current = true; - setTimeout(() => { - aui.composer().setText(prompt); - aui.composer().send(); - }, 500); - } else if (initialText) { - handledRef.current = true; - setTimeout(() => { - aui.composer().setText(initialText); - }, 500); - } + if (!prompt) return; + + handledRef.current = true; + setTimeout(() => { + aui.composer().setText(prompt); + aui.composer().send(); + }, 500); }, [searchParams, aui]); return null; diff --git a/surfsense_web/app/dashboard/quick-ask/page.tsx b/surfsense_web/app/dashboard/quick-ask/page.tsx index 95395c14d..e4fb18dde 100644 --- a/surfsense_web/app/dashboard/quick-ask/page.tsx +++ b/surfsense_web/app/dashboard/quick-ask/page.tsx @@ -37,6 +37,7 @@ export default function QuickAskPage() { const navigateToChat = (prompt: string, mode: string) => { sessionStorage.setItem("quickAskMode", mode); + sessionStorage.setItem("quickAskAutoSubmit", "true"); const encoded = encodeURIComponent(prompt); window.location.href = `/dashboard?quickAskPrompt=${encoded}`; }; @@ -44,8 +45,9 @@ export default function QuickAskPage() { const navigateWithInitialText = () => { if (!clipboardText) return; sessionStorage.setItem("quickAskMode", "explore"); - const encoded = encodeURIComponent(clipboardText); - window.location.href = `/dashboard?quickAskInitialText=${encoded}`; + sessionStorage.setItem("quickAskAutoSubmit", "false"); + sessionStorage.setItem("quickAskInitialText", clipboardText); + window.location.href = `/dashboard?quickAskPrompt=${encodeURIComponent(clipboardText)}`; }; const handleAction = (actionId: string) => { diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 195afc090..059aaf5a0 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -306,6 +306,18 @@ const Composer: FC = () => { const aui = useAui(); const hasAutoFocusedRef = useRef(false); + const [quickAskInitialText, setQuickAskInitialText] = useState(); + useEffect(() => { + if (!window.electronAPI) return; + if (sessionStorage.getItem("quickAskAutoSubmit") === "false") { + const text = sessionStorage.getItem("quickAskInitialText"); + if (text) { + setQuickAskInitialText(text); + sessionStorage.removeItem("quickAskInitialText"); + } + } + }, []); + const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty); const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); @@ -512,6 +524,7 @@ const Composer: FC = () => { onDocumentRemove={handleDocumentRemove} onSubmit={handleSubmit} onKeyDown={handleKeyDown} + initialText={quickAskInitialText} className="min-h-[24px]" />
From 58ac17fb81893fe5b587a386dc2fcf942c46e483 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 27 Mar 2026 20:35:29 +0200 Subject: [PATCH 25/37] fix: move quickAskMode to IPC to prevent sessionStorage leak between windows --- surfsense_desktop/src/ipc/channels.ts | 2 ++ surfsense_desktop/src/modules/quick-ask.ts | 10 ++++++++++ surfsense_desktop/src/preload.ts | 2 ++ .../new-chat/[[...chat_id]]/page.tsx | 2 +- surfsense_web/app/dashboard/quick-ask/page.tsx | 8 ++++---- .../assistant-ui/assistant-message.tsx | 17 +++++++++++------ surfsense_web/types/window.d.ts | 2 ++ 7 files changed, 32 insertions(+), 11 deletions(-) diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 1a2a9993e..25ec1bc0e 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -3,5 +3,7 @@ export const IPC_CHANNELS = { GET_APP_VERSION: 'get-app-version', DEEP_LINK: 'deep-link', QUICK_ASK_TEXT: 'quick-ask-text', + SET_QUICK_ASK_MODE: 'set-quick-ask-mode', + GET_QUICK_ASK_MODE: 'get-quick-ask-mode', REPLACE_TEXT: 'replace-text', } as const; diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 6c7e7a711..4a8b4c315 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -7,6 +7,7 @@ import { getServerPort } from './server'; const SHORTCUT = 'CommandOrControl+Option+S'; let quickAskWindow: BrowserWindow | null = null; let pendingText = ''; +let pendingMode = ''; let sourceApp = ''; let savedClipboard = ''; @@ -15,6 +16,7 @@ function destroyQuickAsk(): void { quickAskWindow.close(); } quickAskWindow = null; + pendingMode = ''; } function clampToScreen(x: number, y: number, w: number, h: number): { x: number; y: number } { @@ -102,6 +104,14 @@ export function registerQuickAsk(): void { return pendingText; }); + 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.REPLACE_TEXT, async (_event, text: string) => { if (!sourceApp) return; diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index fbb272108..264ec25b3 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -18,5 +18,7 @@ contextBridge.exposeInMainWorld('electronAPI', { }; }, getQuickAskText: () => ipcRenderer.invoke(IPC_CHANNELS.QUICK_ASK_TEXT), + setQuickAskMode: (mode: string) => ipcRenderer.invoke(IPC_CHANNELS.SET_QUICK_ASK_MODE, mode), + getQuickAskMode: () => ipcRenderer.invoke(IPC_CHANNELS.GET_QUICK_ASK_MODE), replaceText: (text: string) => ipcRenderer.invoke(IPC_CHANNELS.REPLACE_TEXT, text), }); diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index b0928d9b2..ac203157a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -1621,4 +1621,4 @@ export default function NewChatPage() {
); -} +} \ No newline at end of file diff --git a/surfsense_web/app/dashboard/quick-ask/page.tsx b/surfsense_web/app/dashboard/quick-ask/page.tsx index e4fb18dde..dca398254 100644 --- a/surfsense_web/app/dashboard/quick-ask/page.tsx +++ b/surfsense_web/app/dashboard/quick-ask/page.tsx @@ -35,16 +35,16 @@ export default function QuickAskPage() { }); }, []); - const navigateToChat = (prompt: string, mode: string) => { - sessionStorage.setItem("quickAskMode", mode); + const navigateToChat = async (prompt: string, mode: string) => { + await window.electronAPI?.setQuickAskMode(mode); sessionStorage.setItem("quickAskAutoSubmit", "true"); const encoded = encodeURIComponent(prompt); window.location.href = `/dashboard?quickAskPrompt=${encoded}`; }; - const navigateWithInitialText = () => { + const navigateWithInitialText = async () => { if (!clipboardText) return; - sessionStorage.setItem("quickAskMode", "explore"); + await window.electronAPI?.setQuickAskMode("explore"); sessionStorage.setItem("quickAskAutoSubmit", "false"); sessionStorage.setItem("quickAskInitialText", clipboardText); window.location.href = `/dashboard?quickAskPrompt=${encodeURIComponent(clipboardText)}`; diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index d9837b224..af4d8def4 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -9,7 +9,7 @@ import { import { useAtomValue } from "jotai"; import { CheckIcon, ClipboardPaste, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; import type { FC } from "react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; @@ -274,11 +274,16 @@ export const AssistantMessage: FC = () => { const AssistantActionBar: FC = () => { const isLast = useAuiState((s) => s.message.isLast); const aui = useAui(); - const isTransform = - isLast && - typeof window !== "undefined" && - !!window.electronAPI?.replaceText && - sessionStorage.getItem("quickAskMode") === "transform"; + const [quickAskMode, setQuickAskMode] = useState(""); + + useEffect(() => { + if (!isLast || !window.electronAPI?.getQuickAskMode) return; + window.electronAPI.getQuickAskMode().then((mode) => { + if (mode) setQuickAskMode(mode); + }); + }, [isLast]); + + const isTransform = isLast && !!window.electronAPI?.replaceText && quickAskMode === "transform"; return ( Promise; onDeepLink: (callback: (url: string) => void) => () => void; getQuickAskText: () => Promise; + setQuickAskMode: (mode: string) => Promise; + getQuickAskMode: () => Promise; replaceText: (text: string) => Promise; } From cb6f4562ded756a92fb29cecc764a1bc0241b2b0 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sat, 28 Mar 2026 23:12:33 +0200 Subject: [PATCH 26/37] add / action trigger to InlineMentionEditor --- .../assistant-ui/inline-mention-editor.tsx | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 66389cade..3994de1d5 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -40,6 +40,8 @@ interface InlineMentionEditorProps { placeholder?: string; onMentionTrigger?: (query: string) => void; onMentionClose?: () => void; + onActionTrigger?: (query: string) => void; + onActionClose?: () => void; onSubmit?: () => void; onChange?: (text: string, docs: MentionedDocument[]) => void; onDocumentRemove?: (docId: number, docType?: string) => void; @@ -90,6 +92,8 @@ export const InlineMentionEditor = forwardRef 0) { + const range = selection.getRangeAt(0); + const textNode = range.startContainer; + + if (textNode.nodeType === Node.TEXT_NODE) { + const textContent = textNode.textContent || ""; + const cursorPos = range.startOffset; + + let slashIndex = -1; + for (let i = cursorPos - 1; i >= 0; i--) { + if (textContent[i] === "/") { + slashIndex = i; + break; + } + if (textContent[i] === " " || textContent[i] === "\n") { + break; + } + } + + if (slashIndex !== -1 && (slashIndex === 0 || textContent[slashIndex - 1] === " " || textContent[slashIndex - 1] === "\n")) { + const query = textContent.slice(slashIndex + 1, cursorPos); + if (!query.startsWith(" ")) { + shouldTriggerAction = true; + actionQuery = query; + } + } + } + } + // If no @ found before cursor, check if text contains @ at all // If text is empty or doesn't contain @, close the mention if (!shouldTriggerMention) { @@ -533,9 +570,15 @@ export const InlineMentionEditor = forwardRef Date: Sat, 28 Mar 2026 23:16:02 +0200 Subject: [PATCH 27/37] add ActionPicker component for / command trigger --- .../components/new-chat/action-picker.tsx | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 surfsense_web/components/new-chat/action-picker.tsx diff --git a/surfsense_web/components/new-chat/action-picker.tsx b/surfsense_web/components/new-chat/action-picker.tsx new file mode 100644 index 000000000..d5ef01ae1 --- /dev/null +++ b/surfsense_web/components/new-chat/action-picker.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { + BookOpen, + Check, + Globe, + Languages, + List, + Minimize2, + PenLine, + Search, + Zap, +} from "lucide-react"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; + +import { cn } from "@/lib/utils"; + +export interface ActionPickerRef { + selectHighlighted: () => void; + moveUp: () => void; + moveDown: () => void; +} + +interface ActionPickerProps { + onSelect: (action: { name: string; prompt: string; mode: "transform" | "explore" }) => void; + onDone: () => void; + externalSearch?: string; + containerStyle?: React.CSSProperties; +} + +const ICONS: Record = { + check: , + minimize: , + languages: , + "pen-line": , + "book-open": , + list: , + search: , + globe: , + zap: , +}; + +const DEFAULT_ACTIONS: { name: string; prompt: string; mode: "transform" | "explore"; icon: string }[] = [ + { name: "Fix grammar", prompt: "Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}", mode: "transform", icon: "check" }, + { name: "Make shorter", prompt: "Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}", mode: "transform", icon: "minimize" }, + { name: "Translate", prompt: "Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}", mode: "transform", icon: "languages" }, + { name: "Rewrite", prompt: "Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}", mode: "transform", icon: "pen-line" }, + { name: "Summarize", prompt: "Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}", mode: "transform", icon: "list" }, + { name: "Explain", prompt: "Explain the following text in simple terms:\n\n{selection}", mode: "explore", icon: "book-open" }, + { name: "Ask my knowledge base", prompt: "Search my knowledge base for information related to:\n\n{selection}", mode: "explore", icon: "search" }, + { name: "Look up on the web", prompt: "Search the web for information about:\n\n{selection}", mode: "explore", icon: "globe" }, +]; + +export const ActionPicker = forwardRef( + function ActionPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) { + const [highlightedIndex, setHighlightedIndex] = useState(0); + const scrollContainerRef = useRef(null); + const shouldScrollRef = useRef(false); + const itemRefs = useRef>(new Map()); + + const allActions = DEFAULT_ACTIONS; + + const filtered = useMemo(() => { + if (!externalSearch) return allActions; + return allActions.filter((a) => + a.name.toLowerCase().includes(externalSearch.toLowerCase()) + ); + }, [allActions, externalSearch]); + + // Reset highlight when results change + const prevSearchRef = useRef(externalSearch); + if (prevSearchRef.current !== externalSearch) { + prevSearchRef.current = externalSearch; + if (highlightedIndex !== 0) { + setHighlightedIndex(0); + } + } + + const handleSelect = useCallback( + (index: number) => { + const action = filtered[index]; + if (!action) return; + onSelect({ name: action.name, prompt: action.prompt, mode: action.mode }); + onDone(); + }, + [filtered, onSelect, onDone] + ); + + // Auto-scroll highlighted item into view + useEffect(() => { + if (!shouldScrollRef.current) return; + shouldScrollRef.current = false; + + const rafId = requestAnimationFrame(() => { + const item = itemRefs.current.get(highlightedIndex); + const container = scrollContainerRef.current; + if (item && container) { + const itemRect = item.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) { + item.scrollIntoView({ block: "nearest" }); + } + } + }); + + return () => cancelAnimationFrame(rafId); + }, [highlightedIndex]); + + useImperativeHandle( + ref, + () => ({ + selectHighlighted: () => handleSelect(highlightedIndex), + moveUp: () => { + shouldScrollRef.current = true; + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1)); + }, + moveDown: () => { + shouldScrollRef.current = true; + setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0)); + }, + }), + [filtered.length, highlightedIndex, handleSelect] + ); + + if (filtered.length === 0) return null; + + return ( +
+
+ {filtered.map((action, index) => ( + + ))} +
+
+ ); + } +); From c2644aa6a256029bd25916b09b46b32781dfe45f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sat, 28 Mar 2026 23:20:10 +0200 Subject: [PATCH 28/37] wire / action picker in Composer with keyboard navigation --- .../components/assistant-ui/thread.tsx | 94 ++++++++++++++++++- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 059aaf5a0..efe56ca19 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -57,6 +57,7 @@ import { import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { UserMessage } from "@/components/assistant-ui/user-message"; import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/components/layout/ui/sidebar/SidebarSlideOutPanel"; +import { ActionPicker, type ActionPickerRef } from "@/components/new-chat/action-picker"; import { DocumentMentionPicker, type DocumentMentionPickerRef, @@ -298,10 +299,13 @@ const Composer: FC = () => { const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const setSidebarDocs = useSetAtom(sidebarSelectedDocumentsAtom); const [showDocumentPopover, setShowDocumentPopover] = useState(false); + const [showActionPicker, setShowActionPicker] = useState(false); const [mentionQuery, setMentionQuery] = useState(""); + const [actionQuery, setActionQuery] = useState(""); const editorRef = useRef(null); const editorContainerRef = useRef(null); const documentPickerRef = useRef(null); + const actionPickerRef = useRef(null); const { search_space_id, chat_id } = useParams(); const aui = useAui(); const hasAutoFocusedRef = useRef(false); @@ -421,9 +425,69 @@ const Composer: FC = () => { } }, [showDocumentPopover]); - // Keyboard navigation for document picker (arrow keys, Enter, Escape) + // Open action picker when / is triggered + const handleActionTrigger = useCallback((query: string) => { + setShowActionPicker(true); + setActionQuery(query); + }, []); + + // Close action picker and reset query + const handleActionClose = useCallback(() => { + if (showActionPicker) { + setShowActionPicker(false); + setActionQuery(""); + } + }, [showActionPicker]); + + // Handle action selection: prepend prompt template and auto-submit + 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([]); + } + }, + [aui, setMentionedDocuments, setSidebarDocs] + ); + + // Keyboard navigation for document/action picker (arrow keys, Enter, Escape) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (showActionPicker) { + if (e.key === "ArrowDown") { + e.preventDefault(); + actionPickerRef.current?.moveDown(); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + actionPickerRef.current?.moveUp(); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + actionPickerRef.current?.selectHighlighted(); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setShowActionPicker(false); + setActionQuery(""); + return; + } + } if (showDocumentPopover) { if (e.key === "ArrowDown") { e.preventDefault(); @@ -448,7 +512,7 @@ const Composer: FC = () => { } } }, - [showDocumentPopover] + [showDocumentPopover, showActionPicker] ); // Submit message (blocked during streaming, document picker open, or AI responding to another user) @@ -520,6 +584,8 @@ const Composer: FC = () => { placeholder={currentPlaceholder} onMentionTrigger={handleMentionTrigger} onMentionClose={handleMentionClose} + onActionTrigger={handleActionTrigger} + onActionClose={handleActionClose} onChange={handleEditorChange} onDocumentRemove={handleDocumentRemove} onSubmit={handleSubmit} @@ -553,6 +619,30 @@ const Composer: FC = () => { />, document.body )} + {showActionPicker && + typeof document !== "undefined" && + createPortal( + { + setShowActionPicker(false); + setActionQuery(""); + }} + externalSearch={actionQuery} + containerStyle={{ + position: "fixed", + bottom: editorContainerRef.current + ? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px` + : "200px", + left: editorContainerRef.current + ? `${editorContainerRef.current.getBoundingClientRect().left}px` + : "50%", + zIndex: 50, + }} + />, + document.body + )} From 407059ce84c74185bf5f923f345abbd040568cbc Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sat, 28 Mar 2026 23:45:11 +0200 Subject: [PATCH 29/37] add action chip in composer with prompt prepend at send time --- .../assistant-ui/inline-mention-editor.tsx | 123 +++++++++++++++++- .../components/assistant-ui/thread.tsx | 38 +++--- 2 files changed, 142 insertions(+), 19 deletions(-) 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` From 041401aefc3f09aefa449d1da772b4c27d3ac04a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 27 Mar 2026 21:02:36 +0200 Subject: [PATCH 30/37] add custom quick-ask actions: model, migration, schemas, CRUD routes --- .../109_add_quick_ask_actions_table.py | 62 ++++++++++++ surfsense_backend/app/db.py | 29 ++++++ surfsense_backend/app/routes/__init__.py | 2 + .../app/routes/quick_ask_actions_routes.py | 94 +++++++++++++++++++ .../app/schemas/quick_ask_actions.py | 31 ++++++ 5 files changed, 218 insertions(+) create mode 100644 surfsense_backend/alembic/versions/109_add_quick_ask_actions_table.py create mode 100644 surfsense_backend/app/routes/quick_ask_actions_routes.py create mode 100644 surfsense_backend/app/schemas/quick_ask_actions.py diff --git a/surfsense_backend/alembic/versions/109_add_quick_ask_actions_table.py b/surfsense_backend/alembic/versions/109_add_quick_ask_actions_table.py new file mode 100644 index 000000000..2b8db7cd4 --- /dev/null +++ b/surfsense_backend/alembic/versions/109_add_quick_ask_actions_table.py @@ -0,0 +1,62 @@ +"""add quick_ask_actions table + +Revision ID: 109 +Revises: 108 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "109" +down_revision: str | None = "108" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute(""" + DO $$ BEGIN + CREATE TYPE quick_ask_action_mode AS ENUM ('transform', 'explore'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + """) + + conn = op.get_bind() + result = conn.execute( + sa.text("SELECT 1 FROM information_schema.tables WHERE table_name = 'quick_ask_actions'") + ) + if not result.fetchone(): + op.create_table( + "quick_ask_actions", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("search_space_id", sa.Integer(), nullable=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("prompt", sa.Text(), nullable=False), + sa.Column( + "mode", + sa.Enum("transform", "explore", name="quick_ask_action_mode", create_type=False), + nullable=False, + ), + sa.Column("icon", sa.String(50), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["search_space_id"], ["searchspaces.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_quick_ask_actions_user_id", "quick_ask_actions", ["user_id"]) + op.create_index("ix_quick_ask_actions_search_space_id", "quick_ask_actions", ["search_space_id"]) + + +def downgrade() -> None: + op.drop_table("quick_ask_actions") + op.execute("DROP TYPE IF EXISTS quick_ask_action_mode") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 132bd8dae..eaa445223 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1722,6 +1722,35 @@ class SearchSpaceInvite(BaseModel, TimestampMixin): ) +class QuickAskActionMode(StrEnum): + TRANSFORM = "transform" + EXPLORE = "explore" + + +class QuickAskAction(BaseModel, TimestampMixin): + __tablename__ = "quick_ask_actions" + + user_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + search_space_id = Column( + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + name = Column(String(200), nullable=False) + prompt = Column(Text, nullable=False) + mode = Column(SQLAlchemyEnum(QuickAskActionMode), nullable=False) + icon = Column(String(50), nullable=True) + + user = relationship("User") + search_space = relationship("SearchSpace") + + if config.AUTH_TYPE == "GOOGLE": class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base): diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index f6975b69d..171ee5792 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -34,6 +34,7 @@ from .notion_add_connector_route import router as notion_add_connector_router from .podcasts_routes import router as podcasts_router from .public_chat_routes import router as public_chat_router +from .quick_ask_actions_routes import router as quick_ask_actions_router from .rbac_routes import router as rbac_router from .reports_routes import router as reports_router from .sandbox_routes import router as sandbox_router @@ -85,3 +86,4 @@ router.include_router(public_chat_router) # Public chat sharing and cloning router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages router.include_router(youtube_router) # YouTube playlist resolution +router.include_router(quick_ask_actions_router) diff --git a/surfsense_backend/app/routes/quick_ask_actions_routes.py b/surfsense_backend/app/routes/quick_ask_actions_routes.py new file mode 100644 index 000000000..6b9868a07 --- /dev/null +++ b/surfsense_backend/app/routes/quick_ask_actions_routes.py @@ -0,0 +1,94 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import QuickAskAction, User, get_async_session +from app.schemas.quick_ask_actions import ( + QuickAskActionCreate, + QuickAskActionRead, + QuickAskActionUpdate, +) +from app.users import current_active_user + +router = APIRouter(tags=["Quick Ask Actions"]) + + +@router.get("/quick-ask-actions", response_model=list[QuickAskActionRead]) +async def list_actions( + search_space_id: int | None = None, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + query = select(QuickAskAction).where(QuickAskAction.user_id == user.id) + if search_space_id is not None: + query = query.where(QuickAskAction.search_space_id == search_space_id) + query = query.order_by(QuickAskAction.created_at.desc()) + result = await session.execute(query) + return result.scalars().all() + + +@router.post("/quick-ask-actions", response_model=QuickAskActionRead) +async def create_action( + body: QuickAskActionCreate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + action = QuickAskAction( + user_id=user.id, + search_space_id=body.search_space_id, + name=body.name, + prompt=body.prompt, + mode=body.mode, + icon=body.icon, + ) + session.add(action) + await session.commit() + await session.refresh(action) + return action + + +@router.put("/quick-ask-actions/{action_id}", response_model=QuickAskActionRead) +async def update_action( + action_id: int, + body: QuickAskActionUpdate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + result = await session.execute( + select(QuickAskAction).where( + QuickAskAction.id == action_id, + QuickAskAction.user_id == user.id, + ) + ) + action = result.scalar_one_or_none() + if not action: + raise HTTPException(status_code=404, detail="Action not found") + + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(action, field, value) + + session.add(action) + await session.commit() + await session.refresh(action) + return action + + +@router.delete("/quick-ask-actions/{action_id}") +async def delete_action( + action_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + result = await session.execute( + select(QuickAskAction).where( + QuickAskAction.id == action_id, + QuickAskAction.user_id == user.id, + ) + ) + action = result.scalar_one_or_none() + if not action: + raise HTTPException(status_code=404, detail="Action not found") + + await session.delete(action) + await session.commit() + return {"success": True} diff --git a/surfsense_backend/app/schemas/quick_ask_actions.py b/surfsense_backend/app/schemas/quick_ask_actions.py new file mode 100644 index 000000000..90fa716b9 --- /dev/null +++ b/surfsense_backend/app/schemas/quick_ask_actions.py @@ -0,0 +1,31 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class QuickAskActionCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=200) + prompt: str = Field(..., min_length=1) + mode: str = Field(..., pattern="^(transform|explore)$") + icon: str | None = Field(None, max_length=50) + search_space_id: int | None = None + + +class QuickAskActionUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=200) + prompt: str | None = Field(None, min_length=1) + mode: str | None = Field(None, pattern="^(transform|explore)$") + icon: str | None = Field(None, max_length=50) + + +class QuickAskActionRead(BaseModel): + id: int + name: str + prompt: str + mode: str + icon: str | None + search_space_id: int | None + created_at: datetime + + class Config: + from_attributes = True From 11374248d82cbf30dd80d6f2c92a8473ebd2b759 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sat, 28 Mar 2026 23:51:33 +0200 Subject: [PATCH 31/37] restore custom actions API service and wire to ActionPicker --- .../components/new-chat/action-picker.tsx | 17 +++++- .../types/quick-ask-actions.types.ts | 39 ++++++++++++ .../lib/apis/quick-ask-actions-api.service.ts | 59 +++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 surfsense_web/lib/apis/quick-ask-actions-api.service.ts diff --git a/surfsense_web/components/new-chat/action-picker.tsx b/surfsense_web/components/new-chat/action-picker.tsx index d5ef01ae1..4bfac23f4 100644 --- a/surfsense_web/components/new-chat/action-picker.tsx +++ b/surfsense_web/components/new-chat/action-picker.tsx @@ -21,6 +21,8 @@ import { useState, } from "react"; +import type { QuickAskActionRead } from "@/contracts/types/quick-ask-actions.types"; +import { quickAskActionsApiService } from "@/lib/apis/quick-ask-actions-api.service"; import { cn } from "@/lib/utils"; export interface ActionPickerRef { @@ -62,11 +64,24 @@ const DEFAULT_ACTIONS: { name: string; prompt: string; mode: "transform" | "expl export const ActionPicker = forwardRef( function ActionPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) { const [highlightedIndex, setHighlightedIndex] = useState(0); + const [customActions, setCustomActions] = useState([]); const scrollContainerRef = useRef(null); const shouldScrollRef = useRef(false); const itemRefs = useRef>(new Map()); - const allActions = DEFAULT_ACTIONS; + useEffect(() => { + quickAskActionsApiService.list().then(setCustomActions).catch(() => {}); + }, []); + + const allActions = useMemo(() => { + const customs = customActions.map((a) => ({ + name: a.name, + prompt: a.prompt, + mode: a.mode as "transform" | "explore", + icon: a.icon || "zap", + })); + return [...DEFAULT_ACTIONS, ...customs]; + }, [customActions]); const filtered = useMemo(() => { if (!externalSearch) return allActions; diff --git a/surfsense_web/contracts/types/quick-ask-actions.types.ts b/surfsense_web/contracts/types/quick-ask-actions.types.ts index f7ee22c0b..eaee09501 100644 --- a/surfsense_web/contracts/types/quick-ask-actions.types.ts +++ b/surfsense_web/contracts/types/quick-ask-actions.types.ts @@ -1,5 +1,44 @@ +import { z } from "zod"; + export type QuickAskActionMode = "transform" | "explore"; +export const quickAskActionRead = z.object({ + id: z.number(), + name: z.string(), + prompt: z.string(), + mode: z.enum(["transform", "explore"]), + icon: z.string().nullable(), + search_space_id: z.number().nullable(), + created_at: z.string(), +}); + +export type QuickAskActionRead = z.infer; + +export const quickAskActionsListResponse = z.array(quickAskActionRead); + +export const quickAskActionCreateRequest = z.object({ + name: z.string().min(1).max(200), + prompt: z.string().min(1), + mode: z.enum(["transform", "explore"]), + icon: z.string().max(50).nullable().optional(), + search_space_id: z.number().nullable().optional(), +}); + +export type QuickAskActionCreateRequest = z.infer; + +export const quickAskActionUpdateRequest = z.object({ + name: z.string().min(1).max(200).optional(), + prompt: z.string().min(1).optional(), + mode: z.enum(["transform", "explore"]).optional(), + icon: z.string().max(50).nullable().optional(), +}); + +export type QuickAskActionUpdateRequest = z.infer; + +export const quickAskActionDeleteResponse = z.object({ + success: z.boolean(), +}); + export interface QuickAskAction { id: string; name: string; diff --git a/surfsense_web/lib/apis/quick-ask-actions-api.service.ts b/surfsense_web/lib/apis/quick-ask-actions-api.service.ts new file mode 100644 index 000000000..ae1c3a360 --- /dev/null +++ b/surfsense_web/lib/apis/quick-ask-actions-api.service.ts @@ -0,0 +1,59 @@ +import { + type QuickAskActionCreateRequest, + type QuickAskActionUpdateRequest, + quickAskActionCreateRequest, + quickAskActionDeleteResponse, + quickAskActionRead, + quickAskActionUpdateRequest, + quickAskActionsListResponse, +} from "@/contracts/types/quick-ask-actions.types"; +import { ValidationError } from "@/lib/error"; +import { baseApiService } from "./base-api.service"; + +class QuickAskActionsApiService { + list = async (searchSpaceId?: number) => { + const params = new URLSearchParams(); + if (searchSpaceId !== undefined) { + params.set("search_space_id", String(searchSpaceId)); + } + const queryString = params.toString(); + const url = queryString + ? `/api/v1/quick-ask-actions?${queryString}` + : "/api/v1/quick-ask-actions"; + + return baseApiService.get(url, quickAskActionsListResponse); + }; + + create = async (request: QuickAskActionCreateRequest) => { + const parsed = quickAskActionCreateRequest.safeParse(request); + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.post("/api/v1/quick-ask-actions", quickAskActionRead, { + body: parsed.data, + }); + }; + + update = async (actionId: number, request: QuickAskActionUpdateRequest) => { + const parsed = quickAskActionUpdateRequest.safeParse(request); + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.put(`/api/v1/quick-ask-actions/${actionId}`, quickAskActionRead, { + body: parsed.data, + }); + }; + + delete = async (actionId: number) => { + return baseApiService.delete( + `/api/v1/quick-ask-actions/${actionId}`, + quickAskActionDeleteResponse + ); + }; +} + +export const quickAskActionsApiService = new QuickAskActionsApiService(); From a6ccb7a875a20a221819a2848e97963fae655fad Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sun, 29 Mar 2026 00:07:08 +0200 Subject: [PATCH 32/37] rename quick-ask-actions to prompts across backend and frontend --- .../alembic/versions/109_add_prompts_table.py | 50 ++++++ .../109_add_quick_ask_actions_table.py | 62 ------- surfsense_backend/app/db.py | 8 +- surfsense_backend/app/routes/__init__.py | 4 +- .../app/routes/prompts_routes.py | 94 +++++++++++ .../app/routes/quick_ask_actions_routes.py | 94 ----------- .../{quick_ask_actions.py => prompts.py} | 6 +- .../app/dashboard/quick-ask/actions.ts | 68 -------- .../app/dashboard/quick-ask/page.tsx | 152 ------------------ .../components/assistant-ui/thread.tsx | 40 ++--- .../{action-picker.tsx => prompt-picker.tsx} | 20 +-- .../contracts/types/prompts.types.ts | 40 +++++ .../types/quick-ask-actions.types.ts | 49 ------ surfsense_web/lib/apis/prompts-api.service.ts | 54 +++++++ .../lib/apis/quick-ask-actions-api.service.ts | 59 ------- 15 files changed, 277 insertions(+), 523 deletions(-) create mode 100644 surfsense_backend/alembic/versions/109_add_prompts_table.py delete mode 100644 surfsense_backend/alembic/versions/109_add_quick_ask_actions_table.py create mode 100644 surfsense_backend/app/routes/prompts_routes.py delete mode 100644 surfsense_backend/app/routes/quick_ask_actions_routes.py rename surfsense_backend/app/schemas/{quick_ask_actions.py => prompts.py} (86%) delete mode 100644 surfsense_web/app/dashboard/quick-ask/actions.ts delete mode 100644 surfsense_web/app/dashboard/quick-ask/page.tsx rename surfsense_web/components/new-chat/{action-picker.tsx => prompt-picker.tsx} (90%) create mode 100644 surfsense_web/contracts/types/prompts.types.ts delete mode 100644 surfsense_web/contracts/types/quick-ask-actions.types.ts create mode 100644 surfsense_web/lib/apis/prompts-api.service.ts delete mode 100644 surfsense_web/lib/apis/quick-ask-actions-api.service.ts diff --git a/surfsense_backend/alembic/versions/109_add_prompts_table.py b/surfsense_backend/alembic/versions/109_add_prompts_table.py new file mode 100644 index 000000000..e044839b0 --- /dev/null +++ b/surfsense_backend/alembic/versions/109_add_prompts_table.py @@ -0,0 +1,50 @@ +"""add prompts table + +Revision ID: 109 +Revises: 108 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "109" +down_revision: str | None = "108" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + conn = op.get_bind() + + result = conn.execute( + sa.text("SELECT 1 FROM pg_type WHERE typname = 'prompt_mode'") + ) + if not result.fetchone(): + op.execute("CREATE TYPE prompt_mode AS ENUM ('transform', 'explore')") + + result = conn.execute( + sa.text("SELECT 1 FROM information_schema.tables WHERE table_name = 'prompts'") + ) + if not result.fetchone(): + op.execute(""" + CREATE TABLE prompts ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + search_space_id INTEGER REFERENCES searchspaces(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + prompt TEXT NOT NULL, + mode prompt_mode NOT NULL, + icon VARCHAR(50), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() + ) + """) + op.execute("CREATE INDEX ix_prompts_user_id ON prompts (user_id)") + op.execute("CREATE INDEX ix_prompts_search_space_id ON prompts (search_space_id)") + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS prompts") + op.execute("DROP TYPE IF EXISTS prompt_mode") diff --git a/surfsense_backend/alembic/versions/109_add_quick_ask_actions_table.py b/surfsense_backend/alembic/versions/109_add_quick_ask_actions_table.py deleted file mode 100644 index 2b8db7cd4..000000000 --- a/surfsense_backend/alembic/versions/109_add_quick_ask_actions_table.py +++ /dev/null @@ -1,62 +0,0 @@ -"""add quick_ask_actions table - -Revision ID: 109 -Revises: 108 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa - -from alembic import op - -revision: str = "109" -down_revision: str | None = "108" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - op.execute(""" - DO $$ BEGIN - CREATE TYPE quick_ask_action_mode AS ENUM ('transform', 'explore'); - EXCEPTION - WHEN duplicate_object THEN null; - END $$; - """) - - conn = op.get_bind() - result = conn.execute( - sa.text("SELECT 1 FROM information_schema.tables WHERE table_name = 'quick_ask_actions'") - ) - if not result.fetchone(): - op.create_table( - "quick_ask_actions", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column("user_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("search_space_id", sa.Integer(), nullable=True), - sa.Column("name", sa.String(200), nullable=False), - sa.Column("prompt", sa.Text(), nullable=False), - sa.Column( - "mode", - sa.Enum("transform", "explore", name="quick_ask_action_mode", create_type=False), - nullable=False, - ), - sa.Column("icon", sa.String(50), nullable=True), - sa.Column( - "created_at", - sa.TIMESTAMP(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["search_space_id"], ["searchspaces.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_quick_ask_actions_user_id", "quick_ask_actions", ["user_id"]) - op.create_index("ix_quick_ask_actions_search_space_id", "quick_ask_actions", ["search_space_id"]) - - -def downgrade() -> None: - op.drop_table("quick_ask_actions") - op.execute("DROP TYPE IF EXISTS quick_ask_action_mode") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index eaa445223..42282d0d5 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1722,13 +1722,13 @@ class SearchSpaceInvite(BaseModel, TimestampMixin): ) -class QuickAskActionMode(StrEnum): +class PromptMode(StrEnum): TRANSFORM = "transform" EXPLORE = "explore" -class QuickAskAction(BaseModel, TimestampMixin): - __tablename__ = "quick_ask_actions" +class Prompt(BaseModel, TimestampMixin): + __tablename__ = "prompts" user_id = Column( UUID(as_uuid=True), @@ -1744,7 +1744,7 @@ class QuickAskAction(BaseModel, TimestampMixin): ) name = Column(String(200), nullable=False) prompt = Column(Text, nullable=False) - mode = Column(SQLAlchemyEnum(QuickAskActionMode), nullable=False) + mode = Column(SQLAlchemyEnum(PromptMode), nullable=False) icon = Column(String(50), nullable=True) user = relationship("User") diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 171ee5792..1ddc958aa 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -34,7 +34,7 @@ from .notion_add_connector_route import router as notion_add_connector_router from .podcasts_routes import router as podcasts_router from .public_chat_routes import router as public_chat_router -from .quick_ask_actions_routes import router as quick_ask_actions_router +from .prompts_routes import router as prompts_router from .rbac_routes import router as rbac_router from .reports_routes import router as reports_router from .sandbox_routes import router as sandbox_router @@ -86,4 +86,4 @@ router.include_router(public_chat_router) # Public chat sharing and cloning router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages router.include_router(youtube_router) # YouTube playlist resolution -router.include_router(quick_ask_actions_router) +router.include_router(prompts_router) diff --git a/surfsense_backend/app/routes/prompts_routes.py b/surfsense_backend/app/routes/prompts_routes.py new file mode 100644 index 000000000..ebfe67130 --- /dev/null +++ b/surfsense_backend/app/routes/prompts_routes.py @@ -0,0 +1,94 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import Prompt, User, get_async_session +from app.schemas.prompts import ( + PromptCreate, + PromptRead, + PromptUpdate, +) +from app.users import current_active_user + +router = APIRouter(tags=["Prompts"]) + + +@router.get("/prompts", response_model=list[PromptRead]) +async def list_prompts( + search_space_id: int | None = None, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + query = select(Prompt).where(Prompt.user_id == user.id) + if search_space_id is not None: + query = query.where(Prompt.search_space_id == search_space_id) + query = query.order_by(Prompt.created_at.desc()) + result = await session.execute(query) + return result.scalars().all() + + +@router.post("/prompts", response_model=PromptRead) +async def create_prompt( + body: PromptCreate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + prompt = Prompt( + user_id=user.id, + search_space_id=body.search_space_id, + name=body.name, + prompt=body.prompt, + mode=body.mode, + icon=body.icon, + ) + session.add(prompt) + await session.commit() + await session.refresh(prompt) + return prompt + + +@router.put("/prompts/{prompt_id}", response_model=PromptRead) +async def update_prompt( + prompt_id: int, + body: PromptUpdate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + result = await session.execute( + select(Prompt).where( + Prompt.id == prompt_id, + Prompt.user_id == user.id, + ) + ) + prompt = result.scalar_one_or_none() + if not prompt: + raise HTTPException(status_code=404, detail="Prompt not found") + + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(prompt, field, value) + + session.add(prompt) + await session.commit() + await session.refresh(prompt) + return prompt + + +@router.delete("/prompts/{prompt_id}") +async def delete_prompt( + prompt_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + result = await session.execute( + select(Prompt).where( + Prompt.id == prompt_id, + Prompt.user_id == user.id, + ) + ) + prompt = result.scalar_one_or_none() + if not prompt: + raise HTTPException(status_code=404, detail="Prompt not found") + + await session.delete(prompt) + await session.commit() + return {"success": True} diff --git a/surfsense_backend/app/routes/quick_ask_actions_routes.py b/surfsense_backend/app/routes/quick_ask_actions_routes.py deleted file mode 100644 index 6b9868a07..000000000 --- a/surfsense_backend/app/routes/quick_ask_actions_routes.py +++ /dev/null @@ -1,94 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db import QuickAskAction, User, get_async_session -from app.schemas.quick_ask_actions import ( - QuickAskActionCreate, - QuickAskActionRead, - QuickAskActionUpdate, -) -from app.users import current_active_user - -router = APIRouter(tags=["Quick Ask Actions"]) - - -@router.get("/quick-ask-actions", response_model=list[QuickAskActionRead]) -async def list_actions( - search_space_id: int | None = None, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - query = select(QuickAskAction).where(QuickAskAction.user_id == user.id) - if search_space_id is not None: - query = query.where(QuickAskAction.search_space_id == search_space_id) - query = query.order_by(QuickAskAction.created_at.desc()) - result = await session.execute(query) - return result.scalars().all() - - -@router.post("/quick-ask-actions", response_model=QuickAskActionRead) -async def create_action( - body: QuickAskActionCreate, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - action = QuickAskAction( - user_id=user.id, - search_space_id=body.search_space_id, - name=body.name, - prompt=body.prompt, - mode=body.mode, - icon=body.icon, - ) - session.add(action) - await session.commit() - await session.refresh(action) - return action - - -@router.put("/quick-ask-actions/{action_id}", response_model=QuickAskActionRead) -async def update_action( - action_id: int, - body: QuickAskActionUpdate, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - result = await session.execute( - select(QuickAskAction).where( - QuickAskAction.id == action_id, - QuickAskAction.user_id == user.id, - ) - ) - action = result.scalar_one_or_none() - if not action: - raise HTTPException(status_code=404, detail="Action not found") - - for field, value in body.model_dump(exclude_unset=True).items(): - setattr(action, field, value) - - session.add(action) - await session.commit() - await session.refresh(action) - return action - - -@router.delete("/quick-ask-actions/{action_id}") -async def delete_action( - action_id: int, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - result = await session.execute( - select(QuickAskAction).where( - QuickAskAction.id == action_id, - QuickAskAction.user_id == user.id, - ) - ) - action = result.scalar_one_or_none() - if not action: - raise HTTPException(status_code=404, detail="Action not found") - - await session.delete(action) - await session.commit() - return {"success": True} diff --git a/surfsense_backend/app/schemas/quick_ask_actions.py b/surfsense_backend/app/schemas/prompts.py similarity index 86% rename from surfsense_backend/app/schemas/quick_ask_actions.py rename to surfsense_backend/app/schemas/prompts.py index 90fa716b9..c2fd753e6 100644 --- a/surfsense_backend/app/schemas/quick_ask_actions.py +++ b/surfsense_backend/app/schemas/prompts.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, Field -class QuickAskActionCreate(BaseModel): +class PromptCreate(BaseModel): name: str = Field(..., min_length=1, max_length=200) prompt: str = Field(..., min_length=1) mode: str = Field(..., pattern="^(transform|explore)$") @@ -11,14 +11,14 @@ class QuickAskActionCreate(BaseModel): search_space_id: int | None = None -class QuickAskActionUpdate(BaseModel): +class PromptUpdate(BaseModel): name: str | None = Field(None, min_length=1, max_length=200) prompt: str | None = Field(None, min_length=1) mode: str | None = Field(None, pattern="^(transform|explore)$") icon: str | None = Field(None, max_length=50) -class QuickAskActionRead(BaseModel): +class PromptRead(BaseModel): id: int name: str prompt: str diff --git a/surfsense_web/app/dashboard/quick-ask/actions.ts b/surfsense_web/app/dashboard/quick-ask/actions.ts deleted file mode 100644 index 984aef2b6..000000000 --- a/surfsense_web/app/dashboard/quick-ask/actions.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { QuickAskAction } from "@/contracts/types/quick-ask-actions.types"; - -export const DEFAULT_ACTIONS: QuickAskAction[] = [ - { - id: "fix-grammar", - name: "Fix grammar", - prompt: "Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}", - mode: "transform", - icon: "check", - group: "transform", - }, - { - id: "make-shorter", - name: "Make shorter", - prompt: "Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}", - mode: "transform", - icon: "minimize", - group: "transform", - }, - { - id: "translate", - name: "Translate", - prompt: "Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}", - mode: "transform", - icon: "languages", - group: "transform", - }, - { - id: "rewrite", - name: "Rewrite", - prompt: "Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}", - mode: "transform", - icon: "pen-line", - group: "transform", - }, - { - id: "summarize", - name: "Summarize", - prompt: "Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}", - mode: "transform", - icon: "list", - group: "transform", - }, - { - id: "explain", - name: "Explain", - prompt: "Explain the following text in simple terms:\n\n{selection}", - mode: "explore", - icon: "book-open", - group: "explore", - }, - { - id: "ask-knowledge-base", - name: "Ask my knowledge base", - prompt: "Search my knowledge base for information related to:\n\n{selection}", - mode: "explore", - icon: "search", - group: "explore", - }, - { - id: "look-up-web", - name: "Look up on the web", - prompt: "Search the web for information about:\n\n{selection}", - mode: "explore", - icon: "globe", - group: "explore", - }, -]; diff --git a/surfsense_web/app/dashboard/quick-ask/page.tsx b/surfsense_web/app/dashboard/quick-ask/page.tsx deleted file mode 100644 index dca398254..000000000 --- a/surfsense_web/app/dashboard/quick-ask/page.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client"; - -import { - BookOpen, - Check, - Globe, - Languages, - List, - MessageSquare, - Minimize2, - PenLine, - Search, -} from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; -import { DEFAULT_ACTIONS } from "./actions"; - -const ICONS: Record = { - check: , - minimize: , - languages: , - "pen-line": , - "book-open": , - list: , - search: , - globe: , -}; - -export default function QuickAskPage() { - const [clipboardText, setClipboardText] = useState(""); - const [searchQuery, setSearchQuery] = useState(""); - - useEffect(() => { - window.electronAPI?.getQuickAskText().then((text) => { - if (text) setClipboardText(text); - }); - }, []); - - const navigateToChat = async (prompt: string, mode: string) => { - await window.electronAPI?.setQuickAskMode(mode); - sessionStorage.setItem("quickAskAutoSubmit", "true"); - const encoded = encodeURIComponent(prompt); - window.location.href = `/dashboard?quickAskPrompt=${encoded}`; - }; - - const navigateWithInitialText = async () => { - if (!clipboardText) return; - await window.electronAPI?.setQuickAskMode("explore"); - sessionStorage.setItem("quickAskAutoSubmit", "false"); - sessionStorage.setItem("quickAskInitialText", clipboardText); - window.location.href = `/dashboard?quickAskPrompt=${encodeURIComponent(clipboardText)}`; - }; - - const handleAction = (actionId: string) => { - const action = DEFAULT_ACTIONS.find((a) => a.id === actionId); - if (!action || !clipboardText) return; - const prompt = action.prompt.replace("{selection}", clipboardText); - navigateToChat(prompt, action.mode); - }; - - const transformActions = DEFAULT_ACTIONS.filter((a) => a.group === "transform"); - const exploreActions = DEFAULT_ACTIONS.filter((a) => a.group === "explore"); - - const filteredTransform = useMemo( - () => transformActions.filter((a) => a.name.toLowerCase().includes(searchQuery.toLowerCase())), - [searchQuery] - ); - const filteredExplore = useMemo( - () => exploreActions.filter((a) => a.name.toLowerCase().includes(searchQuery.toLowerCase())), - [searchQuery] - ); - - if (!clipboardText) { - return ( -
-
Loading...
-
- ); - } - - return ( -
-
-
- - setSearchQuery(e.target.value)} - className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" - /> -
-
- -
- {filteredTransform.length > 0 && ( - <> -
Transform
-
- {filteredTransform.map((action) => ( - - ))} -
- - )} - - {filteredExplore.length > 0 && ( - <> -
Explore
-
- {filteredExplore.map((action) => ( - - ))} -
- - )} - -
My Actions
-
- Custom actions coming soon -
-
- -
- -
-
- ); -} diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index d6edc640d..6ff05a252 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -57,7 +57,7 @@ import { import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { UserMessage } from "@/components/assistant-ui/user-message"; import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/components/layout/ui/sidebar/SidebarSlideOutPanel"; -import { ActionPicker, type ActionPickerRef } from "@/components/new-chat/action-picker"; +import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker"; import { DocumentMentionPicker, type DocumentMentionPickerRef, @@ -299,13 +299,13 @@ const Composer: FC = () => { const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const setSidebarDocs = useSetAtom(sidebarSelectedDocumentsAtom); const [showDocumentPopover, setShowDocumentPopover] = useState(false); - const [showActionPicker, setShowActionPicker] = useState(false); + const [showPromptPicker, setShowPromptPicker] = useState(false); const [mentionQuery, setMentionQuery] = useState(""); const [actionQuery, setActionQuery] = useState(""); const editorRef = useRef(null); const editorContainerRef = useRef(null); const documentPickerRef = useRef(null); - const actionPickerRef = useRef(null); + const promptPickerRef = useRef(null); const { search_space_id, chat_id } = useParams(); const aui = useAui(); const hasAutoFocusedRef = useRef(false); @@ -427,24 +427,24 @@ const Composer: FC = () => { // Open action picker when / is triggered const handleActionTrigger = useCallback((query: string) => { - setShowActionPicker(true); + setShowPromptPicker(true); setActionQuery(query); }, []); // Close action picker and reset query const handleActionClose = useCallback(() => { - if (showActionPicker) { - setShowActionPicker(false); + if (showPromptPicker) { + setShowPromptPicker(false); setActionQuery(""); } - }, [showActionPicker]); + }, [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" }) => { - setShowActionPicker(false); + setShowPromptPicker(false); setActionQuery(""); pendingActionRef.current = action; editorRef.current?.insertActionChip(action.name); @@ -459,25 +459,25 @@ const Composer: FC = () => { // Keyboard navigation for document/action picker (arrow keys, Enter, Escape) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (showActionPicker) { + if (showPromptPicker) { if (e.key === "ArrowDown") { e.preventDefault(); - actionPickerRef.current?.moveDown(); + promptPickerRef.current?.moveDown(); return; } if (e.key === "ArrowUp") { e.preventDefault(); - actionPickerRef.current?.moveUp(); + promptPickerRef.current?.moveUp(); return; } if (e.key === "Enter") { e.preventDefault(); - actionPickerRef.current?.selectHighlighted(); + promptPickerRef.current?.selectHighlighted(); return; } if (e.key === "Escape") { e.preventDefault(); - setShowActionPicker(false); + setShowPromptPicker(false); setActionQuery(""); return; } @@ -506,7 +506,7 @@ const Composer: FC = () => { } } }, - [showDocumentPopover, showActionPicker] + [showDocumentPopover, showPromptPicker] ); // Submit message (blocked during streaming, document picker open, or AI responding to another user) @@ -514,7 +514,7 @@ const Composer: FC = () => { if (isThreadRunning || isBlockedByOtherUser) { return; } - if (!showDocumentPopover && !showActionPicker) { + if (!showDocumentPopover && !showPromptPicker) { if (pendingActionRef.current) { const userText = editorRef.current?.getText() ?? ""; const finalPrompt = pendingActionRef.current.prompt.replace("{selection}", userText); @@ -528,7 +528,7 @@ const Composer: FC = () => { } }, [ showDocumentPopover, - showActionPicker, + showPromptPicker, isThreadRunning, isBlockedByOtherUser, aui, @@ -621,14 +621,14 @@ const Composer: FC = () => { />, document.body )} - {showActionPicker && + {showPromptPicker && typeof document !== "undefined" && createPortal( - { - setShowActionPicker(false); + setShowPromptPicker(false); setActionQuery(""); }} externalSearch={actionQuery} diff --git a/surfsense_web/components/new-chat/action-picker.tsx b/surfsense_web/components/new-chat/prompt-picker.tsx similarity index 90% rename from surfsense_web/components/new-chat/action-picker.tsx rename to surfsense_web/components/new-chat/prompt-picker.tsx index 4bfac23f4..28176524d 100644 --- a/surfsense_web/components/new-chat/action-picker.tsx +++ b/surfsense_web/components/new-chat/prompt-picker.tsx @@ -21,17 +21,17 @@ import { useState, } from "react"; -import type { QuickAskActionRead } from "@/contracts/types/quick-ask-actions.types"; -import { quickAskActionsApiService } from "@/lib/apis/quick-ask-actions-api.service"; +import type { PromptRead } from "@/contracts/types/prompts.types"; +import { promptsApiService } from "@/lib/apis/prompts-api.service"; import { cn } from "@/lib/utils"; -export interface ActionPickerRef { +export interface PromptPickerRef { selectHighlighted: () => void; moveUp: () => void; moveDown: () => void; } -interface ActionPickerProps { +interface PromptPickerProps { onSelect: (action: { name: string; prompt: string; mode: "transform" | "explore" }) => void; onDone: () => void; externalSearch?: string; @@ -61,27 +61,27 @@ const DEFAULT_ACTIONS: { name: string; prompt: string; mode: "transform" | "expl { name: "Look up on the web", prompt: "Search the web for information about:\n\n{selection}", mode: "explore", icon: "globe" }, ]; -export const ActionPicker = forwardRef( - function ActionPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) { +export const PromptPicker = forwardRef( + function PromptPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) { const [highlightedIndex, setHighlightedIndex] = useState(0); - const [customActions, setCustomActions] = useState([]); + const [customPrompts, setCustomPrompts] = useState([]); const scrollContainerRef = useRef(null); const shouldScrollRef = useRef(false); const itemRefs = useRef>(new Map()); useEffect(() => { - quickAskActionsApiService.list().then(setCustomActions).catch(() => {}); + promptsApiService.list().then(setCustomPrompts).catch(() => {}); }, []); const allActions = useMemo(() => { - const customs = customActions.map((a) => ({ + const customs = customPrompts.map((a) => ({ name: a.name, prompt: a.prompt, mode: a.mode as "transform" | "explore", icon: a.icon || "zap", })); return [...DEFAULT_ACTIONS, ...customs]; - }, [customActions]); + }, [customPrompts]); const filtered = useMemo(() => { if (!externalSearch) return allActions; diff --git a/surfsense_web/contracts/types/prompts.types.ts b/surfsense_web/contracts/types/prompts.types.ts new file mode 100644 index 000000000..a5c895bc9 --- /dev/null +++ b/surfsense_web/contracts/types/prompts.types.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +export type PromptMode = "transform" | "explore"; + +export const promptRead = z.object({ + id: z.number(), + name: z.string(), + prompt: z.string(), + mode: z.enum(["transform", "explore"]), + icon: z.string().nullable(), + search_space_id: z.number().nullable(), + created_at: z.string(), +}); + +export type PromptRead = z.infer; + +export const promptsListResponse = z.array(promptRead); + +export const promptCreateRequest = z.object({ + name: z.string().min(1).max(200), + prompt: z.string().min(1), + mode: z.enum(["transform", "explore"]), + icon: z.string().max(50).nullable().optional(), + search_space_id: z.number().nullable().optional(), +}); + +export type PromptCreateRequest = z.infer; + +export const promptUpdateRequest = z.object({ + name: z.string().min(1).max(200).optional(), + prompt: z.string().min(1).optional(), + mode: z.enum(["transform", "explore"]).optional(), + icon: z.string().max(50).nullable().optional(), +}); + +export type PromptUpdateRequest = z.infer; + +export const promptDeleteResponse = z.object({ + success: z.boolean(), +}); diff --git a/surfsense_web/contracts/types/quick-ask-actions.types.ts b/surfsense_web/contracts/types/quick-ask-actions.types.ts deleted file mode 100644 index eaee09501..000000000 --- a/surfsense_web/contracts/types/quick-ask-actions.types.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { z } from "zod"; - -export type QuickAskActionMode = "transform" | "explore"; - -export const quickAskActionRead = z.object({ - id: z.number(), - name: z.string(), - prompt: z.string(), - mode: z.enum(["transform", "explore"]), - icon: z.string().nullable(), - search_space_id: z.number().nullable(), - created_at: z.string(), -}); - -export type QuickAskActionRead = z.infer; - -export const quickAskActionsListResponse = z.array(quickAskActionRead); - -export const quickAskActionCreateRequest = z.object({ - name: z.string().min(1).max(200), - prompt: z.string().min(1), - mode: z.enum(["transform", "explore"]), - icon: z.string().max(50).nullable().optional(), - search_space_id: z.number().nullable().optional(), -}); - -export type QuickAskActionCreateRequest = z.infer; - -export const quickAskActionUpdateRequest = z.object({ - name: z.string().min(1).max(200).optional(), - prompt: z.string().min(1).optional(), - mode: z.enum(["transform", "explore"]).optional(), - icon: z.string().max(50).nullable().optional(), -}); - -export type QuickAskActionUpdateRequest = z.infer; - -export const quickAskActionDeleteResponse = z.object({ - success: z.boolean(), -}); - -export interface QuickAskAction { - id: string; - name: string; - prompt: string; - mode: QuickAskActionMode; - icon: string; - group: "transform" | "explore" | "knowledge" | "custom"; -} diff --git a/surfsense_web/lib/apis/prompts-api.service.ts b/surfsense_web/lib/apis/prompts-api.service.ts new file mode 100644 index 000000000..5c445c02a --- /dev/null +++ b/surfsense_web/lib/apis/prompts-api.service.ts @@ -0,0 +1,54 @@ +import { + type PromptCreateRequest, + type PromptUpdateRequest, + promptCreateRequest, + promptDeleteResponse, + promptRead, + promptUpdateRequest, + promptsListResponse, +} from "@/contracts/types/prompts.types"; +import { ValidationError } from "@/lib/error"; +import { baseApiService } from "./base-api.service"; + +class PromptsApiService { + list = async (searchSpaceId?: number) => { + const params = new URLSearchParams(); + if (searchSpaceId !== undefined) { + params.set("search_space_id", String(searchSpaceId)); + } + const queryString = params.toString(); + const url = queryString ? `/api/v1/prompts?${queryString}` : "/api/v1/prompts"; + + return baseApiService.get(url, promptsListResponse); + }; + + create = async (request: PromptCreateRequest) => { + const parsed = promptCreateRequest.safeParse(request); + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.post("/api/v1/prompts", promptRead, { + body: parsed.data, + }); + }; + + update = async (promptId: number, request: PromptUpdateRequest) => { + const parsed = promptUpdateRequest.safeParse(request); + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.put(`/api/v1/prompts/${promptId}`, promptRead, { + body: parsed.data, + }); + }; + + delete = async (promptId: number) => { + return baseApiService.delete(`/api/v1/prompts/${promptId}`, promptDeleteResponse); + }; +} + +export const promptsApiService = new PromptsApiService(); diff --git a/surfsense_web/lib/apis/quick-ask-actions-api.service.ts b/surfsense_web/lib/apis/quick-ask-actions-api.service.ts deleted file mode 100644 index ae1c3a360..000000000 --- a/surfsense_web/lib/apis/quick-ask-actions-api.service.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - type QuickAskActionCreateRequest, - type QuickAskActionUpdateRequest, - quickAskActionCreateRequest, - quickAskActionDeleteResponse, - quickAskActionRead, - quickAskActionUpdateRequest, - quickAskActionsListResponse, -} from "@/contracts/types/quick-ask-actions.types"; -import { ValidationError } from "@/lib/error"; -import { baseApiService } from "./base-api.service"; - -class QuickAskActionsApiService { - list = async (searchSpaceId?: number) => { - const params = new URLSearchParams(); - if (searchSpaceId !== undefined) { - params.set("search_space_id", String(searchSpaceId)); - } - const queryString = params.toString(); - const url = queryString - ? `/api/v1/quick-ask-actions?${queryString}` - : "/api/v1/quick-ask-actions"; - - return baseApiService.get(url, quickAskActionsListResponse); - }; - - create = async (request: QuickAskActionCreateRequest) => { - const parsed = quickAskActionCreateRequest.safeParse(request); - if (!parsed.success) { - const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); - throw new ValidationError(`Invalid request: ${errorMessage}`); - } - - return baseApiService.post("/api/v1/quick-ask-actions", quickAskActionRead, { - body: parsed.data, - }); - }; - - update = async (actionId: number, request: QuickAskActionUpdateRequest) => { - const parsed = quickAskActionUpdateRequest.safeParse(request); - if (!parsed.success) { - const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); - throw new ValidationError(`Invalid request: ${errorMessage}`); - } - - return baseApiService.put(`/api/v1/quick-ask-actions/${actionId}`, quickAskActionRead, { - body: parsed.data, - }); - }; - - delete = async (actionId: number) => { - return baseApiService.delete( - `/api/v1/quick-ask-actions/${actionId}`, - quickAskActionDeleteResponse - ); - }; -} - -export const quickAskActionsApiService = new QuickAskActionsApiService(); From 03ca4f1f32dd441caef2a6872fa5de164fd0ab31 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Sun, 29 Mar 2026 00:33:02 +0200 Subject: [PATCH 33/37] add My Prompts settings tab and create prompt button in picker --- .../components/PromptsContent.tsx | 225 ++++++++++++++++++ .../components/new-chat/prompt-picker.tsx | 49 +++- .../settings/user-settings-dialog.tsx | 9 +- 3 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx 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 new file mode 100644 index 000000000..4b7f1d9a7 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { PenLine, Plus, Sparkles, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import type { PromptRead } from "@/contracts/types/prompts.types"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { promptsApiService } from "@/lib/apis/prompts-api.service"; + +interface PromptFormData { + name: string; + prompt: string; + mode: "transform" | "explore"; +} + +const EMPTY_FORM: PromptFormData = { name: "", prompt: "", mode: "transform" }; + +export function PromptsContent() { + const [prompts, setPrompts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [formData, setFormData] = useState(EMPTY_FORM); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + promptsApiService + .list() + .then(setPrompts) + .catch(() => toast.error("Failed to load prompts")) + .finally(() => setIsLoading(false)); + }, []); + + const handleSave = useCallback(async () => { + if (!formData.name.trim() || !formData.prompt.trim()) { + toast.error("Name and prompt are required"); + return; + } + + setIsSaving(true); + try { + if (editingId) { + const updated = await promptsApiService.update(editingId, formData); + setPrompts((prev) => prev.map((p) => (p.id === editingId ? updated : p))); + toast.success("Prompt updated"); + } else { + const created = await promptsApiService.create(formData); + setPrompts((prev) => [created, ...prev]); + toast.success("Prompt created"); + } + setShowForm(false); + setFormData(EMPTY_FORM); + setEditingId(null); + } catch { + toast.error("Failed to save prompt"); + } finally { + setIsSaving(false); + } + }, [formData, editingId]); + + const handleEdit = useCallback((prompt: PromptRead) => { + setFormData({ + name: prompt.name, + prompt: prompt.prompt, + mode: prompt.mode as "transform" | "explore", + }); + setEditingId(prompt.id); + setShowForm(true); + }, []); + + const handleDelete = useCallback(async (id: number) => { + try { + await promptsApiService.delete(id); + setPrompts((prev) => prev.filter((p) => p.id !== id)); + toast.success("Prompt deleted"); + } catch { + toast.error("Failed to delete prompt"); + } + }, []); + + const handleCancel = useCallback(() => { + setShowForm(false); + setFormData(EMPTY_FORM); + setEditingId(null); + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ Create prompt templates triggered with / in the chat composer. +

+ {!showForm && ( + + )} +
+ + {showForm && ( +
+

+ {editingId ? "Edit prompt" : "New prompt"} +

+ +
+ + setFormData((p) => ({ ...p, name: e.target.value }))} + placeholder="e.g. Fix grammar" + /> +
+ +
+ +