Compare commits

...

36 Commits

Author SHA1 Message Date
Innei 0e9e4d42d0 🔧 build(desktop): add @lobechat/electron-panel workspace dependency 2026-03-17 20:27:49 +08:00
Innei e9f8e83bae feat(spotlight): use native animateResize for smooth expand/collapse 2026-03-17 20:04:42 +08:00
Innei 97bd70c53e feat(spotlight): integrate native Panel addon — panelize + native drag 2026-03-17 20:01:18 +08:00
Innei a7ded504ab 🔧 build(desktop): register @lobechat/electron-panel in native-deps 2026-03-17 20:00:37 +08:00
Innei 7526d37fb3 🔧 build(electron-panel): compile native module and add prebuilt binary 2026-03-17 19:59:43 +08:00
Innei bf590bfdc9 feat(electron-panel): CJS wrapper + type declarations 2026-03-17 19:58:47 +08:00
Innei 9dff2c012c feat(electron-panel): N-API binding layer with ObjectWrap<Panel> 2026-03-17 19:57:58 +08:00
Innei 27e58afc00 feat(electron-panel): Objective-C++ native layer — panelize, drag, animateResize 2026-03-17 19:57:29 +08:00
Innei df303a3423 feat(electron-panel): scaffold package with binding.gyp and config 2026-03-17 19:56:51 +08:00
Innei 89ad58b5f1 📝 docs: add Electron Panel addon implementation plan 2026-03-17 19:52:58 +08:00
Innei 6589388907 📝 docs: add Electron Panel native addon design spec (Plan 3) 2026-03-17 19:47:44 +08:00
Innei 0e865c432f feat(spotlight): wire syncData cross-window broadcast for SWR revalidation 2026-03-17 19:19:19 +08:00
Innei 6092aabffc fix(spotlight): make onFinish async to match OnFinishHandler type 2026-03-17 19:17:55 +08:00
Innei f21f5505d7 feat(spotlight): chat service, store actions, and send flow wiring 2026-03-17 19:12:58 +08:00
Innei 088d07f562 feat(spotlight): SpotlightMessage with CustomMDX, MessageList, and ChatView wiring 2026-03-17 19:09:26 +08:00
Innei b78428f735 📝 docs: add Spotlight Chat implementation plan (Plan 2 of 2) 2026-03-17 18:07:24 +08:00
Innei 1ece295d7b fix(spotlight): add type parameter to invoke call in ModelChip 2026-03-17 17:57:31 +08:00
Innei af40ec9982 feat(spotlight): full UI shell — store, InputArea, ChatView, view state switching 2026-03-17 17:53:14 +08:00
Innei cf645cab71 feat(desktop): rewrite SpotlightCtr with blur strategy, model menu IPC, syncData event 2026-03-17 17:50:19 +08:00
Innei 6492bc3f18 feat(desktop): update spotlight to type:panel with expand-aware positioning 2026-03-17 17:48:58 +08:00
Innei 1a462adc8f 📝 docs: add Spotlight UI Shell implementation plan (Plan 1 of 2) 2026-03-17 17:47:34 +08:00
Innei 8c4a4c13f6 📝 docs: fix spotlight UI spec — model menu IPC, atomic send, expandToMain payload, syncData event 2026-03-17 17:40:39 +08:00
Innei 18b98f9a98 📝 docs: add spotlight UI design spec 2026-03-17 17:33:08 +08:00
Innei 9b95d90030 fix(desktop): fix SpotlightCtr lifecycle — lazy attach blur/crash, use retrieveByIdentifier, reload on crash 2026-03-17 14:52:09 +08:00
Innei c0b732438c fix(spotlight): use electronAPI.invoke instead of ipcRenderer.send for renderer→main IPC 2026-03-17 14:46:05 +08:00
Innei 9ae3a8bc77 feat(spotlight): create renderer entry, provider chain, and UI shell 2026-03-17 14:44:52 +08:00
Innei 9594dc6af0 feat(desktop): add spotlight.html entry and configure Vite MPA 2026-03-17 14:43:12 +08:00
Innei 8a17ca6908 feat(electron-client-ipc): add spotlight broadcast event types 2026-03-17 14:42:28 +08:00
Innei 9757032671 fix(desktop): use render-process-gone instead of deprecated crashed event 2026-03-17 14:41:39 +08:00
Innei f5800805e6 feat(desktop): create SpotlightController with show/hide/resize and crash recovery 2026-03-17 14:41:13 +08:00
Innei bd26b3ba23 feat(desktop): extend BrowserManager and RendererUrlManager for spotlight 2026-03-17 14:39:31 +08:00
Innei 9d09a21282 feat(desktop): extend Browser class with showAt, whenReady, skipSplash, spotlight overrides 2026-03-17 14:37:54 +08:00
Innei ab557a86ba feat(desktop): add spotlight window definition and shortcut config 2026-03-17 14:30:47 +08:00
Innei 3428ddd5ee 📝 docs: add spotlight window implementation plan 2026-03-17 14:28:18 +08:00
Innei cee36c6bde 📝 docs: address spec review findings for spotlight window design 2026-03-17 03:14:03 +08:00
Innei 4cdf293ff0 📝 docs: add spotlight window design spec 2026-03-17 03:09:32 +08:00
44 changed files with 7049 additions and 20 deletions
+1 -1
View File
@@ -134,4 +134,4 @@ i18n-unused-keys-report.json
pnpm-lock.yaml
.turbo
spaHtmlTemplates.ts
spaHtmlTemplates.ts.superpowers/
+8 -1
View File
@@ -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,
},
},
+1 -1
View File
@@ -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
];
+1
View File
@@ -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:*",
+44
View File
@@ -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>
+17
View File
@@ -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);
});
});
}
}
+98 -6
View File
@@ -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;
}
+24
View File
@@ -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"]
}
}]
]
}
]
}
+57
View File
@@ -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
View File
@@ -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;
}
+28
View File
@@ -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.
+168
View File
@@ -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)
+230
View File
@@ -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;
+66
View File
@@ -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;
+70
View File
@@ -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;
+100
View File
@@ -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;
}
};
+73
View File
@@ -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],
})),
};
});
+125
View File
@@ -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 });
}
},
});
+21
View File
@@ -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;
`,
}));
+14
View File
@@ -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 = () =>
+3
View File
@@ -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';
+44
View File
@@ -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 />);