Compare commits

...

1 Commits

Author SHA1 Message Date
J
3191d93e09 feat(web): native notification banners for the web app (MUL-3116)
The in-app inbox (sidebar badge, real-time WS updates, settings, inbox
page) was already shared and worked on web. The only Desktop-only piece
was the native OS banner: handleInboxNew called desktopAPI.showNotification,
which is undefined on web, so no banner fired for new inbox items while the
app was unfocused.

Add the browser equivalent, keeping handleInboxNew as the single decision
point (focus + source-workspace mute gating stays shared with desktop):

- packages/core/platform/system-notification.ts: browser Notification engine
  (showWebNotification) + permission helpers + a click-handler registry. Lives
  in core (the caller does) but injects the click-routing decision so core
  stays headless.
- handleInboxNew: branch desktopAPI (unchanged) → else showWebNotification.
- apps/web WebNotificationBridge: registers click routing to the source
  workspace's inbox (?issue=…), mirroring desktop's DesktopInboxBridge.
- Settings → Notifications: web-only opt-in to grant browser permission
  (hidden on desktop / where the API is unavailable); en/zh-Hans/ja/ko.

Permission is an explicit settings opt-in (no auto-prompt on load, per
browser best practice). Tests cover the engine and the web path in
handleInboxNew.

Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 13:59:44 +08:00
13 changed files with 523 additions and 16 deletions

View File

@@ -4,6 +4,7 @@ import { DashboardLayout } from "@multica/views/layout";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { WebNotificationBridge } from "@/components/web-notification-bridge";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
@@ -15,6 +16,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<SearchCommand />
<ChatWindow />
<ChatFab />
<WebNotificationBridge />
</>
}
>

View File

@@ -0,0 +1,44 @@
"use client";
import { useEffect, useRef } from "react";
import {
registerSystemNotificationClickHandler,
type SystemNotificationPayload,
} from "@multica/core/platform";
import { paths } from "@multica/core/paths";
import { useNavigation } from "@multica/views/navigation";
/**
* Routes browser notification clicks to the source workspace's inbox, focused
* on the clicked item. The web counterpart of the desktop `DesktopInboxBridge`:
* desktop receives the click via Electron IPC, web wires it through the
* Notification API's `onclick` (registered here into the core singleton).
*
* The route uses the `slug` the notification was emitted with — the SOURCE
* workspace — not the active one, so a click always opens the right inbox even
* after the user switches workspaces (#3766). An empty slug (unresolved
* source) is ignored. Marking the row read is handled by InboxPage's
* selected-item effect, which covers the `?issue=` URL-param path.
*/
export function WebNotificationBridge() {
const { push } = useNavigation();
// The adapter identity changes with the current route; the ref keeps the
// registered click handler stable while always calling the latest push.
const pushRef = useRef(push);
useEffect(() => {
pushRef.current = push;
}, [push]);
useEffect(() => {
registerSystemNotificationClickHandler(
({ slug, issueKey }: SystemNotificationPayload) => {
if (!slug) return;
const inboxPath = `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(issueKey)}`;
pushRef.current(inboxPath);
},
);
return () => registerSystemNotificationClickHandler(null);
}, []);
return null;
}

View File

@@ -6,3 +6,12 @@ export { createPersistStorage } from "./persist-storage";
export { createWorkspaceAwareStorage, setCurrentWorkspace, getCurrentSlug, getCurrentWsId, subscribeToCurrentSlug, registerForWorkspaceRehydration } from "./workspace-storage";
export { clearWorkspaceStorage } from "./storage-cleanup";
export { isMac, modKey, enterKey, formatShortcut } from "./keyboard";
export {
registerSystemNotificationClickHandler,
isWebNotificationSupported,
getWebNotificationPermission,
requestWebNotificationPermission,
showWebNotification,
type SystemNotificationPayload,
type WebNotificationPermission,
} from "./system-notification";

View File

@@ -0,0 +1,142 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getWebNotificationPermission,
isWebNotificationSupported,
registerSystemNotificationClickHandler,
requestWebNotificationPermission,
showWebNotification,
type SystemNotificationPayload,
} from "./system-notification";
// The core test environment is Node — `window`/`Notification` don't exist.
// These tests install a minimal `window.Notification` stub on `globalThis`
// (the same surface `getNotificationCtor` reads) to exercise the browser path.
type Created = { title: string; options?: NotificationOptions; instance: FakeNotification };
class FakeNotification {
static permission: NotificationPermission = "granted";
static requestPermission = vi.fn(async () => FakeNotification.permission);
onclick: (() => void) | null = null;
close = vi.fn();
constructor(
public title: string,
public options?: NotificationOptions,
) {
created.push({ title, options, instance: this });
}
}
let created: Created[] = [];
function installWindow(
ctor: unknown,
focus: () => void = () => {},
): void {
(globalThis as Record<string, unknown>).window = { Notification: ctor, focus };
}
function payload(
overrides: Partial<SystemNotificationPayload> = {},
): SystemNotificationPayload {
return {
slug: "workspace-a",
itemId: "item-1",
issueKey: "issue-1",
title: "Mentioned you",
body: "in a comment",
...overrides,
};
}
afterEach(() => {
created = [];
FakeNotification.permission = "granted";
FakeNotification.requestPermission.mockClear();
registerSystemNotificationClickHandler(null);
delete (globalThis as Record<string, unknown>).window;
});
describe("isWebNotificationSupported / getWebNotificationPermission", () => {
it("reports unsupported when there is no window", () => {
expect(isWebNotificationSupported()).toBe(false);
expect(getWebNotificationPermission()).toBe("unsupported");
});
it("reflects the browser's current permission when supported", () => {
installWindow(FakeNotification);
FakeNotification.permission = "denied";
expect(isWebNotificationSupported()).toBe(true);
expect(getWebNotificationPermission()).toBe("denied");
});
});
describe("requestWebNotificationPermission", () => {
it("returns 'unsupported' without the API", async () => {
await expect(requestWebNotificationPermission()).resolves.toBe("unsupported");
});
it("prompts only when permission is 'default'", async () => {
installWindow(FakeNotification);
FakeNotification.permission = "default";
FakeNotification.requestPermission.mockResolvedValueOnce("granted");
await expect(requestWebNotificationPermission()).resolves.toBe("granted");
expect(FakeNotification.requestPermission).toHaveBeenCalledTimes(1);
});
it("does not re-prompt once already decided", async () => {
installWindow(FakeNotification);
FakeNotification.permission = "denied";
await expect(requestWebNotificationPermission()).resolves.toBe("denied");
expect(FakeNotification.requestPermission).not.toHaveBeenCalled();
});
});
describe("showWebNotification", () => {
it("does nothing when the API is unavailable", () => {
expect(() => showWebNotification(payload())).not.toThrow();
expect(created).toHaveLength(0);
});
it("does nothing unless permission is granted", () => {
installWindow(FakeNotification);
FakeNotification.permission = "default";
showWebNotification(payload());
expect(created).toHaveLength(0);
});
it("shows a banner with body + a dedup tag when granted", () => {
installWindow(FakeNotification);
showWebNotification(payload());
expect(created).toHaveLength(1);
expect(created[0]?.title).toBe("Mentioned you");
expect(created[0]?.options).toMatchObject({ body: "in a comment", tag: "item-1" });
});
it("routes a click to the registered handler and closes the banner", () => {
const focus = vi.fn();
installWindow(FakeNotification, focus);
const onClick = vi.fn();
registerSystemNotificationClickHandler(onClick);
showWebNotification(payload());
created[0]?.instance.onclick?.();
expect(focus).toHaveBeenCalledTimes(1);
expect(created[0]?.instance.close).toHaveBeenCalledTimes(1);
expect(onClick).toHaveBeenCalledWith(payload());
});
it("swallows constructors that throw (e.g. service-worker-only engines)", () => {
class ThrowingNotification {
static permission: NotificationPermission = "granted";
constructor() {
throw new Error("requires a ServiceWorkerRegistration");
}
}
installWindow(ThrowingNotification);
expect(() => showWebNotification(payload())).not.toThrow();
});
});

View File

@@ -0,0 +1,123 @@
"use client";
// Native system notification bridge for the WEB app.
//
// The desktop app renders OS banners through its Electron main process
// (`window.desktopAPI.showNotification`); the web app has no such bridge, so it
// uses the browser Notification API here. `handleInboxNew` (realtime sync) is
// the single decision point — it already gates on focus + the source
// workspace's mute preference — and calls `showWebNotification` as the web path
// when no `desktopAPI` is present.
//
// Lives in `packages/core` (not `packages/views`) because the caller
// (`handleInboxNew`) lives in core and `views` cannot be imported from core
// (dependency direction is views → core). The click-routing decision, which
// needs the app router, is injected by the host app via
// `registerSystemNotificationClickHandler` instead — core stays headless.
export interface SystemNotificationPayload {
/**
* Source workspace slug. Empty when it couldn't be resolved — the click is
* then a no-op rather than routing to the wrong workspace (#3766).
*/
slug: string;
/** Inbox row id — lets the click mark the item read. */
itemId: string;
/** `?issue=<…>` selector for the inbox page (issue id, else the item id). */
issueKey: string;
title: string;
body: string;
}
type ClickHandler = (payload: SystemNotificationPayload) => void;
// Module-level singleton — mirrors how the desktop preload registers its
// behavior once at boot. The web shell registers a router-aware handler; while
// unregistered (SSR, tests, pre-mount) a click is a silent no-op.
let clickHandler: ClickHandler | null = null;
/**
* Register how a clicked web notification routes (focus + navigate to the
* source workspace's inbox, focused on the item). Called once by the web app
* shell; pass `null` to unregister. Desktop does NOT use this — it routes
* through its own Electron IPC bridge (`onInboxOpen`).
*/
export function registerSystemNotificationClickHandler(
handler: ClickHandler | null,
): void {
clickHandler = handler;
}
// Read the Notification constructor off `window` (rather than the bare global)
// so it's both SSR-safe and injectable from the core Node test environment,
// where `window`/`Notification` don't exist by default.
function getNotificationCtor(): typeof Notification | null {
if (typeof window === "undefined") return null;
const ctor = (window as { Notification?: typeof Notification }).Notification;
return typeof ctor === "function" ? ctor : null;
}
/** True when the browser exposes the Notification API (false on SSR / old engines). */
export function isWebNotificationSupported(): boolean {
return getNotificationCtor() !== null;
}
export type WebNotificationPermission = NotificationPermission | "unsupported";
/** Current permission, or "unsupported" when the API is unavailable. */
export function getWebNotificationPermission(): WebNotificationPermission {
const ctor = getNotificationCtor();
return ctor ? ctor.permission : "unsupported";
}
/**
* Prompt for notification permission. Resolves to the resulting permission, or
* "unsupported". Only "default" triggers the browser prompt — an
* already-decided permission (granted/denied) is returned as-is.
*/
export async function requestWebNotificationPermission(): Promise<WebNotificationPermission> {
const ctor = getNotificationCtor();
if (!ctor) return "unsupported";
if (ctor.permission !== "default") return ctor.permission;
try {
return await ctor.requestPermission();
} catch {
// Older Safari used a callback signature and can reject the promise form.
return ctor.permission;
}
}
/**
* Show a native browser notification for a new inbox item. No-op unless the
* Notification API is supported AND permission is "granted" — the caller
* (`handleInboxNew`) owns the WHETHER (focus + mute gating); this owns only the
* rendering. Clicking the banner focuses the tab and routes via the registered
* click handler.
*/
export function showWebNotification(payload: SystemNotificationPayload): void {
const ctor = getNotificationCtor();
if (!ctor || ctor.permission !== "granted") return;
let notification: Notification;
try {
notification = new ctor(payload.title, {
body: payload.body,
// Collapse repeat banners for the same inbox row (e.g. a reconnect
// replays the `inbox:new` event).
tag: payload.itemId,
});
} catch {
// Some engines require an active ServiceWorkerRegistration to construct a
// Notification (notably Chrome on Android). Degrade silently — the in-app
// inbox and unread badge still surface the new item.
return;
}
notification.onclick = () => {
try {
window.focus();
} catch {
// Best-effort; some browsers disallow programmatic focus.
}
notification.close();
clickHandler?.(payload);
};
}

View File

@@ -468,4 +468,74 @@ describe("handleInboxNew", () => {
expect.objectContaining({ slug: "" }),
);
});
// --- Web path: no desktopAPI → the browser Notification API ---
// Same focus/mute gating as desktop, but the desktop bridge is absent and a
// granted browser Notification stub is installed on `window`.
let webBanners: { title: string; options?: NotificationOptions }[] = [];
class FakeNotification {
static permission: NotificationPermission = "granted";
onclick: (() => void) | null = null;
close = vi.fn();
constructor(
public title: string,
public options?: NotificationOptions,
) {
webBanners.push({ title, options });
}
}
function installBrowserNotification(
permission: NotificationPermission = "granted",
) {
webBanners = [];
FakeNotification.permission = permission;
(globalThis as Record<string, unknown>).window = {
Notification: FakeNotification,
focus: vi.fn(),
};
}
afterEach(() => {
delete (globalThis as Record<string, unknown>).window;
});
it("shows a browser banner on web (no desktopAPI) when granted and not muted", async () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [workspace()]);
qc.setQueryData(notificationPreferenceKeys.all("ws-a"), {
preferences: { system_notifications: "all" },
});
installBrowserNotification("granted");
await handleInboxNew(qc, inboxItem());
expect(webBanners).toHaveLength(1);
expect(webBanners[0]?.title).toBe("Mentioned you");
});
it("shows no browser banner when the SOURCE workspace is muted", async () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [workspace()]);
qc.setQueryData(notificationPreferenceKeys.all("ws-a"), {
preferences: { system_notifications: "muted" },
});
installBrowserNotification("granted");
await handleInboxNew(qc, inboxItem());
expect(webBanners).toHaveLength(0);
});
it("shows no browser banner when permission is not granted", async () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [workspace()]);
qc.setQueryData(notificationPreferenceKeys.all("ws-a"), {
preferences: { system_notifications: "all" },
});
installBrowserNotification("default");
await handleInboxNew(qc, inboxItem());
expect(webBanners).toHaveLength(0);
});
});

View File

@@ -37,6 +37,10 @@ import {
notificationPreferenceKeys,
} from "../notification-preferences/queries";
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
import {
showWebNotification,
type SystemNotificationPayload,
} from "../platform/system-notification";
import type { Workspace } from "../types/workspace";
import { chatKeys } from "../chat/queries";
import { useChatStore } from "../chat";
@@ -254,33 +258,35 @@ export async function handleInboxNew(
// Fall through with default behavior.
}
}
const desktopAPI = (
globalThis as unknown as {
desktopAPI?: {
showNotification?: (payload: {
slug: string;
itemId: string;
issueKey: string;
title: string;
body: string;
}) => void;
};
}
).desktopAPI;
// `issueKey` matches the inbox page's URL selector (issue id when the
// item is attached to an issue, otherwise the inbox item id). `itemId`
// is the inbox row's own id, needed to fire markInboxRead on click.
// A null slug (workspace list unavailable / item from a workspace this
// client can't see) still shows the banner — the user should learn about
// the inbox item — but with an empty slug so the click is a no-op
// (DesktopInboxBridge ignores empty slugs) instead of routing wrong.
desktopAPI?.showNotification?.({
// (the inbox bridge ignores empty slugs) instead of routing wrong.
const payload: SystemNotificationPayload = {
slug: slug ?? "",
itemId: item.id,
issueKey: item.issue_id ?? item.id,
title: item.title,
body: item.body ?? "",
});
};
const desktopAPI = (
globalThis as unknown as {
desktopAPI?: {
showNotification?: (payload: SystemNotificationPayload) => void;
};
}
).desktopAPI;
if (desktopAPI?.showNotification) {
// Desktop: native OS banner rendered by the Electron main process.
desktopAPI.showNotification(payload);
return;
}
// Web: the browser Notification API. No-op without granted permission or on
// SSR — the in-app inbox + unread badge still reflect the new item.
showWebNotification(payload);
}
/**

View File

@@ -84,6 +84,14 @@
"description": "Control native OS notification banners shown when Multica is in the background.",
"label": "Show system notifications",
"hint": "Show a banner from your operating system for new inbox items when the app isn't focused."
},
"browser": {
"label": "Browser notifications",
"hint": "Allow this browser to show notification banners for new inbox items.",
"enable": "Enable",
"granted": "This browser can show notification banners.",
"denied": "Blocked by your browser. Enable notifications for this site in your browser settings.",
"enabled_badge": "Enabled"
}
},
"tokens": {

View File

@@ -84,6 +84,14 @@
"description": "Multica がバックグラウンドにあるときに表示される OS のネイティブ通知バナーを制御します。",
"label": "システム通知を表示",
"hint": "アプリにフォーカスがないときに、新しいインボックス項目について OS のバナーを表示します。"
},
"browser": {
"label": "ブラウザ通知",
"hint": "このブラウザで新しいインボックス項目の通知バナーを表示できるようにします。",
"enable": "有効にする",
"granted": "このブラウザは通知バナーを表示できます。",
"denied": "ブラウザによってブロックされています。ブラウザの設定でこのサイトの通知を許可してください。",
"enabled_badge": "有効"
}
},
"tokens": {

View File

@@ -84,6 +84,14 @@
"description": "Multica가 백그라운드에 있을 때 표시되는 운영체제 알림 배너를 제어합니다.",
"label": "시스템 알림 표시",
"hint": "앱에 포커스가 없을 때 새 인박스 항목에 대해 운영체제 배너를 표시합니다."
},
"browser": {
"label": "브라우저 알림",
"hint": "이 브라우저에서 새 인박스 항목에 대한 알림 배너를 표시하도록 허용합니다.",
"enable": "사용",
"granted": "이 브라우저에서 알림 배너를 표시할 수 있습니다.",
"denied": "브라우저에서 차단되었습니다. 브라우저 설정에서 이 사이트의 알림을 허용하세요.",
"enabled_badge": "사용 중"
}
},
"tokens": {

View File

@@ -84,6 +84,14 @@
"description": "控制 Multica 在后台时是否显示操作系统的原生通知横幅。",
"label": "显示系统通知",
"hint": "App 未获得焦点时,新的收件箱条目通过操作系统弹出通知横幅。"
},
"browser": {
"label": "浏览器通知",
"hint": "允许当前浏览器为新的收件箱条目弹出通知横幅。",
"enable": "启用",
"granted": "当前浏览器可以弹出通知横幅。",
"denied": "已被浏览器拦截。请在浏览器设置中为本站点开启通知权限。",
"enabled_badge": "已启用"
}
},
"tokens": {

View File

@@ -0,0 +1,74 @@
"use client";
import { useEffect, useState } from "react";
import {
getWebNotificationPermission,
isWebNotificationSupported,
requestWebNotificationPermission,
type WebNotificationPermission,
} from "@multica/core/platform";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { isDesktopShell } from "../../platform";
import { useT } from "../../i18n";
/**
* Web-only control for the browser permission that native notification banners
* require. Desktop delivers banners through the OS via Electron (no browser
* permission involved), so this renders nothing there. It also renders nothing
* when the Notification API is unavailable (SSR, older browsers).
*
* Capability and permission are read from `window`, so the first paint defers
* to a post-mount effect to keep SSR and client markup identical (no hydration
* mismatch).
*/
export function BrowserNotificationSetting() {
const { t } = useT("settings");
const [mounted, setMounted] = useState(false);
const [permission, setPermission] =
useState<WebNotificationPermission>("default");
useEffect(() => {
setMounted(true);
setPermission(getWebNotificationPermission());
}, []);
// Pre-mount, on desktop, or where the API is missing → nothing to manage.
if (!mounted || isDesktopShell() || !isWebNotificationSupported()) return null;
const handleEnable = async () => {
setPermission(await requestWebNotificationPermission());
};
const statusHint =
permission === "granted"
? t(($) => $.notifications.browser.granted)
: permission === "denied"
? t(($) => $.notifications.browser.denied)
: t(($) => $.notifications.browser.hint);
return (
<Card>
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5 pr-4">
<p className="text-sm font-medium">
{t(($) => $.notifications.browser.label)}
</p>
<p className="text-xs text-muted-foreground">{statusHint}</p>
</div>
{permission === "default" && (
<Button size="sm" variant="outline" onClick={handleEnable}>
{t(($) => $.notifications.browser.enable)}
</Button>
)}
{permission === "granted" && (
<span className="shrink-0 text-xs font-medium text-muted-foreground">
{t(($) => $.notifications.browser.enabled_badge)}
</span>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -9,6 +9,7 @@ import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Switch } from "@multica/ui/components/ui/switch";
import { toast } from "sonner";
import { useT } from "../../i18n";
import { BrowserNotificationSetting } from "./browser-notification-setting";
// Inbox event groups rendered in the per-event toggle list. `system_notifications`
// is a sibling preference key but lives in its own section below.
@@ -110,6 +111,10 @@ export function NotificationsTab() {
</div>
</CardContent>
</Card>
{/* Web-only: the browser permission banners require. Renders nothing on
desktop (OS-native delivery) or where the Notification API is absent. */}
<BrowserNotificationSetting />
</section>
</div>
);