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.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-04-07 04:45:48 -07:00
parent b74ac8a608
commit 7c6e52a0a5
12 changed files with 189 additions and 62 deletions

View file

@ -38,4 +38,7 @@ export const IPC_CHANNELS = {
// Keyboard shortcut configuration // Keyboard shortcut configuration
GET_SHORTCUTS: 'shortcuts:get', GET_SHORTCUTS: 'shortcuts:get',
SET_SHORTCUTS: 'shortcuts:set', 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; } as const;

View file

@ -21,6 +21,7 @@ import {
readLocalFiles, readLocalFiles,
} from '../modules/folder-watcher'; } from '../modules/folder-watcher';
import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts'; import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts';
import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/active-search-space';
import { reregisterQuickAsk } from '../modules/quick-ask'; import { reregisterQuickAsk } from '../modules/quick-ask';
import { reregisterAutocomplete } from '../modules/autocomplete'; import { reregisterAutocomplete } from '../modules/autocomplete';
import { reregisterGeneralAssist } from '../modules/tray'; 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_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<ShortcutConfig>) => { ipcMain.handle(IPC_CHANNELS.SET_SHORTCUTS, async (_event, config: Partial<ShortcutConfig>) => {
const updated = await setShortcuts(config); const updated = await setShortcuts(config);
if (config.generalAssist) await reregisterGeneralAssist(); if (config.generalAssist) await reregisterGeneralAssist();

View file

@ -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<string | null> {
const s = await getStore();
return (s.get(STORE_KEY) as string | null) ?? null;
}
export async function setActiveSearchSpaceId(id: string): Promise<void> {
const s = await getStore();
s.set(STORE_KEY, id);
}

View file

@ -2,16 +2,15 @@ import { clipboard, globalShortcut, ipcMain, screen } from 'electron';
import { IPC_CHANNELS } from '../../ipc/channels'; import { IPC_CHANNELS } from '../../ipc/channels';
import { getFrontmostApp, getWindowTitle, hasAccessibilityPermission, simulatePaste } from '../platform'; import { getFrontmostApp, getWindowTitle, hasAccessibilityPermission, simulatePaste } from '../platform';
import { hasScreenRecordingPermission, requestAccessibility, requestScreenRecording } from '../permissions'; import { hasScreenRecordingPermission, requestAccessibility, requestScreenRecording } from '../permissions';
import { getMainWindow } from '../window';
import { captureScreen } from './screenshot'; import { captureScreen } from './screenshot';
import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window'; import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window';
import { getShortcuts } from '../shortcuts'; import { getShortcuts } from '../shortcuts';
import { getActiveSearchSpaceId } from '../active-search-space';
let currentShortcut = ''; let currentShortcut = '';
let autocompleteEnabled = true; let autocompleteEnabled = true;
let savedClipboard = ''; let savedClipboard = '';
let sourceApp = ''; let sourceApp = '';
let lastSearchSpaceId: string | null = null;
function isSurfSenseWindow(): boolean { function isSurfSenseWindow(): boolean {
const app = getFrontmostApp(); const app = getFrontmostApp();
@ -37,21 +36,11 @@ async function triggerAutocomplete(): Promise<void> {
return; return;
} }
const mainWin = getMainWindow(); const searchSpaceId = await getActiveSearchSpaceId();
if (mainWin && !mainWin.isDestroyed()) { if (!searchSpaceId) {
const mainUrl = mainWin.webContents.getURL(); console.warn('[autocomplete] No active search space. Select a search space first.');
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.');
return; return;
} }
const searchSpaceId = lastSearchSpaceId;
const cursor = screen.getCursorScreenPoint(); const cursor = screen.getCursorScreenPoint();
const win = createSuggestionWindow(cursor.x, cursor.y); const win = createSuggestionWindow(cursor.x, cursor.y);

View file

@ -4,11 +4,13 @@ import { IPC_CHANNELS } from '../ipc/channels';
import { checkAccessibilityPermission, getFrontmostApp, simulateCopy, simulatePaste } from './platform'; import { checkAccessibilityPermission, getFrontmostApp, simulateCopy, simulatePaste } from './platform';
import { getServerPort } from './server'; import { getServerPort } from './server';
import { getShortcuts } from './shortcuts'; import { getShortcuts } from './shortcuts';
import { getActiveSearchSpaceId } from './active-search-space';
let currentShortcut = ''; let currentShortcut = '';
let quickAskWindow: BrowserWindow | null = null; let quickAskWindow: BrowserWindow | null = null;
let pendingText = ''; let pendingText = '';
let pendingMode = ''; let pendingMode = '';
let pendingSearchSpaceId: string | null = null;
let sourceApp = ''; let sourceApp = '';
let savedClipboard = ''; let savedClipboard = '';
@ -53,7 +55,9 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
skipTaskbar: true, 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.once('ready-to-show', () => {
quickAskWindow?.show(); quickAskWindow?.show();
@ -78,8 +82,9 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
return quickAskWindow; return quickAskWindow;
} }
function openQuickAsk(text: string): void { async function openQuickAsk(text: string): Promise<void> {
pendingText = text; pendingText = text;
pendingSearchSpaceId = await getActiveSearchSpaceId();
const cursor = screen.getCursorScreenPoint(); const cursor = screen.getCursorScreenPoint();
const pos = clampToScreen(cursor.x, cursor.y, 450, 750); const pos = clampToScreen(cursor.x, cursor.y, 450, 750);
createQuickAskWindow(pos.x, pos.y); createQuickAskWindow(pos.x, pos.y);

View file

@ -2,6 +2,7 @@ import { app, BrowserWindow, shell, session } from 'electron';
import path from 'path'; import path from 'path';
import { showErrorDialog } from './errors'; import { showErrorDialog } from './errors';
import { getServerPort } from './server'; import { getServerPort } from './server';
import { setActiveSearchSpaceId } from './active-search-space';
const isDev = !app.isPackaged; const isDev = !app.isPackaged;
const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string; 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}`)); 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) { if (isDev) {
mainWindow.webContents.openDevTools(); mainWindow.webContents.openDevTools();
} }

View file

@ -78,4 +78,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
getShortcuts: () => ipcRenderer.invoke(IPC_CHANNELS.GET_SHORTCUTS), getShortcuts: () => ipcRenderer.invoke(IPC_CHANNELS.GET_SHORTCUTS),
setShortcuts: (config: Record<string, string>) => setShortcuts: (config: Record<string, string>) =>
ipcRenderer.invoke(IPC_CHANNELS.SET_SHORTCUTS, config), 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),
}); });

View file

@ -19,6 +19,7 @@ import { OnboardingTour } from "@/components/onboarding-tour";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useFolderSync } from "@/hooks/use-folder-sync"; import { useFolderSync } from "@/hooks/use-folder-sync";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { useElectronAPI } from "@/hooks/use-platform";
export function DashboardClientLayout({ export function DashboardClientLayout({
children, children,
@ -139,6 +140,8 @@ export function DashboardClientLayout({
refetchPreferences, refetchPreferences,
]); ]);
const electronAPI = useElectronAPI();
useEffect(() => { useEffect(() => {
const activeSeacrhSpaceId = const activeSeacrhSpaceId =
typeof search_space_id === "string" typeof search_space_id === "string"
@ -148,7 +151,16 @@ export function DashboardClientLayout({
: ""; : "";
if (!activeSeacrhSpaceId) return; if (!activeSeacrhSpaceId) return;
setActiveSearchSpaceIdState(activeSeacrhSpaceId); 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 // Determine if we should show loading
const shouldShowLoading = const shouldShowLoading =

View file

@ -1,14 +1,17 @@
"use client"; "use client";
import { BrainCog, Rocket, Zap } from "lucide-react"; import { BrainCog, Rocket, Zap } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder"; import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder";
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 { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useElectronAPI } from "@/hooks/use-platform"; 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() { export function DesktopContent() {
const api = useElectronAPI(); const api = useElectronAPI();
@ -18,6 +21,9 @@ export function DesktopContent() {
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS); const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
const [shortcutsLoaded, setShortcutsLoaded] = useState(false); const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!api) { if (!api) {
setLoading(false); setLoading(false);
@ -27,11 +33,18 @@ export function DesktopContent() {
let mounted = true; let mounted = true;
Promise.all([api.getAutocompleteEnabled(), api.getShortcuts?.() ?? Promise.resolve(null)]) Promise.all([
.then(([autoEnabled, config]) => { api.getAutocompleteEnabled(),
api.getShortcuts?.() ?? Promise.resolve(null),
api.getActiveSearchSpace?.() ?? Promise.resolve(null),
searchSpacesApiService.getSearchSpaces(),
])
.then(([autoEnabled, config, spaceId, spaces]) => {
if (!mounted) return; if (!mounted) return;
setEnabled(autoEnabled); setEnabled(autoEnabled);
if (config) setShortcuts(config); if (config) setShortcuts(config);
setActiveSpaceId(spaceId);
if (spaces) setSearchSpaces(spaces);
setLoading(false); setLoading(false);
setShortcutsLoaded(true); setShortcutsLoaded(true);
}) })
@ -84,8 +97,42 @@ export function DesktopContent() {
updateShortcut(key, DEFAULT_SHORTCUTS[key]); updateShortcut(key, DEFAULT_SHORTCUTS[key]);
}; };
const handleSearchSpaceChange = (value: string) => {
setActiveSpaceId(value);
api.setActiveSearchSpace?.(value);
toast.success("Default search space updated");
};
return ( return (
<div className="space-y-4 md:space-y-6"> <div className="space-y-4 md:space-y-6">
{/* Default Search Space */}
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg">Default Search Space</CardTitle>
<CardDescription className="text-xs md:text-sm">
Choose which search space General Assist, Quick Assist, and Extreme Assist operate against.
</CardDescription>
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
{searchSpaces.length > 0 ? (
<Select value={activeSpaceId ?? undefined} onValueChange={handleSearchSpaceChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a search space" />
</SelectTrigger>
<SelectContent>
{searchSpaces.map((space) => (
<SelectItem key={space.id} value={String(space.id)}>
{space.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">No search spaces found. Create one first.</p>
)}
</CardContent>
</Card>
{/* Keyboard Shortcuts */} {/* Keyboard Shortcuts */}
<Card> <Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3"> <CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">

View file

@ -15,6 +15,8 @@ import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useElectronAPI } from "@/hooks/use-platform"; 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"; import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
const isGoogleAuth = AUTH_TYPE === "GOOGLE"; const isGoogleAuth = AUTH_TYPE === "GOOGLE";
@ -71,6 +73,19 @@ export default function DesktopLoginPage() {
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`; 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) => { const handleLocalLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoginError(null); setLoginError(null);
@ -86,6 +101,9 @@ export default function DesktopLoginPage() {
sessionStorage.setItem("login_success_tracked", "true"); sessionStorage.setItem("login_success_tracked", "true");
} }
setBearerToken(data.access_token);
await autoSetSearchSpace();
setTimeout(() => { setTimeout(() => {
router.push(`/auth/callback?token=${data.access_token}`); router.push(`/auth/callback?token=${data.access_token}`);
}, 300); }, 300);

View file

@ -3,6 +3,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils"; import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { trackLoginSuccess } from "@/lib/posthog/events"; import { trackLoginSuccess } from "@/lib/posthog/events";
interface TokenHandlerProps { interface TokenHandlerProps {
@ -29,52 +30,54 @@ const TokenHandler = ({
useGlobalLoadingEffect(true); useGlobalLoadingEffect(true);
useEffect(() => { useEffect(() => {
// Only run on client-side
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
// Read tokens from URL at mount time — no subscription needed. const run = async () => {
// TokenHandler only runs once after an auth redirect, so a stale read const params = new URLSearchParams(window.location.search);
// is impossible and useSearchParams() would add a pointless subscription. const token = params.get(tokenParamName);
// (Vercel Best Practice: rerender-defer-reads 5.2) const refreshToken = params.get("refresh_token");
const params = new URLSearchParams(window.location.search);
const token = params.get(tokenParamName);
const refreshToken = params.get("refresh_token");
if (token) { if (token) {
try { try {
// Track login success for OAuth flows (e.g., Google) const alreadyTracked = sessionStorage.getItem("login_success_tracked");
// Local login already tracks success before redirecting here if (!alreadyTracked) {
const alreadyTracked = sessionStorage.getItem("login_success_tracked"); trackLoginSuccess("google");
if (!alreadyTracked) { }
// This is an OAuth flow (Google login) - track success sessionStorage.removeItem("login_success_tracked");
trackLoginSuccess("google");
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]); }, [tokenParamName, storageKey, redirectPath]);
// Return null - the global provider handles the loading UI // Return null - the global provider handles the loading UI

View file

@ -93,6 +93,9 @@ interface ElectronAPI {
setShortcuts: ( setShortcuts: (
config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }> config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }>
) => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>; ) => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>;
// Active search space
getActiveSearchSpace: () => Promise<string | null>;
setActiveSearchSpace: (id: string) => Promise<void>;
} }
declare global { declare global {