diff --git a/docs/en/wegent/user-guide/settings/desktop-app-preferences.md b/docs/en/wegent/user-guide/settings/desktop-app-preferences.md index 72492e80963..ae3d091e792 100644 --- a/docs/en/wegent/user-guide/settings/desktop-app-preferences.md +++ b/docs/en/wegent/user-guide/settings/desktop-app-preferences.md @@ -55,6 +55,14 @@ When Wework moves to the background, it destroys the main window WebView to rele When no conversation is open, the newly created main window keeps the task launcher within the window bounds. The message area scrolls as needed only after a conversation is opened. +## Prevent Sleep While Tasks Are Running + +**Prevent sleep while tasks are running** is enabled by default. While at least one task is running, Wework prevents the computer from entering idle system sleep. This inhibition remains active when the main window is closed and Wework continues from the system tray. + +After every task completes, fails, or is interrupted, Wework immediately releases the inhibition and restores the operating system's normal sleep policy. Wework does not force the computer to sleep and does not change the configured system sleep timeout. + +To keep Wework from controlling sleep, disable this option under **Settings → General**. Disabling it immediately releases any current sleep inhibition. If it is enabled again while a task is still running, Wework resumes preventing idle sleep. + ## Appshots On macOS desktop, **Settings → Integrations → Appshots** shows the Appshots status and sound preference. The default shortcut is `⌘⇧2`. When pressed, Wework captures the frontmost application window and uses macOS Accessibility to read text exposed by that window, which can include text outside the visible scroll area. It then adds both the PNG and text context to the current composer attachments. diff --git a/docs/zh/wegent/user-guide/settings/desktop-app-preferences.md b/docs/zh/wegent/user-guide/settings/desktop-app-preferences.md index f928a92f061..c5561af76cf 100644 --- a/docs/zh/wegent/user-guide/settings/desktop-app-preferences.md +++ b/docs/zh/wegent/user-guide/settings/desktop-app-preferences.md @@ -55,6 +55,14 @@ Wework 桌面版的“设置 → 通用”用于控制应用窗口和后台运 如果没有打开会话,新创建的主窗口会显示固定在窗口范围内的任务启动页;只有进入会话后,消息内容区域才会按需滚动。 +## 任务运行时阻止电脑休眠 + +“任务运行时阻止电脑休眠”默认开启。当至少有一个任务正在运行时,Wework 会阻止电脑因空闲自动进入系统休眠。关闭主窗口并进入系统托盘后,只要任务仍在运行,防休眠状态就会继续保持。 + +所有任务结束、失败或被中断后,Wework 会立即释放防休眠状态,并恢复操作系统原本的休眠策略。Wework 不会主动让电脑立即休眠,也不会修改系统的休眠时间设置。 + +如果不希望 Wework 控制休眠,可以在“设置 → 通用”中关闭该选项。关闭选项会立即释放当前的防休眠状态;之后重新开启时,如果仍有任务在运行,Wework 会重新阻止空闲休眠。 + ## 应用快照 在 macOS 桌面版中,**设置 → 集成 → 应用快照** 用于查看 Appshots 状态和配置快照声音。默认快捷键是 `⌘⇧2`。按下快捷键后,Wework 会截取当前最前面的应用窗口,并通过 macOS 辅助功能读取该窗口提供的文本(可能包括滚动区域外文本),然后把 PNG 和文本上下文一起添加到当前输入框附件中。 diff --git a/wework/e2e/desktop/task-flow.e2e.mjs b/wework/e2e/desktop/task-flow.e2e.mjs index 03054dcaab9..14932dc29fe 100644 --- a/wework/e2e/desktop/task-flow.e2e.mjs +++ b/wework/e2e/desktop/task-flow.e2e.mjs @@ -613,6 +613,30 @@ function processIsAlive(processId) { } } +function macosSleepInhibitorProcessIds(appProcessId) { + if (process.platform !== 'darwin') return [] + const output = commandOutput('/bin/ps', ['-axo', 'pid=,ppid=,command=']) + return output.split('\n').flatMap(line => { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/) + if (!match || Number(match[2]) !== appProcessId || match[3] !== '/usr/bin/caffeinate -i') { + return [] + } + return [Number(match[1])] + }) +} + +async function waitForMacosSleepInhibitor(appProcessId, expectedRunning) { + const startedAt = Date.now() + while (Date.now() - startedAt < UI_TIMEOUT_MS) { + const processIds = macosSleepInhibitorProcessIds(appProcessId) + if (processIds.length > 0 === expectedRunning) return processIds + await new Promise(resolvePromise => setTimeout(resolvePromise, 100)) + } + throw new Error( + `Timed out waiting for the macOS sleep inhibitor to be ${expectedRunning ? 'running' : 'stopped'}` + ) +} + async function waitForExecutorReadyEvidence(logPath, timeoutMs = UI_TIMEOUT_MS) { const startedAt = Date.now() while (Date.now() - startedAt < timeoutMs) { @@ -749,6 +773,11 @@ async function verifyBackgroundTaskWindowLifecycle({ UI_TIMEOUT_MS, 'Timed out waiting for the streaming response to start' ) + const sleepInhibitorEvidence = [] + if (process.platform === 'darwin') { + const processIds = await waitForMacosSleepInhibitor(app.pid, true) + sleepInhibitorEvidence.push({ stage: 'task-running', processIds }) + } const runningTaskSnapshot = await waitForSnapshot( control, snapshot => snapshot.testIds.some(testId => testId.startsWith('runtime-local-task-running-')), @@ -794,6 +823,11 @@ async function verifyBackgroundTaskWindowLifecycle({ true, 'Closing to tray terminated the executor process' ) + const backgroundProcessIds = await waitForMacosSleepInhibitor(app.pid, true) + sleepInhibitorEvidence.push({ + stage: 'window-closed-to-tray', + processIds: backgroundProcessIds, + }) await reactivateMacApplication(appIdentifier) await withTimeout( @@ -893,6 +927,14 @@ async function verifyBackgroundTaskWindowLifecycle({ control, lifecycleScreenshotName('04-task-completed-after-reopen.png') ) + if (process.platform === 'darwin') { + const processIds = await waitForMacosSleepInhibitor(app.pid, false) + sleepInhibitorEvidence.push({ stage: 'task-completed', processIds }) + await writeFile( + join(resultDir, 'sleep-inhibitor-lifecycle-verification.json'), + `${JSON.stringify({ appProcessId: app.pid, stages: sleepInhibitorEvidence }, null, 2)}\n` + ) + } } async function attachAndSendOnlyFile(control, composerSelector) { diff --git a/wework/src-tauri/src/lib.rs b/wework/src-tauri/src/lib.rs index 522c2f23e25..0b5e5f48ae6 100644 --- a/wework/src-tauri/src/lib.rs +++ b/wework/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod local_executor; mod local_terminal; mod process_environment; mod system_drag; +mod system_sleep; mod workbench_background; use std::collections::{HashMap, HashSet}; @@ -383,6 +384,8 @@ struct AppPreferences { show_main_window_on_launch: bool, #[serde(default = "default_true")] system_drag_enabled: bool, + #[serde(default = "default_true")] + prevent_sleep_while_tasks_running: bool, #[serde(default)] close_to_tray_hint_seen: bool, #[serde(default = "default_language_preference")] @@ -482,6 +485,7 @@ impl Default for AppPreferences { close_to_tray_enabled: true, show_main_window_on_launch: true, system_drag_enabled: true, + prevent_sleep_while_tasks_running: true, close_to_tray_hint_seen: false, language: default_language_preference(), terminal_context_injection_enabled: true, @@ -507,6 +511,7 @@ struct AppPreferencesPatch { close_to_tray_enabled: Option, show_main_window_on_launch: Option, system_drag_enabled: Option, + prevent_sleep_while_tasks_running: Option, close_to_tray_hint_seen: Option, language: Option, terminal_context_injection_enabled: Option, @@ -935,6 +940,9 @@ fn update_app_preferences( if let Some(value) = patch.system_drag_enabled { preferences.system_drag_enabled = value; } + if let Some(value) = patch.prevent_sleep_while_tasks_running { + preferences.prevent_sleep_while_tasks_running = value; + } if let Some(value) = patch.close_to_tray_hint_seen { preferences.close_to_tray_hint_seen = value; } @@ -979,6 +987,8 @@ fn update_app_preferences( preferences.quick_phrases = value; } write_app_preferences_impl(&app, &preferences)?; + app.state::() + .set_enabled(preferences.prevent_sleep_while_tasks_running); Ok(preferences) } @@ -989,6 +999,7 @@ struct AppPreferences { close_to_tray_enabled: bool, show_main_window_on_launch: bool, system_drag_enabled: bool, + prevent_sleep_while_tasks_running: bool, close_to_tray_hint_seen: bool, language: String, terminal_context_injection_enabled: bool, @@ -1012,6 +1023,7 @@ struct AppPreferencesPatch { close_to_tray_enabled: Option, show_main_window_on_launch: Option, system_drag_enabled: Option, + prevent_sleep_while_tasks_running: Option, close_to_tray_hint_seen: Option, language: Option, terminal_context_injection_enabled: Option, @@ -1035,6 +1047,7 @@ fn get_app_preferences(_app: tauri::AppHandle) -> Result close_to_tray_enabled: true, show_main_window_on_launch: true, system_drag_enabled: true, + prevent_sleep_while_tasks_running: true, close_to_tray_hint_seen: false, language: "zh-CN".to_string(), terminal_context_injection_enabled: true, @@ -1062,6 +1075,7 @@ fn update_app_preferences( close_to_tray_enabled: patch.close_to_tray_enabled.unwrap_or(true), show_main_window_on_launch: patch.show_main_window_on_launch.unwrap_or(true), system_drag_enabled: patch.system_drag_enabled.unwrap_or(true), + prevent_sleep_while_tasks_running: patch.prevent_sleep_while_tasks_running.unwrap_or(true), close_to_tray_hint_seen: patch.close_to_tray_hint_seen.unwrap_or(false), language: patch.language.unwrap_or_else(|| "zh-CN".to_string()), terminal_context_injection_enabled: patch @@ -2434,6 +2448,8 @@ struct TrayMenuStatePayload { unread_more: Vec, running_count: usize, #[serde(default)] + active_task_count: usize, + #[serde(default)] show_running_status: bool, #[serde(default)] unread_count: usize, @@ -2479,6 +2495,7 @@ impl TrayMenuStatePayload { unread: Vec::new(), unread_more: Vec::new(), running_count: 0, + active_task_count: 0, show_running_status: false, unread_count: 0, pinned: Vec::new(), @@ -3310,6 +3327,8 @@ fn update_tray_visual( #[cfg(desktop)] #[tauri::command] fn set_tray_menu_state(app: tauri::AppHandle, state: TrayMenuStatePayload) -> Result<(), String> { + app.state::() + .set_running_count(state.active_task_count); let menu = build_system_tray_menu(&app, &state) .map_err(|error| format!("Failed to build tray menu: {error}"))?; let Some(tray) = app.tray_by_id(TRAY_ID) else { @@ -3765,6 +3784,7 @@ pub fn run() { .manage(local_executor::LocalExecutorState::default()) .manage(local_terminal::LocalTerminalState::default()) .manage(system_drag::SystemDragState::default()) + .manage(system_sleep::SystemSleepState::default()) .on_window_event(|window, event| { #[cfg(desktop)] if hide_main_window_on_close(window, event) { @@ -3804,6 +3824,10 @@ pub fn run() { #[cfg(desktop)] setup_system_tray(app)?; #[cfg(desktop)] + app.state::().set_enabled( + read_app_preferences_impl(app.handle()).prevent_sleep_while_tasks_running, + ); + #[cfg(desktop)] system_drag::setup(app.handle().clone()); #[cfg(desktop)] appshots::setup(app.handle()); diff --git a/wework/src-tauri/src/local_executor.rs b/wework/src-tauri/src/local_executor.rs index 9f4ff394fa1..f7989368414 100644 --- a/wework/src-tauri/src/local_executor.rs +++ b/wework/src-tauri/src/local_executor.rs @@ -1460,6 +1460,11 @@ fn handle_executor_line_inner( resolve_response_inner(inner, response); } ExecutorLine::Event(event) => { + app.state::() + .handle_terminal_event( + &event.event, + event.payload.get("taskId").and_then(Value::as_str), + ); if let Some((previous_runtime_instance_id, runtime_instance_id)) = update_ready_event_inner(inner, &event) { @@ -1621,12 +1626,15 @@ fn handle_stdio_line( } fn finish_stdio_reader( + app: &tauri::AppHandle, state: &SharedExecutorInner, generation: u64, sender: &mut Option>>, message: String, terminate_child: bool, ) { + app.state::() + .clear_running_tasks(); if let Some(sender) = sender.take() { let _ = sender.send(Err(message.clone())); } @@ -1688,6 +1696,7 @@ fn spawn_configured_sidecar( Err(error) => { let message = format!("Local executor stdout read failed: {error}"); finish_stdio_reader( + &app, &state_handle, generation, &mut ready_sender, @@ -1699,6 +1708,7 @@ fn spawn_configured_sidecar( } } finish_stdio_reader( + &app, &state_handle, generation, &mut ready_sender, @@ -1747,6 +1757,7 @@ fn spawn_bundled_sidecar( CommandEvent::Terminated(payload) => { let message = format!("Local executor exited: {payload:?}"); finish_stdio_reader( + &app, &state_handle, generation, &mut ready_sender, @@ -1758,6 +1769,7 @@ fn spawn_bundled_sidecar( CommandEvent::Error(error) => { let message = format!("Local executor stdio failed: {error}"); finish_stdio_reader( + &app, &state_handle, generation, &mut ready_sender, @@ -1770,6 +1782,7 @@ fn spawn_bundled_sidecar( } } finish_stdio_reader( + &app, &state_handle, generation, &mut ready_sender, diff --git a/wework/src-tauri/src/system_sleep.rs b/wework/src-tauri/src/system_sleep.rs new file mode 100644 index 00000000000..8807a6299d9 --- /dev/null +++ b/wework/src-tauri/src/system_sleep.rs @@ -0,0 +1,225 @@ +use std::collections::HashSet; +use std::process::{Child, Command, Stdio}; +use std::sync::Mutex; + +#[derive(Default)] +pub(crate) struct SystemSleepState { + inner: Mutex, +} + +struct SystemSleepInner { + enabled: bool, + tasks: RunningTaskState, + inhibitor: Option, +} + +impl Default for SystemSleepInner { + fn default() -> Self { + Self { + enabled: true, + tasks: RunningTaskState::default(), + inhibitor: None, + } + } +} + +#[derive(Default)] +struct RunningTaskState { + count: usize, + settled_task_ids: HashSet, +} + +impl RunningTaskState { + fn replace_count(&mut self, count: usize) { + self.count = count; + self.settled_task_ids.clear(); + } + + fn settle(&mut self, task_id: Option<&str>) { + let newly_settled = match task_id { + Some(id) => self.settled_task_ids.insert(id.to_owned()), + None => true, + }; + if newly_settled { + self.count = self.count.saturating_sub(1); + } + } +} + +impl SystemSleepState { + pub(crate) fn set_enabled(&self, enabled: bool) { + let Ok(mut inner) = self.inner.lock() else { + log::warn!("Failed to lock system sleep state"); + return; + }; + inner.enabled = enabled; + inner.reconcile(); + } + + pub(crate) fn set_running_count(&self, running_count: usize) { + let Ok(mut inner) = self.inner.lock() else { + log::warn!("Failed to lock system sleep state"); + return; + }; + inner.tasks.replace_count(running_count); + inner.reconcile(); + } + + pub(crate) fn handle_terminal_event(&self, event: &str, task_id: Option<&str>) { + if !is_terminal_response_event(event) { + return; + } + let Ok(mut inner) = self.inner.lock() else { + log::warn!("Failed to lock system sleep state"); + return; + }; + inner.tasks.settle(task_id); + inner.reconcile(); + } + + pub(crate) fn clear_running_tasks(&self) { + let Ok(mut inner) = self.inner.lock() else { + log::warn!("Failed to lock system sleep state while clearing tasks"); + return; + }; + inner.tasks.replace_count(0); + inner.reconcile(); + } +} + +impl SystemSleepInner { + fn reconcile(&mut self) { + if !self.enabled || self.tasks.count == 0 { + if self.inhibitor.take().is_some() { + log::info!("Released system sleep inhibition after local tasks settled"); + } + return; + } + if self.inhibitor.is_some() { + return; + } + match SleepInhibitor::acquire() { + Ok(inhibitor) => { + self.inhibitor = Some(inhibitor); + log::info!("Inhibited system sleep while local tasks are running"); + } + Err(error) => log::warn!("Failed to inhibit system sleep: {error}"), + } + } +} + +fn is_terminal_response_event(event: &str) -> bool { + matches!( + event, + "response.completed" | "response.failed" | "response.incomplete" + ) +} + +struct SleepInhibitor { + child: Child, +} + +impl SleepInhibitor { + fn acquire() -> Result { + let mut command = sleep_inhibitor_command()?; + let child = command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("failed to start sleep inhibitor: {error}"))?; + Ok(Self { child }) + } +} + +impl Drop for SleepInhibitor { + fn drop(&mut self) { + if let Err(error) = self.child.kill() { + log::warn!("Failed to stop system sleep inhibitor: {error}"); + } + let _ = self.child.wait(); + } +} + +#[cfg(target_os = "macos")] +fn sleep_inhibitor_command() -> Result { + let mut command = Command::new("/usr/bin/caffeinate"); + command.arg("-i"); + Ok(command) +} + +#[cfg(target_os = "linux")] +fn sleep_inhibitor_command() -> Result { + let mut command = Command::new("systemd-inhibit"); + command.args([ + "--what=sleep", + "--who=Wework", + "--why=Local task is running", + "--mode=block", + "sleep", + "infinity", + ]); + Ok(command) +} + +#[cfg(target_os = "windows")] +fn sleep_inhibitor_command() -> Result { + let mut command = Command::new("powershell.exe"); + command.args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "Add-Type -Namespace Wework -Name Sleep -MemberDefinition '[DllImport(\"kernel32.dll\")] public static extern uint SetThreadExecutionState(uint flags);'; [Wework.Sleep]::SetThreadExecutionState(0x80000001); while ($true) { Start-Sleep -Seconds 3600 }", + ]); + Ok(command) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +fn sleep_inhibitor_command() -> Result { + Err("system sleep inhibition is not supported on this platform".to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recognizes_every_terminal_response_event() { + assert!(is_terminal_response_event("response.completed")); + assert!(is_terminal_response_event("response.failed")); + assert!(is_terminal_response_event("response.incomplete")); + assert!(!is_terminal_response_event("response.created")); + assert!(!is_terminal_response_event("response.output_text.delta")); + } + + #[test] + fn tracks_concurrent_tasks_without_releasing_early() { + let mut tasks = RunningTaskState::default(); + tasks.replace_count(2); + + tasks.settle(Some("task-1")); + assert_eq!(tasks.count, 1); + + tasks.settle(Some("task-1")); + assert_eq!(tasks.count, 1); + + tasks.settle(Some("task-2")); + assert_eq!(tasks.count, 0); + } + + #[test] + fn refreshed_running_count_replaces_terminal_event_bookkeeping() { + let mut tasks = RunningTaskState::default(); + tasks.replace_count(1); + tasks.settle(Some("task-1")); + tasks.replace_count(1); + + tasks.settle(Some("task-1")); + assert_eq!(tasks.count, 0); + } + + #[test] + fn sleep_inhibition_is_enabled_by_default() { + assert!(SystemSleepInner::default().enabled); + } +} diff --git a/wework/src/components/settings/AppshotsSettingsPage.test.tsx b/wework/src/components/settings/AppshotsSettingsPage.test.tsx index fa54a2e20a4..5c937ecade0 100644 --- a/wework/src/components/settings/AppshotsSettingsPage.test.tsx +++ b/wework/src/components/settings/AppshotsSettingsPage.test.tsx @@ -13,6 +13,7 @@ const defaultPreferences: AppPreferences = { closeToTrayEnabled: true, showMainWindowOnLaunch: true, systemDragEnabled: true, + preventSleepWhileTasksRunning: true, closeToTrayHintSeen: false, language: 'zh-CN', terminalContextInjectionEnabled: true, @@ -42,6 +43,7 @@ vi.mock('@/tauri/appPreferences', () => ({ closeToTrayEnabled: true, showMainWindowOnLaunch: true, systemDragEnabled: true, + preventSleepWhileTasksRunning: true, closeToTrayHintSeen: false, language: 'zh-CN', terminalContextInjectionEnabled: true, diff --git a/wework/src/components/settings/BrowserSettingsPage.test.tsx b/wework/src/components/settings/BrowserSettingsPage.test.tsx index 519ff9224be..40abb835839 100644 --- a/wework/src/components/settings/BrowserSettingsPage.test.tsx +++ b/wework/src/components/settings/BrowserSettingsPage.test.tsx @@ -14,6 +14,7 @@ const preferences: AppPreferences = vi.hoisted(() => ({ closeToTrayEnabled: true, showMainWindowOnLaunch: true, systemDragEnabled: true, + preventSleepWhileTasksRunning: true, closeToTrayHintSeen: false, language: 'zh-CN', terminalContextInjectionEnabled: true, diff --git a/wework/src/components/settings/ContextSettingsPage.test.tsx b/wework/src/components/settings/ContextSettingsPage.test.tsx index 342e8fc06bb..aa484089e2f 100644 --- a/wework/src/components/settings/ContextSettingsPage.test.tsx +++ b/wework/src/components/settings/ContextSettingsPage.test.tsx @@ -8,6 +8,7 @@ const defaultPreferences: AppPreferences = { closeToTrayEnabled: true, showMainWindowOnLaunch: true, systemDragEnabled: true, + preventSleepWhileTasksRunning: true, closeToTrayHintSeen: false, language: 'zh-CN', terminalContextInjectionEnabled: true, @@ -40,6 +41,7 @@ vi.mock('@/tauri/appPreferences', () => ({ closeToTrayEnabled: true, showMainWindowOnLaunch: true, systemDragEnabled: true, + preventSleepWhileTasksRunning: true, closeToTrayHintSeen: false, language: 'zh-CN', terminalContextInjectionEnabled: true, diff --git a/wework/src/components/settings/GeneralSettingsPage.test.tsx b/wework/src/components/settings/GeneralSettingsPage.test.tsx index 8cfa78c1049..4068ff872ee 100644 --- a/wework/src/components/settings/GeneralSettingsPage.test.tsx +++ b/wework/src/components/settings/GeneralSettingsPage.test.tsx @@ -8,6 +8,7 @@ const defaultPreferences: AppPreferences = { closeToTrayEnabled: true, showMainWindowOnLaunch: true, systemDragEnabled: true, + preventSleepWhileTasksRunning: true, closeToTrayHintSeen: false, language: 'zh-CN', terminalContextInjectionEnabled: true, @@ -40,6 +41,7 @@ vi.mock('@/tauri/appPreferences', () => ({ closeToTrayEnabled: true, showMainWindowOnLaunch: true, systemDragEnabled: true, + preventSleepWhileTasksRunning: true, closeToTrayHintSeen: false, language: 'zh-CN', terminalContextInjectionEnabled: true, @@ -156,6 +158,22 @@ describe('GeneralSettingsPage', () => { }) }) + test('prevents sleep during tasks by default and persists disabling it', async () => { + render() + + const toggle = await screen.findByTestId('general-prevent-sleep-while-tasks-running-toggle') + await waitFor(() => expect(toggle).toBeEnabled()) + expect(toggle).toHaveAttribute('aria-checked', 'true') + + await userEvent.click(toggle) + + await waitFor(() => { + expect(updateAppPreferencesMock).toHaveBeenCalledWith({ + preventSleepWhileTasksRunning: false, + }) + }) + }) + test('rolls back language selection when saving fails', async () => { getAppPreferencesMock.mockResolvedValue({ ...defaultPreferences, language: 'zh-CN' }) updateAppPreferencesMock.mockRejectedValue(new Error('save failed')) diff --git a/wework/src/components/settings/GeneralSettingsPage.tsx b/wework/src/components/settings/GeneralSettingsPage.tsx index bd7f1615460..b8cdce87def 100644 --- a/wework/src/components/settings/GeneralSettingsPage.tsx +++ b/wework/src/components/settings/GeneralSettingsPage.tsx @@ -245,6 +245,14 @@ export function GeneralSettingsPage() { ? t('workbench.general_settings_background_description') : t('workbench.general_settings_background_disabled_description'), })} + {renderSwitchRow({ + preferenceKey: 'preventSleepWhileTasksRunning', + testId: 'general-prevent-sleep-while-tasks-running-toggle', + label: t('workbench.general_settings_prevent_sleep_while_tasks_running'), + description: t( + 'workbench.general_settings_prevent_sleep_while_tasks_running_description' + ), + })} {renderSwitchRow({ preferenceKey: 'taskCompletionNotificationsEnabled', testId: 'general-task-completion-notifications-toggle', diff --git a/wework/src/i18n/locales/en/common.json b/wework/src/i18n/locales/en/common.json index 4ed197fe33c..7562bede50a 100644 --- a/wework/src/i18n/locales/en/common.json +++ b/wework/src/i18n/locales/en/common.json @@ -910,6 +910,8 @@ "general_settings_close_to_tray": "Keep running after closing the main window", "general_settings_background_description": "Closing the main window does not stop running tasks. Tasks continue in the background, and you can double-click the system tray icon to return to the main window.", "general_settings_background_disabled_description": "Closing the main window exits the app and interrupts running tasks.", + "general_settings_prevent_sleep_while_tasks_running": "Prevent sleep while tasks are running", + "general_settings_prevent_sleep_while_tasks_running_description": "Prevent idle system sleep while any task is running, then restore the system's normal sleep policy after all tasks finish.", "general_settings_system_tray_title": "System tray", "general_settings_task_completion_notifications": "Send a system notification when a task completes", "general_settings_task_completion_notifications_description": "Send an operating system notification through the system notification plugin when a background task completes.", diff --git a/wework/src/i18n/locales/zh-CN/common.json b/wework/src/i18n/locales/zh-CN/common.json index 737539626e6..7b16170ae93 100644 --- a/wework/src/i18n/locales/zh-CN/common.json +++ b/wework/src/i18n/locales/zh-CN/common.json @@ -910,6 +910,8 @@ "general_settings_close_to_tray": "关闭主窗口后后台运行", "general_settings_background_description": "关闭主窗口不会停止正在运行的任务。任务会在后台继续执行,你可以双击系统托盘图标回到主界面。", "general_settings_background_disabled_description": "关闭主窗口会退出应用,并中断正在运行的任务。", + "general_settings_prevent_sleep_while_tasks_running": "任务运行时阻止电脑休眠", + "general_settings_prevent_sleep_while_tasks_running_description": "有任务运行时阻止电脑因空闲自动休眠;所有任务结束后恢复系统原本的休眠策略。", "general_settings_system_tray_title": "系统托盘", "general_settings_task_completion_notifications": "任务完成时发送系统通知", "general_settings_task_completion_notifications_description": "后台任务完成后,通过系统通知插件发送操作系统通知。", diff --git a/wework/src/tauri/appPreferences.test.ts b/wework/src/tauri/appPreferences.test.ts index e4c83f53e75..44526ab0fd4 100644 --- a/wework/src/tauri/appPreferences.test.ts +++ b/wework/src/tauri/appPreferences.test.ts @@ -15,6 +15,7 @@ const mergedDefaultPreferences = { closeToTrayEnabled: true, showMainWindowOnLaunch: true, systemDragEnabled: true, + preventSleepWhileTasksRunning: true, closeToTrayHintSeen: false, language: 'zh-CN', terminalContextInjectionEnabled: true, diff --git a/wework/src/tauri/appPreferences.ts b/wework/src/tauri/appPreferences.ts index 448269ef826..b809a37ec61 100644 --- a/wework/src/tauri/appPreferences.ts +++ b/wework/src/tauri/appPreferences.ts @@ -5,6 +5,7 @@ export interface AppPreferences { closeToTrayEnabled: boolean showMainWindowOnLaunch: boolean systemDragEnabled: boolean + preventSleepWhileTasksRunning: boolean closeToTrayHintSeen: boolean language: AppLanguagePreference terminalContextInjectionEnabled: boolean @@ -56,6 +57,7 @@ export interface AppPreferencesPatch { closeToTrayEnabled?: boolean showMainWindowOnLaunch?: boolean systemDragEnabled?: boolean + preventSleepWhileTasksRunning?: boolean closeToTrayHintSeen?: boolean language?: AppLanguagePreference terminalContextInjectionEnabled?: boolean @@ -97,6 +99,7 @@ export const defaultAppPreferences: AppPreferences = { closeToTrayEnabled: true, showMainWindowOnLaunch: true, systemDragEnabled: true, + preventSleepWhileTasksRunning: true, closeToTrayHintSeen: false, language: 'zh-CN', terminalContextInjectionEnabled: true, @@ -151,6 +154,10 @@ function mergeAppPreferences(value: unknown): AppPreferences { typeof record.systemDragEnabled === 'boolean' ? record.systemDragEnabled : defaultAppPreferences.systemDragEnabled, + preventSleepWhileTasksRunning: + typeof record.preventSleepWhileTasksRunning === 'boolean' + ? record.preventSleepWhileTasksRunning + : defaultAppPreferences.preventSleepWhileTasksRunning, closeToTrayHintSeen: typeof record.closeToTrayHintSeen === 'boolean' ? record.closeToTrayHintSeen diff --git a/wework/src/tauri/trayMenuState.test.ts b/wework/src/tauri/trayMenuState.test.ts index c84c01bc948..b0d6cb5040d 100644 --- a/wework/src/tauri/trayMenuState.test.ts +++ b/wework/src/tauri/trayMenuState.test.ts @@ -201,6 +201,7 @@ describe('buildTrayMenuTaskGroups', () => { expect(groups.hasRunningTasks).toBe(true) expect(groups.showRunningStatus).toBe(true) expect(groups.runningCount).toBe(1) + expect(groups.activeTaskCount).toBe(1) }) test('hides unread and running task groups when tray status switches are off', () => { @@ -249,6 +250,7 @@ describe('buildTrayMenuTaskGroups', () => { expect(groups.unreadCount).toBe(0) expect(groups.running).toEqual([]) expect(groups.runningCount).toBe(0) + expect(groups.activeTaskCount).toBe(1) expect(groups.hasRunningTasks).toBe(false) expect(groups.showRunningStatus).toBe(false) expect(groups.recent.map(item => item.title)).toEqual(['Running task', 'Unread task']) diff --git a/wework/src/tauri/trayMenuState.ts b/wework/src/tauri/trayMenuState.ts index 7672fe681fa..94da65213c6 100644 --- a/wework/src/tauri/trayMenuState.ts +++ b/wework/src/tauri/trayMenuState.ts @@ -29,6 +29,7 @@ export interface TrayMenuTaskGroups { hasRunningTasks: boolean showRunningStatus: boolean runningCount: number + activeTaskCount: number unreadCount: number pinned: TrayMenuTaskItem[] pinnedMore: TrayMenuTaskItem[] @@ -48,6 +49,7 @@ export const EMPTY_TRAY_MENU_TASK_GROUPS: TrayMenuTaskGroups = { hasRunningTasks: false, showRunningStatus: false, runningCount: 0, + activeTaskCount: 0, unreadCount: 0, pinned: [], pinnedMore: [], @@ -149,7 +151,8 @@ export function buildTrayMenuTaskGroups( ): TrayMenuTaskGroups { const { reminders, showUnread = true, showRunning = true } = options const items = collectRuntimeTaskItems(runtimeWork) - const running = showRunning ? items.filter(({ task }) => task.running) : [] + const activeTasks = items.filter(({ task }) => task.running) + const running = showRunning ? activeTasks : [] const unread = showUnread && reminders ? items.filter(({ workspace, task }) => @@ -170,6 +173,7 @@ export function buildTrayMenuTaskGroups( hasRunningTasks: showRunning ? (reminders?.hasRunningTasks ?? running.length > 0) : false, showRunningStatus: showRunning, runningCount: running.length, + activeTaskCount: activeTasks.length, unreadCount: showUnread ? (reminders?.unreadCount ?? unread.length) : 0, pinned: pinnedTasks.visible, pinnedMore: pinnedTasks.more, diff --git a/wework/src/tauri/trayNavigation.test.ts b/wework/src/tauri/trayNavigation.test.ts index 069aa00b70d..c2a85271cde 100644 --- a/wework/src/tauri/trayNavigation.test.ts +++ b/wework/src/tauri/trayNavigation.test.ts @@ -145,6 +145,7 @@ describe('trayNavigation', () => { hasRunningTasks: false, showRunningStatus: false, runningCount: 0, + activeTaskCount: 0, unreadCount: 0, pinned: [], pinnedMore: [], @@ -162,6 +163,7 @@ describe('trayNavigation', () => { hasRunningTasks: true, showRunningStatus: true, runningCount: 1, + activeTaskCount: 1, unreadCount: 0, pinned: [], pinnedMore: [],