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
This commit is contained in:
Bohdan Triapitsyn
2026-05-19 13:16:47 +03:00
parent f40110698f
commit 87d13cf61b
4 changed files with 65 additions and 2 deletions
+2
View File
@@ -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();
@@ -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) => {
@@ -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<string, unknown>;
const properties = record.properties && typeof record.properties === 'object'
? record.properties as Record<string, unknown>
: 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]);
};
@@ -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);