diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index c08c5941..50dbf679 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -3,9 +3,13 @@ import path from 'path' import fs from 'fs' import http from 'http' import { PythonBackend } from './python-manager' +import { buildAppMenu } from './menu' +import { createTray, destroyTray } from './tray' let mainWindow: BrowserWindow | null = null let pythonBackend: PythonBackend | null = null +// True once the user explicitly quits (menu/tray), so close-to-tray is bypassed. +let isQuitting = false const isDev = !app.isPackaged const VITE_DEV_PORTS = [5173, 5174, 5175, 5176] @@ -125,6 +129,15 @@ function createWindow() { return { action: 'deny' } }) + // Close-to-tray: hide the window instead of destroying it, so the tray's + // "Show" can bring it back. Only a real Quit (menu/tray/Cmd+Q) destroys it. + mainWindow.on('close', (e) => { + if (!isQuitting) { + e.preventDefault() + mainWindow?.hide() + } + }) + mainWindow.on('closed', () => { mainWindow = null }) @@ -202,6 +215,20 @@ function emitMaximizeState() { mainWindow?.webContents.send('window-maximize-changed', max) } +// Single-instance lock: focus the existing window instead of opening a second app. +const gotTheLock = app.requestSingleInstanceLock() +if (!gotTheLock) { + app.quit() +} else { + app.on('second-instance', () => { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.show() + mainWindow.focus() + } + }) +} + app.whenReady().then(async () => { // Set Dock icon on macOS (PNG is most reliable for nativeImage) if (process.platform === 'darwin') { @@ -221,11 +248,22 @@ app.whenReady().then(async () => { setupIPC() createWindow() + buildAppMenu(() => mainWindow) + createTray({ + getWindow: () => mainWindow, + iconPath: getIconPath('png'), + onQuit: () => { + isQuitting = true + app.quit() + }, + }) await startBackend() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() + } else { + mainWindow?.show() } }) }) @@ -237,6 +275,8 @@ app.on('window-all-closed', () => { }) app.on('before-quit', () => { + isQuitting = true saveWindowState() + destroyTray() pythonBackend?.stop() }) diff --git a/desktop/src/main/menu.ts b/desktop/src/main/menu.ts new file mode 100644 index 00000000..cf7759af --- /dev/null +++ b/desktop/src/main/menu.ts @@ -0,0 +1,106 @@ +import { app, Menu, BrowserWindow, shell } from 'electron' +import type { MenuItemConstructorOptions } from 'electron' + +const isMac = process.platform === 'darwin' +const SKILL_HUB_URL = 'https://skills.cowagent.ai/' + +// Send a menu-triggered action to the renderer (e.g. new chat, open settings). +function emit(win: BrowserWindow | null, action: string) { + win?.webContents.send('menu-action', action) +} + +/** + * Build a minimal, purpose-built application menu. We intentionally drop most of + * Electron's verbose defaults and keep only items that are actually useful for + * this app, plus the shortcuts users expect (New Chat, Settings, Reload, etc). + */ +export function buildAppMenu(getWindow: () => BrowserWindow | null) { + const win = () => getWindow() + + const appMenu: MenuItemConstructorOptions[] = isMac + ? [ + { + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { label: 'Settings…', accelerator: 'Cmd+,', click: () => emit(win(), 'open-settings') }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + ] + : [] + + const fileMenu: MenuItemConstructorOptions = { + label: 'File', + submenu: [ + { label: 'New Chat', accelerator: 'CmdOrCtrl+N', click: () => emit(win(), 'new-chat') }, + ...(!isMac + ? ([ + { label: 'Settings', accelerator: 'Ctrl+,', click: () => emit(win(), 'open-settings') }, + { type: 'separator' }, + { role: 'quit' }, + ] as MenuItemConstructorOptions[]) + : []), + ], + } + + const editMenu: MenuItemConstructorOptions = { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'selectAll' }, + ], + } + + const viewMenu: MenuItemConstructorOptions = { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + } + + const windowMenu: MenuItemConstructorOptions = { + label: 'Window', + submenu: [ + { role: 'minimize' }, + ...(isMac ? ([{ role: 'zoom' }] as MenuItemConstructorOptions[]) : []), + { type: 'separator' }, + // Explicit Close so Cmd/Ctrl+W reliably triggers our close-to-tray hide. + { label: 'Close Window', accelerator: 'CmdOrCtrl+W', click: () => win()?.close() }, + ], + } + + const helpMenu: MenuItemConstructorOptions = { + label: 'Help', + submenu: [{ label: 'Skill Hub', click: () => shell.openExternal(SKILL_HUB_URL) }], + } + + const template: MenuItemConstructorOptions[] = [ + ...appMenu, + fileMenu, + editMenu, + viewMenu, + windowMenu, + helpMenu, + ] + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)) +} diff --git a/desktop/src/main/preload.ts b/desktop/src/main/preload.ts index 642e2014..e34e7326 100644 --- a/desktop/src/main/preload.ts +++ b/desktop/src/main/preload.ts @@ -24,5 +24,10 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('window-maximize-changed', (_event, max) => callback(max)) }, + // App menu / shortcut actions forwarded from the main process. + onMenuAction: (callback: (action: string) => void) => { + ipcRenderer.on('menu-action', (_event, action: string) => callback(action)) + }, + platform: process.platform, }) diff --git a/desktop/src/main/tray.ts b/desktop/src/main/tray.ts new file mode 100644 index 00000000..3039754d --- /dev/null +++ b/desktop/src/main/tray.ts @@ -0,0 +1,59 @@ +import { app, Tray, Menu, BrowserWindow, nativeImage } from 'electron' + +let tray: Tray | null = null + +interface TrayDeps { + getWindow: () => BrowserWindow | null + iconPath?: string + // Called when the user picks "Quit" so the app can fully exit. + onQuit: () => void +} + +// Build a system tray icon with a minimal menu. The tray lets users restore the +// window after closing it to the background and start a new chat quickly. +export function createTray({ getWindow, iconPath, onQuit }: TrayDeps): Tray | null { + if (tray) return tray + if (!iconPath) return null + + let image = nativeImage.createFromPath(iconPath) + if (image.isEmpty()) return null + // Tray icons render small; resize to avoid an oversized image on some platforms. + image = image.resize({ width: 18, height: 18 }) + // macOS renders template images correctly in both light/dark menubars. + if (process.platform === 'darwin') image.setTemplateImage(false) + + tray = new Tray(image) + tray.setToolTip(app.name) + + const showWindow = () => { + const win = getWindow() + if (!win) return + if (win.isMinimized()) win.restore() + win.show() + win.focus() + } + + const contextMenu = Menu.buildFromTemplate([ + { label: 'Show CowAgent', click: showWindow }, + { + label: 'New Chat', + click: () => { + showWindow() + getWindow()?.webContents.send('menu-action', 'new-chat') + }, + }, + { type: 'separator' }, + { label: 'Quit', click: onQuit }, + ]) + tray.setContextMenu(contextMenu) + + // Single click restores the window (common Windows/Linux behavior). + tray.on('click', showWindow) + + return tray +} + +export function destroyTray() { + tray?.destroy() + tray = null +} diff --git a/desktop/src/renderer/src/App.tsx b/desktop/src/renderer/src/App.tsx index 5e746074..0e45febb 100644 --- a/desktop/src/renderer/src/App.tsx +++ b/desktop/src/renderer/src/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useEffect } from 'react' -import { Routes, Route, useLocation } from 'react-router-dom' +import { Routes, Route, useLocation, useNavigate } from 'react-router-dom' import { PanelLeftOpen } from 'lucide-react' import NavRail from './layout/NavRail' import SessionList from './layout/SessionList' @@ -8,6 +8,7 @@ import StatusScreen from './components/StatusScreen' import { useBackend } from './hooks/useBackend' import { usePlatform } from './hooks/usePlatform' import { useUIStore } from './store/uiStore' +import { useSessionStore } from './store/sessionStore' import apiClient from './api/client' import { t } from './i18n' import ChatPage from './pages/ChatPage' @@ -22,6 +23,7 @@ import LogsPage from './pages/LogsPage' const App: React.FC = () => { const backend = useBackend() const location = useLocation() + const navigate = useNavigate() const { isWin } = usePlatform() const { sessionsCollapsed, toggleSessions } = useUIStore() const [, forceUpdate] = useState(0) @@ -30,6 +32,18 @@ const App: React.FC = () => { if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl) }, [backend.status, backend.baseUrl]) + // Handle app-menu / shortcut actions forwarded from the main process. + useEffect(() => { + window.electronAPI?.onMenuAction?.((action) => { + if (action === 'new-chat') { + useSessionStore.getState().newSession() + navigate('/') + } else if (action === 'open-settings') { + navigate('/settings') + } + }) + }, [navigate]) + const handleLangChange = useCallback(() => forceUpdate((n) => n + 1), []) if (backend.status !== 'ready') { diff --git a/desktop/src/renderer/src/types.ts b/desktop/src/renderer/src/types.ts index 276b1d1d..44dbffec 100644 --- a/desktop/src/renderer/src/types.ts +++ b/desktop/src/renderer/src/types.ts @@ -15,6 +15,7 @@ export interface ElectronAPI { windowClose: () => Promise windowIsMaximized: () => Promise onMaximizeChange: (callback: (maximized: boolean) => void) => void + onMenuAction?: (callback: (action: string) => void) => void platform: string }