feat(desktop): native desktop enhancements

- Add a minimal application menu with common items and shortcuts:
  New Chat (Cmd+N), Settings (Cmd+,), Reload, etc.
- Add system tray with show window, new chat, and quit; click icon to restore
- Add single-instance lock so relaunching focuses the existing window
- Implement close-to-tray: closing the window hides it; only a real quit destroys it
- Explicitly define Window menu's Close Window bound to Cmd+W / Ctrl+W to reliably trigger hide-to-tray
- Listen for menu-action in the renderer to handle menu-triggered new chat / open settings
This commit is contained in:
zhayujie
2026-06-22 15:32:22 +08:00
parent d1336b872e
commit 49452e035d
6 changed files with 226 additions and 1 deletions

View File

@@ -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()
})

106
desktop/src/main/menu.ts Normal file
View File

@@ -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))
}

View File

@@ -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,
})

59
desktop/src/main/tray.ts Normal file
View File

@@ -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
}

View File

@@ -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') {

View File

@@ -15,6 +15,7 @@ export interface ElectronAPI {
windowClose: () => Promise<void>
windowIsMaximized: () => Promise<boolean>
onMaximizeChange: (callback: (maximized: boolean) => void) => void
onMenuAction?: (callback: (action: string) => void) => void
platform: string
}