diff --git a/frontend/src/components/workspace/settings/notification-settings-page.tsx b/frontend/src/components/workspace/settings/notification-settings-page.tsx
index 5fbdb5002..fa876c6ba 100644
--- a/frontend/src/components/workspace/settings/notification-settings-page.tsx
+++ b/frontend/src/components/workspace/settings/notification-settings-page.tsx
@@ -54,6 +54,7 @@ export function NotificationSettingsPage() {
{t.settings.notification.description}
("default");
const [isSupported, setIsSupported] = useState(false);
- const lastNotificationTime = useRef(new Date());
+ const lastNotificationTime = useRef(null);
useEffect(() => {
// Check if browser supports Notification API
@@ -60,19 +60,25 @@ export function useNotification(): UseNotificationReturn {
return;
}
+ const currentPermission = Notification.permission;
+ if (currentPermission !== permission) {
+ setPermission(currentPermission);
+ }
+
+ if (currentPermission !== "granted") {
+ console.warn("Notification permission not granted");
+ return;
+ }
+
+ const now = Date.now();
if (
- new Date().getTime() - lastNotificationTime.current.getTime() <
- 1000
+ lastNotificationTime.current !== null &&
+ now - lastNotificationTime.current < 1000
) {
console.warn("Notification sent too soon");
return;
}
- lastNotificationTime.current = new Date();
-
- if (permission !== "granted") {
- console.warn("Notification permission not granted");
- return;
- }
+ lastNotificationTime.current = now;
const notification = new Notification(title, options);
diff --git a/frontend/tests/e2e/settings-notification.spec.ts b/frontend/tests/e2e/settings-notification.spec.ts
new file mode 100644
index 000000000..7248ccdb8
--- /dev/null
+++ b/frontend/tests/e2e/settings-notification.spec.ts
@@ -0,0 +1,115 @@
+import { expect, test, type Page } from "@playwright/test";
+
+import { mockLangGraphAPI } from "./utils/mock-api";
+
+declare global {
+ interface Window {
+ __deerflowNotifications?: Array<{ title: string; body?: string }>;
+ }
+}
+
+async function installNotificationMock(
+ page: Page,
+ initialPermission: NotificationPermission = "default",
+) {
+ await page.addInitScript((permission) => {
+ window.__deerflowNotifications = [];
+
+ class MockNotification {
+ static permission: NotificationPermission = permission;
+
+ static async requestPermission(): Promise {
+ MockNotification.permission = "granted";
+ return "granted";
+ }
+
+ onclick: (() => void) | null = null;
+ onerror: ((error: Event) => void) | null = null;
+ closed = false;
+
+ constructor(title: string, options?: NotificationOptions) {
+ window.__deerflowNotifications?.push({
+ title,
+ body: options?.body,
+ });
+ }
+
+ close() {
+ this.closed = true;
+ }
+ }
+
+ Object.defineProperty(window, "Notification", {
+ configurable: true,
+ value: MockNotification,
+ });
+ }, initialPermission);
+}
+
+async function openNotificationSettings(page: Page) {
+ await page.goto("/workspace/chats/new");
+ const sidebar = page.locator("[data-sidebar='sidebar']");
+ await sidebar.getByRole("button", { name: /Settings and more/ }).click();
+ await page.getByRole("menuitem", { name: "Settings" }).click();
+ const dialog = page.getByRole("dialog", { name: "Settings" });
+ await expect(dialog).toBeVisible();
+ await dialog.getByRole("button", { name: "Notification" }).click();
+ return dialog;
+}
+
+test.describe("Notification settings", () => {
+ test("can request permission and send the first test notification immediately", async ({
+ page,
+ }) => {
+ mockLangGraphAPI(page);
+ await installNotificationMock(page);
+
+ const dialog = await openNotificationSettings(page);
+ await dialog
+ .getByRole("button", { name: "Request notification permission" })
+ .click();
+
+ await expect(
+ dialog.getByRole("switch", { name: "Notification" }),
+ ).toBeChecked();
+
+ await dialog
+ .getByRole("button", { name: "Send test notification" })
+ .click();
+
+ await expect
+ .poll(() => page.evaluate(() => window.__deerflowNotifications ?? []))
+ .toEqual([
+ {
+ title: "DeerFlow",
+ body: "This is a test notification.",
+ },
+ ]);
+ });
+
+ test("sends a completion notification when chat finishes while the page is unfocused", async ({
+ page,
+ }) => {
+ mockLangGraphAPI(page);
+ await installNotificationMock(page, "granted");
+ await page.addInitScript(() => {
+ Document.prototype.hasFocus = () => false;
+ });
+
+ await page.goto("/workspace/chats/new");
+
+ const textarea = page.getByPlaceholder(/how can i assist you/i);
+ await expect(textarea).toBeVisible({ timeout: 15_000 });
+ await textarea.fill("Run a one minute task");
+ await textarea.press("Enter");
+
+ await expect
+ .poll(() => page.evaluate(() => window.__deerflowNotifications ?? []))
+ .toEqual([
+ {
+ title: "New Chat",
+ body: "Hello from DeerFlow!",
+ },
+ ]);
+ });
+});
diff --git a/frontend/tests/unit/core/notification/hooks.test.ts b/frontend/tests/unit/core/notification/hooks.test.ts
new file mode 100644
index 000000000..4833755d1
--- /dev/null
+++ b/frontend/tests/unit/core/notification/hooks.test.ts
@@ -0,0 +1,123 @@
+import { afterEach, describe, expect, test, rs } from "@rstest/core";
+
+type NotificationInstance = {
+ onclick: (() => void) | null;
+ onerror: ((error: Event) => void) | null;
+ close: () => void;
+};
+
+async function loadNotificationHook({
+ browserPermission = "granted",
+ hookPermission = "granted",
+}: {
+ browserPermission?: NotificationPermission;
+ hookPermission?: NotificationPermission;
+} = {}) {
+ const notifications: Array<{
+ title: string;
+ options: NotificationOptions | undefined;
+ instance: NotificationInstance;
+ }> = [];
+
+ class MockNotification implements NotificationInstance {
+ static permission: NotificationPermission = browserPermission;
+ static requestPermission = rs.fn(async () => {
+ MockNotification.permission = "granted";
+ return "granted" as const;
+ });
+
+ onclick: (() => void) | null = null;
+ onerror: ((error: Event) => void) | null = null;
+ close = rs.fn();
+
+ constructor(title: string, options?: NotificationOptions) {
+ notifications.push({ title, options, instance: this });
+ }
+ }
+
+ rs.resetModules();
+ rs.doMock("react", () => ({
+ useCallback: unknown>(callback: T) =>
+ callback,
+ useEffect: () => undefined,
+ useRef: (initialValue: T) => ({ current: initialValue }),
+ useState: (initialValue: T) => {
+ if (initialValue === "default") {
+ return [hookPermission, rs.fn()] as const;
+ }
+ if (initialValue === false) {
+ return [true, rs.fn()] as const;
+ }
+ return [initialValue, rs.fn()] as const;
+ },
+ }));
+ rs.doMock("@/core/settings", () => ({
+ useLocalSettings: () => [
+ {
+ notification: { enabled: true },
+ tokenUsage: { headerTotal: true, inlineMode: "per_turn" },
+ context: {
+ model_name: undefined,
+ mode: undefined,
+ reasoning_effort: undefined,
+ },
+ },
+ rs.fn(),
+ ],
+ }));
+ rs.stubGlobal("window", { focus: rs.fn() });
+ rs.stubGlobal("Notification", MockNotification);
+
+ const { useNotification } = await import("@/core/notification/hooks");
+
+ return {
+ notifications,
+ useNotification,
+ };
+}
+
+afterEach(() => {
+ rs.doUnmock("react");
+ rs.doUnmock("@/core/settings");
+ rs.unstubAllGlobals();
+ rs.resetModules();
+});
+
+describe("useNotification", () => {
+ test("allows the first notification immediately after the hook is created", async () => {
+ const { notifications, useNotification } = await loadNotificationHook();
+ const { showNotification } = useNotification();
+
+ showNotification("Finished", { body: "Conversation finished" });
+
+ expect(notifications).toHaveLength(1);
+ expect(notifications[0]?.title).toBe("Finished");
+ expect(notifications[0]?.options?.body).toBe("Conversation finished");
+ });
+
+ test("rate limits only after a notification has been sent", async () => {
+ const { notifications, useNotification } = await loadNotificationHook();
+ const { showNotification } = useNotification();
+
+ showNotification("First");
+ showNotification("Second");
+
+ expect(notifications.map((notification) => notification.title)).toEqual([
+ "First",
+ ]);
+ });
+
+ test("uses the browser's current permission when another hook requested it", async () => {
+ const { notifications, useNotification } = await loadNotificationHook({
+ browserPermission: "granted",
+ hookPermission: "default",
+ });
+ const { showNotification } = useNotification();
+
+ showNotification("Finished elsewhere");
+
+ expect(notifications.map((notification) => notification.title)).toEqual([
+ "Finished elsewhere",
+ ]);
+ });
+});