feat: enhance keyboard shortcut management and improve app responsiveness

- Updated the development script to include a build step before launching the app.
- Refactored the registration of quick ask and autocomplete functionalities to be asynchronous, ensuring proper initialization.
- Introduced IPC channels for getting and setting keyboard shortcuts, allowing users to customize their experience.
- Enhanced the platform module to support better interaction with the Electron API for clipboard operations.
- Improved the user interface for managing keyboard shortcuts in the settings dialog, providing a more intuitive experience.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-04-07 00:43:40 -07:00
parent e920923fa4
commit 49441233e7
30 changed files with 923 additions and 191 deletions

View file

@ -4,7 +4,7 @@
"description": "SurfSense Desktop App",
"main": "dist/main.js",
"scripts": {
"dev": "concurrently -k \"pnpm --dir ../surfsense_web dev\" \"wait-on http://localhost:3000 && electron .\"",
"dev": "pnpm build && concurrently -k \"pnpm --dir ../surfsense_web dev\" \"wait-on http://localhost:3000 && electron .\"",
"build": "node scripts/build-electron.mjs",
"pack:dir": "pnpm build && electron-builder --dir --config electron-builder.yml",
"dist": "pnpm build && electron-builder --config electron-builder.yml",

View file

@ -35,4 +35,7 @@ export const IPC_CHANNELS = {
// Auth token sync across windows
GET_AUTH_TOKENS: 'auth:get-tokens',
SET_AUTH_TOKENS: 'auth:set-tokens',
// Keyboard shortcut configuration
GET_SHORTCUTS: 'shortcuts:get',
SET_SHORTCUTS: 'shortcuts:set',
} as const;

View file

@ -20,6 +20,9 @@ import {
browseFiles,
readLocalFiles,
} from '../modules/folder-watcher';
import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts';
import { reregisterQuickAsk } from '../modules/quick-ask';
import { reregisterAutocomplete } from '../modules/autocomplete';
let authTokens: { bearer: string; refresh: string } | null = null;
@ -99,4 +102,13 @@ export function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.GET_AUTH_TOKENS, () => {
return authTokens;
});
ipcMain.handle(IPC_CHANNELS.GET_SHORTCUTS, () => getShortcuts());
ipcMain.handle(IPC_CHANNELS.SET_SHORTCUTS, async (_event, config: Partial<ShortcutConfig>) => {
const updated = await setShortcuts(config);
if (config.quickAsk) await reregisterQuickAsk();
if (config.autocomplete) await reregisterAutocomplete();
return updated;
});
}

View file

@ -29,8 +29,8 @@ app.whenReady().then(async () => {
}
createMainWindow('/dashboard');
registerQuickAsk();
registerAutocomplete();
await registerQuickAsk();
await registerAutocomplete();
registerFolderWatcher();
setupAutoUpdater();

View file

@ -5,9 +5,9 @@ import { hasScreenRecordingPermission, requestAccessibility, requestScreenRecord
import { getMainWindow } from '../window';
import { captureScreen } from './screenshot';
import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window';
import { getShortcuts } from '../shortcuts';
const SHORTCUT = 'CommandOrControl+Shift+Space';
let currentShortcut = '';
let autocompleteEnabled = true;
let savedClipboard = '';
let sourceApp = '';
@ -91,7 +91,12 @@ async function acceptAndInject(text: string): Promise<void> {
}
}
let ipcRegistered = false;
function registerIpcHandlers(): void {
if (ipcRegistered) return;
ipcRegistered = true;
ipcMain.handle(IPC_CHANNELS.ACCEPT_SUGGESTION, async (_event, text: string) => {
await acceptAndInject(text);
});
@ -107,26 +112,39 @@ function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.GET_AUTOCOMPLETE_ENABLED, () => autocompleteEnabled);
}
export function registerAutocomplete(): void {
registerIpcHandlers();
function autocompleteHandler(): void {
const sw = getSuggestionWindow();
if (sw && !sw.isDestroyed()) {
destroySuggestion();
return;
}
triggerAutocomplete();
}
const ok = globalShortcut.register(SHORTCUT, () => {
const sw = getSuggestionWindow();
if (sw && !sw.isDestroyed()) {
destroySuggestion();
return;
}
triggerAutocomplete();
});
async function registerShortcut(): Promise<void> {
const shortcuts = await getShortcuts();
currentShortcut = shortcuts.autocomplete;
const ok = globalShortcut.register(currentShortcut, autocompleteHandler);
if (!ok) {
console.error(`[autocomplete] Failed to register shortcut ${SHORTCUT}`);
console.error(`[autocomplete] Failed to register shortcut ${currentShortcut}`);
} else {
console.log(`[autocomplete] Registered shortcut ${SHORTCUT}`);
console.log(`[autocomplete] Registered shortcut ${currentShortcut}`);
}
}
export async function registerAutocomplete(): Promise<void> {
registerIpcHandlers();
await registerShortcut();
}
export function unregisterAutocomplete(): void {
globalShortcut.unregister(SHORTCUT);
if (currentShortcut) globalShortcut.unregister(currentShortcut);
destroySuggestion();
}
export async function reregisterAutocomplete(): Promise<void> {
unregisterAutocomplete();
await registerShortcut();
}

View file

@ -1,16 +1,20 @@
import { execSync } from 'child_process';
import { systemPreferences } from 'electron';
const EXEC_OPTS = { windowsHide: true } as const;
export function getFrontmostApp(): string {
try {
if (process.platform === 'darwin') {
return execSync(
'osascript -e \'tell application "System Events" to get name of first application process whose frontmost is true\''
'osascript -e \'tell application "System Events" to get name of first application process whose frontmost is true\'',
EXEC_OPTS,
).toString().trim();
}
if (process.platform === 'win32') {
return execSync(
'powershell -command "Add-Type \'using System; using System.Runtime.InteropServices; public class W { [DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow(); }\'; (Get-Process | Where-Object { $_.MainWindowHandle -eq [W]::GetForegroundWindow() }).ProcessName"'
'powershell -NoProfile -NonInteractive -command "Add-Type \'using System; using System.Runtime.InteropServices; public class W { [DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow(); }\'; (Get-Process | Where-Object { $_.MainWindowHandle -eq [W]::GetForegroundWindow() }).ProcessName"',
EXEC_OPTS,
).toString().trim();
}
} catch {
@ -21,9 +25,23 @@ export function getFrontmostApp(): string {
export function simulatePaste(): void {
if (process.platform === 'darwin') {
execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\'');
execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\'', EXEC_OPTS);
} else if (process.platform === 'win32') {
execSync('powershell -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^v\')"');
execSync('powershell -NoProfile -NonInteractive -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^v\')"', EXEC_OPTS);
}
}
export function simulateCopy(): boolean {
try {
if (process.platform === 'darwin') {
execSync('osascript -e \'tell application "System Events" to keystroke "c" using command down\'', EXEC_OPTS);
} else if (process.platform === 'win32') {
execSync('powershell -NoProfile -NonInteractive -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^c\')"', EXEC_OPTS);
}
return true;
} catch (err) {
console.error('[simulateCopy] Failed:', err);
return false;
}
}
@ -36,12 +54,14 @@ export function getWindowTitle(): string {
try {
if (process.platform === 'darwin') {
return execSync(
'osascript -e \'tell application "System Events" to get title of front window of first application process whose frontmost is true\''
'osascript -e \'tell application "System Events" to get title of front window of first application process whose frontmost is true\'',
EXEC_OPTS,
).toString().trim();
}
if (process.platform === 'win32') {
return execSync(
'powershell -command "(Get-Process | Where-Object { $_.MainWindowHandle -eq (Add-Type -MemberDefinition \'[DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow();\' -Name W -PassThru)::GetForegroundWindow() }).MainWindowTitle"'
'powershell -NoProfile -NonInteractive -command "(Get-Process | Where-Object { $_.MainWindowHandle -eq (Add-Type -MemberDefinition \'[DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow();\' -Name W -PassThru)::GetForegroundWindow() }).MainWindowTitle"',
EXEC_OPTS,
).toString().trim();
}
} catch {

View file

@ -1,10 +1,11 @@
import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron';
import path from 'path';
import { IPC_CHANNELS } from '../ipc/channels';
import { checkAccessibilityPermission, getFrontmostApp, simulatePaste } from './platform';
import { checkAccessibilityPermission, getFrontmostApp, simulateCopy, simulatePaste } from './platform';
import { getServerPort } from './server';
import { getShortcuts } from './shortcuts';
const SHORTCUT = 'CommandOrControl+Option+S';
let currentShortcut = '';
let quickAskWindow: BrowserWindow | null = null;
let pendingText = '';
let pendingMode = '';
@ -77,29 +78,52 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
return quickAskWindow;
}
export function registerQuickAsk(): void {
const ok = globalShortcut.register(SHORTCUT, () => {
if (quickAskWindow && !quickAskWindow.isDestroyed()) {
destroyQuickAsk();
return;
}
function openQuickAsk(text: string): void {
pendingText = text;
const cursor = screen.getCursorScreenPoint();
const pos = clampToScreen(cursor.x, cursor.y, 450, 750);
createQuickAskWindow(pos.x, pos.y);
}
sourceApp = getFrontmostApp();
savedClipboard = clipboard.readText();
async function quickAskHandler(): Promise<void> {
console.log('[quick-ask] Handler triggered');
const text = savedClipboard.trim();
if (!text) return;
pendingText = text;
const cursor = screen.getCursorScreenPoint();
const pos = clampToScreen(cursor.x, cursor.y, 450, 750);
createQuickAskWindow(pos.x, pos.y);
});
if (!ok) {
console.log(`Quick-ask: failed to register ${SHORTCUT}`);
if (quickAskWindow && !quickAskWindow.isDestroyed()) {
console.log('[quick-ask] Window already open, closing');
destroyQuickAsk();
return;
}
if (!checkAccessibilityPermission()) {
console.log('[quick-ask] Accessibility permission denied');
return;
}
savedClipboard = clipboard.readText();
console.log('[quick-ask] Saved clipboard length:', savedClipboard.length);
const copyOk = simulateCopy();
console.log('[quick-ask] simulateCopy result:', copyOk);
await new Promise((r) => setTimeout(r, 300));
const afterCopy = clipboard.readText();
const selected = afterCopy.trim();
console.log('[quick-ask] Clipboard after copy length:', afterCopy.length, 'changed:', afterCopy !== savedClipboard);
const text = selected || savedClipboard.trim();
sourceApp = getFrontmostApp();
console.log('[quick-ask] Source app:', sourceApp, '| Opening Quick Ask with', text.length, 'chars', selected ? '(selected)' : text ? '(clipboard fallback)' : '(empty)');
openQuickAsk(text);
}
let ipcRegistered = false;
function registerIpcHandlers(): void {
if (ipcRegistered) return;
ipcRegistered = true;
ipcMain.handle(IPC_CHANNELS.QUICK_ASK_TEXT, () => {
const text = pendingText;
pendingText = '';
@ -136,6 +160,24 @@ export function registerQuickAsk(): void {
});
}
export function unregisterQuickAsk(): void {
globalShortcut.unregister(SHORTCUT);
async function registerShortcut(): Promise<void> {
const shortcuts = await getShortcuts();
currentShortcut = shortcuts.quickAsk;
const ok = globalShortcut.register(currentShortcut, () => { quickAskHandler(); });
console.log(`[quick-ask] Register ${currentShortcut}: ${ok ? 'OK' : 'FAILED'}`);
}
export async function registerQuickAsk(): Promise<void> {
registerIpcHandlers();
await registerShortcut();
}
export function unregisterQuickAsk(): void {
if (currentShortcut) globalShortcut.unregister(currentShortcut);
}
export async function reregisterQuickAsk(): Promise<void> {
unregisterQuickAsk();
await registerShortcut();
}

View file

@ -0,0 +1,42 @@
export interface ShortcutConfig {
quickAsk: string;
autocomplete: string;
}
const DEFAULTS: ShortcutConfig = {
quickAsk: 'CommandOrControl+Alt+S',
autocomplete: 'CommandOrControl+Shift+Space',
};
const STORE_KEY = 'shortcuts';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- lazily imported ESM module; matches folder-watcher.ts pattern
let store: any = null;
async function getStore() {
if (!store) {
const { default: Store } = await import('electron-store');
store = new Store({
name: 'keyboard-shortcuts',
defaults: { [STORE_KEY]: DEFAULTS },
});
}
return store;
}
export async function getShortcuts(): Promise<ShortcutConfig> {
const s = await getStore();
const stored = s.get(STORE_KEY) as Partial<ShortcutConfig> | undefined;
return { ...DEFAULTS, ...stored };
}
export async function setShortcuts(config: Partial<ShortcutConfig>): Promise<ShortcutConfig> {
const s = await getStore();
const current = (s.get(STORE_KEY) as ShortcutConfig) ?? DEFAULTS;
const merged = { ...current, ...config };
s.set(STORE_KEY, merged);
return merged;
}
export function getDefaults(): ShortcutConfig {
return { ...DEFAULTS };
}

View file

@ -73,4 +73,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
getAuthTokens: () => ipcRenderer.invoke(IPC_CHANNELS.GET_AUTH_TOKENS),
setAuthTokens: (bearer: string, refresh: string) =>
ipcRenderer.invoke(IPC_CHANNELS.SET_AUTH_TOKENS, { bearer, refresh }),
// Keyboard shortcut configuration
getShortcuts: () => ipcRenderer.invoke(IPC_CHANNELS.GET_SHORTCUTS),
setShortcuts: (config: Record<string, string>) =>
ipcRenderer.invoke(IPC_CHANNELS.SET_SHORTCUTS, config),
});

View file

@ -1,30 +1,54 @@
"use client";
import { useEffect, useState } from "react";
import { Clipboard, Sparkles } from "lucide-react";
import { useCallback, 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 { Switch } from "@/components/ui/switch";
import { Spinner } from "@/components/ui/spinner";
import { useElectronAPI } from "@/hooks/use-platform";
export function DesktopContent() {
const [isElectron, setIsElectron] = useState(false);
const api = useElectronAPI();
const [loading, setLoading] = useState(true);
const [enabled, setEnabled] = useState(true);
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
useEffect(() => {
if (!window.electronAPI) {
if (!api) {
setLoading(false);
setShortcutsLoaded(true);
return;
}
setIsElectron(true);
window.electronAPI.getAutocompleteEnabled().then((val) => {
setEnabled(val);
let mounted = true;
Promise.all([
api.getAutocompleteEnabled(),
api.getShortcuts?.() ?? Promise.resolve(null),
]).then(([autoEnabled, config]) => {
if (!mounted) return;
setEnabled(autoEnabled);
if (config) setShortcuts(config);
setLoading(false);
setShortcutsLoaded(true);
}).catch(() => {
if (!mounted) return;
setLoading(false);
setShortcutsLoaded(true);
});
}, []);
if (!isElectron) {
return () => { mounted = false; };
}, [api]);
if (!api) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-sm text-muted-foreground">
@ -44,11 +68,68 @@ export function DesktopContent() {
const handleToggle = async (checked: boolean) => {
setEnabled(checked);
await window.electronAPI!.setAutocompleteEnabled(checked);
await api.setAutocompleteEnabled(checked);
};
const updateShortcut = (key: "quickAsk" | "autocomplete", accelerator: string) => {
setShortcuts((prev) => {
const updated = { ...prev, [key]: accelerator };
api.setShortcuts?.({ [key]: accelerator }).catch(() => {
toast.error("Failed to update shortcut");
});
return updated;
});
toast.success("Shortcut updated");
};
const resetShortcut = (key: "quickAsk" | "autocomplete") => {
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
};
return (
<div className="space-y-4 md:space-y-6">
{/* Keyboard Shortcuts */}
<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">Keyboard Shortcuts</CardTitle>
<CardDescription className="text-xs md:text-sm">
Customize the global keyboard shortcuts for desktop features.
</CardDescription>
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
{shortcutsLoaded ? (
<div className="flex flex-col gap-3">
<ShortcutRecorder
value={shortcuts.quickAsk}
onChange={(accel) => updateShortcut("quickAsk", accel)}
onReset={() => resetShortcut("quickAsk")}
defaultValue={DEFAULT_SHORTCUTS.quickAsk}
label="Quick Ask"
description="Copy selected text and ask AI about it"
icon={Clipboard}
/>
<ShortcutRecorder
value={shortcuts.autocomplete}
onChange={(accel) => updateShortcut("autocomplete", accel)}
onReset={() => resetShortcut("autocomplete")}
defaultValue={DEFAULT_SHORTCUTS.autocomplete}
label="Autocomplete"
description="Get AI writing suggestions from a screenshot"
icon={Sparkles}
/>
<p className="text-[11px] text-muted-foreground">
Click a shortcut and press a new key combination to change it.
</p>
</div>
) : (
<div className="flex justify-center py-4">
<Spinner size="sm" />
</div>
)}
</CardContent>
</Card>
{/* Autocomplete Toggle */}
<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">Autocomplete</CardTitle>

View file

@ -0,0 +1,258 @@
"use client";
import { IconBrandGoogleFilled } from "@tabler/icons-react";
import { useAtom } from "jotai";
import {
Eye,
EyeOff,
Keyboard,
Clipboard,
Sparkles,
} from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import {
DEFAULT_SHORTCUTS,
ShortcutRecorder,
} from "@/components/desktop/shortcut-recorder";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
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 { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
const isGoogleAuth = AUTH_TYPE === "GOOGLE";
export default function DesktopLoginPage() {
const router = useRouter();
const api = useElectronAPI();
const [{ mutateAsync: login, isPending: isLoggingIn }] =
useAtom(loginMutationAtom);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
useEffect(() => {
if (!api?.getShortcuts) {
setShortcutsLoaded(true);
return;
}
api.getShortcuts().then((config) => {
if (config) setShortcuts(config);
setShortcutsLoaded(true);
}).catch(() => setShortcutsLoaded(true));
}, [api]);
const updateShortcut = useCallback(
(key: "quickAsk" | "autocomplete", accelerator: string) => {
setShortcuts((prev) => {
const updated = { ...prev, [key]: accelerator };
api?.setShortcuts?.({ [key]: accelerator }).catch(() => {
toast.error("Failed to update shortcut");
});
return updated;
});
toast.success("Shortcut updated");
},
[api]
);
const resetShortcut = useCallback(
(key: "quickAsk" | "autocomplete") => {
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
},
[updateShortcut]
);
const handleGoogleLogin = () => {
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
};
const handleLocalLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoginError(null);
try {
const data = await login({
username: email,
password,
grant_type: "password",
});
if (typeof window !== "undefined") {
sessionStorage.setItem("login_success_tracked", "true");
}
setTimeout(() => {
router.push(`/auth/callback?token=${data.access_token}`);
}, 300);
} catch (err) {
if (err instanceof Error) {
setLoginError(err.message);
} else {
setLoginError("Login failed. Please check your credentials.");
}
}
};
return (
<div className="relative flex min-h-screen items-center justify-center bg-background p-4">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div
className="absolute -top-1/2 left-1/2 size-[800px] -translate-x-1/2 rounded-full opacity-[0.03]"
style={{
background:
"radial-gradient(circle, hsl(var(--primary)) 0%, transparent 70%)",
}}
/>
</div>
<Card className="relative w-full max-w-md shadow-lg">
<CardHeader className="items-center text-center pb-4">
<Image
src="/icon-128.svg"
className="select-none dark:invert size-14 rounded-md mb-2"
alt="SurfSense"
width={56}
height={56}
priority
/>
<CardTitle className="text-xl">Welcome to SurfSense Desktop App</CardTitle>
<CardDescription>
Configure your shortcuts, then sign in to get started.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
{/* ---- Shortcuts Section (first) ---- */}
{shortcutsLoaded ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground mb-1">
<Keyboard className="size-3" />
Keyboard Shortcuts
</div>
<ShortcutRecorder
value={shortcuts.quickAsk}
onChange={(accel) => updateShortcut("quickAsk", accel)}
onReset={() => resetShortcut("quickAsk")}
defaultValue={DEFAULT_SHORTCUTS.quickAsk}
label="Quick Ask"
description="Copy selected text and ask AI about it"
icon={Clipboard}
/>
<ShortcutRecorder
value={shortcuts.autocomplete}
onChange={(accel) => updateShortcut("autocomplete", accel)}
onReset={() => resetShortcut("autocomplete")}
defaultValue={DEFAULT_SHORTCUTS.autocomplete}
label="Autocomplete"
description="Get AI writing suggestions from a screenshot"
icon={Sparkles}
/>
<p className="text-[11px] text-muted-foreground text-center">
Click a shortcut and press a new key combination to change it.
</p>
</div>
) : (
<div className="flex justify-center py-4">
<Spinner size="sm" />
</div>
)}
{/* ---- Divider ---- */}
<Separator />
{/* ---- Auth Section (second) ---- */}
{isGoogleAuth ? (
<Button
variant="outline"
className="w-full gap-2 py-5"
onClick={handleGoogleLogin}
>
<IconBrandGoogleFilled className="size-5" />
Continue with Google
</Button>
) : (
<form onSubmit={handleLocalLogin} className="flex flex-col gap-4">
{loginError && (
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{loginError}
</div>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoggingIn}
autoFocus
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Enter your password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoggingIn}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
</div>
<Button type="submit" disabled={isLoggingIn} className="mt-1">
{isLoggingIn ? (
<>
<Spinner size="sm" className="text-primary-foreground" />
Signing in...
</>
) : (
"Sign in"
)}
</Button>
</form>
)}
</CardContent>
</Card>
</div>
);
}

View file

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { Logo } from "@/components/Logo";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useElectronAPI } from "@/hooks/use-platform";
type PermissionStatus = "authorized" | "denied" | "not determined" | "restricted" | "limited";
@ -57,19 +58,18 @@ function StatusBadge({ status }: { status: PermissionStatus }) {
export default function DesktopPermissionsPage() {
const router = useRouter();
const api = useElectronAPI();
const [permissions, setPermissions] = useState<PermissionsStatus | null>(null);
const [isElectron, setIsElectron] = useState(false);
useEffect(() => {
if (!window.electronAPI) return;
setIsElectron(true);
if (!api) return;
let interval: ReturnType<typeof setInterval> | null = null;
const isResolved = (s: string) => s === "authorized" || s === "restricted";
const poll = async () => {
const status = await window.electronAPI!.getPermissionsStatus();
const status = await api.getPermissionsStatus();
setPermissions(status);
if (isResolved(status.accessibility) && isResolved(status.screenRecording)) {
@ -80,9 +80,9 @@ export default function DesktopPermissionsPage() {
poll();
interval = setInterval(poll, 2000);
return () => { if (interval) clearInterval(interval); };
}, []);
}, [api]);
if (!isElectron) {
if (!api) {
return (
<div className="h-screen flex items-center justify-center bg-background">
<p className="text-muted-foreground">This page is only available in the desktop app.</p>
@ -102,15 +102,15 @@ export default function DesktopPermissionsPage() {
const handleRequest = async (action: string) => {
if (action === "requestScreenRecording") {
await window.electronAPI!.requestScreenRecording();
await api.requestScreenRecording();
} else if (action === "requestAccessibility") {
await window.electronAPI!.requestAccessibility();
await api.requestAccessibility();
}
};
const handleContinue = () => {
if (allGranted) {
window.electronAPI!.restartApp();
api.restartApp();
}
};

View file

@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useElectronAPI } from "@/hooks/use-platform";
import { getBearerToken, ensureTokensFromElectron } from "@/lib/auth-utils";
type SSEEvent =
@ -34,26 +35,27 @@ function friendlyError(raw: string | number): string {
const AUTO_DISMISS_MS = 3000;
export default function SuggestionPage() {
const api = useElectronAPI();
const [suggestion, setSuggestion] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [isDesktop, setIsDesktop] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const isDesktop = !!api?.onAutocompleteContext;
useEffect(() => {
if (!window.electronAPI?.onAutocompleteContext) {
setIsDesktop(false);
if (!api?.onAutocompleteContext) {
setIsLoading(false);
}
}, []);
}, [api]);
useEffect(() => {
if (!error) return;
const timer = setTimeout(() => {
window.electronAPI?.dismissSuggestion?.();
api?.dismissSuggestion?.();
}, AUTO_DISMISS_MS);
return () => clearTimeout(timer);
}, [error]);
}, [error, api]);
const fetchSuggestion = useCallback(
async (screenshot: string, searchSpaceId: string, appName?: string, windowTitle?: string) => {
@ -153,9 +155,9 @@ export default function SuggestionPage() {
);
useEffect(() => {
if (!window.electronAPI?.onAutocompleteContext) return;
if (!api?.onAutocompleteContext) return;
const cleanup = window.electronAPI.onAutocompleteContext((data) => {
const cleanup = api.onAutocompleteContext((data) => {
const searchSpaceId = data.searchSpaceId || "1";
if (data.screenshot) {
fetchSuggestion(data.screenshot, searchSpaceId, data.appName, data.windowTitle);
@ -163,7 +165,7 @@ export default function SuggestionPage() {
});
return cleanup;
}, [fetchSuggestion]);
}, [fetchSuggestion, api]);
if (!isDesktop) {
return (
@ -197,12 +199,12 @@ export default function SuggestionPage() {
const handleAccept = () => {
if (suggestion) {
window.electronAPI?.acceptSuggestion?.(suggestion);
api?.acceptSuggestion?.(suggestion);
}
};
const handleDismiss = () => {
window.electronAPI?.dismissSuggestion?.();
api?.dismissSuggestion?.();
};
if (!suggestion) return null;

View file

@ -10,6 +10,7 @@ import { ZeroProvider } from "@/components/providers/ZeroProvider";
import { ThemeProvider } from "@/components/theme/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { LocaleProvider } from "@/contexts/LocaleContext";
import { PlatformProvider } from "@/contexts/platform-context";
import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider";
import { cn } from "@/lib/utils";
@ -139,15 +140,17 @@ export default function RootLayout({
disableTransitionOnChange
defaultTheme="system"
>
<RootProvider>
<ReactQueryClientProvider>
<ZeroProvider>
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
</ZeroProvider>
</ReactQueryClientProvider>
<Toaster />
<AnnouncementToastProvider />
</RootProvider>
<PlatformProvider>
<RootProvider>
<ReactQueryClientProvider>
<ZeroProvider>
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
</ZeroProvider>
</ReactQueryClientProvider>
<Toaster />
<AnnouncementToastProvider />
</RootProvider>
</PlatformProvider>
</ThemeProvider>
</I18nProvider>
</LocaleProvider>

View file

@ -15,7 +15,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { logout } from "@/lib/auth-utils";
import { getLoginPath, logout } from "@/lib/auth-utils";
import { resetUser, trackLogout } from "@/lib/posthog/events";
export function UserDropdown({
@ -33,22 +33,19 @@ export function UserDropdown({
if (isLoggingOut) return;
setIsLoggingOut(true);
try {
// Track logout event and reset PostHog identity
trackLogout();
resetUser();
// Revoke refresh token on server and clear all tokens from localStorage
await logout();
if (typeof window !== "undefined") {
window.location.href = "/";
window.location.href = getLoginPath();
}
} catch (error) {
console.error("Error during logout:", error);
// Even if there's an error, try to clear tokens and redirect
await logout();
if (typeof window !== "undefined") {
window.location.href = "/";
window.location.href = getLoginPath();
}
}
};

View file

@ -87,6 +87,7 @@ import {
} from "@/components/ui/drawer";
import { useComments } from "@/hooks/use-comments";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
// Dynamically import video presentation tool to avoid loading Babel and Remotion in main bundle
@ -463,16 +464,17 @@ export const AssistantMessage: FC = () => {
const AssistantActionBar: FC = () => {
const isLast = useAuiState((s) => s.message.isLast);
const aui = useAui();
const api = useElectronAPI();
const [quickAskMode, setQuickAskMode] = useState("");
useEffect(() => {
if (!isLast || !window.electronAPI?.getQuickAskMode) return;
window.electronAPI.getQuickAskMode().then((mode) => {
if (!isLast || !api?.getQuickAskMode) return;
api.getQuickAskMode().then((mode) => {
if (mode) setQuickAskMode(mode);
});
}, [isLast]);
}, [isLast, api]);
const isTransform = isLast && !!window.electronAPI?.replaceText && quickAskMode === "transform";
const isTransform = isLast && !!api?.replaceText && quickAskMode === "transform";
return (
<ActionBarPrimitive.Root
@ -508,7 +510,7 @@ const AssistantActionBar: FC = () => {
type="button"
onClick={() => {
const text = aui.message().getCopyText();
window.electronAPI?.replaceText(text);
api?.replaceText(text);
}}
className="ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>

View file

@ -3,6 +3,7 @@
import type { FC } from "react";
import { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { usePlatform } from "@/hooks/use-platform";
import { isSelfHosted } from "@/lib/env-config";
import { ConnectorCard } from "../components/connector-card";
import {
@ -74,9 +75,8 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
onManage,
onViewAccountsList,
}) => {
// Check if self-hosted mode (for showing self-hosted only connectors)
const selfHosted = isSelfHosted();
const isDesktop = typeof window !== "undefined" && !!window.electronAPI;
const { isDesktop } = usePlatform();
const matchesSearch = (title: string, description: string) =>
title.toLowerCase().includes(searchQuery.toLowerCase()) ||

View file

@ -24,6 +24,7 @@ export interface MentionedDocument {
export interface InlineMentionEditorRef {
focus: () => void;
clear: () => void;
setText: (text: string) => void;
getText: () => string;
getMentionedDocuments: () => MentionedDocument[];
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
@ -397,6 +398,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
}
}, []);
// Replace editor content with plain text and place cursor at end
const setText = useCallback(
(text: string) => {
if (!editorRef.current) return;
editorRef.current.innerText = text;
const empty = text.length === 0;
setIsEmpty(empty);
onChange?.(text, Array.from(mentionedDocs.values()));
focusAtEnd();
},
[focusAtEnd, onChange, mentionedDocs]
);
const setDocumentChipStatus = useCallback(
(
docId: number,
@ -469,6 +483,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
useImperativeHandle(ref, () => ({
focus: () => editorRef.current?.focus(),
clear,
setText,
getText,
getMentionedDocuments,
insertDocumentChip,

View file

@ -89,6 +89,7 @@ import type { Document } from "@/contracts/types/document.types";
import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsSync } from "@/hooks/use-comments-sync";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { cn } from "@/lib/utils";
/** Placeholder texts that cycle in new chats when input is empty */
@ -362,18 +363,19 @@ const Composer: FC = () => {
};
}, []);
const electronAPI = useElectronAPI();
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
const clipboardLoadedRef = useRef(false);
useEffect(() => {
if (!window.electronAPI || clipboardLoadedRef.current) return;
if (!electronAPI || clipboardLoadedRef.current) return;
clipboardLoadedRef.current = true;
window.electronAPI.getQuickAskText().then((text) => {
electronAPI.getQuickAskText().then((text) => {
if (text) {
setClipboardInitialText(text);
setShowPromptPicker(true);
}
});
}, []);
}, [electronAPI]);
const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty);
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
@ -504,34 +506,28 @@ const Composer: FC = () => {
: userText
? `${action.prompt}\n\n${userText}`
: action.prompt;
editorRef.current?.setText(finalPrompt);
aui.composer().setText(finalPrompt);
aui.composer().send();
editorRef.current?.clear();
setShowPromptPicker(false);
setActionQuery("");
setMentionedDocuments([]);
setSidebarDocs([]);
},
[actionQuery, aui, setMentionedDocuments, setSidebarDocs]
[actionQuery, aui]
);
const handleQuickAskSelect = useCallback(
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
if (!clipboardInitialText) return;
window.electronAPI?.setQuickAskMode(action.mode);
electronAPI?.setQuickAskMode(action.mode);
const finalPrompt = action.prompt.includes("{selection}")
? action.prompt.replace("{selection}", () => clipboardInitialText)
: `${action.prompt}\n\n${clipboardInitialText}`;
editorRef.current?.setText(finalPrompt);
aui.composer().setText(finalPrompt);
aui.composer().send();
editorRef.current?.clear();
setShowPromptPicker(false);
setActionQuery("");
setClipboardInitialText(undefined);
setMentionedDocuments([]);
setSidebarDocs([]);
},
[clipboardInitialText, aui, setMentionedDocuments, setSidebarDocs]
[clipboardInitialText, electronAPI, aui]
);
// Keyboard navigation for document/action picker (arrow keys, Enter, Escape)

View file

@ -0,0 +1,168 @@
"use client";
import { RotateCcw } from "lucide-react";
import { useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Accelerator ↔ display helpers
// ---------------------------------------------------------------------------
export function keyEventToAccelerator(e: React.KeyboardEvent): string | null {
const parts: string[] = [];
if (e.ctrlKey || e.metaKey) parts.push("CommandOrControl");
if (e.altKey) parts.push("Alt");
if (e.shiftKey) parts.push("Shift");
const key = e.key;
if (["Control", "Meta", "Alt", "Shift"].includes(key)) return null;
if (key === " ") parts.push("Space");
else if (key.length === 1) parts.push(key.toUpperCase());
else parts.push(key);
if (parts.length < 2) return null;
return parts.join("+");
}
export function acceleratorToDisplay(accel: string): string[] {
if (!accel) return [];
return accel.split("+").map((part) => {
if (part === "CommandOrControl") return "Ctrl";
if (part === "Space") return "Space";
return part;
});
}
export const DEFAULT_SHORTCUTS = {
quickAsk: "CommandOrControl+Alt+S",
autocomplete: "CommandOrControl+Shift+Space",
};
// ---------------------------------------------------------------------------
// Kbd pill component
// ---------------------------------------------------------------------------
export function Kbd({
keys,
className,
}: {
keys: string[];
className?: string;
}) {
return (
<span className={cn("inline-flex items-center gap-1", className)}>
{keys.map((key) => (
<kbd
key={key}
className={cn(
"inline-flex h-7 min-w-7 items-center justify-center rounded-md border bg-muted px-1.5 font-mono text-xs font-medium text-muted-foreground shadow-sm",
key.length > 3 && "px-2"
)}
>
{key}
</kbd>
))}
</span>
);
}
// ---------------------------------------------------------------------------
// Shortcut recorder component
// ---------------------------------------------------------------------------
export function ShortcutRecorder({
value,
onChange,
onReset,
defaultValue,
label,
description,
icon: Icon,
}: {
value: string;
onChange: (accelerator: string) => void;
onReset: () => void;
defaultValue: string;
label: string;
description: string;
icon: React.ElementType;
}) {
const [recording, setRecording] = useState(false);
const inputRef = useRef<HTMLButtonElement>(null);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!recording) return;
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
setRecording(false);
return;
}
const accel = keyEventToAccelerator(e);
if (accel) {
onChange(accel);
setRecording(false);
}
},
[recording, onChange]
);
const displayKeys = acceleratorToDisplay(value);
const isDefault = value === defaultValue;
return (
<div className="flex items-center justify-between gap-4 rounded-lg border bg-background p-3">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
<Icon className="size-4" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium leading-none">{label}</p>
<p className="mt-1 text-xs text-muted-foreground truncate">
{description}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{!isDefault && (
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={onReset}
title="Reset to default"
>
<RotateCcw />
</Button>
)}
<button
ref={inputRef}
type="button"
onClick={() => setRecording(true)}
onKeyDown={handleKeyDown}
onBlur={() => setRecording(false)}
className={cn(
"flex h-9 items-center gap-1 rounded-md border px-3 text-sm transition-all focus:outline-none",
recording
? "border-primary bg-primary/5 ring-2 ring-primary/20"
: "border-input bg-muted/50 hover:bg-muted"
)}
>
{recording ? (
<span className="text-xs text-primary animate-pulse">
Press keys...
</span>
) : (
<Kbd keys={displayKeys} />
)}
</button>
</div>
</div>
);
}

View file

@ -55,7 +55,7 @@ import { useInbox } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile";
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { logout } from "@/lib/auth-utils";
import { getLoginPath, logout } from "@/lib/auth-utils";
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
@ -600,12 +600,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
await logout();
if (typeof window !== "undefined") {
router.push("/");
router.push(getLoginPath());
}
} catch (error) {
console.error("Error during logout:", error);
await logout();
router.push("/");
router.push(getLoginPath());
}
}, [router]);

View file

@ -41,6 +41,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { foldersApiService } from "@/lib/apis/folders-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
@ -84,6 +85,7 @@ export function DocumentsSidebar({
const tSidebar = useTranslations("sidebar");
const params = useParams();
const isMobile = !useMediaQuery("(min-width: 640px)");
const electronAPI = useElectronAPI();
const searchSpaceId = Number(params.search_space_id);
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
@ -97,11 +99,11 @@ export function DocumentsSidebar({
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
useEffect(() => {
const api = typeof window !== "undefined" ? window.electronAPI : null;
if (!api?.getWatchedFolders) return;
if (!electronAPI?.getWatchedFolders) return;
const api = electronAPI;
async function loadWatchedIds() {
const folders = await api!.getWatchedFolders();
const folders = await api.getWatchedFolders();
if (folders.length === 0) {
try {
@ -109,7 +111,7 @@ export function DocumentsSidebar({
for (const bf of backendFolders) {
const meta = bf.metadata as Record<string, unknown> | null;
if (!meta?.watched || !meta.folder_path) continue;
await api!.addWatchedFolder({
await api.addWatchedFolder({
path: meta.folder_path as string,
name: bf.name,
rootFolderId: bf.id,
@ -119,7 +121,7 @@ export function DocumentsSidebar({
active: true,
});
}
const recovered = await api!.getWatchedFolders();
const recovered = await api.getWatchedFolders();
const ids = new Set(
recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
);
@ -137,7 +139,7 @@ export function DocumentsSidebar({
}
loadWatchedIds();
}, [searchSpaceId]);
}, [searchSpaceId, electronAPI]);
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
@ -276,10 +278,9 @@ export function DocumentsSidebar({
const handleRescanFolder = useCallback(
async (folder: FolderDisplay) => {
const api = window.electronAPI;
if (!api) return;
if (!electronAPI) return;
const watchedFolders = await api.getWatchedFolders();
const watchedFolders = await electronAPI.getWatchedFolders();
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
if (!matched) {
toast.error("This folder is not being watched");
@ -298,28 +299,27 @@ export function DocumentsSidebar({
toast.error((err as Error)?.message || "Failed to re-scan folder");
}
},
[searchSpaceId]
[searchSpaceId, electronAPI]
);
const handleStopWatching = useCallback(async (folder: FolderDisplay) => {
const api = window.electronAPI;
if (!api) return;
if (!electronAPI) return;
const watchedFolders = await api.getWatchedFolders();
const watchedFolders = await electronAPI.getWatchedFolders();
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
if (!matched) {
toast.error("This folder is not being watched");
return;
}
await api.removeWatchedFolder(matched.path);
await electronAPI.removeWatchedFolder(matched.path);
try {
await foldersApiService.stopWatching(folder.id);
} catch (err) {
console.error("[DocumentsSidebar] Failed to clear watched metadata:", err);
}
toast.success(`Stopped watching: ${matched.name}`);
}, []);
}, [electronAPI]);
const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => {
try {
@ -333,12 +333,11 @@ export function DocumentsSidebar({
const handleDeleteFolder = useCallback(async (folder: FolderDisplay) => {
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
try {
const api = window.electronAPI;
if (api) {
const watchedFolders = await api.getWatchedFolders();
if (electronAPI) {
const watchedFolders = await electronAPI.getWatchedFolders();
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
if (matched) {
await api.removeWatchedFolder(matched.path);
await electronAPI.removeWatchedFolder(matched.path);
}
}
await foldersApiService.deleteFolder(folder.id);
@ -346,7 +345,7 @@ export function DocumentsSidebar({
} catch (e: unknown) {
toast.error((e as Error)?.message || "Failed to delete folder");
}
}, []);
}, [electronAPI]);
const handleMoveFolder = useCallback(
(folder: FolderDisplay) => {

View file

@ -0,0 +1,16 @@
"use client";
import type { ReactNode } from "react";
import { usePlatform } from "@/hooks/use-platform";
export function DesktopOnly({ children }: { children: ReactNode }) {
const { isDesktop } = usePlatform();
if (!isDesktop) return null;
return <>{children}</>;
}
export function WebOnly({ children }: { children: ReactNode }) {
const { isWeb } = usePlatform();
if (!isWeb) return null;
return <>{children}</>;
}

View file

@ -3,6 +3,7 @@
import { useAtom } from "jotai";
import { Globe, KeyRound, Monitor, Receipt, Sparkles, User } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent";
import { CommunityPromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent";
import { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent";
@ -11,37 +12,42 @@ import { PurchaseHistoryContent } from "@/app/dashboard/[search_space_id]/user-s
import { DesktopContent } from "@/app/dashboard/[search_space_id]/user-settings/components/DesktopContent";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { SettingsDialog } from "@/components/settings/settings-dialog";
import { usePlatform } from "@/hooks/use-platform";
export function UserSettingsDialog() {
const t = useTranslations("userSettings");
const [state, setState] = useAtom(userSettingsDialogAtom);
const { isDesktop } = usePlatform();
const navItems = [
{ value: "profile", label: t("profile_nav_label"), icon: <User className="h-4 w-4" /> },
{
value: "api-key",
label: t("api_key_nav_label"),
icon: <KeyRound className="h-4 w-4" />,
},
{
value: "prompts",
label: "My Prompts",
icon: <Sparkles className="h-4 w-4" />,
},
{
value: "community-prompts",
label: "Community Prompts",
icon: <Globe className="h-4 w-4" />,
},
{
value: "purchases",
label: "Purchase History",
icon: <Receipt className="h-4 w-4" />,
},
...(typeof window !== "undefined" && window.electronAPI
? [{ value: "desktop", label: "Desktop", icon: <Monitor className="h-4 w-4" /> }]
: []),
];
const navItems = useMemo(
() => [
{ value: "profile", label: t("profile_nav_label"), icon: <User className="h-4 w-4" /> },
{
value: "api-key",
label: t("api_key_nav_label"),
icon: <KeyRound className="h-4 w-4" />,
},
{
value: "prompts",
label: "My Prompts",
icon: <Sparkles className="h-4 w-4" />,
},
{
value: "community-prompts",
label: "Community Prompts",
icon: <Globe className="h-4 w-4" />,
},
{
value: "purchases",
label: "Purchase History",
icon: <Receipt className="h-4 w-4" />,
},
...(isDesktop
? [{ value: "desktop", label: "Desktop", icon: <Monitor className="h-4 w-4" /> }]
: []),
],
[t, isDesktop]
);
return (
<SettingsDialog

View file

@ -26,6 +26,7 @@ import { Progress } from "@/components/ui/progress";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { useElectronAPI } from "@/hooks/use-platform";
import {
trackDocumentUploadFailure,
trackDocumentUploadStarted,
@ -151,10 +152,11 @@ export function DocumentUploadTab({
};
}, []);
const electronAPI = useElectronAPI();
const [selectedFolder, setSelectedFolder] = useState<SelectedFolder | null>(null);
const [watchFolder, setWatchFolder] = useState(true);
const [folderSubmitting, setFolderSubmitting] = useState(false);
const isElectron = typeof window !== "undefined" && !!window.electronAPI?.browseFiles;
const isElectron = !!electronAPI?.browseFiles;
const acceptedFileTypes = useMemo(() => {
const etlService = process.env.NEXT_PUBLIC_ETL_SERVICE;
@ -216,33 +218,31 @@ export function DocumentUploadTab({
}, []);
const handleBrowseFiles = useCallback(async () => {
const api = window.electronAPI;
if (!api?.browseFiles) return;
if (!electronAPI?.browseFiles) return;
const paths = await api.browseFiles();
const paths = await electronAPI.browseFiles();
if (!paths || paths.length === 0) return;
setSelectedFolder(null);
const fileDataList = await api.readLocalFiles(paths);
const fileDataList = await electronAPI.readLocalFiles(paths);
const newFiles: FileWithId[] = fileDataList.map((fd) => ({
id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`,
file: new File([fd.data], fd.name, { type: fd.mimeType }),
}));
setFiles((prev) => [...prev, ...newFiles]);
}, []);
}, [electronAPI]);
const handleBrowseFolder = useCallback(async () => {
const api = window.electronAPI;
if (!api?.selectFolder) return;
if (!electronAPI?.selectFolder) return;
const folderPath = await api.selectFolder();
const folderPath = await electronAPI.selectFolder();
if (!folderPath) return;
const folderName = folderPath.split("/").pop() || folderPath.split("\\").pop() || folderPath;
setFiles([]);
setSelectedFolder({ path: folderPath, name: folderName });
setWatchFolder(true);
}, []);
}, [electronAPI]);
const handleFolderChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
@ -287,9 +287,7 @@ export function DocumentUploadTab({
);
const handleFolderSubmit = useCallback(async () => {
if (!selectedFolder) return;
const api = window.electronAPI;
if (!api) return;
if (!selectedFolder || !electronAPI) return;
setFolderSubmitting(true);
try {
@ -304,7 +302,7 @@ export function DocumentUploadTab({
const rootFolderId = (result as { root_folder_id?: number })?.root_folder_id ?? null;
if (watchFolder) {
await api.addWatchedFolder({
await electronAPI.addWatchedFolder({
path: selectedFolder.path,
name: selectedFolder.name,
excludePatterns: [
@ -332,7 +330,7 @@ export function DocumentUploadTab({
} finally {
setFolderSubmitting(false);
}
}, [selectedFolder, watchFolder, searchSpaceId, shouldSummarize, onSuccess]);
}, [selectedFolder, watchFolder, searchSpaceId, shouldSummarize, onSuccess, electronAPI]);
const handleUpload = async () => {
setUploadProgress(0);

View file

@ -0,0 +1,31 @@
"use client";
import { createContext, useEffect, useState, type ReactNode } from "react";
export interface PlatformContextValue {
isDesktop: boolean;
isWeb: boolean;
electronAPI: ElectronAPI | null;
}
const SSR_VALUE: PlatformContextValue = {
isDesktop: false,
isWeb: false,
electronAPI: null,
};
export const PlatformContext = createContext<PlatformContextValue>(SSR_VALUE);
export function PlatformProvider({ children }: { children: ReactNode }) {
const [value, setValue] = useState<PlatformContextValue>(SSR_VALUE);
useEffect(() => {
const api = window.electronAPI ?? null;
const isDesktop = !!api;
setValue({ isDesktop, isWeb: !isDesktop, electronAPI: api });
}, []);
return (
<PlatformContext.Provider value={value}>{children}</PlatformContext.Provider>
);
}

View file

@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { useElectronAPI } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service";
interface FileChangedEvent {
@ -29,6 +30,7 @@ interface BatchItem {
}
export function useFolderSync() {
const electronAPI = useElectronAPI();
const queueRef = useRef<BatchItem[]>([]);
const processingRef = useRef(false);
const debounceTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
@ -49,9 +51,8 @@ export function useFolderSync() {
target_file_paths: batch.filePaths,
root_folder_id: batch.rootFolderId,
});
const api = typeof window !== "undefined" ? window.electronAPI : null;
if (api?.acknowledgeFileEvents && batch.ackIds.length > 0) {
await api.acknowledgeFileEvents(batch.ackIds);
if (electronAPI?.acknowledgeFileEvents && batch.ackIds.length > 0) {
await electronAPI.acknowledgeFileEvents(batch.ackIds);
}
} catch (err) {
console.error("[FolderSync] Failed to trigger batch re-index:", err);
@ -117,25 +118,22 @@ export function useFolderSync() {
useEffect(() => {
isMountedRef.current = true;
const api = typeof window !== "undefined" ? window.electronAPI : null;
if (!api?.onFileChanged) {
if (!electronAPI?.onFileChanged) {
return () => {
isMountedRef.current = false;
};
}
// Signal to main process that the renderer is ready to receive events
api.signalRendererReady?.();
electronAPI.signalRendererReady?.();
// Drain durable outbox first so events survive renderer startup gaps and restarts
void api.getPendingFileEvents?.().then((pendingEvents) => {
void electronAPI.getPendingFileEvents?.().then((pendingEvents) => {
if (!isMountedRef.current || !pendingEvents?.length) return;
for (const event of pendingEvents) {
enqueueWithDebounce(event);
}
});
const cleanup = api.onFileChanged((event: FileChangedEvent) => {
const cleanup = electronAPI.onFileChanged((event: FileChangedEvent) => {
enqueueWithDebounce(event);
});
@ -149,5 +147,5 @@ export function useFolderSync() {
pendingByFolder.current.clear();
firstEventTime.current.clear();
};
}, []);
}, [electronAPI]);
}

View file

@ -0,0 +1,12 @@
import { useContext } from "react";
import { PlatformContext, type PlatformContextValue } from "@/contexts/platform-context";
export function usePlatform(): Pick<PlatformContextValue, "isDesktop" | "isWeb"> {
const { isDesktop, isWeb } = useContext(PlatformContext);
return { isDesktop, isWeb };
}
export function useElectronAPI(): ElectronAPI | null {
const { electronAPI } = useContext(PlatformContext);
return electronAPI;
}

View file

@ -15,6 +15,7 @@ const PUBLIC_ROUTE_PREFIXES = [
"/login",
"/register",
"/auth",
"/desktop/login",
"/docs",
"/public",
"/invite",
@ -34,6 +35,11 @@ export function isPublicRoute(pathname: string): boolean {
return PUBLIC_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}
export function getLoginPath(): string {
if (typeof window !== "undefined" && window.electronAPI) return "/desktop/login";
return "/login";
}
/**
* Clears tokens and optionally redirects to login.
* Call this when a 401 response is received.
@ -55,7 +61,7 @@ export function handleUnauthorized(): void {
if (!excludedPaths.includes(pathname)) {
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
}
window.location.href = "/login";
window.location.href = getLoginPath();
}
}
@ -221,13 +227,12 @@ export function redirectToLogin(): void {
const currentPath = window.location.pathname + window.location.search + window.location.hash;
// Don't save auth-related paths or home page
const excludedPaths = ["/auth", "/auth/callback", "/", "/login", "/register"];
const excludedPaths = ["/auth", "/auth/callback", "/", "/login", "/register", "/desktop/login"];
if (!excludedPaths.includes(window.location.pathname)) {
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
}
// Redirect to login page
window.location.href = "/login";
window.location.href = getLoginPath();
}
/**

View file

@ -81,6 +81,9 @@ interface ElectronAPI {
// Auth token sync across windows
getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>;
setAuthTokens: (bearer: string, refresh: string) => Promise<void>;
// Keyboard shortcut configuration
getShortcuts: () => Promise<{ quickAsk: string; autocomplete: string }>;
setShortcuts: (config: Partial<{ quickAsk: string; autocomplete: string }>) => Promise<{ quickAsk: string; autocomplete: string }>;
}
declare global {