Skip to content

Commit 8f783c0

Browse files
author
Riajul Islam
committed
feat: add browser notification support
1 parent e4d4829 commit 8f783c0

2 files changed

Lines changed: 392 additions & 0 deletions

File tree

Lines changed: 391 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,391 @@
1+
From 2c0de163332bc3c955daeeb6ddeb8a2f1d7db567 Mon Sep 17 00:00:00 2001
2+
From: Riajul Islam <riajul@kahf.co>
3+
Date: Mon, 20 Apr 2026 15:11:53 +0000
4+
Subject: [PATCH] feat: add browser notification support
5+
6+
---
7+
packages/local-web/src/app/entry/App.tsx | 3 +
8+
packages/remote-web/src/routes/__root.tsx | 2 +
9+
.../pages/workspaces/NotificationsPage.tsx | 90 ++++++++++++++++---
10+
.../hooks/useBrowserNotificationPreference.ts | 48 ++++++++++
11+
.../src/shared/lib/browserNotifications.ts | 52 +++++++++++
12+
.../notifications/AppBrowserNotifications.tsx | 81 +++++++++++++++++
13+
6 files changed, 265 insertions(+), 11 deletions(-)
14+
create mode 100644 packages/web-core/src/shared/hooks/useBrowserNotificationPreference.ts
15+
create mode 100644 packages/web-core/src/shared/lib/browserNotifications.ts
16+
create mode 100644 packages/web-core/src/shared/notifications/AppBrowserNotifications.tsx
17+
18+
diff --git a/packages/local-web/src/app/entry/App.tsx b/packages/local-web/src/app/entry/App.tsx
19+
index adcfbc901..3df3894b1 100644
20+
--- a/packages/local-web/src/app/entry/App.tsx
21+
+++ b/packages/local-web/src/app/entry/App.tsx
22+
@@ -10,6 +10,8 @@ import { useTauriNotificationNavigation } from '@web/app/hooks/useTauriNotificat
23+
import { useTauriUpdateReady } from '@web/app/hooks/useTauriUpdateReady';
24+
import { AppSystemNotifications } from '@web/app/notifications/AppSystemNotifications';
25+
import { router } from '@web/app/router';
26+
+import { AppBrowserNotifications } from '@/shared/notifications/AppBrowserNotifications';
27+
+import { isTauriApp } from '@/shared/lib/platform';
28+
29+
function TauriListeners() {
30+
useTauriNotificationNavigation();
31+
@@ -25,6 +27,7 @@ function App() {
32+
<UserSystemProvider>
33+
<LocalAuthProvider>
34+
<AppSystemNotifications />
35+
+ {!isTauriApp() && <AppBrowserNotifications />}
36+
<ClickedElementsProvider>
37+
<HotkeysProvider
38+
initiallyActiveScopes={[
39+
diff --git a/packages/remote-web/src/routes/__root.tsx b/packages/remote-web/src/routes/__root.tsx
40+
index 84a16b1ce..6c4a9b9a0 100644
41+
--- a/packages/remote-web/src/routes/__root.tsx
42+
+++ b/packages/remote-web/src/routes/__root.tsx
43+
@@ -11,6 +11,7 @@ import { RemoteActionsProvider } from "@remote/app/providers/RemoteActionsProvid
44+
import { RemoteUserSystemProvider } from "@remote/app/providers/RemoteUserSystemProvider";
45+
import { RemoteAppShell } from "@remote/app/layout/RemoteAppShell";
46+
import { UserProvider } from "@/shared/providers/remote/UserProvider";
47+
+import { AppBrowserNotifications } from "@/shared/notifications/AppBrowserNotifications";
48+
import { WorkspaceProvider } from "@/shared/providers/WorkspaceProvider";
49+
import { ExecutionProcessesProvider } from "@/shared/providers/ExecutionProcessesProvider";
50+
import { TerminalProvider } from "@/shared/providers/TerminalProvider";
51+
@@ -162,6 +163,7 @@ function RootLayout() {
52+
return (
53+
<AppNavigationProvider value={appNavigation}>
54+
<UserProvider>
55+
+ <AppBrowserNotifications />
56+
<RemoteActionsProvider>
57+
<RemoteUserSystemProvider>{content}</RemoteUserSystemProvider>
58+
</RemoteActionsProvider>
59+
diff --git a/packages/web-core/src/pages/workspaces/NotificationsPage.tsx b/packages/web-core/src/pages/workspaces/NotificationsPage.tsx
60+
index 6542bf7d8..c5cd03c2a 100644
61+
--- a/packages/web-core/src/pages/workspaces/NotificationsPage.tsx
62+
+++ b/packages/web-core/src/pages/workspaces/NotificationsPage.tsx
63+
@@ -1,7 +1,9 @@
64+
-import { useCallback } from 'react';
65+
+import { useCallback, useEffect, useState } from 'react';
66+
import { useRouter } from '@tanstack/react-router';
67+
import { BellIcon, CheckIcon, ChecksIcon } from '@phosphor-icons/react';
68+
import { UserAvatar } from '@vibe/ui/components/UserAvatar';
69+
+import { useAuth } from '@/shared/hooks/auth/useAuth';
70+
+import { useBrowserNotificationPreference } from '@/shared/hooks/useBrowserNotificationPreference';
71+
import { useNotifications } from '@/shared/hooks/useNotifications';
72+
import { useNotificationMembers } from '@/shared/hooks/useNotificationMembers';
73+
import type { GroupedNotification } from '@/shared/lib/notifications';
74+
@@ -9,6 +11,11 @@ import {
75+
getGroupedNotificationSegments,
76+
type MessageSegment,
77+
} from '@/shared/lib/notificationMessage';
78+
+import {
79+
+ getBrowserNotificationPermission,
80+
+ requestBrowserNotificationPermission,
81+
+ type BrowserNotificationPermission,
82+
+} from '@/shared/lib/browserNotifications';
83+
import { formatRelativeTime } from '@/shared/lib/date';
84+
import { cn } from '@/shared/lib/utils';
85+
86+
@@ -58,9 +65,22 @@ function NotificationMessage({
87+
88+
export function NotificationsPage() {
89+
const router = useRouter();
90+
+ const { userId } = useAuth();
91+
const { data, updateMany, enabled, unseenCount, groupedNotifications } =
92+
useNotifications();
93+
const { membersByUserId } = useNotificationMembers(data);
94+
+ const {
95+
+ enabled: browserNotificationsEnabled,
96+
+ setEnabled: setBrowserNotificationsEnabled,
97+
+ } = useBrowserNotificationPreference(userId);
98+
+ const [browserPermission, setBrowserPermission] =
99+
+ useState<BrowserNotificationPermission>(() =>
100+
+ getBrowserNotificationPermission()
101+
+ );
102+
+
103+
+ useEffect(() => {
104+
+ setBrowserPermission(getBrowserNotificationPermission());
105+
+ }, []);
106+
107+
const markGroupSeen = useCallback(
108+
(group: GroupedNotification) => {
109+
@@ -95,6 +115,29 @@ export function NotificationsPage() {
110+
updateMany(unseen.map((n) => ({ id: n.id, changes: { seen: true } })));
111+
}, [data, updateMany]);
112+
113+
+ const handleBrowserNotificationsClick = useCallback(async () => {
114+
+ if (browserNotificationsEnabled) {
115+
+ setBrowserNotificationsEnabled(false);
116+
+ return;
117+
+ }
118+
+
119+
+ const permission =
120+
+ browserPermission === 'granted'
121+
+ ? browserPermission
122+
+ : await requestBrowserNotificationPermission();
123+
+
124+
+ setBrowserPermission(permission);
125+
+ setBrowserNotificationsEnabled(permission === 'granted');
126+
+ }, [
127+
+ browserNotificationsEnabled,
128+
+ browserPermission,
129+
+ setBrowserNotificationsEnabled,
130+
+ ]);
131+
+
132+
+ const browserNotificationLabel = browserNotificationsEnabled
133+
+ ? 'Disable browser notifications'
134+
+ : 'Enable browser notifications';
135+
+
136+
if (!enabled) {
137+
return (
138+
<div className="flex items-center justify-center h-full text-low">
139+
@@ -107,16 +150,41 @@ export function NotificationsPage() {
140+
<div className="flex flex-col h-full overflow-hidden">
141+
<div className="flex items-center justify-between px-double py-base border-b border-border">
142+
<h1 className="text-xl font-medium text-high">Notifications</h1>
143+
- {unseenCount > 0 && (
144+
- <button
145+
- type="button"
146+
- onClick={handleMarkAllSeen}
147+
- className="flex items-center gap-1 px-base py-half text-sm text-low hover:text-normal transition-colors cursor-pointer"
148+
- >
149+
- <ChecksIcon size={16} />
150+
- Mark all as read
151+
- </button>
152+
- )}
153+
+ <div className="flex items-center gap-half">
154+
+ {browserPermission !== 'unsupported' && (
155+
+ <button
156+
+ type="button"
157+
+ onClick={handleBrowserNotificationsClick}
158+
+ disabled={browserPermission === 'denied'}
159+
+ className={cn(
160+
+ 'flex items-center gap-1 px-base py-half text-sm text-low hover:text-normal transition-colors cursor-pointer',
161+
+ 'disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:text-low'
162+
+ )}
163+
+ title={
164+
+ browserPermission === 'denied'
165+
+ ? 'Browser notifications are blocked in this browser'
166+
+ : browserNotificationLabel
167+
+ }
168+
+ >
169+
+ <BellIcon size={16} />
170+
+ <span className="hidden sm:inline">
171+
+ {browserPermission === 'denied'
172+
+ ? 'Notifications blocked'
173+
+ : browserNotificationLabel}
174+
+ </span>
175+
+ </button>
176+
+ )}
177+
+ {unseenCount > 0 && (
178+
+ <button
179+
+ type="button"
180+
+ onClick={handleMarkAllSeen}
181+
+ className="flex items-center gap-1 px-base py-half text-sm text-low hover:text-normal transition-colors cursor-pointer"
182+
+ >
183+
+ <ChecksIcon size={16} />
184+
+ <span className="hidden sm:inline">Mark all as read</span>
185+
+ </button>
186+
+ )}
187+
+ </div>
188+
</div>
189+
190+
<div className="flex-1 overflow-y-auto">
191+
diff --git a/packages/web-core/src/shared/hooks/useBrowserNotificationPreference.ts b/packages/web-core/src/shared/hooks/useBrowserNotificationPreference.ts
192+
new file mode 100644
193+
index 000000000..a34252059
194+
--- /dev/null
195+
+++ b/packages/web-core/src/shared/hooks/useBrowserNotificationPreference.ts
196+
@@ -0,0 +1,48 @@
197+
+import { useCallback, useEffect, useMemo, useState } from 'react';
198+
+
199+
+const STORAGE_KEY_PREFIX = 'vibe-kanban.browser-notifications.enabled';
200+
+
201+
+function getStorageKey(userId: string | null | undefined): string | null {
202+
+ return userId ? `${STORAGE_KEY_PREFIX}.${userId}` : null;
203+
+}
204+
+
205+
+function readPreference(storageKey: string | null): boolean {
206+
+ if (!storageKey || typeof window === 'undefined') {
207+
+ return false;
208+
+ }
209+
+
210+
+ return window.localStorage.getItem(storageKey) === 'true';
211+
+}
212+
+
213+
+export function useBrowserNotificationPreference(
214+
+ userId: string | null | undefined
215+
+) {
216+
+ const storageKey = useMemo(() => getStorageKey(userId), [userId]);
217+
+ const [enabled, setEnabledState] = useState(() => readPreference(storageKey));
218+
+
219+
+ useEffect(() => {
220+
+ setEnabledState(readPreference(storageKey));
221+
+ }, [storageKey]);
222+
+
223+
+ const setEnabled = useCallback(
224+
+ (nextEnabled: boolean) => {
225+
+ setEnabledState(nextEnabled);
226+
+
227+
+ if (!storageKey || typeof window === 'undefined') {
228+
+ return;
229+
+ }
230+
+
231+
+ if (nextEnabled) {
232+
+ window.localStorage.setItem(storageKey, 'true');
233+
+ } else {
234+
+ window.localStorage.removeItem(storageKey);
235+
+ }
236+
+ },
237+
+ [storageKey]
238+
+ );
239+
+
240+
+ return {
241+
+ enabled,
242+
+ setEnabled,
243+
+ };
244+
+}
245+
diff --git a/packages/web-core/src/shared/lib/browserNotifications.ts b/packages/web-core/src/shared/lib/browserNotifications.ts
246+
new file mode 100644
247+
index 000000000..5394b0546
248+
--- /dev/null
249+
+++ b/packages/web-core/src/shared/lib/browserNotifications.ts
250+
@@ -0,0 +1,52 @@
251+
+export type BrowserNotificationPermission =
252+
+ | NotificationPermission
253+
+ | 'unsupported';
254+
+
255+
+export interface BrowserNotificationPayload {
256+
+ id: string;
257+
+ title: string;
258+
+ body: string;
259+
+ deeplinkPath?: string;
260+
+}
261+
+
262+
+export function getBrowserNotificationPermission(): BrowserNotificationPermission {
263+
+ if (typeof window === 'undefined' || !('Notification' in window)) {
264+
+ return 'unsupported';
265+
+ }
266+
+
267+
+ return window.Notification.permission;
268+
+}
269+
+
270+
+export async function requestBrowserNotificationPermission(): Promise<BrowserNotificationPermission> {
271+
+ if (typeof window === 'undefined' || !('Notification' in window)) {
272+
+ return 'unsupported';
273+
+ }
274+
+
275+
+ return window.Notification.requestPermission();
276+
+}
277+
+
278+
+export function showBrowserNotification({
279+
+ id,
280+
+ title,
281+
+ body,
282+
+ deeplinkPath,
283+
+}: BrowserNotificationPayload): void {
284+
+ if (getBrowserNotificationPermission() !== 'granted') {
285+
+ return;
286+
+ }
287+
+
288+
+ const notification = new window.Notification(title, {
289+
+ body,
290+
+ icon: '/favicon.png',
291+
+ tag: id,
292+
+ });
293+
+
294+
+ notification.onclick = () => {
295+
+ window.focus();
296+
+ notification.close();
297+
+
298+
+ if (deeplinkPath) {
299+
+ window.location.assign(deeplinkPath);
300+
+ }
301+
+ };
302+
+}
303+
diff --git a/packages/web-core/src/shared/notifications/AppBrowserNotifications.tsx b/packages/web-core/src/shared/notifications/AppBrowserNotifications.tsx
304+
new file mode 100644
305+
index 000000000..d8b2a8895
306+
--- /dev/null
307+
+++ b/packages/web-core/src/shared/notifications/AppBrowserNotifications.tsx
308+
@@ -0,0 +1,81 @@
309+
+import { useEffect, useRef } from 'react';
310+
+import { useAuth } from '@/shared/hooks/auth/useAuth';
311+
+import { useBrowserNotificationPreference } from '@/shared/hooks/useBrowserNotificationPreference';
312+
+import { useNotificationMembers } from '@/shared/hooks/useNotificationMembers';
313+
+import { useNotifications } from '@/shared/hooks/useNotifications';
314+
+import { getGroupedNotificationText } from '@/shared/lib/notificationMessage';
315+
+import {
316+
+ getBrowserNotificationPermission,
317+
+ showBrowserNotification,
318+
+} from '@/shared/lib/browserNotifications';
319+
+
320+
+export function AppBrowserNotifications() {
321+
+ const { userId } = useAuth();
322+
+ const { enabled: browserNotificationsEnabled } =
323+
+ useBrowserNotificationPreference(userId);
324+
+ const { data, enabled, groupedNotifications } = useNotifications();
325+
+ const { membersByUserId, isLoading, isFetching } =
326+
+ useNotificationMembers(data);
327+
+ const displayedNotificationIdsRef = useRef(new Set<string>());
328+
+ const initializedRef = useRef(false);
329+
+
330+
+ useEffect(() => {
331+
+ displayedNotificationIdsRef.current.clear();
332+
+ initializedRef.current = false;
333+
+ }, [userId]);
334+
+
335+
+ useEffect(() => {
336+
+ if (!enabled || isLoading || isFetching) {
337+
+ return;
338+
+ }
339+
+
340+
+ if (!initializedRef.current) {
341+
+ for (const group of groupedNotifications) {
342+
+ if (!group.seen) {
343+
+ displayedNotificationIdsRef.current.add(group.id);
344+
+ }
345+
+ }
346+
+ initializedRef.current = true;
347+
+ return;
348+
+ }
349+
+
350+
+ const activeGroupIds = new Set(
351+
+ groupedNotifications.map((group) => group.id)
352+
+ );
353+
+ for (const id of displayedNotificationIdsRef.current) {
354+
+ if (!activeGroupIds.has(id)) {
355+
+ displayedNotificationIdsRef.current.delete(id);
356+
+ }
357+
+ }
358+
+
359+
+ if (
360+
+ !browserNotificationsEnabled ||
361+
+ getBrowserNotificationPermission() !== 'granted'
362+
+ ) {
363+
+ return;
364+
+ }
365+
+
366+
+ for (const group of groupedNotifications) {
367+
+ if (group.seen || displayedNotificationIdsRef.current.has(group.id)) {
368+
+ continue;
369+
+ }
370+
+
371+
+ displayedNotificationIdsRef.current.add(group.id);
372+
+ showBrowserNotification({
373+
+ id: group.id,
374+
+ title: 'Vibe Kanban',
375+
+ body: getGroupedNotificationText(group, membersByUserId),
376+
+ deeplinkPath: group.deeplinkPath ?? undefined,
377+
+ });
378+
+ }
379+
+ }, [
380+
+ browserNotificationsEnabled,
381+
+ enabled,
382+
+ groupedNotifications,
383+
+ isFetching,
384+
+ isLoading,
385+
+ membersByUserId,
386+
+ ]);
387+
+
388+
+ return null;
389+
+}
390+
--
391+
2.50.1

patches/series

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@
1717
0016-fix-relay-install-perl-and-make-for-.patch
1818
0017-fix-add-30s-WebSocket-ping-keepalive.patch
1919
0018-feat-allowed-email-domains-restriction.patch
20+
0019-feat-add-browser-notification-support.patch

0 commit comments

Comments
 (0)