From e7af3676f2f762df4116c1eaabc184fd7a1b9a5f Mon Sep 17 00:00:00 2001 From: Janderson Fagner <135186779+janderson-fagner@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:30:44 -0300 Subject: [PATCH] feat(tray): Windows system tray with minimize-to-tray on close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Windows system tray support and a minimize-to-tray setting for close behavior. --- src/main/index.ts | 38 ++++ src/main/ipc/app.test.ts | 25 ++- src/main/ipc/app.ts | 4 + src/main/persistence.test.ts | 50 +++++ src/main/persistence.ts | 12 + src/main/tray/system-tray.test.ts | 142 ++++++++++++ src/main/tray/system-tray.ts | 58 +++++ src/main/window/createMainWindow.test.ts | 205 +++++++++++++++++- src/main/window/createMainWindow.ts | 67 +++++- .../components/settings/AppearancePane.tsx | 47 +++- .../components/settings/appearance-search.ts | 80 ++++++- .../settings/terminal-search.test.ts | 10 + .../hooks/useSettingsNavigationMetadata.ts | 13 +- src/renderer/src/i18n/locales/en.json | 12 + src/renderer/src/i18n/locales/es.json | 16 +- src/renderer/src/i18n/locales/ja.json | 16 +- src/renderer/src/i18n/locales/ko.json | 12 + src/renderer/src/i18n/locales/zh.json | 16 +- src/renderer/src/lib/web-client-location.ts | 9 + src/shared/constants.ts | 2 + src/shared/types.ts | 7 + 21 files changed, 802 insertions(+), 39 deletions(-) create mode 100644 src/main/tray/system-tray.test.ts create mode 100644 src/main/tray/system-tray.ts create mode 100644 src/renderer/src/lib/web-client-location.ts diff --git a/src/main/index.ts b/src/main/index.ts index 54fccd05d..891eb0071 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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(), diff --git a/src/main/ipc/app.test.ts b/src/main/ipc/app.test.ts index 76cc5fb69..68c3403c8 100644 --- a/src/main/ipc/app.test.ts +++ b/src/main/ipc/app.test.ts @@ -1,12 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { handlers, appExitMock, appQuitMock, appRelaunchMock, execFileMock } = vi.hoisted(() => ({ - handlers: new Map 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 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 () => { diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index 7424c4db8..e16b70b40 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -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) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 9618b1343..9ac653533 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 8a140d4cd..0492a7b55 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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 diff --git a/src/main/tray/system-tray.test.ts b/src/main/tray/system-tray.test.ts new file mode 100644 index 000000000..e269d9307 --- /dev/null +++ b/src/main/tray/system-tray.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/main/tray/system-tray.ts b/src/main/tray/system-tray.ts new file mode 100644 index 000000000..d531d2c59 --- /dev/null +++ b/src/main/tray/system-tray.ts @@ -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 +} diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 6dd1d33d8..be0d796c6 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -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 void> + webContents: { send: ReturnType } + instance: { hide: ReturnType; isMinimized: ReturnType } + } + + function setupCloseWindow(): CloseFixture { + const windowHandlers: Record 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 void> { + const ipcHandlers: Record 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 + }) + }) + }) }) diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 0ff617b1b..d0cc4332f 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -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 diff --git a/src/renderer/src/components/settings/AppearancePane.tsx b/src/renderer/src/components/settings/AppearancePane.tsx index aed005c68..8a392e305 100644 --- a/src/renderer/src/components/settings/AppearancePane.tsx +++ b/src/renderer/src/components/settings/AppearancePane.tsx @@ -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({ ) : null, + isDesktopWindows && matchesSettingsSearch(searchQuery, systemTrayEntries) ? ( +
+ + +
+ + + updateSettings({ minimizeToTrayOnClose: !settings.minimizeToTrayOnClose }) + } + /> + +
+
+ ) : null, matchesSettingsSearch(searchQuery, getStatusBarEntries()) ? (
[ + { + 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 + }) } diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index 7b1246694..56ca5e3c2 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -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' diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 6a06b7498..64e36fc67 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -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' }, { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 81fc00598..323a469fc 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 9134c7249..016e565ae 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index e8ba30da9..8e379f8a7 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 568e3ea72..8654c1059 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": "워크스페이스 사이드바 옵션 메뉴에서 워크스페이스 카드를 컴팩트 또는 상세 보기로 전환합니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 67d01ddfa..9525182f4 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/renderer/src/lib/web-client-location.ts b/src/renderer/src/lib/web-client-location.ts new file mode 100644 index 000000000..26c7e70bb --- /dev/null +++ b/src/renderer/src/lib/web-client-location.ts @@ -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') + ) +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index e1711b29b..00da56400 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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 diff --git a/src/shared/types.ts b/src/shared/types.ts index 82db4123b..9c72b816e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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