feat(notifications): webview notifications end-to-end (Phase 7) (#741)

* wip(notifications): core module skeleton + controller registration

Phase 7 PR #714 work-in-progress. Shipped:
- src/openhuman/webview_notifications/ — mod, types, dispatch, bus, schemas
- pub mod declaration in src/openhuman/mod.rs
- controllers entry in src/core/all.rs

Remaining: schemas line in all.rs, Tauri shell edits (OpenHuman: prefix,
feature flag gate, click event emit), new shell module for feature flag
state + commands, frontend app/src/lib/webviewNotifications/ IPC wrapper,
Redux click handler.

* feat(notifications): register webview_notifications schemas in core registry

Adds the schemas line next to the already-registered controllers entry so
the domain participates in declared-schema validation. v1 controller list
is empty (settings live shell-side), but the wiring is in place for
future additions.

* feat(notifications): add OpenHuman: prefix, feature flag, and click-routing event

Updates the CEF notification hook in webview_accounts to:
- Prefix OS toast titles with "OpenHuman:" so they visually disambiguate
  from natively installed apps (Slack, Gmail, Discord desktop) firing the
  same DM twice.
- Gate delivery on a new shell-side feature flag (off by default).
- Mirror each fire to the React frontend via a `webview-notification:fired`
  Tauri event carrying {account_id, provider, title, body, tag}, so the
  UI can route click-to-focus back to the originating webview.

Adds a new notification_settings shell module holding the runtime toggle
as an AtomicBool (lock-free read from the CEF callback thread) plus
notification_settings_{get,set} Tauri commands for the settings UI to
flip the flag. State lives shell-side rather than in the core sidecar so
the toggle doesn't require a JSON-RPC round-trip.

* feat(notifications): frontend IPC wrapper + Redux click routing

Mirrors the Rust-side `webview-notification:fired` Tauri event into
Redux so the UI can:

- bump an unread badge on the originating account (sidebar surface
  reads `accounts.unread[accountId]`)
- route a subsequent click intent back to the right embedded webview
  via `focusAccountFromNotification`, which sets `activeAccountId`
  and clears the unread counter in one action

Plumbing:
- `app/src/lib/webviewNotifications/` — typed subscribe/unsubscribe,
  idempotent `started` guard mirroring `webviewAccountService.ts`,
  `handleNotificationClick(accountId)` as the public click entrypoint
  so in-app toast UI or a future OS-notification click hook share one
  Redux intent
- `accountsSlice`: `noteWebviewNotificationFired` and
  `focusAccountFromNotification` reducers (both no-op for unknown
  account ids — guards against stale payloads from a closed webview)
- `App.tsx`: start the service at boot, right next to the existing
  `startWebviewAccountService()` call

Tests:
- `accountsSlice.webviewNotifications.test.ts` — reducer behavior
- `service.test.ts` — fired dispatches + click focuses via real store

Also: `cargo fmt` noise fixups on `src/core/all.rs` and
`src/openhuman/webview_notifications/mod.rs` so the branch passes
`cargo fmt --check` in CI.

---------

Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
This commit is contained in:
Jwalin Shah
2026-04-21 22:25:12 -07:00
committed by GitHub
co-authored by Jwalin Shah
parent 1b6a16f1ab
commit 4a5a00cb96
17 changed files with 529 additions and 7 deletions
+5 -1
View File
@@ -3,6 +3,7 @@ compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supp
mod core_process;
mod core_update;
mod notification_settings;
#[cfg(feature = "cef")]
mod discord_scanner;
#[cfg(feature = "cef")]
@@ -574,7 +575,8 @@ pub fn run() {
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.manage(DictationHotkeyState(Mutex::new(Vec::new())))
.manage(webview_accounts::WebviewAccountsState::default());
.manage(webview_accounts::WebviewAccountsState::default())
.manage(notification_settings::NotificationSettingsState::new());
#[cfg(feature = "cef")]
let builder = builder.manage(std::sync::Arc::new(imessage_scanner::ScannerRegistry::new()));
let builder = builder.manage(whatsapp_scanner::ScannerRegistry::new());
@@ -884,6 +886,8 @@ pub fn run() {
webview_accounts::webview_account_show,
webview_accounts::webview_recipe_event,
webview_accounts::webview_account_eval,
notification_settings::notification_settings_get,
notification_settings::notification_settings_set,
activate_main_window
])
.build(tauri::generate_context!())
@@ -0,0 +1,83 @@
//! Shell-side runtime toggle for webview-originated OS notifications.
//!
//! The embedded webviews (Slack, Gmail, Discord, …) can fire native OS
//! notifications via the CEF IPC hook in `webview_accounts`. This domain
//! owns the on/off switch: OFF by default so v1 ships the plumbing
//! without producing a toast storm the first time someone opens a busy
//! Slack tab.
//!
//! State lives in the Tauri shell rather than the core sidecar so the
//! settings UI can flip it without a JSON-RPC round-trip. Persistence is
//! frontend-side (Redux/localStorage) — on boot the React side reads its
//! persisted value and pushes it down via `notification_settings_set`.
use std::sync::atomic::{AtomicBool, Ordering};
use serde::{Deserialize, Serialize};
/// Tauri-managed state holding the current feature-flag value.
///
/// Wrapped in an `AtomicBool` so reads from the CEF notification
/// callback (which runs on a CEF thread, not the Tauri runtime thread)
/// stay lock-free.
pub struct NotificationSettingsState {
enabled: AtomicBool,
}
impl NotificationSettingsState {
/// Construct the initial state — v1 defaults to **disabled**.
pub fn new() -> Self {
Self {
enabled: AtomicBool::new(false),
}
}
/// Current feature-flag value.
pub fn enabled(&self) -> bool {
self.enabled.load(Ordering::Relaxed)
}
/// Update the feature-flag value. Returns the previous value so
/// callers can log a single line about the transition if they want.
pub fn set_enabled(&self, value: bool) -> bool {
self.enabled.swap(value, Ordering::Relaxed)
}
}
impl Default for NotificationSettingsState {
fn default() -> Self {
Self::new()
}
}
/// Payload returned to the frontend.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct NotificationSettingsPayload {
pub enabled: bool,
}
/// Read the current notification feature-flag value.
#[tauri::command]
pub fn notification_settings_get(
state: tauri::State<'_, NotificationSettingsState>,
) -> NotificationSettingsPayload {
NotificationSettingsPayload {
enabled: state.enabled(),
}
}
/// Update the current notification feature-flag value. Returns the new
/// value so the caller can round-trip confirm.
#[tauri::command]
pub fn notification_settings_set(
state: tauri::State<'_, NotificationSettingsState>,
enabled: bool,
) -> NotificationSettingsPayload {
let prev = state.set_enabled(enabled);
log::info!(
"[notify-settings] feature-flag transition enabled={} (was {})",
enabled,
prev
);
NotificationSettingsPayload { enabled }
}
+63 -6
View File
@@ -229,10 +229,36 @@ pub struct WebviewAccountsState {
browser_ids: Mutex<HashMap<String, i32>>,
}
/// Title prefix applied to every OS toast fired from an embedded webview.
/// Matches `openhuman_core::webview_notifications::OPENHUMAN_TITLE_PREFIX`
/// — kept inline here so the shell crate doesn't take a build-time dep on
/// the core library. Disambiguates from natively-installed apps (Slack,
/// Discord, Gmail desktop) firing the same message twice.
#[cfg(feature = "cef")]
const OPENHUMAN_TITLE_PREFIX: &str = "OpenHuman: ";
/// Serialised fire-event payload shipped to the frontend over the
/// `webview-notification:fired` Tauri event. Carries `account_id` +
/// `provider` so the React side can route a subsequent click back to
/// the originating webview via Redux.
#[cfg(feature = "cef")]
#[derive(Debug, Clone, Serialize)]
struct WebviewNotificationFired {
account_id: String,
provider: String,
title: String,
body: String,
#[serde(skip_serializing_if = "Option::is_none")]
tag: Option<String>,
}
/// Translate a `tauri-runtime-cef` notification payload into a native OS
/// toast via `tauri-plugin-notification`. Title is prefixed with the
/// human-readable provider label so a glance tells the user which webview
/// fired the ping.
/// toast via `tauri-plugin-notification`, and mirror the fire to the
/// React frontend so it can drive click-to-focus routing.
///
/// Gated on the runtime `NotificationSettings` flag (OFF by default) so
/// v1 ships the plumbing without surprising users with a toast storm the
/// first time they open a busy Slack tab.
#[cfg(feature = "cef")]
fn forward_native_notification<R: Runtime>(
app: &AppHandle<R>,
@@ -240,12 +266,25 @@ fn forward_native_notification<R: Runtime>(
provider: &str,
payload: &tauri_runtime_cef::notification::NotificationPayload,
) {
// Feature flag — bail early when the user hasn't opted in.
if let Some(settings) = app.try_state::<crate::notification_settings::NotificationSettingsState>()
{
if !settings.enabled() {
log::debug!(
"[notify-cef][{}] suppressed (feature flag off) provider={}",
account_id,
provider
);
return;
}
}
let provider_label = provider_display_name(provider);
let raw_title = payload.title.as_str();
let raw_title = payload.title.as_str().trim();
let notify_title = if raw_title.is_empty() {
provider_label.to_string()
format!("{OPENHUMAN_TITLE_PREFIX}{provider_label}")
} else {
format!("{}{}", provider_label, raw_title)
format!("{OPENHUMAN_TITLE_PREFIX}{provider_label}{raw_title}")
};
let body = payload.body.as_deref().unwrap_or("");
log::info!(
@@ -257,6 +296,24 @@ fn forward_native_notification<R: Runtime>(
raw_title,
body.chars().count()
);
// Mirror to the frontend BEFORE firing the OS toast so the Redux
// store has the routing context ready by the time the user clicks.
let fired = WebviewNotificationFired {
account_id: account_id.to_string(),
provider: provider.to_string(),
title: notify_title.clone(),
body: body.to_string(),
tag: payload.tag.clone(),
};
if let Err(err) = app.emit("webview-notification:fired", &fired) {
log::warn!(
"[notify-cef][{}] emit webview-notification:fired failed: {}",
account_id,
err
);
}
let mut builder = app.notification().builder().title(&notify_title);
if !body.is_empty() {
builder = builder.body(body);
+2
View File
@@ -13,6 +13,7 @@ import MeshGradient from './components/MeshGradient';
import OnboardingOverlay from './components/OnboardingOverlay';
import RouteLoadingScreen from './components/RouteLoadingScreen';
import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner';
import { startWebviewNotificationsService } from './lib/webviewNotifications';
import ChatRuntimeProvider from './providers/ChatRuntimeProvider';
import CoreStateProvider from './providers/CoreStateProvider';
import SocketProvider from './providers/SocketProvider';
@@ -27,6 +28,7 @@ import { isAccountsFullscreen } from './utils/accountsFullscreen';
// are handled even when the user hasn't navigated to /accounts yet.
// Idempotent — the service uses a `started` singleton guard.
startWebviewAccountService();
startWebviewNotificationsService();
function App() {
return (
@@ -0,0 +1,6 @@
export {
handleNotificationClick,
startWebviewNotificationsService,
stopWebviewNotificationsService,
} from './service';
export { WEBVIEW_NOTIFICATION_FIRED_EVENT, type WebviewNotificationFired } from './types';
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { store } from '../../store';
import { addAccount } from '../../store/accountsSlice';
import { __handleFiredForTests, __resetForTests, handleNotificationClick } from './service';
const sampleAccount = {
id: 'acct1',
provider: 'slack' as const,
label: 'Slack',
createdAt: '2026-01-01T00:00:00Z',
status: 'open' as const,
};
describe('webviewNotifications service', () => {
beforeEach(() => {
__resetForTests();
store.dispatch(addAccount(sampleAccount));
});
afterEach(() => {
vi.restoreAllMocks();
});
it('fired events increment unread via Redux', () => {
const before = store.getState().accounts.unread.acct1 ?? 0;
__handleFiredForTests({
account_id: 'acct1',
provider: 'slack',
title: 'OpenHuman: Slack — Ping',
body: 'hi',
tag: null,
});
const after = store.getState().accounts.unread.acct1 ?? 0;
expect(after).toBe(before + 1);
});
it('handleNotificationClick focuses account and clears unread', () => {
__handleFiredForTests({
account_id: 'acct1',
provider: 'slack',
title: 'OpenHuman: Slack — Ping',
body: '',
});
expect(store.getState().accounts.unread.acct1).toBeGreaterThan(0);
handleNotificationClick('acct1');
expect(store.getState().accounts.activeAccountId).toBe('acct1');
expect(store.getState().accounts.unread.acct1).toBe(0);
});
it('fired events for unknown accounts are no-ops', () => {
__handleFiredForTests({ account_id: 'ghost', provider: 'slack', title: 't', body: 'b' });
expect(store.getState().accounts.unread.ghost).toBeUndefined();
});
});
@@ -0,0 +1,82 @@
import { isTauri } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import debug from 'debug';
import { store } from '../../store';
import {
focusAccountFromNotification,
noteWebviewNotificationFired,
} from '../../store/accountsSlice';
import { WEBVIEW_NOTIFICATION_FIRED_EVENT, type WebviewNotificationFired } from './types';
const log = debug('webview-notifications');
const errLog = debug('webview-notifications:error');
let started = false;
let unlisten: UnlistenFn | null = null;
/**
* Subscribe to `webview-notification:fired` events from the Tauri shell and
* mirror each fire into Redux so the sidebar can bump an unread badge on
* the originating account. Idempotent — subsequent calls are no-ops.
*/
export function startWebviewNotificationsService(): void {
if (started) return;
if (!isTauri()) {
log('not running in tauri, skipping subscription');
return;
}
started = true;
listen<WebviewNotificationFired>(WEBVIEW_NOTIFICATION_FIRED_EVENT, event => {
handleFired(event.payload);
})
.then(fn => {
unlisten = fn;
log('subscribed to %s', WEBVIEW_NOTIFICATION_FIRED_EVENT);
})
.catch(err => {
errLog('failed to subscribe: %O', err);
started = false;
});
}
export function stopWebviewNotificationsService(): void {
if (unlisten) {
unlisten();
unlisten = null;
}
started = false;
}
/**
* Route a user-visible "click this notification" intent back to the
* originating account — focuses it and clears the unread count. Safe to
* call from in-app toast UIs or a future OS-notification click hook.
*/
export function handleNotificationClick(accountId: string): void {
store.dispatch(focusAccountFromNotification({ accountId }));
}
function handleFired(payload: WebviewNotificationFired): void {
const { account_id: accountId, provider, title, body } = payload;
log(
'fired account=%s provider=%s title_chars=%d body_chars=%d',
accountId,
provider,
title.length,
body.length
);
store.dispatch(noteWebviewNotificationFired({ accountId }));
}
/** Exposed for tests — resets module singletons between runs. */
export function __resetForTests(): void {
started = false;
unlisten = null;
}
/** Exposed for tests — dispatches as if a fired event arrived. */
export function __handleFiredForTests(payload: WebviewNotificationFired): void {
handleFired(payload);
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Shape of the `webview-notification:fired` Tauri event payload emitted by
* the Rust shell whenever an embedded webview renderer creates a native
* notification. Mirror of `WebviewNotificationFired` in
* `app/src-tauri/src/webview_accounts/mod.rs` — keep the two in sync.
*/
export interface WebviewNotificationFired {
account_id: string;
provider: string;
title: string;
body: string;
tag?: string | null;
}
export const WEBVIEW_NOTIFICATION_FIRED_EVENT = 'webview-notification:fired';
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import reducer, {
addAccount,
focusAccountFromNotification,
noteWebviewNotificationFired,
} from '../accountsSlice';
const sampleAccount = {
id: 'acct1',
provider: 'slack' as const,
label: 'Slack',
createdAt: '2026-01-01T00:00:00Z',
status: 'open' as const,
};
describe('accountsSlice webview-notification actions', () => {
it('noteWebviewNotificationFired increments unread for known accounts', () => {
const added = reducer(undefined, addAccount(sampleAccount));
const fired = reducer(added, noteWebviewNotificationFired({ accountId: 'acct1' }));
const firedTwice = reducer(fired, noteWebviewNotificationFired({ accountId: 'acct1' }));
expect(firedTwice.unread.acct1).toBe(2);
});
it('noteWebviewNotificationFired ignores unknown accounts', () => {
const fired = reducer(undefined, noteWebviewNotificationFired({ accountId: 'ghost' }));
expect(fired.unread.ghost).toBeUndefined();
});
it('focusAccountFromNotification sets active account and clears unread', () => {
let state = reducer(undefined, addAccount(sampleAccount));
state = reducer(state, noteWebviewNotificationFired({ accountId: 'acct1' }));
state = reducer(state, noteWebviewNotificationFired({ accountId: 'acct1' }));
expect(state.unread.acct1).toBe(2);
state = reducer(state, focusAccountFromNotification({ accountId: 'acct1' }));
expect(state.activeAccountId).toBe('acct1');
expect(state.unread.acct1).toBe(0);
});
it('focusAccountFromNotification ignores unknown accounts', () => {
const state = reducer(undefined, focusAccountFromNotification({ accountId: 'ghost' }));
expect(state.activeAccountId).toBeNull();
});
});
+15
View File
@@ -88,6 +88,19 @@ const accountsSlice = createSlice({
}
},
noteWebviewNotificationFired(state, action: PayloadAction<{ accountId: string }>) {
const { accountId } = action.payload;
if (!state.accounts[accountId]) return;
state.unread[accountId] = (state.unread[accountId] ?? 0) + 1;
},
focusAccountFromNotification(state, action: PayloadAction<{ accountId: string }>) {
const { accountId } = action.payload;
if (!state.accounts[accountId]) return;
state.activeAccountId = accountId;
state.unread[accountId] = 0;
},
resetAccountsState() {
return initialState;
},
@@ -101,6 +114,8 @@ export const {
setAccountStatus,
appendMessages,
appendLog,
noteWebviewNotificationFired,
focusAccountFromNotification,
resetAccountsState,
} = accountsSlice.actions;
+8
View File
@@ -141,6 +141,10 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
controllers.extend(crate::openhuman::learning::all_learning_registered_controllers());
// Conversation thread and message management
controllers.extend(crate::openhuman::threads::all_threads_registered_controllers());
// Embedded webview native notifications
controllers.extend(
crate::openhuman::webview_notifications::all_webview_notifications_registered_controllers(),
);
controllers
}
@@ -189,6 +193,10 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas.extend(crate::openhuman::learning::all_learning_controller_schemas());
// Conversation thread and message management
schemas.extend(crate::openhuman::threads::all_threads_controller_schemas());
// Embedded webview native notifications
schemas.extend(
crate::openhuman::webview_notifications::all_webview_notifications_controller_schemas(),
);
schemas
}
+1
View File
@@ -61,4 +61,5 @@ pub mod update;
pub mod util;
pub mod voice;
pub mod webhooks;
pub mod webview_notifications;
pub mod workspace;
@@ -0,0 +1,8 @@
//! Cross-module events for webview notifications.
//!
//! v1 is deliberately empty: the Tauri shell owns the CEF IPC hook and
//! fires notifications directly to the frontend over the Tauri event
//! bus (`webview-notification:fired`). When follow-up phases need core
//! subscribers (e.g. archiving notification history into the memory
//! store) they land here as `EventHandler` implementations wired from
//! the singleton bus.
@@ -0,0 +1,57 @@
//! Title formatting shared between core and the Tauri shell.
//!
//! Why the prefix: embedded webviews (Slack, Discord, Gmail) may be
//! open alongside the user's locally-installed native apps for the
//! same service. Both would fire OS toasts for the same DM. Prefixing
//! the title with `OpenHuman:` makes it trivial for the user to tell
//! the two apart and also gives the OS notification centre a distinct
//! grouping key.
/// Prefix applied to every OS notification title fired by a webview
/// event. Trailing space so the separation from the raw title reads
/// naturally (`OpenHuman: New message from …`).
pub const OPENHUMAN_TITLE_PREFIX: &str = "OpenHuman: ";
/// Format the native-toast title for a webview notification.
///
/// `provider_label` is the human-readable provider name (e.g. `Slack`),
/// `raw_title` is the renderer-supplied title (may be empty).
///
/// Layout: `OpenHuman: <Provider> — <raw title>` when both pieces are
/// present, collapsing to `OpenHuman: <Provider>` when the raw title is
/// empty or whitespace-only.
pub fn format_title(provider_label: &str, raw_title: &str) -> String {
let raw = raw_title.trim();
if raw.is_empty() {
format!("{OPENHUMAN_TITLE_PREFIX}{provider_label}")
} else {
format!("{OPENHUMAN_TITLE_PREFIX}{provider_label}{raw}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefix_empty_title_falls_back_to_provider_only() {
assert_eq!(format_title("Slack", ""), "OpenHuman: Slack");
assert_eq!(format_title("Slack", " "), "OpenHuman: Slack");
}
#[test]
fn prefix_with_title_joins_with_em_dash() {
assert_eq!(
format_title("Gmail", "New mail from Alice"),
"OpenHuman: Gmail — New mail from Alice"
);
}
#[test]
fn prefix_trims_raw_title_whitespace() {
assert_eq!(
format_title("Discord", " ping "),
"OpenHuman: Discord — ping"
);
}
}
@@ -0,0 +1,25 @@
//! Webview-originated Web Notifications routed to the OS.
//!
//! Scope (v1): deliver `window.Notification` invocations from embedded
//! webviews (Slack, Gmail, Discord, …) as native OS toasts, with the
//! account + provider encoded on the notification so the UI can focus
//! the right webview on click.
//!
//! The CEF IPC hook that captures the renderer-side call lives in the
//! Tauri shell crate (`openhuman` crate at `app/src-tauri/` —
//! `tauri_runtime_cef::notification::register`). This domain owns the
//! shared wire types, the title-formatting contract (`OpenHuman:`
//! prefix for dedup against installed native apps), and future
//! controllers that read/write the user-facing on/off toggle over
//! JSON-RPC.
pub mod bus;
pub mod dispatch;
pub mod schemas;
pub mod types;
pub use dispatch::{format_title, OPENHUMAN_TITLE_PREFIX};
pub use schemas::{
all_webview_notifications_controller_schemas, all_webview_notifications_registered_controllers,
};
pub use types::{NotificationSettings, WebviewNotificationEvent};
@@ -0,0 +1,19 @@
//! Controller registry for `webview_notifications`.
//!
//! v1 has no user-facing controllers: the on/off toggle lives in the
//! Tauri shell (per-install state rather than core config) so the
//! settings UI can flip it without a sidecar round-trip. The stubs
//! below exist so this domain participates in `src/core/all.rs` the
//! same way every other domain does, which keeps future additions
//! (notification history, per-account mute, etc.) a trivial extend.
use crate::core::all::RegisteredController;
use crate::core::ControllerSchema;
pub fn all_webview_notifications_controller_schemas() -> Vec<ControllerSchema> {
Vec::new()
}
pub fn all_webview_notifications_registered_controllers() -> Vec<RegisteredController> {
Vec::new()
}
@@ -0,0 +1,39 @@
//! Shared wire types for webview-originated notifications.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Payload emitted from the Tauri shell when a webview renderer fires a
/// `window.Notification`. Carried verbatim to the React side over the
/// `webview-notification:fired` Tauri event so the UI can bump unread
/// counts, show its own in-app toast, and route a subsequent click back
/// to the right embedded webview via Redux.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WebviewNotificationEvent {
/// Stable account id from the Redux `accounts` slice (persisted).
pub account_id: String,
/// Provider id, e.g. `slack`, `gmail`, `discord`.
pub provider: String,
/// OS-visible title (already `OpenHuman:`-prefixed by `format_title`).
pub title: String,
/// OS-visible body. Empty string when the page didn't set one.
pub body: String,
/// Optional renderer-supplied `tag` for native dedup.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
}
/// Runtime on/off toggle for the feature. Defaults to **disabled** —
/// v1 ships the plumbing but requires an explicit opt-in so the
/// release doesn't suddenly start firing OS toasts for every
/// background DM in an idle Slack tab.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
pub struct NotificationSettings {
pub enabled: bool,
}
impl Default for NotificationSettings {
fn default() -> Self {
Self { enabled: false }
}
}