From 87d13cf61b1300309a0df7b1a75668de58172ee0 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 19 May 2026 13:16:47 +0300 Subject: [PATCH] fix: enable web runtime notifications without push (#1322) Web notifications now listen to the notification stream directly Native notifications no longer depend on PWA push being enabled Window focus is respected when deciding whether to send background push notifications --- packages/ui/src/App.tsx | 2 + .../ui/src/hooks/usePushVisibilityBeacon.ts | 3 +- .../ui/src/hooks/useWebNotificationStream.ts | 60 +++++++++++++++++++ .../web/server/lib/notifications/routes.js | 2 + 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/hooks/useWebNotificationStream.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index d6099bf7..5347682a 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -12,6 +12,7 @@ import { useMenuActions } from '@/hooks/useMenuActions'; import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap'; import { useRouter } from '@/hooks/useRouter'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; +import { useWebNotificationStream } from '@/hooks/useWebNotificationStream'; import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -621,6 +622,7 @@ function App({ apis }: AppProps) { // Session attention now handled by notification-store via SSE events (session.idle/session.error) usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled }); + useWebNotificationStream({ enabled: embeddedBackgroundWorkEnabled }); usePwaInstallPrompt(); useWindowTitle(); diff --git a/packages/ui/src/hooks/usePushVisibilityBeacon.ts b/packages/ui/src/hooks/usePushVisibilityBeacon.ts index b23973a7..9f9965c1 100644 --- a/packages/ui/src/hooks/usePushVisibilityBeacon.ts +++ b/packages/ui/src/hooks/usePushVisibilityBeacon.ts @@ -6,8 +6,7 @@ const HEARTBEAT_MS = 20000; const resolveVisibilityState = (): 'visible' | 'hidden' => { if (typeof document === 'undefined') return 'visible'; - const state = document.visibilityState; - return state === 'hidden' && document.hasFocus() ? 'visible' : state; + return document.visibilityState === 'visible' && document.hasFocus() ? 'visible' : 'hidden'; }; const sendVisibility = (visible: boolean) => { diff --git a/packages/ui/src/hooks/useWebNotificationStream.ts b/packages/ui/src/hooks/useWebNotificationStream.ts new file mode 100644 index 00000000..3c5db075 --- /dev/null +++ b/packages/ui/src/hooks/useWebNotificationStream.ts @@ -0,0 +1,60 @@ +import React from 'react'; +import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { isWebRuntime } from '@/lib/desktop'; +import { useUIStore } from '@/stores/useUIStore'; +import type { NotificationPayload } from '@/lib/api/types'; + +const NOTIFICATION_STREAM_PATH = '/api/notifications/stream'; + +const isFocused = () => { + if (typeof document === 'undefined') return true; + return document.visibilityState === 'visible' && document.hasFocus(); +}; + +const toNotificationPayload = (value: unknown): NotificationPayload | null => { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + const properties = record.properties && typeof record.properties === 'object' + ? record.properties as Record + : null; + if (record.type !== 'openchamber:notification' || !properties) return null; + return { + title: typeof properties.title === 'string' ? properties.title : undefined, + body: typeof properties.body === 'string' ? properties.body : undefined, + tag: typeof properties.tag === 'string' ? properties.tag : undefined, + }; +}; + +export const useWebNotificationStream = (options?: { enabled?: boolean }) => { + const enabled = options?.enabled ?? true; + + React.useEffect(() => { + if (!enabled || !isWebRuntime() || typeof window === 'undefined' || typeof EventSource === 'undefined') { + return; + } + + const source = new EventSource(NOTIFICATION_STREAM_PATH); + source.onmessage = (event) => { + let data: unknown; + try { + data = JSON.parse(event.data) as unknown; + } catch { + return; + } + + const settings = useUIStore.getState(); + if (!settings.nativeNotificationsEnabled) return; + if (settings.notificationMode !== 'always' && isFocused()) return; + + const payload = toNotificationPayload(data); + if (!payload) return; + + const apis = getRegisteredRuntimeAPIs(); + void apis?.notifications?.notifyAgentCompletion(payload); + }; + + return () => { + source.close(); + }; + }, [enabled]); +}; diff --git a/packages/web/server/lib/notifications/routes.js b/packages/web/server/lib/notifications/routes.js index c4a2f5fa..83b28f84 100644 --- a/packages/web/server/lib/notifications/routes.js +++ b/packages/web/server/lib/notifications/routes.js @@ -162,6 +162,8 @@ export const registerNotificationRoutes = (app, dependencies) => { }); app.get('/api/notifications/stream', async (req, res) => { + await ensureSessionWatcher(); + const uiToken = uiAuthController?.ensureSessionToken ? await uiAuthController.ensureSessionToken(req, res) : getUiSessionTokenFromRequest(req);