From dff3440f72a7cbfe2c788ca96bc3067c99b6c5ef Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 19:44:48 +0200 Subject: [PATCH 01/65] refactor(desktop): extract error handling into modules/errors.ts --- surfsense_desktop/src/main.ts | 33 +++---------------------- surfsense_desktop/src/modules/errors.ts | 33 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 30 deletions(-) create mode 100644 surfsense_desktop/src/modules/errors.ts diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index e0a6c3be5..a7a12c485 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,37 +1,10 @@ -import { app, BrowserWindow, shell, ipcMain, session, dialog, clipboard, Menu } from 'electron'; +import { app, BrowserWindow, shell, ipcMain, session, dialog, Menu } from 'electron'; import path from 'path'; import { getPort } from 'get-port-please'; import { autoUpdater } from 'electron-updater'; +import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; -function showErrorDialog(title: string, error: unknown): void { - const err = error instanceof Error ? error : new Error(String(error)); - console.error(`${title}:`, err); - - if (app.isReady()) { - const detail = err.stack || err.message; - const buttonIndex = dialog.showMessageBoxSync({ - type: 'error', - buttons: ['OK', process.platform === 'darwin' ? 'Copy Error' : 'Copy error'], - defaultId: 0, - noLink: true, - message: title, - detail, - }); - if (buttonIndex === 1) { - clipboard.writeText(`${title}\n${detail}`); - } - } else { - dialog.showErrorBox(title, err.stack || err.message); - } -} - -process.on('uncaughtException', (error) => { - showErrorDialog('Unhandled Error', error); -}); - -process.on('unhandledRejection', (reason) => { - showErrorDialog('Unhandled Promise Rejection', reason); -}); +registerGlobalErrorHandlers(); const isDev = !app.isPackaged; let mainWindow: BrowserWindow | null = null; diff --git a/surfsense_desktop/src/modules/errors.ts b/surfsense_desktop/src/modules/errors.ts new file mode 100644 index 000000000..ab9f7088c --- /dev/null +++ b/surfsense_desktop/src/modules/errors.ts @@ -0,0 +1,33 @@ +import { app, clipboard, dialog } from 'electron'; + +export function showErrorDialog(title: string, error: unknown): void { + const err = error instanceof Error ? error : new Error(String(error)); + console.error(`${title}:`, err); + + if (app.isReady()) { + const detail = err.stack || err.message; + const buttonIndex = dialog.showMessageBoxSync({ + type: 'error', + buttons: ['OK', process.platform === 'darwin' ? 'Copy Error' : 'Copy error'], + defaultId: 0, + noLink: true, + message: title, + detail, + }); + if (buttonIndex === 1) { + clipboard.writeText(`${title}\n${detail}`); + } + } else { + dialog.showErrorBox(title, err.stack || err.message); + } +} + +export function registerGlobalErrorHandlers(): void { + process.on('uncaughtException', (error) => { + showErrorDialog('Unhandled Error', error); + }); + + process.on('unhandledRejection', (reason) => { + showErrorDialog('Unhandled Promise Rejection', reason); + }); +} From f08199ececd8df86c8695bc06dd6c6bed15b8302 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 19:48:35 +0200 Subject: [PATCH 02/65] refactor(desktop): extract server startup into modules/server.ts --- surfsense_desktop/src/main.ts | 57 ++----------------------- surfsense_desktop/src/modules/server.ts | 53 +++++++++++++++++++++++ 2 files changed, 57 insertions(+), 53 deletions(-) create mode 100644 surfsense_desktop/src/modules/server.ts diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index a7a12c485..db0f8f937 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,67 +1,18 @@ import { app, BrowserWindow, shell, ipcMain, session, dialog, Menu } from 'electron'; import path from 'path'; -import { getPort } from 'get-port-please'; import { autoUpdater } from 'electron-updater'; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; +import { startNextServer, getServerPort } from './modules/server'; registerGlobalErrorHandlers(); const isDev = !app.isPackaged; let mainWindow: BrowserWindow | null = null; let deepLinkUrl: string | null = null; -let serverPort: number = 3000; // overwritten at startup with a free port const PROTOCOL = 'surfsense'; -// Injected at compile time from .env via esbuild define const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string; -function getStandalonePath(): string { - if (isDev) { - return path.join(__dirname, '..', '..', 'surfsense_web', '.next', 'standalone', 'surfsense_web'); - } - return path.join(process.resourcesPath, 'standalone'); -} - -async function waitForServer(url: string, maxRetries = 60): Promise { - for (let i = 0; i < maxRetries; i++) { - try { - const res = await fetch(url); - if (res.ok || res.status === 404 || res.status === 500) return true; - } catch { - // not ready yet - } - await new Promise((r) => setTimeout(r, 500)); - } - return false; -} - -async function startNextServer(): Promise { - if (isDev) return; - - serverPort = await getPort({ port: 3000, portRange: [30_011, 50_000] }); - console.log(`Selected port ${serverPort}`); - - const standalonePath = getStandalonePath(); - const serverScript = path.join(standalonePath, 'server.js'); - - // The standalone server.js reads PORT / HOSTNAME from process.env and - // uses process.chdir(__dirname). Running it via require() in the same - // process is the proven approach (avoids spawning a second Electron - // instance whose ASAR-patched fs breaks Next.js static file serving). - process.env.PORT = String(serverPort); - process.env.HOSTNAME = 'localhost'; - process.env.NODE_ENV = 'production'; - process.chdir(standalonePath); - - require(serverScript); - - const ready = await waitForServer(`http://localhost:${serverPort}`); - if (!ready) { - throw new Error('Next.js server failed to start within 30 s'); - } - console.log(`Next.js server ready on port ${serverPort}`); -} - function createWindow() { mainWindow = new BrowserWindow({ width: 1280, @@ -83,7 +34,7 @@ function createWindow() { mainWindow?.show(); }); - mainWindow.loadURL(`http://localhost:${serverPort}/login`); + mainWindow.loadURL(`http://localhost:${getServerPort()}/login`); // External links open in system browser, not in the Electron window mainWindow.webContents.setWindowOpenHandler(({ url }) => { @@ -98,7 +49,7 @@ function createWindow() { // and rewrite them to localhost so the user stays in the desktop app. const filter = { urls: [`${HOSTED_FRONTEND_URL}/*`] }; session.defaultSession.webRequest.onBeforeRequest(filter, (details, callback) => { - const rewritten = details.url.replace(HOSTED_FRONTEND_URL, `http://localhost:${serverPort}`); + const rewritten = details.url.replace(HOSTED_FRONTEND_URL, `http://localhost:${getServerPort()}`); callback({ redirectURL: rewritten }); }); @@ -145,7 +96,7 @@ function handleDeepLink(url: string) { const parsed = new URL(url); if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { const params = parsed.searchParams.toString(); - mainWindow.loadURL(`http://localhost:${serverPort}/auth/callback?${params}`); + mainWindow.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); } mainWindow.show(); diff --git a/surfsense_desktop/src/modules/server.ts b/surfsense_desktop/src/modules/server.ts new file mode 100644 index 000000000..969478e4a --- /dev/null +++ b/surfsense_desktop/src/modules/server.ts @@ -0,0 +1,53 @@ +import path from 'path'; +import { app } from 'electron'; +import { getPort } from 'get-port-please'; + +const isDev = !app.isPackaged; +let serverPort = 3000; + +export function getServerPort(): number { + return serverPort; +} + +function getStandalonePath(): string { + if (isDev) { + return path.join(__dirname, '..', '..', 'surfsense_web', '.next', 'standalone', 'surfsense_web'); + } + return path.join(process.resourcesPath, 'standalone'); +} + +async function waitForServer(url: string, maxRetries = 60): Promise { + for (let i = 0; i < maxRetries; i++) { + try { + const res = await fetch(url); + if (res.ok || res.status === 404 || res.status === 500) return true; + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 500)); + } + return false; +} + +export async function startNextServer(): Promise { + if (isDev) return; + + serverPort = await getPort({ port: 3000, portRange: [30_011, 50_000] }); + console.log(`Selected port ${serverPort}`); + + const standalonePath = getStandalonePath(); + const serverScript = path.join(standalonePath, 'server.js'); + + process.env.PORT = String(serverPort); + process.env.HOSTNAME = 'localhost'; + process.env.NODE_ENV = 'production'; + process.chdir(standalonePath); + + require(serverScript); + + const ready = await waitForServer(`http://localhost:${serverPort}`); + if (!ready) { + throw new Error('Next.js server failed to start within 30 s'); + } + console.log(`Next.js server ready on port ${serverPort}`); +} From 95c4a674be26a76fce84b95edbef07be9cc61ae5 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 19:50:50 +0200 Subject: [PATCH 03/65] refactor(desktop): extract window creation into modules/window.ts --- surfsense_desktop/src/main.ts | 81 ++++--------------------- surfsense_desktop/src/modules/window.ts | 67 ++++++++++++++++++++ 2 files changed, 80 insertions(+), 68 deletions(-) create mode 100644 surfsense_desktop/src/modules/window.ts diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index db0f8f937..efbee44bb 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,72 +1,16 @@ -import { app, BrowserWindow, shell, ipcMain, session, dialog, Menu } from 'electron'; +import { app, BrowserWindow, shell, ipcMain, dialog, Menu } from 'electron'; import path from 'path'; import { autoUpdater } from 'electron-updater'; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; import { startNextServer, getServerPort } from './modules/server'; +import { createMainWindow, getMainWindow } from './modules/window'; registerGlobalErrorHandlers(); const isDev = !app.isPackaged; -let mainWindow: BrowserWindow | null = null; let deepLinkUrl: string | null = null; const PROTOCOL = 'surfsense'; -const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string; - -function createWindow() { - mainWindow = new BrowserWindow({ - width: 1280, - height: 800, - minWidth: 800, - minHeight: 600, - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - webviewTag: false, - }, - show: false, - titleBarStyle: 'hiddenInset', - }); - - mainWindow.once('ready-to-show', () => { - mainWindow?.show(); - }); - - mainWindow.loadURL(`http://localhost:${getServerPort()}/login`); - - // External links open in system browser, not in the Electron window - mainWindow.webContents.setWindowOpenHandler(({ url }) => { - if (url.startsWith('http://localhost')) { - return { action: 'allow' }; - } - shell.openExternal(url); - return { action: 'deny' }; - }); - - // Intercept backend OAuth redirects targeting the hosted web frontend - // and rewrite them to localhost so the user stays in the desktop app. - const filter = { urls: [`${HOSTED_FRONTEND_URL}/*`] }; - session.defaultSession.webRequest.onBeforeRequest(filter, (details, callback) => { - const rewritten = details.url.replace(HOSTED_FRONTEND_URL, `http://localhost:${getServerPort()}`); - callback({ redirectURL: rewritten }); - }); - - mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { - console.error(`Failed to load ${validatedURL}: ${errorDescription} (${errorCode})`); - if (errorCode === -3) return; // ERR_ABORTED — normal during redirects - showErrorDialog('Page failed to load', new Error(`${errorDescription} (${errorCode})\n${validatedURL}`)); - }); - - if (isDev) { - mainWindow.webContents.openDevTools(); - } - - mainWindow.on('closed', () => { - mainWindow = null; - }); -} // IPC handlers ipcMain.on('open-external', (_event, url: string) => { @@ -90,17 +34,17 @@ function handleDeepLink(url: string) { deepLinkUrl = url; - if (!mainWindow) return; + const win = getMainWindow(); + if (!win) return; - // Rewrite surfsense:// deep link to localhost so TokenHandler.tsx processes it const parsed = new URL(url); if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { const params = parsed.searchParams.toString(); - mainWindow.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); + win.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); } - mainWindow.show(); - mainWindow.focus(); + win.show(); + win.focus(); } // Single instance lock — second instance passes deep link to first @@ -113,9 +57,10 @@ if (!gotTheLock) { const url = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`)); if (url) handleDeepLink(url); - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.focus(); + const win = getMainWindow(); + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); } }); } @@ -188,7 +133,7 @@ app.whenReady().then(async () => { setTimeout(() => app.quit(), 0); return; } - createWindow(); + createMainWindow(); setupAutoUpdater(); // If a deep link was received before the window was ready, handle it now @@ -199,7 +144,7 @@ app.whenReady().then(async () => { app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { - createWindow(); + createMainWindow(); } }); }); diff --git a/surfsense_desktop/src/modules/window.ts b/surfsense_desktop/src/modules/window.ts new file mode 100644 index 000000000..1b3f3baed --- /dev/null +++ b/surfsense_desktop/src/modules/window.ts @@ -0,0 +1,67 @@ +import { app, BrowserWindow, shell, session } from 'electron'; +import path from 'path'; +import { showErrorDialog } from './errors'; +import { getServerPort } from './server'; + +const isDev = !app.isPackaged; +const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string; + +let mainWindow: BrowserWindow | null = null; + +export function getMainWindow(): BrowserWindow | null { + return mainWindow; +} + +export function createMainWindow(): BrowserWindow { + mainWindow = new BrowserWindow({ + width: 1280, + height: 800, + minWidth: 800, + minHeight: 600, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + webviewTag: false, + }, + show: false, + titleBarStyle: 'hiddenInset', + }); + + mainWindow.once('ready-to-show', () => { + mainWindow?.show(); + }); + + mainWindow.loadURL(`http://localhost:${getServerPort()}/login`); + + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('http://localhost')) { + return { action: 'allow' }; + } + shell.openExternal(url); + return { action: 'deny' }; + }); + + const filter = { urls: [`${HOSTED_FRONTEND_URL}/*`] }; + session.defaultSession.webRequest.onBeforeRequest(filter, (details, callback) => { + const rewritten = details.url.replace(HOSTED_FRONTEND_URL, `http://localhost:${getServerPort()}`); + callback({ redirectURL: rewritten }); + }); + + mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { + console.error(`Failed to load ${validatedURL}: ${errorDescription} (${errorCode})`); + if (errorCode === -3) return; + showErrorDialog('Page failed to load', new Error(`${errorDescription} (${errorCode})\n${validatedURL}`)); + }); + + if (isDev) { + mainWindow.webContents.openDevTools(); + } + + mainWindow.on('closed', () => { + mainWindow = null; + }); + + return mainWindow; +} From 35da1cf1b4e8f5476388fbce110fcc0689299b61 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 19:55:44 +0200 Subject: [PATCH 04/65] refactor(desktop): extract deep link handling into modules/deep-links.ts --- surfsense_desktop/src/main.ts | 71 +++------------------ surfsense_desktop/src/modules/deep-links.ts | 66 +++++++++++++++++++ 2 files changed, 74 insertions(+), 63 deletions(-) create mode 100644 surfsense_desktop/src/modules/deep-links.ts diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index efbee44bb..b61e82008 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,16 +1,17 @@ import { app, BrowserWindow, shell, ipcMain, dialog, Menu } from 'electron'; -import path from 'path'; import { autoUpdater } from 'electron-updater'; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; -import { startNextServer, getServerPort } from './modules/server'; -import { createMainWindow, getMainWindow } from './modules/window'; +import { startNextServer } from './modules/server'; +import { createMainWindow } from './modules/window'; +import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; registerGlobalErrorHandlers(); -const isDev = !app.isPackaged; -let deepLinkUrl: string | null = null; +if (!setupDeepLinks()) { + app.quit(); +} -const PROTOCOL = 'surfsense'; +const isDev = !app.isPackaged; // IPC handlers ipcMain.on('open-external', (_event, url: string) => { @@ -28,58 +29,6 @@ ipcMain.handle('get-app-version', () => { return app.getVersion(); }); -// Deep link handling -function handleDeepLink(url: string) { - if (!url.startsWith(`${PROTOCOL}://`)) return; - - deepLinkUrl = url; - - const win = getMainWindow(); - if (!win) return; - - const parsed = new URL(url); - if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { - const params = parsed.searchParams.toString(); - win.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); - } - - win.show(); - win.focus(); -} - -// Single instance lock — second instance passes deep link to first -const gotTheLock = app.requestSingleInstanceLock(); -if (!gotTheLock) { - app.quit(); -} else { - app.on('second-instance', (_event, argv) => { - // Windows/Linux: deep link URL is in argv - const url = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`)); - if (url) handleDeepLink(url); - - const win = getMainWindow(); - if (win) { - if (win.isMinimized()) win.restore(); - win.focus(); - } - }); -} - -// macOS: deep link arrives via open-url event -app.on('open-url', (event, url) => { - event.preventDefault(); - handleDeepLink(url); -}); - -// Register surfsense:// protocol -if (process.defaultApp) { - if (process.argv.length >= 2) { - app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [path.resolve(process.argv[1])]); - } -} else { - app.setAsDefaultProtocolClient(PROTOCOL); -} - function setupAutoUpdater() { if (isDev) return; @@ -136,11 +85,7 @@ app.whenReady().then(async () => { createMainWindow(); setupAutoUpdater(); - // If a deep link was received before the window was ready, handle it now - if (deepLinkUrl) { - handleDeepLink(deepLinkUrl); - deepLinkUrl = null; - } + handlePendingDeepLink(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { diff --git a/surfsense_desktop/src/modules/deep-links.ts b/surfsense_desktop/src/modules/deep-links.ts new file mode 100644 index 000000000..1a2b08395 --- /dev/null +++ b/surfsense_desktop/src/modules/deep-links.ts @@ -0,0 +1,66 @@ +import { app } from 'electron'; +import path from 'path'; +import { getMainWindow } from './window'; +import { getServerPort } from './server'; + +const PROTOCOL = 'surfsense'; + +let deepLinkUrl: string | null = null; + +function handleDeepLink(url: string) { + if (!url.startsWith(`${PROTOCOL}://`)) return; + + deepLinkUrl = url; + + const win = getMainWindow(); + if (!win) return; + + const parsed = new URL(url); + if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { + const params = parsed.searchParams.toString(); + win.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); + } + + win.show(); + win.focus(); +} + +export function setupDeepLinks(): boolean { + const gotTheLock = app.requestSingleInstanceLock(); + if (!gotTheLock) { + return false; + } + + app.on('second-instance', (_event, argv) => { + const url = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`)); + if (url) handleDeepLink(url); + + const win = getMainWindow(); + if (win) { + if (win.isMinimized()) win.restore(); + win.focus(); + } + }); + + app.on('open-url', (event, url) => { + event.preventDefault(); + handleDeepLink(url); + }); + + if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [path.resolve(process.argv[1])]); + } + } else { + app.setAsDefaultProtocolClient(PROTOCOL); + } + + return true; +} + +export function handlePendingDeepLink(): void { + if (deepLinkUrl) { + handleDeepLink(deepLinkUrl); + deepLinkUrl = null; + } +} From d868464de71ba9ee34e14297b472fd6e2e3c7e67 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 19:59:20 +0200 Subject: [PATCH 05/65] refactor(desktop): extract auto-updater into modules/auto-updater.ts --- surfsense_desktop/src/main.ts | 37 +------------------ surfsense_desktop/src/modules/auto-updater.ts | 33 +++++++++++++++++ 2 files changed, 35 insertions(+), 35 deletions(-) create mode 100644 surfsense_desktop/src/modules/auto-updater.ts diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index b61e82008..11b2f7ab6 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,9 +1,9 @@ -import { app, BrowserWindow, shell, ipcMain, dialog, Menu } from 'electron'; -import { autoUpdater } from 'electron-updater'; +import { app, BrowserWindow, shell, ipcMain, Menu } from 'electron'; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; import { startNextServer } from './modules/server'; import { createMainWindow } from './modules/window'; import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; +import { setupAutoUpdater } from './modules/auto-updater'; registerGlobalErrorHandlers(); @@ -11,8 +11,6 @@ if (!setupDeepLinks()) { app.quit(); } -const isDev = !app.isPackaged; - // IPC handlers ipcMain.on('open-external', (_event, url: string) => { try { @@ -29,37 +27,6 @@ ipcMain.handle('get-app-version', () => { return app.getVersion(); }); -function setupAutoUpdater() { - if (isDev) return; - - autoUpdater.autoDownload = true; - - autoUpdater.on('update-available', (info) => { - console.log(`Update available: ${info.version}`); - }); - - autoUpdater.on('update-downloaded', (info) => { - console.log(`Update downloaded: ${info.version}`); - dialog.showMessageBox({ - type: 'info', - buttons: ['Restart', 'Later'], - defaultId: 0, - title: 'Update Ready', - message: `Version ${info.version} has been downloaded. Restart to apply the update.`, - }).then(({ response }) => { - if (response === 0) { - autoUpdater.quitAndInstall(); - } - }); - }); - - autoUpdater.on('error', (err) => { - console.error('Auto-updater error:', err); - }); - - autoUpdater.checkForUpdates(); -} - function setupMenu() { const isMac = process.platform === 'darwin'; const template: Electron.MenuItemConstructorOptions[] = [ diff --git a/surfsense_desktop/src/modules/auto-updater.ts b/surfsense_desktop/src/modules/auto-updater.ts new file mode 100644 index 000000000..f895516c0 --- /dev/null +++ b/surfsense_desktop/src/modules/auto-updater.ts @@ -0,0 +1,33 @@ +import { app, dialog } from 'electron'; +import { autoUpdater } from 'electron-updater'; + +export function setupAutoUpdater(): void { + if (!app.isPackaged) return; + + autoUpdater.autoDownload = true; + + autoUpdater.on('update-available', (info) => { + console.log(`Update available: ${info.version}`); + }); + + autoUpdater.on('update-downloaded', (info) => { + console.log(`Update downloaded: ${info.version}`); + dialog.showMessageBox({ + type: 'info', + buttons: ['Restart', 'Later'], + defaultId: 0, + title: 'Update Ready', + message: `Version ${info.version} has been downloaded. Restart to apply the update.`, + }).then(({ response }) => { + if (response === 0) { + autoUpdater.quitAndInstall(); + } + }); + }); + + autoUpdater.on('error', (err) => { + console.error('Auto-updater error:', err); + }); + + autoUpdater.checkForUpdates(); +} From b6a7f0afa7bced3f83fdd90a4424b4c9c8038a04 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 20:01:13 +0200 Subject: [PATCH 06/65] refactor(desktop): extract menu setup into modules/menu.ts --- surfsense_desktop/src/main.ts | 15 ++------------- surfsense_desktop/src/modules/menu.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 13 deletions(-) create mode 100644 surfsense_desktop/src/modules/menu.ts diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 11b2f7ab6..6d55d478b 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,9 +1,10 @@ -import { app, BrowserWindow, shell, ipcMain, Menu } from 'electron'; +import { app, BrowserWindow, shell, ipcMain } from 'electron'; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; import { startNextServer } from './modules/server'; import { createMainWindow } from './modules/window'; import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; import { setupAutoUpdater } from './modules/auto-updater'; +import { setupMenu } from './modules/menu'; registerGlobalErrorHandlers(); @@ -27,18 +28,6 @@ ipcMain.handle('get-app-version', () => { return app.getVersion(); }); -function setupMenu() { - const isMac = process.platform === 'darwin'; - const template: Electron.MenuItemConstructorOptions[] = [ - ...(isMac ? [{ role: 'appMenu' as const }] : []), - { role: 'fileMenu' as const }, - { role: 'editMenu' as const }, - { role: 'viewMenu' as const }, - { role: 'windowMenu' as const }, - ]; - Menu.setApplicationMenu(Menu.buildFromTemplate(template)); -} - // App lifecycle app.whenReady().then(async () => { setupMenu(); diff --git a/surfsense_desktop/src/modules/menu.ts b/surfsense_desktop/src/modules/menu.ts new file mode 100644 index 000000000..128a73a21 --- /dev/null +++ b/surfsense_desktop/src/modules/menu.ts @@ -0,0 +1,13 @@ +import { Menu } from 'electron'; + +export function setupMenu(): void { + const isMac = process.platform === 'darwin'; + const template: Electron.MenuItemConstructorOptions[] = [ + ...(isMac ? [{ role: 'appMenu' as const }] : []), + { role: 'fileMenu' as const }, + { role: 'editMenu' as const }, + { role: 'viewMenu' as const }, + { role: 'windowMenu' as const }, + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} From fb4dbf04ae805e6e8ce8e77d5e02210d2ac881f4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 20:06:21 +0200 Subject: [PATCH 07/65] refactor(desktop): extract IPC channels and handlers into src/ipc/ --- surfsense_desktop/src/ipc/channels.ts | 6 ++++++ surfsense_desktop/src/ipc/handlers.ts | 19 +++++++++++++++++++ surfsense_desktop/src/main.ts | 19 +++---------------- 3 files changed, 28 insertions(+), 16 deletions(-) create mode 100644 surfsense_desktop/src/ipc/channels.ts create mode 100644 surfsense_desktop/src/ipc/handlers.ts diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts new file mode 100644 index 000000000..4d0f3bf80 --- /dev/null +++ b/surfsense_desktop/src/ipc/channels.ts @@ -0,0 +1,6 @@ +export const IPC_CHANNELS = { + OPEN_EXTERNAL: 'open-external', + GET_APP_VERSION: 'get-app-version', + DEEP_LINK: 'deep-link', + GET_CLIPBOARD_CONTENT: 'get-clipboard-content', +} as const; diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts new file mode 100644 index 000000000..18e343719 --- /dev/null +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -0,0 +1,19 @@ +import { app, ipcMain, shell } from 'electron'; +import { IPC_CHANNELS } from './channels'; + +export function registerIpcHandlers(): void { + ipcMain.on(IPC_CHANNELS.OPEN_EXTERNAL, (_event, url: string) => { + try { + const parsed = new URL(url); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + shell.openExternal(url); + } + } catch { + // invalid URL — ignore + } + }); + + ipcMain.handle(IPC_CHANNELS.GET_APP_VERSION, () => { + return app.getVersion(); + }); +} diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 6d55d478b..aff64db22 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,10 +1,11 @@ -import { app, BrowserWindow, shell, ipcMain } from 'electron'; +import { app, BrowserWindow } from 'electron'; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; import { startNextServer } from './modules/server'; import { createMainWindow } from './modules/window'; import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; import { setupAutoUpdater } from './modules/auto-updater'; import { setupMenu } from './modules/menu'; +import { registerIpcHandlers } from './ipc/handlers'; registerGlobalErrorHandlers(); @@ -12,21 +13,7 @@ if (!setupDeepLinks()) { app.quit(); } -// IPC handlers -ipcMain.on('open-external', (_event, url: string) => { - try { - const parsed = new URL(url); - if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { - shell.openExternal(url); - } - } catch { - // invalid URL — ignore - } -}); - -ipcMain.handle('get-app-version', () => { - return app.getVersion(); -}); +registerIpcHandlers(); // App lifecycle app.whenReady().then(async () => { From ecdd7354e930d71364f6c2735ca24201cf55c83b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 20:13:58 +0200 Subject: [PATCH 08/65] refactor(desktop): use IPC channel constants in preload, add getClipboardContent --- surfsense_desktop/src/preload.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index dd4b89cf8..3f0f4be1f 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -1,4 +1,5 @@ const { contextBridge, ipcRenderer } = require('electron'); +const { IPC_CHANNELS } = require('./ipc/channels'); contextBridge.exposeInMainWorld('electronAPI', { versions: { @@ -7,13 +8,14 @@ contextBridge.exposeInMainWorld('electronAPI', { chrome: process.versions.chrome, platform: process.platform, }, - openExternal: (url: string) => ipcRenderer.send('open-external', url), - getAppVersion: () => ipcRenderer.invoke('get-app-version'), + openExternal: (url: string) => ipcRenderer.send(IPC_CHANNELS.OPEN_EXTERNAL, url), + getAppVersion: () => ipcRenderer.invoke(IPC_CHANNELS.GET_APP_VERSION), + getClipboardContent: () => ipcRenderer.invoke(IPC_CHANNELS.GET_CLIPBOARD_CONTENT), onDeepLink: (callback: (url: string) => void) => { const listener = (_event: unknown, url: string) => callback(url); - ipcRenderer.on('deep-link', listener); + ipcRenderer.on(IPC_CHANNELS.DEEP_LINK, listener); return () => { - ipcRenderer.removeListener('deep-link', listener); + ipcRenderer.removeListener(IPC_CHANNELS.DEEP_LINK, listener); }; }, }); From 275fa86ecddbf5c9b51cb89f74734af49f7549d2 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 20:22:37 +0200 Subject: [PATCH 09/65] feat(desktop): add system tray with clipboard-to-chat support --- surfsense_desktop/src/main.ts | 4 ++ surfsense_desktop/src/modules/clipboard.ts | 14 +++++ surfsense_desktop/src/modules/tray.ts | 73 ++++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 surfsense_desktop/src/modules/clipboard.ts create mode 100644 surfsense_desktop/src/modules/tray.ts diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index aff64db22..10f442c08 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -5,7 +5,9 @@ import { createMainWindow } from './modules/window'; import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; import { setupAutoUpdater } from './modules/auto-updater'; import { setupMenu } from './modules/menu'; +import { setupTray } from './modules/tray'; import { registerIpcHandlers } from './ipc/handlers'; +import { registerClipboardHandlers } from './modules/clipboard'; registerGlobalErrorHandlers(); @@ -14,6 +16,7 @@ if (!setupDeepLinks()) { } registerIpcHandlers(); +registerClipboardHandlers(); // App lifecycle app.whenReady().then(async () => { @@ -26,6 +29,7 @@ app.whenReady().then(async () => { return; } createMainWindow(); + setupTray(); setupAutoUpdater(); handlePendingDeepLink(); diff --git a/surfsense_desktop/src/modules/clipboard.ts b/surfsense_desktop/src/modules/clipboard.ts new file mode 100644 index 000000000..4f9d7b802 --- /dev/null +++ b/surfsense_desktop/src/modules/clipboard.ts @@ -0,0 +1,14 @@ +import { ipcMain } from 'electron'; +import { IPC_CHANNELS } from '../ipc/channels'; + +let lastClipboardContent = ''; + +export function setClipboardContent(text: string): void { + lastClipboardContent = text; +} + +export function registerClipboardHandlers(): void { + ipcMain.handle(IPC_CHANNELS.GET_CLIPBOARD_CONTENT, () => { + return lastClipboardContent; + }); +} diff --git a/surfsense_desktop/src/modules/tray.ts b/surfsense_desktop/src/modules/tray.ts new file mode 100644 index 000000000..3527cf691 --- /dev/null +++ b/surfsense_desktop/src/modules/tray.ts @@ -0,0 +1,73 @@ +import { app, BrowserWindow, clipboard, Menu, Tray } from 'electron'; +import path from 'path'; +import { getServerPort } from './server'; +import { setClipboardContent } from './clipboard'; + +let tray: Tray | null = null; +let clipWindow: BrowserWindow | null = null; + +function getIconPath(): string { + if (app.isPackaged) { + return path.join(process.resourcesPath, 'icon.png'); + } + return path.join(__dirname, '..', 'assets', 'icon.png'); +} + +function createClipWindow(): BrowserWindow { + if (clipWindow && !clipWindow.isDestroyed()) { + clipWindow.focus(); + return clipWindow; + } + + clipWindow = new BrowserWindow({ + width: 420, + height: 620, + resizable: true, + minimizable: false, + maximizable: false, + fullscreenable: false, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + show: false, + titleBarStyle: 'hiddenInset', + }); + + clipWindow.loadURL(`http://localhost:${getServerPort()}/dashboard`); + + clipWindow.once('ready-to-show', () => { + clipWindow?.show(); + }); + + clipWindow.on('closed', () => { + clipWindow = null; + }); + + return clipWindow; +} + +export function setupTray(): void { + tray = new Tray(getIconPath()); + tray.setToolTip('SurfSense'); + + const contextMenu = Menu.buildFromTemplate([ + { + label: 'Ask about clipboard', + click: () => { + const text = clipboard.readText(); + setClipboardContent(text); + createClipWindow(); + }, + }, + { type: 'separator' }, + { + label: 'Quit', + click: () => app.quit(), + }, + ]); + + tray.setContextMenu(contextMenu); +} From 5ab534511c8b6ff99d324d17579276ac4cea0801 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 20:26:33 +0200 Subject: [PATCH 10/65] feat(web): add initialText prop to InlineMentionEditor --- .../assistant-ui/inline-mention-editor.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index dacc845ec..656a3ca2d 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -47,6 +47,7 @@ interface InlineMentionEditorProps { disabled?: boolean; className?: string; initialDocuments?: MentionedDocument[]; + initialText?: string; } // Unique data attribute to identify chip elements @@ -96,6 +97,7 @@ export const InlineMentionEditor = forwardRef { @@ -115,6 +117,16 @@ export const InlineMentionEditor = forwardRef { + if (!initialText || initialTextAppliedRef.current || !editorRef.current) return; + initialTextAppliedRef.current = true; + editorRef.current.textContent = initialText; + setIsEmpty(false); + onChange?.(initialText, Array.from(mentionedDocs.values())); + }, [initialText]); // eslint-disable-line react-hooks/exhaustive-deps + // Focus at the end of the editor const focusAtEnd = useCallback(() => { if (!editorRef.current) return; From c78f0e78aae2e540eaf7f86cf8cb194e53ec6980 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 20:33:43 +0200 Subject: [PATCH 11/65] feat(web): wire Composer to pre-fill clipboard content from Electron tray --- surfsense_web/components/assistant-ui/thread.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index b7a5bcf0e..b6bbea2f4 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -314,6 +314,16 @@ const Composer: FC = () => { const composerRuntime = useComposerRuntime(); const hasAutoFocusedRef = useRef(false); + // Clipboard content from Electron tray (pre-filled into composer) + const [clipboardText, setClipboardText] = useState(); + useEffect(() => { + const api = (window as { electronAPI?: { getClipboardContent?: () => Promise } }).electronAPI; + if (!api?.getClipboardContent) return; + api.getClipboardContent().then((text) => { + if (text) setClipboardText(text); + }); + }, []); + const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty); const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); @@ -520,6 +530,7 @@ const Composer: FC = () => { onDocumentRemove={handleDocumentRemove} onSubmit={handleSubmit} onKeyDown={handleKeyDown} + initialText={clipboardText} className="min-h-[24px]" /> From 9e058e13290fdbdb258d7984afc611fecdcda190 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 20:35:29 +0200 Subject: [PATCH 12/65] chore: clean up comments in editor and composer --- surfsense_web/components/assistant-ui/inline-mention-editor.tsx | 2 +- surfsense_web/components/assistant-ui/thread.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 656a3ca2d..be48b60fa 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -117,7 +117,7 @@ export const InlineMentionEditor = forwardRef { if (!initialText || initialTextAppliedRef.current || !editorRef.current) return; diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index b6bbea2f4..023b0f7bc 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -314,7 +314,7 @@ const Composer: FC = () => { const composerRuntime = useComposerRuntime(); const hasAutoFocusedRef = useRef(false); - // Clipboard content from Electron tray (pre-filled into composer) + // Clipboard content const [clipboardText, setClipboardText] = useState(); useEffect(() => { const api = (window as { electronAPI?: { getClipboardContent?: () => Promise } }).electronAPI; From d6d4ebc75de6bdbff5ffb4732d8ad8e8e9693b80 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 20 Mar 2026 20:39:18 +0200 Subject: [PATCH 13/65] feat(web): add ElectronAPI type declaration for window.electronAPI --- surfsense_web/components/assistant-ui/thread.tsx | 2 +- surfsense_web/types/window.d.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 023b0f7bc..389b9f204 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -317,7 +317,7 @@ const Composer: FC = () => { // Clipboard content const [clipboardText, setClipboardText] = useState(); useEffect(() => { - const api = (window as { electronAPI?: { getClipboardContent?: () => Promise } }).electronAPI; + const api = window.electronAPI; if (!api?.getClipboardContent) return; api.getClipboardContent().then((text) => { if (text) setClipboardText(text); diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 4d4abc9c1..487e2058f 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -1,7 +1,21 @@ import type { PostHog } from "posthog-js"; +interface ElectronAPI { + versions: { + electron: string; + node: string; + chrome: string; + platform: string; + }; + openExternal: (url: string) => void; + getAppVersion: () => Promise; + getClipboardContent: () => Promise; + onDeepLink: (callback: (url: string) => void) => () => void; +} + declare global { interface Window { posthog?: PostHog; + electronAPI?: ElectronAPI; } } From dea0651a94921f99f4f5d7017f6579fb91d71c80 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 23 Mar 2026 15:49:50 +0200 Subject: [PATCH 14/65] fix(desktop): include tray icon in packaged app resources --- surfsense_desktop/electron-builder.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/surfsense_desktop/electron-builder.yml b/surfsense_desktop/electron-builder.yml index eaca0f19b..715366e0c 100644 --- a/surfsense_desktop/electron-builder.yml +++ b/surfsense_desktop/electron-builder.yml @@ -13,6 +13,8 @@ files: - "!scripts" - "!release" extraResources: + - from: assets/icon.png + to: icon.png - from: ../surfsense_web/.next/standalone/surfsense_web/ to: standalone/ filter: From f783b00d2e287a488b5129e4fe2cbc652c175ab6 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 18:40:07 +0200 Subject: [PATCH 15/65] fix(desktop): bind to 0.0.0.0 and silence auto-updater 404 --- surfsense_desktop/src/modules/auto-updater.ts | 4 ++-- surfsense_desktop/src/modules/server.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/surfsense_desktop/src/modules/auto-updater.ts b/surfsense_desktop/src/modules/auto-updater.ts index f895516c0..2e7680953 100644 --- a/surfsense_desktop/src/modules/auto-updater.ts +++ b/surfsense_desktop/src/modules/auto-updater.ts @@ -26,8 +26,8 @@ export function setupAutoUpdater(): void { }); autoUpdater.on('error', (err) => { - console.error('Auto-updater error:', err); + console.log('Auto-updater: update check skipped —', err.message?.split('\n')[0]); }); - autoUpdater.checkForUpdates(); + autoUpdater.checkForUpdates().catch(() => {}); } diff --git a/surfsense_desktop/src/modules/server.ts b/surfsense_desktop/src/modules/server.ts index 969478e4a..e2f078a8c 100644 --- a/surfsense_desktop/src/modules/server.ts +++ b/surfsense_desktop/src/modules/server.ts @@ -39,7 +39,7 @@ export async function startNextServer(): Promise { const serverScript = path.join(standalonePath, 'server.js'); process.env.PORT = String(serverPort); - process.env.HOSTNAME = 'localhost'; + process.env.HOSTNAME = '0.0.0.0'; process.env.NODE_ENV = 'production'; process.chdir(standalonePath); From 59e7f8f06880bf7d7cd42f7a9c6cb8e17239a173 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 19:12:04 +0200 Subject: [PATCH 16/65] remove tray and clipboard-to-chat feature --- surfsense_desktop/electron-builder.yml | 2 - surfsense_desktop/src/ipc/channels.ts | 1 - surfsense_desktop/src/main.ts | 4 - surfsense_desktop/src/modules/clipboard.ts | 14 ---- surfsense_desktop/src/modules/tray.ts | 73 ------------------- surfsense_desktop/src/preload.ts | 1 - .../assistant-ui/inline-mention-editor.tsx | 12 --- .../components/assistant-ui/thread.tsx | 11 --- surfsense_web/types/window.d.ts | 1 - 9 files changed, 119 deletions(-) delete mode 100644 surfsense_desktop/src/modules/clipboard.ts delete mode 100644 surfsense_desktop/src/modules/tray.ts diff --git a/surfsense_desktop/electron-builder.yml b/surfsense_desktop/electron-builder.yml index 715366e0c..eaca0f19b 100644 --- a/surfsense_desktop/electron-builder.yml +++ b/surfsense_desktop/electron-builder.yml @@ -13,8 +13,6 @@ files: - "!scripts" - "!release" extraResources: - - from: assets/icon.png - to: icon.png - from: ../surfsense_web/.next/standalone/surfsense_web/ to: standalone/ filter: diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 4d0f3bf80..8ae21cfcf 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -2,5 +2,4 @@ export const IPC_CHANNELS = { OPEN_EXTERNAL: 'open-external', GET_APP_VERSION: 'get-app-version', DEEP_LINK: 'deep-link', - GET_CLIPBOARD_CONTENT: 'get-clipboard-content', } as const; diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 10f442c08..aff64db22 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -5,9 +5,7 @@ import { createMainWindow } from './modules/window'; import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; import { setupAutoUpdater } from './modules/auto-updater'; import { setupMenu } from './modules/menu'; -import { setupTray } from './modules/tray'; import { registerIpcHandlers } from './ipc/handlers'; -import { registerClipboardHandlers } from './modules/clipboard'; registerGlobalErrorHandlers(); @@ -16,7 +14,6 @@ if (!setupDeepLinks()) { } registerIpcHandlers(); -registerClipboardHandlers(); // App lifecycle app.whenReady().then(async () => { @@ -29,7 +26,6 @@ app.whenReady().then(async () => { return; } createMainWindow(); - setupTray(); setupAutoUpdater(); handlePendingDeepLink(); diff --git a/surfsense_desktop/src/modules/clipboard.ts b/surfsense_desktop/src/modules/clipboard.ts deleted file mode 100644 index 4f9d7b802..000000000 --- a/surfsense_desktop/src/modules/clipboard.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ipcMain } from 'electron'; -import { IPC_CHANNELS } from '../ipc/channels'; - -let lastClipboardContent = ''; - -export function setClipboardContent(text: string): void { - lastClipboardContent = text; -} - -export function registerClipboardHandlers(): void { - ipcMain.handle(IPC_CHANNELS.GET_CLIPBOARD_CONTENT, () => { - return lastClipboardContent; - }); -} diff --git a/surfsense_desktop/src/modules/tray.ts b/surfsense_desktop/src/modules/tray.ts deleted file mode 100644 index 3527cf691..000000000 --- a/surfsense_desktop/src/modules/tray.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { app, BrowserWindow, clipboard, Menu, Tray } from 'electron'; -import path from 'path'; -import { getServerPort } from './server'; -import { setClipboardContent } from './clipboard'; - -let tray: Tray | null = null; -let clipWindow: BrowserWindow | null = null; - -function getIconPath(): string { - if (app.isPackaged) { - return path.join(process.resourcesPath, 'icon.png'); - } - return path.join(__dirname, '..', 'assets', 'icon.png'); -} - -function createClipWindow(): BrowserWindow { - if (clipWindow && !clipWindow.isDestroyed()) { - clipWindow.focus(); - return clipWindow; - } - - clipWindow = new BrowserWindow({ - width: 420, - height: 620, - resizable: true, - minimizable: false, - maximizable: false, - fullscreenable: false, - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - sandbox: true, - }, - show: false, - titleBarStyle: 'hiddenInset', - }); - - clipWindow.loadURL(`http://localhost:${getServerPort()}/dashboard`); - - clipWindow.once('ready-to-show', () => { - clipWindow?.show(); - }); - - clipWindow.on('closed', () => { - clipWindow = null; - }); - - return clipWindow; -} - -export function setupTray(): void { - tray = new Tray(getIconPath()); - tray.setToolTip('SurfSense'); - - const contextMenu = Menu.buildFromTemplate([ - { - label: 'Ask about clipboard', - click: () => { - const text = clipboard.readText(); - setClipboardContent(text); - createClipWindow(); - }, - }, - { type: 'separator' }, - { - label: 'Quit', - click: () => app.quit(), - }, - ]); - - tray.setContextMenu(contextMenu); -} diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index 3f0f4be1f..d36db8c22 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -10,7 +10,6 @@ contextBridge.exposeInMainWorld('electronAPI', { }, openExternal: (url: string) => ipcRenderer.send(IPC_CHANNELS.OPEN_EXTERNAL, url), getAppVersion: () => ipcRenderer.invoke(IPC_CHANNELS.GET_APP_VERSION), - getClipboardContent: () => ipcRenderer.invoke(IPC_CHANNELS.GET_CLIPBOARD_CONTENT), onDeepLink: (callback: (url: string) => void) => { const listener = (_event: unknown, url: string) => callback(url); ipcRenderer.on(IPC_CHANNELS.DEEP_LINK, listener); diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index be48b60fa..dacc845ec 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -47,7 +47,6 @@ interface InlineMentionEditorProps { disabled?: boolean; className?: string; initialDocuments?: MentionedDocument[]; - initialText?: string; } // Unique data attribute to identify chip elements @@ -97,7 +96,6 @@ export const InlineMentionEditor = forwardRef { @@ -117,16 +115,6 @@ export const InlineMentionEditor = forwardRef { - if (!initialText || initialTextAppliedRef.current || !editorRef.current) return; - initialTextAppliedRef.current = true; - editorRef.current.textContent = initialText; - setIsEmpty(false); - onChange?.(initialText, Array.from(mentionedDocs.values())); - }, [initialText]); // eslint-disable-line react-hooks/exhaustive-deps - // Focus at the end of the editor const focusAtEnd = useCallback(() => { if (!editorRef.current) return; diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 0a38e19d5..081e234a8 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -330,16 +330,6 @@ const Composer: FC = () => { const composerRuntime = useComposerRuntime(); const hasAutoFocusedRef = useRef(false); - // Clipboard content - const [clipboardText, setClipboardText] = useState(); - useEffect(() => { - const api = window.electronAPI; - if (!api?.getClipboardContent) return; - api.getClipboardContent().then((text) => { - if (text) setClipboardText(text); - }); - }, []); - const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty); const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); @@ -546,7 +536,6 @@ const Composer: FC = () => { onDocumentRemove={handleDocumentRemove} onSubmit={handleSubmit} onKeyDown={handleKeyDown} - initialText={clipboardText} className="min-h-[24px]" /> diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 487e2058f..8d2c38c8b 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -9,7 +9,6 @@ interface ElectronAPI { }; openExternal: (url: string) => void; getAppVersion: () => Promise; - getClipboardContent: () => Promise; onDeepLink: (callback: (url: string) => void) => () => void; } From 801c07291e4cd09f21bc27ecd657b430ef977711 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 19:22:43 +0200 Subject: [PATCH 17/65] add quick-ask IPC channel and shortcut module --- surfsense_desktop/src/ipc/channels.ts | 1 + surfsense_desktop/src/modules/quick-ask.ts | 29 ++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 surfsense_desktop/src/modules/quick-ask.ts diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 8ae21cfcf..18002b520 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -2,4 +2,5 @@ export const IPC_CHANNELS = { OPEN_EXTERNAL: 'open-external', GET_APP_VERSION: 'get-app-version', DEEP_LINK: 'deep-link', + QUICK_ASK_TEXT: 'quick-ask-text', } as const; diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts new file mode 100644 index 000000000..e38b0d693 --- /dev/null +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -0,0 +1,29 @@ +import { clipboard, globalShortcut } from 'electron'; +import { IPC_CHANNELS } from '../ipc/channels'; +import { getMainWindow } from './window'; + +const SHORTCUT = 'CommandOrControl+Option+S'; + +export function registerQuickAsk(): void { + const ok = globalShortcut.register(SHORTCUT, () => { + const win = getMainWindow(); + if (!win) return; + + const text = clipboard.readText().trim(); + if (!text) return; + + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); + + win.webContents.send(IPC_CHANNELS.QUICK_ASK_TEXT, text); + }); + + if (!ok) { + console.log(`Quick-ask: failed to register ${SHORTCUT}`); + } +} + +export function unregisterQuickAsk(): void { + globalShortcut.unregister(SHORTCUT); +} From 45e91135227d5faa2758595eca21f5c4ec199c39 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 19:23:24 +0200 Subject: [PATCH 18/65] expose onQuickAskText in preload --- surfsense_desktop/src/preload.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index d36db8c22..ca894d6b3 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -17,4 +17,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.removeListener(IPC_CHANNELS.DEEP_LINK, listener); }; }, + onQuickAskText: (callback: (text: string) => void) => { + const listener = (_event: unknown, text: string) => callback(text); + ipcRenderer.on(IPC_CHANNELS.QUICK_ASK_TEXT, listener); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.QUICK_ASK_TEXT, listener); + }; + }, }); From 032ccd95415b4c060372f2e2d03a9050e21adf30 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 19:24:02 +0200 Subject: [PATCH 19/65] add onQuickAskText 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 8d2c38c8b..6c7e192db 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -10,6 +10,7 @@ interface ElectronAPI { openExternal: (url: string) => void; getAppVersion: () => Promise; onDeepLink: (callback: (url: string) => void) => () => void; + onQuickAskText: (callback: (text: string) => void) => () => void; } declare global { From 71a262b2e797f92e9abaab5e2ee6f028f19825ab Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 19:24:41 +0200 Subject: [PATCH 20/65] wire quick-ask in main.ts --- surfsense_desktop/src/main.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index aff64db22..3ab41073b 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -5,6 +5,7 @@ import { createMainWindow } from './modules/window'; import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; import { setupAutoUpdater } from './modules/auto-updater'; import { setupMenu } from './modules/menu'; +import { registerQuickAsk, unregisterQuickAsk } from './modules/quick-ask'; import { registerIpcHandlers } from './ipc/handlers'; registerGlobalErrorHandlers(); @@ -26,6 +27,7 @@ app.whenReady().then(async () => { return; } createMainWindow(); + registerQuickAsk(); setupAutoUpdater(); handlePendingDeepLink(); @@ -44,5 +46,5 @@ app.on('window-all-closed', () => { }); app.on('will-quit', () => { - // Server runs in-process — no child process to kill + unregisterQuickAsk(); }); From f9be80ab766af7cfd78d93a044d6724b68326ab6 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 19:26:13 +0200 Subject: [PATCH 21/65] re-add initialText prop to InlineMentionEditor --- .../components/assistant-ui/inline-mention-editor.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index dacc845ec..ab1213a49 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -47,6 +47,7 @@ interface InlineMentionEditorProps { disabled?: boolean; className?: string; initialDocuments?: MentionedDocument[]; + initialText?: string; } // Unique data attribute to identify chip elements @@ -96,6 +97,7 @@ export const InlineMentionEditor = forwardRef { @@ -115,6 +117,13 @@ export const InlineMentionEditor = forwardRef { + if (!initialText || !editorRef.current) return; + editorRef.current.textContent = initialText; + setIsEmpty(false); + onChange?.(initialText, Array.from(mentionedDocs.values())); + }, [initialText]); // eslint-disable-line react-hooks/exhaustive-deps + // Focus at the end of the editor const focusAtEnd = useCallback(() => { if (!editorRef.current) return; From 875046263738ddcbfc6b92ed7d08546eef24b8d1 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 19:28:41 +0200 Subject: [PATCH 22/65] listen for quick-ask text in Composer --- surfsense_web/components/assistant-ui/thread.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 081e234a8..eb98fd025 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -330,6 +330,13 @@ const Composer: FC = () => { const composerRuntime = useComposerRuntime(); const hasAutoFocusedRef = useRef(false); + const [quickAskText, setQuickAskText] = useState(); + useEffect(() => { + return window.electronAPI?.onQuickAskText((text) => { + if (text) setQuickAskText(text); + }); + }, []); + const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty); const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning); @@ -536,6 +543,7 @@ const Composer: FC = () => { onDocumentRemove={handleDocumentRemove} onSubmit={handleSubmit} onKeyDown={handleKeyDown} + initialText={quickAskText} className="min-h-[24px]" /> From d033e1cb4847e0b08b2f4d7e82e6ac235e169c63 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 20:12:49 +0200 Subject: [PATCH 23/65] open quick-ask as mini window at cursor position --- surfsense_desktop/src/modules/quick-ask.ts | 53 ++++++++++++++++++---- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index e38b0d693..6785fb3ce 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -1,20 +1,57 @@ -import { clipboard, globalShortcut } from 'electron'; +import { BrowserWindow, clipboard, globalShortcut, screen } from 'electron'; +import path from 'path'; import { IPC_CHANNELS } from '../ipc/channels'; -import { getMainWindow } from './window'; +import { getServerPort } from './server'; const SHORTCUT = 'CommandOrControl+Option+S'; +let quickAskWindow: BrowserWindow | null = null; + +function createQuickAskWindow(x: number, y: number): BrowserWindow { + if (quickAskWindow && !quickAskWindow.isDestroyed()) { + quickAskWindow.setPosition(x, y); + quickAskWindow.show(); + quickAskWindow.focus(); + return quickAskWindow; + } + + quickAskWindow = new BrowserWindow({ + width: 450, + height: 550, + x, + y, + alwaysOnTop: true, + resizable: true, + frame: false, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + show: false, + skipTaskbar: true, + }); + + quickAskWindow.loadURL(`http://localhost:${getServerPort()}/dashboard`); + + quickAskWindow.once('ready-to-show', () => { + quickAskWindow?.show(); + }); + + quickAskWindow.on('closed', () => { + quickAskWindow = null; + }); + + return quickAskWindow; +} export function registerQuickAsk(): void { const ok = globalShortcut.register(SHORTCUT, () => { - const win = getMainWindow(); - if (!win) return; - const text = clipboard.readText().trim(); if (!text) return; - if (win.isMinimized()) win.restore(); - win.show(); - win.focus(); + const cursor = screen.getCursorScreenPoint(); + const win = createQuickAskWindow(cursor.x, cursor.y); win.webContents.send(IPC_CHANNELS.QUICK_ASK_TEXT, text); }); From 985299b72da8759f363d187b26f4828929bdabca Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 20:32:27 +0200 Subject: [PATCH 24/65] toggle, blur-dismiss, and titlebar for quick-ask window --- surfsense_desktop/src/modules/quick-ask.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 6785fb3ce..acf41febf 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -6,6 +6,12 @@ import { getServerPort } from './server'; const SHORTCUT = 'CommandOrControl+Option+S'; let quickAskWindow: BrowserWindow | null = null; +function hideQuickAsk(): void { + if (quickAskWindow && !quickAskWindow.isDestroyed()) { + quickAskWindow.hide(); + } +} + function createQuickAskWindow(x: number, y: number): BrowserWindow { if (quickAskWindow && !quickAskWindow.isDestroyed()) { quickAskWindow.setPosition(x, y); @@ -19,9 +25,8 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { height: 550, x, y, - alwaysOnTop: true, resizable: true, - frame: false, + titleBarStyle: 'hiddenInset', webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, @@ -38,6 +43,8 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { quickAskWindow?.show(); }); + quickAskWindow.on('blur', hideQuickAsk); + quickAskWindow.on('closed', () => { quickAskWindow = null; }); @@ -47,6 +54,11 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { export function registerQuickAsk(): void { const ok = globalShortcut.register(SHORTCUT, () => { + if (quickAskWindow && !quickAskWindow.isDestroyed() && quickAskWindow.isVisible()) { + hideQuickAsk(); + return; + } + const text = clipboard.readText().trim(); if (!text) return; From 296b95ba5b689bdadc0be18fc2d55d3ba36cd119 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 24 Mar 2026 20:45:56 +0200 Subject: [PATCH 25/65] use type panel for floating non-focus-stealing window --- 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 acf41febf..0058a738e 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -25,8 +25,8 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { height: 550, x, y, + type: 'panel', resizable: true, - titleBarStyle: 'hiddenInset', webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, From 7f3c7f47f26aebaee2c5d707f48bdd568970b9d0 Mon Sep 17 00:00:00 2001 From: Nishant-k-sagar <147799872+Nishant-k-sagar@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:35:37 +0000 Subject: [PATCH 26/65] feat: add placeholder text to login and register inputs --- surfsense_web/app/(home)/login/LocalLoginForm.tsx | 2 ++ surfsense_web/app/(home)/register/page.tsx | 3 +++ 2 files changed, 5 insertions(+) diff --git a/surfsense_web/app/(home)/login/LocalLoginForm.tsx b/surfsense_web/app/(home)/login/LocalLoginForm.tsx index cb3ee73a1..f8dad43c4 100644 --- a/surfsense_web/app/(home)/login/LocalLoginForm.tsx +++ b/surfsense_web/app/(home)/login/LocalLoginForm.tsx @@ -166,6 +166,7 @@ export function LocalLoginForm() { id="email" type="email" required + placeholder="you@example.com" value={username} onChange={(e) => setUsername(e.target.value)} className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-all ${ @@ -189,6 +190,7 @@ export function LocalLoginForm() { id="password" type={showPassword ? "text" : "password"} required + placeholder="Enter your password" value={password} onChange={(e) => setPassword(e.target.value)} className={`mt-1 block w-full rounded-md border pr-10 px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-all ${ diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx index 60c3ba1be..bfde70d24 100644 --- a/surfsense_web/app/(home)/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -231,6 +231,7 @@ export default function RegisterPage() { id="email" type="email" required + placeholder="you@example.com" value={email} onChange={(e) => setEmail(e.target.value)} className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-all ${ @@ -253,6 +254,7 @@ export default function RegisterPage() { id="password" type="password" required + placeholder="Enter your password" value={password} onChange={(e) => setPassword(e.target.value)} className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-all ${ @@ -275,6 +277,7 @@ export default function RegisterPage() { id="confirmPassword" type="password" required + placeholder="Confirm your password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className={`mt-1 block w-full rounded-md border px-3 py-1.5 md:py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-all ${ From 6f64a2de9bbe260d2cdd212b67372af439eb0642 Mon Sep 17 00:00:00 2001 From: Nishant-k-sagar <147799872+Nishant-k-sagar@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:38:21 +0000 Subject: [PATCH 27/65] perf: replace img with Next.js Image for avatars --- surfsense_web/components/assistant-ui/user-message.tsx | 5 ++++- surfsense_web/components/public-chat/public-thread.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index 1c0525277..7f4bc0eb7 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -1,6 +1,7 @@ import { ActionBarPrimitive, MessagePrimitive, useAssistantState } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; import { FileText, Pen } from "lucide-react"; +import Image from "next/image"; import { type FC, useState } from "react"; import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; @@ -24,9 +25,11 @@ const UserAvatar: FC = ({ displayName, avatarUrl }) => { if (avatarUrl && !hasError) { return ( - {displayName setHasError(true)} diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx index e88e5aae7..b0c0582e7 100644 --- a/surfsense_web/components/public-chat/public-thread.tsx +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -8,6 +8,7 @@ import { useAssistantState, } from "@assistant-ui/react"; import { CheckIcon, CopyIcon } from "lucide-react"; +import Image from "next/image"; import { type FC, type ReactNode, useState } from "react"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; @@ -75,9 +76,11 @@ const UserAvatar: FC void } if (avatarUrl && !hasError) { return ( - {displayName Date: Wed, 25 Mar 2026 10:31:35 +0800 Subject: [PATCH 28/65] fix: add missing clearTimeout cleanup in CopyButton useEffect Closes #934 --- surfsense_web/components/ui/code-block-node.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/surfsense_web/components/ui/code-block-node.tsx b/surfsense_web/components/ui/code-block-node.tsx index 23cc163a1..d323fc894 100644 --- a/surfsense_web/components/ui/code-block-node.tsx +++ b/surfsense_web/components/ui/code-block-node.tsx @@ -143,9 +143,11 @@ function CopyButton({ const [hasCopied, setHasCopied] = React.useState(false); React.useEffect(() => { - setTimeout(() => { + if (!hasCopied) return; + const timer = setTimeout(() => { setHasCopied(false); }, 2000); + return () => clearTimeout(timer); }, [hasCopied]); return ( From 8d8c36fc5972fc6173ac9e6357c14eab84a65bfc Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 25 Mar 2026 10:32:11 +0800 Subject: [PATCH 29/65] fix: use stable references for event listeners in Spotlight component Anonymous arrow functions create different references on add/remove, so the listeners were never actually removed. Closes #933 --- surfsense_web/components/ui/spotlight.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/surfsense_web/components/ui/spotlight.tsx b/surfsense_web/components/ui/spotlight.tsx index 2258e8814..3047559e1 100644 --- a/surfsense_web/components/ui/spotlight.tsx +++ b/surfsense_web/components/ui/spotlight.tsx @@ -48,14 +48,17 @@ export function Spotlight({ useEffect(() => { if (!parentElement) return; + const handleEnter = () => setIsHovered(true); + const handleLeave = () => setIsHovered(false); + parentElement.addEventListener("mousemove", handleMouseMove); - parentElement.addEventListener("mouseenter", () => setIsHovered(true)); - parentElement.addEventListener("mouseleave", () => setIsHovered(false)); + parentElement.addEventListener("mouseenter", handleEnter); + parentElement.addEventListener("mouseleave", handleLeave); return () => { parentElement.removeEventListener("mousemove", handleMouseMove); - parentElement.removeEventListener("mouseenter", () => setIsHovered(true)); - parentElement.removeEventListener("mouseleave", () => setIsHovered(false)); + parentElement.removeEventListener("mouseenter", handleEnter); + parentElement.removeEventListener("mouseleave", handleLeave); }; }, [parentElement, handleMouseMove]); From cbb93518b6bb2b926ea84741e60038a139310963 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 25 Mar 2026 10:32:53 +0800 Subject: [PATCH 30/65] fix: remove unused useRouter and useParams in SidebarHeader Closes #944 --- surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx index 9d6642623..2c8539dc8 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx @@ -1,7 +1,6 @@ "use client"; import { ChevronsUpDown, Settings, UserPen } from "lucide-react"; -import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { @@ -29,9 +28,6 @@ export function SidebarHeader({ className, }: SidebarHeaderProps) { const t = useTranslations("sidebar"); - const router = useRouter(); - const params = useParams(); - const searchSpaceId = params.search_space_id as string; return (
From 16ffdd898a706e0fdb1a69b52ad40e53aa305556 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 25 Mar 2026 10:40:06 +0800 Subject: [PATCH 31/65] fix: add missing setTimeout cleanup in hero section collision effect Closes #940 --- .../components/homepage/hero-section.tsx | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/surfsense_web/components/homepage/hero-section.tsx b/surfsense_web/components/homepage/hero-section.tsx index 830f9febf..3e7bccccb 100644 --- a/surfsense_web/components/homepage/hero-section.tsx +++ b/surfsense_web/components/homepage/hero-section.tsx @@ -277,21 +277,24 @@ const CollisionMechanism = ({ }, [cycleCollisionDetected, parentRef]); useEffect(() => { - if (collision.detected && collision.coordinates) { - setTimeout(() => { - setCollision({ detected: false, coordinates: null }); - setCycleCollisionDetected(false); - // Set beam opacity to 0 - if (beamRef.current) { - beamRef.current.style.opacity = "1"; - } - }, 2000); + if (!collision.detected || !collision.coordinates) return; - // Reset the beam animation after a delay - setTimeout(() => { - setBeamKey((prevKey) => prevKey + 1); - }, 2000); - } + const timer1 = setTimeout(() => { + setCollision({ detected: false, coordinates: null }); + setCycleCollisionDetected(false); + if (beamRef.current) { + beamRef.current.style.opacity = "1"; + } + }, 2000); + + const timer2 = setTimeout(() => { + setBeamKey((prevKey) => prevKey + 1); + }, 2000); + + return () => { + clearTimeout(timer1); + clearTimeout(timer2); + }; }, [collision]); return ( From 1967c14a8ed9ad459330f7df82326f73ebf22f28 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 25 Mar 2026 10:40:35 +0800 Subject: [PATCH 32/65] fix: add rel="noopener noreferrer" to target="_blank" link in link-toolbar Closes #938 --- surfsense_web/components/ui/link-toolbar.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/surfsense_web/components/ui/link-toolbar.tsx b/surfsense_web/components/ui/link-toolbar.tsx index 4f5988ded..f44b5a1d1 100644 --- a/surfsense_web/components/ui/link-toolbar.tsx +++ b/surfsense_web/components/ui/link-toolbar.tsx @@ -187,6 +187,7 @@ function LinkOpenButton() { }} aria-label="Open link in a new tab" target="_blank" + rel="noopener noreferrer" > From 967f9762c3ac61cf9f7f5a58be8acc793c0bfb17 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 25 Mar 2026 10:41:56 +0800 Subject: [PATCH 33/65] fix: replace key={index} with stable keys in pricing component Use plan.name and feature text as keys instead of array indices. Closes #942 --- surfsense_web/components/pricing.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/surfsense_web/components/pricing.tsx b/surfsense_web/components/pricing.tsx index 0023a3eaf..731c3654c 100644 --- a/surfsense_web/components/pricing.tsx +++ b/surfsense_web/components/pricing.tsx @@ -101,9 +101,9 @@ export function Pricing({ plans.length === 2 ? "md:grid-cols-2 max-w-5xl mx-auto" : "md:grid-cols-3" )} > - {plans.map((plan, index) => ( + {plans.map((plan) => (
    - {plan.features.map((feature, idx) => ( -
  • + {plan.features.map((feature) => ( +
  • {feature}
  • From e3f5b7923abcc6b2792ed5bc2323850cb60a709f Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 24 Mar 2026 20:44:14 -0700 Subject: [PATCH 34/65] Add index parameter to plans.map callback in Pricing component Made-with: Cursor --- surfsense_web/components/pricing.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_web/components/pricing.tsx b/surfsense_web/components/pricing.tsx index 731c3654c..9a8e29c32 100644 --- a/surfsense_web/components/pricing.tsx +++ b/surfsense_web/components/pricing.tsx @@ -101,7 +101,7 @@ export function Pricing({ plans.length === 2 ? "md:grid-cols-2 max-w-5xl mx-auto" : "md:grid-cols-3" )} > - {plans.map((plan) => ( + {plans.map((plan, index) => ( Date: Tue, 24 Mar 2026 21:50:40 -0700 Subject: [PATCH 35/65] fix: add CheckIcon and CopyIcon imports to user-message component --- surfsense_web/components/assistant-ui/user-message.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index 61cecd1a2..74461e760 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -1,6 +1,6 @@ import { ActionBarPrimitive, AuiIf, MessagePrimitive, useAuiState } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; -import { FileText, Pen } from "lucide-react"; +import { CheckIcon, CopyIcon, FileText, Pen } from "lucide-react"; import Image from "next/image"; import { type FC, useState } from "react"; import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom"; From 8d6e249c10bdf0a331a008713b6ec7584f552cc0 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Wed, 25 Mar 2026 03:09:05 -0700 Subject: [PATCH 36/65] docs: update CONTRIBUTING.md to clarify branching workflow and PR submission process --- CONTRIBUTING.md | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb3d607c1..493f24c02 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,22 @@ Found a bug? Create an issue with: Want to fix it? Go for it! Just link the issue in your PR. +## 🌿 Branching Workflow + +We follow a **branch protection model** to keep `main` stable: + +| Branch | Purpose | Who can merge | +|--------|---------|---------------| +| `main` | Stable/release branch | Maintainers only (from `dev`) | +| `dev` | Active development & integration | Via approved PRs from contributors | +| `feature/*`, `fix/*`, etc. | Individual work branches | Contributors create PRs to `dev` | + +### Important Rules + +- **All contributor PRs must target the `dev` branch.** PRs targeting `main` will not be accepted. +- `main` is updated exclusively by maintainers merging from `dev` when a release is ready. +- Always create your feature/fix branches from the latest `dev`. + ## 🛠️ Development Setup ### Prerequisites @@ -49,17 +65,24 @@ Want to fix it? Go for it! Just link the issue in your PR. - **API Keys** for external services you're testing ### Quick Start -1. **Clone the repository** +1. **Fork and clone the repository** ```bash - git clone https://github.com/MODSetter/SurfSense.git + git clone https://github.com//SurfSense.git cd SurfSense ``` -2. **Choose your setup method**: +2. **Create your branch from `dev`** + ```bash + git checkout dev + git pull origin dev + git checkout -b feature/your-feature-name + ``` + +3. **Choose your setup method**: - **Docker Setup**: Follow the [Docker Setup Guide](./DOCKER_SETUP.md) - **Manual Setup**: Follow the [Installation Guide](https://www.surfsense.com/docs/) -3. **Configure services**: +4. **Configure services**: - Set up PGVector & PostgreSQL - Configure a file ETL service: `Unstructured.io` or `LlamaIndex` - Add API keys for external services @@ -103,7 +126,7 @@ refactor: improve error handling in connectors - Include integration tests for API endpoints ### Branch Naming -Use descriptive branch names: +Create branches from `dev` with descriptive names: - `feature/add-document-search` - `fix/pagination-issue` - `docs/update-contributing-guide` @@ -112,12 +135,16 @@ Use descriptive branch names: ### Before Submitting 1. **Create an issue** first (unless it's a minor fix) -2. **Fork the repository** and create a feature branch +2. **Fork the repository** and create a branch from `dev` 3. **Make your changes** following the coding guidelines 4. **Test your changes** thoroughly 5. **Update documentation** if needed +6. **Open a PR targeting the `dev` branch** + +> **Note:** PRs targeting `main` will **not** be reviewed or merged. If you accidentally open a PR to `main`, please retarget it to `dev`. ### PR Requirements +- **Target the `dev` branch** — this is mandatory - **One feature or fix per PR** - keep changes focused - **Link related issues** in the PR description - **Include screenshots or demos** for UI changes From 91ad36027dbe32f00dbf1a81a8908564ce3dfab4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 25 Mar 2026 15:57:20 +0200 Subject: [PATCH 37/65] fix: send clipboard text after page load on first open --- surfsense_desktop/src/modules/quick-ask.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 0058a738e..8eb094812 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -62,10 +62,17 @@ export function registerQuickAsk(): void { const text = clipboard.readText().trim(); if (!text) return; + const isExisting = quickAskWindow && !quickAskWindow.isDestroyed(); const cursor = screen.getCursorScreenPoint(); const win = createQuickAskWindow(cursor.x, cursor.y); - win.webContents.send(IPC_CHANNELS.QUICK_ASK_TEXT, text); + if (isExisting) { + win.webContents.send(IPC_CHANNELS.QUICK_ASK_TEXT, text); + } else { + win.webContents.once('did-finish-load', () => { + win.webContents.send(IPC_CHANNELS.QUICK_ASK_TEXT, text); + }); + } }); if (!ok) { From 4a5a28805d6ce670d01056c67a47b47c46b3a882 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 25 Mar 2026 16:08:39 +0200 Subject: [PATCH 38/65] start at /dashboard, focus cursor after clipboard text --- surfsense_desktop/src/modules/window.ts | 2 +- .../components/assistant-ui/inline-mention-editor.tsx | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/surfsense_desktop/src/modules/window.ts b/surfsense_desktop/src/modules/window.ts index 1b3f3baed..245814cad 100644 --- a/surfsense_desktop/src/modules/window.ts +++ b/surfsense_desktop/src/modules/window.ts @@ -33,7 +33,7 @@ export function createMainWindow(): BrowserWindow { mainWindow?.show(); }); - mainWindow.loadURL(`http://localhost:${getServerPort()}/login`); + mainWindow.loadURL(`http://localhost:${getServerPort()}/dashboard`); mainWindow.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith('http://localhost')) { diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index ab1213a49..ae490cdd0 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -119,9 +119,16 @@ export const InlineMentionEditor = forwardRef { if (!initialText || !editorRef.current) return; - editorRef.current.textContent = initialText; + editorRef.current.textContent = initialText + "\n"; setIsEmpty(false); onChange?.(initialText, Array.from(mentionedDocs.values())); + editorRef.current.focus(); + const sel = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(editorRef.current); + range.collapse(false); + sel?.removeAllRanges(); + sel?.addRange(range); }, [initialText]); // eslint-disable-line react-hooks/exhaustive-deps // Focus at the end of the editor From 227fb014d4695908768687e8cec573dea6b05c89 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 25 Mar 2026 12:32:24 +0800 Subject: [PATCH 39/65] fix: add noopener to window.open call in AnnouncementToastProvider Closes #939 --- .../components/announcements/AnnouncementToastProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_web/components/announcements/AnnouncementToastProvider.tsx b/surfsense_web/components/announcements/AnnouncementToastProvider.tsx index 3ae6bf233..6cb1b17e5 100644 --- a/surfsense_web/components/announcements/AnnouncementToastProvider.tsx +++ b/surfsense_web/components/announcements/AnnouncementToastProvider.tsx @@ -34,7 +34,7 @@ function showAnnouncementToast(announcement: Announcement) { label: announcement.link.label, onClick: () => { if (announcement.link?.url.startsWith("http")) { - window.open(announcement.link.url, "_blank"); + window.open(announcement.link.url, "_blank", "noopener,noreferrer"); } else if (announcement.link?.url) { window.location.href = announcement.link.url; } From 2a7b50408f5219003c4e6469c9e38bc174f369d9 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 25 Mar 2026 12:32:56 +0800 Subject: [PATCH 40/65] fix: add missing type dependency in DocumentTypeChip truncation check Closes #946 --- .../documents/(manage)/components/DocumentTypeIcon.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx index c07f34935..25eeb4cab 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx @@ -63,7 +63,7 @@ export function DocumentTypeChip({ type, className }: { type: string; className? checkTruncation(); window.addEventListener("resize", checkTruncation); return () => window.removeEventListener("resize", checkTruncation); - }, []); + }, [type]); const chip = ( Date: Wed, 25 Mar 2026 16:58:46 +0800 Subject: [PATCH 41/65] fix: avoid stale event reference in register page retry action Extract submission logic into submitForm() so the retry toast action does not capture the original SyntheticEvent, which may be recycled by React by the time the user clicks retry. Closes #945 --- surfsense_web/app/(home)/register/page.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx index 35fa2b668..96fab2c6a 100644 --- a/surfsense_web/app/(home)/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -43,9 +43,12 @@ export default function RegisterPage() { } }, [router]); - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + submitForm(); + }; + const submitForm = async () => { // Form validation if (password !== confirmPassword) { setError({ title: t("password_mismatch"), message: t("passwords_no_match_desc") }); @@ -140,7 +143,7 @@ export default function RegisterPage() { if (shouldRetry(errorCode)) { toastOptions.action = { label: tCommon("retry"), - onClick: () => handleSubmit(e), + onClick: () => submitForm(), }; } From 97e7e73baf76340c79a47522c2b11f3983aae78a Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 25 Mar 2026 16:55:26 +0800 Subject: [PATCH 42/65] fix: remove unnecessary useEffect + useState for AUTH_TYPE constant AUTH_TYPE is a static module-level import that never changes. No need for useState + useEffect; use the constant directly. Closes #941 --- surfsense_web/app/(home)/login/LocalLoginForm.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/surfsense_web/app/(home)/login/LocalLoginForm.tsx b/surfsense_web/app/(home)/login/LocalLoginForm.tsx index 9481976a9..7c85eedbd 100644 --- a/surfsense_web/app/(home)/login/LocalLoginForm.tsx +++ b/surfsense_web/app/(home)/login/LocalLoginForm.tsx @@ -5,7 +5,7 @@ import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { Spinner } from "@/components/ui/spinner"; import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors"; @@ -25,15 +25,10 @@ export function LocalLoginForm() { title: null, message: null, }); - const [authType, setAuthType] = useState(null); + const authType = AUTH_TYPE; const router = useRouter(); const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom); - useEffect(() => { - // Get the auth type from centralized config - setAuthType(AUTH_TYPE); - }, []); - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError({ title: null, message: null }); // Clear any previous errors From e5cabf95e46f75854d56a5ca6eb2315ccce9752b Mon Sep 17 00:00:00 2001 From: likiosliu Date: Wed, 25 Mar 2026 12:34:30 +0800 Subject: [PATCH 43/65] fix: clean up recursive setTimeout calls in onboarding tour - Add cancelled flag to prevent state updates after unmount in checkAndStartTour retry loop - Store retry timer ID in a ref and clear it on cleanup in updateTarget effect Closes #950 --- surfsense_web/components/onboarding-tour.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/surfsense_web/components/onboarding-tour.tsx b/surfsense_web/components/onboarding-tour.tsx index 03fad87b6..114a46141 100644 --- a/surfsense_web/components/onboarding-tour.tsx +++ b/surfsense_web/components/onboarding-tour.tsx @@ -436,6 +436,7 @@ export function OnboardingTour() { const { resolvedTheme } = useTheme(); const pathname = usePathname(); const retryCountRef = useRef(0); + const retryTimerRef = useRef | null>(null); const maxRetries = 10; // Track previous user ID to detect user changes const previousUserIdRef = useRef(null); @@ -477,7 +478,7 @@ export function OnboardingTour() { retryCountRef.current = 0; } else if (retryCountRef.current < maxRetries) { retryCountRef.current++; - setTimeout(() => { + retryTimerRef.current = setTimeout(() => { const retryEl = document.querySelector(currentStep.target); if (retryEl) { setTargetEl(retryEl); @@ -487,6 +488,10 @@ export function OnboardingTour() { } }, 200); } + + return () => { + if (retryTimerRef.current) clearTimeout(retryTimerRef.current); + }; }, [currentStep]); // Check if tour should run: localStorage + data validation with user ID tracking @@ -556,7 +561,11 @@ export function OnboardingTour() { } // User is new and hasn't seen tour - wait for DOM elements and start tour + let cancelled = false; + const checkAndStartTour = () => { + if (cancelled) return; + // Check if all required elements exist const connectorEl = document.querySelector(TOUR_STEPS[0].target); const documentsEl = document.querySelector(TOUR_STEPS[1].target); @@ -578,7 +587,10 @@ export function OnboardingTour() { // Start checking after initial delay const timer = setTimeout(checkAndStartTour, 500); - return () => clearTimeout(timer); + return () => { + cancelled = true; + clearTimeout(timer); + }; }, [mounted, user?.id, searchSpaceId, pathname, threadsData, documentTypeCounts, connectors]); // Update position on resize/scroll From f3d6ae95e1dc1c731e9f36de450a4a64053bcf37 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 25 Mar 2026 16:22:32 +0200 Subject: [PATCH 44/65] fix: pull-based clipboard text and cursor at end with br --- surfsense_desktop/src/modules/quick-ask.ts | 21 +++++++++---------- surfsense_desktop/src/preload.ts | 8 +------ .../assistant-ui/inline-mention-editor.tsx | 4 +++- .../components/assistant-ui/thread.tsx | 2 +- surfsense_web/types/window.d.ts | 2 +- 5 files changed, 16 insertions(+), 21 deletions(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 8eb094812..45bfe7c04 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -1,10 +1,11 @@ -import { BrowserWindow, clipboard, globalShortcut, screen } from 'electron'; +import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen } from 'electron'; import path from 'path'; import { IPC_CHANNELS } from '../ipc/channels'; import { getServerPort } from './server'; const SHORTCUT = 'CommandOrControl+Option+S'; let quickAskWindow: BrowserWindow | null = null; +let pendingText = ''; function hideQuickAsk(): void { if (quickAskWindow && !quickAskWindow.isDestroyed()) { @@ -62,22 +63,20 @@ export function registerQuickAsk(): void { const text = clipboard.readText().trim(); if (!text) return; - const isExisting = quickAskWindow && !quickAskWindow.isDestroyed(); + pendingText = text; const cursor = screen.getCursorScreenPoint(); - const win = createQuickAskWindow(cursor.x, cursor.y); - - if (isExisting) { - win.webContents.send(IPC_CHANNELS.QUICK_ASK_TEXT, text); - } else { - win.webContents.once('did-finish-load', () => { - win.webContents.send(IPC_CHANNELS.QUICK_ASK_TEXT, text); - }); - } + createQuickAskWindow(cursor.x, cursor.y); }); if (!ok) { console.log(`Quick-ask: failed to register ${SHORTCUT}`); } + + ipcMain.handle(IPC_CHANNELS.QUICK_ASK_TEXT, () => { + const text = pendingText; + pendingText = ''; + return text; + }); } export function unregisterQuickAsk(): void { diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index ca894d6b3..9c857de1b 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -17,11 +17,5 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.removeListener(IPC_CHANNELS.DEEP_LINK, listener); }; }, - onQuickAskText: (callback: (text: string) => void) => { - const listener = (_event: unknown, text: string) => callback(text); - ipcRenderer.on(IPC_CHANNELS.QUICK_ASK_TEXT, listener); - return () => { - ipcRenderer.removeListener(IPC_CHANNELS.QUICK_ASK_TEXT, listener); - }; - }, + getQuickAskText: () => ipcRenderer.invoke(IPC_CHANNELS.QUICK_ASK_TEXT), }); diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index ae490cdd0..40bd16f8d 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -119,7 +119,9 @@ export const InlineMentionEditor = forwardRef { if (!initialText || !editorRef.current) return; - editorRef.current.textContent = initialText + "\n"; + editorRef.current.innerText = initialText; + editorRef.current.appendChild(document.createElement("br")); + editorRef.current.appendChild(document.createElement("br")); setIsEmpty(false); onChange?.(initialText, Array.from(mentionedDocs.values())); editorRef.current.focus(); diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index eb98fd025..64ec79ef2 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -332,7 +332,7 @@ const Composer: FC = () => { const [quickAskText, setQuickAskText] = useState(); useEffect(() => { - return window.electronAPI?.onQuickAskText((text) => { + window.electronAPI?.getQuickAskText().then((text) => { if (text) setQuickAskText(text); }); }, []); diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 6c7e192db..c8b4c004a 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -10,7 +10,7 @@ interface ElectronAPI { openExternal: (url: string) => void; getAppVersion: () => Promise; onDeepLink: (callback: (url: string) => void) => () => void; - onQuickAskText: (callback: (text: string) => void) => () => void; + getQuickAskText: () => Promise; } declare global { From 7cbb67f0dd88f557670b6767c966e50e42dd911c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 25 Mar 2026 16:35:23 +0200 Subject: [PATCH 45/65] scroll to cursor after inserting clipboard text --- .../components/assistant-ui/inline-mention-editor.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index 40bd16f8d..66389cade 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -119,11 +119,13 @@ export const InlineMentionEditor = forwardRef { if (!initialText || !editorRef.current) return; + // Insert the text and add trailing line breaks for typing space editorRef.current.innerText = initialText; editorRef.current.appendChild(document.createElement("br")); editorRef.current.appendChild(document.createElement("br")); setIsEmpty(false); onChange?.(initialText, Array.from(mentionedDocs.values())); + // Place cursor at the end of the content editorRef.current.focus(); const sel = window.getSelection(); const range = document.createRange(); @@ -131,6 +133,11 @@ export const InlineMentionEditor = forwardRef Date: Wed, 25 Mar 2026 14:43:11 +0000 Subject: [PATCH 46/65] fix(ui): show skeleton instead of fake star count while loading (#918) Replace the misleading 10000 placeholder with a Skeleton component during the loading state of the GitHub stars badge. This prevents users from thinking 10000 is the actual star count before real data loads. Closes #918 --- .../components/homepage/github-stars-badge.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/homepage/github-stars-badge.tsx b/surfsense_web/components/homepage/github-stars-badge.tsx index e11d6ff2d..feee8ee33 100644 --- a/surfsense_web/components/homepage/github-stars-badge.tsx +++ b/surfsense_web/components/homepage/github-stars-badge.tsx @@ -4,6 +4,7 @@ import { IconBrandGithub } from "@tabler/icons-react"; import { motion, useMotionValue, useSpring } from "motion/react"; import * as React from "react"; import { cn } from "@/lib/utils"; +import { Skeleton } from "@/components/ui/skeleton"; // --------------------------------------------------------------------------- // Per-digit scrolling wheel @@ -277,12 +278,16 @@ function NavbarGitHubStars({ )} > - + {isLoading ? ( + + ) : ( + + )} ); } From 2ae83e8b289858bc7eec23d909ebd207f017c485 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 25 Mar 2026 17:05:03 +0200 Subject: [PATCH 47/65] keep panel floating, handle window opens, disable fullscreen --- surfsense_desktop/src/modules/quick-ask.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 45bfe7c04..81b74d986 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 } from 'electron'; +import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron'; import path from 'path'; import { IPC_CHANNELS } from '../ipc/channels'; import { getServerPort } from './server'; @@ -28,6 +28,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { y, type: 'panel', resizable: true, + fullscreenable: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, @@ -44,7 +45,13 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { quickAskWindow?.show(); }); - quickAskWindow.on('blur', hideQuickAsk); + quickAskWindow.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('http://localhost')) { + return { action: 'allow' }; + } + shell.openExternal(url); + return { action: 'deny' }; + }); quickAskWindow.on('closed', () => { quickAskWindow = null; From 743172785da56dbd9a750cd380917dfea480ba0b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 25 Mar 2026 18:00:00 +0200 Subject: [PATCH 48/65] escape to hide, clamp panel to screen bounds, disable maximize --- surfsense_desktop/src/modules/quick-ask.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 81b74d986..f7753d1d6 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -13,6 +13,15 @@ function hideQuickAsk(): void { } } +function clampToScreen(x: number, y: number, w: number, h: number): { x: number; y: number } { + const display = screen.getDisplayNearestPoint({ x, y }); + const { x: dx, y: dy, width: dw, height: dh } = display.workArea; + return { + x: Math.max(dx, Math.min(x, dx + dw - w)), + y: Math.max(dy, Math.min(y, dy + dh - h)), + }; +} + function createQuickAskWindow(x: number, y: number): BrowserWindow { if (quickAskWindow && !quickAskWindow.isDestroyed()) { quickAskWindow.setPosition(x, y); @@ -29,6 +38,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { type: 'panel', resizable: true, fullscreenable: false, + maximizable: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, @@ -45,6 +55,10 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { quickAskWindow?.show(); }); + quickAskWindow.webContents.on('before-input-event', (_event, input) => { + if (input.key === 'Escape') hideQuickAsk(); + }); + quickAskWindow.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith('http://localhost')) { return { action: 'allow' }; @@ -72,7 +86,8 @@ export function registerQuickAsk(): void { pendingText = text; const cursor = screen.getCursorScreenPoint(); - createQuickAskWindow(cursor.x, cursor.y); + const pos = clampToScreen(cursor.x, cursor.y, 450, 550); + createQuickAskWindow(pos.x, pos.y); }); if (!ok) { From 2af4784e63a7193946a991369f16e6c06a446c59 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 25 Mar 2026 18:26:28 +0200 Subject: [PATCH 49/65] cross-platform panel: toolbar fallback for Windows/Linux --- 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 f7753d1d6..9009099a3 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -35,7 +35,9 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { height: 550, x, y, - type: 'panel', + ...(process.platform === 'darwin' + ? { type: 'panel' as const } + : { type: 'toolbar' as const, alwaysOnTop: true }), resizable: true, fullscreenable: false, maximizable: false, From f7640671f3dfe96e0432ba8c2df88a38bb9fd6ba Mon Sep 17 00:00:00 2001 From: likiosliu Date: Thu, 26 Mar 2026 11:49:45 +0800 Subject: [PATCH 50/65] fix: replace router.push with Link for static navigation in UserDropdown Enables route prefetching and follows Next.js best practices. Removes unused useRouter import. --- surfsense_web/components/UserDropdown.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/surfsense_web/components/UserDropdown.tsx b/surfsense_web/components/UserDropdown.tsx index b79ab6e79..197db6287 100644 --- a/surfsense_web/components/UserDropdown.tsx +++ b/surfsense_web/components/UserDropdown.tsx @@ -1,7 +1,7 @@ "use client"; import { BadgeCheck, LogOut } from "lucide-react"; -import { useRouter } from "next/navigation"; +import Link from "next/link"; import { useState } from "react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; @@ -27,7 +27,6 @@ export function UserDropdown({ avatar: string; }; }) { - const router = useRouter(); const [isLoggingOut, setIsLoggingOut] = useState(false); const handleLogout = async () => { @@ -75,12 +74,11 @@ export function UserDropdown({ - router.push(`/dashboard/api-key`)} - className="text-xs md:text-sm" - > - - API Key + + + + API Key + From 3d762ccf6216bac059079b513c63472cfd19c861 Mon Sep 17 00:00:00 2001 From: likiosliu Date: Thu, 26 Mar 2026 11:50:39 +0800 Subject: [PATCH 51/65] fix: remove unnecessary "use client" from pure presentational components These components only render JSX with props and don't use hooks, event handlers, or browser APIs. --- surfsense_web/app/docs/sidebar-separator.tsx | 2 -- surfsense_web/components/Logo.tsx | 2 -- .../components/announcements/AnnouncementsEmptyState.tsx | 2 -- .../public-chat-snapshots/public-chat-snapshots-empty-state.tsx | 2 -- 4 files changed, 8 deletions(-) diff --git a/surfsense_web/app/docs/sidebar-separator.tsx b/surfsense_web/app/docs/sidebar-separator.tsx index 36fff09a4..ceb56b160 100644 --- a/surfsense_web/app/docs/sidebar-separator.tsx +++ b/surfsense_web/app/docs/sidebar-separator.tsx @@ -1,5 +1,3 @@ -"use client"; - import type { Separator } from "fumadocs-core/page-tree"; export function SidebarSeparator({ item }: { item: Separator }) { diff --git a/surfsense_web/components/Logo.tsx b/surfsense_web/components/Logo.tsx index 76446ca59..121185757 100644 --- a/surfsense_web/components/Logo.tsx +++ b/surfsense_web/components/Logo.tsx @@ -1,5 +1,3 @@ -"use client"; - import Image from "next/image"; import Link from "next/link"; import { cn } from "@/lib/utils"; diff --git a/surfsense_web/components/announcements/AnnouncementsEmptyState.tsx b/surfsense_web/components/announcements/AnnouncementsEmptyState.tsx index b4551f56a..9ed1ea45d 100644 --- a/surfsense_web/components/announcements/AnnouncementsEmptyState.tsx +++ b/surfsense_web/components/announcements/AnnouncementsEmptyState.tsx @@ -1,5 +1,3 @@ -"use client"; - import { BellOff } from "lucide-react"; export function AnnouncementsEmptyState() { diff --git a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx index 4bb295217..4a4a57770 100644 --- a/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx +++ b/surfsense_web/components/public-chat-snapshots/public-chat-snapshots-empty-state.tsx @@ -1,5 +1,3 @@ -"use client"; - import { Link2Off } from "lucide-react"; interface PublicChatSnapshotsEmptyStateProps { From 2cf6866c10e7e7219ffcf205b33744972dbed866 Mon Sep 17 00:00:00 2001 From: JoeMakuta Date: Thu, 26 Mar 2026 11:59:04 +0200 Subject: [PATCH 52/65] Add loader on new chat route --- .../new-chat/[[...chat_id]]/page.tsx | 38 ++-------------- .../[search_space_id]/new-chat/loading.tsx | 45 +++++++++++++++++++ 2 files changed, 48 insertions(+), 35 deletions(-) create mode 100644 surfsense_web/app/dashboard/[search_space_id]/new-chat/loading.tsx 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..1cbfca2df 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 @@ -74,6 +74,7 @@ import { trackChatMessageSent, trackChatResponseReceived, } from "@/lib/posthog/events"; +import Loading from "../loading"; /** * After a tool produces output, mark any previously-decided interrupt tool @@ -1527,40 +1528,7 @@ export default function NewChatPage() { // Show loading state only when loading an existing thread if (isInitializing) { return ( -
    -
    - {/* User message */} -
    - -
    - - {/* Assistant message */} -
    - - - -
    - - {/* User message */} -
    - -
    - - {/* Assistant message */} -
    - - - -
    -
    - - {/* Input bar */} -
    -
    - -
    -
    -
    + ); } @@ -1597,4 +1565,4 @@ export default function NewChatPage() {
); -} +} \ No newline at end of file diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/loading.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/loading.tsx new file mode 100644 index 000000000..1f47fb95a --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/loading.tsx @@ -0,0 +1,45 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+ {/* User message */} +
+ +
+ + {/* Assistant message */} +
+ + + +
+ + {/* User message */} +
+ +
+ + {/* Assistant message */} +
+ + + +
+ + {/* User message */} +
+ +
+
+ + {/* Input bar */} +
+
+ +
+
+
+ ); +} From 80ede9849ab5feed4c0cb3be0935422315811d1f Mon Sep 17 00:00:00 2001 From: JoeMakuta Date: Thu, 26 Mar 2026 12:19:18 +0200 Subject: [PATCH 53/65] Add loading od logs route --- .../[search_space_id]/logs/loading.tsx | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 surfsense_web/app/dashboard/[search_space_id]/logs/loading.tsx diff --git a/surfsense_web/app/dashboard/[search_space_id]/logs/loading.tsx b/surfsense_web/app/dashboard/[search_space_id]/logs/loading.tsx new file mode 100644 index 000000000..318c2836b --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/logs/loading.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { motion } from "motion/react"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( + + {/* Summary Dashboard Skeleton */} + + {[...Array(4)].map((_, i) => ( +
+
+ + +
+
+ + +
+
+ ))} +
+ + {/* Header Section Skeleton */} + +
+ + +
+ +
+ + {/* Filters Skeleton */} + +
+ + + + +
+
+ + {/* Table Skeleton */} + + {/* Table Header */} +
+ + + + + + + +
+ + {/* Table Rows */} + {[...Array(6)].map((_, i) => ( +
+ + + +
+ + +
+
+ + +
+
+ + +
+ +
+ ))} +
+ + {/* Pagination Skeleton */} +
+ + + + + + + + + +
+ + + + +
+
+
+ ); +} From d535851ad51ad574fd99664ec553c13786b0e5b5 Mon Sep 17 00:00:00 2001 From: JoeMakuta Date: Thu, 26 Mar 2026 12:44:46 +0200 Subject: [PATCH 54/65] Add loader to more-pages route --- .../dashboard/[search_space_id]/more-pages/loading.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 surfsense_web/app/dashboard/[search_space_id]/more-pages/loading.tsx diff --git a/surfsense_web/app/dashboard/[search_space_id]/more-pages/loading.tsx b/surfsense_web/app/dashboard/[search_space_id]/more-pages/loading.tsx new file mode 100644 index 000000000..9a0c45f3f --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/more-pages/loading.tsx @@ -0,0 +1,10 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+ + +
+ ); +} From e4d5c119ef6879aa9a58ef59140b36e00695b8f1 Mon Sep 17 00:00:00 2001 From: JoeMakuta Date: Thu, 26 Mar 2026 13:33:29 +0200 Subject: [PATCH 55/65] fix: convert public chat page to server component --- surfsense_web/app/public/[token]/page.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/surfsense_web/app/public/[token]/page.tsx b/surfsense_web/app/public/[token]/page.tsx index 530664ac6..10cd19732 100644 --- a/surfsense_web/app/public/[token]/page.tsx +++ b/surfsense_web/app/public/[token]/page.tsx @@ -1,11 +1,11 @@ -"use client"; - -import { useParams } from "next/navigation"; import { PublicChatView } from "@/components/public-chat/public-chat-view"; -export default function PublicChatPage() { - const params = useParams(); - const token = params.token as string; +export default async function PublicChatPage({ + params, +}: { + params: Promise<{ token: string }>; +}) { + const { token } = await params; - return ; + return ; } From f00f7826ed09c94d32ee85fc75cd101946dec133 Mon Sep 17 00:00:00 2001 From: JoeMakuta Date: Thu, 26 Mar 2026 15:11:39 +0200 Subject: [PATCH 56/65] fix: improve semantics and structure of settings forms in GeneralSettingsManager and PromptConfigManager --- .../settings/general-settings-manager.tsx | 300 ++++++++------- .../settings/prompt-config-manager.tsx | 344 ++++++++++-------- 2 files changed, 350 insertions(+), 294 deletions(-) diff --git a/surfsense_web/components/settings/general-settings-manager.tsx b/surfsense_web/components/settings/general-settings-manager.tsx index a9482001d..8a847b629 100644 --- a/surfsense_web/components/settings/general-settings-manager.tsx +++ b/surfsense_web/components/settings/general-settings-manager.tsx @@ -9,160 +9,190 @@ import { toast } from "sonner"; import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { Spinner } from "../ui/spinner"; interface GeneralSettingsManagerProps { - searchSpaceId: number; + searchSpaceId: number; } -export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManagerProps) { - const t = useTranslations("searchSpaceSettings"); - const tCommon = useTranslations("common"); - const { - data: searchSpace, - isLoading: loading, - refetch: fetchSearchSpace, - } = useQuery({ - queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()), - queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }), - enabled: !!searchSpaceId, - }); +export function GeneralSettingsManager({ + searchSpaceId, +}: GeneralSettingsManagerProps) { + const t = useTranslations("searchSpaceSettings"); + const tCommon = useTranslations("common"); + const { + data: searchSpace, + isLoading: loading, + refetch: fetchSearchSpace, + } = useQuery({ + queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()), + queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }), + enabled: !!searchSpaceId, + }); - const { mutateAsync: updateSearchSpace } = useAtomValue(updateSearchSpaceMutationAtom); + const { mutateAsync: updateSearchSpace } = useAtomValue( + updateSearchSpaceMutationAtom, + ); - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const [saving, setSaving] = useState(false); - const [hasChanges, setHasChanges] = useState(false); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [saving, setSaving] = useState(false); + const [hasChanges, setHasChanges] = useState(false); - // Initialize state from fetched search space - useEffect(() => { - if (searchSpace) { - setName(searchSpace.name || ""); - setDescription(searchSpace.description || ""); - setHasChanges(false); - } - }, [searchSpace]); + // Initialize state from fetched search space + useEffect(() => { + if (searchSpace) { + setName(searchSpace.name || ""); + setDescription(searchSpace.description || ""); + setHasChanges(false); + } + }, [searchSpace]); - // Track changes - useEffect(() => { - if (searchSpace) { - const currentName = searchSpace.name || ""; - const currentDescription = searchSpace.description || ""; - const changed = currentName !== name || currentDescription !== description; - setHasChanges(changed); - } - }, [searchSpace, name, description]); + // Track changes + useEffect(() => { + if (searchSpace) { + const currentName = searchSpace.name || ""; + const currentDescription = searchSpace.description || ""; + const changed = + currentName !== name || currentDescription !== description; + setHasChanges(changed); + } + }, [searchSpace, name, description]); - const handleSave = async () => { - try { - setSaving(true); + const handleSave = async () => { + try { + setSaving(true); - await updateSearchSpace({ - id: searchSpaceId, - data: { - name: name.trim(), - description: description.trim() || undefined, - }, - }); + await updateSearchSpace({ + id: searchSpaceId, + data: { + name: name.trim(), + description: description.trim() || undefined, + }, + }); - setHasChanges(false); - await fetchSearchSpace(); - } catch (error: any) { - console.error("Error saving search space details:", error); - toast.error(error.message || "Failed to save search space details"); - } finally { - setSaving(false); - } - }; + setHasChanges(false); + await fetchSearchSpace(); + } catch (error: any) { + console.error("Error saving search space details:", error); + toast.error(error.message || "Failed to save search space details"); + } finally { + setSaving(false); + } + }; - if (loading) { - return ( -
- - - - - - - - - - -
- ); - } + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + handleSave(); + }; - return ( -
- - - - Update your search space name and description. These details help identify and organize - your workspace. - - + if (loading) { + return ( +
+ + + + + + + + + + +
+ ); + } - {/* Search Space Details Card */} - - - Search Space Details - - Manage the basic information for this search space. - - - -
- - setName(e.target.value)} - className="text-sm md:text-base h-9 md:h-10" - /> -

- {t("general_name_description")} -

-
+ return ( +
+ + + + Update your search space name and description. These details help + identify and organize your workspace. + + -
- - setDescription(e.target.value)} - className="text-sm md:text-base h-9 md:h-10" - /> -

- {t("general_description_description")} -

-
- - + {/* Search Space Details Card */} +
+ + + + Search Space Details + + + Manage the basic information for this search space. + + + +
+ + setName(e.target.value)} + className="text-sm md:text-base h-9 md:h-10" + /> +

+ {t("general_name_description")} +

+
- {/* Action Buttons */} -
- -
-
- ); +
+ + setDescription(e.target.value)} + className="text-sm md:text-base h-9 md:h-10" + /> +

+ {t("general_description_description")} +

+
+
+
+ + {/* Action Buttons */} +
+ +
+ +
+ ); } diff --git a/surfsense_web/components/settings/prompt-config-manager.tsx b/surfsense_web/components/settings/prompt-config-manager.tsx index b9c9c2fc8..dc3a15a7d 100644 --- a/surfsense_web/components/settings/prompt-config-manager.tsx +++ b/surfsense_web/components/settings/prompt-config-manager.tsx @@ -6,187 +6,213 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; import { Textarea } from "@/components/ui/textarea"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { Spinner } from "../ui/spinner"; interface PromptConfigManagerProps { - searchSpaceId: number; + searchSpaceId: number; } -export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) { - const { - data: searchSpace, - isLoading: loading, - refetch: fetchSearchSpace, - } = useQuery({ - queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()), - queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }), - enabled: !!searchSpaceId, - }); +export function PromptConfigManager({ + searchSpaceId, +}: PromptConfigManagerProps) { + const { + data: searchSpace, + isLoading: loading, + refetch: fetchSearchSpace, + } = useQuery({ + queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()), + queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }), + enabled: !!searchSpaceId, + }); - const [customInstructions, setCustomInstructions] = useState(""); - const [saving, setSaving] = useState(false); - const [hasChanges, setHasChanges] = useState(false); + const [customInstructions, setCustomInstructions] = useState(""); + const [saving, setSaving] = useState(false); + const [hasChanges, setHasChanges] = useState(false); - // Initialize state from fetched search space - useEffect(() => { - if (searchSpace) { - setCustomInstructions(searchSpace.qna_custom_instructions || ""); - setHasChanges(false); - } - }, [searchSpace]); + // Initialize state from fetched search space + useEffect(() => { + if (searchSpace) { + setCustomInstructions(searchSpace.qna_custom_instructions || ""); + setHasChanges(false); + } + }, [searchSpace]); - // Track changes - useEffect(() => { - if (searchSpace) { - const currentCustom = searchSpace.qna_custom_instructions || ""; - const changed = currentCustom !== customInstructions; - setHasChanges(changed); - } - }, [searchSpace, customInstructions]); + // Track changes + useEffect(() => { + if (searchSpace) { + const currentCustom = searchSpace.qna_custom_instructions || ""; + const changed = currentCustom !== customInstructions; + setHasChanges(changed); + } + }, [searchSpace, customInstructions]); - const handleSave = async () => { - try { - setSaving(true); + const handleSave = async () => { + try { + setSaving(true); - const payload = { - qna_custom_instructions: customInstructions.trim() || "", - }; + const payload = { + qna_custom_instructions: customInstructions.trim() || "", + }; - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - } - ); + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to save system instructions"); - } + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.detail || "Failed to save system instructions", + ); + } - toast.success("System instructions saved successfully"); - setHasChanges(false); - await fetchSearchSpace(); - } catch (error: any) { - console.error("Error saving system instructions:", error); - toast.error(error.message || "Failed to save system instructions"); - } finally { - setSaving(false); - } - }; + toast.success("System instructions saved successfully"); + setHasChanges(false); + await fetchSearchSpace(); + } catch (error: any) { + console.error("Error saving system instructions:", error); + toast.error(error.message || "Failed to save system instructions"); + } finally { + setSaving(false); + } + }; - if (loading) { - return ( -
- - - - - - - - - - -
- ); - } + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + handleSave(); + }; - return ( -
- {/* Work in Progress Notice */} - - - - Work in Progress: This functionality is currently - under development and not yet connected to the backend. Your instructions will be saved - but won't affect AI behavior until the feature is fully implemented. - - + if (loading) { + return ( +
+ + + + + + + + + + +
+ ); + } - - - - System instructions apply to all AI interactions in this search space. They guide how the - AI responds, its tone, focus areas, and behavior patterns. - - + return ( +
+ {/* Work in Progress Notice */} + + + + Work in Progress: This + functionality is currently under development and not yet connected to + the backend. Your instructions will be saved but won't affect AI + behavior until the feature is fully implemented. + + - {/* System Instructions Card */} - - - Custom System Instructions - - Provide specific guidelines for how you want the AI to respond. These instructions will - be applied to all answers in this search space. - - - -
- -