From de468361ecc2c49c1a73a8751869132e55fa64c3 Mon Sep 17 00:00:00 2001 From: Jovines <1246634075@qq.com> Date: Fri, 30 Jan 2026 20:15:00 +0800 Subject: [PATCH] feat: add notifyOnSubtasks setting to control subtask notifications (#227) * feat: add notifyOnSubtasks setting to control subtask notifications - Add notifyOnSubtasks toggle in NotificationSettings (moved to general section) - Update useEventStream.ts to skip notifications for subtasks when disabled - Add server-side push notification control for subtasks - Update UI store, persistence, and desktop settings types * feat: apply notifyOnSubtasks across web push + desktop notifications --------- Co-authored-by: Jovines --- .../src-tauri/src/assistant_notifications.rs | 126 ++++++++++- .../src-tauri/src/commands/settings.rs | 3 + .../openchamber/NotificationSettings.tsx | 214 +++++++++++------- .../openchamber/OpenChamberSidebar.tsx | 1 - packages/ui/src/hooks/useEventStream.ts | 16 +- packages/ui/src/lib/appearanceAutoSave.ts | 6 + packages/ui/src/lib/desktop.ts | 1 + packages/ui/src/lib/persistence.ts | 6 + packages/ui/src/stores/useUIStore.ts | 8 + packages/web/server/index.js | 66 ++++++ 10 files changed, 354 insertions(+), 93 deletions(-) diff --git a/packages/desktop/src-tauri/src/assistant_notifications.rs b/packages/desktop/src-tauri/src/assistant_notifications.rs index 088c33ac..5cfd6ec7 100644 --- a/packages/desktop/src-tauri/src/assistant_notifications.rs +++ b/packages/desktop/src-tauri/src/assistant_notifications.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, path::PathBuf, time::Duration}; +use std::{collections::{HashMap, HashSet}, path::PathBuf, time::Duration}; use anyhow::Result; use futures_util::TryStreamExt; @@ -45,6 +45,7 @@ pub fn spawn_assistant_notifications( let mut shutdown_rx = runtime.subscribe_shutdown(); let notified_messages = Mutex::new(HashSet::::new()); let notified_questions = Mutex::new(HashSet::::new()); + let session_parent_cache = Mutex::new(HashMap::>::new()); loop { tokio::select! { @@ -53,7 +54,14 @@ pub fn spawn_assistant_notifications( break; } _ = async { - if let Err(err) = run_once(&app, &runtime, &client, ¬ified_messages, ¬ified_questions).await { + if let Err(err) = run_once( + &app, + &runtime, + &client, + ¬ified_messages, + ¬ified_questions, + &session_parent_cache, + ).await { warn!("[desktop:notify] SSE loop error: {err:?}"); } tokio::time::sleep(Duration::from_secs(2)).await; @@ -69,6 +77,7 @@ async fn run_once( client: &Client, notified_messages: &Mutex>, notified_questions: &Mutex>, + session_parent_cache: &Mutex>>, ) -> Result<()> { let opencode = runtime.opencode_manager(); @@ -121,7 +130,19 @@ async fn run_once( data_lines.clear(); match parse_event_envelope(&raw) { - Ok(event) => handle_event(app, event, notified_messages, notified_questions).await, + Ok(event) => { + handle_event( + app, + runtime, + client, + &base, + event, + notified_messages, + notified_questions, + session_parent_cache, + ) + .await + } Err(err) => { warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}"); } @@ -244,13 +265,26 @@ async fn try_connect_sse( async fn handle_event( app: &AppHandle, + runtime: &DesktopRuntime, + client: &Client, + base: &str, event: EventEnvelope, notified_messages: &Mutex>, notified_questions: &Mutex>, + session_parent_cache: &Mutex>>, ) { match event.event_type.as_str() { "message.updated" => { - handle_message_updated(app, &event.properties, notified_messages).await; + handle_message_updated( + app, + runtime, + client, + base, + &event.properties, + notified_messages, + session_parent_cache, + ) + .await; } "question.asked" => { handle_question_asked(app, &event.properties, notified_questions).await; @@ -262,6 +296,61 @@ async fn handle_event( } } +async fn resolve_session_parent_id( + client: &Client, + base: &str, + session_id: &str, + cache: &Mutex>>, +) -> Option> { + { + let locked = cache.lock().await; + if let Some(existing) = locked.get(session_id) { + return Some(existing.clone()); + } + } + + // Fail open: on any error, return None (unknown) + let sessions_url = format!("{base}/session"); + let response = match tokio::time::timeout( + Duration::from_secs(2), + client.get(&sessions_url).header("accept", "application/json").send(), + ) + .await + { + Ok(Ok(resp)) => resp, + _ => return None, + }; + + if !response.status().is_success() { + return None; + } + + let data: Value = match response.json().await { + Ok(v) => v, + Err(_) => return None, + }; + + let parent = data + .as_array() + .and_then(|arr| { + arr.iter().find_map(|entry| { + let id = entry.get("id").and_then(Value::as_str)?; + if id != session_id { + return None; + } + let parent = entry.get("parentID").and_then(Value::as_str); + Some(parent.filter(|s| !s.is_empty()).map(|s| s.to_string())) + }) + }) + .flatten(); + + { + let mut locked = cache.lock().await; + locked.insert(session_id.to_string(), parent.clone()); + } + Some(parent) +} + async fn handle_question_asked( app: &AppHandle, properties: &Value, @@ -396,8 +485,12 @@ async fn handle_permission_asked( async fn handle_message_updated( app: &AppHandle, + runtime: &DesktopRuntime, + client: &Client, + base: &str, properties: &Value, notified_messages: &Mutex>, + session_parent_cache: &Mutex>>, ) { let Some(info) = properties.get("info") else { return; @@ -418,6 +511,31 @@ async fn handle_message_updated( None => return, }; + // Subtask filtering (fail open) + let notify_on_subtasks = runtime + .settings() + .load() + .await + .ok() + .and_then(|settings| settings.get("notifyOnSubtasks").and_then(Value::as_bool)) + .unwrap_or(true); + + if !notify_on_subtasks { + let session_id = info + .get("sessionID") + .and_then(Value::as_str) + .or_else(|| properties.get("sessionID").and_then(Value::as_str)) + .or_else(|| properties.get("sessionId").and_then(Value::as_str)); + + if let Some(session_id) = session_id { + if let Some(parent) = resolve_session_parent_id(client, base, session_id, session_parent_cache).await { + if parent.is_some() { + return; + } + } + } + } + { let mut notified = notified_messages.lock().await; if notified.contains(&message_id) { diff --git a/packages/desktop/src-tauri/src/commands/settings.rs b/packages/desktop/src-tauri/src/commands/settings.rs index 65de2e85..11a37939 100644 --- a/packages/desktop/src-tauri/src/commands/settings.rs +++ b/packages/desktop/src-tauri/src/commands/settings.rs @@ -295,6 +295,9 @@ fn sanitize_settings_update(payload: &Value) -> Value { if let Some(Value::Bool(b)) = obj.get("nativeNotificationsEnabled") { result_obj.insert("nativeNotificationsEnabled".to_string(), json!(b)); } + if let Some(Value::Bool(b)) = obj.get("notifyOnSubtasks") { + result_obj.insert("notifyOnSubtasks".to_string(), json!(b)); + } if let Some(Value::String(s)) = obj.get("notificationMode") { let trimmed = s.trim(); if trimmed == "always" || trimmed == "hidden-only" { diff --git a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx index 65acfbea..3815384f 100644 --- a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx @@ -8,10 +8,13 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { GridLoader } from '@/components/ui/grid-loader'; export const NotificationSettings: React.FC = () => { + const isWeb = isWebRuntime(); const nativeNotificationsEnabled = useUIStore(state => state.nativeNotificationsEnabled); const setNativeNotificationsEnabled = useUIStore(state => state.setNativeNotificationsEnabled); const notificationMode = useUIStore(state => state.notificationMode); const setNotificationMode = useUIStore(state => state.setNotificationMode); + const notifyOnSubtasks = useUIStore(state => state.notifyOnSubtasks); + const setNotifyOnSubtasks = useUIStore(state => state.setNotifyOnSubtasks); const [notificationPermission, setNotificationPermission] = React.useState('default'); const [pushSupported, setPushSupported] = React.useState(false); @@ -19,6 +22,12 @@ export const NotificationSettings: React.FC = () => { const [pushBusy, setPushBusy] = React.useState(false); React.useEffect(() => { + if (!isWeb) { + setPushSupported(false); + setPushSubscribed(false); + return; + } + if (typeof Notification !== 'undefined') { setNotificationPermission(Notification.permission); } @@ -49,9 +58,12 @@ export const NotificationSettings: React.FC = () => { }; void refresh(); - }, []); + }, [isWeb]); const handleToggleChange = async (checked: boolean) => { + if (!isWeb) { + return; + } if (checked && typeof Notification !== 'undefined' && Notification.permission === 'default') { try { const permission = await Notification.requestPermission(); @@ -74,7 +86,7 @@ export const NotificationSettings: React.FC = () => { } }; - const canShowNotifications = typeof Notification !== 'undefined' && Notification.permission === 'granted'; + const canShowNotifications = isWeb && typeof Notification !== 'undefined' && Notification.permission === 'granted'; const base64UrlToUint8Array = (base64Url: string): Uint8Array => { const padding = '='.repeat((4 - (base64Url.length % 4)) % 4); @@ -351,113 +363,141 @@ export const NotificationSettings: React.FC = () => { } }; - if (!isWebRuntime()) { - return null; - } - return (
+ {/* General Notification Settings */}

- Foreground Notifications + Notification Preferences

- Uses the browser Notification API while OpenChamber is open. + Configure how and when you receive notifications.

- - Enable foreground notifications - +
+ + Notify for subtasks + +

+ When off, no notifications for child sessions created during multi-run. +

+
setNotifyOnSubtasks(checked)} className="data-[state=checked]:bg-status-info" />
- {nativeNotificationsEnabled && canShowNotifications && ( -
-
- - Notify even when visible - -

- When off, only notifies when the tab is hidden or the window is not focused. -

-
- setNotificationMode(checked ? 'always' : 'hidden-only')} - className="data-[state=checked]:bg-status-info" - /> -
- )} - - {notificationPermission === 'denied' && ( -

- Notification permission denied. Enable notifications in your browser settings. -

- )} - - {notificationPermission === 'granted' && !nativeNotificationsEnabled && ( -

- Permission granted, but foreground notifications are disabled. -

- )} - -
-

- Background Notifications (Push) -

-

- Uses push notifications; works when OpenChamber is closed. -

-
- - {!pushSupported ? ( -

- Push not supported in this browser. -

- ) : ( -

- Desktop Chrome/Edge and Android support push in the browser. iOS requires an installed PWA. -

- )} - - {pushSupported && ( -
-
- - Enable background notifications - -

- Opens chat with /?session=<id> deep link. + {isWeb && ( + <> + {/* Foreground Notifications */} +

+

+ Foreground Notifications +

+

+ Uses the browser Notification API while OpenChamber is open.

-
- {pushBusy && ( -
- -
- )} - +
+ + Enable foreground notifications + { - if (checked) { - void handleEnableBackgroundNotifications(); - } else { - void handleDisableBackgroundNotifications(); - } - }} + checked={nativeNotificationsEnabled && canShowNotifications} + onCheckedChange={handleToggleChange} className="data-[state=checked]:bg-status-info" />
-
+ + {nativeNotificationsEnabled && canShowNotifications && ( +
+
+ + Notify even when visible + +

+ When off, only notifies when the tab is hidden or the window is not focused. +

+
+ setNotificationMode(checked ? 'always' : 'hidden-only')} + className="data-[state=checked]:bg-status-info" + /> +
+ )} + + {notificationPermission === 'denied' && ( +

+ Notification permission denied. Enable notifications in your browser settings. +

+ )} + + {notificationPermission === 'granted' && !nativeNotificationsEnabled && ( +

+ Permission granted, but foreground notifications are disabled. +

+ )} + + {/* Background Notifications */} +
+

+ Background Notifications (Push) +

+

+ Uses push notifications; works when OpenChamber is closed. +

+
+ + {!pushSupported ? ( +

+ Push not supported in this browser. +

+ ) : ( +

+ Desktop Chrome/Edge and Android support push in the browser. iOS requires an installed PWA. +

+ )} + + {pushSupported && ( +
+
+ + Enable background notifications + +

+ Opens chat with /?session=<id> deep link. +

+
+ +
+ {pushBusy && ( +
+ +
+ )} + + { + if (checked) { + void handleEnableBackgroundNotifications(); + } else { + void handleDisableBackgroundNotifications(); + } + }} + className="data-[state=checked]:bg-status-info" + /> +
+
+ )} + )}
); diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx index 15984202..a2e10378 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberSidebar.tsx @@ -52,7 +52,6 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [ id: 'notifications', label: 'Notifications', items: ['Native'], - webOnly: true, }, ]; diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index 547f70e9..1c2a9f90 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -150,6 +150,7 @@ export const useEventStream = () => { const { checkConnection } = useConfigStore(); const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled); const notificationMode = useUIStore((state) => state.notificationMode); + const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks); const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory); const activeSessionDirectory = React.useMemo(() => { @@ -1310,6 +1311,17 @@ export const useEventStream = () => { const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden'; if (shouldNotify) { + // Check if this is a subtask and if we should notify for subtasks + if (!notifyOnSubtasks) { + const sessions = useSessionStore.getState().sessions; + const session = sessions.find(s => s.id === sessionId); + const isSubtask = session && 'parentID' in session && Boolean((session as { parentID?: string }).parentID); + if (isSubtask) { + // Skip notification for subtasks + return; + } + } + const notifiedMessages = notifiedMessagesRef.current; if (!notifiedMessages.has(messageId)) { @@ -1610,6 +1622,7 @@ export const useEventStream = () => { currentSessionId, nativeNotificationsEnabled, notificationMode, + notifyOnSubtasks, addStreamingPart, completeStreamingMessage, updateMessageInfo, @@ -2095,6 +2108,7 @@ export const useEventStream = () => { loadSessions, maybeBootstrapIfStale, resyncMessages, - scheduleSoftResync + scheduleSoftResync, + notifyOnSubtasks, ]); }; diff --git a/packages/ui/src/lib/appearanceAutoSave.ts b/packages/ui/src/lib/appearanceAutoSave.ts index 531dd70a..7ee621ea 100644 --- a/packages/ui/src/lib/appearanceAutoSave.ts +++ b/packages/ui/src/lib/appearanceAutoSave.ts @@ -7,6 +7,7 @@ type AppearanceSlice = { showTextJustificationActivity: boolean; nativeNotificationsEnabled: boolean; notificationMode: 'always' | 'hidden-only'; + notifyOnSubtasks: boolean; autoDeleteEnabled: boolean; autoDeleteAfterDays: number; toolCallExpansion: 'collapsed' | 'activity' | 'detailed'; @@ -32,6 +33,7 @@ export const startAppearanceAutoSave = (): void => { showTextJustificationActivity: useUIStore.getState().showTextJustificationActivity, nativeNotificationsEnabled: useUIStore.getState().nativeNotificationsEnabled, notificationMode: useUIStore.getState().notificationMode, + notifyOnSubtasks: useUIStore.getState().notifyOnSubtasks, autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled, autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays, toolCallExpansion: useUIStore.getState().toolCallExpansion, @@ -69,6 +71,7 @@ export const startAppearanceAutoSave = (): void => { showTextJustificationActivity: state.showTextJustificationActivity, nativeNotificationsEnabled: state.nativeNotificationsEnabled, notificationMode: state.notificationMode, + notifyOnSubtasks: state.notifyOnSubtasks, autoDeleteEnabled: state.autoDeleteEnabled, autoDeleteAfterDays: state.autoDeleteAfterDays, toolCallExpansion: state.toolCallExpansion, @@ -94,6 +97,9 @@ export const startAppearanceAutoSave = (): void => { if (current.notificationMode !== previous.notificationMode) { diff.notificationMode = current.notificationMode; } + if (current.notifyOnSubtasks !== previous.notifyOnSubtasks) { + diff.notifyOnSubtasks = current.notifyOnSubtasks; + } if (current.autoDeleteEnabled !== previous.autoDeleteEnabled) { diff.autoDeleteEnabled = current.autoDeleteEnabled; } diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 8e19cd27..c68ac730 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -54,6 +54,7 @@ export type DesktopSettings = { showTextJustificationActivity?: boolean; nativeNotificationsEnabled?: boolean; notificationMode?: 'always' | 'hidden-only'; + notifyOnSubtasks?: boolean; autoDeleteEnabled?: boolean; autoDeleteAfterDays?: number; defaultModel?: string; // format: "provider/model" diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index dde5cd4c..63882642 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -216,6 +216,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { store.setNotificationMode(settings.notificationMode); } } + if (typeof settings.notifyOnSubtasks === 'boolean' && settings.notifyOnSubtasks !== store.notifyOnSubtasks) { + store.setNotifyOnSubtasks(settings.notifyOnSubtasks); + } if (typeof settings.toolCallExpansion === 'string' && (settings.toolCallExpansion === 'collapsed' || settings.toolCallExpansion === 'activity' || settings.toolCallExpansion === 'detailed')) { if (settings.toolCallExpansion !== store.toolCallExpansion) { @@ -345,6 +348,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.notificationMode === 'string' && (candidate.notificationMode === 'always' || candidate.notificationMode === 'hidden-only')) { result.notificationMode = candidate.notificationMode; } + if (typeof candidate.notifyOnSubtasks === 'boolean') { + result.notifyOnSubtasks = candidate.notifyOnSubtasks; + } if ( typeof candidate.toolCallExpansion === 'string' && (candidate.toolCallExpansion === 'collapsed' diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 198aa3b1..0266bb53 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -67,6 +67,7 @@ interface UIStore { isImagePreviewOpen: boolean; nativeNotificationsEnabled: boolean; notificationMode: 'always' | 'hidden-only'; + notifyOnSubtasks: boolean; setTheme: (theme: 'light' | 'dark' | 'system') => void; toggleSidebar: () => void; @@ -121,6 +122,7 @@ interface UIStore { setImagePreviewOpen: (open: boolean) => void; setNativeNotificationsEnabled: (value: boolean) => void; setNotificationMode: (mode: 'always' | 'hidden-only') => void; + setNotifyOnSubtasks: (value: boolean) => void; openMultiRunLauncher: () => void; openMultiRunLauncherWithPrompt: (prompt: string) => void; } @@ -176,6 +178,7 @@ export const useUIStore = create()( isImagePreviewOpen: false, nativeNotificationsEnabled: false, notificationMode: 'hidden-only', + notifyOnSubtasks: true, setTheme: (theme) => { set({ theme }); @@ -593,6 +596,10 @@ export const useUIStore = create()( setNotificationMode: (mode) => { set({ notificationMode: mode }); }, + + setNotifyOnSubtasks: (value) => { + set({ notifyOnSubtasks: value }); + }, }), { name: 'ui-store', @@ -627,6 +634,7 @@ export const useUIStore = create()( diffViewMode: state.diffViewMode, nativeNotificationsEnabled: state.nativeNotificationsEnabled, notificationMode: state.notificationMode, + notifyOnSubtasks: state.notifyOnSubtasks, }) } ), diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 4ed41863..5938cc56 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -783,6 +783,9 @@ const sanitizeSettingsUpdate = (payload) => { result.notificationMode = mode; } } + if (typeof candidate.notifyOnSubtasks === 'boolean') { + result.notifyOnSubtasks = candidate.notifyOnSubtasks; + } if (typeof candidate.autoDeleteEnabled === 'boolean') { result.autoDeleteEnabled = candidate.autoDeleteEnabled; } @@ -1861,6 +1864,53 @@ const pushPermissionDebounceTimers = new Map(); const notifiedPermissionRequests = new Set(); const lastReadyNotificationAt = new Map(); +// Cache: sessionId -> parentID (string) or null (no parent). Undefined = unknown. +const sessionParentIdCache = new Map(); +const SESSION_PARENT_CACHE_TTL_MS = 60 * 1000; + +const getCachedSessionParentId = (sessionId) => { + const entry = sessionParentIdCache.get(sessionId); + if (!entry) return undefined; + if (Date.now() - entry.at > SESSION_PARENT_CACHE_TTL_MS) { + sessionParentIdCache.delete(sessionId); + return undefined; + } + return entry.parentID; +}; + +const setCachedSessionParentId = (sessionId, parentID) => { + sessionParentIdCache.set(sessionId, { parentID: parentID ?? null, at: Date.now() }); +}; + +const fetchSessionParentId = async (sessionId) => { + if (!sessionId) return undefined; + + const cached = getCachedSessionParentId(sessionId); + if (cached !== undefined) return cached; + + try { + const response = await fetch(buildOpenCodeUrl('/session', ''), { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(2000), + }); + if (!response.ok) { + return undefined; + } + const data = await response.json().catch(() => null); + if (!Array.isArray(data)) { + return undefined; + } + + const match = data.find((s) => s && typeof s === 'object' && s.id === sessionId); + const parentID = match && typeof match.parentID === 'string' && match.parentID.length > 0 ? match.parentID : null; + setCachedSessionParentId(sessionId, parentID); + return parentID; + } catch { + return undefined; + } +}; + const extractSessionIdFromPayload = (payload) => { if (!payload || typeof payload !== 'object') return null; const props = payload.properties; @@ -1919,6 +1969,22 @@ const maybeSendPushForTrigger = async (payload) => { if (payload.type === 'message.updated') { const info = payload.properties?.info; if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) { + // Check if this is a subtask and if we should notify for subtasks + const settings = await readSettingsFromDisk(); + if (settings.notifyOnSubtasks === false) { + // Prefer parentID on payload (if present), else fetch from sessions list. + const sessionInfo = payload.properties?.session; + const parentIDFromPayload = sessionInfo?.parentID ?? payload.properties?.parentID; + const parentID = parentIDFromPayload + ? parentIDFromPayload + : await fetchSessionParentId(sessionId); + + // Fail open: if parentID cannot be resolved, send notification. + if (parentID) { + return; + } + } + const now = Date.now(); const lastAt = lastReadyNotificationAt.get(sessionId) ?? 0; if (now - lastAt < PUSH_READY_COOLDOWN_MS) {