diff --git a/apps/buddy/electron/main/__tests__/window.spec.ts b/apps/buddy/electron/main/__tests__/window.spec.ts index 41267f31..79bc8fe0 100644 --- a/apps/buddy/electron/main/__tests__/window.spec.ts +++ b/apps/buddy/electron/main/__tests__/window.spec.ts @@ -73,6 +73,29 @@ vi.mock('electron', () => ({ })) describe('createDesktopWindow lifecycle', () => { + it('requests application quit instead of hiding when tray minimization is disabled', () => { + const onCloseToQuit = vi.fn() + const onHidden = vi.fn() + createDesktopWindow({ + appName: 'Lexora Buddy Dev', + iconPath: '/tmp/icon.png', + isQuitting: () => false, + minimizeToTrayOnClose: () => false, + onCloseToQuit, + onHidden, + rendererUrl: null, + }) + const window = electron.FakeBrowserWindow.instances.at(-1)! + const event = { preventDefault: vi.fn() } + + window.emit('close', event) + + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(onCloseToQuit).toHaveBeenCalledOnce() + expect(onHidden).not.toHaveBeenCalled() + expect(window.hide).not.toHaveBeenCalled() + }) + it('hides to tray on ordinary close and lets a committed quit close the window', () => { let isQuitting = false const onHidden = vi.fn() @@ -80,6 +103,8 @@ describe('createDesktopWindow lifecycle', () => { appName: 'Lexora Buddy Dev', iconPath: '/tmp/icon.png', isQuitting: () => isQuitting, + minimizeToTrayOnClose: () => true, + onCloseToQuit: vi.fn(), onHidden, rendererUrl: null, }) diff --git a/apps/buddy/electron/main/app/DesktopApplication.ts b/apps/buddy/electron/main/app/DesktopApplication.ts index cde80375..cadc1ee2 100644 --- a/apps/buddy/electron/main/app/DesktopApplication.ts +++ b/apps/buddy/electron/main/app/DesktopApplication.ts @@ -115,6 +115,8 @@ class DesktopApplication { return this.#windows.initialize({ onWindowCreated: window => this.#browser.bindWindow(window), isQuitting: () => this.#quit.quitting, + minimizeToTrayOnClose: () => this.#runtime.config?.desktop.minimizeToTrayOnClose ?? true, + onCloseToQuit: () => this.#requestQuit(), onHidden: () => { void showBackgroundCloseNotice(this.#runtime.configStore) }, }) }, ['desktop.integrations']) diff --git a/apps/buddy/electron/main/app/DesktopWindowHost.ts b/apps/buddy/electron/main/app/DesktopWindowHost.ts index 3eca070f..0116c93a 100644 --- a/apps/buddy/electron/main/app/DesktopWindowHost.ts +++ b/apps/buddy/electron/main/app/DesktopWindowHost.ts @@ -12,6 +12,8 @@ import { observeRendererDiagnostics } from './desktopProcessDiagnostics' interface WindowBindings { isQuitting: () => boolean + minimizeToTrayOnClose: () => boolean + onCloseToQuit: () => void onHidden: () => void onWindowCreated: (window: BrowserWindow) => void } @@ -46,6 +48,8 @@ export class DesktopWindowHost { appName: environment.paths.appName, iconPath: environment.desktopIconPath, isQuitting: bindings.isQuitting, + minimizeToTrayOnClose: bindings.minimizeToTrayOnClose, + onCloseToQuit: bindings.onCloseToQuit, onHidden() { if (!handle.window.webContents.isDestroyed()) handle.window.webContents.send(DESKTOP_IPC_CHANNELS.appHidden) diff --git a/apps/buddy/electron/main/config/LexoraConfigStore.ts b/apps/buddy/electron/main/config/LexoraConfigStore.ts index ec2d44fa..d1627c52 100644 --- a/apps/buddy/electron/main/config/LexoraConfigStore.ts +++ b/apps/buddy/electron/main/config/LexoraConfigStore.ts @@ -44,6 +44,7 @@ const desktopConfigSchema = z.object({ }).passthrough().prefault({}), context_panel_mode: z.enum(['task', 'independent']).default('task'), context_panel_global: z.boolean().default(false), + minimize_to_tray_on_close: z.boolean().default(true), keybindings: keybindingsSchema.default({}), task_sidebar_pinned_items: z.array(taskSidebarPinnedItemSchema) .max(500) @@ -75,6 +76,7 @@ const desktopConfigSchema = z.object({ }, context_panel_mode: 'task', context_panel_global: false, + minimize_to_tray_on_close: true, keybindings: {}, task_sidebar_pinned_items: [], task_sidebar: { collapsed: false, collapsed_sections: [], collapsed_spaces: [] }, @@ -231,6 +233,7 @@ function decodeConfig(value: unknown): LexoraConfig { }, contextPanelMode: config.desktop.context_panel_mode, contextPanelGlobal: config.desktop.context_panel_global, + minimizeToTrayOnClose: config.desktop.minimize_to_tray_on_close, keybindings: config.desktop.keybindings, taskSidebarPinnedItems: config.desktop.task_sidebar_pinned_items, taskSidebar: { @@ -280,6 +283,7 @@ function encodeConfig(config: LexoraConfig) { }, context_panel_mode: config.desktop.contextPanelMode, context_panel_global: config.desktop.contextPanelGlobal, + minimize_to_tray_on_close: config.desktop.minimizeToTrayOnClose, keybindings: config.desktop.keybindings, task_sidebar_pinned_items: config.desktop.taskSidebarPinnedItems, task_sidebar: { diff --git a/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts b/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts index f1a53957..67ed64c2 100644 --- a/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts +++ b/apps/buddy/electron/main/config/__tests__/LexoraConfigStore.spec.ts @@ -142,6 +142,7 @@ describe('lexoraConfigStore', () => { const updated = await store.update({ desktop: { theme: 'dark', + minimizeToTrayOnClose: false, chat: { welcome: 'writing' }, }, pet: { alwaysOnTop: false, enabled: false, rememberPosition: false }, @@ -150,6 +151,7 @@ describe('lexoraConfigStore', () => { expect(updated.desktop).toEqual({ contextPanelMode: 'task', contextPanelGlobal: false, + minimizeToTrayOnClose: false, keybindings: {}, backgroundCloseNoticeShown: false, pluginAuthor: '', @@ -176,6 +178,7 @@ describe('lexoraConfigStore', () => { }) const content = await readFile(configPath, 'utf8') expect(content).toContain('[desktop]') + expect(content).toContain('minimize_to_tray_on_close = false') expect(content).toContain('[pet]') expect(content).toContain('always_on_top = false') expect(content).toContain('enabled = false') diff --git a/apps/buddy/electron/main/window.ts b/apps/buddy/electron/main/window.ts index b3c815ee..d861c28b 100644 --- a/apps/buddy/electron/main/window.ts +++ b/apps/buddy/electron/main/window.ts @@ -11,6 +11,8 @@ export interface CreateDesktopWindowOptions { appName: string iconPath: string isQuitting: () => boolean + minimizeToTrayOnClose: () => boolean + onCloseToQuit: () => void onHidden?: () => void onPlacementChanged?: (placement: DesktopWindowPlacement) => void placement?: DesktopWindowPlacement | null @@ -104,8 +106,13 @@ export function createDesktopWindow(options: CreateDesktopWindowOptions): Deskto return event.preventDefault() - window.hide() - options.onHidden?.() + if (options.minimizeToTrayOnClose()) { + window.hide() + options.onHidden?.() + } + else { + options.onCloseToQuit() + } }) window.once('ready-to-show', () => { diff --git a/apps/buddy/electron/shared/desktopApi.ts b/apps/buddy/electron/shared/desktopApi.ts index 1c9da186..bb9d6a80 100644 --- a/apps/buddy/electron/shared/desktopApi.ts +++ b/apps/buddy/electron/shared/desktopApi.ts @@ -159,6 +159,7 @@ export interface LexoraConfig { chat: DesktopChatPreferences contextPanelMode: DesktopContextPanelMode contextPanelGlobal: boolean + minimizeToTrayOnClose: boolean keybindings: Record taskSidebarPinnedItems: DesktopTaskPinnedItem[] taskSidebar: DesktopTaskSidebarPreferences diff --git a/apps/buddy/electron/shared/desktopApiSchemas.ts b/apps/buddy/electron/shared/desktopApiSchemas.ts index 14bb8be9..d184fcd7 100644 --- a/apps/buddy/electron/shared/desktopApiSchemas.ts +++ b/apps/buddy/electron/shared/desktopApiSchemas.ts @@ -69,6 +69,7 @@ export const lexoraConfigPatchSchema: z.ZodType = z.object({ }).strict().optional(), contextPanelMode: z.enum(['task', 'independent']).optional(), contextPanelGlobal: z.boolean().optional(), + minimizeToTrayOnClose: z.boolean().optional(), keybindings: keybindingsSchema.optional(), taskSidebarPinnedItems: taskSidebarPinnedItemsSchema.optional(), taskSidebar: taskSidebarPreferencesSchema.optional(), diff --git a/apps/buddy/src/i18n/locales/en-US/settings.ts b/apps/buddy/src/i18n/locales/en-US/settings.ts index 2cdbcff8..7bc39972 100644 --- a/apps/buddy/src/i18n/locales/en-US/settings.ts +++ b/apps/buddy/src/i18n/locales/en-US/settings.ts @@ -87,6 +87,9 @@ export default { 'desktop.browser.screenshotSaved': 'Screenshot saved', 'desktop.browser.screenshotCopied': 'Screenshot copied to clipboard', 'desktop.browser.screenshotFailed': 'Could not capture the screenshot. Retry or change the destination in browser settings.', + 'desktop.settings.windowBehavior': 'Window behavior', + 'desktop.settings.minimizeToTrayOnClose': 'Minimize to tray when closing the window', + 'desktop.settings.minimizeToTrayOnCloseDescription': 'When enabled, the window hides and Lexora Buddy keeps running in the background. When disabled, closing the window quits the app.', 'desktop.settings.contextPanel': 'Resource panel', 'desktop.settings.contextPanelMode': 'Browsing mode', 'desktop.settings.contextPanelModeDescription': 'Task-linked selects resources when focusing a task. Independent keeps the current selection.', diff --git a/apps/buddy/src/i18n/locales/zh-CN/settings.ts b/apps/buddy/src/i18n/locales/zh-CN/settings.ts index d2f3ac4b..6dd25bad 100644 --- a/apps/buddy/src/i18n/locales/zh-CN/settings.ts +++ b/apps/buddy/src/i18n/locales/zh-CN/settings.ts @@ -85,6 +85,9 @@ export default { 'desktop.browser.screenshotSaved': '截图已保存', 'desktop.browser.screenshotCopied': '截图已复制到剪贴板', 'desktop.browser.screenshotFailed': '截图失败,请重试或在浏览器设置中切换保存方式。', + 'desktop.settings.windowBehavior': '窗口行为', + 'desktop.settings.minimizeToTrayOnClose': '关闭窗口时最小化到托盘', + 'desktop.settings.minimizeToTrayOnCloseDescription': '开启后窗口会隐藏,Lexora Buddy 仍在后台运行;关闭后点击关闭按钮会退出应用。', 'desktop.settings.contextPanel': '资源面板', 'desktop.settings.contextPanelMode': '浏览模式', 'desktop.settings.contextPanelModeDescription': '任务联动在聚焦任务时选择其资源,独立浏览保留当前选择。', diff --git a/apps/buddy/src/modules/settings/widgets/app/DesktopGeneralSettings.vue b/apps/buddy/src/modules/settings/widgets/app/DesktopGeneralSettings.vue index 61bf611d..2436a797 100644 --- a/apps/buddy/src/modules/settings/widgets/app/DesktopGeneralSettings.vue +++ b/apps/buddy/src/modules/settings/widgets/app/DesktopGeneralSettings.vue @@ -5,13 +5,14 @@ import { NSelect, NSpin, NSwitch, useMessage } from 'naive-ui' import { computed, shallowRef, useId } from 'vue' import { useBuddyI18n } from '@/i18n/buddyI18n' -type GeneralSettingField = 'language' | 'contextPanelMode' | 'contextPanelGlobal' +type GeneralSettingField = 'language' | 'contextPanelMode' | 'contextPanelGlobal' | 'minimizeToTrayOnClose' const props = defineProps() const { languageOptions, t } = useBuddyI18n(() => props.language) const message = useMessage() const pendingFields = shallowRef>(new Set()) const globalPanelLabelId = useId() +const minimizeToTrayLabelId = useId() const contextPanelModes = computed(() => [ { label: t('desktop.settings.contextPanelTask'), value: 'task' }, { label: t('desktop.settings.contextPanelIndependent'), value: 'independent' }, @@ -52,6 +53,29 @@ async function updateSetting(field: GeneralSettingField, patch: LexoraConfigPatc +
+

+ {{ t('desktop.settings.windowBehavior') }} +

+
+
+
+ {{ t('desktop.settings.minimizeToTrayOnClose') }} + {{ t('desktop.settings.minimizeToTrayOnCloseDescription') }} +
+
+ +
+
+
+

{{ t('desktop.settings.contextPanel') }}