diff --git a/packages/core/realtime/use-realtime-sync.ts b/packages/core/realtime/use-realtime-sync.ts index 1c5577bde7..b380642c40 100644 --- a/packages/core/realtime/use-realtime-sync.ts +++ b/packages/core/realtime/use-realtime-sync.ts @@ -28,6 +28,7 @@ import { } from "../issues/ws-updaters"; import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters"; import { inboxKeys } from "../inbox/queries"; +import { notificationPreferenceOptions } from "../notification-preferences/queries"; import { workspaceKeys, workspaceListOptions } from "../workspace/queries"; import { chatKeys } from "../chat/queries"; import { useChatStore } from "../chat"; @@ -268,7 +269,7 @@ export function useRealtimeSync( if (wsId) onIssueLabelsChanged(qc, wsId, issue_id, labels ?? []); }); - const unsubInboxNew = ws.on("inbox:new", (p) => { + const unsubInboxNew = ws.on("inbox:new", async (p) => { const { item } = p as InboxNewPayload; if (!item) return; const wsId = getCurrentWsId(); @@ -278,6 +279,22 @@ export function useRealtimeSync( // styling is enough — no need to interrupt with a banner. `desktopAPI` // is injected by the preload script; its absence (web app) skips silently. if (typeof document !== "undefined" && document.hasFocus()) return; + // Respect the user's system-notification preference. The Settings page + // owns the only `useQuery` for this resource, so on a fresh app start + // (or any session that hasn't visited Settings) the React Query cache + // is empty — using `getQueryData` would silently default to "all" and + // ignore the user's saved choice. `ensureQueryData` resolves to the + // cached value if present and otherwise fetches once, populating the + // cache for subsequent events. On network failure we fall through to + // the default ("all") rather than swallow the banner entirely. + if (wsId) { + try { + const prefData = await qc.ensureQueryData(notificationPreferenceOptions(wsId)); + if (prefData?.preferences?.system_notifications === "muted") return; + } catch { + // Fall through with default behavior. + } + } // Capture the source workspace slug at emit time. The user may switch // workspaces before clicking the banner (macOS Notification Center // holds banners), so routing must not read "current slug" at click diff --git a/packages/core/types/notification-preference.ts b/packages/core/types/notification-preference.ts index 36b08cc7b0..394eb3e8f8 100644 --- a/packages/core/types/notification-preference.ts +++ b/packages/core/types/notification-preference.ts @@ -3,7 +3,8 @@ export type NotificationGroupKey = | "status_changes" | "comments" | "updates" - | "agent_activity"; + | "agent_activity" + | "system_notifications"; export type NotificationGroupValue = "all" | "muted"; diff --git a/packages/views/locales/en/settings.json b/packages/views/locales/en/settings.json index dd1e14724b..1092481a7a 100644 --- a/packages/views/locales/en/settings.json +++ b/packages/views/locales/en/settings.json @@ -64,6 +64,12 @@ "label": "Agent activity", "description": "When an agent task completes or fails" } + }, + "system": { + "title": "System Notifications", + "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." } }, "tokens": { diff --git a/packages/views/locales/zh-Hans/settings.json b/packages/views/locales/zh-Hans/settings.json index 94298bc310..9d53269cc3 100644 --- a/packages/views/locales/zh-Hans/settings.json +++ b/packages/views/locales/zh-Hans/settings.json @@ -64,6 +64,12 @@ "label": "智能体活动", "description": "智能体 task 完成或失败时" } + }, + "system": { + "title": "系统通知", + "description": "控制 Multica 在后台时是否显示操作系统的原生通知横幅。", + "label": "显示系统通知", + "hint": "App 未获得焦点时,新的收件箱条目通过操作系统弹出通知横幅。" } }, "tokens": { diff --git a/packages/views/settings/components/notifications-tab.tsx b/packages/views/settings/components/notifications-tab.tsx index 06273ba5ea..a2e7698328 100644 --- a/packages/views/settings/components/notifications-tab.tsx +++ b/packages/views/settings/components/notifications-tab.tsx @@ -10,13 +10,16 @@ import { Switch } from "@multica/ui/components/ui/switch"; import { toast } from "sonner"; import { useT } from "../../i18n"; -const NOTIFICATION_GROUP_KEYS: NotificationGroupKey[] = [ +// Inbox event groups rendered in the per-event toggle list. `system_notifications` +// is a sibling preference key but lives in its own section below. +const INBOX_GROUP_KEYS = [ "assignments", "status_changes", "comments", "updates", "agent_activity", -]; +] as const; +type InboxGroupKey = (typeof INBOX_GROUP_KEYS)[number]; export function NotificationsTab() { const { t } = useT("settings"); @@ -40,8 +43,10 @@ export function NotificationsTab() { }); }; + const systemEnabled = preferences.system_notifications !== "muted"; + return ( -
+

{t(($) => $.notifications.title)}

@@ -52,7 +57,7 @@ export function NotificationsTab() { - {NOTIFICATION_GROUP_KEYS.map((key) => { + {INBOX_GROUP_KEYS.map((key: InboxGroupKey) => { const enabled = preferences[key] !== "muted"; return (
+ +
+
+

{t(($) => $.notifications.system.title)}

+

+ {t(($) => $.notifications.system.description)} +

+
+ + + +
+
+

{t(($) => $.notifications.system.label)}

+

+ {t(($) => $.notifications.system.hint)} +

+
+ handleToggle("system_notifications", checked)} + /> +
+
+
+
); } diff --git a/server/internal/handler/notification_preference.go b/server/internal/handler/notification_preference.go index 8067c953e9..3cc5669402 100644 --- a/server/internal/handler/notification_preference.go +++ b/server/internal/handler/notification_preference.go @@ -12,13 +12,18 @@ import ( ) // validNotifGroups is the set of notification preference group keys that the -// API accepts. Keys not in this set are rejected. +// API accepts. Keys not in this set are rejected. `system_notifications` is +// not an inbox event group — it's a delivery-channel toggle controlling +// whether native OS notification banners fire — but it shares the same +// preferences map so a single endpoint covers all user notification +// preferences. var validNotifGroups = map[string]bool{ - "assignments": true, - "status_changes": true, - "comments": true, - "updates": true, - "agent_activity": true, + "assignments": true, + "status_changes": true, + "comments": true, + "updates": true, + "agent_activity": true, + "system_notifications": true, } // validNotifValues is the set of allowed preference values per group.