From d2e22a6ec75298cddc2ae9d6ab7405f2a9fb6325 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Sat, 14 Feb 2026 22:40:28 +0800 Subject: [PATCH] feat(desktop): add system tray for background agent status When the main window is closed, the app now stays running with a menu bar tray icon showing agent/hub status. Users can toggle the window visibility and quit from the tray menu. Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/main/index.ts | 25 +++++-- apps/desktop/src/main/ipc/hub.ts | 2 +- apps/desktop/src/main/ipc/index.ts | 2 +- apps/desktop/src/main/tray.ts | 113 +++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/main/tray.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 07e362a1..5bc16b92 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -57,6 +57,7 @@ import path from 'node:path' import { registerAllIpcHandlers, initializeApp, cleanupAll, setupDeviceConfirmation, setAuthMainWindow, handleAuthDeepLink } from './ipc/index.js' import { appStateManager } from '@multica/core' import { createUpdater, AutoUpdater } from './updater/index.js' +import { createTray, destroyTray } from './tray.js' // CJS output will have __dirname natively, but TypeScript source needs this for type checking const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -77,6 +78,7 @@ const forceOnboarding = process.argv.includes('--force-onboarding') let win: BrowserWindow | null let updater: AutoUpdater +let isQuitting = false // ============================================================================ // Custom Protocol for Auth (multica://) @@ -108,8 +110,9 @@ if (!gotTheLock) { app.quit() } else { app.on('second-instance', (_event, commandLine) => { - // Focus window + // Show and focus window if (win) { + if (!win.isVisible()) win.show() if (win.isMinimized()) win.restore() win.focus() } @@ -144,6 +147,14 @@ function createWindow() { return { action: 'deny' } }) + // Hide window on close instead of quitting (tray keeps running) + win.on('close', (event) => { + if (!isQuitting) { + event.preventDefault() + win?.hide() + } + }) + if (VITE_DEV_SERVER_URL) { win.loadURL(VITE_DEV_SERVER_URL) } else { @@ -152,19 +163,20 @@ function createWindow() { } app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit() - win = null - } + // Keep app running with tray on all platforms }) app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() + } else if (win && !win.isVisible()) { + win.show() } }) app.on('before-quit', () => { + isQuitting = true + destroyTray() cleanupAll() }) @@ -187,10 +199,11 @@ app.whenReady().then(async () => { createWindow() - // Set up device confirmation flow and auth (requires window) + // Set up device confirmation flow, auth, and tray (requires window) if (win) { setupDeviceConfirmation(win) setAuthMainWindow(win) + createTray(win) } // Initialize auto-updater diff --git a/apps/desktop/src/main/ipc/hub.ts b/apps/desktop/src/main/ipc/hub.ts index cfd91e86..882ea55b 100644 --- a/apps/desktop/src/main/ipc/hub.ts +++ b/apps/desktop/src/main/ipc/hub.ts @@ -74,7 +74,7 @@ function getHub(): Hub { /** * Get the default agent. */ -function getDefaultAgent(): AsyncAgent | null { +export function getDefaultAgent(): AsyncAgent | null { if (!hub || !defaultAgentId) return null return hub.getAgent(defaultAgentId) ?? null } diff --git a/apps/desktop/src/main/ipc/index.ts b/apps/desktop/src/main/ipc/index.ts index 8f6e2ecb..eed30e01 100644 --- a/apps/desktop/src/main/ipc/index.ts +++ b/apps/desktop/src/main/ipc/index.ts @@ -3,7 +3,7 @@ */ export { registerAgentIpcHandlers, cleanupAgent } from './agent.js' export { registerSkillsIpcHandlers } from './skills.js' -export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation } from './hub.js' +export { registerHubIpcHandlers, cleanupHub, initializeHub, setupDeviceConfirmation, getDefaultAgent } from './hub.js' export { registerProfileIpcHandlers } from './profile.js' export { registerProviderIpcHandlers } from './provider.js' export { registerChannelsIpcHandlers } from './channels.js' diff --git a/apps/desktop/src/main/tray.ts b/apps/desktop/src/main/tray.ts new file mode 100644 index 00000000..a0e2d47b --- /dev/null +++ b/apps/desktop/src/main/tray.ts @@ -0,0 +1,113 @@ +/** + * System tray (menu bar) for the desktop app. + * + * Shows agent/hub status and allows window show/hide even + * when the main window is closed. + */ +import { Tray, Menu, nativeImage, app, type BrowserWindow } from 'electron' +import path from 'node:path' +import { getCurrentHub, getDefaultAgent } from './ipc/hub.js' + +let tray: Tray | null = null +let mainWindowRef: BrowserWindow | null = null +let statusInterval: ReturnType | null = null + +/** + * Create the system tray and start status polling. + */ +export function createTray(window: BrowserWindow): void { + mainWindowRef = window + + const iconPath = path.join(process.env.APP_ROOT!, 'build', 'icon.png') + const icon = nativeImage.createFromPath(iconPath) + const trayIcon = icon.resize({ width: 16, height: 16 }) + + if (process.platform === 'darwin') { + trayIcon.setTemplateImage(true) + } + + tray = new Tray(trayIcon) + tray.setToolTip('Multica') + + // Click to toggle window visibility + tray.on('click', toggleWindowVisibility) + + // Initial menu + updateTrayMenu() + + // Poll status every 2 seconds + statusInterval = setInterval(updateTrayMenu, 2000) +} + +/** + * Destroy tray and stop polling. + */ +export function destroyTray(): void { + if (statusInterval) { + clearInterval(statusInterval) + statusInterval = null + } + if (tray) { + tray.destroy() + tray = null + } + mainWindowRef = null +} + +function toggleWindowVisibility(): void { + if (!mainWindowRef || mainWindowRef.isDestroyed()) return + + if (mainWindowRef.isVisible()) { + mainWindowRef.hide() + } else { + mainWindowRef.show() + mainWindowRef.focus() + } +} + +function updateTrayMenu(): void { + if (!tray) return + + const hub = getCurrentHub() + const agent = getDefaultAgent() + + let agentStatus = 'Initializing' + let hubStatus = 'Disconnected' + + if (hub) { + hubStatus = hub.connectionState === 'connected' ? 'Connected' : 'Disconnected' + } + + if (agent && !agent.closed) { + if (agent.isStreaming) { + agentStatus = 'Streaming' + } else if (agent.isRunning) { + agentStatus = 'Running' + } else { + agentStatus = 'Idle' + } + } + + tray.setToolTip(`Multica - Agent: ${agentStatus}`) + + const windowVisible = mainWindowRef && !mainWindowRef.isDestroyed() && mainWindowRef.isVisible() + + const menu = Menu.buildFromTemplate([ + { label: `Agent: ${agentStatus}`, enabled: false }, + { label: `Hub: ${hubStatus}`, enabled: false }, + { type: 'separator' }, + { + label: windowVisible ? 'Hide Window' : 'Show Window', + click: toggleWindowVisibility, + }, + { type: 'separator' }, + { + label: 'Quit Multica', + click: () => { + app.quit() + }, + }, + ]) + + tray.setContextMenu(menu) +}