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);