From 7c6e52a0a50e4ec8fe0a920f4d37e0df2eae539b Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Tue, 7 Apr 2026 04:45:48 -0700 Subject: [PATCH] feat: add active search space management to Electron API and UI - Introduced IPC channels for getting and setting the active search space, enhancing user experience across the application. - Updated the preload script to expose new API methods for active search space management. - Modified the main window and quick ask functionalities to sync the active search space based on user navigation. - Enhanced the desktop and web applications to allow users to select and manage their default search space seamlessly. - Implemented automatic synchronization of the active search space during login and navigation events. --- surfsense_desktop/src/ipc/channels.ts | 3 + surfsense_desktop/src/ipc/handlers.ts | 7 ++ .../src/modules/active-search-space.ts | 24 ++++++ .../src/modules/autocomplete/index.ts | 19 +---- surfsense_desktop/src/modules/quick-ask.ts | 9 +- surfsense_desktop/src/modules/window.ts | 11 +++ surfsense_desktop/src/preload.ts | 5 ++ .../[search_space_id]/client-layout.tsx | 14 ++- .../components/DesktopContent.tsx | 53 +++++++++++- surfsense_web/app/desktop/login/page.tsx | 18 ++++ surfsense_web/components/TokenHandler.tsx | 85 ++++++++++--------- surfsense_web/types/window.d.ts | 3 + 12 files changed, 189 insertions(+), 62 deletions(-) create mode 100644 surfsense_desktop/src/modules/active-search-space.ts diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 8051703fb..39e75f046 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -38,4 +38,7 @@ export const IPC_CHANNELS = { // Keyboard shortcut configuration GET_SHORTCUTS: 'shortcuts:get', SET_SHORTCUTS: 'shortcuts:set', + // Active search space + GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active', + SET_ACTIVE_SEARCH_SPACE: 'search-space:set-active', } as const; diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index a583e5afc..200fa75bd 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -21,6 +21,7 @@ import { readLocalFiles, } from '../modules/folder-watcher'; import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts'; +import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/active-search-space'; import { reregisterQuickAsk } from '../modules/quick-ask'; import { reregisterAutocomplete } from '../modules/autocomplete'; import { reregisterGeneralAssist } from '../modules/tray'; @@ -106,6 +107,12 @@ export function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.GET_SHORTCUTS, () => getShortcuts()); + ipcMain.handle(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE, () => getActiveSearchSpaceId()); + + ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, (_event, id: string) => + setActiveSearchSpaceId(id) + ); + ipcMain.handle(IPC_CHANNELS.SET_SHORTCUTS, async (_event, config: Partial) => { const updated = await setShortcuts(config); if (config.generalAssist) await reregisterGeneralAssist(); diff --git a/surfsense_desktop/src/modules/active-search-space.ts b/surfsense_desktop/src/modules/active-search-space.ts new file mode 100644 index 000000000..e5f55c8f4 --- /dev/null +++ b/surfsense_desktop/src/modules/active-search-space.ts @@ -0,0 +1,24 @@ +const STORE_KEY = 'activeSearchSpaceId'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let store: any = null; + +async function getStore() { + if (!store) { + const { default: Store } = await import('electron-store'); + store = new Store({ + name: 'active-search-space', + defaults: { [STORE_KEY]: null as string | null }, + }); + } + return store; +} + +export async function getActiveSearchSpaceId(): Promise { + const s = await getStore(); + return (s.get(STORE_KEY) as string | null) ?? null; +} + +export async function setActiveSearchSpaceId(id: string): Promise { + const s = await getStore(); + s.set(STORE_KEY, id); +} diff --git a/surfsense_desktop/src/modules/autocomplete/index.ts b/surfsense_desktop/src/modules/autocomplete/index.ts index 1b64396b0..cb09a42e1 100644 --- a/surfsense_desktop/src/modules/autocomplete/index.ts +++ b/surfsense_desktop/src/modules/autocomplete/index.ts @@ -2,16 +2,15 @@ import { clipboard, globalShortcut, ipcMain, screen } from 'electron'; import { IPC_CHANNELS } from '../../ipc/channels'; import { getFrontmostApp, getWindowTitle, hasAccessibilityPermission, simulatePaste } from '../platform'; import { hasScreenRecordingPermission, requestAccessibility, requestScreenRecording } from '../permissions'; -import { getMainWindow } from '../window'; import { captureScreen } from './screenshot'; import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window'; import { getShortcuts } from '../shortcuts'; +import { getActiveSearchSpaceId } from '../active-search-space'; let currentShortcut = ''; let autocompleteEnabled = true; let savedClipboard = ''; let sourceApp = ''; -let lastSearchSpaceId: string | null = null; function isSurfSenseWindow(): boolean { const app = getFrontmostApp(); @@ -37,21 +36,11 @@ async function triggerAutocomplete(): Promise { return; } - const mainWin = getMainWindow(); - if (mainWin && !mainWin.isDestroyed()) { - const mainUrl = mainWin.webContents.getURL(); - const match = mainUrl.match(/\/dashboard\/(\d+)/); - if (match) { - lastSearchSpaceId = match[1]; - } - } - - if (!lastSearchSpaceId) { - console.warn('[autocomplete] No active search space. Open a search space first.'); + const searchSpaceId = await getActiveSearchSpaceId(); + if (!searchSpaceId) { + console.warn('[autocomplete] No active search space. Select a search space first.'); return; } - - const searchSpaceId = lastSearchSpaceId; const cursor = screen.getCursorScreenPoint(); const win = createSuggestionWindow(cursor.x, cursor.y); diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 224444be6..d5a2a9c2e 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -4,11 +4,13 @@ import { IPC_CHANNELS } from '../ipc/channels'; import { checkAccessibilityPermission, getFrontmostApp, simulateCopy, simulatePaste } from './platform'; import { getServerPort } from './server'; import { getShortcuts } from './shortcuts'; +import { getActiveSearchSpaceId } from './active-search-space'; let currentShortcut = ''; let quickAskWindow: BrowserWindow | null = null; let pendingText = ''; let pendingMode = ''; +let pendingSearchSpaceId: string | null = null; let sourceApp = ''; let savedClipboard = ''; @@ -53,7 +55,9 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { skipTaskbar: true, }); - quickAskWindow.loadURL(`http://localhost:${getServerPort()}/dashboard`); + const spaceId = pendingSearchSpaceId; + const route = spaceId ? `/dashboard/${spaceId}/new-chat` : '/dashboard'; + quickAskWindow.loadURL(`http://localhost:${getServerPort()}${route}`); quickAskWindow.once('ready-to-show', () => { quickAskWindow?.show(); @@ -78,8 +82,9 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { return quickAskWindow; } -function openQuickAsk(text: string): void { +async function openQuickAsk(text: string): Promise { pendingText = text; + pendingSearchSpaceId = await getActiveSearchSpaceId(); const cursor = screen.getCursorScreenPoint(); const pos = clampToScreen(cursor.x, cursor.y, 450, 750); createQuickAskWindow(pos.x, pos.y); diff --git a/surfsense_desktop/src/modules/window.ts b/surfsense_desktop/src/modules/window.ts index 7a77773d8..9cd216501 100644 --- a/surfsense_desktop/src/modules/window.ts +++ b/surfsense_desktop/src/modules/window.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, shell, session } from 'electron'; import path from 'path'; import { showErrorDialog } from './errors'; import { getServerPort } from './server'; +import { setActiveSearchSpaceId } from './active-search-space'; const isDev = !app.isPackaged; const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string; @@ -55,6 +56,16 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { showErrorDialog('Page failed to load', new Error(`${errorDescription} (${errorCode})\n${validatedURL}`)); }); + // Auto-sync active search space from URL navigation + const syncSearchSpace = (url: string) => { + const match = url.match(/\/dashboard\/(\d+)/); + if (match) { + setActiveSearchSpaceId(match[1]); + } + }; + mainWindow.webContents.on('did-navigate', (_event, url) => syncSearchSpace(url)); + mainWindow.webContents.on('did-navigate-in-page', (_event, url) => syncSearchSpace(url)); + if (isDev) { mainWindow.webContents.openDevTools(); } diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index 58ddd745e..4d9537c91 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -78,4 +78,9 @@ contextBridge.exposeInMainWorld('electronAPI', { getShortcuts: () => ipcRenderer.invoke(IPC_CHANNELS.GET_SHORTCUTS), setShortcuts: (config: Record) => ipcRenderer.invoke(IPC_CHANNELS.SET_SHORTCUTS, config), + + // Active search space + getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE), + setActiveSearchSpace: (id: string) => + ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, id), }); diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index 60b8aef12..16af9ac6b 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -19,6 +19,7 @@ import { OnboardingTour } from "@/components/onboarding-tour"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { useFolderSync } from "@/hooks/use-folder-sync"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; +import { useElectronAPI } from "@/hooks/use-platform"; export function DashboardClientLayout({ children, @@ -139,6 +140,8 @@ export function DashboardClientLayout({ refetchPreferences, ]); + const electronAPI = useElectronAPI(); + useEffect(() => { const activeSeacrhSpaceId = typeof search_space_id === "string" @@ -148,7 +151,16 @@ export function DashboardClientLayout({ : ""; if (!activeSeacrhSpaceId) return; setActiveSearchSpaceIdState(activeSeacrhSpaceId); - }, [search_space_id, setActiveSearchSpaceIdState]); + + // Sync to Electron store if stored value is null (first navigation) + if (electronAPI?.setActiveSearchSpace) { + electronAPI.getActiveSearchSpace?.().then((stored) => { + if (!stored) { + electronAPI.setActiveSearchSpace!(activeSeacrhSpaceId); + } + }).catch(() => {}); + } + }, [search_space_id, setActiveSearchSpaceIdState, electronAPI]); // Determine if we should show loading const shouldShowLoading = diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx index 5ecea6708..596ed3e8b 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx @@ -1,14 +1,17 @@ "use client"; import { BrainCog, Rocket, Zap } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import { useElectronAPI } from "@/hooks/use-platform"; +import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; +import type { SearchSpace } from "@/contracts/types/search-space.types"; export function DesktopContent() { const api = useElectronAPI(); @@ -18,6 +21,9 @@ export function DesktopContent() { const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS); const [shortcutsLoaded, setShortcutsLoaded] = useState(false); + const [searchSpaces, setSearchSpaces] = useState([]); + const [activeSpaceId, setActiveSpaceId] = useState(null); + useEffect(() => { if (!api) { setLoading(false); @@ -27,11 +33,18 @@ export function DesktopContent() { let mounted = true; - Promise.all([api.getAutocompleteEnabled(), api.getShortcuts?.() ?? Promise.resolve(null)]) - .then(([autoEnabled, config]) => { + Promise.all([ + api.getAutocompleteEnabled(), + api.getShortcuts?.() ?? Promise.resolve(null), + api.getActiveSearchSpace?.() ?? Promise.resolve(null), + searchSpacesApiService.getSearchSpaces(), + ]) + .then(([autoEnabled, config, spaceId, spaces]) => { if (!mounted) return; setEnabled(autoEnabled); if (config) setShortcuts(config); + setActiveSpaceId(spaceId); + if (spaces) setSearchSpaces(spaces); setLoading(false); setShortcutsLoaded(true); }) @@ -84,8 +97,42 @@ export function DesktopContent() { updateShortcut(key, DEFAULT_SHORTCUTS[key]); }; + const handleSearchSpaceChange = (value: string) => { + setActiveSpaceId(value); + api.setActiveSearchSpace?.(value); + toast.success("Default search space updated"); + }; + return (
+ {/* Default Search Space */} + + + Default Search Space + + Choose which search space General Assist, Quick Assist, and Extreme Assist operate against. + + + + {searchSpaces.length > 0 ? ( + + ) : ( +

No search spaces found. Create one first.

+ )} +
+
+ {/* Keyboard Shortcuts */} diff --git a/surfsense_web/app/desktop/login/page.tsx b/surfsense_web/app/desktop/login/page.tsx index 5d931b5c2..744680010 100644 --- a/surfsense_web/app/desktop/login/page.tsx +++ b/surfsense_web/app/desktop/login/page.tsx @@ -15,6 +15,8 @@ import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Spinner } from "@/components/ui/spinner"; import { useElectronAPI } from "@/hooks/use-platform"; +import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; +import { setBearerToken } from "@/lib/auth-utils"; import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config"; const isGoogleAuth = AUTH_TYPE === "GOOGLE"; @@ -71,6 +73,19 @@ export default function DesktopLoginPage() { window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`; }; + const autoSetSearchSpace = async () => { + try { + const stored = await api?.getActiveSearchSpace?.(); + if (stored) return; + const spaces = await searchSpacesApiService.getSearchSpaces(); + if (spaces?.length) { + await api?.setActiveSearchSpace?.(String(spaces[0].id)); + } + } catch { + // non-critical — dashboard-sync will catch it later + } + }; + const handleLocalLogin = async (e: React.FormEvent) => { e.preventDefault(); setLoginError(null); @@ -86,6 +101,9 @@ export default function DesktopLoginPage() { sessionStorage.setItem("login_success_tracked", "true"); } + setBearerToken(data.access_token); + await autoSetSearchSpace(); + setTimeout(() => { router.push(`/auth/callback?token=${data.access_token}`); }, 300); diff --git a/surfsense_web/components/TokenHandler.tsx b/surfsense_web/components/TokenHandler.tsx index 02a7089d9..cd585f0ca 100644 --- a/surfsense_web/components/TokenHandler.tsx +++ b/surfsense_web/components/TokenHandler.tsx @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils"; +import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { trackLoginSuccess } from "@/lib/posthog/events"; interface TokenHandlerProps { @@ -29,52 +30,54 @@ const TokenHandler = ({ useGlobalLoadingEffect(true); useEffect(() => { - // Only run on client-side if (typeof window === "undefined") return; - // Read tokens from URL at mount time — no subscription needed. - // TokenHandler only runs once after an auth redirect, so a stale read - // is impossible and useSearchParams() would add a pointless subscription. - // (Vercel Best Practice: rerender-defer-reads 5.2) - const params = new URLSearchParams(window.location.search); - const token = params.get(tokenParamName); - const refreshToken = params.get("refresh_token"); + const run = async () => { + const params = new URLSearchParams(window.location.search); + const token = params.get(tokenParamName); + const refreshToken = params.get("refresh_token"); - if (token) { - try { - // Track login success for OAuth flows (e.g., Google) - // Local login already tracks success before redirecting here - const alreadyTracked = sessionStorage.getItem("login_success_tracked"); - if (!alreadyTracked) { - // This is an OAuth flow (Google login) - track success - trackLoginSuccess("google"); + if (token) { + try { + const alreadyTracked = sessionStorage.getItem("login_success_tracked"); + if (!alreadyTracked) { + trackLoginSuccess("google"); + } + sessionStorage.removeItem("login_success_tracked"); + + localStorage.setItem(storageKey, token); + setBearerToken(token); + + if (refreshToken) { + setRefreshToken(refreshToken); + } + + // Auto-set active search space in desktop if not already set + if (window.electronAPI?.getActiveSearchSpace) { + try { + const stored = await window.electronAPI.getActiveSearchSpace(); + if (!stored) { + const spaces = await searchSpacesApiService.getSearchSpaces(); + if (spaces?.length) { + await window.electronAPI.setActiveSearchSpace?.(String(spaces[0].id)); + } + } + } catch { + // non-critical + } + } + + const savedRedirectPath = getAndClearRedirectPath(); + const finalRedirectPath = savedRedirectPath || redirectPath; + window.location.href = finalRedirectPath; + } catch (error) { + console.error("Error storing token in localStorage:", error); + window.location.href = redirectPath; } - // Clear the flag for future logins - sessionStorage.removeItem("login_success_tracked"); - - // Store access token in localStorage using both methods for compatibility - localStorage.setItem(storageKey, token); - setBearerToken(token); - - // Store refresh token if provided - if (refreshToken) { - setRefreshToken(refreshToken); - } - - // Check if there's a saved redirect path from before the auth flow - const savedRedirectPath = getAndClearRedirectPath(); - - // Use the saved path if available, otherwise use the default redirectPath - const finalRedirectPath = savedRedirectPath || redirectPath; - - // Redirect to the appropriate path - window.location.href = finalRedirectPath; - } catch (error) { - console.error("Error storing token in localStorage:", error); - // Even if there's an error, try to redirect to the default path - window.location.href = redirectPath; } - } + }; + + run(); }, [tokenParamName, storageKey, redirectPath]); // Return null - the global provider handles the loading UI diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 25077d1da..eeef51b0b 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -93,6 +93,9 @@ interface ElectronAPI { setShortcuts: ( config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }> ) => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>; + // Active search space + getActiveSearchSpace: () => Promise; + setActiveSearchSpace: (id: string) => Promise; } declare global {