feat(tray): Windows system tray with minimize-to-tray on close

Adds Windows system tray support and a minimize-to-tray setting for close behavior.
This commit is contained in:
Janderson Fagner 2026-06-19 02:30:44 -03:00 committed by GitHub
parent 4a2dc069d5
commit e7af3676f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 802 additions and 39 deletions

View File

@ -78,6 +78,7 @@ import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit
import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target'
import { attachMainWindowServices } from './window/attach-main-window-services'
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
import { createSystemTray, destroySystemTray } from './tray/system-tray'
import { focusExistingMainWindow } from './window/focus-existing-window'
import { CodexAccountService } from './codex-accounts/service'
import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service'
@ -554,6 +555,23 @@ function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget):
return runtimeHomePath
}
// Why: tray "Open Orca" / left-click restores the window the close handler may
// have hidden to the tray; if the window was fully torn down, reopen it the
// same way macOS dock re-activation does (guarded against update relaunch).
function showMainWindowFromTray(): void {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
mainWindow.show()
mainWindow.focus()
return
}
if (!isQuittingForUpdate()) {
openMainWindow()
}
}
function openMainWindow(): BrowserWindow {
logStartupMilestone('open-main-window-start')
if (!store) {
@ -757,6 +775,23 @@ function openMainWindow(): BrowserWindow {
stopAllSyntheticTitleSpinners()
})
mainWindow = window
// Why: Windows-only system tray. createSystemTray is idempotent and a no-op
// off win32, so calling it on each window open keeps exactly one live icon.
createSystemTray({
appIcon: store.getSettings().appIcon,
onOpen: showMainWindowFromTray,
onQuit: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
// Why: a real quit can still surface renderer save/discard prompts; the
// window must be visible if a hidden-to-tray session vetoes shutdown.
showMainWindowFromTray()
}
// Why: set the quit latch before app.quit() so the window 'close' handler
// proceeds to teardown instead of re-hiding the window to the tray.
isQuitting = true
app.quit()
}
})
window.on('show', resumeSyntheticTitleSpinnerTimer)
window.on('restore', resumeSyntheticTitleSpinnerTimer)
window.on('hide', stopSyntheticTitleSpinnerTimer)
@ -1663,6 +1698,9 @@ app.on('before-quit', () => {
// async work and let Electron exit.
let daemonDisconnectDone = false
app.on('will-quit', (e) => {
// Why: before-quit can still be aborted by renderer beforeunload; wait until
// the committed quit path before removing the Windows notification icon.
destroySystemTray()
// Why: stats.flush() must run before killAllPty() so it can read the
// live agent state and emit synthetic agent_stop events for agents that
// are still running. killAllPty() does not call runtime.onPtyExit(),

View File

@ -1,12 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { handlers, appExitMock, appQuitMock, appRelaunchMock, execFileMock } = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
appExitMock: vi.fn(),
appQuitMock: vi.fn(),
appRelaunchMock: vi.fn(),
execFileMock: vi.fn()
}))
const { handlers, appExitMock, appQuitMock, appRelaunchMock, execFileMock, destroySystemTrayMock } =
vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
appExitMock: vi.fn(),
appQuitMock: vi.fn(),
appRelaunchMock: vi.fn(),
execFileMock: vi.fn(),
destroySystemTrayMock: vi.fn()
}))
vi.mock('node:child_process', () => ({
execFile: execFileMock
@ -37,6 +39,10 @@ vi.mock('@electron-toolkit/utils', () => ({
is: { dev: true }
}))
vi.mock('../tray/system-tray', () => ({
destroySystemTray: destroySystemTrayMock
}))
import { registerAppHandlers } from './app'
describe('registerAppHandlers', () => {
@ -49,6 +55,7 @@ describe('registerAppHandlers', () => {
appQuitMock.mockReset()
appRelaunchMock.mockReset()
execFileMock.mockReset()
destroySystemTrayMock.mockReset()
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
})
@ -70,8 +77,12 @@ describe('registerAppHandlers', () => {
await relaunchPromise
await vi.advanceTimersByTimeAsync(150)
expect(destroySystemTrayMock).toHaveBeenCalledTimes(1)
expect(appRelaunchMock).toHaveBeenCalledTimes(1)
expect(appExitMock).toHaveBeenCalledWith(0)
expect(destroySystemTrayMock.mock.invocationCallOrder[0]).toBeLessThan(
appExitMock.mock.invocationCallOrder[0]
)
})
it('waits for pre-relaunch cleanup before exiting', async () => {

View File

@ -12,6 +12,7 @@ import { isPwshAvailable } from '../pwsh'
import { isWslAvailable, listWslDistros } from '../wsl'
import { isGitBashAvailable } from '../git-bash'
import { setUnreadDockBadgeCount } from '../dock/unread-badge'
import { destroySystemTray } from '../tray/system-tray'
import { authorizeExternalPath } from './filesystem-auth'
import {
ensureDefaultFloatingWorkspacePath,
@ -214,6 +215,9 @@ export function registerAppHandlers(store: Store, options: RegisterAppHandlersOp
// Mark shutdown first because app.exit() can bypass the usual quit latch.
await runBeforeRelaunchCleanup(options.onBeforeRelaunch)
setTimeout(() => {
// Why: app.exit(0) skips before-quit/will-quit, so clean the Windows tray
// explicitly before relaunching to avoid a stale notification-area icon.
destroySystemTray()
app.relaunch()
app.exit(0)
}, 150)

View File

@ -510,6 +510,56 @@ describe('Store', () => {
expect(ui.setupGuideBrowserMilestoneLegacyComplete).toBe(false)
})
it('defaults minimizeToTrayOnClose to false when unset', async () => {
const store = await createStore()
expect(store.getSettings().minimizeToTrayOnClose).toBe(false)
})
it('coerces loaded minimizeToTrayOnClose to false unless stored as true', async () => {
writeDataFile({
...getDefaultPersistedState(testState.dir),
settings: {
minimizeToTrayOnClose: 'true' as unknown as boolean
}
})
const store = await createStore()
expect(store.getSettings().minimizeToTrayOnClose).toBe(false)
})
it('persists minimizeToTrayOnClose true/false round-trip', async () => {
const store = await createStore()
store.updateSettings({ minimizeToTrayOnClose: true })
expect(store.getSettings().minimizeToTrayOnClose).toBe(true)
store.flush()
expect((readDataFile() as PersistedState).settings.minimizeToTrayOnClose).toBe(true)
store.updateSettings({ minimizeToTrayOnClose: false })
expect(store.getSettings().minimizeToTrayOnClose).toBe(false)
})
it('coerces non-boolean minimizeToTrayOnClose payloads to a strict boolean', async () => {
const store = await createStore()
// Why: a renderer-supplied non-bool must never persist as a truthy non-bool
// that would later read as "tray-minimize on".
store.updateSettings({ minimizeToTrayOnClose: 'true' as unknown as boolean })
expect(store.getSettings().minimizeToTrayOnClose).toBe(false)
store.updateSettings({ minimizeToTrayOnClose: 1 as unknown as boolean })
expect(store.getSettings().minimizeToTrayOnClose).toBe(false)
store.updateSettings({ minimizeToTrayOnClose: null as unknown as boolean })
expect(store.getSettings().minimizeToTrayOnClose).toBe(false)
})
it('defaults trayMinimizeNoticeShown to false and persists it strictly', async () => {
const store = await createStore()
expect(store.getUI().trayMinimizeNoticeShown).toBe(false)
store.updateUI({ trayMinimizeNoticeShown: true })
expect(store.getUI().trayMinimizeNoticeShown).toBe(true)
store.flush()
const reloaded = await createStore()
expect(reloaded.getUI().trayMinimizeNoticeShown).toBe(true)
})
it('hides the setup guide sidebar entry for existing users backfilled as completed', async () => {
writeDataFile({
schemaVersion: 1,

View File

@ -2714,6 +2714,9 @@ export class Store {
parsed.settings?.terminalCustomThemes
),
appIcon: normalizeAppIconId(parsed.settings?.appIcon),
// Why: persisted settings can be user-edited or written by older
// builds; keep tray-minimize false unless the stored value is true.
minimizeToTrayOnClose: parsed.settings?.minimizeToTrayOnClose === true,
uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage),
defaultTaskSource: taskProviderSettings.defaultTaskSource,
visibleTaskProviders: taskProviderSettings.visibleTaskProviders,
@ -4498,6 +4501,12 @@ export class Store {
options: { notifyListeners?: boolean; originWebContentsId?: number } = {}
): GlobalSettings {
const sanitizedUpdates = { ...updates }
// Why: coerce strictly to boolean here (not at the IPC edge) so every write
// path is covered and a non-bool renderer payload can never persist a
// truthy non-bool that later reads as "tray-minimize on".
if ('minimizeToTrayOnClose' in updates) {
sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true
}
if ('disabledTuiAgents' in updates) {
sanitizedUpdates.disabledTuiAgents = normalizeDisabledTuiAgents(updates.disabledTuiAgents)
}
@ -4640,6 +4649,9 @@ export class Store {
this.state.ui?.workspaceBoardColumnWidth
),
syncTaskStatusFromWorkspaceBoard: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true,
// Why: strict boolean coercion so a missing/legacy value reads as false
// (first-run notice still fires) rather than leaking a non-bool through.
trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true,
markdownTocPanelWidth: clampMarkdownTocPanelWidth(this.state.ui?.markdownTocPanelWidth),
visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds(
this.state.ui?.visibleWorkspaceHostIds

View File

@ -0,0 +1,142 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as SystemTrayModule from './system-tray'
const { trayInstances, menuFromTemplateMock, createAppIconImageMock, resizedImage } = vi.hoisted(
() => {
const resizedImage = { resized: true }
return {
trayInstances: [] as FakeTray[],
menuFromTemplateMock: vi.fn((template: unknown) => ({ template })),
createAppIconImageMock: vi.fn(),
resizedImage
}
}
)
class FakeTray {
setToolTip = vi.fn()
setContextMenu = vi.fn()
on = vi.fn()
destroy = vi.fn()
isDestroyed = vi.fn(() => false)
constructor(public readonly image: unknown) {
trayInstances.push(this)
}
}
vi.mock('electron', () => ({
Tray: FakeTray,
Menu: { buildFromTemplate: menuFromTemplateMock }
}))
vi.mock('../app-icon', () => ({
createAppIconImage: createAppIconImageMock
}))
type TrayModule = typeof SystemTrayModule
const originalPlatform = process.platform
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
}
async function loadModule(): Promise<TrayModule> {
vi.resetModules()
return import('./system-tray')
}
type MenuItem = { label?: string; type?: string; click?: () => void }
function builtMenuItems(): MenuItem[] {
return menuFromTemplateMock.mock.calls.at(-1)?.[0] as MenuItem[]
}
beforeEach(() => {
trayInstances.length = 0
menuFromTemplateMock.mockClear()
createAppIconImageMock.mockReset()
createAppIconImageMock.mockReturnValue({ resize: vi.fn(() => resizedImage) })
})
afterEach(() => {
setPlatform(originalPlatform)
})
describe('createSystemTray', () => {
it('creates a tray with an Orca tooltip and Open/Quit menu on win32', async () => {
setPlatform('win32')
const { createSystemTray } = await loadModule()
const onOpen = vi.fn()
const onQuit = vi.fn()
const tray = createSystemTray({ appIcon: 'classic', onOpen, onQuit })
expect(tray).not.toBeNull()
expect(trayInstances).toHaveLength(1)
expect(trayInstances[0].image).toBe(resizedImage)
expect(trayInstances[0].setToolTip).toHaveBeenCalledWith('Orca')
const items = builtMenuItems()
expect(items.map((i) => i.label)).toEqual(['Open Orca', undefined, 'Quit'])
expect(items[1].type).toBe('separator')
})
it('wires Open Orca, the tray click, and Quit to their callbacks', async () => {
setPlatform('win32')
const { createSystemTray } = await loadModule()
const onOpen = vi.fn()
const onQuit = vi.fn()
createSystemTray({ appIcon: 'classic', onOpen, onQuit })
const items = builtMenuItems()
items.find((i) => i.label === 'Open Orca')?.click?.()
expect(onOpen).toHaveBeenCalledTimes(1)
const clickHandler = trayInstances[0].on.mock.calls.find((c) => c[0] === 'click')?.[1] as
| (() => void)
| undefined
clickHandler?.()
expect(onOpen).toHaveBeenCalledTimes(2)
items.find((i) => i.label === 'Quit')?.click?.()
expect(onQuit).toHaveBeenCalledTimes(1)
})
it('is idempotent: a second call does not create a duplicate tray', async () => {
setPlatform('win32')
const { createSystemTray } = await loadModule()
const opts = { appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() }
const first = createSystemTray(opts)
const second = createSystemTray(opts)
expect(trayInstances).toHaveLength(1)
expect(second).toBe(first)
})
it('is a no-op on non-win32 platforms', async () => {
setPlatform('darwin')
const { createSystemTray } = await loadModule()
const tray = createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() })
expect(tray).toBeNull()
expect(trayInstances).toHaveLength(0)
})
})
describe('destroySystemTray', () => {
it('destroys an existing tray and is safe to call without one', async () => {
setPlatform('win32')
const { createSystemTray, destroySystemTray } = await loadModule()
createSystemTray({ appIcon: 'classic', onOpen: vi.fn(), onQuit: vi.fn() })
const created = trayInstances[0]
destroySystemTray()
expect(created.destroy).toHaveBeenCalledTimes(1)
// Second call with no live tray must not throw.
expect(() => destroySystemTray()).not.toThrow()
})
})

View File

@ -0,0 +1,58 @@
import { Menu, Tray } from 'electron'
import { createAppIconImage } from '../app-icon'
import { translateMain } from '../i18n/main-i18n'
type SystemTrayOptions = {
/** App icon id from settings; the tray reuses the app icon image. */
appIcon: unknown
/** Restore + show + focus the main window (recreating it if needed). */
onOpen: () => void
/** Quit Orca for real (caller must set the quitting latch before quitting). */
onQuit: () => void
}
// Why: Electron's Tray is GC-collected and its icon vanishes if no live
// reference is kept, so hold it at module scope for the app's lifetime.
let tray: Tray | null = null
// Why: on Windows the notification area expects a 16px icon; the app icon PNG
// is larger, so downscale to avoid a cropped/blurry tray glyph.
const TRAY_ICON_SIZE = 16
/**
* Creates the Windows system tray icon. No-op on macOS/Linux. Idempotent: a
* second call while a tray is alive returns the existing one instead of
* stacking a duplicate ghost icon.
*/
export function createSystemTray(opts: SystemTrayOptions): Tray | null {
if (process.platform !== 'win32') {
return null
}
if (tray && !tray.isDestroyed()) {
return tray
}
const image = createAppIconImage(opts.appIcon).resize({
width: TRAY_ICON_SIZE,
height: TRAY_ICON_SIZE
})
tray = new Tray(image)
tray.setToolTip('Orca')
const menu = Menu.buildFromTemplate([
{ label: translateMain('tray.openOrca', 'Open Orca'), click: () => opts.onOpen() },
{ type: 'separator' },
{ label: translateMain('tray.quit', 'Quit'), click: () => opts.onQuit() }
])
tray.setContextMenu(menu)
// Why: a left-click on the tray icon is the conventional Windows gesture to
// restore a minimized-to-tray app.
tray.on('click', () => opts.onOpen())
return tray
}
/** Destroys the tray icon if present. Safe to call repeatedly or with no tray. */
export function destroySystemTray(): void {
if (tray && !tray.isDestroyed()) {
tray.destroy()
}
tray = null
}

View File

@ -1,5 +1,5 @@
/* oxlint-disable max-lines */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
browserWindowMock,
@ -7,15 +7,22 @@ const {
attachGuestPoliciesMock,
buildFromTemplateMock,
menuPopupMock,
notificationMock,
notificationShowMock,
isMock
} = vi.hoisted(() => {
const menuPopupMock = vi.fn()
const notificationShowMock = vi.fn()
return {
browserWindowMock: vi.fn(),
openExternalMock: vi.fn(),
attachGuestPoliciesMock: vi.fn(),
buildFromTemplateMock: vi.fn(() => ({ popup: menuPopupMock })),
menuPopupMock,
notificationMock: vi.fn(function () {
return { show: notificationShowMock }
}),
notificationShowMock,
isMock: { dev: false }
}
})
@ -25,6 +32,7 @@ vi.mock('electron', () => ({
BrowserWindow: browserWindowMock,
ipcMain: { on: vi.fn(), removeListener: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() },
Menu: { buildFromTemplate: buildFromTemplateMock },
Notification: notificationMock,
nativeTheme: { shouldUseDarkColors: false },
screen: {
getPrimaryDisplay: () => ({ workAreaSize: { width: 1440, height: 900 } })
@ -57,6 +65,8 @@ describe('createMainWindow', () => {
attachGuestPoliciesMock.mockReset()
buildFromTemplateMock.mockClear()
menuPopupMock.mockClear()
notificationMock.mockClear()
notificationShowMock.mockClear()
isMock.dev = false
vi.mocked(ipcMain.on).mockReset()
vi.mocked(ipcMain.removeListener).mockReset()
@ -2521,4 +2531,197 @@ describe('createMainWindow', () => {
expect(browserWindowInstance.maximize).toHaveBeenCalledTimes(1)
expect(browserWindowInstance.show).toHaveBeenCalledTimes(1)
})
describe('minimize to tray on close (win32)', () => {
const originalPlatform = process.platform
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
}
type CloseFixture = {
windowHandlers: Record<string, (...args: any[]) => void>
webContents: { send: ReturnType<typeof vi.fn> }
instance: { hide: ReturnType<typeof vi.fn>; isMinimized: ReturnType<typeof vi.fn> }
}
function setupCloseWindow(): CloseFixture {
const windowHandlers: Record<string, (...args: any[]) => void> = {}
const webContents = {
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
setZoomLevel: vi.fn(),
setBackgroundThrottling: vi.fn(),
invalidate: vi.fn(),
setWindowOpenHandler: vi.fn(),
send: vi.fn(),
isCrashed: vi.fn(() => false),
id: 1
}
const instance = {
webContents,
on: vi.fn((event, handler) => {
windowHandlers[event] = handler
}),
isDestroyed: vi.fn(() => false),
isMaximized: vi.fn(() => false),
isFullScreen: vi.fn(() => false),
isMinimized: vi.fn(() => false),
getSize: vi.fn(() => [1200, 800]),
setSize: vi.fn(),
maximize: vi.fn(),
show: vi.fn(),
hide: vi.fn(),
loadFile: vi.fn(),
loadURL: vi.fn()
}
browserWindowMock.mockImplementation(function () {
return instance
})
return { windowHandlers, webContents, instance }
}
function makeStore(minimizeToTrayOnClose: boolean, trayMinimizeNoticeShown: boolean) {
return {
getUI: vi.fn(() => ({ trayMinimizeNoticeShown })),
getSettings: vi.fn(() => ({ windowBackgroundBlur: false, minimizeToTrayOnClose })),
updateUI: vi.fn()
}
}
afterEach(() => {
setPlatform(originalPlatform)
})
it('hides to the tray instead of closing when the setting is on', () => {
setPlatform('win32')
const { windowHandlers, webContents, instance } = setupCloseWindow()
const store = makeStore(true, true)
createMainWindow(store as never, { getIsQuitting: () => false })
const preventDefault = vi.fn()
windowHandlers.close({ preventDefault } as never)
expect(preventDefault).toHaveBeenCalled()
expect(instance.hide).toHaveBeenCalledTimes(1)
expect(webContents.send).not.toHaveBeenCalledWith('window:close-requested', expect.anything())
// Notice already shown, so it must not fire again.
expect(notificationMock).not.toHaveBeenCalled()
})
it('keeps the normal close flow when the setting is off', () => {
setPlatform('win32')
const { windowHandlers, webContents, instance } = setupCloseWindow()
const store = makeStore(false, true)
createMainWindow(store as never, { getIsQuitting: () => false })
windowHandlers.close({ preventDefault: vi.fn() } as never)
expect(instance.hide).not.toHaveBeenCalled()
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
isQuitting: false
})
})
it('does not hide on a real quit even with the setting on', () => {
setPlatform('win32')
const { windowHandlers, webContents, instance } = setupCloseWindow()
const store = makeStore(true, true)
createMainWindow(store as never, { getIsQuitting: () => true })
windowHandlers.close({ preventDefault: vi.fn() } as never)
expect(instance.hide).not.toHaveBeenCalled()
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
isQuitting: true
})
})
it('does not hide when the renderer process is gone', () => {
setPlatform('win32')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const { windowHandlers, instance } = setupCloseWindow()
const store = makeStore(true, true)
createMainWindow(store as never, { getIsQuitting: () => false })
windowHandlers['render-process-gone']?.(
{} as never,
{ reason: 'crashed', exitCode: 5 } as never
)
const preventDefault = vi.fn()
windowHandlers.close({ preventDefault } as never)
expect(instance.hide).not.toHaveBeenCalled()
expect(preventDefault).not.toHaveBeenCalled()
consoleError.mockRestore()
})
it('shows the first-run notification once and persists the flag', () => {
setPlatform('win32')
const { windowHandlers } = setupCloseWindow()
const store = makeStore(true, false)
createMainWindow(store as never, { getIsQuitting: () => false })
windowHandlers.close({ preventDefault: vi.fn() } as never)
expect(notificationMock).toHaveBeenCalledTimes(1)
expect(notificationShowMock).toHaveBeenCalledTimes(1)
expect(store.updateUI).toHaveBeenCalledWith({ trayMinimizeNoticeShown: true })
})
it('leaves the close handler unchanged off win32', () => {
setPlatform('darwin')
const { windowHandlers, webContents, instance } = setupCloseWindow()
const store = makeStore(true, true)
createMainWindow(store as never, { getIsQuitting: () => false })
windowHandlers.close({ preventDefault: vi.fn() } as never)
expect(instance.hide).not.toHaveBeenCalled()
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
isQuitting: false
})
})
// Why: on Windows the renderer-drawn X routes through window:request-close,
// not the native close event — regression guard for the bug where the app
// quit instead of hiding because the guard only covered the native event.
function captureIpcHandlers(): Record<string, (...args: any[]) => void> {
const ipcHandlers: Record<string, (...args: any[]) => void> = {}
vi.mocked(ipcMain.on).mockImplementation((channel, handler) => {
ipcHandlers[channel] = handler as (...args: any[]) => void
return ipcMain
})
return ipcHandlers
}
it('hides to the tray when the renderer-drawn X requests close', () => {
setPlatform('win32')
const ipcHandlers = captureIpcHandlers()
const { webContents, instance } = setupCloseWindow()
const store = makeStore(true, true)
createMainWindow(store as never, { getIsQuitting: () => false })
ipcHandlers['window:request-close']?.()
expect(instance.hide).toHaveBeenCalledTimes(1)
expect(webContents.send).not.toHaveBeenCalledWith('window:close-requested', expect.anything())
})
it('forwards window:request-close to the renderer when the setting is off', () => {
setPlatform('win32')
const ipcHandlers = captureIpcHandlers()
const { webContents, instance } = setupCloseWindow()
const store = makeStore(false, true)
createMainWindow(store as never, { getIsQuitting: () => false })
ipcHandlers['window:request-close']?.()
expect(instance.hide).not.toHaveBeenCalled()
expect(webContents.send).toHaveBeenCalledWith('window:close-requested', {
isQuitting: false
})
})
})
})

View File

@ -1,11 +1,21 @@
/* oxlint-disable max-lines */
import { app, BrowserWindow, ipcMain, Menu, nativeTheme, screen, shell } from 'electron'
import {
app,
BrowserWindow,
ipcMain,
Menu,
nativeTheme,
Notification,
screen,
shell
} from 'electron'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
import type { Store } from '../persistence'
import { getAppIconPath } from '../app-icon'
import { browserManager } from '../browser/browser-manager'
import { browserSessionRegistry } from '../browser/browser-session-registry'
import { translateMain } from '../i18n/main-i18n'
import {
normalizeBrowserNavigationUrl,
normalizeExternalBrowserUrl
@ -932,7 +942,50 @@ export function createMainWindow(
let windowCloseConfirmed = false
const confirmCloseChannel = 'window:confirm-close'
// Why: Windows minimize-to-tray. Hides the window instead of closing when the
// setting is on, this isn't a real quit (Ctrl+Q / tray "Quit" set
// getIsQuitting), and the renderer is alive. Returns true when it handled the
// close by hiding, so callers skip their normal close path. Shared by BOTH the
// renderer-drawn X (window:request-close) and the native close event (Alt+F4).
const hideToTrayIfEnabled = (): boolean => {
const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false
if (
process.platform !== 'win32' ||
rendererProcessGone ||
isRendererCrashed ||
opts?.getIsQuitting?.() === true ||
store?.getSettings().minimizeToTrayOnClose !== true
) {
return false
}
mainWindow.hide()
// Why: tell the user once that closing only hid the window; the persisted
// flag stops the notice from repeating on every later minimize.
if (store.getUI().trayMinimizeNoticeShown !== true) {
try {
new Notification({
title: 'Orca',
body: translateMain(
'tray.minimizeNotice.body',
'Orca is still running in the system tray'
)
}).show()
} catch {
// Notification is best-effort — never block hiding the window.
}
store.updateUI({ trayMinimizeNoticeShown: true })
}
return true
}
mainWindow.on('close', (e) => {
// Why: Alt+F4 and programmatic closes reach the native event; apply the same
// minimize-to-tray guard the renderer-drawn X uses via onRequestClose.
if (!windowCloseConfirmed && hideToTrayIfEnabled()) {
e.preventDefault()
return
}
const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false
if (windowCloseConfirmed) {
windowCloseConfirmed = false
// Why: past this point Electron/OS may emit resize/move/unmaximize as
@ -947,7 +1000,6 @@ export function createMainWindow(
}
return
}
const isRendererCrashed = mainWindow.webContents.isCrashed?.() ?? false
if (rendererProcessGone || isRendererCrashed) {
// Why: after a native renderer crash the renderer cannot answer
// window:close-requested. Let Cmd+Q / OS close complete instead of
@ -1016,9 +1068,16 @@ export function createMainWindow(
// with windowCloseConfirmed = true.
const requestCloseChannel = 'window:request-close'
const onRequestClose = (): void => {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('window:close-requested', { isQuitting: false })
if (mainWindow.isDestroyed()) {
return
}
// Why: the renderer-drawn X on Windows routes here (not the native close
// event), so the minimize-to-tray guard must run on this path too — hide
// instead of asking the renderer to close.
if (hideToTrayIfEnabled()) {
return
}
mainWindow.webContents.send('window:close-requested', { isQuitting: false })
}
// Why: the ··· button in the renderer-drawn title bar on Windows pops up
// the application menu at the cursor position, replicating the Alt-key

View File

@ -31,6 +31,7 @@ import {
getSidebarEntries,
getStatusBarEntries,
getStatusBarToggles,
getSystemTrayEntries,
getThemeEntries,
getTitlebarEntries,
getTypographyEntries,
@ -41,7 +42,8 @@ import { TerminalAppearanceSection } from './TerminalAppearanceSection'
import type { UseGhosttyImportReturn } from './useGhosttyImport'
import type { UseWarpThemeImportReturn } from './useWarpThemeImport'
import { AppIconSelector } from './AppIconSelector'
import { isWebClientLocation } from '@/hooks/useSettingsNavigationMetadata'
import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
import { isWebClientLocation } from '@/lib/web-client-location'
import {
getUiLanguageChoiceLabel,
SHOW_UI_LANGUAGE_SETTING,
@ -99,6 +101,10 @@ export function AppearancePane({
warpThemes
}: AppearancePaneProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const isWebClient = isWebClientLocation()
// Why: the system tray behavior is desktop-Electron Windows-only; a Windows
// browser web client has no local tray to control.
const isDesktopWindows = getRendererAppPlatform() === 'win32' && !isWebClient
const zoomInKeyCombos = useShortcutKeyComboDetails('zoom.in')
const zoomOutKeyCombos = useShortcutKeyComboDetails('zoom.out')
const statusBarItems = useAppStore((state) => state.statusBarItems)
@ -106,8 +112,9 @@ export function AppearancePane({
const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction)
const visibleStatusBarToggles = useAvailableStatusBarToggles(getStatusBarToggles())
const terminalAppearanceSearchEntries = getTerminalAppearanceSearchEntries({
showWarpImport: !isWebClientLocation()
showWarpImport: !isWebClient
})
const systemTrayEntries = getSystemTrayEntries({ showSystemTray: isDesktopWindows })
const leftSidebarAppearanceEntry = getLeftSidebarAppearanceEntry()
const workspaceCardLayoutEntry = getWorkspaceCardLayoutEntry()
const visibleSections = [
@ -362,6 +369,42 @@ export function AppearancePane({
</div>
</section>
) : null,
isDesktopWindows && matchesSettingsSearch(searchQuery, systemTrayEntries) ? (
<section key="system-tray" className="space-y-3">
<SettingsSubsectionHeader
title={translate('auto.components.settings.AppearancePane.872af9556e', 'System Tray')}
/>
<div className="divide-y divide-border/40">
<SearchableSetting
title={translate(
'auto.components.settings.AppearancePane.2edf606c46',
'Minimize to Tray on Close'
)}
description={translate(
'auto.components.settings.AppearancePane.b707773a0d',
'When enabled, closing the window keeps Orca running in the system tray instead of quitting.'
)}
keywords={systemTrayEntries[0]?.keywords ?? ['tray', 'minimize', 'close']}
>
<SettingsSwitchRow
label={translate(
'auto.components.settings.AppearancePane.2edf606c46',
'Minimize to Tray on Close'
)}
description={translate(
'auto.components.settings.AppearancePane.b707773a0d',
'When enabled, closing the window keeps Orca running in the system tray instead of quitting.'
)}
checked={settings.minimizeToTrayOnClose === true}
onChange={() =>
updateSettings({ minimizeToTrayOnClose: !settings.minimizeToTrayOnClose })
}
/>
</SearchableSetting>
</div>
</section>
) : null,
matchesSettingsSearch(searchQuery, getStatusBarEntries()) ? (
<section key="status-bar" className="space-y-3">
<SettingsSubsectionHeader

View File

@ -2,6 +2,8 @@ import type { SettingsSearchEntry } from './settings-search'
import { getTerminalAppearanceSearchEntries } from './terminal-search'
import { getLeftSidebarAppearanceEntry, getSidebarEntries } from './appearance-sidebar-search'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { getRendererAppPlatform } from '@/lib/renderer-app-platform'
import { isWebClientLocation } from '@/lib/web-client-location'
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
import { SHOW_UI_LANGUAGE_SETTING } from '@/i18n/supported-languages'
@ -184,8 +186,68 @@ export const getAppIconEntries = createLocalizedCatalog((): SettingsSearchEntry[
}
])
const getSystemTrayEntryCatalog = createLocalizedCatalog((): SettingsSearchEntry[] => [
{
title: translate(
'auto.components.settings.appearance.search.9a115966d3',
'Minimize to Tray on Close'
),
description: translate(
'auto.components.settings.appearance.search.4d5b9427b5',
'When enabled, closing the window keeps Orca running in the system tray instead of quitting.'
),
keywords: [
...translateSearchKeyword('auto.components.settings.appearance.search.tray.tray', 'tray', {
englishOnly: true
}),
...translateSearchKeyword(
'auto.components.settings.appearance.search.tray.system',
'system tray',
{ englishOnly: true }
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.tray.minimize',
'minimize',
{ englishOnly: true }
),
...translateSearchKeyword('auto.components.settings.appearance.search.tray.close', 'close', {
englishOnly: true
}),
...translateSearchKeyword('auto.components.settings.appearance.search.e5bc35d59e', 'window'),
...translateSearchKeyword(
'auto.components.settings.appearance.search.tray.notification',
'notification area',
{ englishOnly: true }
),
...translateSearchKeyword(
'auto.components.settings.appearance.search.tray.background',
'background',
{ englishOnly: true }
)
]
}
])
type SystemTraySearchOptions = {
showSystemTray?: boolean
}
function shouldShowSystemTrayEntries(options: SystemTraySearchOptions): boolean {
return (
options.showSystemTray ??
// Why: this setting controls Electron's Windows tray only. A Windows web
// browser can report win32, but it has no local tray to affect.
(getRendererAppPlatform() === 'win32' && !isWebClientLocation())
)
}
export function getSystemTrayEntries(options: SystemTraySearchOptions = {}): SettingsSearchEntry[] {
return shouldShowSystemTrayEntries(options) ? getSystemTrayEntryCatalog() : []
}
type AppearancePaneSearchOptions = {
showWarpImport?: boolean
showSystemTray?: boolean
}
function buildAppearancePaneSearchEntries(
@ -201,22 +263,16 @@ function buildAppearancePaneSearchEntries(
...getTitlebarEntries(),
...getStatusBarEntries(),
...getSidebarEntries(),
...getAppIconEntries()
...getAppIconEntries(),
...getSystemTrayEntries(options)
]
}
const getAppearancePaneSearchEntriesWithWarp = createLocalizedCatalog(() =>
buildAppearancePaneSearchEntries({ showWarpImport: true })
)
const getAppearancePaneSearchEntriesWithoutWarp = createLocalizedCatalog(() =>
buildAppearancePaneSearchEntries({ showWarpImport: false })
)
export function getAppearancePaneSearchEntries(
options: AppearancePaneSearchOptions = {}
): SettingsSearchEntry[] {
return (options.showWarpImport ?? true)
? getAppearancePaneSearchEntriesWithWarp()
: getAppearancePaneSearchEntriesWithoutWarp()
return buildAppearancePaneSearchEntries({
showWarpImport: options.showWarpImport ?? true,
showSystemTray: options.showSystemTray
})
}

View File

@ -131,6 +131,16 @@ describe('getTerminalPaneSearchEntries', () => {
expect(webEntries.some((entry) => entry.title === 'Import from Ghostty')).toBe(true)
})
it('includes the system tray appearance entry only when desktop tray controls are shown', () => {
const desktopEntries = getAppearancePaneSearchEntries({ showSystemTray: true })
const webEntries = getAppearancePaneSearchEntries({ showSystemTray: false })
expect(desktopEntries.some((entry) => entry.title === 'Minimize to Tray on Close')).toBe(true)
expect(webEntries.some((entry) => entry.title === 'Minimize to Tray on Close')).toBe(false)
expect(matchesSettingsSearch('tray', desktopEntries)).toBe(true)
expect(matchesSettingsSearch('tray', webEntries)).toBe(false)
})
it('keeps sidebar shortcut restore settings in the Appearance search index', () => {
const automationsEntry = getSidebarEntries().find(
(entry) => entry.title === 'Show Automations Button'

View File

@ -70,18 +70,14 @@ import { getShortcutsPaneSearchEntries } from '@/components/settings/shortcuts-s
import { getStatsPaneSearchEntries } from '@/components/stats/stats-search'
import { getExperimentalPaneSearchEntries } from '@/components/settings/experimental-search'
import { getRepositoryPaneSearchEntries } from '@/components/settings/repository-search'
import { isWebClientLocation } from '@/lib/web-client-location'
import {
getCachedWindowsTerminalCapabilities,
getWindowsTerminalCapabilityOwnerKey
} from '@/lib/windows-terminal-capabilities'
import { translate } from '@/i18n/i18n'
export function isWebClientLocation(): boolean {
return (
Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) ||
window.location.pathname.endsWith('/web-index.html')
)
}
export { isWebClientLocation } from '@/lib/web-client-location'
export function buildSettingsNavigationMetadata({
isMac,
@ -333,7 +329,10 @@ export function buildSettingsNavigationMetadata({
'Theme, zoom, app and terminal appearance, sidebars, and status bar.'
),
icon: Palette,
searchEntries: getAppearancePaneSearchEntries(),
searchEntries: getAppearancePaneSearchEntries({
showWarpImport: showDesktopOnlySettings,
showSystemTray: showDesktopOnlySettings && isWindows
}),
group: 'interface'
},
{

View File

@ -58,6 +58,13 @@
"window": "Window",
"help": "Help"
},
"tray": {
"openOrca": "Open Orca",
"quit": "Quit",
"minimizeNotice": {
"body": "Orca is still running in the system tray"
}
},
"worktreeJumpPalette": {
"matchLabel": {
"comment": "Comment",
@ -4383,6 +4390,9 @@
"5f5142a62a": "Previous icon"
},
"AppearancePane": {
"872af9556e": "System Tray",
"2edf606c46": "Minimize to Tray on Close",
"b707773a0d": "When enabled, closing the window keeps Orca running in the system tray instead of quitting.",
"3057983501": "Unassigned",
"0cd9b8228f": "Choose the app icon shown in the Dock and window switcher.",
"ca1590d42f": "App Icon",
@ -6463,6 +6473,8 @@
},
"appearance": {
"search": {
"9a115966d3": "Minimize to Tray on Close",
"4d5b9427b5": "When enabled, closing the window keeps Orca running in the system tray instead of quitting.",
"468448bba4": "watercolor",
"f586abfa35": "blue",
"651f35b2c6": "switcher",

View File

@ -58,6 +58,13 @@
"window": "Ventana",
"help": "Ayuda"
},
"tray": {
"openOrca": "Abrir Orca",
"quit": "Salir",
"minimizeNotice": {
"body": "Orca sigue ejecutándose en la bandeja del sistema"
}
},
"worktreeJumpPalette": {
"matchLabel": {
"comment": "Comentario",
@ -4426,7 +4433,10 @@
"tintOpacity": "Intensidad del tinte",
"tintOpacityDescription": "Controla con qué fuerza se mezcla el tinte en la barra lateral."
},
"workspaceCardLayoutGuidance": "Usa el menú de opciones de la barra lateral de espacios de trabajo > Diseño de tarjeta > Compacto."
"workspaceCardLayoutGuidance": "Usa el menú de opciones de la barra lateral de espacios de trabajo > Diseño de tarjeta > Compacto.",
"872af9556e": "Bandeja del sistema",
"2edf606c46": "Minimizar a la bandeja al cerrar",
"b707773a0d": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir."
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@ -6549,7 +6559,9 @@
"cardLayout": "diseño de tarjeta",
"workspaceOptions": "opciones de espacios de trabajo",
"detailed": "detallado"
}
},
"9a115966d3": "Minimizar a la bandeja al cerrar",
"4d5b9427b5": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir."
}
},
"auto": {

View File

@ -58,6 +58,13 @@
"window": "ウィンドウ",
"help": "ヘルプ"
},
"tray": {
"openOrca": "Orcaを開く",
"quit": "終了",
"minimizeNotice": {
"body": "Orcaはシステムトレイで実行中です"
}
},
"worktreeJumpPalette": {
"matchLabel": {
"comment": "コメント",
@ -4411,7 +4418,10 @@
"tintOpacity": "色合いの強さ",
"tintOpacityDescription": "サイドバーに色合いをどの程度強く混ぜるかを調整します。"
},
"workspaceCardLayoutGuidance": "ワークスペースサイドバーのオプションメニュー > カードレイアウト > コンパクト を使用します。"
"workspaceCardLayoutGuidance": "ワークスペースサイドバーのオプションメニュー > カードレイアウト > コンパクト を使用します。",
"872af9556e": "システムトレイ",
"2edf606c46": "閉じるときにトレイへ最小化",
"b707773a0d": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。"
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@ -6571,7 +6581,9 @@
"cardLayout": "カードレイアウト",
"workspaceOptions": "ワークスペースオプション",
"detailed": "詳細"
}
},
"9a115966d3": "閉じるときにトレイへ最小化",
"4d5b9427b5": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。"
}
},
"auto": {

View File

@ -58,6 +58,13 @@
"window": "창",
"help": "도움말"
},
"tray": {
"openOrca": "Orca 열기",
"quit": "종료",
"minimizeNotice": {
"body": "Orca가 시스템 트레이에서 계속 실행 중입니다"
}
},
"worktreeJumpPalette": {
"matchLabel": {
"comment": "댓글",
@ -4368,6 +4375,9 @@
"5f5142a62a": "이전 아이콘"
},
"AppearancePane": {
"872af9556e": "시스템 트레이",
"2edf606c46": "닫을 때 트레이로 최소화",
"b707773a0d": "활성화하면 창을 닫아도 Orca가 종료되지 않고 시스템 트레이에서 계속 실행됩니다.",
"3057983501": "할당되지 않음",
"0cd9b8228f": "Dock 및 창 전환기에 표시된 앱 아이콘을 선택합니다.",
"ca1590d42f": "앱 아이콘",
@ -6524,6 +6534,8 @@
"title": "왼쪽 사이드바 모양",
"description": "왼쪽 사이드바를 terminal에 맞추거나 기본값을 유지하거나 은은한 색조를 적용합니다."
},
"9a115966d3": "닫을 때 트레이로 최소화",
"4d5b9427b5": "활성화하면 창을 닫아도 Orca가 종료되지 않고 시스템 트레이에서 계속 실행됩니다.",
"workspaceCardLayout": {
"title": "워크스페이스 카드 레이아웃",
"description": "워크스페이스 사이드바 옵션 메뉴에서 워크스페이스 카드를 컴팩트 또는 상세 보기로 전환합니다.",

View File

@ -58,6 +58,13 @@
"window": "窗口",
"help": "帮助"
},
"tray": {
"openOrca": "打开 Orca",
"quit": "退出",
"minimizeNotice": {
"body": "Orca 仍在系统托盘中运行"
}
},
"worktreeJumpPalette": {
"matchLabel": {
"comment": "评论",
@ -4411,7 +4418,10 @@
"tintOpacity": "色调强度",
"tintOpacityDescription": "控制色调混入边栏的强度。"
},
"workspaceCardLayoutGuidance": "使用工作区侧边栏选项菜单 > 卡片布局 > 紧凑。"
"workspaceCardLayoutGuidance": "使用工作区侧边栏选项菜单 > 卡片布局 > 紧凑。",
"872af9556e": "系统托盘",
"2edf606c46": "关闭时最小化到托盘",
"b707773a0d": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。"
},
"AutoRenameBranchFromWorkSetting": {
"1626524572": "Nautilus",
@ -6534,7 +6544,9 @@
"cardLayout": "卡片布局",
"workspaceOptions": "工作区选项",
"detailed": "详细"
}
},
"9a115966d3": "关闭时最小化到托盘",
"4d5b9427b5": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。"
}
},
"auto": {

View File

@ -0,0 +1,9 @@
export function isWebClientLocation(): boolean {
if (typeof window === 'undefined') {
return false
}
return (
Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) ||
window.location.pathname.endsWith('/web-index.html')
)
}

View File

@ -250,6 +250,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
// focus-follows-mouse never happens unexpectedly.
terminalFocusFollowsMouse: false,
windowBackgroundBlur: false,
minimizeToTrayOnClose: false,
terminalClipboardOnSelect: false,
terminalAllowOsc52Clipboard: false,
claudeAgentTeamsMode: 'off',
@ -472,6 +473,7 @@ export function getDefaultUIState(): PersistedUIState {
setupGuideBrowserMilestoneMigrated: true,
setupGuideBrowserMilestoneLegacyComplete: false,
browserImportHintHidden: false,
trayMinimizeNoticeShown: false,
mobileEmulatorTabIntroDismissed: false,
mobileEmulatorAgentSetupDismissed: false,
// Why: brand-new profiles never saw recent project ordering; only upgraded

View File

@ -2419,6 +2419,10 @@ export type GlobalSettings = {
terminalCursorOpacity?: number
terminalQuickCommands?: TerminalQuickCommand[]
windowBackgroundBlur?: boolean
/** Why: Windows-only. When on, the close (X) button hides the window to the
* system tray instead of quitting Orca; off keeps the default quit-on-close.
* The tray icon itself is always present on Windows regardless of this flag. */
minimizeToTrayOnClose?: boolean
/** Why: Windows terminals conventionally use right-click as a paste gesture.
* The setting stays Windows-only so macOS/Linux keep their existing context
* menu behavior and users can still reach the menu with Ctrl+right-click. */
@ -3102,6 +3106,9 @@ export type PersistedUIState = {
/** User-dismissed browser import hint in the browser toolbar. Import remains
* available from Settings > Browser and the toolbar overflow menu. */
browserImportHintHidden?: boolean
/** Why: Windows-only. Set once after the window first hides to the system
* tray, so the "Orca is still running" notification shows only on first use. */
trayMinimizeNoticeShown?: boolean
/** User dismissed the first-run Mobile Emulator intro (Keep, Hide, or close).
* Reversible only by re-enabling the feature in Settings. */
mobileEmulatorTabIntroDismissed?: boolean