feat: add general assist feature and enhance shortcut management

- Introduced a new "General Assist" shortcut, allowing users to open SurfSense from anywhere.
- Updated shortcut management to include the new general assist functionality in both the desktop and web applications.
- Enhanced the UI to reflect changes in shortcut labels and descriptions for better clarity.
- Improved the Electron API to support the new shortcut configuration.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-04-07 03:42:46 -07:00
parent e574b5ec4a
commit 27e9e8d873
10 changed files with 159 additions and 33 deletions

View file

@ -19,6 +19,9 @@ files:
- "!scripts" - "!scripts"
- "!release" - "!release"
extraResources: extraResources:
- from: assets/
to: assets/
filter: ["*.ico", "*.png", "*.icns"]
- from: ../surfsense_web/.next/standalone/surfsense_web/ - from: ../surfsense_web/.next/standalone/surfsense_web/
to: standalone/ to: standalone/
filter: filter:

View file

@ -23,6 +23,7 @@ import {
import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts'; import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts';
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';
let authTokens: { bearer: string; refresh: string } | null = null; let authTokens: { bearer: string; refresh: string } | null = null;
@ -107,6 +108,7 @@ export function registerIpcHandlers(): void {
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.quickAsk) await reregisterQuickAsk(); if (config.quickAsk) await reregisterQuickAsk();
if (config.autocomplete) await reregisterAutocomplete(); if (config.autocomplete) await reregisterAutocomplete();
return updated; return updated;

View file

@ -1,7 +1,9 @@
import { app, BrowserWindow } from 'electron'; import { app, BrowserWindow } from 'electron';
let isQuitting = false;
import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors';
import { startNextServer } from './modules/server'; import { startNextServer } from './modules/server';
import { createMainWindow } from './modules/window'; import { createMainWindow, getMainWindow } from './modules/window';
import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links';
import { setupAutoUpdater } from './modules/auto-updater'; import { setupAutoUpdater } from './modules/auto-updater';
import { setupMenu } from './modules/menu'; import { setupMenu } from './modules/menu';
@ -9,6 +11,7 @@ import { registerQuickAsk, unregisterQuickAsk } from './modules/quick-ask';
import { registerAutocomplete, unregisterAutocomplete } from './modules/autocomplete'; import { registerAutocomplete, unregisterAutocomplete } from './modules/autocomplete';
import { registerFolderWatcher, unregisterFolderWatcher } from './modules/folder-watcher'; import { registerFolderWatcher, unregisterFolderWatcher } from './modules/folder-watcher';
import { registerIpcHandlers } from './ipc/handlers'; import { registerIpcHandlers } from './ipc/handlers';
import { createTray, destroyTray } from './modules/tray';
registerGlobalErrorHandlers(); registerGlobalErrorHandlers();
@ -28,7 +31,18 @@ app.whenReady().then(async () => {
return; return;
} }
createMainWindow('/dashboard'); await createTray();
const win = createMainWindow('/dashboard');
// Minimize to tray instead of closing the app
win.on('close', (e) => {
if (!isQuitting) {
e.preventDefault();
win.hide();
}
});
await registerQuickAsk(); await registerQuickAsk();
await registerAutocomplete(); await registerAutocomplete();
registerFolderWatcher(); registerFolderWatcher();
@ -37,20 +51,28 @@ app.whenReady().then(async () => {
handlePendingDeepLink(); handlePendingDeepLink();
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { const mw = getMainWindow();
if (!mw || mw.isDestroyed()) {
createMainWindow('/dashboard'); createMainWindow('/dashboard');
} else {
mw.show();
mw.focus();
} }
}); });
}); });
// Keep running in the background — the tray "Quit" calls app.exit()
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') { // Do nothing: the app stays alive in the tray
app.quit(); });
}
app.on('before-quit', () => {
isQuitting = true;
}); });
app.on('will-quit', () => { app.on('will-quit', () => {
unregisterQuickAsk(); unregisterQuickAsk();
unregisterAutocomplete(); unregisterAutocomplete();
unregisterFolderWatcher(); unregisterFolderWatcher();
destroyTray();
}); });

View file

@ -114,7 +114,7 @@ async function quickAskHandler(): Promise<void> {
const text = selected || savedClipboard.trim(); const text = selected || savedClipboard.trim();
sourceApp = getFrontmostApp(); sourceApp = getFrontmostApp();
console.log('[quick-ask] Source app:', sourceApp, '| Opening Quick Ask with', text.length, 'chars', selected ? '(selected)' : text ? '(clipboard fallback)' : '(empty)'); console.log('[quick-ask] Source app:', sourceApp, '| Opening Quick Assist with', text.length, 'chars', selected ? '(selected)' : text ? '(clipboard fallback)' : '(empty)');
openQuickAsk(text); openQuickAsk(text);
} }

View file

@ -1,9 +1,11 @@
export interface ShortcutConfig { export interface ShortcutConfig {
generalAssist: string;
quickAsk: string; quickAsk: string;
autocomplete: string; autocomplete: string;
} }
const DEFAULTS: ShortcutConfig = { const DEFAULTS: ShortcutConfig = {
generalAssist: 'CommandOrControl+Shift+S',
quickAsk: 'CommandOrControl+Alt+S', quickAsk: 'CommandOrControl+Alt+S',
autocomplete: 'CommandOrControl+Shift+Space', autocomplete: 'CommandOrControl+Shift+Space',
}; };

View file

@ -0,0 +1,77 @@
import { app, globalShortcut, Menu, nativeImage, Tray } from 'electron';
import path from 'path';
import { getMainWindow, createMainWindow } from './window';
import { getShortcuts } from './shortcuts';
let tray: Tray | null = null;
let currentShortcut: string | null = null;
function getTrayIcon(): nativeImage {
const iconName = process.platform === 'win32' ? 'icon.ico' : 'icon.png';
const iconPath = app.isPackaged
? path.join(process.resourcesPath, 'assets', iconName)
: path.join(__dirname, '..', 'assets', iconName);
const img = nativeImage.createFromPath(iconPath);
return img.resize({ width: 16, height: 16 });
}
function showMainWindow(): void {
let win = getMainWindow();
if (!win || win.isDestroyed()) {
win = createMainWindow('/dashboard');
} else {
win.show();
win.focus();
}
}
function registerShortcut(accelerator: string): void {
if (currentShortcut) {
globalShortcut.unregister(currentShortcut);
currentShortcut = null;
}
if (!accelerator) return;
try {
const ok = globalShortcut.register(accelerator, showMainWindow);
if (ok) {
currentShortcut = accelerator;
} else {
console.warn(`[tray] Failed to register General Assist shortcut: ${accelerator}`);
}
} catch (err) {
console.error(`[tray] Error registering General Assist shortcut:`, err);
}
}
export async function createTray(): Promise<void> {
if (tray) return;
tray = new Tray(getTrayIcon());
tray.setToolTip('SurfSense');
const contextMenu = Menu.buildFromTemplate([
{ label: 'Open SurfSense', click: showMainWindow },
{ type: 'separator' },
{ label: 'Quit', click: () => { app.exit(0); } },
]);
tray.setContextMenu(contextMenu);
tray.on('double-click', showMainWindow);
const shortcuts = await getShortcuts();
registerShortcut(shortcuts.generalAssist);
}
export async function reregisterGeneralAssist(): Promise<void> {
const shortcuts = await getShortcuts();
registerShortcut(shortcuts.generalAssist);
}
export function destroyTray(): void {
if (currentShortcut) {
globalShortcut.unregister(currentShortcut);
currentShortcut = null;
}
tray?.destroy();
tray = null;
}

View file

@ -1,12 +1,13 @@
"use client"; "use client";
import { Clipboard, Sparkles } from "lucide-react"; import { AppWindow, Clipboard, Sparkles } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, 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 { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { useElectronAPI } from "@/hooks/use-platform"; import { useElectronAPI } from "@/hooks/use-platform";
export function DesktopContent() { export function DesktopContent() {
@ -68,7 +69,7 @@ export function DesktopContent() {
await api.setAutocompleteEnabled(checked); await api.setAutocompleteEnabled(checked);
}; };
const updateShortcut = (key: "quickAsk" | "autocomplete", accelerator: string) => { const updateShortcut = (key: "generalAssist" | "quickAsk" | "autocomplete", accelerator: string) => {
setShortcuts((prev) => { setShortcuts((prev) => {
const updated = { ...prev, [key]: accelerator }; const updated = { ...prev, [key]: accelerator };
api.setShortcuts?.({ [key]: accelerator }).catch(() => { api.setShortcuts?.({ [key]: accelerator }).catch(() => {
@ -79,7 +80,7 @@ export function DesktopContent() {
toast.success("Shortcut updated"); toast.success("Shortcut updated");
}; };
const resetShortcut = (key: "quickAsk" | "autocomplete") => { const resetShortcut = (key: "generalAssist" | "quickAsk" | "autocomplete") => {
updateShortcut(key, DEFAULT_SHORTCUTS[key]); updateShortcut(key, DEFAULT_SHORTCUTS[key]);
}; };
@ -95,23 +96,32 @@ export function DesktopContent() {
</CardHeader> </CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6"> <CardContent className="px-3 md:px-6 pb-3 md:pb-6">
{shortcutsLoaded ? ( {shortcutsLoaded ? (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<ShortcutRecorder <ShortcutRecorder
value={shortcuts.quickAsk} value={shortcuts.generalAssist}
onChange={(accel) => updateShortcut("quickAsk", accel)} onChange={(accel) => updateShortcut("generalAssist", accel)}
onReset={() => resetShortcut("quickAsk")} onReset={() => resetShortcut("generalAssist")}
defaultValue={DEFAULT_SHORTCUTS.quickAsk} defaultValue={DEFAULT_SHORTCUTS.generalAssist}
label="Quick Ask" label="General Assist"
description="Open SurfSense from anywhere"
icon={AppWindow}
/>
<ShortcutRecorder
value={shortcuts.quickAsk}
onChange={(accel) => updateShortcut("quickAsk", accel)}
onReset={() => resetShortcut("quickAsk")}
defaultValue={DEFAULT_SHORTCUTS.quickAsk}
label="Quick Assist"
description="Copy selected text and ask AI about it" description="Copy selected text and ask AI about it"
icon={Clipboard} icon={Clipboard}
/> />
<ShortcutRecorder <ShortcutRecorder
value={shortcuts.autocomplete} value={shortcuts.autocomplete}
onChange={(accel) => updateShortcut("autocomplete", accel)} onChange={(accel) => updateShortcut("autocomplete", accel)}
onReset={() => resetShortcut("autocomplete")} onReset={() => resetShortcut("autocomplete")}
defaultValue={DEFAULT_SHORTCUTS.autocomplete} defaultValue={DEFAULT_SHORTCUTS.autocomplete}
label="Autocomplete" label="Extreme Assist"
description="Get AI writing suggestions from a screenshot" description="AI writing powered by your screen and knowledge base"
icon={Sparkles} icon={Sparkles}
/> />
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
@ -126,10 +136,10 @@ export function DesktopContent() {
</CardContent> </CardContent>
</Card> </Card>
{/* Autocomplete Toggle */} {/* Extreme Assist Toggle */}
<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">
<CardTitle className="text-base md:text-lg">Autocomplete</CardTitle> <CardTitle className="text-base md:text-lg">Extreme Assist</CardTitle>
<CardDescription className="text-xs md:text-sm"> <CardDescription className="text-xs md:text-sm">
Get inline writing suggestions powered by your knowledge base as you type in any app. Get inline writing suggestions powered by your knowledge base as you type in any app.
</CardDescription> </CardDescription>
@ -138,7 +148,7 @@ export function DesktopContent() {
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label htmlFor="autocomplete-toggle" className="text-sm font-medium cursor-pointer"> <Label htmlFor="autocomplete-toggle" className="text-sm font-medium cursor-pointer">
Enable autocomplete Enable Extreme Assist
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Show suggestions while typing in other applications. Show suggestions while typing in other applications.

View file

@ -2,7 +2,7 @@
import { IconBrandGoogleFilled } from "@tabler/icons-react"; import { IconBrandGoogleFilled } from "@tabler/icons-react";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { Clipboard, Eye, EyeOff, Keyboard, Sparkles } from "lucide-react"; import { AppWindow, Clipboard, Eye, EyeOff, Keyboard, Sparkles } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
@ -48,7 +48,7 @@ export default function DesktopLoginPage() {
}, [api]); }, [api]);
const updateShortcut = useCallback( const updateShortcut = useCallback(
(key: "quickAsk" | "autocomplete", accelerator: string) => { (key: "generalAssist" | "quickAsk" | "autocomplete", accelerator: string) => {
setShortcuts((prev) => { setShortcuts((prev) => {
const updated = { ...prev, [key]: accelerator }; const updated = { ...prev, [key]: accelerator };
api?.setShortcuts?.({ [key]: accelerator }).catch(() => { api?.setShortcuts?.({ [key]: accelerator }).catch(() => {
@ -62,7 +62,7 @@ export default function DesktopLoginPage() {
); );
const resetShortcut = useCallback( const resetShortcut = useCallback(
(key: "quickAsk" | "autocomplete") => { (key: "generalAssist" | "quickAsk" | "autocomplete") => {
updateShortcut(key, DEFAULT_SHORTCUTS[key]); updateShortcut(key, DEFAULT_SHORTCUTS[key]);
}, },
[updateShortcut] [updateShortcut]
@ -132,12 +132,21 @@ export default function DesktopLoginPage() {
<Keyboard className="size-3" /> <Keyboard className="size-3" />
Keyboard Shortcuts Keyboard Shortcuts
</div> </div>
<ShortcutRecorder
value={shortcuts.generalAssist}
onChange={(accel) => updateShortcut("generalAssist", accel)}
onReset={() => resetShortcut("generalAssist")}
defaultValue={DEFAULT_SHORTCUTS.generalAssist}
label="General Assist"
description="Open SurfSense from anywhere"
icon={AppWindow}
/>
<ShortcutRecorder <ShortcutRecorder
value={shortcuts.quickAsk} value={shortcuts.quickAsk}
onChange={(accel) => updateShortcut("quickAsk", accel)} onChange={(accel) => updateShortcut("quickAsk", accel)}
onReset={() => resetShortcut("quickAsk")} onReset={() => resetShortcut("quickAsk")}
defaultValue={DEFAULT_SHORTCUTS.quickAsk} defaultValue={DEFAULT_SHORTCUTS.quickAsk}
label="Quick Ask" label="Quick Assist"
description="Copy selected text and ask AI about it" description="Copy selected text and ask AI about it"
icon={Clipboard} icon={Clipboard}
/> />
@ -146,8 +155,8 @@ export default function DesktopLoginPage() {
onChange={(accel) => updateShortcut("autocomplete", accel)} onChange={(accel) => updateShortcut("autocomplete", accel)}
onReset={() => resetShortcut("autocomplete")} onReset={() => resetShortcut("autocomplete")}
defaultValue={DEFAULT_SHORTCUTS.autocomplete} defaultValue={DEFAULT_SHORTCUTS.autocomplete}
label="Autocomplete" label="Extreme Assist"
description="Get AI writing suggestions from a screenshot" description="AI writing powered by your screen and knowledge base"
icon={Sparkles} icon={Sparkles}
/> />
<p className="text-[11px] text-muted-foreground text-center"> <p className="text-[11px] text-muted-foreground text-center">

View file

@ -36,6 +36,7 @@ export function acceleratorToDisplay(accel: string): string[] {
} }
export const DEFAULT_SHORTCUTS = { export const DEFAULT_SHORTCUTS = {
generalAssist: "CommandOrControl+Shift+S",
quickAsk: "CommandOrControl+Alt+S", quickAsk: "CommandOrControl+Alt+S",
autocomplete: "CommandOrControl+Shift+Space", autocomplete: "CommandOrControl+Shift+Space",
}; };

View file

@ -89,10 +89,10 @@ interface ElectronAPI {
getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>; getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>;
setAuthTokens: (bearer: string, refresh: string) => Promise<void>; setAuthTokens: (bearer: string, refresh: string) => Promise<void>;
// Keyboard shortcut configuration // Keyboard shortcut configuration
getShortcuts: () => Promise<{ quickAsk: string; autocomplete: string }>; getShortcuts: () => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>;
setShortcuts: ( setShortcuts: (
config: Partial<{ quickAsk: string; autocomplete: string }> config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }>
) => Promise<{ quickAsk: string; autocomplete: string }>; ) => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>;
} }
declare global { declare global {