Compare commits

...

2 Commits

Author SHA1 Message Date
Jiayuan Zhang
a6c80d65d3 fix(notifications): fetch system_notifications pref lazily
Settings is the only mounted reader of notificationPreferenceOptions,
so a fresh app start (or any session that never visits Settings) left
the cache empty and the muted preference silently fell back to default
"all". Switch the inbox:new handler to ensureQueryData so the value is
fetched on first use and cached for subsequent events.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 16:59:01 +08:00
Jiayuan Zhang
56b71d62ce feat(notifications): add system notifications toggle in settings
Add a per-user, per-workspace toggle to enable/disable native OS
notification banners. Reuses the existing notification-preferences
endpoint by introducing a `system_notifications` key alongside the
inbox event groups; the realtime handler reads the cached preference
and skips desktopAPI.showNotification when muted.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-06 16:50:58 +08:00
6 changed files with 78 additions and 12 deletions

View File

@@ -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

View File

@@ -3,7 +3,8 @@ export type NotificationGroupKey =
| "status_changes"
| "comments"
| "updates"
| "agent_activity";
| "agent_activity"
| "system_notifications";
export type NotificationGroupValue = "all" | "muted";

View File

@@ -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": {

View File

@@ -64,6 +64,12 @@
"label": "智能体活动",
"description": "智能体 task 完成或失败时"
}
},
"system": {
"title": "系统通知",
"description": "控制 Multica 在后台时是否显示操作系统的原生通知横幅。",
"label": "显示系统通知",
"hint": "App 未获得焦点时,新的收件箱条目通过操作系统弹出通知横幅。"
}
},
"tokens": {

View File

@@ -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 (
<div className="space-y-4">
<div className="space-y-8">
<section className="space-y-4">
<div>
<h2 className="text-sm font-semibold">{t(($) => $.notifications.title)}</h2>
@@ -52,7 +57,7 @@ export function NotificationsTab() {
<Card>
<CardContent className="divide-y">
{NOTIFICATION_GROUP_KEYS.map((key) => {
{INBOX_GROUP_KEYS.map((key: InboxGroupKey) => {
const enabled = preferences[key] !== "muted";
return (
<div
@@ -75,6 +80,32 @@ export function NotificationsTab() {
</CardContent>
</Card>
</section>
<section className="space-y-4">
<div>
<h2 className="text-sm font-semibold">{t(($) => $.notifications.system.title)}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t(($) => $.notifications.system.description)}
</p>
</div>
<Card>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-0.5 pr-4">
<p className="text-sm font-medium">{t(($) => $.notifications.system.label)}</p>
<p className="text-xs text-muted-foreground">
{t(($) => $.notifications.system.hint)}
</p>
</div>
<Switch
checked={systemEnabled}
onCheckedChange={(checked) => handleToggle("system_notifications", checked)}
/>
</div>
</CardContent>
</Card>
</section>
</div>
);
}

View File

@@ -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.