mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-18 05:18:31 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e9e4d42d0 | |||
| e9f8e83bae | |||
| 97bd70c53e | |||
| a7ded504ab | |||
| 7526d37fb3 | |||
| bf590bfdc9 | |||
| 9dff2c012c | |||
| 27e58afc00 | |||
| df303a3423 | |||
| 89ad58b5f1 | |||
| 6589388907 | |||
| 0e865c432f | |||
| 6092aabffc | |||
| f21f5505d7 | |||
| 088d07f562 | |||
| b78428f735 | |||
| 1ece295d7b | |||
| af40ec9982 | |||
| cf645cab71 | |||
| 6492bc3f18 | |||
| 1a462adc8f | |||
| 8c4a4c13f6 | |||
| 18b98f9a98 | |||
| 9b95d90030 | |||
| c0b732438c | |||
| 9ae3a8bc77 | |||
| 9594dc6af0 | |||
| 8a17ca6908 | |||
| 9757032671 | |||
| f5800805e6 | |||
| bd26b3ba23 | |||
| 9d09a21282 | |||
| ab557a86ba | |||
| 3428ddd5ee | |||
| cee36c6bde | |||
| 4cdf293ff0 |
+1
-1
@@ -134,4 +134,4 @@ i18n-unused-keys-report.json
|
||||
|
||||
pnpm-lock.yaml
|
||||
.turbo
|
||||
spaHtmlTemplates.ts
|
||||
spaHtmlTemplates.ts.superpowers/
|
||||
|
||||
@@ -24,6 +24,10 @@ function electronDesktopHtmlPlugin(): PluginOption {
|
||||
if (req.url === '/' || req.url === '/index.html') {
|
||||
req.url = '/apps/desktop/index.html';
|
||||
}
|
||||
// Spotlight routes serve spotlight.html
|
||||
if (req.url?.startsWith('/desktop/spotlight')) {
|
||||
req.url = '/apps/desktop/spotlight.html';
|
||||
}
|
||||
next();
|
||||
});
|
||||
},
|
||||
@@ -98,7 +102,10 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: resolve(__dirname, 'dist/renderer'),
|
||||
rollupOptions: {
|
||||
input: resolve(__dirname, 'index.html'),
|
||||
input: {
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
spotlight: resolve(__dirname, 'spotlight.html'),
|
||||
},
|
||||
output: sharedRollupOutput,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ const isDarwin = getTargetPlatform() === 'darwin';
|
||||
*/
|
||||
export const nativeModules = [
|
||||
// macOS-only native modules
|
||||
...(isDarwin ? ['node-mac-permissions', 'electron-liquid-glass'] : []),
|
||||
...(isDarwin ? ['node-mac-permissions', 'electron-liquid-glass', '@lobechat/electron-panel'] : []),
|
||||
'@napi-rs/canvas',
|
||||
// Add more native modules here as needed
|
||||
];
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@lobechat/desktop-bridge": "workspace:*",
|
||||
"@lobechat/electron-client-ipc": "workspace:*",
|
||||
"@lobechat/electron-panel": "workspace:*",
|
||||
"@lobechat/electron-server-ipc": "workspace:*",
|
||||
"@lobechat/file-loaders": "workspace:*",
|
||||
"@lobechat/local-file-shell": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
(function () {
|
||||
var theme = 'system';
|
||||
try {
|
||||
theme = localStorage.getItem('theme') || 'system';
|
||||
} catch (_) {}
|
||||
var systemTheme =
|
||||
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
var resolvedTheme = theme === 'system' ? systemTheme : theme;
|
||||
if (resolvedTheme === 'dark' || resolvedTheme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', resolvedTheme);
|
||||
}
|
||||
var locale = navigator.language || 'en-US';
|
||||
document.documentElement.lang = locale;
|
||||
})();
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script>
|
||||
window.__SERVER_CONFIG__ = undefined;
|
||||
</script>
|
||||
<script type="module" src="../../src/spa/entry.spotlight.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,6 +5,7 @@ import type { BrowserWindowOpts } from './core/browser/Browser';
|
||||
export const BrowsersIdentifiers = {
|
||||
app: 'app',
|
||||
devtools: 'devtools',
|
||||
spotlight: 'spotlight',
|
||||
};
|
||||
|
||||
export const appBrowsers = {
|
||||
@@ -32,6 +33,22 @@ export const appBrowsers = {
|
||||
titleBarStyle: 'hiddenInset',
|
||||
width: 1000,
|
||||
},
|
||||
spotlight: {
|
||||
fullscreenable: false,
|
||||
hasShadow: true,
|
||||
height: 120,
|
||||
identifier: 'spotlight',
|
||||
keepAlive: true,
|
||||
maximizable: false,
|
||||
minimizable: false,
|
||||
path: '/desktop/spotlight',
|
||||
resizable: false,
|
||||
showOnInit: false,
|
||||
skipSplash: true,
|
||||
skipTaskbar: true,
|
||||
type: 'panel',
|
||||
width: 680,
|
||||
},
|
||||
} satisfies Record<string, BrowserWindowOpts>;
|
||||
|
||||
// Window templates for multi-instance windows
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Panel } from '@lobechat/electron-panel';
|
||||
import { ipcMain, Menu, screen } from 'electron';
|
||||
|
||||
import { BrowsersIdentifiers } from '@/appBrowsers';
|
||||
|
||||
import { ControllerModule, IpcMethod, shortcut } from './index';
|
||||
|
||||
interface ModelMenuItem {
|
||||
group?: string;
|
||||
label: string;
|
||||
provider: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export default class SpotlightCtr extends ControllerModule {
|
||||
static override readonly groupName = 'spotlight';
|
||||
|
||||
private blurAttached = false;
|
||||
private crashRecoveryAttached = false;
|
||||
private menuOpen = false;
|
||||
private chatState = false;
|
||||
private panel?: Panel;
|
||||
private panelInitialized = false;
|
||||
|
||||
afterAppReady() {
|
||||
ipcMain.handle('spotlight:ready', () => {
|
||||
const spotlight = this.app.browserManager.browsers.get(BrowsersIdentifiers.spotlight);
|
||||
spotlight?.markReady();
|
||||
});
|
||||
|
||||
ipcMain.handle('spotlight:hide', () => {
|
||||
this.hideSpotlight();
|
||||
});
|
||||
|
||||
ipcMain.handle('spotlight:resize', (_event, params: { height: number; width: number }) => {
|
||||
this.resizeSpotlight(params);
|
||||
});
|
||||
|
||||
ipcMain.handle('spotlight:setChatState', (_event, isChatting: boolean) => {
|
||||
this.chatState = isChatting;
|
||||
});
|
||||
}
|
||||
|
||||
@shortcut('showSpotlight')
|
||||
async toggleSpotlight() {
|
||||
const spotlight = this.app.browserManager.retrieveByIdentifier(BrowsersIdentifiers.spotlight);
|
||||
|
||||
this.ensureBlurHandler(spotlight);
|
||||
this.ensureCrashRecovery(spotlight);
|
||||
|
||||
if (spotlight.browserWindow.isVisible()) {
|
||||
this.hideSpotlight();
|
||||
return;
|
||||
}
|
||||
|
||||
await spotlight.whenReady();
|
||||
|
||||
this.initializePanel(spotlight);
|
||||
|
||||
const cursor = screen.getCursorScreenPoint();
|
||||
spotlight.showAt(cursor);
|
||||
spotlight.broadcast('spotlightFocus');
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async openModelMenu(items: ModelMenuItem[]) {
|
||||
const spotlight = this.app.browserManager.browsers.get(BrowsersIdentifiers.spotlight);
|
||||
if (!spotlight) return null;
|
||||
|
||||
this.menuOpen = true;
|
||||
|
||||
return new Promise<{ model: string; provider: string } | null>((resolve) => {
|
||||
const menuItems: Electron.MenuItemConstructorOptions[] = [];
|
||||
let currentGroup: string | undefined;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.group && item.group !== currentGroup) {
|
||||
if (currentGroup !== undefined) {
|
||||
menuItems.push({ type: 'separator' });
|
||||
}
|
||||
menuItems.push({ enabled: false, label: item.group });
|
||||
currentGroup = item.group;
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
click: () => resolve({ model: item.value, provider: item.provider }),
|
||||
label: item.label,
|
||||
});
|
||||
}
|
||||
|
||||
const menu = Menu.buildFromTemplate(menuItems);
|
||||
|
||||
menu.popup({
|
||||
callback: () => {
|
||||
this.menuOpen = false;
|
||||
resolve(null);
|
||||
},
|
||||
window: spotlight.browserWindow,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async resize(params: { height: number; width: number }) {
|
||||
this.resizeSpotlight(params);
|
||||
}
|
||||
|
||||
private resizeSpotlight(params: { height: number; width: number }) {
|
||||
const spotlight = this.app.browserManager.browsers.get(BrowsersIdentifiers.spotlight);
|
||||
if (!spotlight) return;
|
||||
|
||||
const currentBounds = spotlight.browserWindow.getBounds();
|
||||
const newBounds = {
|
||||
height: params.height,
|
||||
width: params.width,
|
||||
x: currentBounds.x,
|
||||
y: currentBounds.y,
|
||||
};
|
||||
|
||||
if (spotlight.expandDirection === 'up' && params.height > currentBounds.height) {
|
||||
newBounds.y = currentBounds.y - (params.height - currentBounds.height);
|
||||
}
|
||||
|
||||
if (this.panel) {
|
||||
this.panel.animateResizeElectron(newBounds, 0.15);
|
||||
} else {
|
||||
spotlight.browserWindow.setBounds(newBounds, true);
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async hide() {
|
||||
this.hideSpotlight();
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async expandToMain(params: { agentId: string; groupId?: string; topicId: string }) {
|
||||
const mainWindow = this.app.browserManager.getMainWindow();
|
||||
const path = params.groupId
|
||||
? `/group/${params.groupId}?topic=${params.topicId}`
|
||||
: `/agent/${params.agentId}?topic=${params.topicId}`;
|
||||
|
||||
mainWindow.show();
|
||||
mainWindow.broadcast('navigate', { path });
|
||||
this.hideSpotlight();
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async notifySync(params: { keys: string[] }) {
|
||||
this.app.browserManager.broadcastToOtherWindows('syncData', {
|
||||
keys: params.keys,
|
||||
source: 'spotlight',
|
||||
});
|
||||
}
|
||||
|
||||
private initializePanel(
|
||||
spotlight: ReturnType<typeof this.app.browserManager.retrieveByIdentifier>,
|
||||
) {
|
||||
if (this.panelInitialized) return;
|
||||
this.panelInitialized = true;
|
||||
|
||||
try {
|
||||
const handle = spotlight.browserWindow.getNativeWindowHandle();
|
||||
this.panel = new Panel(handle);
|
||||
this.panel.panelize();
|
||||
this.panel.enableNativeDrag({ height: 44, width: 680, x: 0, y: 0 });
|
||||
} catch (e) {
|
||||
console.error('[SpotlightCtr] Failed to initialize native panel:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private hideSpotlight() {
|
||||
const spotlight = this.app.browserManager.browsers.get(BrowsersIdentifiers.spotlight);
|
||||
if (spotlight) {
|
||||
spotlight.hide();
|
||||
this.chatState = false;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureBlurHandler(
|
||||
spotlight: ReturnType<typeof this.app.browserManager.retrieveByIdentifier>,
|
||||
) {
|
||||
if (this.blurAttached) return;
|
||||
this.blurAttached = true;
|
||||
|
||||
spotlight.browserWindow.on('blur', () => {
|
||||
if (this.menuOpen || this.chatState) return;
|
||||
if (spotlight.browserWindow.isVisible()) {
|
||||
spotlight.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private ensureCrashRecovery(
|
||||
spotlight: ReturnType<typeof this.app.browserManager.retrieveByIdentifier>,
|
||||
) {
|
||||
if (this.crashRecoveryAttached) return;
|
||||
this.crashRecoveryAttached = true;
|
||||
|
||||
spotlight.browserWindow.webContents.on('render-process-gone', () => {
|
||||
console.error('[SpotlightCtr] Spotlight renderer crashed, reloading...');
|
||||
spotlight.resetReady();
|
||||
spotlight.loadUrl(spotlight.options.path).catch((e) => {
|
||||
console.error('[SpotlightCtr] Failed to reload after crash:', e);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export interface BrowserWindowOpts extends BrowserWindowConstructorOptions {
|
||||
parentIdentifier?: string;
|
||||
path: string;
|
||||
showOnInit?: boolean;
|
||||
skipSplash?: boolean;
|
||||
title?: string;
|
||||
width?: number;
|
||||
}
|
||||
@@ -42,6 +43,8 @@ export default class Browser {
|
||||
private readonly themeManager: WindowThemeManager;
|
||||
|
||||
private _browserWindow?: BrowserWindow;
|
||||
private _readyResolve?: () => void;
|
||||
private _readyPromise: Promise<void>;
|
||||
|
||||
readonly identifier: string;
|
||||
readonly options: BrowserWindowOpts;
|
||||
@@ -67,6 +70,10 @@ export default class Browser {
|
||||
this.identifier = options.identifier;
|
||||
this.options = options;
|
||||
|
||||
this._readyPromise = new Promise<void>((resolve) => {
|
||||
this._readyResolve = resolve;
|
||||
});
|
||||
|
||||
// Initialize managers
|
||||
this.stateManager = new WindowStateManager(application, {
|
||||
identifier: options.identifier,
|
||||
@@ -127,6 +134,14 @@ export default class Browser {
|
||||
logger.info(`Creating new BrowserWindow instance: ${this.identifier}`);
|
||||
logger.debug(`[${this.identifier}] Resolved window state: ${JSON.stringify(resolvedState)}`);
|
||||
|
||||
const platformConfig = this.themeManager.getPlatformConfig();
|
||||
|
||||
// Spotlight window: force transparent, no vibrancy (clean floating panel)
|
||||
const spotlightOverrides =
|
||||
this.identifier === 'spotlight'
|
||||
? { trafficLightPosition: undefined, transparent: true, vibrancy: undefined }
|
||||
: {};
|
||||
|
||||
return new BrowserWindow({
|
||||
...rest,
|
||||
autoHideMenuBar: true,
|
||||
@@ -137,7 +152,7 @@ export default class Browser {
|
||||
show: false,
|
||||
title,
|
||||
webPreferences: {
|
||||
backgroundThrottling: false,
|
||||
backgroundThrottling: this.identifier === 'spotlight',
|
||||
contextIsolation: true,
|
||||
preload: join(preloadDir, 'index.js'),
|
||||
sandbox: false,
|
||||
@@ -146,8 +161,8 @@ export default class Browser {
|
||||
width: resolvedState.width,
|
||||
x: resolvedState.x,
|
||||
y: resolvedState.y,
|
||||
// Platform visual config is the SOLE source of vibrancy / transparency / titleBarOverlay.
|
||||
...this.themeManager.getPlatformConfig(),
|
||||
...platformConfig,
|
||||
...spotlightOverrides,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -157,6 +172,11 @@ export default class Browser {
|
||||
// Setup theme management (includes liquid glass lifecycle on macOS Tahoe)
|
||||
this.themeManager.attach(browserWindow);
|
||||
|
||||
// Spotlight: hide from Mission Control (Electron-specific, not covered by native panel addon)
|
||||
if (this.identifier === 'spotlight') {
|
||||
browserWindow.setHiddenInMissionControl(true);
|
||||
}
|
||||
|
||||
// Setup network interceptors
|
||||
this.setupCORSBypass(browserWindow);
|
||||
this.setupRemoteServerRequestHook(browserWindow);
|
||||
@@ -178,15 +198,25 @@ export default class Browser {
|
||||
}
|
||||
|
||||
private initiateContentLoading(): void {
|
||||
logger.debug(`[${this.identifier}] Initiating placeholder and URL loading sequence.`);
|
||||
this.loadPlaceholder().then(() => {
|
||||
logger.debug(`[${this.identifier}] Initiating content loading sequence.`);
|
||||
|
||||
if (this.options.skipSplash) {
|
||||
this.loadUrl(this.options.path).catch((e) => {
|
||||
logger.error(
|
||||
`[${this.identifier}] Initial loadUrl error for path '${this.options.path}':`,
|
||||
e,
|
||||
);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
this.loadPlaceholder().then(() => {
|
||||
this.loadUrl(this.options.path).catch((e) => {
|
||||
logger.error(
|
||||
`[${this.identifier}] Initial loadUrl error for path '${this.options.path}':`,
|
||||
e,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Event Listeners ====================
|
||||
@@ -326,6 +356,68 @@ export default class Browser {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until renderer signals ready via IPC.
|
||||
* Resolves immediately if already ready. Times out after 3s.
|
||||
*/
|
||||
async whenReady(timeoutMs = 3000): Promise<void> {
|
||||
await Promise.race([
|
||||
this._readyPromise,
|
||||
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this window's renderer as ready. Called from IPC handler.
|
||||
*/
|
||||
markReady(): void {
|
||||
this._readyResolve?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset ready state (e.g. after crash + recreate).
|
||||
*/
|
||||
resetReady(): void {
|
||||
this._readyPromise = new Promise<void>((resolve) => {
|
||||
this._readyResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
private _expandDirection: 'down' | 'up' = 'down';
|
||||
|
||||
get expandDirection() {
|
||||
return this._expandDirection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show window at a specific screen coordinate.
|
||||
* Applies boundary correction to keep within display work area.
|
||||
*/
|
||||
showAt(point: { x: number; y: number }): void {
|
||||
const display = screen.getDisplayNearestPoint(point);
|
||||
const { width } = this.browserWindow.getBounds();
|
||||
const maxHeight = 480;
|
||||
|
||||
let x = Math.round(point.x - width / 2);
|
||||
let y = point.y + 8;
|
||||
|
||||
const bounds = display.workArea;
|
||||
x = Math.max(bounds.x, Math.min(x, bounds.x + bounds.width - width));
|
||||
|
||||
if (y + maxHeight > bounds.y + bounds.height) {
|
||||
y = point.y - 8 - this.browserWindow.getBounds().height;
|
||||
y = Math.max(bounds.y, y);
|
||||
this._expandDirection = 'up';
|
||||
} else {
|
||||
y = Math.max(bounds.y, y);
|
||||
this._expandDirection = 'down';
|
||||
}
|
||||
|
||||
this.browserWindow.setPosition(x, y);
|
||||
this.browserWindow.show();
|
||||
this.browserWindow.focus();
|
||||
}
|
||||
|
||||
moveToCenter(): void {
|
||||
logger.debug(`Centering window: ${this.identifier}`);
|
||||
this._browserWindow?.center();
|
||||
|
||||
@@ -4,14 +4,8 @@ import type { WebContents } from 'electron';
|
||||
import RemoteServerConfigCtr from '@/controllers/RemoteServerConfigCtr';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import type {
|
||||
AppBrowsersIdentifiers,
|
||||
WindowTemplateIdentifiers} from '../../appBrowsers';
|
||||
import {
|
||||
appBrowsers,
|
||||
BrowsersIdentifiers,
|
||||
windowTemplates,
|
||||
} from '../../appBrowsers';
|
||||
import type { AppBrowsersIdentifiers, WindowTemplateIdentifiers } from '../../appBrowsers';
|
||||
import { appBrowsers, BrowsersIdentifiers, windowTemplates } from '../../appBrowsers';
|
||||
import type { App } from '../App';
|
||||
import type { BrowserWindowOpts } from './Browser';
|
||||
import Browser from './Browser';
|
||||
@@ -35,6 +29,10 @@ export class BrowserManager {
|
||||
return this.retrieveByIdentifier(BrowsersIdentifiers.app);
|
||||
}
|
||||
|
||||
getSpotlightWindow() {
|
||||
return this.retrieveByIdentifier(BrowsersIdentifiers.spotlight);
|
||||
}
|
||||
|
||||
showMainWindow() {
|
||||
logger.debug('Showing main window');
|
||||
const window = this.getMainWindow();
|
||||
@@ -60,6 +58,18 @@ export class BrowserManager {
|
||||
this.browsers.get(identifier)?.broadcast(event, data);
|
||||
};
|
||||
|
||||
broadcastToOtherWindows = <T extends MainBroadcastEventKey>(
|
||||
event: T,
|
||||
data: MainBroadcastParams<T>,
|
||||
excludeWebContents?: WebContents,
|
||||
) => {
|
||||
logger.debug(`Broadcasting event ${event} to all windows except sender`);
|
||||
this.browsers.forEach((browser) => {
|
||||
if (excludeWebContents && browser.webContents === excludeWebContents) return;
|
||||
browser.broadcast(event, data);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate window to specific sub-path
|
||||
* @param identifier Window identifier
|
||||
@@ -193,6 +203,11 @@ export class BrowserManager {
|
||||
Object.values(appBrowsers).forEach((browser: BrowserWindowOpts) => {
|
||||
logger.debug(`Initializing browser: ${browser.identifier}`);
|
||||
|
||||
// Don't initialize spotlight until onboarding is done
|
||||
if (browser.identifier === BrowsersIdentifiers.spotlight && !isOnboardingCompleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dynamically determine initial path for main window
|
||||
if (browser.identifier === BrowsersIdentifiers.app) {
|
||||
const initialPath = isOnboardingCompleted ? '/' : '/desktop-onboarding';
|
||||
|
||||
@@ -14,6 +14,7 @@ const logger = createLogger('core:RendererUrlManager');
|
||||
// Vite build with root=monorepo preserves input path structure,
|
||||
// so index.html ends up at apps/desktop/index.html in outDir.
|
||||
const SPA_ENTRY_HTML = join(rendererDir, 'apps', 'desktop', 'index.html');
|
||||
const SPOTLIGHT_ENTRY_HTML = join(rendererDir, 'apps', 'desktop', 'spotlight.html');
|
||||
|
||||
export class RendererUrlManager {
|
||||
private readonly rendererProtocolManager: RendererProtocolManager;
|
||||
@@ -77,6 +78,11 @@ export class RendererUrlManager {
|
||||
return pathExistsSync(filePath) ? filePath : null;
|
||||
}
|
||||
|
||||
// Spotlight routes → spotlight.html
|
||||
if (pathname.startsWith('/desktop/spotlight')) {
|
||||
return SPOTLIGHT_ENTRY_HTML;
|
||||
}
|
||||
|
||||
// All routes fallback to index.html (SPA)
|
||||
return SPA_ENTRY_HTML;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ export const ShortcutActionEnum = {
|
||||
* Show/hide main window
|
||||
*/
|
||||
showApp: 'showApp',
|
||||
showSpotlight: 'showSpotlight',
|
||||
} as const;
|
||||
|
||||
export type ShortcutActionType = (typeof ShortcutActionEnum)[keyof typeof ShortcutActionEnum];
|
||||
@@ -17,4 +18,5 @@ export type ShortcutActionType = (typeof ShortcutActionEnum)[keyof typeof Shortc
|
||||
export const DEFAULT_SHORTCUTS_CONFIG: Record<ShortcutActionType, string> = {
|
||||
[ShortcutActionEnum.showApp]: 'Control+E',
|
||||
[ShortcutActionEnum.openSettings]: 'CommandOrControl+,',
|
||||
[ShortcutActionEnum.showSpotlight]: 'CommandOrControl+Shift+Space',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,888 @@
|
||||
# Electron Panel Native Addon Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Create `@lobechat/electron-panel` N-API addon that converts Spotlight's BrowserWindow into a floating NSPanel-like panel with native drag and animated resize.
|
||||
|
||||
**Architecture:** Monorepo package at `packages/electron-panel/` using `Napi::ObjectWrap<Panel>` pattern (mirroring `electron-liquid-glass`). Objective-C++ runtime property injection on NSWindow — no isa-swizzle. prebuildify for prebuilt binaries, node-gyp-build for runtime loading.
|
||||
|
||||
**Tech Stack:** N-API (node-addon-api), Objective-C++ (AppKit), prebuildify, node-gyp-build, TypeScript wrapper
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-17-electron-panel-addon-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Package scaffold
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/electron-panel/package.json`
|
||||
|
||||
- Create: `packages/electron-panel/binding.gyp`
|
||||
|
||||
- Create: `packages/electron-panel/tsconfig.json`
|
||||
|
||||
- [ ] **Step 1: Create package.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.4.0"
|
||||
},
|
||||
"description": "NSPanel-grade native window behavior for Electron BrowserWindow",
|
||||
"exports": {
|
||||
".": "./js/index.ts"
|
||||
},
|
||||
"gypfile": false,
|
||||
"main": "./js/index.ts",
|
||||
"name": "@lobechat/electron-panel",
|
||||
"optionalDependencies": {
|
||||
"node-gyp-build": "^4"
|
||||
},
|
||||
"os": ["darwin"],
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:native": "prebuildify --napi --strip --tag-armv --arch=arm64 && prebuildify --napi --strip --arch=x64",
|
||||
"build:native:current": "node-gyp rebuild && mkdir -p prebuilds/darwin-$(uname -m) && cp build/Release/electron_panel.node prebuilds/darwin-$(uname -m)/node.napi.node"
|
||||
},
|
||||
"type": "module",
|
||||
"types": "./js/index.ts",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create binding.gyp**
|
||||
|
||||
```python
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"target_name": "electron_panel",
|
||||
"sources": [
|
||||
"src/panel.cc",
|
||||
"src/panel_mac.mm"
|
||||
],
|
||||
"include_dirs": [
|
||||
"<!@(node -p \"require('node-addon-api').include\")"
|
||||
],
|
||||
"defines": ["NAPI_DISABLE_CPP_EXCEPTIONS"],
|
||||
"conditions": [
|
||||
["OS=='mac'", {
|
||||
"defines": ["PLATFORM_OSX"],
|
||||
"xcode_settings": {
|
||||
"OTHER_CPLUSPLUSFLAGS": ["-std=c++17", "-ObjC++"],
|
||||
"OTHER_LDFLAGS": ["-framework AppKit", "-framework QuartzCore"]
|
||||
}
|
||||
}]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create tsconfig.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["js/**/*.ts"]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/electron-panel/
|
||||
git commit -m "✨ feat(electron-panel): scaffold package with binding.gyp and config"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Objective-C++ native layer
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/electron-panel/src/panel_mac.mm`
|
||||
|
||||
This file implements all three native functions: panelize, native drag, and animated resize.
|
||||
|
||||
- [ ] **Step 1: Create panel_mac.mm with panelize**
|
||||
|
||||
```objc
|
||||
#ifdef PLATFORM_OSX
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <QuartzCore/QuartzCore.h>
|
||||
#import <objc/runtime.h>
|
||||
#include <cstdio>
|
||||
|
||||
// Key for the drag overlay view associated with the window
|
||||
static const void *kDragViewKey = &kDragViewKey;
|
||||
|
||||
#define RUN_ON_MAIN(block) \
|
||||
if ([NSThread isMainThread]) { \
|
||||
block(); \
|
||||
} else { \
|
||||
dispatch_sync(dispatch_get_main_queue(), block); \
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DragView — transparent overlay that intercepts mouse events for window drag
|
||||
// ---------------------------------------------------------------------------
|
||||
@interface PanelDragView : NSView
|
||||
@end
|
||||
|
||||
@implementation PanelDragView
|
||||
|
||||
- (BOOL)acceptsFirstMouse:(NSEvent *)event {
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)mouseDown:(NSEvent *)event {
|
||||
[self.window performWindowDragWithEvent:event];
|
||||
}
|
||||
|
||||
// Allow click-through for non-drag interactions
|
||||
- (NSView *)hitTest:(NSPoint)point {
|
||||
// Only intercept if the point is within our frame
|
||||
NSPoint local = [self convertPoint:point fromView:self.superview];
|
||||
if (NSPointInRect(local, self.bounds)) {
|
||||
return self;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// panelize — set NSPanel-grade properties on an NSWindow
|
||||
// ---------------------------------------------------------------------------
|
||||
extern "C" bool panelize(unsigned char *buffer) {
|
||||
if (!buffer) return false;
|
||||
|
||||
__block bool success = false;
|
||||
|
||||
RUN_ON_MAIN(^{
|
||||
NSView *rootView = *reinterpret_cast<NSView **>(buffer);
|
||||
if (!rootView) return;
|
||||
|
||||
NSWindow *window = [rootView window];
|
||||
if (!window) return;
|
||||
|
||||
// Set floating panel behavior
|
||||
if ([window respondsToSelector:@selector(setFloatingPanel:)]) {
|
||||
[(id)window setFloatingPanel:YES];
|
||||
}
|
||||
|
||||
// Only become key when needed (don't steal focus from other apps)
|
||||
if ([window respondsToSelector:@selector(setBecomesKeyOnlyIfNeeded:)]) {
|
||||
[(id)window setBecomesKeyOnlyIfNeeded:YES];
|
||||
}
|
||||
|
||||
// Don't hide when app deactivates
|
||||
if ([window respondsToSelector:@selector(setHidesOnDeactivate:)]) {
|
||||
[window setHidesOnDeactivate:NO];
|
||||
}
|
||||
|
||||
// Collection behavior: join all spaces + fullscreen auxiliary
|
||||
window.collectionBehavior |=
|
||||
NSWindowCollectionBehaviorCanJoinAllSpaces |
|
||||
NSWindowCollectionBehaviorFullScreenAuxiliary;
|
||||
|
||||
// Float above normal windows
|
||||
window.level = NSFloatingWindowLevel;
|
||||
|
||||
success = true;
|
||||
});
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// enableNativeDrag — add a transparent drag overlay at the specified rect
|
||||
// ---------------------------------------------------------------------------
|
||||
extern "C" bool enableNativeDrag(unsigned char *buffer,
|
||||
double x, double y,
|
||||
double width, double height) {
|
||||
if (!buffer) return false;
|
||||
|
||||
__block bool success = false;
|
||||
|
||||
RUN_ON_MAIN(^{
|
||||
NSView *rootView = *reinterpret_cast<NSView **>(buffer);
|
||||
if (!rootView) return;
|
||||
|
||||
NSWindow *window = [rootView window];
|
||||
if (!window) return;
|
||||
|
||||
NSView *contentView = window.contentView;
|
||||
if (!contentView) return;
|
||||
|
||||
// Remove existing drag view if any
|
||||
NSView *oldDrag = objc_getAssociatedObject(contentView, kDragViewKey);
|
||||
if (oldDrag) {
|
||||
[oldDrag removeFromSuperview];
|
||||
}
|
||||
|
||||
// macOS coordinate system is bottom-left origin.
|
||||
// The rect from JS is top-left origin (web convention).
|
||||
// Convert: flipped_y = contentHeight - y - height
|
||||
NSRect contentBounds = contentView.bounds;
|
||||
double flippedY = contentBounds.size.height - y - height;
|
||||
NSRect dragRect = NSMakeRect(x, flippedY, width, height);
|
||||
|
||||
PanelDragView *dragView = [[PanelDragView alloc] initWithFrame:dragRect];
|
||||
// Auto-resize: stick to top, resize width with window
|
||||
dragView.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin;
|
||||
|
||||
[contentView addSubview:dragView positioned:NSWindowAbove relativeTo:nil];
|
||||
objc_setAssociatedObject(contentView, kDragViewKey, dragView,
|
||||
OBJC_ASSOCIATION_RETAIN);
|
||||
|
||||
success = true;
|
||||
});
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// disableNativeDrag — remove the drag overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
extern "C" bool disableNativeDrag(unsigned char *buffer) {
|
||||
if (!buffer) return false;
|
||||
|
||||
__block bool success = false;
|
||||
|
||||
RUN_ON_MAIN(^{
|
||||
NSView *rootView = *reinterpret_cast<NSView **>(buffer);
|
||||
if (!rootView) return;
|
||||
|
||||
NSWindow *window = [rootView window];
|
||||
if (!window) return;
|
||||
|
||||
NSView *contentView = window.contentView;
|
||||
if (!contentView) return;
|
||||
|
||||
NSView *dragView = objc_getAssociatedObject(contentView, kDragViewKey);
|
||||
if (dragView) {
|
||||
[dragView removeFromSuperview];
|
||||
objc_setAssociatedObject(contentView, kDragViewKey, nil,
|
||||
OBJC_ASSOCIATION_ASSIGN);
|
||||
}
|
||||
|
||||
success = true;
|
||||
});
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// animateResize — smoothly animate window to a new frame
|
||||
// ---------------------------------------------------------------------------
|
||||
extern "C" bool animateResize(unsigned char *buffer,
|
||||
double x, double y,
|
||||
double width, double height,
|
||||
double duration) {
|
||||
if (!buffer) return false;
|
||||
|
||||
__block bool success = false;
|
||||
|
||||
RUN_ON_MAIN(^{
|
||||
NSView *rootView = *reinterpret_cast<NSView **>(buffer);
|
||||
if (!rootView) return;
|
||||
|
||||
NSWindow *window = [rootView window];
|
||||
if (!window) return;
|
||||
|
||||
NSRect newFrame = NSMakeRect(x, y, width, height);
|
||||
|
||||
if (duration <= 0) {
|
||||
[window setFrame:newFrame display:YES];
|
||||
} else {
|
||||
[NSAnimationContext runAnimationGroup:^(NSAnimationContext *ctx) {
|
||||
ctx.duration = duration;
|
||||
ctx.timingFunction = [CAMediaTimingFunction
|
||||
functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
|
||||
[[window animator] setFrame:newFrame display:YES];
|
||||
}];
|
||||
}
|
||||
|
||||
success = true;
|
||||
});
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
#endif // PLATFORM_OSX
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/electron-panel/src/panel_mac.mm
|
||||
git commit -m "✨ feat(electron-panel): Objective-C++ native layer — panelize, drag, animateResize"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: N-API binding layer
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/electron-panel/src/panel.cc`
|
||||
|
||||
- [ ] **Step 1: Create panel.cc**
|
||||
|
||||
```cpp
|
||||
#include <napi.h>
|
||||
|
||||
#ifdef __APPLE__
|
||||
extern "C" bool panelize(unsigned char *buffer);
|
||||
extern "C" bool enableNativeDrag(unsigned char *buffer,
|
||||
double x, double y,
|
||||
double width, double height);
|
||||
extern "C" bool disableNativeDrag(unsigned char *buffer);
|
||||
extern "C" bool animateResize(unsigned char *buffer,
|
||||
double x, double y,
|
||||
double width, double height,
|
||||
double duration);
|
||||
#endif
|
||||
|
||||
class Panel : public Napi::ObjectWrap<Panel> {
|
||||
public:
|
||||
static Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
||||
Napi::Function func = DefineClass(env, "Panel", {
|
||||
InstanceMethod("panelize", &Panel::Panelize),
|
||||
InstanceMethod("enableNativeDrag", &Panel::EnableNativeDrag),
|
||||
InstanceMethod("disableNativeDrag", &Panel::DisableNativeDrag),
|
||||
InstanceMethod("animateResize", &Panel::AnimateResize),
|
||||
});
|
||||
|
||||
Napi::FunctionReference *constructor = new Napi::FunctionReference();
|
||||
*constructor = Napi::Persistent(func);
|
||||
env.SetInstanceData(constructor);
|
||||
|
||||
exports.Set("Panel", func);
|
||||
return exports;
|
||||
}
|
||||
|
||||
// Constructor: receives Buffer from getNativeWindowHandle()
|
||||
Panel(const Napi::CallbackInfo &info)
|
||||
: Napi::ObjectWrap<Panel>(info), handle_(nullptr) {
|
||||
Napi::Env env = info.Env();
|
||||
|
||||
if (info.Length() < 1 || !info[0].IsBuffer()) {
|
||||
Napi::TypeError::New(env,
|
||||
"Expected first argument to be a Buffer from getNativeWindowHandle()")
|
||||
.ThrowAsJavaScriptException();
|
||||
return;
|
||||
}
|
||||
|
||||
auto buffer = info[0].As<Napi::Buffer<unsigned char>>();
|
||||
// Store a copy of the handle data (pointer-sized)
|
||||
size_t len = buffer.Length();
|
||||
handle_ = new unsigned char[len];
|
||||
memcpy(handle_, buffer.Data(), len);
|
||||
}
|
||||
|
||||
~Panel() {
|
||||
delete[] handle_;
|
||||
}
|
||||
|
||||
private:
|
||||
unsigned char *handle_;
|
||||
|
||||
Napi::Value Panelize(const Napi::CallbackInfo &info) {
|
||||
Napi::Env env = info.Env();
|
||||
#ifdef __APPLE__
|
||||
bool ok = panelize(handle_);
|
||||
return Napi::Boolean::New(env, ok);
|
||||
#else
|
||||
return Napi::Boolean::New(env, false);
|
||||
#endif
|
||||
}
|
||||
|
||||
Napi::Value EnableNativeDrag(const Napi::CallbackInfo &info) {
|
||||
Napi::Env env = info.Env();
|
||||
|
||||
if (info.Length() < 1 || !info[0].IsObject()) {
|
||||
Napi::TypeError::New(env, "Expected {x, y, width, height}")
|
||||
.ThrowAsJavaScriptException();
|
||||
return env.Null();
|
||||
}
|
||||
|
||||
auto rect = info[0].As<Napi::Object>();
|
||||
double x = rect.Get("x").As<Napi::Number>().DoubleValue();
|
||||
double y = rect.Get("y").As<Napi::Number>().DoubleValue();
|
||||
double w = rect.Get("width").As<Napi::Number>().DoubleValue();
|
||||
double h = rect.Get("height").As<Napi::Number>().DoubleValue();
|
||||
|
||||
#ifdef __APPLE__
|
||||
bool ok = enableNativeDrag(handle_, x, y, w, h);
|
||||
return Napi::Boolean::New(env, ok);
|
||||
#else
|
||||
return Napi::Boolean::New(env, false);
|
||||
#endif
|
||||
}
|
||||
|
||||
Napi::Value DisableNativeDrag(const Napi::CallbackInfo &info) {
|
||||
Napi::Env env = info.Env();
|
||||
#ifdef __APPLE__
|
||||
bool ok = disableNativeDrag(handle_);
|
||||
return Napi::Boolean::New(env, ok);
|
||||
#else
|
||||
return Napi::Boolean::New(env, false);
|
||||
#endif
|
||||
}
|
||||
|
||||
Napi::Value AnimateResize(const Napi::CallbackInfo &info) {
|
||||
Napi::Env env = info.Env();
|
||||
|
||||
if (info.Length() < 1 || !info[0].IsObject()) {
|
||||
Napi::TypeError::New(env, "Expected {x, y, width, height}")
|
||||
.ThrowAsJavaScriptException();
|
||||
return env.Null();
|
||||
}
|
||||
|
||||
auto frame = info[0].As<Napi::Object>();
|
||||
double x = frame.Get("x").As<Napi::Number>().DoubleValue();
|
||||
double y = frame.Get("y").As<Napi::Number>().DoubleValue();
|
||||
double w = frame.Get("width").As<Napi::Number>().DoubleValue();
|
||||
double h = frame.Get("height").As<Napi::Number>().DoubleValue();
|
||||
|
||||
double duration = 0.2;
|
||||
if (info.Length() >= 2 && info[1].IsNumber()) {
|
||||
duration = info[1].As<Napi::Number>().DoubleValue();
|
||||
}
|
||||
|
||||
#ifdef __APPLE__
|
||||
bool ok = animateResize(handle_, x, y, w, h, duration);
|
||||
return Napi::Boolean::New(env, ok);
|
||||
#else
|
||||
return Napi::Boolean::New(env, false);
|
||||
#endif
|
||||
}
|
||||
};
|
||||
|
||||
Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
||||
return Panel::Init(env, exports);
|
||||
}
|
||||
|
||||
NODE_API_MODULE(electron_panel, Init)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/electron-panel/src/panel.cc
|
||||
git commit -m "✨ feat(electron-panel): N-API binding layer with ObjectWrap<Panel>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: TypeScript wrapper
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `packages/electron-panel/js/index.ts`
|
||||
|
||||
- [ ] **Step 1: Create js/index.ts**
|
||||
|
||||
```typescript
|
||||
import path from 'node:path';
|
||||
|
||||
export interface Rect {
|
||||
height: number;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface NativePanel {
|
||||
animateResize(frame: Rect, duration: number): boolean;
|
||||
disableNativeDrag(): boolean;
|
||||
enableNativeDrag(rect: Rect): boolean;
|
||||
panelize(): boolean;
|
||||
}
|
||||
|
||||
interface NativeBinding {
|
||||
Panel: new (handle: Buffer) => NativePanel;
|
||||
}
|
||||
|
||||
function loadNative(): NativeBinding | null {
|
||||
if (process.platform !== 'darwin') return null;
|
||||
|
||||
try {
|
||||
// Use node-gyp-build to locate the correct prebuilt binary
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const gypBuild = require('node-gyp-build');
|
||||
return gypBuild(path.join(__dirname, '..'));
|
||||
} catch {
|
||||
try {
|
||||
// Fallback: try build/Release directly (development)
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
return require('../build/Release/electron_panel.node');
|
||||
} catch {
|
||||
console.warn(
|
||||
'[@lobechat/electron-panel] Failed to load native addon — panel features disabled.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const native = loadNative();
|
||||
|
||||
export class Panel {
|
||||
private _addon: NativePanel | null;
|
||||
|
||||
constructor(handle: Buffer) {
|
||||
if (!Buffer.isBuffer(handle)) {
|
||||
throw new Error('[@lobechat/electron-panel] handle must be a Buffer');
|
||||
}
|
||||
|
||||
if (native) {
|
||||
this._addon = new native.Panel(handle);
|
||||
} else {
|
||||
this._addon = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set NSPanel-grade properties: floatingPanel, becomesKeyOnlyIfNeeded,
|
||||
* hidesOnDeactivate:NO, canJoinAllSpaces, floatingWindowLevel.
|
||||
*/
|
||||
panelize(): boolean {
|
||||
return this._addon?.panelize() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a transparent native drag overlay at the given rect (top-left origin).
|
||||
* Replaces -webkit-app-region: drag with native performWindowDragWithEvent.
|
||||
*/
|
||||
enableNativeDrag(rect: Rect): boolean {
|
||||
return this._addon?.enableNativeDrag(rect) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the native drag overlay.
|
||||
*/
|
||||
disableNativeDrag(): boolean {
|
||||
return this._addon?.disableNativeDrag() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate window to a new frame with easeInOut timing.
|
||||
* @param frame Target frame {x, y, width, height} in screen coordinates.
|
||||
* @param duration Animation duration in seconds (default 0.2).
|
||||
*/
|
||||
animateResize(frame: Rect, duration = 0.2): boolean {
|
||||
return this._addon?.animateResize(frame, duration) ?? false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/electron-panel/js/
|
||||
git commit -m "✨ feat(electron-panel): TypeScript wrapper with platform-safe loading"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Build and verify native module loads
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/electron-panel/package.json` (install deps if needed)
|
||||
|
||||
- [ ] **Step 1: Install dependencies and build**
|
||||
|
||||
```bash
|
||||
cd packages/electron-panel
|
||||
pnpm install
|
||||
node-gyp configure
|
||||
node-gyp build
|
||||
```
|
||||
|
||||
Run: verify `build/Release/electron_panel.node` exists.
|
||||
|
||||
- [ ] **Step 2: Verify module loads in Node**
|
||||
|
||||
```bash
|
||||
node -e "const m = require('./build/Release/electron_panel.node'); console.log('Exports:', Object.keys(m)); console.log('Panel:', typeof m.Panel);"
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
Exports: [ 'Panel' ]
|
||||
Panel: function
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Copy to prebuilds for dev**
|
||||
|
||||
```bash
|
||||
mkdir -p prebuilds/darwin-$(uname -m)
|
||||
cp build/Release/electron_panel.node prebuilds/darwin-$(uname -m)/node.napi.node
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit prebuilds**
|
||||
|
||||
```bash
|
||||
git add packages/electron-panel/build/ packages/electron-panel/prebuilds/
|
||||
git commit -m "🔧 build(electron-panel): compile native module and add prebuilt"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Register in native-deps and integrate with build
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/desktop/native-deps.config.mjs:34-38`
|
||||
|
||||
- [ ] **Step 1: Add @lobechat/electron-panel to nativeModules**
|
||||
|
||||
In `apps/desktop/native-deps.config.mjs`, add to the darwin array:
|
||||
|
||||
```javascript
|
||||
export const nativeModules = [
|
||||
// macOS-only native modules
|
||||
...(isDarwin
|
||||
? ['node-mac-permissions', 'electron-liquid-glass', '@lobechat/electron-panel']
|
||||
: []),
|
||||
'@napi-rs/canvas',
|
||||
// Add more native modules here as needed
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify dependency resolution**
|
||||
|
||||
```bash
|
||||
cd apps/desktop
|
||||
node -e "import('./native-deps.config.mjs').then(m => console.log(m.getAllDependencies().filter(d => d.includes('electron-panel'))))"
|
||||
```
|
||||
|
||||
Expected: `['@lobechat/electron-panel']`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/native-deps.config.mjs
|
||||
git commit -m "🔧 build(desktop): register @lobechat/electron-panel in native-deps"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Integrate panelize into SpotlightCtr
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/desktop/src/main/controllers/SpotlightCtr.ts`
|
||||
|
||||
- Modify: `apps/desktop/src/main/core/browser/Browser.ts:176-179`
|
||||
|
||||
- [ ] **Step 1: Add Panel import and initialization in SpotlightCtr**
|
||||
|
||||
In `apps/desktop/src/main/controllers/SpotlightCtr.ts`, add:
|
||||
|
||||
```typescript
|
||||
import { Panel } from '@lobechat/electron-panel';
|
||||
```
|
||||
|
||||
Add a private field:
|
||||
|
||||
```typescript
|
||||
private panel?: Panel;
|
||||
private panelInitialized = false;
|
||||
```
|
||||
|
||||
Add initialization method:
|
||||
|
||||
```typescript
|
||||
private initializePanel(
|
||||
spotlight: ReturnType<typeof this.app.browserManager.retrieveByIdentifier>,
|
||||
) {
|
||||
if (this.panelInitialized) return;
|
||||
this.panelInitialized = true;
|
||||
|
||||
try {
|
||||
const handle = spotlight.browserWindow.getNativeWindowHandle();
|
||||
this.panel = new Panel(handle);
|
||||
this.panel.panelize();
|
||||
this.panel.enableNativeDrag({ x: 0, y: 0, width: 680, height: 44 });
|
||||
} catch (e) {
|
||||
console.error('[SpotlightCtr] Failed to initialize native panel:', e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Call initializePanel in toggleSpotlight**
|
||||
|
||||
In the `toggleSpotlight` method, after `await spotlight.whenReady()`, add:
|
||||
|
||||
```typescript
|
||||
this.initializePanel(spotlight);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove redundant Electron panel settings from Browser.ts**
|
||||
|
||||
In `apps/desktop/src/main/core/browser/Browser.ts`, the spotlight panel behavior block (lines 176-179) is now handled by the native addon. Remove:
|
||||
|
||||
```typescript
|
||||
// Spotlight: panel behavior
|
||||
if (this.identifier === 'spotlight') {
|
||||
browserWindow.setAlwaysOnTop(true, 'floating');
|
||||
browserWindow.setHiddenInMissionControl(true);
|
||||
browserWindow.setVisibleOnAllWorkspaces(true);
|
||||
}
|
||||
```
|
||||
|
||||
Note: `setHiddenInMissionControl` is NOT handled by the native addon (it's Electron-specific). Keep it or add it to panelize. For safety, keep only `setHiddenInMissionControl`:
|
||||
|
||||
```typescript
|
||||
// Spotlight: hide from Mission Control (Electron-specific API)
|
||||
if (this.identifier === 'spotlight') {
|
||||
browserWindow.setHiddenInMissionControl(true);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/main/controllers/SpotlightCtr.ts apps/desktop/src/main/core/browser/Browser.ts
|
||||
git commit -m "✨ feat(spotlight): integrate native Panel addon — panelize + native drag"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Integrate animateResize into spotlight resize
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/desktop/src/main/controllers/SpotlightCtr.ts`
|
||||
|
||||
- [ ] **Step 1: Replace setBounds with animateResize in the resize IPC handler**
|
||||
|
||||
In the `spotlight:resize` handler and the `resize` IPC method, replace `spotlight.browserWindow.setBounds(newBounds, true)` with `this.panel?.animateResize(...)`.
|
||||
|
||||
Note: `animateResize` takes screen coordinates (macOS bottom-left origin). Electron's `getBounds()` returns screen coordinates already (top-left origin on macOS for Electron). The native `setFrame:display:animate:` expects macOS screen coords (bottom-left). We need to convert.
|
||||
|
||||
Actually, Electron's `getBounds()` already returns screen-level coordinates that account for macOS coordinate system internally. But `NSWindow.setFrame` uses macOS native coords (bottom-left origin). We need to convert y:
|
||||
|
||||
```
|
||||
macOS_y = screen_height - electron_y - window_height
|
||||
```
|
||||
|
||||
However, this gets complex. A simpler approach: use Electron's `setBounds` for positioning (which handles coordinate conversion) and only use `animateResize` for the animation aspect. OR, do the conversion in the Obj-C++ layer.
|
||||
|
||||
**Revised approach:** Add a helper in panel_mac.mm that takes Electron-style bounds (top-left origin) and converts internally. Actually, the simplest approach is to keep using `NSWindow.frame` (which is already in native coords) and just do offset math in native code.
|
||||
|
||||
**Simplest approach for now:** In the resize handler, get current native frame from the window, compute delta, and set new frame. But this adds complexity.
|
||||
|
||||
**Pragmatic approach:** For the initial integration, use `animateResize` only when expanding/collapsing (height change) since that's where animation matters most. Pass Electron bounds through, and in the Obj-C++ layer, read the window's current screen and convert.
|
||||
|
||||
Let me simplify: keep `setBounds` for now but add an optional `animate` parameter that uses native animation. We'll address this in a focused follow-up.
|
||||
|
||||
**Updated Step 1:** Replace `setBounds` calls to use Panel's animateResize with coordinate conversion.
|
||||
|
||||
In SpotlightCtr, update the `resize` method:
|
||||
|
||||
```typescript
|
||||
@IpcMethod()
|
||||
async resize(params: { height: number; width: number }) {
|
||||
const spotlight = this.app.browserManager.browsers.get(BrowsersIdentifiers.spotlight);
|
||||
if (!spotlight) return;
|
||||
|
||||
const currentBounds = spotlight.browserWindow.getBounds();
|
||||
const newBounds = {
|
||||
height: params.height,
|
||||
width: params.width,
|
||||
x: currentBounds.x,
|
||||
y: currentBounds.y,
|
||||
};
|
||||
|
||||
if (spotlight.expandDirection === 'up' && params.height > currentBounds.height) {
|
||||
newBounds.y = currentBounds.y - (params.height - currentBounds.height);
|
||||
}
|
||||
|
||||
// Use native animated resize if panel addon is available
|
||||
if (this.panel) {
|
||||
// Convert Electron screen coords (top-left) to macOS screen coords (bottom-left)
|
||||
const { screen } = await import('electron');
|
||||
const display = screen.getDisplayNearestPoint({ x: newBounds.x, y: newBounds.y });
|
||||
const screenHeight = display.bounds.y + display.bounds.height;
|
||||
const macY = screenHeight - newBounds.y - newBounds.height;
|
||||
|
||||
this.panel.animateResize(
|
||||
{ x: newBounds.x, y: macY, width: newBounds.width, height: newBounds.height },
|
||||
0.15,
|
||||
);
|
||||
} else {
|
||||
spotlight.browserWindow.setBounds(newBounds, true);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also update the `spotlight:resize` ipcMain handler similarly (or better: remove the duplicate handler and use only the IpcMethod).
|
||||
|
||||
- [ ] **Step 2: Remove the duplicate spotlight:resize ipcMain.handle**
|
||||
|
||||
In `afterAppReady()`, the `spotlight:resize` handler duplicates the `resize` IpcMethod. Remove it (the renderer should use the IpcMethod via the standard invoke pattern).
|
||||
|
||||
If the renderer currently calls `spotlight:resize` directly, keep both for now and mark the old one as deprecated.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/main/controllers/SpotlightCtr.ts
|
||||
git commit -m "✨ feat(spotlight): use native animateResize for smooth expand/collapse"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Manual verification
|
||||
|
||||
- [ ] **Step 1: Start the desktop app in dev mode**
|
||||
|
||||
```bash
|
||||
cd apps/desktop
|
||||
bun run dev
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify panelize behavior**
|
||||
|
||||
Trigger Spotlight (hotkey). Verify:
|
||||
|
||||
- Window floats above all windows
|
||||
|
||||
- Window does not hide when clicking another app
|
||||
|
||||
- Window visible on all Spaces (switch Space and re-trigger)
|
||||
|
||||
- [ ] **Step 3: Verify native drag**
|
||||
|
||||
- Click and drag on the top 44px of the Spotlight window
|
||||
|
||||
- Verify smooth window drag without flicker
|
||||
|
||||
- [ ] **Step 4: Verify animated resize**
|
||||
|
||||
- Type a query to trigger chat expansion
|
||||
|
||||
- Verify smooth animated height change (not instant jump)
|
||||
|
||||
- [ ] **Step 5: Final commit if any fixes needed**
|
||||
|
||||
```bash
|
||||
git add -u
|
||||
git commit -m "🐛 fix(electron-panel): post-verification adjustments"
|
||||
```
|
||||
@@ -0,0 +1,833 @@
|
||||
# Spotlight Chat Implementation Plan (Plan 2 of 2)
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Wire up real chat functionality in the Spotlight window: message rendering, atomic send flow via TRPC + streaming, cross-window sync, and "open in main window".
|
||||
|
||||
**Architecture:** Spotlight calls TRPC `aiChat.sendMessageInServer` for atomic message creation, then `chatService.createAssistantMessageStream()` for streaming. Messages rendered via existing `CustomMDX` component (react-markdown + @lobehub/ui code blocks). Cross-window sync via `syncData` IPC broadcast.
|
||||
|
||||
**Tech Stack:** TRPC, chatService, react-markdown (via CustomMDX), @lobehub/ui (Pre/code blocks), Zustand, `@lobechat/electron-client-ipc`
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-17-spotlight-ui-design.md`
|
||||
|
||||
**Depends on:** Plan 1 (completed) — UI shell, store, IPC infrastructure
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
# New files
|
||||
src/features/Spotlight/ChatView/SpotlightMessage.tsx — single message bubble (reuses CustomMDX)
|
||||
src/features/Spotlight/ChatView/MessageList.tsx — scrollable message list
|
||||
src/features/Spotlight/services/chat.ts — spotlight chat service (TRPC + streaming)
|
||||
src/features/Spotlight/store/chatActions.ts — send message action for spotlight store
|
||||
|
||||
# Modified files
|
||||
src/features/Spotlight/store.ts — integrate chat actions, add reset-on-hide
|
||||
src/features/Spotlight/ChatView/index.tsx — replace skeleton with real components
|
||||
src/features/Spotlight/index.tsx — wire send to chat action, handle hide reset
|
||||
src/spa/entry.spotlight.tsx — add syncData listener for cross-window sync
|
||||
apps/desktop/src/main/controllers/SpotlightCtr.ts — add notifySync IPC handler
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Message Rendering
|
||||
|
||||
### Task 1: Create SpotlightMessage component
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/features/Spotlight/ChatView/SpotlightMessage.tsx`
|
||||
|
||||
- [ ] **Step 1: Create SpotlightMessage**
|
||||
|
||||
Reuses the existing `CustomMDX` component from `@/components/mdx` for markdown rendering. This component already integrates react-markdown + @lobehub/ui code blocks (which use shiki internally). No custom markdown renderer needed.
|
||||
|
||||
```typescript
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { CustomMDX } from '@/components/mdx';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
assistant: css`
|
||||
color: ${token.colorText};
|
||||
`,
|
||||
container: css`
|
||||
padding: 8px 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
`,
|
||||
cursor: css`
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
margin-left: 2px;
|
||||
vertical-align: text-bottom;
|
||||
background: ${token.colorPrimary};
|
||||
animation: blink 1s step-end infinite;
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
user: css`
|
||||
padding: 8px 12px;
|
||||
color: ${token.colorText};
|
||||
background: ${token.colorFillTertiary};
|
||||
border-radius: 12px;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface SpotlightMessageProps {
|
||||
content: string;
|
||||
loading?: boolean;
|
||||
role: 'user' | 'assistant';
|
||||
}
|
||||
|
||||
const SpotlightMessage = memo<SpotlightMessageProps>(({ content, loading, role }) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
if (role === 'user') {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.user}>{content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.assistant}>
|
||||
<CustomMDX source={content} />
|
||||
{loading && <span className={styles.cursor} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SpotlightMessage.displayName = 'SpotlightMessage';
|
||||
|
||||
export default SpotlightMessage;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/Spotlight/ChatView/SpotlightMessage.tsx
|
||||
git commit -m "feat(spotlight): create SpotlightMessage with CustomMDX rendering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create MessageList and update ChatView
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/features/Spotlight/ChatView/MessageList.tsx`
|
||||
|
||||
- Modify: `src/features/Spotlight/ChatView/index.tsx`
|
||||
|
||||
- [ ] **Step 1: Create MessageList**
|
||||
|
||||
```typescript
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
|
||||
import { useSpotlightStore } from '../store';
|
||||
import SpotlightMessage from './SpotlightMessage';
|
||||
|
||||
const useStyles = createStyles(({ css }) => ({
|
||||
container: css`
|
||||
flex: 1;
|
||||
padding: 8px 16px;
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
`,
|
||||
}));
|
||||
|
||||
const MessageList = memo(() => {
|
||||
const { styles } = useStyles();
|
||||
const messages = useSpotlightStore((s) => s.messages);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom on new messages or content updates
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={styles.container}>
|
||||
{messages.map((msg) => (
|
||||
<SpotlightMessage
|
||||
key={msg.id}
|
||||
content={msg.content}
|
||||
loading={msg.loading}
|
||||
role={msg.role}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MessageList.displayName = 'MessageList';
|
||||
|
||||
export default MessageList;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update ChatView to use real components**
|
||||
|
||||
Replace `src/features/Spotlight/ChatView/index.tsx`:
|
||||
|
||||
```typescript
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useSpotlightStore } from '../store';
|
||||
import MessageList from './MessageList';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
container: css`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
`,
|
||||
expandButton: css`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
align-self: flex-end;
|
||||
padding: 4px 8px;
|
||||
margin: 4px 12px;
|
||||
font-size: 11px;
|
||||
color: ${token.colorTextTertiary};
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: ${token.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
const ChatView = memo(() => {
|
||||
const { styles } = useStyles();
|
||||
const topicId = useSpotlightStore((s) => s.topicId);
|
||||
|
||||
const handleExpandToMain = async () => {
|
||||
const { agentId, groupId } = useSpotlightStore.getState();
|
||||
if (!topicId) return;
|
||||
await window.electronAPI?.invoke?.('spotlight.expandToMain', { agentId, groupId, topicId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{topicId && (
|
||||
<button className={styles.expandButton} onClick={handleExpandToMain}>
|
||||
↗ Open in main window
|
||||
</button>
|
||||
)}
|
||||
<MessageList />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ChatView.displayName = 'ChatView';
|
||||
|
||||
export default ChatView;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/Spotlight/ChatView/MessageList.tsx src/features/Spotlight/ChatView/index.tsx
|
||||
git commit -m "feat(spotlight): wire MessageList and ChatView with real SpotlightMessage rendering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Send Message Flow
|
||||
|
||||
### Task 3: Create spotlight chat service
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/features/Spotlight/services/chat.ts`
|
||||
|
||||
- [ ] **Step 1: Create the service**
|
||||
|
||||
This is a thin wrapper around the existing `chatService` and TRPC endpoint. It handles:
|
||||
|
||||
1. Atomic message creation via TRPC `aiChat.sendMessageInServer`
|
||||
2. Streaming via `chatService.createAssistantMessageStream`
|
||||
|
||||
```typescript
|
||||
import { chatService } from '@/services/chat';
|
||||
|
||||
interface SendSpotlightMessageParams {
|
||||
abortController: AbortController;
|
||||
agentId: string;
|
||||
content: string;
|
||||
groupId?: string;
|
||||
model: string;
|
||||
onContentUpdate: (content: string) => void;
|
||||
onError: (error: Error) => void;
|
||||
onFinish: () => void;
|
||||
provider: string;
|
||||
topicId?: string;
|
||||
}
|
||||
|
||||
interface SendSpotlightMessageResult {
|
||||
assistantMessageId: string;
|
||||
topicId: string;
|
||||
userMessageId: string;
|
||||
}
|
||||
|
||||
export const sendSpotlightMessage = async (
|
||||
params: SendSpotlightMessageParams,
|
||||
): Promise<SendSpotlightMessageResult | null> => {
|
||||
const {
|
||||
content,
|
||||
agentId,
|
||||
groupId,
|
||||
topicId,
|
||||
model,
|
||||
provider,
|
||||
abortController,
|
||||
onContentUpdate,
|
||||
onError,
|
||||
onFinish,
|
||||
} = params;
|
||||
|
||||
try {
|
||||
// Step 1: Create topic + messages atomically via TRPC
|
||||
const serverResult = await (
|
||||
window as any
|
||||
).__SPOTLIGHT_TRPC__?.aiChat.sendMessageInServer.mutate({
|
||||
agentId,
|
||||
groupId,
|
||||
newAssistantMessage: { model, provider },
|
||||
newTopic: topicId ? undefined : { title: content.slice(0, 50), topicMessageIds: [] },
|
||||
newUserMessage: { content },
|
||||
topicId,
|
||||
});
|
||||
|
||||
if (!serverResult) {
|
||||
onError(new Error('Failed to create messages'));
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedTopicId = serverResult.topicId || topicId || '';
|
||||
|
||||
// Step 2: Stream AI response
|
||||
let accumulatedContent = '';
|
||||
|
||||
await chatService.createAssistantMessageStream({
|
||||
abortController,
|
||||
onErrorHandle: (error) => {
|
||||
onError(new Error(typeof error === 'string' ? error : error.message || 'Stream error'));
|
||||
},
|
||||
onFinish: () => {
|
||||
onFinish();
|
||||
},
|
||||
onMessageHandle: (chunk: any) => {
|
||||
if (chunk.type === 'text') {
|
||||
accumulatedContent += chunk.text;
|
||||
onContentUpdate(accumulatedContent);
|
||||
}
|
||||
},
|
||||
params: {
|
||||
agentId,
|
||||
groupId,
|
||||
messages: [{ content, role: 'user' }] as any,
|
||||
model,
|
||||
provider,
|
||||
resolvedAgentConfig: { model, provider, params: {} } as any,
|
||||
topicId: resolvedTopicId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
assistantMessageId: serverResult.assistantMessageId,
|
||||
topicId: resolvedTopicId,
|
||||
userMessageId: serverResult.userMessageId,
|
||||
};
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Important note for implementer:** The TRPC client setup in spotlight's minimal provider chain may not include the full TRPC client. The `chatService` works because it uses raw `fetch` internally. However, `aiChat.sendMessageInServer` requires TRPC. Check if `QueryProvider` in `entry.spotlight.tsx` already sets up TRPC (it uses the same `QueryProvider` as main app which includes TRPC). If not, this needs to be verified during integration testing.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/Spotlight/services/chat.ts
|
||||
git commit -m "feat(spotlight): create chat service for atomic send + streaming"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Create chat actions for spotlight store
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/features/Spotlight/store/chatActions.ts`
|
||||
|
||||
- Modify: `src/features/Spotlight/store.ts`
|
||||
|
||||
- [ ] **Step 1: Create chatActions**
|
||||
|
||||
```typescript
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
import { sendSpotlightMessage } from '../services/chat';
|
||||
|
||||
export interface ChatMessage {
|
||||
content: string;
|
||||
id: string;
|
||||
loading?: boolean;
|
||||
role: 'user' | 'assistant';
|
||||
}
|
||||
|
||||
export interface SpotlightChatActions {
|
||||
abortStreaming: () => void;
|
||||
resetChat: () => void;
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface SpotlightChatState {
|
||||
_abortController: AbortController | null;
|
||||
messages: ChatMessage[];
|
||||
streaming: boolean;
|
||||
topicId: string | null;
|
||||
}
|
||||
|
||||
export const chatInitialState: SpotlightChatState = {
|
||||
_abortController: null,
|
||||
messages: [],
|
||||
streaming: false,
|
||||
topicId: null,
|
||||
};
|
||||
|
||||
export const createChatActions: StateCreator<
|
||||
SpotlightChatState &
|
||||
SpotlightChatActions & {
|
||||
agentId: string;
|
||||
currentModel: { model: string; provider: string };
|
||||
groupId?: string;
|
||||
},
|
||||
[],
|
||||
[],
|
||||
SpotlightChatActions
|
||||
> = (set, get) => ({
|
||||
abortStreaming: () => {
|
||||
const { _abortController } = get();
|
||||
_abortController?.abort();
|
||||
set({ _abortController: null, streaming: false });
|
||||
// Update loading state on last message
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg, i) =>
|
||||
i === state.messages.length - 1 && msg.role === 'assistant'
|
||||
? { ...msg, loading: false }
|
||||
: msg,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
resetChat: () => {
|
||||
const { _abortController } = get();
|
||||
_abortController?.abort();
|
||||
set(chatInitialState);
|
||||
// Notify main process
|
||||
window.electronAPI?.invoke?.('spotlight:setChatState', false);
|
||||
},
|
||||
|
||||
sendMessage: async (content: string) => {
|
||||
const { agentId, currentModel, groupId, topicId } = get();
|
||||
const userMsgId = nanoid();
|
||||
const assistantMsgId = nanoid();
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Optimistic: add user message + loading assistant message
|
||||
set((state) => ({
|
||||
_abortController: abortController,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{ content, id: userMsgId, role: 'user' as const },
|
||||
{ content: '', id: assistantMsgId, loading: true, role: 'assistant' as const },
|
||||
],
|
||||
streaming: true,
|
||||
}));
|
||||
|
||||
const result = await sendSpotlightMessage({
|
||||
abortController,
|
||||
agentId,
|
||||
content,
|
||||
groupId,
|
||||
model: currentModel.model,
|
||||
onContentUpdate: (updatedContent) => {
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === assistantMsgId ? { ...msg, content: updatedContent } : msg,
|
||||
),
|
||||
}));
|
||||
},
|
||||
onError: (error) => {
|
||||
set((state) => ({
|
||||
_abortController: null,
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? { ...msg, content: `Error: ${error.message}`, loading: false }
|
||||
: msg,
|
||||
),
|
||||
streaming: false,
|
||||
}));
|
||||
},
|
||||
onFinish: () => {
|
||||
set((state) => ({
|
||||
_abortController: null,
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === assistantMsgId ? { ...msg, loading: false } : msg,
|
||||
),
|
||||
streaming: false,
|
||||
}));
|
||||
|
||||
// Notify other windows to sync
|
||||
window.electronAPI?.invoke?.('spotlight.notifySync', {
|
||||
keys: ['chat/messages', 'chat/topics'],
|
||||
});
|
||||
},
|
||||
provider: currentModel.provider,
|
||||
topicId: topicId || undefined,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
set({ topicId: result.topicId });
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update store.ts to integrate chat actions**
|
||||
|
||||
Read the current `src/features/Spotlight/store.ts`, then replace with:
|
||||
|
||||
```typescript
|
||||
import { create } from 'zustand';
|
||||
|
||||
import {
|
||||
type ChatMessage,
|
||||
type SpotlightChatActions,
|
||||
type SpotlightChatState,
|
||||
chatInitialState,
|
||||
createChatActions,
|
||||
} from './store/chatActions';
|
||||
|
||||
export type { ChatMessage };
|
||||
|
||||
interface SpotlightUIState {
|
||||
activePlugins: string[];
|
||||
agentId: string;
|
||||
currentModel: { model: string; provider: string };
|
||||
groupId?: string;
|
||||
inputValue: string;
|
||||
viewState: 'input' | 'chat';
|
||||
}
|
||||
|
||||
interface SpotlightUIActions {
|
||||
reset: () => void;
|
||||
setCurrentModel: (model: { model: string; provider: string }) => void;
|
||||
setInputValue: (value: string) => void;
|
||||
setViewState: (state: 'input' | 'chat') => void;
|
||||
togglePlugin: (pluginId: string) => void;
|
||||
}
|
||||
|
||||
type SpotlightStore = SpotlightUIState &
|
||||
SpotlightUIActions &
|
||||
SpotlightChatState &
|
||||
SpotlightChatActions;
|
||||
|
||||
const uiInitialState: SpotlightUIState = {
|
||||
activePlugins: [],
|
||||
agentId: 'default',
|
||||
currentModel: { model: '', provider: '' },
|
||||
inputValue: '',
|
||||
viewState: 'input',
|
||||
};
|
||||
|
||||
export const useSpotlightStore = create<SpotlightStore>()((...args) => {
|
||||
const [set] = args;
|
||||
|
||||
return {
|
||||
...uiInitialState,
|
||||
...chatInitialState,
|
||||
|
||||
...createChatActions(...args),
|
||||
|
||||
reset: () => {
|
||||
const chatActions = createChatActions(...args);
|
||||
chatActions.resetChat();
|
||||
set(uiInitialState);
|
||||
},
|
||||
|
||||
setCurrentModel: (model) => set({ currentModel: model }),
|
||||
|
||||
setInputValue: (value) => set({ inputValue: value }),
|
||||
|
||||
setViewState: (viewState) => {
|
||||
set({ viewState });
|
||||
window.electronAPI?.invoke?.('spotlight:setChatState', viewState === 'chat');
|
||||
},
|
||||
|
||||
togglePlugin: (pluginId) =>
|
||||
set((state) => ({
|
||||
activePlugins: state.activePlugins.includes(pluginId)
|
||||
? state.activePlugins.filter((id) => id !== pluginId)
|
||||
: [...state.activePlugins, pluginId],
|
||||
})),
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/Spotlight/store/chatActions.ts src/features/Spotlight/store.ts
|
||||
git commit -m "feat(spotlight): integrate chat actions with store — send, abort, reset"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Wire SpotlightWindow to real send flow
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/features/Spotlight/index.tsx`
|
||||
|
||||
- [ ] **Step 1: Update SpotlightWindow**
|
||||
|
||||
Read the current file, then update `handleSubmit` to call the real `sendMessage` action and handle hide → reset:
|
||||
|
||||
```typescript
|
||||
import { lazy, memo, Suspense, useCallback } from 'react';
|
||||
|
||||
import InputArea from './InputArea';
|
||||
import { useSpotlightStore } from './store';
|
||||
import { useStyles } from './style';
|
||||
|
||||
const ChatView = lazy(() => import('./ChatView'));
|
||||
|
||||
const SpotlightWindow = memo(() => {
|
||||
const { styles } = useStyles();
|
||||
const viewState = useSpotlightStore((s) => s.viewState);
|
||||
const inputValue = useSpotlightStore((s) => s.inputValue);
|
||||
const setInputValue = useSpotlightStore((s) => s.setInputValue);
|
||||
const setViewState = useSpotlightStore((s) => s.setViewState);
|
||||
const sendMessage = useSpotlightStore((s) => s.sendMessage);
|
||||
|
||||
const handleHide = useCallback(() => {
|
||||
// Reset to input state when hiding
|
||||
const { viewState: currentView, streaming } = useSpotlightStore.getState();
|
||||
if (currentView === 'chat' && !streaming) {
|
||||
// Reset chat state and resize back to input size
|
||||
useSpotlightStore.getState().resetChat();
|
||||
setViewState('input');
|
||||
window.electronAPI?.invoke?.('spotlight:resize', { height: 120, width: 680 });
|
||||
}
|
||||
window.electronAPI?.invoke?.('spotlight:hide');
|
||||
}, [setViewState]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (value: string) => {
|
||||
if (value.startsWith('>')) {
|
||||
handleHide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith('@')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Chat mode: expand window and switch to chat view
|
||||
if (viewState === 'input') {
|
||||
window.electronAPI?.invoke?.('spotlight:resize', { height: 480, width: 680 });
|
||||
setViewState('chat');
|
||||
}
|
||||
|
||||
setInputValue('');
|
||||
await sendMessage(value);
|
||||
},
|
||||
[handleHide, viewState, setViewState, setInputValue, sendMessage],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.dragHandle} />
|
||||
|
||||
{viewState === 'chat' && (
|
||||
<Suspense fallback={null}>
|
||||
<ChatView />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
<InputArea
|
||||
value={inputValue}
|
||||
onEscape={handleHide}
|
||||
onSubmit={handleSubmit}
|
||||
onValueChange={setInputValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SpotlightWindow.displayName = 'SpotlightWindow';
|
||||
|
||||
export default SpotlightWindow;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/features/Spotlight/index.tsx
|
||||
git commit -m "feat(spotlight): wire SpotlightWindow to real send flow with hide-reset"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 3: Cross-Window Sync
|
||||
|
||||
### Task 6: Add notifySync IPC and syncData broadcast
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/desktop/src/main/controllers/SpotlightCtr.ts`
|
||||
|
||||
- Modify: `src/spa/entry.spotlight.tsx`
|
||||
|
||||
- [ ] **Step 1: Add notifySync IPC handler to SpotlightCtr**
|
||||
|
||||
Read `SpotlightCtr.ts`, then add a new `@IpcMethod()` after `expandToMain`:
|
||||
|
||||
```typescript
|
||||
@IpcMethod()
|
||||
async notifySync(params: { keys: string[] }) {
|
||||
this.app.browserManager.broadcastToOtherWindows(
|
||||
'syncData',
|
||||
{ keys: params.keys, source: 'spotlight' },
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Also add a matching `ipcMain.handle` in `afterAppReady()` for the direct `spotlight.notifySync` channel (since chatActions calls it directly):
|
||||
|
||||
```typescript
|
||||
ipcMain.handle('spotlight:notifySync', (_event, params: { keys: string[] }) => {
|
||||
this.app.browserManager.broadcastToOtherWindows('syncData', {
|
||||
keys: params.keys,
|
||||
source: 'spotlight',
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Wait — the chat actions in `store/chatActions.ts` calls `window.electronAPI?.invoke?.('spotlight.notifySync', ...)`. The `window.electronAPI.invoke` maps to `ipcRenderer.invoke`, which goes through the IPC proxy (`groupName.methodName` pattern). Since `SpotlightCtr.groupName = 'spotlight'` and the method is `notifySync`, the channel is `spotlight.notifySync`. The `@IpcMethod()` decorator auto-registers this. So the `ipcMain.handle` is NOT needed — the decorator handles it. Only add the `@IpcMethod()` method.
|
||||
|
||||
- [ ] **Step 2: Add syncData listener in entry.spotlight.tsx**
|
||||
|
||||
Read `src/spa/entry.spotlight.tsx`, then add a listener in the `App` component's useEffect for syncData from other windows (so spotlight can also receive sync notifications):
|
||||
|
||||
Actually, the main use case is spotlight → main window sync. The reverse (main window → spotlight) is less critical for now since spotlight manages its own state. Skip this for now — the main window needs the listener, and that belongs in the main app's code, not in spotlight's entry. The `useWatchBroadcast` hook in the main app will handle it.
|
||||
|
||||
For the main app to respond to `syncData`:
|
||||
|
||||
Create a small hook `src/hooks/useSyncDataBroadcast.ts`:
|
||||
|
||||
```typescript
|
||||
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
|
||||
import { useSWRConfig } from 'swr';
|
||||
|
||||
export const useSyncDataBroadcast = () => {
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
useWatchBroadcast('syncData', (data) => {
|
||||
if (data?.keys) {
|
||||
data.keys.forEach((key: string) => {
|
||||
mutate(key);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
Then add `useSyncDataBroadcast()` in `src/layout/SPAGlobalProvider/index.tsx` (inside the SPAGlobalProvider component, after the existing useLayoutEffect).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/desktop/src/main/controllers/SpotlightCtr.ts src/hooks/useSyncDataBroadcast.ts src/layout/SPAGlobalProvider/index.tsx
|
||||
git commit -m "feat(spotlight): wire syncData cross-window broadcast for SWR revalidation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Integration testing checklist
|
||||
|
||||
- [ ] **Step 1: Start dev environment**
|
||||
|
||||
```bash
|
||||
cd apps/desktop && bun run dev
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify spotlight window behavior**
|
||||
|
||||
- Press `Cmd+Shift+Space` → spotlight appears at cursor
|
||||
|
||||
- Type text → textarea auto-grows
|
||||
|
||||
- Click model chip → native menu appears with models
|
||||
|
||||
- Press Enter → window expands to chat size
|
||||
|
||||
- User message appears, AI response streams in with markdown
|
||||
|
||||
- "Open in main window" → main window navigates to topic, spotlight hides
|
||||
|
||||
- Esc or hotkey → spotlight hides, resets to input state on next open
|
||||
|
||||
- [ ] **Step 3: Verify cross-window sync**
|
||||
|
||||
- Send message in spotlight
|
||||
|
||||
- Check main window sidebar shows new topic
|
||||
|
||||
- If not, check console for syncData broadcast errors
|
||||
|
||||
- [ ] **Step 4: Fix any issues found**
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Task | Description | Files |
|
||||
| ---- | -------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| 1 | SpotlightMessage component (CustomMDX) | `ChatView/SpotlightMessage.tsx` (new) |
|
||||
| 2 | MessageList + ChatView update | `ChatView/MessageList.tsx` (new), `ChatView/index.tsx` |
|
||||
| 3 | Spotlight chat service (TRPC + stream) | `services/chat.ts` (new) |
|
||||
| 4 | Chat store actions + store integration | `store/chatActions.ts` (new), `store.ts` |
|
||||
| 5 | SpotlightWindow send flow + hide reset | `index.tsx` |
|
||||
| 6 | syncData broadcast wiring | `SpotlightCtr.ts`, `useSyncDataBroadcast.ts` (new), `SPAGlobalProvider` |
|
||||
| 7 | Integration testing | — |
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
# Electron Panel Native Addon Design
|
||||
|
||||
> Plan 3 of Spotlight Window — NSPanel N-API addon for Raycast-like panel behavior.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace Electron `BrowserWindow` panel-level workarounds with true NSPanel-grade behavior via a native N-API addon. The addon converts an existing `NSWindow` (obtained from `getNativeWindowHandle()`) into a floating panel with native drag and animated resize.
|
||||
|
||||
## Package
|
||||
|
||||
- **Name:** `@lobechat/electron-panel`
|
||||
- **Location:** `packages/electron-panel/` (monorepo internal)
|
||||
- **Platform:** macOS only; other platforms export no-op stubs
|
||||
- **Build:** `prebuildify --napi` (arm64 + x64), loaded via `node-gyp-build`
|
||||
|
||||
## Architecture
|
||||
|
||||
### Strategy: Runtime Property Injection
|
||||
|
||||
Rather than isa-swizzling `NSWindow` → `NSPanel` (risky, breaks Electron internals), we set NSPanel-equivalent properties on the existing `NSWindow` at runtime. This covers all behaviors needed for Spotlight.
|
||||
|
||||
### API: ObjectWrap Instance
|
||||
|
||||
Follows the `electron-liquid-glass` pattern — `Napi::ObjectWrap<Panel>` bound once to a window handle, methods called on the instance.
|
||||
|
||||
## Package Structure
|
||||
|
||||
```
|
||||
packages/electron-panel/
|
||||
├── package.json
|
||||
├── binding.gyp
|
||||
├── src/
|
||||
│ ├── panel.cc # N-API bindings (Napi::ObjectWrap<Panel>)
|
||||
│ └── panel_mac.mm # Objective-C++ native implementation
|
||||
├── js/
|
||||
│ └── index.ts # TypeScript wrapper + type exports
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Panelize
|
||||
|
||||
Set NSPanel-grade properties on the `NSWindow`:
|
||||
|
||||
```objc
|
||||
nsWindow.floatingPanel = YES;
|
||||
nsWindow.becomesKeyOnlyIfNeeded = YES;
|
||||
nsWindow.hidesOnDeactivate = NO;
|
||||
nsWindow.collectionBehavior |=
|
||||
NSWindowCollectionBehaviorCanJoinAllSpaces |
|
||||
NSWindowCollectionBehaviorFullScreenAuxiliary;
|
||||
nsWindow.level = NSFloatingWindowLevel;
|
||||
```
|
||||
|
||||
**Effect:** Window floats above all windows, does not steal focus from other apps, does not hide when app loses focus, visible on all Spaces.
|
||||
|
||||
### 2. Native Drag
|
||||
|
||||
Replace `-webkit-app-region: drag` with a native transparent `NSView` overlay:
|
||||
|
||||
- Create a transparent `NSView` subclass positioned at a caller-specified rect
|
||||
- Override `mouseDown:` / `mouseDragged:` to call `[nsWindow performWindowDragWithEvent:]`
|
||||
- Supports dynamic rect updates (e.g., when window expands, drag region changes)
|
||||
- `disableNativeDrag()` removes the overlay view
|
||||
|
||||
**Why native over CSS:** Smoother drag, no Electron event loop latency, consistent with macOS panel conventions.
|
||||
|
||||
### 3. Animated Resize
|
||||
|
||||
Replace `BrowserWindow.setBounds()` with native animated frame change:
|
||||
|
||||
```objc
|
||||
[NSAnimationContext runAnimationGroup:^(NSAnimationContext *ctx) {
|
||||
ctx.duration = duration;
|
||||
ctx.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
|
||||
[[nsWindow animator] setFrame:newFrame display:YES];
|
||||
}];
|
||||
```
|
||||
|
||||
**Parameters:** `{ x, y, width, height }` target frame, optional `duration` (default 0.2s).
|
||||
|
||||
## N-API Binding Layer (panel.cc)
|
||||
|
||||
```cpp
|
||||
class Panel : public Napi::ObjectWrap<Panel> {
|
||||
NSWindow* nsWindow_; // Cached from handle Buffer in constructor
|
||||
|
||||
static Napi::Object Init(Napi::Env env, Napi::Object exports);
|
||||
Panel(const Napi::CallbackInfo& info);
|
||||
|
||||
void Panelize(const Napi::CallbackInfo& info);
|
||||
void EnableNativeDrag(const Napi::CallbackInfo& info); // {x, y, width, height}
|
||||
void DisableNativeDrag(const Napi::CallbackInfo& info);
|
||||
void AnimateResize(const Napi::CallbackInfo& info); // {x, y, width, height}, duration?
|
||||
};
|
||||
```
|
||||
|
||||
**Handle resolution in constructor:**
|
||||
|
||||
```cpp
|
||||
auto buffer = info[0].As<Napi::Buffer<unsigned char>>();
|
||||
NSView* rootView = *reinterpret_cast<NSView**>(buffer.Data());
|
||||
nsWindow_ = [rootView window];
|
||||
```
|
||||
|
||||
**Thread safety:** All Objective-C calls dispatched via:
|
||||
|
||||
```cpp
|
||||
dispatch_sync(dispatch_get_main_queue(), ^{ /* ... */ });
|
||||
```
|
||||
|
||||
## TypeScript Wrapper (js/index.ts)
|
||||
|
||||
```typescript
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export class Panel {
|
||||
private _addon: NativePanel;
|
||||
|
||||
constructor(handle: Buffer) {
|
||||
this._addon = new NativePanel(handle);
|
||||
}
|
||||
|
||||
panelize(): void;
|
||||
enableNativeDrag(rect: Rect): void;
|
||||
disableNativeDrag(): void;
|
||||
animateResize(frame: Rect, duration?: number): void;
|
||||
}
|
||||
```
|
||||
|
||||
Non-macOS platforms export a `PanelStub` class with identical interface and no-op methods.
|
||||
|
||||
## Integration Points
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `apps/desktop/native-deps.config.mjs` | Add `@lobechat/electron-panel` to darwin native modules list |
|
||||
| `electron.vite.config.ts` | Covered by `getExternalDependencies()` |
|
||||
| `electron-builder.mjs` | Covered by `getAsarUnpackPatterns()` |
|
||||
| `SpotlightCtr.ts` | Initialize `Panel` on window ready, call `panelize()` + `enableNativeDrag()` |
|
||||
| `Browser.ts` | Remove Spotlight-specific `setAlwaysOnTop`/`setVisibleOnAllWorkspaces` (addon handles) |
|
||||
| Spotlight resize IPC | Replace `setBounds` with `panel.animateResize()` |
|
||||
|
||||
## SpotlightCtr Integration
|
||||
|
||||
```typescript
|
||||
import { Panel } from '@lobechat/electron-panel';
|
||||
|
||||
class SpotlightCtr {
|
||||
private panel?: Panel;
|
||||
|
||||
onSpotlightReady() {
|
||||
const handle = this.spotlight.browserWindow.getNativeWindowHandle();
|
||||
this.panel = new Panel(handle);
|
||||
this.panel.panelize();
|
||||
this.panel.enableNativeDrag({ x: 0, y: 0, width: 680, height: 44 });
|
||||
}
|
||||
|
||||
onResize(frame: Rect) {
|
||||
this.panel?.animateResize(frame);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Risks & Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `floatingPanel` property may not exist on `NSWindow` | Runtime check with `respondsToSelector:`, degrade gracefully |
|
||||
| Electron version upgrade breaks handle layout | Buffer → NSView\*\* pattern is stable across Electron versions (same as electron-liquid-glass) |
|
||||
| Native drag view conflicts with web content events | Drag view is transparent, positioned only over non-interactive header area |
|
||||
| `prebuildify` binary mismatch | CI builds for both arm64 and x64; fallback to runtime `node-gyp` compile |
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- isa-swizzle NSWindow → NSPanel (rejected: too risky)
|
||||
- Follow-cursor positioning (already implemented in SpotlightCtr)
|
||||
- Window appearance/vibrancy (handled by electron-liquid-glass)
|
||||
@@ -0,0 +1,352 @@
|
||||
# Spotlight UI Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
Full UI design for the LobeChat Desktop Spotlight mini-window. Builds on the existing Spotlight shell (window lifecycle, IPC, MPA entry) with a rich input panel, model/plugin selection, and in-window conversation rendering.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Textarea + Model/Plugin Chips + ShortcutBar layout (Kimi-style)
|
||||
- Model selection via Electron `Menu.popup()` native menu
|
||||
- Plugin chips (Web Search, Knowledge Base, etc.) as toggleable tags
|
||||
- Two view states: input (680×120) → chat (680×480, single-step expand)
|
||||
- In-window conversation with SpotlightMessage (lightweight, shiki code highlight)
|
||||
- `type: 'panel'` window for native panel behavior (no NSPanel addon needed in v1)
|
||||
- Smooth window resize via `setBounds({ animate: true })`
|
||||
- "Open in main window" to continue conversation in full app
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Electron BrowserWindow (type: 'panel')
|
||||
+ setHiddenInMissionControl(true)
|
||||
+ setVisibleOnAllWorkspaces(true)
|
||||
+ setAlwaysOnTop(true, 'floating')
|
||||
+ setBounds({ animate: true }) for resize
|
||||
│
|
||||
│ renders
|
||||
▼
|
||||
Spotlight Renderer (MPA entry)
|
||||
└─ SpotlightWindow
|
||||
├─ State: 'input' | 'chat'
|
||||
│
|
||||
├─ [MessageList] (chat state only, React.lazy)
|
||||
│ - SpotlightMessage (lightweight renderer)
|
||||
│ - Internal scroll, fixed height region
|
||||
│
|
||||
├─ [InputArea] (always visible)
|
||||
│ - Textarea (auto-grow, 1-4 lines)
|
||||
│ - Model Chip (→ Menu.popup())
|
||||
│ - Plugin Chips (toggleable)
|
||||
│ - Attachment button (paste image)
|
||||
│
|
||||
└─ [ShortcutBar] (always visible)
|
||||
- Context-aware keyboard hints
|
||||
```
|
||||
|
||||
## Window Configuration
|
||||
|
||||
### appBrowsers.ts update
|
||||
|
||||
```typescript
|
||||
spotlight: {
|
||||
identifier: 'spotlight',
|
||||
path: '/desktop/spotlight',
|
||||
keepAlive: true,
|
||||
showOnInit: false,
|
||||
skipSplash: true,
|
||||
skipTaskbar: true,
|
||||
type: 'panel',
|
||||
width: 680,
|
||||
height: 120,
|
||||
}
|
||||
```
|
||||
|
||||
### Post-creation setup
|
||||
|
||||
```typescript
|
||||
win.setAlwaysOnTop(true, 'floating');
|
||||
win.setHiddenInMissionControl(true);
|
||||
win.setVisibleOnAllWorkspaces(true);
|
||||
```
|
||||
|
||||
### Window sizing
|
||||
|
||||
| State | Size | Trigger |
|
||||
| ----- | --------- | --------------------------------------------------------- |
|
||||
| input | 680 × 120 | Initial state |
|
||||
| chat | 680 × 480 | First Enter send, one-time `setBounds({ animate: true })` |
|
||||
|
||||
Chat state uses internal scrolling for messages; no further window resize after initial expansion.
|
||||
|
||||
### Positioning with expand-aware boundary correction
|
||||
|
||||
`showAt(cursor)` reserves space for maximum height (480px) when calculating y position:
|
||||
|
||||
- If `cursor.y + 480 < screenBottom` → expand downward (normal)
|
||||
- If `cursor.y + 480 >= screenBottom` → expand upward (window above cursor)
|
||||
|
||||
On expand:
|
||||
|
||||
- Downward: keep x,y, increase height
|
||||
- Upward: y -= deltaHeight, window grows upward
|
||||
|
||||
### Blur behavior
|
||||
|
||||
| State | Blur action | Reason |
|
||||
| --------- | -------------- | --------------------------------------------- |
|
||||
| input | blur → hide | No popups, blur means user clicked outside |
|
||||
| chat | blur → no hide | User may switch to other window for reference |
|
||||
| menu open | blur → no hide | Menu.popup() callback re-enables |
|
||||
|
||||
Chat state close: `Esc` or re-press global hotkey.
|
||||
|
||||
### backgroundThrottling
|
||||
|
||||
Set to `true` (revised from original). Heavy components (markdown renderer, shiki) load only when visible + chat state via dynamic import. Hidden window is naturally throttled.
|
||||
|
||||
## UI Components
|
||||
|
||||
### InputArea
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Tell me about the architecture... 📎 │ ← Textarea (auto-grow, max 4 lines)
|
||||
│ │
|
||||
│ [AI Default ▼] [🌐 Web Search] [📚 KB] │ ← Chips row
|
||||
├──────────────────────────────────────────┤
|
||||
│ Esc Close ⌘V Paste Image Enter Send │ ← ShortcutBar
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Textarea:**
|
||||
|
||||
- Auto-growing height (1-4 lines), internal scroll beyond 4 lines
|
||||
- Right-side attachment button (📎) for paste image (⌘V)
|
||||
- `-webkit-app-region: no-drag`
|
||||
|
||||
**Model Chip:**
|
||||
|
||||
- Displays current model name + ▼ indicator
|
||||
- Click: renderer calls `useEnabledChatModels()` to get model list, serializes to `{ label, value, group }[]`, sends via IPC `spotlight.openModelMenu(items)` to main process
|
||||
- Main process builds `Menu` from the serialized items and calls `Menu.popup()`
|
||||
- During menu display, blur→hide is suppressed via popup callback
|
||||
- User selects → main process returns `{ model, provider }` via IPC response → renderer updates `currentModel`
|
||||
- Note: `useEnabledChatModels()` is a renderer-side React hook reading `useAiInfraStore`; main process has no React environment and cannot call it directly
|
||||
|
||||
**Plugin Chips:**
|
||||
|
||||
- Toggleable tag buttons (Web Search, Knowledge Base, etc.)
|
||||
- Active state: highlighted with accent color
|
||||
- Source: enabled plugins from agent/global config
|
||||
|
||||
**ShortcutBar:**
|
||||
|
||||
- Fixed at bottom, shows context-aware keyboard hints
|
||||
- Input state: `Esc Close` / `⌘V Paste` / `Enter Send`
|
||||
- Chat state: `Esc Close` / `⌘N New Chat` / `Enter Send`
|
||||
|
||||
### ChatView (chat state)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ [↗ Open in main window] │
|
||||
│ ┌── MessageList (scrollable) ────────┐ │
|
||||
│ │ You: Tell me about the arch... │ │
|
||||
│ │ │ │
|
||||
│ │ AI: The project uses Next.js 16... │ │
|
||||
│ │ ██████ (streaming) │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Continue the conversation... 📎 │ ← InputArea (collapses to single line)
|
||||
│ [AI Default ▼] [🌐 Web Search] │
|
||||
├──────────────────────────────────────────┤
|
||||
│ Esc Close ⌘N New Chat Enter Send │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**MessageList:**
|
||||
|
||||
- Uses `SpotlightMessage` component (see below)
|
||||
- `overflow-y: auto`, fixed height region
|
||||
- Auto-scroll to bottom on new messages
|
||||
|
||||
**"Open in main window" button:**
|
||||
|
||||
- Small button at top of MessageList
|
||||
- Click → IPC `spotlight.expandToMain({ topicId })` → main window navigates to topic → spotlight hides
|
||||
|
||||
## SpotlightMessage (Lightweight Renderer)
|
||||
|
||||
### Design principle
|
||||
|
||||
Pure props-driven component, zero store dependency. Does NOT reuse main window `MessageContent` (which depends on full conversation store, tool UI, plugin rendering).
|
||||
|
||||
### Interface
|
||||
|
||||
```typescript
|
||||
interface SpotlightMessageProps {
|
||||
content: string;
|
||||
loading?: boolean; // streaming indicator
|
||||
role: 'user' | 'assistant';
|
||||
}
|
||||
```
|
||||
|
||||
### Rendering capabilities
|
||||
|
||||
| Supported | Not supported (v1) |
|
||||
| ----------------------------------------------- | ------------------------------------------ |
|
||||
| Markdown (headings, lists, bold, italic, links) | Tool call result custom UI (text fallback) |
|
||||
| Code blocks + shiki syntax highlighting | Image preview / gallery |
|
||||
| Inline code | Vote / edit / retry buttons |
|
||||
| Tables | File attachment preview |
|
||||
| Blockquotes | Artifacts |
|
||||
|
||||
Tool calls in spotlight display as text summary, not full Tool UI.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `react-markdown` — already in project
|
||||
- `shiki` — already in project (`^3.21.0`), load common languages only (ts, js, python, bash, json, css, html)
|
||||
- Zero store dependency, pure props
|
||||
|
||||
### Dynamic import strategy
|
||||
|
||||
- InputView components (Textarea, Chips, ShortcutBar): bundled immediately in spotlight entry
|
||||
- SpotlightMessage + react-markdown + shiki: `React.lazy()` on first entry to chat state
|
||||
- Loading state: skeleton placeholder during import
|
||||
|
||||
## State Management
|
||||
|
||||
### Spotlight Zustand slice (independent of main window store)
|
||||
|
||||
```typescript
|
||||
interface SpotlightState {
|
||||
// Input
|
||||
inputValue: string;
|
||||
|
||||
// View state
|
||||
viewState: 'input' | 'chat';
|
||||
|
||||
// Model selection
|
||||
currentModel: { model: string; provider: string };
|
||||
|
||||
// Active plugins
|
||||
activePlugins: string[];
|
||||
|
||||
// Agent context (needed for topic creation and "open in main window")
|
||||
agentId: string; // default agent or last-used agent
|
||||
groupId?: string; // if group topic
|
||||
|
||||
// Chat
|
||||
topicId: string | null;
|
||||
messages: ChatMessage[];
|
||||
streaming: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Data flows
|
||||
|
||||
**Send message:**
|
||||
|
||||
The send flow must be atomic — topic creation, user message, assistant message, and streaming are handled as a single transaction, consistent with the main window's `conversationLifecycle.ts` pattern (`sendMessageInServer` combines newTopic + newUserMessage + newAssistantMessage + runtime start). Spotlight should reuse or mirror this service layer to avoid orphaned topics on failure.
|
||||
|
||||
```
|
||||
Enter pressed
|
||||
→ viewState: 'input' → 'chat'
|
||||
→ IPC: spotlight.resize({ width: 680, height: 480 }) // one-time expand
|
||||
→ Call sendMessage service (atomic: topic + userMsg + assistantMsg + stream)
|
||||
- If no topicId yet: creates topic as part of the atomic operation
|
||||
- Optimistic: user message shown immediately
|
||||
- Stream chunks update assistant message content
|
||||
→ SpotlightMessage renders stream content
|
||||
→ Stream ends → notify main window to revalidate (see Cross-window sync)
|
||||
→ On failure: error shown in chat area, no orphan topic created
|
||||
```
|
||||
|
||||
The spotlight renderer needs access to the same TRPC/service endpoints as the main window. Since both renderers share the same Electron session (cookies, auth), TRPC calls work identically. The spotlight may either:
|
||||
|
||||
- Import a minimal subset of the chat service layer (preferred: reuse `chatService.createAssistantMessage` etc.)
|
||||
- Or implement a thin wrapper that calls the same TRPC endpoints directly
|
||||
|
||||
**Model selection:**
|
||||
|
||||
```
|
||||
Click model chip
|
||||
→ Renderer calls useEnabledChatModels() to get model list
|
||||
→ Serializes to plain objects: { items: [{ label, value, provider, group }] }
|
||||
→ IPC: spotlight.openModelMenu({ items })
|
||||
→ Main process builds Menu from serialized items (provider groups as separators)
|
||||
→ Menu.popup() with blur suppression
|
||||
→ User selects → callback returns { model, provider }
|
||||
→ IPC response → renderer updates currentModel
|
||||
```
|
||||
|
||||
**Open in main window:**
|
||||
|
||||
Navigation in the main window requires `agentId` + `topicId` (route: `/agent/:agentId?topic=:topicId`). Group topics additionally need `groupId`. The spotlight store must track the active agent context.
|
||||
|
||||
```
|
||||
Click expand button
|
||||
→ IPC: spotlight.expandToMain({ agentId, topicId, groupId? })
|
||||
→ Main process: main window broadcast 'navigate' with full path
|
||||
- Agent topic: /agent/{agentId}?topic={topicId}
|
||||
- Group topic: /group/{groupId}?topic={topicId}
|
||||
→ Main process: spotlight.hide()
|
||||
```
|
||||
|
||||
**Cross-window sync:**
|
||||
|
||||
- DB as source of truth
|
||||
- New IPC broadcast event `syncData` must be added to `@lobechat/electron-client-ipc` `MainBroadcastEvents`:
|
||||
|
||||
```typescript
|
||||
// packages/electron-client-ipc/src/events/spotlight.ts
|
||||
export interface SpotlightBroadcastEvents {
|
||||
spotlightFocus: () => void;
|
||||
syncData: (data: { keys: string[]; source: string }) => void;
|
||||
}
|
||||
```
|
||||
|
||||
- After spotlight writes messages to DB → renderer sends IPC `spotlight.notifySync({ keys })` to main process → main process broadcasts `syncData` to all other windows via `broadcastToOtherWindows`
|
||||
- Receiving window's renderer listens via `useWatchBroadcast('syncData', ({ keys }) => { keys.forEach(k => mutate(k)) })`
|
||||
- Keys are SWR cache keys (e.g. `['chat/messages', 'chat/topics']`)
|
||||
- Fallback: SWR periodic revalidation (30s) ensures eventual consistency if broadcast lost
|
||||
- This same mechanism benefits any future multi-window scenario, not just spotlight
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Handling |
|
||||
| ---------------------------------- | -------------------------------------------------------------------------- |
|
||||
| Stream request fails | Error message in chat area, user can retry or switch to main window |
|
||||
| No models configured | Model chip shows "Not configured", click navigates to main window settings |
|
||||
| Paste image but model lacks vision | Toast: "Current model does not support images" |
|
||||
| Window position off-screen | Validate `screen.getDisplayNearestPoint()` before each show |
|
||||
| Renderer crash | `render-process-gone` → resetReady → reload (already implemented) |
|
||||
| Rapid hotkey presses | toggleSpotlight with debounce/lock to prevent show/hide race |
|
||||
|
||||
## Relation to Existing Implementation
|
||||
|
||||
This spec builds on the already-committed Spotlight shell:
|
||||
|
||||
**Already implemented (keep as-is):**
|
||||
|
||||
- `appBrowsers.ts` spotlight definition (update: change height to 120, add `type: 'panel'`)
|
||||
- `Browser.ts` extensions (showAt, whenReady, skipSplash — update: showAt uses max-height for boundary calc)
|
||||
- `BrowserManager.ts` extensions (broadcastToOtherWindows, onboarding gate)
|
||||
- `RendererUrlManager.ts` spotlight.html resolution
|
||||
- `SpotlightCtr.ts` controller (update: blur strategy, model menu IPC, expand-aware resize)
|
||||
- `electron-client-ipc` spotlightFocus event
|
||||
- `spotlight.html` + `entry.spotlight.tsx` MPA entry
|
||||
- `src/features/Spotlight/` shell (replace: InputBox → full InputArea, add ChatView)
|
||||
|
||||
**New in this spec:**
|
||||
|
||||
- `type: 'panel'` window configuration
|
||||
- InputArea with Textarea, Model Chip, Plugin Chips, ShortcutBar
|
||||
- ChatView with MessageList and SpotlightMessage
|
||||
- SpotlightMessage lightweight renderer (react-markdown + shiki)
|
||||
- Spotlight Zustand store slice
|
||||
- Model selection via Menu.popup() IPC flow
|
||||
- Expand-aware window positioning
|
||||
- State-dependent blur behavior
|
||||
@@ -0,0 +1,371 @@
|
||||
# Spotlight Window Design Spec
|
||||
|
||||
## Overview
|
||||
|
||||
A global-hotkey-invoked mini window for LobeChat Desktop, providing quick access to chat, commands, and search without switching to the main window.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Global hotkey summons window at cursor position
|
||||
- Single input box entry with smart routing: `>` commands, `@` search, plain text → chat
|
||||
- Command/search mode: auto-hide after execution; chat mode: persist window
|
||||
- Progressive rendering: lightweight input shell initially, full message components on demand
|
||||
- Bi-directional session sharing with main window (DB as source of truth)
|
||||
- Show latency < 100ms (pre-created hidden BrowserWindow)
|
||||
- macOS first; Windows later
|
||||
|
||||
## Architecture
|
||||
|
||||
### Phased Approach
|
||||
|
||||
- **v1**: Pure Electron BrowserWindow (`skipTaskbar` + `alwaysOnTop('floating')` + `blur` hide)
|
||||
- **v2**: Native NSPanel via Swift N-API addon for proper panel behavior + native drag
|
||||
|
||||
### Core Components
|
||||
|
||||
```
|
||||
Electron Main Process
|
||||
├── appBrowsers.ts ← add 'spotlight' static browser definition
|
||||
├── Browser ← extend with showAt(point) + whenReady()
|
||||
├── BrowserManager ← manages spotlight alongside existing windows
|
||||
├── ShortcutManager ← new spotlight shortcut registration
|
||||
└── SpotlightController ← IPC controller (show/hide/resize/invalidate)
|
||||
|
||||
Spotlight Renderer (independent MPA entry)
|
||||
├── apps/desktop/spotlight.html ← lightweight HTML entry
|
||||
├── src/spa/entry.spotlight.tsx ← minimal provider chain
|
||||
└── src/routes/desktop/spotlight/ ← spotlight route components
|
||||
```
|
||||
|
||||
### Window Definition (appBrowsers.ts)
|
||||
|
||||
```typescript
|
||||
spotlight: {
|
||||
identifier: 'spotlight',
|
||||
path: '/desktop/spotlight',
|
||||
keepAlive: true,
|
||||
showOnInit: false,
|
||||
skipSplash: true, // load spotlight route directly, no splash.html
|
||||
options: {
|
||||
width: 680,
|
||||
height: 56, // input box only
|
||||
frame: false,
|
||||
transparent: true, // NOTE: Browser class strips transparent in constructor,
|
||||
// must bypass WindowThemeManager for spotlight identifier
|
||||
skipTaskbar: true,
|
||||
resizable: false,
|
||||
fullscreenable: false,
|
||||
maximizable: false,
|
||||
minimizable: false,
|
||||
hasShadow: true,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Post-creation setup** (in SpotlightController or Browser.retrieveOrInitialize):
|
||||
|
||||
```typescript
|
||||
// alwaysOnTop with 'floating' level (not constructor option)
|
||||
spotlightWindow.setAlwaysOnTop(true, 'floating');
|
||||
|
||||
// backgroundThrottling must be set via webPreferences, not top-level option.
|
||||
// Browser class hardcodes backgroundThrottling: false in webPreferences;
|
||||
// spotlight needs override: keep false to ensure < 100ms wake-up latency.
|
||||
// (Throttling would delay renderer response when hidden)
|
||||
```
|
||||
|
||||
### Lifecycle
|
||||
|
||||
```
|
||||
App launch → create hidden spotlight BrowserWindow
|
||||
→ load /desktop/spotlight route
|
||||
→ renderer sends 'spotlight:ready' via IPC
|
||||
→ readyPromise resolved
|
||||
→ await hotkey
|
||||
|
||||
Hotkey pressed → await readyPromise (normally instant)
|
||||
→ screen.getCursorScreenPoint()
|
||||
→ showAt(cursorPoint) with boundary correction
|
||||
→ focus input box
|
||||
|
||||
Command mode → execute → renderer ack → hide() + reset input + shrink to initial size
|
||||
Chat mode → keep visible → dynamically expand window height
|
||||
→ blur / Esc (empty input) / re-press hotkey → hide()
|
||||
|
||||
hide() → window hidden (not destroyed) → webContents preserved → await next invocation
|
||||
```
|
||||
|
||||
### whenReady() Mechanism
|
||||
|
||||
- Spotlight window skips the splash placeholder (no `splash.html`); loads spotlight route directly
|
||||
- Renderer sends `spotlight:ready` IPC event after initial load completes
|
||||
- Main process registers `ipcMain.once('spotlight:ready')` during window creation, resolving a stored `readyPromise`
|
||||
- Hotkey handler awaits `readyPromise` before showing (normally instant after app startup)
|
||||
- Timeout fallback (> 3s): show anyway, user may see brief loading state
|
||||
- On renderer crash + recreate: `readyPromise` must be reset to a new pending promise; the recreated renderer will re-emit `spotlight:ready`
|
||||
- After `show()`, main process sends `spotlight:focus` IPC to renderer to ensure DOM input focus (Electron's `show()` + `focus()` does not guarantee DOM focus lands on the input element)
|
||||
|
||||
## Renderer Design
|
||||
|
||||
### Independent MPA Entry (Vite)
|
||||
|
||||
New HTML + entry file, separate from main window renderer:
|
||||
|
||||
```
|
||||
apps/desktop/
|
||||
├── index.html → main window
|
||||
└── spotlight.html → spotlight window (new)
|
||||
|
||||
src/spa/
|
||||
├── entry.desktop.tsx → main window entry
|
||||
└── entry.spotlight.tsx → spotlight entry (new)
|
||||
```
|
||||
|
||||
**electron.vite.config.ts modification:**
|
||||
|
||||
```typescript
|
||||
renderer: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(ROOT_DIR, 'apps/desktop/index.html'),
|
||||
spotlight: resolve(ROOT_DIR, 'apps/desktop/spotlight.html'),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**RendererUrlManager modification (production route resolution):**
|
||||
|
||||
The existing `resolveRendererFilePath` always falls back to the main `SPA_ENTRY_HTML` (`index.html`). For the spotlight window, paths starting with `/desktop/spotlight` must resolve to `spotlight.html` instead:
|
||||
|
||||
```typescript
|
||||
// In RendererUrlManager.resolveRendererFilePath:
|
||||
if (pathname.startsWith('/desktop/spotlight')) {
|
||||
return resolve(rendererDir, 'spotlight.html');
|
||||
}
|
||||
// existing fallback to index.html
|
||||
```
|
||||
|
||||
Without this, the spotlight BrowserWindow would load the main app entry in production.
|
||||
|
||||
### Minimal Provider Chain (entry.spotlight.tsx)
|
||||
|
||||
```typescript
|
||||
<Locale>
|
||||
<NextThemeProvider>
|
||||
<AppTheme>
|
||||
<StyleProvider>
|
||||
<SpotlightQueryProvider> // SWR + TRPC only
|
||||
<SpotlightRouter />
|
||||
</SpotlightQueryProvider>
|
||||
</StyleProvider>
|
||||
</AppTheme>
|
||||
</NextThemeProvider>
|
||||
</Locale>
|
||||
```
|
||||
|
||||
**Excluded providers** (vs main window SPAGlobalProvider):
|
||||
|
||||
- StoreInitialization — no full store init needed
|
||||
- AuthProvider — not needed; all BrowserWindow instances share the default Electron session (no `partition` set), so cookies and session storage are shared. TRPC client in spotlight inherits the same auth cookies automatically.
|
||||
- ServerConfigStoreProvider — not required
|
||||
- LazyMotion / DragUploadProvider / FaviconProvider — irrelevant
|
||||
- ModalHost / ToastHost / ContextMenuHost — spotlight has no modals
|
||||
|
||||
**Auth assumption:** Spotlight renderer shares the default Electron session with main window. Auth cookies set by the main window are available to spotlight's TRPC/fetch calls without additional IPC.
|
||||
|
||||
### Progressive Rendering
|
||||
|
||||
**Immediate load (in spotlight bundle):**
|
||||
|
||||
- InputBox component
|
||||
- CommandPalette result list (plain text)
|
||||
- Lightweight inline Markdown renderer
|
||||
|
||||
**Dynamic import (on entering chat mode):**
|
||||
|
||||
- Full ChatItem / MessageRender components
|
||||
- Code block syntax highlighting
|
||||
- Image / file preview
|
||||
- Related store slices (chat operation, etc.)
|
||||
|
||||
## State Synchronization
|
||||
|
||||
### Principle: DB as source of truth, no shared in-memory state
|
||||
|
||||
Each renderer has its own Zustand stores and SWR cache. Synchronization happens through DB + IPC invalidation.
|
||||
|
||||
```
|
||||
Main Window Renderer Electron Main Process Spotlight Renderer
|
||||
│ │ │
|
||||
│──── write to DB ────────────→│ │
|
||||
│ │── store:invalidate ─────────→│
|
||||
│ │ { keys, source } │
|
||||
│ │ │── SWR revalidate
|
||||
│ │ │
|
||||
│ │←── write to DB ──────────────│
|
||||
│←── store:invalidate ─────────│ │
|
||||
│ { keys, source } │ │
|
||||
│── SWR revalidate │ │
|
||||
```
|
||||
|
||||
**IPC events:**
|
||||
|
||||
**Prerequisite:** The `store:invalidate` event must be registered in `@lobechat/electron-client-ipc` package's `MainBroadcastEventKey` type definitions. The current `broadcastToAllWindows` method has no `excludeSender` parameter; it must be extended to accept an optional sender `webContents` to exclude.
|
||||
|
||||
```typescript
|
||||
// After writing to DB, sender broadcasts via preload bridge
|
||||
ipcRenderer.send('store:invalidate', {
|
||||
keys: ['chat/messages', 'chat/topics'],
|
||||
source: 'spotlight', // or 'main'
|
||||
});
|
||||
|
||||
// Main process relays to all other windows (excludeSender added to API)
|
||||
// In SpotlightController or a new StoreInvalidationController:
|
||||
ipcMain.on('store:invalidate', (event, payload) => {
|
||||
browserManager.broadcastToOtherWindows(
|
||||
'store:invalidate',
|
||||
payload,
|
||||
event.sender, // exclude the sender's webContents
|
||||
);
|
||||
});
|
||||
|
||||
// Receiver triggers SWR revalidation
|
||||
ipcRenderer.on('store:invalidate', (_, { keys }) => {
|
||||
keys.forEach((key) => mutate(key));
|
||||
});
|
||||
```
|
||||
|
||||
**"Open in main window" action:**
|
||||
|
||||
- User clicks "expand in main window" from spotlight chat mode
|
||||
- Main window navigates to corresponding topic (data already in DB)
|
||||
- Spotlight hides
|
||||
|
||||
**Fallback:** SWR periodic revalidation (e.g., 30s interval) ensures eventual consistency if IPC events are lost.
|
||||
|
||||
## Window Behavior & Interaction
|
||||
|
||||
### Positioning
|
||||
|
||||
```typescript
|
||||
const cursor = screen.getCursorScreenPoint();
|
||||
const display = screen.getDisplayNearestPoint(cursor);
|
||||
const { width, height } = spotlightWindow.getBounds();
|
||||
|
||||
// Below cursor, horizontally centered
|
||||
let x = Math.round(cursor.x - width / 2);
|
||||
let y = cursor.y + 8;
|
||||
|
||||
// Boundary correction: stay within current display work area
|
||||
const bounds = display.workArea;
|
||||
x = Math.max(bounds.x, Math.min(x, bounds.x + bounds.width - width));
|
||||
y = Math.max(bounds.y, Math.min(y, bounds.y + bounds.height - height));
|
||||
|
||||
spotlightWindow.setPosition(x, y);
|
||||
spotlightWindow.show();
|
||||
```
|
||||
|
||||
### Dynamic Window Sizing
|
||||
|
||||
| State | Size | Behavior |
|
||||
| ---------------------- | --------- | --------------------------------- |
|
||||
| Input box only | 680 x 56 | Initial state |
|
||||
| Command/search results | 680 x 320 | Expand downward (top anchored) |
|
||||
| Chat mode | 680 x 480 | Expand downward, draggable height |
|
||||
|
||||
Size changes via renderer IPC → main process `setBounds()`. On macOS, `setBounds({ ...bounds }, { animate: true })` provides native animation; on Windows, animation is renderer-driven (CSS transition on inner container with instant `setSize()`). Top-left position anchored (expands downward only).
|
||||
|
||||
### Hide Logic
|
||||
|
||||
All Esc/blur handling is **renderer-side** (main process cannot inspect DOM state). Renderer sends `spotlight:hide` IPC to main process when hide is needed.
|
||||
|
||||
| Trigger | Behavior | Handler |
|
||||
| ---------------------------- | --------------------------------------------- | ------------- |
|
||||
| `blur` event (click outside) | Hide in both modes | Main |
|
||||
| `Esc` key | Input has content → clear; input empty → hide | Renderer |
|
||||
| Re-press hotkey | Toggle: visible → hide, hidden → show | Main |
|
||||
| Command executed | Await renderer ack → hide | Renderer→Main |
|
||||
|
||||
### Post-hide Reset
|
||||
|
||||
- Command mode: clear input, shrink window to initial size
|
||||
- Chat mode: retain current topic context (webContents preserved, Zustand store survives hide); next invocation resumes (configurable to reset)
|
||||
|
||||
### Multi-display Behavior
|
||||
|
||||
- Each show always positions at current cursor location, regardless of previous position
|
||||
- If user is in chat mode and re-invokes hotkey after hide, window appears at new cursor position
|
||||
|
||||
### Input Smart Routing
|
||||
|
||||
```
|
||||
User input → check prefix:
|
||||
> prefix → Command mode (list available commands)
|
||||
@ prefix → Search mode (search topics / agents / files)
|
||||
plain text → Chat mode (send to current or new topic)
|
||||
```
|
||||
|
||||
**v1 scope:**
|
||||
|
||||
- Commands (`>`): new chat, switch agent, toggle dark mode, open settings
|
||||
- Search (`@`): topics, agents
|
||||
- Chat: send to current topic or create new topic
|
||||
|
||||
### Preload Script
|
||||
|
||||
The existing preload script (`src/preload/index.ts`) runs `setupRouteInterceptors()` which is designed for the main window's client-side routing. The spotlight window uses a separate MPA entry and simpler router. The spotlight window should use the same preload script (to preserve `window.electronAPI` for IPC), but route interception is harmless since spotlight routes are not in the interception config (`src/common/routes.ts`). No separate preload needed.
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Scenario | Handling |
|
||||
| ------------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| Spotlight renderer crash | Main process listens `webContents.on('crashed')`, recreate window + reload |
|
||||
| `whenReady()` timeout (> 3s) | Degrade: show anyway, user may see brief loading state |
|
||||
| Hotkey occupied by system | Registration failure → notify user, prompt to change shortcut |
|
||||
| IPC `store:invalidate` lost | SWR periodic revalidation (30s) as fallback; IPC is not sole sync source |
|
||||
| Window position off-screen (external display unplugged) | Validate via `screen.getDisplayNearestPoint()` before show, correct to visible area |
|
||||
|
||||
## v2: NSPanel Native Addon (macOS)
|
||||
|
||||
**Scope:** Swift N-API addon (\~100-200 lines)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- Swift + C bridging header → expose C functions for N-API binding
|
||||
- `getNativeWindowHandle()` → obtain `NSWindow` reference → convert to `NSPanel`
|
||||
- Set `NSWindowCollectionBehaviorCanJoinAllSpaces` (exclude from Exposé)
|
||||
- Set `NSNonactivatingPanelMask` (no focus stealing)
|
||||
- Native drag handling (replace `-webkit-app-region: drag`)
|
||||
|
||||
**Bridge approach:** Swift code compiled as static library, C bridging header exposes functions consumed by N-API addon.
|
||||
|
||||
**Platform abstraction:**
|
||||
|
||||
```typescript
|
||||
interface IPanelAdapter {
|
||||
convertToPanel(windowHandle: Buffer): void;
|
||||
setFloatingBehavior(windowHandle: Buffer, options: PanelOptions): void;
|
||||
enableNativeDrag(windowHandle: Buffer, region: DragRegion): void;
|
||||
}
|
||||
|
||||
// macOS implementation (Swift N-API addon)
|
||||
class MacOSPanelAdapter implements IPanelAdapter { ... }
|
||||
|
||||
// Windows implementation (future, C++ N-API addon)
|
||||
class WindowsPanelAdapter implements IPanelAdapter { ... }
|
||||
```
|
||||
|
||||
**Windows (future direction):**
|
||||
|
||||
- `WS_EX_TOOLWINDOW` — exclude from taskbar / Alt+Tab
|
||||
- Native drag via `WM_NCHITTEST` interception
|
||||
- Note: `WS_EX_NOACTIVATE` conflicts with input focus requirement; needs careful handling
|
||||
|
||||
## v1 Known Limitations (Accepted)
|
||||
|
||||
- Window appears in Mission Control / Exposé (resolved in v2)
|
||||
- May briefly steal focus on show (resolved in v2)
|
||||
- Drag uses `-webkit-app-region: drag` with known Electron quirks (resolved in v2)
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NavigationBroadcastEvents } from './navigation';
|
||||
import type { ProtocolBroadcastEvents } from './protocol';
|
||||
import type { RemoteServerBroadcastEvents } from './remoteServer';
|
||||
import type { SpotlightBroadcastEvents } from './spotlight';
|
||||
import type { SystemBroadcastEvents } from './system';
|
||||
import type { AutoUpdateBroadcastEvents } from './update';
|
||||
|
||||
@@ -13,6 +14,7 @@ export interface MainBroadcastEvents
|
||||
AutoUpdateBroadcastEvents,
|
||||
NavigationBroadcastEvents,
|
||||
RemoteServerBroadcastEvents,
|
||||
SpotlightBroadcastEvents,
|
||||
SystemBroadcastEvents,
|
||||
ProtocolBroadcastEvents {}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface SpotlightBroadcastEvents {
|
||||
/**
|
||||
* Ask spotlight renderer to focus the input box.
|
||||
*/
|
||||
spotlightFocus: () => void;
|
||||
|
||||
/**
|
||||
* Cross-window data sync notification.
|
||||
* Receiving windows should revalidate the specified SWR cache keys.
|
||||
*/
|
||||
syncData: (data: { keys: string[]; source: string }) => void;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"target_name": "electron_panel",
|
||||
"sources": [
|
||||
"src/panel.cc",
|
||||
"src/panel_mac.mm"
|
||||
],
|
||||
"include_dirs": [
|
||||
"<!@(node -p \"require('node-addon-api').include\")"
|
||||
],
|
||||
"defines": ["NAPI_DISABLE_CPP_EXCEPTIONS"],
|
||||
"conditions": [
|
||||
["OS=='mac'", {
|
||||
"defines": ["PLATFORM_OSX"],
|
||||
"xcode_settings": {
|
||||
"OTHER_CPLUSPLUSFLAGS": ["-std=c++17", "-ObjC++"],
|
||||
"OTHER_LDFLAGS": ["-framework AppKit", "-framework QuartzCore"]
|
||||
}
|
||||
}]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
|
||||
function loadNative() {
|
||||
if (process.platform !== 'darwin') return null;
|
||||
|
||||
try {
|
||||
const gypBuild = require('node-gyp-build');
|
||||
return gypBuild(path.join(__dirname, '..'));
|
||||
} catch {
|
||||
try {
|
||||
return require('../build/Release/electron_panel.node');
|
||||
} catch {
|
||||
console.warn(
|
||||
'[@lobechat/electron-panel] Failed to load native addon — panel features disabled.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const native = loadNative();
|
||||
|
||||
class Panel {
|
||||
constructor(handle) {
|
||||
if (!Buffer.isBuffer(handle)) {
|
||||
throw new Error('[@lobechat/electron-panel] handle must be a Buffer');
|
||||
}
|
||||
this._addon = native ? new native.Panel(handle) : null;
|
||||
}
|
||||
|
||||
panelize() {
|
||||
return this._addon ? this._addon.panelize() : false;
|
||||
}
|
||||
|
||||
enableNativeDrag(rect) {
|
||||
return this._addon ? this._addon.enableNativeDrag(rect) : false;
|
||||
}
|
||||
|
||||
disableNativeDrag() {
|
||||
return this._addon ? this._addon.disableNativeDrag() : false;
|
||||
}
|
||||
|
||||
animateResize(frame, duration) {
|
||||
if (duration === undefined) duration = 0.2;
|
||||
return this._addon ? this._addon.animateResize(frame, duration) : false;
|
||||
}
|
||||
|
||||
animateResizeElectron(frame, duration) {
|
||||
if (duration === undefined) duration = 0.2;
|
||||
return this._addon ? this._addon.animateResizeElectron(frame, duration) : false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { Panel };
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
export interface Rect {
|
||||
height: number;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export declare class Panel {
|
||||
constructor(handle: Buffer);
|
||||
|
||||
/**
|
||||
* Set NSPanel-grade properties: floatingPanel, becomesKeyOnlyIfNeeded,
|
||||
* hidesOnDeactivate:NO, canJoinAllSpaces, floatingWindowLevel.
|
||||
*/
|
||||
panelize(): boolean;
|
||||
|
||||
/**
|
||||
* Add a transparent native drag overlay at the given rect (top-left origin).
|
||||
* Replaces -webkit-app-region: drag with native performWindowDragWithEvent.
|
||||
*/
|
||||
enableNativeDrag(rect: Rect): boolean;
|
||||
|
||||
/**
|
||||
* Remove the native drag overlay.
|
||||
*/
|
||||
disableNativeDrag(): boolean;
|
||||
|
||||
/**
|
||||
* Animate window to a new frame with easeInOut timing.
|
||||
* @param frame Target frame in macOS screen coordinates (bottom-left origin).
|
||||
* @param duration Animation duration in seconds (default 0.2).
|
||||
*/
|
||||
animateResize(frame: Rect, duration?: number): boolean;
|
||||
|
||||
/**
|
||||
* Animate window to a new frame, accepting Electron-style bounds (top-left origin).
|
||||
* Converts to macOS screen coordinates internally.
|
||||
* @param frame Target frame in Electron screen coordinates (top-left origin).
|
||||
* @param duration Animation duration in seconds (default 0.2).
|
||||
*/
|
||||
animateResizeElectron(frame: Rect, duration?: number): boolean;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@lobechat/electron-panel",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "NSPanel-grade native window behavior for Electron BrowserWindow",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./js/index.d.ts",
|
||||
"require": "./js/index.cjs"
|
||||
}
|
||||
},
|
||||
"main": "./js/index.cjs",
|
||||
"types": "./js/index.d.ts",
|
||||
"scripts": {
|
||||
"build:native": "prebuildify --napi --strip --tag-armv --arch=arm64 && prebuildify --napi --strip --arch=x64",
|
||||
"build:native:current": "node-gyp rebuild && mkdir -p prebuilds/darwin-$(uname -m) && cp build/Release/electron_panel.node prebuilds/darwin-$(uname -m)/node.napi.node"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.4.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"node-gyp-build": "^4"
|
||||
},
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"gypfile": false
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,168 @@
|
||||
#include <napi.h>
|
||||
#include <cstring>
|
||||
|
||||
#ifdef __APPLE__
|
||||
extern "C" bool panelize(unsigned char *buffer);
|
||||
extern "C" bool enableNativeDrag(unsigned char *buffer,
|
||||
double x, double y,
|
||||
double width, double height);
|
||||
extern "C" bool disableNativeDrag(unsigned char *buffer);
|
||||
extern "C" bool animateResize(unsigned char *buffer,
|
||||
double x, double y,
|
||||
double width, double height,
|
||||
double duration);
|
||||
extern "C" bool animateResizeElectron(unsigned char *buffer,
|
||||
double x, double y,
|
||||
double width, double height,
|
||||
double duration);
|
||||
#endif
|
||||
|
||||
class Panel : public Napi::ObjectWrap<Panel> {
|
||||
public:
|
||||
static Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
||||
Napi::Function func = DefineClass(env, "Panel", {
|
||||
InstanceMethod("panelize", &Panel::Panelize),
|
||||
InstanceMethod("enableNativeDrag", &Panel::EnableNativeDrag),
|
||||
InstanceMethod("disableNativeDrag", &Panel::DisableNativeDrag),
|
||||
InstanceMethod("animateResize", &Panel::AnimateResize),
|
||||
InstanceMethod("animateResizeElectron", &Panel::AnimateResizeElectron),
|
||||
});
|
||||
|
||||
Napi::FunctionReference *constructor = new Napi::FunctionReference();
|
||||
*constructor = Napi::Persistent(func);
|
||||
env.SetInstanceData(constructor);
|
||||
|
||||
exports.Set("Panel", func);
|
||||
return exports;
|
||||
}
|
||||
|
||||
Panel(const Napi::CallbackInfo &info)
|
||||
: Napi::ObjectWrap<Panel>(info), handle_(nullptr), handleLen_(0) {
|
||||
Napi::Env env = info.Env();
|
||||
|
||||
if (info.Length() < 1 || !info[0].IsBuffer()) {
|
||||
Napi::TypeError::New(env,
|
||||
"Expected first argument to be a Buffer from getNativeWindowHandle()")
|
||||
.ThrowAsJavaScriptException();
|
||||
return;
|
||||
}
|
||||
|
||||
auto buffer = info[0].As<Napi::Buffer<unsigned char>>();
|
||||
handleLen_ = buffer.Length();
|
||||
handle_ = new unsigned char[handleLen_];
|
||||
memcpy(handle_, buffer.Data(), handleLen_);
|
||||
}
|
||||
|
||||
~Panel() {
|
||||
delete[] handle_;
|
||||
}
|
||||
|
||||
private:
|
||||
unsigned char *handle_;
|
||||
size_t handleLen_;
|
||||
|
||||
Napi::Value Panelize(const Napi::CallbackInfo &info) {
|
||||
Napi::Env env = info.Env();
|
||||
#ifdef __APPLE__
|
||||
bool ok = panelize(handle_);
|
||||
return Napi::Boolean::New(env, ok);
|
||||
#else
|
||||
return Napi::Boolean::New(env, false);
|
||||
#endif
|
||||
}
|
||||
|
||||
Napi::Value EnableNativeDrag(const Napi::CallbackInfo &info) {
|
||||
Napi::Env env = info.Env();
|
||||
|
||||
if (info.Length() < 1 || !info[0].IsObject()) {
|
||||
Napi::TypeError::New(env, "Expected {x, y, width, height}")
|
||||
.ThrowAsJavaScriptException();
|
||||
return env.Null();
|
||||
}
|
||||
|
||||
auto rect = info[0].As<Napi::Object>();
|
||||
double x = rect.Get("x").As<Napi::Number>().DoubleValue();
|
||||
double y = rect.Get("y").As<Napi::Number>().DoubleValue();
|
||||
double w = rect.Get("width").As<Napi::Number>().DoubleValue();
|
||||
double h = rect.Get("height").As<Napi::Number>().DoubleValue();
|
||||
|
||||
#ifdef __APPLE__
|
||||
bool ok = enableNativeDrag(handle_, x, y, w, h);
|
||||
return Napi::Boolean::New(env, ok);
|
||||
#else
|
||||
return Napi::Boolean::New(env, false);
|
||||
#endif
|
||||
}
|
||||
|
||||
Napi::Value DisableNativeDrag(const Napi::CallbackInfo &info) {
|
||||
Napi::Env env = info.Env();
|
||||
#ifdef __APPLE__
|
||||
bool ok = disableNativeDrag(handle_);
|
||||
return Napi::Boolean::New(env, ok);
|
||||
#else
|
||||
return Napi::Boolean::New(env, false);
|
||||
#endif
|
||||
}
|
||||
|
||||
Napi::Value AnimateResize(const Napi::CallbackInfo &info) {
|
||||
Napi::Env env = info.Env();
|
||||
|
||||
if (info.Length() < 1 || !info[0].IsObject()) {
|
||||
Napi::TypeError::New(env, "Expected {x, y, width, height}")
|
||||
.ThrowAsJavaScriptException();
|
||||
return env.Null();
|
||||
}
|
||||
|
||||
auto frame = info[0].As<Napi::Object>();
|
||||
double x = frame.Get("x").As<Napi::Number>().DoubleValue();
|
||||
double y = frame.Get("y").As<Napi::Number>().DoubleValue();
|
||||
double w = frame.Get("width").As<Napi::Number>().DoubleValue();
|
||||
double h = frame.Get("height").As<Napi::Number>().DoubleValue();
|
||||
|
||||
double duration = 0.2;
|
||||
if (info.Length() >= 2 && info[1].IsNumber()) {
|
||||
duration = info[1].As<Napi::Number>().DoubleValue();
|
||||
}
|
||||
|
||||
#ifdef __APPLE__
|
||||
bool ok = animateResize(handle_, x, y, w, h, duration);
|
||||
return Napi::Boolean::New(env, ok);
|
||||
#else
|
||||
return Napi::Boolean::New(env, false);
|
||||
#endif
|
||||
}
|
||||
|
||||
Napi::Value AnimateResizeElectron(const Napi::CallbackInfo &info) {
|
||||
Napi::Env env = info.Env();
|
||||
|
||||
if (info.Length() < 1 || !info[0].IsObject()) {
|
||||
Napi::TypeError::New(env, "Expected {x, y, width, height}")
|
||||
.ThrowAsJavaScriptException();
|
||||
return env.Null();
|
||||
}
|
||||
|
||||
auto frame = info[0].As<Napi::Object>();
|
||||
double x = frame.Get("x").As<Napi::Number>().DoubleValue();
|
||||
double y = frame.Get("y").As<Napi::Number>().DoubleValue();
|
||||
double w = frame.Get("width").As<Napi::Number>().DoubleValue();
|
||||
double h = frame.Get("height").As<Napi::Number>().DoubleValue();
|
||||
|
||||
double duration = 0.2;
|
||||
if (info.Length() >= 2 && info[1].IsNumber()) {
|
||||
duration = info[1].As<Napi::Number>().DoubleValue();
|
||||
}
|
||||
|
||||
#ifdef __APPLE__
|
||||
bool ok = animateResizeElectron(handle_, x, y, w, h, duration);
|
||||
return Napi::Boolean::New(env, ok);
|
||||
#else
|
||||
return Napi::Boolean::New(env, false);
|
||||
#endif
|
||||
}
|
||||
};
|
||||
|
||||
Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
||||
return Panel::Init(env, exports);
|
||||
}
|
||||
|
||||
NODE_API_MODULE(electron_panel, Init)
|
||||
@@ -0,0 +1,230 @@
|
||||
#ifdef PLATFORM_OSX
|
||||
#import <AppKit/AppKit.h>
|
||||
#import <QuartzCore/QuartzCore.h>
|
||||
#import <objc/runtime.h>
|
||||
#include <cstdio>
|
||||
|
||||
static const void *kDragViewKey = &kDragViewKey;
|
||||
|
||||
#define RUN_ON_MAIN(block) \
|
||||
if ([NSThread isMainThread]) { \
|
||||
block(); \
|
||||
} else { \
|
||||
dispatch_sync(dispatch_get_main_queue(), block); \
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DragView — transparent overlay that intercepts mouse events for window drag
|
||||
// ---------------------------------------------------------------------------
|
||||
@interface PanelDragView : NSView
|
||||
@end
|
||||
|
||||
@implementation PanelDragView
|
||||
|
||||
- (BOOL)acceptsFirstMouse:(NSEvent *)event {
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)mouseDown:(NSEvent *)event {
|
||||
[self.window performWindowDragWithEvent:event];
|
||||
}
|
||||
|
||||
- (NSView *)hitTest:(NSPoint)point {
|
||||
NSPoint local = [self convertPoint:point fromView:self.superview];
|
||||
if (NSPointInRect(local, self.bounds)) {
|
||||
return self;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// panelize — set NSPanel-grade properties on an NSWindow
|
||||
// ---------------------------------------------------------------------------
|
||||
extern "C" bool panelize(unsigned char *buffer) {
|
||||
if (!buffer) return false;
|
||||
|
||||
__block bool success = false;
|
||||
|
||||
RUN_ON_MAIN(^{
|
||||
NSView *rootView = *reinterpret_cast<NSView **>(buffer);
|
||||
if (!rootView) return;
|
||||
|
||||
NSWindow *window = [rootView window];
|
||||
if (!window) return;
|
||||
|
||||
if ([window respondsToSelector:@selector(setFloatingPanel:)]) {
|
||||
[(id)window setFloatingPanel:YES];
|
||||
}
|
||||
|
||||
if ([window respondsToSelector:@selector(setBecomesKeyOnlyIfNeeded:)]) {
|
||||
[(id)window setBecomesKeyOnlyIfNeeded:YES];
|
||||
}
|
||||
|
||||
if ([window respondsToSelector:@selector(setHidesOnDeactivate:)]) {
|
||||
[window setHidesOnDeactivate:NO];
|
||||
}
|
||||
|
||||
window.collectionBehavior |=
|
||||
NSWindowCollectionBehaviorCanJoinAllSpaces |
|
||||
NSWindowCollectionBehaviorFullScreenAuxiliary;
|
||||
|
||||
window.level = NSFloatingWindowLevel;
|
||||
|
||||
success = true;
|
||||
});
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// enableNativeDrag — add a transparent drag overlay at the specified rect
|
||||
// ---------------------------------------------------------------------------
|
||||
extern "C" bool enableNativeDrag(unsigned char *buffer,
|
||||
double x, double y,
|
||||
double width, double height) {
|
||||
if (!buffer) return false;
|
||||
|
||||
__block bool success = false;
|
||||
|
||||
RUN_ON_MAIN(^{
|
||||
NSView *rootView = *reinterpret_cast<NSView **>(buffer);
|
||||
if (!rootView) return;
|
||||
|
||||
NSWindow *window = [rootView window];
|
||||
if (!window) return;
|
||||
|
||||
NSView *contentView = window.contentView;
|
||||
if (!contentView) return;
|
||||
|
||||
NSView *oldDrag = objc_getAssociatedObject(contentView, kDragViewKey);
|
||||
if (oldDrag) {
|
||||
[oldDrag removeFromSuperview];
|
||||
}
|
||||
|
||||
// Convert top-left origin (web) to bottom-left origin (macOS)
|
||||
NSRect contentBounds = contentView.bounds;
|
||||
double flippedY = contentBounds.size.height - y - height;
|
||||
NSRect dragRect = NSMakeRect(x, flippedY, width, height);
|
||||
|
||||
PanelDragView *dragView = [[PanelDragView alloc] initWithFrame:dragRect];
|
||||
dragView.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin;
|
||||
|
||||
[contentView addSubview:dragView positioned:NSWindowAbove relativeTo:nil];
|
||||
objc_setAssociatedObject(contentView, kDragViewKey, dragView,
|
||||
OBJC_ASSOCIATION_RETAIN);
|
||||
|
||||
success = true;
|
||||
});
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// disableNativeDrag — remove the drag overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
extern "C" bool disableNativeDrag(unsigned char *buffer) {
|
||||
if (!buffer) return false;
|
||||
|
||||
__block bool success = false;
|
||||
|
||||
RUN_ON_MAIN(^{
|
||||
NSView *rootView = *reinterpret_cast<NSView **>(buffer);
|
||||
if (!rootView) return;
|
||||
|
||||
NSWindow *window = [rootView window];
|
||||
if (!window) return;
|
||||
|
||||
NSView *contentView = window.contentView;
|
||||
if (!contentView) return;
|
||||
|
||||
NSView *dragView = objc_getAssociatedObject(contentView, kDragViewKey);
|
||||
if (dragView) {
|
||||
[dragView removeFromSuperview];
|
||||
objc_setAssociatedObject(contentView, kDragViewKey, nil,
|
||||
OBJC_ASSOCIATION_ASSIGN);
|
||||
}
|
||||
|
||||
success = true;
|
||||
});
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// animateResize — smoothly animate window to a new frame (macOS coordinates)
|
||||
// ---------------------------------------------------------------------------
|
||||
static void applyFrame(NSWindow *window, NSRect newFrame, double duration) {
|
||||
if (duration <= 0) {
|
||||
[window setFrame:newFrame display:YES];
|
||||
} else {
|
||||
[NSAnimationContext runAnimationGroup:^(NSAnimationContext *ctx) {
|
||||
ctx.duration = duration;
|
||||
ctx.timingFunction = [CAMediaTimingFunction
|
||||
functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
|
||||
[[window animator] setFrame:newFrame display:YES];
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" bool animateResize(unsigned char *buffer,
|
||||
double x, double y,
|
||||
double width, double height,
|
||||
double duration) {
|
||||
if (!buffer) return false;
|
||||
|
||||
__block bool success = false;
|
||||
|
||||
RUN_ON_MAIN(^{
|
||||
NSView *rootView = *reinterpret_cast<NSView **>(buffer);
|
||||
if (!rootView) return;
|
||||
|
||||
NSWindow *window = [rootView window];
|
||||
if (!window) return;
|
||||
|
||||
applyFrame(window, NSMakeRect(x, y, width, height), duration);
|
||||
success = true;
|
||||
});
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// animateResizeElectron — animate using Electron-style bounds (top-left origin)
|
||||
// Converts from Electron screen coordinates to macOS screen coordinates
|
||||
// ---------------------------------------------------------------------------
|
||||
extern "C" bool animateResizeElectron(unsigned char *buffer,
|
||||
double x, double y,
|
||||
double width, double height,
|
||||
double duration) {
|
||||
if (!buffer) return false;
|
||||
|
||||
__block bool success = false;
|
||||
|
||||
RUN_ON_MAIN(^{
|
||||
NSView *rootView = *reinterpret_cast<NSView **>(buffer);
|
||||
if (!rootView) return;
|
||||
|
||||
NSWindow *window = [rootView window];
|
||||
if (!window) return;
|
||||
|
||||
// Find the screen containing this window
|
||||
NSScreen *windowScreen = [window screen];
|
||||
if (!windowScreen) windowScreen = [NSScreen mainScreen];
|
||||
|
||||
// macOS screen coordinates: origin is bottom-left of primary screen
|
||||
// Electron coordinates: origin is top-left of primary screen
|
||||
// Primary screen's frame.origin.y is always 0 in macOS coords
|
||||
// For conversion: macY = primaryScreenHeight - electronY - windowHeight
|
||||
NSRect primaryFrame = [[NSScreen screens] firstObject].frame;
|
||||
double macY = primaryFrame.size.height - y - height;
|
||||
|
||||
applyFrame(window, NSMakeRect(x, macY, width, height), duration);
|
||||
success = true;
|
||||
});
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
#endif // PLATFORM_OSX
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
|
||||
import { useSpotlightStore } from '../store';
|
||||
import SpotlightMessage from './SpotlightMessage';
|
||||
|
||||
const useStyles = createStyles(({ css }) => ({
|
||||
container: css`
|
||||
scroll-behavior: smooth;
|
||||
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
|
||||
padding-block: 8px;
|
||||
padding-inline: 16px;
|
||||
`,
|
||||
}));
|
||||
|
||||
const MessageList = memo(() => {
|
||||
const { styles } = useStyles();
|
||||
const messages = useSpotlightStore((s) => s.messages);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className={styles.container} ref={containerRef}>
|
||||
{messages.map((msg) => (
|
||||
<SpotlightMessage
|
||||
content={msg.content}
|
||||
key={msg.id}
|
||||
loading={msg.loading}
|
||||
role={msg.role}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MessageList.displayName = 'MessageList';
|
||||
|
||||
export default MessageList;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { CustomMDX } from '@/components/mdx';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
assistant: css`
|
||||
color: ${token.colorText};
|
||||
`,
|
||||
container: css`
|
||||
padding-block: 8px;
|
||||
padding-inline: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
`,
|
||||
cursor: css`
|
||||
display: inline-block;
|
||||
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
margin-inline-start: 2px;
|
||||
|
||||
vertical-align: text-bottom;
|
||||
|
||||
background: ${token.colorPrimary};
|
||||
|
||||
animation: blink 1s step-end infinite;
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
user: css`
|
||||
padding-block: 8px;
|
||||
padding-inline: 12px;
|
||||
border-radius: 12px;
|
||||
|
||||
color: ${token.colorText};
|
||||
|
||||
background: ${token.colorFillTertiary};
|
||||
`,
|
||||
}));
|
||||
|
||||
interface SpotlightMessageProps {
|
||||
content: string;
|
||||
loading?: boolean;
|
||||
role: 'user' | 'assistant';
|
||||
}
|
||||
|
||||
const SpotlightMessage = memo<SpotlightMessageProps>(({ content, loading, role }) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
if (role === 'user') {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.user}>{content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.assistant}>
|
||||
<CustomMDX source={content} />
|
||||
{loading && <span className={styles.cursor} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SpotlightMessage.displayName = 'SpotlightMessage';
|
||||
|
||||
export default SpotlightMessage;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useSpotlightStore } from '../store';
|
||||
import MessageList from './MessageList';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
container: css`
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
|
||||
min-height: 0;
|
||||
`,
|
||||
expandButton: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
align-self: flex-end;
|
||||
|
||||
margin-block: 4px;
|
||||
margin-inline: 12px;
|
||||
padding-block: 4px;
|
||||
padding-inline: 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
|
||||
font-size: 11px;
|
||||
color: ${token.colorTextTertiary};
|
||||
|
||||
background: none;
|
||||
|
||||
&:hover {
|
||||
background: ${token.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
const ChatView = memo(() => {
|
||||
const { styles } = useStyles();
|
||||
const topicId = useSpotlightStore((s) => s.topicId);
|
||||
|
||||
const handleExpandToMain = async () => {
|
||||
const { agentId, groupId } = useSpotlightStore.getState();
|
||||
if (!topicId) return;
|
||||
await window.electronAPI?.invoke?.('spotlight.expandToMain', { agentId, groupId, topicId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{topicId && (
|
||||
<button className={styles.expandButton} onClick={handleExpandToMain}>
|
||||
↗ Open in main window
|
||||
</button>
|
||||
)}
|
||||
<MessageList />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ChatView.displayName = 'ChatView';
|
||||
|
||||
export default ChatView;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo, useCallback } from 'react';
|
||||
|
||||
import { useEnabledChatModels } from '@/hooks/useEnabledChatModels';
|
||||
|
||||
import { useSpotlightStore } from '../store';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
chip: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 4px;
|
||||
padding-inline: 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${token.colorTextSecondary};
|
||||
|
||||
background: ${token.colorFillTertiary};
|
||||
|
||||
&:hover {
|
||||
background: ${token.colorFillSecondary};
|
||||
}
|
||||
`,
|
||||
icon: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 5px;
|
||||
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
`,
|
||||
indicator: css`
|
||||
font-size: 10px;
|
||||
color: ${token.colorTextQuaternary};
|
||||
`,
|
||||
}));
|
||||
|
||||
const ModelChip = memo(() => {
|
||||
const { styles } = useStyles();
|
||||
const currentModel = useSpotlightStore((s) => s.currentModel);
|
||||
const setCurrentModel = useSpotlightStore((s) => s.setCurrentModel);
|
||||
const enabledModels = useEnabledChatModels();
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
const items = enabledModels.flatMap((provider) =>
|
||||
provider.children.map((model) => ({
|
||||
group: provider.name || provider.id,
|
||||
label: model.displayName || model.id,
|
||||
provider: provider.id,
|
||||
value: model.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const result = await window.electronAPI?.invoke?.<{ model: string; provider: string } | null>(
|
||||
'spotlight.openModelMenu',
|
||||
items,
|
||||
);
|
||||
if (result) {
|
||||
setCurrentModel(result);
|
||||
}
|
||||
}, [enabledModels, setCurrentModel]);
|
||||
|
||||
const displayName = currentModel.model || 'Select Model';
|
||||
|
||||
return (
|
||||
<button className={styles.chip} onClick={handleClick}>
|
||||
<span className={styles.icon}>AI</span>
|
||||
<span>{displayName}</span>
|
||||
<span className={styles.indicator}>▼</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
ModelChip.displayName = 'ModelChip';
|
||||
|
||||
export default ModelChip;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useSpotlightStore } from '../store';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
chip: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 4px;
|
||||
padding-inline: 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
|
||||
font-size: 12px;
|
||||
color: ${token.colorTextTertiary};
|
||||
|
||||
background: ${token.colorFillQuaternary};
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: ${token.colorFillTertiary};
|
||||
}
|
||||
`,
|
||||
chipActive: css`
|
||||
border-color: ${token.colorPrimaryBorder};
|
||||
color: ${token.colorPrimary};
|
||||
background: ${token.colorPrimaryBg};
|
||||
`,
|
||||
container: css`
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
`,
|
||||
}));
|
||||
|
||||
const AVAILABLE_PLUGINS = [
|
||||
{ icon: '🌐', id: 'web-search', label: 'Web Search' },
|
||||
{ icon: '📚', id: 'knowledge-base', label: 'KB' },
|
||||
];
|
||||
|
||||
const PluginChips = memo(() => {
|
||||
const { styles, cx } = useStyles();
|
||||
const activePlugins = useSpotlightStore((s) => s.activePlugins);
|
||||
const togglePlugin = useSpotlightStore((s) => s.togglePlugin);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{AVAILABLE_PLUGINS.map((plugin) => (
|
||||
<button
|
||||
className={cx(styles.chip, activePlugins.includes(plugin.id) && styles.chipActive)}
|
||||
key={plugin.id}
|
||||
onClick={() => togglePlugin(plugin.id)}
|
||||
>
|
||||
<span>{plugin.icon}</span>
|
||||
<span>{plugin.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PluginChips.displayName = 'PluginChips';
|
||||
|
||||
export default PluginChips;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useSpotlightStore } from '../store';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
bar: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
padding-block: 6px;
|
||||
padding-inline: 16px;
|
||||
border-block-start: 1px solid ${token.colorBorderSecondary};
|
||||
`,
|
||||
group: css`
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
`,
|
||||
hint: css`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
|
||||
font-size: 11px;
|
||||
color: ${token.colorTextQuaternary};
|
||||
`,
|
||||
key: css`
|
||||
padding-block: 1px;
|
||||
padding-inline: 5px;
|
||||
border-radius: 4px;
|
||||
|
||||
font-size: 11px;
|
||||
color: ${token.colorTextTertiary};
|
||||
|
||||
background: ${token.colorFillTertiary};
|
||||
`,
|
||||
}));
|
||||
|
||||
const ShortcutBar = memo(() => {
|
||||
const { styles } = useStyles();
|
||||
const viewState = useSpotlightStore((s) => s.viewState);
|
||||
|
||||
return (
|
||||
<div className={styles.bar}>
|
||||
<div className={styles.group}>
|
||||
<span className={styles.hint}>
|
||||
<kbd className={styles.key}>Esc</kbd> Close
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.group}>
|
||||
{viewState === 'chat' && (
|
||||
<span className={styles.hint}>
|
||||
<kbd className={styles.key}>⌘N</kbd> New Chat
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.hint}>
|
||||
<kbd className={styles.key}>⌘V</kbd> Paste Image
|
||||
</span>
|
||||
<span className={styles.hint}>
|
||||
<kbd className={styles.key}>Enter</kbd> Send
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ShortcutBar.displayName = 'ShortcutBar';
|
||||
|
||||
export default ShortcutBar;
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createStyles } from 'antd-style';
|
||||
import { type ChangeEvent, type KeyboardEvent, useEffect, useRef } from 'react';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => ({
|
||||
attachment: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
|
||||
font-size: 14px;
|
||||
|
||||
background: ${token.colorFillTertiary};
|
||||
|
||||
&:hover {
|
||||
background: ${token.colorFillSecondary};
|
||||
}
|
||||
`,
|
||||
container: css`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
|
||||
padding-block: 12px 4px;
|
||||
padding-inline: 16px;
|
||||
|
||||
-webkit-app-region: no-drag;
|
||||
`,
|
||||
textarea: css`
|
||||
resize: none;
|
||||
|
||||
flex: 1;
|
||||
|
||||
max-height: 96px;
|
||||
border: none;
|
||||
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: ${token.colorText};
|
||||
|
||||
background: transparent;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: ${token.colorTextQuaternary};
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
interface TextareaProps {
|
||||
onEscape: () => void;
|
||||
onSubmit: (value: string) => void;
|
||||
onValueChange: (value: string) => void;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const SpotlightTextarea = ({ value, onValueChange, onSubmit, onEscape }: TextareaProps) => {
|
||||
const { styles } = useStyles();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
window.electron?.ipcRenderer.on('spotlightFocus', handler);
|
||||
return () => {
|
||||
window.electron?.ipcRenderer.removeListener('spotlightFocus', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, 96)}px`;
|
||||
}, [value]);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
if (value) {
|
||||
onValueChange('');
|
||||
} else {
|
||||
onEscape();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey && value.trim()) {
|
||||
e.preventDefault();
|
||||
onSubmit(value.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<textarea
|
||||
autoFocus
|
||||
className={styles.textarea}
|
||||
placeholder="Ask anything, > commands, @ search..."
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={value}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => onValueChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button className={styles.attachment} title="Attach file">
|
||||
📎
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpotlightTextarea;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import ModelChip from './ModelChip';
|
||||
import PluginChips from './PluginChips';
|
||||
import ShortcutBar from './ShortcutBar';
|
||||
import SpotlightTextarea from './Textarea';
|
||||
|
||||
const useStyles = createStyles(({ css }) => ({
|
||||
chipsRow: css`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 4px 8px;
|
||||
padding-inline: 16px;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface InputAreaProps {
|
||||
onEscape: () => void;
|
||||
onSubmit: (value: string) => void;
|
||||
onValueChange: (value: string) => void;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const InputArea = memo<InputAreaProps>(({ value, onValueChange, onSubmit, onEscape }) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SpotlightTextarea
|
||||
value={value}
|
||||
onEscape={onEscape}
|
||||
onSubmit={onSubmit}
|
||||
onValueChange={onValueChange}
|
||||
/>
|
||||
<div className={styles.chipsRow}>
|
||||
<ModelChip />
|
||||
<PluginChips />
|
||||
</div>
|
||||
<ShortcutBar />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
InputArea.displayName = 'InputArea';
|
||||
|
||||
export default InputArea;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { lazy, memo, Suspense, useCallback } from 'react';
|
||||
|
||||
import InputArea from './InputArea';
|
||||
import { useSpotlightStore } from './store';
|
||||
import { useStyles } from './style';
|
||||
|
||||
const ChatView = lazy(() => import('./ChatView'));
|
||||
|
||||
const SpotlightWindow = memo(() => {
|
||||
const { styles } = useStyles();
|
||||
const viewState = useSpotlightStore((s) => s.viewState);
|
||||
const inputValue = useSpotlightStore((s) => s.inputValue);
|
||||
const setInputValue = useSpotlightStore((s) => s.setInputValue);
|
||||
const setViewState = useSpotlightStore((s) => s.setViewState);
|
||||
const sendMessage = useSpotlightStore((s) => s.sendMessage);
|
||||
|
||||
const handleHide = useCallback(() => {
|
||||
const { viewState: currentView, streaming } = useSpotlightStore.getState();
|
||||
if (currentView === 'chat' && !streaming) {
|
||||
useSpotlightStore.getState().reset();
|
||||
window.electronAPI?.invoke?.('spotlight:resize', { height: 120, width: 680 });
|
||||
}
|
||||
window.electronAPI?.invoke?.('spotlight:hide');
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (value: string) => {
|
||||
if (value.startsWith('>')) {
|
||||
handleHide();
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith('@')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewState === 'input') {
|
||||
window.electronAPI?.invoke?.('spotlight:resize', { height: 480, width: 680 });
|
||||
setViewState('chat');
|
||||
}
|
||||
|
||||
setInputValue('');
|
||||
await sendMessage(value);
|
||||
},
|
||||
[handleHide, viewState, setViewState, setInputValue, sendMessage],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.dragHandle} />
|
||||
|
||||
{viewState === 'chat' && (
|
||||
<Suspense fallback={null}>
|
||||
<ChatView />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
<InputArea
|
||||
value={inputValue}
|
||||
onEscape={handleHide}
|
||||
onSubmit={handleSubmit}
|
||||
onValueChange={setInputValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
SpotlightWindow.displayName = 'SpotlightWindow';
|
||||
|
||||
export default SpotlightWindow;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { lambdaClient } from '@/libs/trpc/client';
|
||||
import { chatService } from '@/services/chat';
|
||||
|
||||
interface SendSpotlightMessageParams {
|
||||
abortController: AbortController;
|
||||
agentId: string;
|
||||
content: string;
|
||||
groupId?: string;
|
||||
model: string;
|
||||
onContentUpdate: (content: string) => void;
|
||||
onError: (error: Error) => void;
|
||||
onFinish: () => void;
|
||||
provider: string;
|
||||
topicId?: string;
|
||||
}
|
||||
|
||||
interface SendSpotlightMessageResult {
|
||||
assistantMessageId: string;
|
||||
topicId: string;
|
||||
userMessageId: string;
|
||||
}
|
||||
|
||||
export const sendSpotlightMessage = async (
|
||||
params: SendSpotlightMessageParams,
|
||||
): Promise<SendSpotlightMessageResult | null> => {
|
||||
const {
|
||||
content,
|
||||
agentId,
|
||||
groupId,
|
||||
topicId,
|
||||
model,
|
||||
provider,
|
||||
abortController,
|
||||
onContentUpdate,
|
||||
onError,
|
||||
onFinish,
|
||||
} = params;
|
||||
|
||||
try {
|
||||
// Step 1: Create topic + messages atomically via TRPC
|
||||
const serverResult = await lambdaClient.aiChat.sendMessageInServer.mutate(
|
||||
{
|
||||
agentId,
|
||||
groupId,
|
||||
newAssistantMessage: { model, provider },
|
||||
newTopic: topicId ? undefined : { title: content.slice(0, 50), topicMessageIds: [] },
|
||||
newUserMessage: { content },
|
||||
topicId,
|
||||
},
|
||||
{
|
||||
context: { showNotification: false },
|
||||
signal: abortController.signal,
|
||||
},
|
||||
);
|
||||
|
||||
if (!serverResult) {
|
||||
onError(new Error('Failed to create messages'));
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedTopicId = serverResult.topicId || topicId || '';
|
||||
|
||||
// Step 2: Stream AI response
|
||||
let accumulatedContent = '';
|
||||
|
||||
await chatService.createAssistantMessageStream({
|
||||
abortController,
|
||||
onErrorHandle: (error: any) => {
|
||||
onError(new Error(typeof error === 'string' ? error : error?.message || 'Stream error'));
|
||||
},
|
||||
onFinish: async () => {
|
||||
onFinish();
|
||||
},
|
||||
onMessageHandle: (chunk: any) => {
|
||||
if (chunk.type === 'text') {
|
||||
accumulatedContent += chunk.text;
|
||||
onContentUpdate(accumulatedContent);
|
||||
}
|
||||
},
|
||||
params: {
|
||||
agentId,
|
||||
groupId,
|
||||
messages: [{ content, role: 'user' }] as any,
|
||||
model,
|
||||
provider,
|
||||
resolvedAgentConfig: { model, params: {}, provider } as any,
|
||||
topicId: resolvedTopicId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
assistantMessageId: serverResult.assistantMessageId,
|
||||
topicId: resolvedTopicId,
|
||||
userMessageId: serverResult.userMessageId,
|
||||
};
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import {
|
||||
chatInitialState,
|
||||
type ChatMessage,
|
||||
createChatActions,
|
||||
type SpotlightChatActions,
|
||||
type SpotlightChatState,
|
||||
} from './store/chatActions';
|
||||
|
||||
export type { ChatMessage };
|
||||
|
||||
interface SpotlightUIState {
|
||||
activePlugins: string[];
|
||||
agentId: string;
|
||||
currentModel: { model: string; provider: string };
|
||||
groupId?: string;
|
||||
inputValue: string;
|
||||
viewState: 'input' | 'chat';
|
||||
}
|
||||
|
||||
interface SpotlightUIActions {
|
||||
reset: () => void;
|
||||
setCurrentModel: (model: { model: string; provider: string }) => void;
|
||||
setInputValue: (value: string) => void;
|
||||
setViewState: (state: 'input' | 'chat') => void;
|
||||
togglePlugin: (pluginId: string) => void;
|
||||
}
|
||||
|
||||
type SpotlightStore = SpotlightUIState &
|
||||
SpotlightUIActions &
|
||||
SpotlightChatState &
|
||||
SpotlightChatActions;
|
||||
|
||||
const uiInitialState: SpotlightUIState = {
|
||||
activePlugins: [],
|
||||
agentId: 'default',
|
||||
currentModel: { model: '', provider: '' },
|
||||
inputValue: '',
|
||||
viewState: 'input',
|
||||
};
|
||||
|
||||
export const useSpotlightStore = create<SpotlightStore>()((...args) => {
|
||||
const [set] = args;
|
||||
|
||||
return {
|
||||
...uiInitialState,
|
||||
...chatInitialState,
|
||||
|
||||
...createChatActions(...args),
|
||||
|
||||
reset: () => {
|
||||
set({ ...uiInitialState, ...chatInitialState });
|
||||
window.electronAPI?.invoke?.('spotlight:setChatState', false);
|
||||
},
|
||||
|
||||
setCurrentModel: (model) => set({ currentModel: model }),
|
||||
|
||||
setInputValue: (value) => set({ inputValue: value }),
|
||||
|
||||
setViewState: (viewState) => {
|
||||
set({ viewState });
|
||||
window.electronAPI?.invoke?.('spotlight:setChatState', viewState === 'chat');
|
||||
},
|
||||
|
||||
togglePlugin: (pluginId) =>
|
||||
set((state) => ({
|
||||
activePlugins: state.activePlugins.includes(pluginId)
|
||||
? state.activePlugins.filter((id) => id !== pluginId)
|
||||
: [...state.activePlugins, pluginId],
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
import { sendSpotlightMessage } from '../services/chat';
|
||||
|
||||
export interface ChatMessage {
|
||||
content: string;
|
||||
id: string;
|
||||
loading?: boolean;
|
||||
role: 'user' | 'assistant';
|
||||
}
|
||||
|
||||
export interface SpotlightChatActions {
|
||||
abortStreaming: () => void;
|
||||
resetChat: () => void;
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface SpotlightChatState {
|
||||
_abortController: AbortController | null;
|
||||
messages: ChatMessage[];
|
||||
streaming: boolean;
|
||||
topicId: string | null;
|
||||
}
|
||||
|
||||
export const chatInitialState: SpotlightChatState = {
|
||||
_abortController: null,
|
||||
messages: [],
|
||||
streaming: false,
|
||||
topicId: null,
|
||||
};
|
||||
|
||||
export const createChatActions: StateCreator<
|
||||
SpotlightChatState &
|
||||
SpotlightChatActions & {
|
||||
agentId: string;
|
||||
currentModel: { model: string; provider: string };
|
||||
groupId?: string;
|
||||
},
|
||||
[],
|
||||
[],
|
||||
SpotlightChatActions
|
||||
> = (set, get) => ({
|
||||
abortStreaming: () => {
|
||||
const { _abortController } = get();
|
||||
_abortController?.abort();
|
||||
set({ _abortController: null, streaming: false });
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg, i) =>
|
||||
i === state.messages.length - 1 && msg.role === 'assistant'
|
||||
? { ...msg, loading: false }
|
||||
: msg,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
resetChat: () => {
|
||||
const { _abortController } = get();
|
||||
_abortController?.abort();
|
||||
set(chatInitialState);
|
||||
window.electronAPI?.invoke?.('spotlight:setChatState', false);
|
||||
},
|
||||
|
||||
sendMessage: async (content: string) => {
|
||||
const { agentId, currentModel, groupId, topicId } = get();
|
||||
const userMsgId = nanoid();
|
||||
const assistantMsgId = nanoid();
|
||||
const abortController = new AbortController();
|
||||
|
||||
set((state) => ({
|
||||
_abortController: abortController,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{ content, id: userMsgId, role: 'user' as const },
|
||||
{ content: '', id: assistantMsgId, loading: true, role: 'assistant' as const },
|
||||
],
|
||||
streaming: true,
|
||||
}));
|
||||
|
||||
const result = await sendSpotlightMessage({
|
||||
abortController,
|
||||
agentId,
|
||||
content,
|
||||
groupId,
|
||||
model: currentModel.model,
|
||||
onContentUpdate: (updatedContent) => {
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === assistantMsgId ? { ...msg, content: updatedContent } : msg,
|
||||
),
|
||||
}));
|
||||
},
|
||||
onError: (error) => {
|
||||
set((state) => ({
|
||||
_abortController: null,
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === assistantMsgId
|
||||
? { ...msg, content: `Error: ${error.message}`, loading: false }
|
||||
: msg,
|
||||
),
|
||||
streaming: false,
|
||||
}));
|
||||
},
|
||||
onFinish: () => {
|
||||
set((state) => ({
|
||||
_abortController: null,
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === assistantMsgId ? { ...msg, loading: false } : msg,
|
||||
),
|
||||
streaming: false,
|
||||
}));
|
||||
|
||||
window.electronAPI?.invoke?.('spotlight.notifySync', {
|
||||
keys: ['chat/messages', 'chat/topics'],
|
||||
});
|
||||
},
|
||||
provider: currentModel.provider,
|
||||
topicId: topicId || undefined,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
set({ topicId: result.topicId });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createStyles } from 'antd-style';
|
||||
|
||||
export const useStyles = createStyles(({ css, token }) => ({
|
||||
container: css`
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
height: 100vh;
|
||||
border: 1px solid ${token.colorBorderSecondary};
|
||||
border-radius: 12px;
|
||||
|
||||
background: ${token.colorBgContainer};
|
||||
`,
|
||||
dragHandle: css`
|
||||
cursor: default;
|
||||
height: 4px;
|
||||
|
||||
-webkit-app-region: drag;
|
||||
`,
|
||||
}));
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useWatchBroadcast } from '@lobechat/electron-client-ipc';
|
||||
import { useSWRConfig } from 'swr';
|
||||
|
||||
export const useSyncDataBroadcast = () => {
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
useWatchBroadcast('syncData', (data) => {
|
||||
if (data?.keys) {
|
||||
for (const key of data.keys) {
|
||||
mutate(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,8 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { OFFICIAL_URL } from '@lobechat/const';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { isDesktop } from '@/const/version';
|
||||
import { getDesktopOnboardingCompleted } from '@/routes/(desktop)/desktop-onboarding/storage';
|
||||
import { useElectronStore } from '@/store/electron';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { onboardingSelectors } from '@/store/user/selectors';
|
||||
import { type UserInitializationState } from '@/types/user';
|
||||
|
||||
@@ -13,9 +17,46 @@ const redirectIfNotOn = (currentPath: string, path: string) => {
|
||||
};
|
||||
|
||||
export const useDesktopUserStateRedirect = () => {
|
||||
// Desktop onboarding redirect is now handled by main process (BrowserManager)
|
||||
// No need to check localStorage here
|
||||
return useCallback(() => {}, []);
|
||||
const dataSyncConfig = useElectronStore((s) => s.dataSyncConfig);
|
||||
const logout = useUserStore((s) => s.logout);
|
||||
|
||||
const openExternalAndLogout = useCallback(
|
||||
async (path: string) => {
|
||||
const baseUrl = dataSyncConfig.remoteServerUrl || OFFICIAL_URL;
|
||||
let targetUrl = baseUrl;
|
||||
try {
|
||||
targetUrl = new URL(path, baseUrl).toString();
|
||||
} catch {
|
||||
// Ignore: keep fallback URL for external open attempt.
|
||||
}
|
||||
|
||||
try {
|
||||
const { electronSystemService } = await import('@/services/electron/system');
|
||||
await electronSystemService.openExternalLink(targetUrl);
|
||||
} catch {
|
||||
// Ignore: fallback to logout flow even if IPC is unavailable.
|
||||
}
|
||||
|
||||
try {
|
||||
const { remoteServerService } = await import('@/services/electron/remoteServer');
|
||||
await remoteServerService.clearRemoteServerConfig();
|
||||
} catch {
|
||||
// Ignore: fallback to logout flow even if IPC is unavailable.
|
||||
}
|
||||
|
||||
await logout();
|
||||
},
|
||||
[dataSyncConfig.remoteServerUrl, logout],
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
(state: UserInitializationState) => {
|
||||
if (!getDesktopOnboardingCompleted()) return;
|
||||
// Desktop onboarding is handled by desktop-only flow
|
||||
redirectIfNotOn(window.location.pathname, '/desktop-onboarding');
|
||||
},
|
||||
[openExternalAndLogout],
|
||||
);
|
||||
};
|
||||
|
||||
export const useWebUserStateRedirect = () =>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { lazy, memo, type PropsWithChildren, Suspense, useLayoutEffect } from 'r
|
||||
import { LobeAnalyticsProviderWrapper } from '@/components/Analytics/LobeAnalyticsProviderWrapper';
|
||||
import { DragUploadProvider } from '@/components/DragUploadZone/DragUploadProvider';
|
||||
import { isDesktop } from '@/const/version';
|
||||
import { useSyncDataBroadcast } from '@/hooks/useSyncDataBroadcast';
|
||||
import AuthProvider from '@/layout/AuthProvider';
|
||||
import AppTheme from '@/layout/GlobalProvider/AppTheme';
|
||||
import { FaviconProvider } from '@/layout/GlobalProvider/FaviconProvider';
|
||||
@@ -33,6 +34,8 @@ const SPAGlobalProvider = memo<PropsWithChildren>(({ children }) => {
|
||||
document.getElementById('loading-screen')?.remove();
|
||||
}, []);
|
||||
|
||||
useSyncDataBroadcast();
|
||||
|
||||
const serverConfig: SPAServerConfig | undefined = window.__SERVER_CONFIG__;
|
||||
|
||||
const locale = document.documentElement.lang || 'en-US';
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import '../initialize';
|
||||
|
||||
import { StyleProvider } from 'antd-style';
|
||||
import { memo, type PropsWithChildren, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import SpotlightWindow from '@/features/Spotlight';
|
||||
import AppTheme from '@/layout/GlobalProvider/AppTheme';
|
||||
import NextThemeProvider from '@/layout/GlobalProvider/NextThemeProvider';
|
||||
import QueryProvider from '@/layout/GlobalProvider/Query';
|
||||
import Locale from '@/layout/SPAGlobalProvider/Locale';
|
||||
|
||||
const SpotlightProvider = memo<PropsWithChildren>(({ children }) => {
|
||||
const locale = document.documentElement.lang || 'en-US';
|
||||
|
||||
return (
|
||||
<Locale defaultLang={locale}>
|
||||
<NextThemeProvider>
|
||||
<AppTheme>
|
||||
<QueryProvider>
|
||||
<StyleProvider speedy={import.meta.env.PROD}>{children}</StyleProvider>
|
||||
</QueryProvider>
|
||||
</AppTheme>
|
||||
</NextThemeProvider>
|
||||
</Locale>
|
||||
);
|
||||
});
|
||||
|
||||
SpotlightProvider.displayName = 'SpotlightProvider';
|
||||
|
||||
const App = () => {
|
||||
useEffect(() => {
|
||||
// Signal to main process that renderer is ready
|
||||
window.electronAPI?.invoke?.('spotlight:ready');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SpotlightProvider>
|
||||
<SpotlightWindow />
|
||||
</SpotlightProvider>
|
||||
);
|
||||
};
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
Reference in New Issue
Block a user