From 040d083088edb9c355b2dc9b2ed770a5aaf62919 Mon Sep 17 00:00:00 2001 From: michnovka Date: Fri, 21 Nov 2025 15:14:32 +0100 Subject: [PATCH] =?UTF-8?q?=E2=98=95=20feat:=20Prevent=20Screen=20Sleep=20?= =?UTF-8?q?During=20Response=20Generation=20(#10597)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: prevent screen sleep during response generation * refactor: screen wake lock functionality during response generation * chore: import order * chore: reorder import statements in WakeLockManager component --------- Co-authored-by: Danny Avila --- client/src/App.jsx | 2 + .../Nav/SettingsTabs/General/General.tsx | 7 + .../src/components/System/WakeLockManager.tsx | 31 +++ client/src/hooks/useWakeLock.ts | 211 ++++++++++++++++++ client/src/locales/en/translation.json | 1 + client/src/store/families.ts | 9 + client/src/store/settings.ts | 1 + 7 files changed, 262 insertions(+) create mode 100644 client/src/components/System/WakeLockManager.tsx create mode 100644 client/src/hooks/useWakeLock.ts diff --git a/client/src/App.jsx b/client/src/App.jsx index eda775bc71..23651d750c 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -8,6 +8,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { Toast, ThemeProvider, ToastProvider } from '@librechat/client'; import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'; import { ScreenshotProvider, useApiErrorBoundary } from './hooks'; +import WakeLockManager from '~/components/System/WakeLockManager'; import { getThemeFromEnv } from './utils/getThemeFromEnv'; import { initializeFontSize } from '~/store/fontSize'; import { LiveAnnouncer } from '~/a11y'; @@ -51,6 +52,7 @@ const App = () => { + diff --git a/client/src/components/Nav/SettingsTabs/General/General.tsx b/client/src/components/Nav/SettingsTabs/General/General.tsx index 0cb565dad7..1157f9fc8e 100644 --- a/client/src/components/Nav/SettingsTabs/General/General.tsx +++ b/client/src/components/Nav/SettingsTabs/General/General.tsx @@ -29,6 +29,13 @@ const toggleSwitchConfigs = [ hoverCardText: undefined, key: 'hideSidePanel', }, + { + stateAtom: store.keepScreenAwake, + localizationKey: 'com_nav_keep_screen_awake', + switchId: 'keepScreenAwake', + hoverCardText: undefined, + key: 'keepScreenAwake', + }, ]; export const ThemeSelector = ({ diff --git a/client/src/components/System/WakeLockManager.tsx b/client/src/components/System/WakeLockManager.tsx new file mode 100644 index 0000000000..cae88d4ee8 --- /dev/null +++ b/client/src/components/System/WakeLockManager.tsx @@ -0,0 +1,31 @@ +import { useRecoilValue } from 'recoil'; +import useWakeLock from '~/hooks/useWakeLock'; +import store from '~/store'; + +/** + * WakeLockManager Component + * + * Manages the Screen Wake Lock during AI response generation to prevent + * device screens from sleeping or dimming during long-running operations. + * + * The wake lock is only active when: + * 1. Any conversation is currently generating a response (anySubmittingSelector) + * 2. User has not disabled the feature in settings (keepScreenAwake preference) + * + * This component is rendered at the root level of the application + * to ensure wake lock state persists across all conversations and routes. + * + * @see useWakeLock - The hook that manages the actual wake lock implementation + * @see anySubmittingSelector - Recoil selector tracking if any conversation is generating + */ +const WakeLockManager = () => { + const isSubmitting = useRecoilValue(store.anySubmittingSelector); + const keepScreenAwake = useRecoilValue(store.keepScreenAwake); + + const shouldPreventSleep = isSubmitting && keepScreenAwake; + useWakeLock(shouldPreventSleep); + + return null; +}; + +export default WakeLockManager; diff --git a/client/src/hooks/useWakeLock.ts b/client/src/hooks/useWakeLock.ts new file mode 100644 index 0000000000..b4eed3ab30 --- /dev/null +++ b/client/src/hooks/useWakeLock.ts @@ -0,0 +1,211 @@ +import { useEffect, useRef } from 'react'; + +/** + * Extended Navigator type that includes the Screen Wake Lock API + * @see https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API + */ +type WakeLockCapableNavigator = Navigator & { + wakeLock?: { + request: (type: WakeLockType) => Promise; + }; +}; + +/** + * Checks if we're in a client-side environment (browser) + * Prevents SSR issues by verifying window, navigator, and document exist + */ +const isClientEnvironment = + typeof window !== 'undefined' && + typeof navigator !== 'undefined' && + typeof document !== 'undefined'; + +const getNavigator = () => navigator as WakeLockCapableNavigator; + +/** + * Determines if the browser supports the Screen Wake Lock API + * Checking outside component scope for better performance + */ +const supportsWakeLock = isClientEnvironment && 'wakeLock' in navigator; + +/** + * Enable/disable debug logging for wake lock operations + * Set to true during development to see wake lock lifecycle events + */ +const DEBUG_WAKE_LOCK = false; + +/** + * Custom hook to prevent screen from sleeping during critical operations + * Uses the Screen Wake Lock API to keep the device screen active + * + * @param shouldHold - Boolean flag indicating whether to acquire/hold the wake lock + * @returns void - This hook manages wake lock state internally + * + * @example + * ```tsx + * const isGeneratingResponse = useRecoilValue(anySubmittingSelector); + * useWakeLock(isGeneratingResponse); + * ``` + * + * @remarks + * - Automatically handles page visibility changes (reacquires lock when tab becomes visible) + * - Properly cleans up lock on unmount or when shouldHold becomes false + * - Gracefully degrades on browsers without Wake Lock API support + * - Wake locks are automatically released when user switches tabs + */ +export const useWakeLock = (shouldHold: boolean) => { + const wakeLockRef = useRef(null); + + useEffect(() => { + if (!supportsWakeLock) { + if (DEBUG_WAKE_LOCK) { + console.log('[WakeLock] API not supported in this browser'); + } + return; + } + + /** + * Flag to prevent operations after effect cleanup + * Essential for avoiding race conditions when: + * - Component unmounts while lock is being acquired + * - shouldHold changes while async operations are in flight + * - Multiple visibility change events fire in quick succession + */ + let cancelled = false; + const { wakeLock } = getNavigator(); + + if (!wakeLock) { + return; + } + + /** + * Releases the currently held wake lock + * Called when: shouldHold becomes false, component unmounts, or before acquiring new lock + */ + const releaseLock = async () => { + if (!wakeLockRef.current) { + return; + } + + try { + await wakeLockRef.current.release(); + if (DEBUG_WAKE_LOCK) { + console.log('[WakeLock] Lock released successfully'); + } + } catch (error) { + console.warn('[WakeLock] release failed', error); + } finally { + wakeLockRef.current = null; + } + }; + + /** + * Requests a new wake lock from the browser + * Checks multiple conditions before requesting to avoid unnecessary API calls: + * - shouldHold must be true (user wants lock) + * - cancelled must be false (effect still active) + * - document must be visible (API requirement - locks only work in visible tabs) + * - no existing lock (prevent duplicate locks) + */ + const requestLock = async () => { + if ( + !shouldHold || + cancelled || + document.visibilityState !== 'visible' || + wakeLockRef.current + ) { + return; + } + + try { + const sentinel = await wakeLock.request('screen'); + wakeLockRef.current = sentinel; + + if (DEBUG_WAKE_LOCK) { + console.log('[WakeLock] Lock acquired successfully'); + } + + /** + * CRITICAL: Recursive re-acquire logic for automatic lock restoration + * + * The browser automatically releases wake locks when: + * - User switches to a different tab + * - User minimizes the browser window + * - Device goes to sleep + * - User navigates to a different page + * + * This handler automatically re-acquires the lock when: + * 1. The lock is released by the browser + * 2. The effect is still active (not cancelled) + * 3. The component still wants to hold the lock (shouldHold is true) + * 4. The tab is visible again (document.visibilityState === 'visible') + * + * This ensures users don't need to manually restart their work after + * switching tabs during long-running operations like AI response generation. + */ + const handleRelease = () => { + wakeLockRef.current = null; + sentinel.removeEventListener('release', handleRelease); + + if (DEBUG_WAKE_LOCK) { + console.log('[WakeLock] Lock released, checking if re-acquire needed'); + } + + if (!cancelled && shouldHold && document.visibilityState === 'visible') { + if (DEBUG_WAKE_LOCK) { + console.log('[WakeLock] Re-acquiring lock'); + } + void requestLock(); + } + }; + + sentinel.addEventListener('release', handleRelease); + } catch (error) { + console.warn('[WakeLock] request failed', error); + } + }; + + /** + * Handles browser tab visibility changes + * When user returns to the tab, re-acquire the lock if it's still needed + * This is necessary because wake locks are automatically released when tab becomes hidden + */ + const handleVisibilityChange = () => { + if (cancelled) { + return; + } + + if (DEBUG_WAKE_LOCK) { + console.log('[WakeLock] Visibility changed:', document.visibilityState); + } + + if (document.visibilityState === 'visible' && shouldHold) { + void requestLock(); + } + }; + + if (shouldHold) { + void requestLock(); + document.addEventListener('visibilitychange', handleVisibilityChange); + } else { + void releaseLock(); + } + + /** + * Cleanup function runs when: + * - Component unmounts + * - shouldHold changes + * - Effect dependencies change + * + * Sets cancelled flag first to prevent any in-flight operations from completing + * Removes event listeners to prevent memory leaks + * Releases any held wake lock + */ + return () => { + cancelled = true; + document.removeEventListener('visibilitychange', handleVisibilityChange); + void releaseLock(); + }; + }, [shouldHold]); +}; + +export default useWakeLock; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 383cde0bca..fb68916c4e 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -480,6 +480,7 @@ "com_nav_font_size_xs": "Extra Small", "com_nav_help_faq": "Help & FAQ", "com_nav_hide_panel": "Hide right-most side panel", + "com_nav_keep_screen_awake": "Keep screen awake during response generation", "com_nav_info_balance": "Balance shows how many token credits you have left to use. Token credits translate to monetary value (e.g., 1000 credits = $0.001 USD)", "com_nav_info_code_artifacts": "Enables the display of experimental code artifacts next to the chat", "com_nav_info_code_artifacts_agent": "Enables the use of code artifacts for this agent. By default, additional instructions specific to the use of artifacts are added, unless \"Custom Prompt Mode\" is enabled.", diff --git a/client/src/store/families.ts b/client/src/store/families.ts index 947b77a428..f97f3d3a09 100644 --- a/client/src/store/families.ts +++ b/client/src/store/families.ts @@ -190,6 +190,14 @@ const isSubmittingFamily = atomFamily({ ], }); +const anySubmittingSelector = selector({ + key: 'anySubmittingSelector', + get: ({ get }) => { + const keys = get(conversationKeysAtom); + return keys.some((key) => get(isSubmittingFamily(key)) === true); + }, +}); + const optionSettingsFamily = atomFamily({ key: 'optionSettingsByIndex', default: {}, @@ -399,6 +407,7 @@ export default { showPopoverFamily, latestMessageFamily, messagesSiblingIdxFamily, + anySubmittingSelector, allConversationsSelector, conversationByKeySelector, useClearConvoState, diff --git a/client/src/store/settings.ts b/client/src/store/settings.ts index 4e9c2f5cad..ed83c8a4b1 100644 --- a/client/src/store/settings.ts +++ b/client/src/store/settings.ts @@ -25,6 +25,7 @@ const localStorageAtoms = { LocalStorageKeys.ENABLE_USER_MSG_MARKDOWN, true, ), + keepScreenAwake: atomWithLocalStorage('keepScreenAwake', true), // Chat settings enterToSend: atomWithLocalStorage('enterToSend', true),