From 56deb0d0977efed7ce000a1163b9057a6fd759c5 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Thu, 30 Jul 2026 15:15:49 +0800 Subject: [PATCH 1/2] feat(notifications): unify background delivery lifecycle --- .../NotificationLifecycle.md | 48 ++++ .../system-services/src/notifications.rs | 6 - .../src/agent_sessions/cli/commands/run.rs | 79 ++++- src-tauri/src/commands/handler_list.inc | 1 - src/api/services/notification.test.ts | 104 +++++++ src/api/services/notification.ts | 272 ++++++++---------- .../settingsSchema/registry/notifications.ts | 19 +- .../cliTurnLifecycleCoordinator.test.ts | 18 ++ .../cliSession/cliTurnLifecycleCoordinator.ts | 10 +- .../cliSession/useBackgroundSessionMonitor.ts | 103 ++++--- .../backgroundSessionNotifications.test.ts | 43 +++ .../session/backgroundSessionNotifications.ts | 78 +++++ .../session/useNativeSessionStatusMonitor.ts | 60 +++- src/i18n/locales/en/common.json | 9 + src/i18n/locales/en/settings.json | 4 +- src/i18n/locales/zh/common.json | 9 + src/i18n/locales/zh/settings.json | 4 +- .../__tests__/NotificationsSettings.test.ts | 184 ++++++++++++ .../slots/NotificationsAdvancedBlocks.tsx | 142 ++++----- .../slots/NotificationsMasterToggleRow.tsx | 50 +--- src/store/ui/notificationAtom.ts | 10 +- 21 files changed, 881 insertions(+), 372 deletions(-) create mode 100644 docs/frontend-ui-audit-2026-07-30/NotificationLifecycle.md create mode 100644 src/api/services/notification.test.ts create mode 100644 src/hooks/session/backgroundSessionNotifications.test.ts create mode 100644 src/hooks/session/backgroundSessionNotifications.ts create mode 100644 src/modules/MainApp/Settings/__tests__/NotificationsSettings.test.ts diff --git a/docs/frontend-ui-audit-2026-07-30/NotificationLifecycle.md b/docs/frontend-ui-audit-2026-07-30/NotificationLifecycle.md new file mode 100644 index 0000000000..8af46e8186 --- /dev/null +++ b/docs/frontend-ui-audit-2026-07-30/NotificationLifecycle.md @@ -0,0 +1,48 @@ +# Frontend UI Audit — NotificationLifecycle + +**Files:** + +- `src/modules/MainApp/Settings/renderer/slots/NotificationsAdvancedBlocks.tsx` +- `src/modules/MainApp/Settings/renderer/slots/NotificationsMasterToggleRow.tsx` + +**Date:** 2026-07-30 +**Auditor:** Codex + +## D1 — Raw HTML vs Design System + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| `NotificationsAdvancedBlocks.tsx:150-261` | Notification settings groups | keep with reason | Uses the canonical `SectionContainer`, `SectionRow`, `Switch`, `Slider`, and `Button` primitives throughout. | — | +| `NotificationsMasterToggleRow.tsx:9` | Master notification toggle | keep with reason | Uses the shared `Switch` and the settings atom instead of introducing a local checkbox pattern. | — | + +## D2 — Arbitrary Tailwind Value vs Token + +| Line | Value | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| `NotificationsAdvancedBlocks.tsx:160` | `w-[160px]` | keep with reason | The fixed slider track width is an optical control dimension inside a responsive `max-w-full` wrapper; there is no matching design token. | — | +| `NotificationsAdvancedBlocks.tsx:207-214` | Permission status text classes | keep with reason | Uses existing spacing and semantic text tokens; no raw colors were introduced. | — | + +## D3 — Hardcoded Sizes / Colors + +| Line | Value | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| `NotificationsAdvancedBlocks.tsx:160` | 160 px slider width | keep with reason | Keeps the volume control stable across conditional rows while `max-w-full` prevents overflow on narrow settings surfaces. | — | + +## D4 — Accessibility + +| Line | Element | Verdict | Reason | Suggested change | +| --- | --- | --- | --- | --- | +| `NotificationsAdvancedBlocks.tsx:151-260` | Settings controls | keep with reason | Every control is paired with a localized `SectionRow` label and preserves the shared primitives' keyboard behavior. | — | +| `NotificationsAdvancedBlocks.tsx:188-228` | Permission status and system-settings action | keep with reason | Disabled/requesting state is exposed on the switch and the status remains visible as localized text rather than color alone. | — | + +## D5 — Visual Patterns Observed + +- Notification categories share one data-driven `SectionRow` and `Switch` pattern. +- Sound, system permission, dock badge, and test actions reuse the existing Settings section hierarchy. +- No visual pattern reached the three-independent-implementation abstraction threshold. + +## Summary + +- 0 fixes recommended +- 7 kept with documented reason +- 0 abstract candidates diff --git a/src-tauri/crates/system-services/src/notifications.rs b/src-tauri/crates/system-services/src/notifications.rs index f0df2c65cc..0e27f7bc8d 100644 --- a/src-tauri/crates/system-services/src/notifications.rs +++ b/src-tauri/crates/system-services/src/notifications.rs @@ -84,9 +84,3 @@ pub fn set_dock_badge(count: Option) -> Result<(), String> { Ok(()) } } - -/// Clear the dock badge on macOS -#[tauri::command] -pub fn clear_dock_badge() -> Result<(), String> { - set_dock_badge(None) -} diff --git a/src-tauri/src/agent_sessions/cli/commands/run.rs b/src-tauri/src/agent_sessions/cli/commands/run.rs index 5b51135611..a5d0ca16d5 100644 --- a/src-tauri/src/agent_sessions/cli/commands/run.rs +++ b/src-tauri/src/agent_sessions/cli/commands/run.rs @@ -34,6 +34,26 @@ fn inject_ide_context_into_prompt(user_input: &str, ide_context: Option<&IdeCont ) } +fn failed_status_message( + session_id: &str, + error_message: &str, + turn_intent_id: &str, + notification_context: Option<(bool, &str)>, +) -> serde_json::Value { + let mut message = serde_json::json!({ + "type": "code_session.status_changed", + "session_id": session_id, + "status": "failed", + "error_message": error_message, + "turn_intent_id": turn_intent_id, + }); + if let Some((background, session_name)) = notification_context { + message["background"] = serde_json::Value::Bool(background); + message["session_name"] = serde_json::Value::String(session_name.to_string()); + } + message +} + /// Park a TUI-hosted session when its terminal pane goes away (PTY exit or /// tab close). Non-TUI sessions and already-terminal rows are left alone. #[tauri::command] @@ -184,13 +204,38 @@ async fn cli_agent_run_internal( } integrations::proxy::server::stop_session_proxy(&sid).await; session_runner::release_proxy_token_for_session_pub(&sid).await; - let mut failed_msg = serde_json::json!({ - "type": "code_session.status_changed", - "session_id": sid, - "status": "failed", - "error_message": e, - }); - failed_msg["turn_intent_id"] = serde_json::Value::String(runner_turn_intent_id.clone()); + let notification_sid = sid.clone(); + let notification_context = match tokio::task::spawn_blocking(move || { + persistence::get_session(¬ification_sid) + }) + .await + { + Ok(Ok(session)) => session, + Ok(Err(error)) => { + tracing::warn!( + "[CodeSession] Failed to reload notification context for {}: {}", + sid, + error + ); + None + } + Err(error) => { + tracing::warn!( + "[CodeSession] Notification context task failed for {}: {}", + sid, + error + ); + None + } + }; + let failed_msg = failed_status_message( + &sid, + &e, + &runner_turn_intent_id, + notification_context + .as_ref() + .map(|session| (session.background, session.name.as_str())), + ); crate::api::websocket_handler::broadcast(failed_msg.to_string()); } // Remove finished entry from RUNNING_SESSIONS to prevent unbounded growth @@ -432,3 +477,23 @@ pub async fn cli_agent_approval_response( ) .await } + +#[cfg(test)] +mod tests { + use super::failed_status_message; + + #[test] + fn failed_background_status_keeps_notification_context() { + let message = failed_status_message( + "cli-session-1", + "provider failed", + "intent-1", + Some((true, "Background review")), + ); + + assert_eq!(message["status"], "failed"); + assert_eq!(message["background"], true); + assert_eq!(message["session_name"], "Background review"); + assert_eq!(message["turn_intent_id"], "intent-1"); + } +} diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index b06774cee9..83fd278ce3 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -199,7 +199,6 @@ system_services::notifications::send_notification, system_services::notifications::check_notification_permission, system_services::notifications::request_notification_permission, system_services::notifications::set_dock_badge, -system_services::notifications::clear_dock_badge, // Platform commands - App Menu (File > Open Recent) system_services::app_menu::menu_add_recent, system_services::app_menu::menu_get_recent, diff --git a/src/api/services/notification.test.ts b/src/api/services/notification.test.ts new file mode 100644 index 0000000000..c422ae4cb9 --- /dev/null +++ b/src/api/services/notification.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + checkNotificationPermission, + notifyTeamInbox, + sendSystemNotification, + setDockBadge, +} from "./notification"; + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + isPermissionGranted: vi.fn(), + requestPermission: vi.fn(), + sendNotification: vi.fn(), +})); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: mocks.invoke, +})); + +vi.mock("@tauri-apps/plugin-notification", () => ({ + isPermissionGranted: mocks.isPermissionGranted, + requestPermission: mocks.requestPermission, + sendNotification: mocks.sendNotification, +})); + +vi.mock("@src/hooks/logger", () => ({ + createLogger: () => ({ + error: vi.fn(), + warn: vi.fn(), + }), +})); + +const SETTINGS = { + enabled: true, + systemNotificationEnabled: true, + dockBadgeEnabled: true, + completionSound: false, + soundVolume: 70, + categories: { + taskCompletion: true, + errors: true, + teamInbox: true, + }, +}; + +describe("notification service", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("preserves the Rust permission tri-state", async () => { + mocks.invoke.mockResolvedValueOnce("unknown"); + + await expect(checkNotificationPermission()).resolves.toBe("unknown"); + expect(mocks.invoke).toHaveBeenCalledWith("check_notification_permission"); + expect(mocks.isPermissionGranted).not.toHaveBeenCalled(); + }); + + it("does not mislabel a boolean fallback as denied", async () => { + mocks.invoke.mockRejectedValueOnce(new Error("IPC unavailable")); + mocks.isPermissionGranted.mockResolvedValueOnce(false); + + await expect(checkNotificationPermission()).resolves.toBe("unknown"); + }); + + it("gates Team Inbox delivery on the master and category settings", async () => { + await notifyTeamInbox("New assignment", "Review it", { + ...SETTINGS, + enabled: false, + }); + await notifyTeamInbox("New assignment", "Review it", { + ...SETTINGS, + categories: { ...SETTINGS.categories, teamInbox: false }, + }); + + expect(mocks.sendNotification).not.toHaveBeenCalled(); + }); + + it("falls back to the Rust send boundary exactly once", async () => { + mocks.sendNotification.mockRejectedValueOnce(new Error("plugin failed")); + mocks.invoke.mockResolvedValueOnce(undefined); + + await expect(sendSystemNotification("Title", "Body")).resolves.toBeTruthy(); + expect(mocks.invoke).toHaveBeenCalledWith("send_notification", { + title: "Title", + body: "Body", + }); + }); + + it("projects positive and cleared dock badge values", async () => { + mocks.invoke.mockResolvedValue(undefined); + + await setDockBadge(7.9); + await setDockBadge(0); + + expect(mocks.invoke).toHaveBeenNthCalledWith(1, "set_dock_badge", { + count: 7, + }); + expect(mocks.invoke).toHaveBeenNthCalledWith(2, "set_dock_badge", { + count: null, + }); + }); +}); diff --git a/src/api/services/notification.ts b/src/api/services/notification.ts index f1e31e99d2..200f744092 100644 --- a/src/api/services/notification.ts +++ b/src/api/services/notification.ts @@ -6,36 +6,30 @@ import { } from "@tauri-apps/plugin-notification"; import { createLogger } from "@src/hooks/logger"; -import { NotificationSettings } from "@src/store/ui/notificationAtom"; +import type { NotificationSettings } from "@src/store/ui/notificationAtom"; const log = createLogger("Notification"); -// Audio element for completion sounds -let audioElement: HTMLAudioElement | null = null; let audioContext: AudioContext | null = null; -// Initialize audio element -const getAudioElement = (): HTMLAudioElement => { - if (!audioElement) { - audioElement = new Audio("/sounds/completion.mp3"); - // Add error handler to fall back to generated sound - audioElement.addEventListener("error", () => { - log.warn("Sound file not found, using generated sound"); - }); - } - return audioElement; -}; +export type NotificationPermissionStatus = "granted" | "denied" | "unknown"; -// Generate a simple notification beep using Web Audio API as fallback -const playGeneratedSound = (volume: number): void => { +export interface NotificationDeliveryResult { + systemSent: boolean; + soundPlayed: boolean; +} + +const playGeneratedSound = async (volume: number): Promise => { try { if (!audioContext) { - audioContext = new ( + const AudioContextConstructor = window.AudioContext || - (window as unknown as { webkitAudioContext: typeof AudioContext }) - .webkitAudioContext - )(); + (window as unknown as { webkitAudioContext?: typeof AudioContext }) + .webkitAudioContext; + if (!AudioContextConstructor) return false; + audioContext = new AudioContextConstructor(); } + if (audioContext.state === "suspended") await audioContext.resume(); const oscillator = audioContext.createOscillator(); const gainNode = audioContext.createGain(); @@ -57,10 +51,20 @@ const playGeneratedSound = (volume: number): void => { audioContext.currentTime + 0.3 ); + oscillator.addEventListener( + "ended", + () => { + oscillator.disconnect(); + gainNode.disconnect(); + }, + { once: true } + ); oscillator.start(audioContext.currentTime); oscillator.stop(audioContext.currentTime + 0.3); + return true; } catch (error) { log.error("Failed to play generated sound:", error); + return false; } }; @@ -77,48 +81,60 @@ export interface NotificationOptions { /** * Check notification permission status */ -export const checkNotificationPermission = async (): Promise => { - try { - const granted = await isPermissionGranted(); - return granted ? "granted" : "denied"; - } catch (error) { - log.error( - "[Notification] Permission check failed, trying Rust command:", - error - ); +export const checkNotificationPermission = + async (): Promise => { + // The Rust boundary exposes the full granted / denied / not-yet-requested + // state. The JS helper only returns a boolean and would collapse + // "unknown" into "denied". try { - return await invoke("check_notification_permission"); + return await invoke( + "check_notification_permission" + ); } catch (invokeError) { - log.error("[Notification] Rust command also failed:", invokeError); + log.warn( + "[Notification] Rust permission check failed, using boolean fallback:", + invokeError + ); + } + + try { + return (await isPermissionGranted()) ? "granted" : "unknown"; + } catch (error) { + log.error("[Notification] Permission check failed:", error); return "unknown"; } - } -}; + }; /** * Request notification permission */ -export const requestNotificationPermission = async (): Promise => { - try { - const permission = await requestPermission(); - return permission === "granted" - ? "granted" - : permission === "denied" - ? "denied" - : "unknown"; - } catch (error) { - log.error( - "[Notification] Permission request failed, trying Rust command:", - error - ); +export const requestNotificationPermission = + async (): Promise => { try { - return await invoke("request_notification_permission"); - } catch (invokeError) { - log.error("[Notification] Rust command also failed:", invokeError); - return "denied"; + const permission = await requestPermission(); + return permission === "granted" + ? "granted" + : permission === "denied" + ? "denied" + : "unknown"; + } catch (error) { + log.warn( + "[Notification] Permission request failed, trying Rust command:", + error + ); + try { + return await invoke( + "request_notification_permission" + ); + } catch (invokeError) { + log.error( + "[Notification] Rust permission request failed:", + invokeError + ); + return "unknown"; + } } - } -}; + }; /** * Send a system notification @@ -131,68 +147,67 @@ export const sendSystemNotification = async ( await sendNotification({ title, body }); return true; } catch (error) { - log.error("[Notification] Send failed, trying Rust command:", error); + log.warn("[Notification] Send failed, trying Rust command:", error); try { await invoke("send_notification", { title, body }); return true; } catch (invokeError) { - log.error("[Notification] Rust command also failed:", invokeError); + log.error("[Notification] Rust notification send failed:", invokeError); return false; } } }; /** - * Play completion sound + * Project the authoritative Team Inbox unread count into the dock badge. */ -export const playCompletionSound = (volume: number = 70): void => { +export const setDockBadge = async (count: number): Promise => { try { - const audio = getAudioElement(); - audio.volume = Math.max(0, Math.min(1, volume / 100)); - audio.currentTime = 0; - - const playPromise = audio.play(); - - if (playPromise !== undefined) { - playPromise.catch(() => { - // If the audio file fails to play (not found or error), use generated sound - playGeneratedSound(volume); - }); - } - } catch { - // Fallback to generated sound - playGeneratedSound(volume); + await invoke("set_dock_badge", { + count: Number.isFinite(count) && count > 0 ? Math.floor(count) : null, + }); + return true; + } catch (error) { + log.error("[Notification] Failed to update dock badge:", error); + return false; } }; +/** + * Play the generated notification tone. + */ +export const playCompletionSound = async ( + volume: number = 70 +): Promise => { + return playGeneratedSound(Math.max(0, Math.min(100, volume))); +}; + /** * Send a notification based on settings */ export const notify = async ( options: NotificationOptions, settings: NotificationSettings -): Promise => { +): Promise => { if (!settings.enabled) { - return false; + return { systemSent: false, soundPlayed: false }; } if (options.category && !settings.categories[options.category]) { - return false; + return { systemSent: false, soundPlayed: false }; } - let notificationSent = false; + let systemSent = false; if (settings.systemNotificationEnabled) { - notificationSent = await sendSystemNotification( - options.title, - options.body - ); + systemSent = await sendSystemNotification(options.title, options.body); } + let soundPlayed = false; if (options.playSound !== false && settings.completionSound) { - playCompletionSound(settings.soundVolume); + soundPlayed = await playCompletionSound(settings.soundVolume); } - return notificationSent; + return { systemSent, soundPlayed }; }; /** @@ -200,11 +215,12 @@ export const notify = async ( */ export const notifyTaskCompletion = async ( taskName: string, - settings: NotificationSettings -): Promise => { + settings: NotificationSettings, + title = "Task Completed" +): Promise => { return notify( { - title: "Task Completed", + title, body: taskName, category: "taskCompletion", playSound: true, @@ -213,34 +229,17 @@ export const notifyTaskCompletion = async ( ); }; -/** - * Notify agent approval needed - */ -export const notifyAgentApproval = async ( - actionName: string, - settings: NotificationSettings -): Promise => { - return notify( - { - title: "Action Requires Approval", - body: actionName, - category: "agentApproval", - playSound: true, - }, - settings - ); -}; - /** * Notify error */ export const notifyError = async ( errorMessage: string, - settings: NotificationSettings -): Promise => { + settings: NotificationSettings, + title = "Error" +): Promise => { return notify( { - title: "Error", + title, body: errorMessage, category: "errors", playSound: false, @@ -250,64 +249,29 @@ export const notifyError = async ( }; /** - * Notify session status change + * Notify a new Team Inbox assignment, mention, or handoff. */ -export const notifySessionStatus = async ( - status: string, - settings: NotificationSettings -): Promise => { - return notify( - { - title: "Session Status", - body: status, - category: "sessionStatus", - playSound: false, - }, - settings - ); -}; - -/** - * Notify git operation - */ -export const notifyGitOperation = async ( - operation: string, +export const notifyTeamInbox = async ( + title: string, + body: string, settings: NotificationSettings -): Promise => { +): Promise => { return notify( { - title: "Git Operation", - body: operation, - category: "gitOperations", - playSound: false, + title, + body, + category: "teamInbox", + playSound: true, }, settings ); }; /** - * Test notification - sends a test notification and plays sound + * Test the native notification channel without changing persisted settings. */ -export const sendTestNotification = async ( - settings: NotificationSettings -): Promise => { - const tempSettings = { - ...settings, - enabled: true, - systemNotificationEnabled: true, - categories: { - ...settings.categories, - taskCompletion: true, - }, - }; - - return notify( - { - title: "Test Notification", - body: "This is a test notification from ORGII", - category: "taskCompletion", - playSound: true, - }, - tempSettings +export const sendTestNotification = async (): Promise => + sendSystemNotification( + "Test Notification", + "This is a test notification from ORGII" ); -}; diff --git a/src/config/settingsSchema/registry/notifications.ts b/src/config/settingsSchema/registry/notifications.ts index 14cb403960..89c4618a0b 100644 --- a/src/config/settingsSchema/registry/notifications.ts +++ b/src/config/settingsSchema/registry/notifications.ts @@ -39,28 +39,17 @@ export const NOTIFICATIONS_SETTINGS_REGISTRY = { description: "Show notifications for task/session completion", category: "notifications", }, - "notifications.categories.agentApproval": { - schema: z.boolean(), - default: true, - description: "Show notifications when an agent action requires approval", - category: "notifications", - }, "notifications.categories.errors": { schema: z.boolean(), default: true, description: "Show notifications for errors and warnings", category: "notifications", }, - "notifications.categories.sessionStatus": { - schema: z.boolean(), - default: false, - description: "Show notifications for session status updates", - category: "notifications", - }, - "notifications.categories.gitOperations": { + "notifications.categories.teamInbox": { schema: z.boolean(), - default: false, - description: "Show notifications for git operations (push, pull, merge)", + default: true, + description: + "Show notifications for Team Inbox assignments, mentions, and handoffs", category: "notifications", }, } as const satisfies Record; diff --git a/src/hooks/cliSession/cliTurnLifecycleCoordinator.test.ts b/src/hooks/cliSession/cliTurnLifecycleCoordinator.test.ts index 58dfd488ea..09914ae790 100644 --- a/src/hooks/cliSession/cliTurnLifecycleCoordinator.test.ts +++ b/src/hooks/cliSession/cliTurnLifecycleCoordinator.test.ts @@ -174,4 +174,22 @@ describe("CliTurnLifecycleCoordinator", () => { expect(loadBatch).not.toHaveBeenCalled(); vi.stubGlobal("document", originalDocument); }); + + it("returns only newly-applied statuses so reconnect consumers can recover side effects", async () => { + const terminal = { + sessionId: "cliagent-recovered", + status: "completed", + turnIntentId: "intent-recovered", + }; + const coordinator = new CliTurnLifecycleCoordinator( + vi.fn(async () => [terminal, terminal]) + ); + coordinator.handleStatus({ + sessionId: terminal.sessionId, + status: "running", + turnIntentId: terminal.turnIntentId, + }); + + await expect(coordinator.reconcile()).resolves.toEqual([terminal]); + }); }); diff --git a/src/hooks/cliSession/cliTurnLifecycleCoordinator.ts b/src/hooks/cliSession/cliTurnLifecycleCoordinator.ts index 6616b72387..6d3621ca66 100644 --- a/src/hooks/cliSession/cliTurnLifecycleCoordinator.ts +++ b/src/hooks/cliSession/cliTurnLifecycleCoordinator.ts @@ -59,7 +59,7 @@ type BatchLoader = (input: { export class CliTurnLifecycleCoordinator { private readonly activeBySession = new Map(); private readonly recentTerminalIntents = new Set(); - private reconcilePromise: Promise | null = null; + private reconcilePromise: Promise | null = null; constructor(private readonly loadStatusBatch: BatchLoader) {} @@ -138,20 +138,20 @@ export class CliTurnLifecycleCoordinator { return true; } - reconcile(): Promise { + reconcile(): Promise { if ( typeof document !== "undefined" && document.visibilityState === "hidden" ) { - return Promise.resolve(); + return Promise.resolve([]); } if (this.reconcilePromise) return this.reconcilePromise; const sessionIds = this.collectReconcileSessionIds(); - if (sessionIds.length === 0) return Promise.resolve(); + if (sessionIds.length === 0) return Promise.resolve([]); this.reconcilePromise = this.loadStatusBatch({ sessionIds }) .then((statuses) => { - for (const status of statuses) this.handleStatus(status); + return statuses.filter((status) => this.handleStatus(status)); }) .finally(() => { this.reconcilePromise = null; diff --git a/src/hooks/cliSession/useBackgroundSessionMonitor.ts b/src/hooks/cliSession/useBackgroundSessionMonitor.ts index abb1b8a463..de2ee7fbd6 100644 --- a/src/hooks/cliSession/useBackgroundSessionMonitor.ts +++ b/src/hooks/cliSession/useBackgroundSessionMonitor.ts @@ -11,17 +11,23 @@ * Active adapters remain responsible for transcript/UI mirroring only; turn * finality for active and background sessions is owned here. */ +import type { TFunction } from "i18next"; import { useAtomValue } from "jotai"; import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { getCodeEditorWebSocket } from "@src/api/realtime/codeEditorWebSocket"; +import { deliverBackgroundSessionTerminalNotification } from "@src/hooks/session/backgroundSessionNotifications"; +import { sessionByIdAtom } from "@src/store/session"; import { - notifyError, - notifyTaskCompletion, -} from "@src/api/services/notification"; -import Message from "@src/components/Message"; -import { notificationSettingsAtom } from "@src/store/ui/notificationAtom"; + type NotificationSettings, + notificationSettingsAtom, +} from "@src/store/ui/notificationAtom"; import { isTerminalStatus } from "@src/types/session/session"; +import { + getInstrumentedStore, + isStoreInitialized, +} from "@src/util/core/state/instrumentedStore"; import { cliTurnLifecycleCoordinator } from "./cliTurnLifecycleCoordinator"; @@ -37,12 +43,17 @@ interface BackgroundStatusMessage { } export function useBackgroundSessionMonitor(): void { + const { t } = useTranslation(); const notificationSettings = useAtomValue(notificationSettingsAtom); const settingsRef = useRef(notificationSettings); useEffect(() => { settingsRef.current = notificationSettings; }, [notificationSettings]); + const translationRef = useRef(t); + useEffect(() => { + translationRef.current = t; + }, [t]); useEffect(() => { const wsClient = getCodeEditorWebSocket(); @@ -56,48 +67,31 @@ export function useBackgroundSessionMonitor(): void { turnIntentId: msg.turn_intent_id, }); - if (!msg.background) return; if (!isTerminalStatus(msg.status)) return; if (!applied) return; - - const sessionName = msg.session_name || "Background session"; - - if (msg.status === "completed") { - notifyTaskCompletion( - `"${sessionName}" completed — ready for review`, - settingsRef.current - ); - - Message.success({ - content: `"${sessionName}" completed. Click to review diff.`, - duration: 0, - closable: true, - }); - } else if (msg.status === "failed") { - const errorDetail = msg.error_message - ? `: ${msg.error_message.slice(0, 120)}` - : ""; - - notifyError( - `"${sessionName}" failed${errorDetail}`, - settingsRef.current - ); - - Message.error({ - content: `"${sessionName}" failed${errorDetail}`, - duration: 8000, - closable: true, - }); - } else if (msg.status === "cancelled") { - Message.warning({ - content: `"${sessionName}" was cancelled`, - duration: 5000, - }); - } + deliverBackgroundTerminal( + msg, + settingsRef.current, + translationRef.current + ); }); const reconcile = () => { - void cliTurnLifecycleCoordinator.reconcile(); + void cliTurnLifecycleCoordinator.reconcile().then((appliedStatuses) => { + for (const status of appliedStatuses) { + if (!isTerminalStatus(status.status)) continue; + deliverBackgroundTerminal( + { + type: "code_session.status_changed", + session_id: status.sessionId, + status: status.status, + turn_intent_id: status.turnIntentId, + }, + settingsRef.current, + translationRef.current + ); + } + }); }; const unsubscribeConnected = wsClient.on("connected", reconcile); const handleVisibilityChange = () => { @@ -114,3 +108,28 @@ export function useBackgroundSessionMonitor(): void { }; }, []); } + +function deliverBackgroundTerminal( + msg: BackgroundStatusMessage, + settings: NotificationSettings, + t: TFunction +): void { + const session = isStoreInitialized() + ? getInstrumentedStore().get(sessionByIdAtom(msg.session_id)) + : undefined; + const background = msg.background ?? session?.background ?? false; + if (!background) return; + + const sessionName = + msg.session_name || session?.name || t("notifications.backgroundSession"); + + deliverBackgroundSessionTerminalNotification( + { + status: msg.status, + sessionName, + errorMessage: msg.error_message ?? session?.error_message, + }, + settings, + t + ); +} diff --git a/src/hooks/session/backgroundSessionNotifications.test.ts b/src/hooks/session/backgroundSessionNotifications.test.ts new file mode 100644 index 0000000000..a259311e6b --- /dev/null +++ b/src/hooks/session/backgroundSessionNotifications.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { shouldDeliverBackgroundSessionTerminalNotification } from "./backgroundSessionNotifications"; + +describe("shouldDeliverBackgroundSessionTerminalNotification", () => { + it("delivers only a new terminal transition for a background session", () => { + expect( + shouldDeliverBackgroundSessionTerminalNotification( + "running", + "completed", + true + ) + ).toBe(true); + expect( + shouldDeliverBackgroundSessionTerminalNotification( + "completed", + "completed", + true + ) + ).toBe(false); + expect( + shouldDeliverBackgroundSessionTerminalNotification( + "failed", + "completed", + true + ) + ).toBe(false); + expect( + shouldDeliverBackgroundSessionTerminalNotification( + "running", + "completed", + false + ) + ).toBe(false); + expect( + shouldDeliverBackgroundSessionTerminalNotification( + "running", + "working", + true + ) + ).toBe(false); + }); +}); diff --git a/src/hooks/session/backgroundSessionNotifications.ts b/src/hooks/session/backgroundSessionNotifications.ts new file mode 100644 index 0000000000..35554ffbf1 --- /dev/null +++ b/src/hooks/session/backgroundSessionNotifications.ts @@ -0,0 +1,78 @@ +import type { TFunction } from "i18next"; + +import { + notifyError, + notifyTaskCompletion, +} from "@src/api/services/notification"; +import Message from "@src/components/Message"; +import type { NotificationSettings } from "@src/store/ui/notificationAtom"; +import { isTerminalStatus } from "@src/types/session/session"; + +export interface BackgroundSessionTerminalNotification { + status: string; + sessionName: string; + errorMessage?: string; +} + +export function shouldDeliverBackgroundSessionTerminalNotification( + previousStatus: string | undefined, + nextStatus: string, + background: boolean +): boolean { + return ( + background && + isTerminalStatus(nextStatus) && + (previousStatus === undefined || !isTerminalStatus(previousStatus)) + ); +} + +export function deliverBackgroundSessionTerminalNotification( + event: BackgroundSessionTerminalNotification, + settings: NotificationSettings, + t: TFunction +): void { + if (event.status === "completed") { + const body = t("notifications.taskCompletedBody", { + name: event.sessionName, + }); + void notifyTaskCompletion( + body, + settings, + t("notifications.taskCompletedTitle") + ); + Message.success({ + content: t("notifications.taskCompletedToast", { + name: event.sessionName, + }), + duration: 0, + closable: true, + }); + return; + } + + if (event.status === "failed") { + const detail = event.errorMessage + ? `: ${event.errorMessage.slice(0, 120)}` + : ""; + const body = t("notifications.taskFailedBody", { + name: event.sessionName, + detail, + }); + void notifyError(body, settings, t("notifications.taskFailedTitle")); + Message.error({ + content: body, + duration: 8000, + closable: true, + }); + return; + } + + if (event.status === "cancelled") { + Message.warning({ + content: t("notifications.taskCancelledToast", { + name: event.sessionName, + }), + duration: 5000, + }); + } +} diff --git a/src/hooks/session/useNativeSessionStatusMonitor.ts b/src/hooks/session/useNativeSessionStatusMonitor.ts index 2d0d6d1c1a..f3e0559e1d 100644 --- a/src/hooks/session/useNativeSessionStatusMonitor.ts +++ b/src/hooks/session/useNativeSessionStatusMonitor.ts @@ -16,20 +16,35 @@ * backend-initiated switches reach `sessionsAtom` without relying on the * initiating window's optimistic update. * - * This intentionally does NOT trigger toasts or notifications: those are - * owned by `useBackgroundSessionMonitor` (CLI sessions) and individual - * session panels. This hook is the minimal "keep the store in sync" layer. + * This also owns terminal notifications for native background sessions. + * Delivery is transition-based so repeated native events and hydrated + * historical terminal state cannot replay notifications. */ import { listen } from "@tauri-apps/api/event"; -import { useEffect } from "react"; +import { useAtomValue } from "jotai"; +import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { markTurnRunning, markTurnTerminal, toTurnTerminalStatus, } from "@src/engines/SessionCore/control/turnLifecycle"; -import { type SessionStatus, updateSessionStatus } from "@src/store/session"; +import { + deliverBackgroundSessionTerminalNotification, + shouldDeliverBackgroundSessionTerminalNotification, +} from "@src/hooks/session/backgroundSessionNotifications"; +import { + type SessionStatus, + sessionByIdAtom, + updateSessionStatus, +} from "@src/store/session"; +import { notificationSettingsAtom } from "@src/store/ui/notificationAtom"; import { isTerminalStatus } from "@src/types/session/session"; +import { + getInstrumentedStore, + isStoreInitialized, +} from "@src/util/core/state/instrumentedStore"; import { isSessionRuntimeExecuting } from "@src/util/session/sessionRuntimeExecuting"; interface SessionStatusChangedPayload { @@ -50,13 +65,48 @@ interface SessionRenamedPayload { } export function useNativeSessionStatusMonitor(): void { + const { t } = useTranslation(); + const notificationSettings = useAtomValue(notificationSettingsAtom); + const settingsRef = useRef(notificationSettings); + const translationRef = useRef(t); + + useEffect(() => { + settingsRef.current = notificationSettings; + }, [notificationSettings]); + useEffect(() => { + translationRef.current = t; + }, [t]); + useEffect(() => { const unlistenPromise = listen( "session-status-changed", (event) => { const { sessionId, status } = event.payload; + const session = isStoreInitialized() + ? getInstrumentedStore().get(sessionByIdAtom(sessionId)) + : undefined; if (isTerminalStatus(status)) { markTurnTerminal(sessionId, toTurnTerminalStatus(status)); + if ( + session && + shouldDeliverBackgroundSessionTerminalNotification( + session.status, + status, + session.background === true + ) + ) { + deliverBackgroundSessionTerminalNotification( + { + status, + sessionName: + session.name || + translationRef.current("notifications.backgroundSession"), + errorMessage: session.error_message, + }, + settingsRef.current, + translationRef.current + ); + } } else if (isSessionRuntimeExecuting(status)) { markTurnRunning(sessionId); } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index d5f7c7c21a..b8d15e9788 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -2464,6 +2464,15 @@ "noRepo": "No repo" } }, + "notifications": { + "backgroundSession": "Background Session", + "taskCompletedTitle": "Task completed", + "taskCompletedBody": "“{{name}}” completed — ready for review", + "taskCompletedToast": "“{{name}}” completed. Open the Session to review the result.", + "taskFailedTitle": "Task failed", + "taskFailedBody": "“{{name}}” failed{{detail}}", + "taskCancelledToast": "“{{name}}” was cancelled" + }, "teamInbox": { "title": "Team Inbox", "listLabel": "Team Inbox list", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index b327815825..3df72693a0 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -707,7 +707,9 @@ "sent": "Test notification sent", "permissionWarning": "Failed to send test notification. Check permissions.", "sendFailed": "Unable to send test notification. Please check your notification permissions and try again." - } + }, + "teamInbox": "Team Inbox", + "teamInboxDesc": "Assignments, mentions, and handoffs from teammates" }, "editor": { "tabEditor": "Editor", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 5776ce79a0..f2f1e1b9d6 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -2348,6 +2348,15 @@ "noRepo": "无仓库" } }, + "notifications": { + "backgroundSession": "后台会话", + "taskCompletedTitle": "任务已完成", + "taskCompletedBody": "“{{name}}”已完成,可以查看结果", + "taskCompletedToast": "“{{name}}”已完成,请打开会话查看结果。", + "taskFailedTitle": "任务失败", + "taskFailedBody": "“{{name}}”失败{{detail}}", + "taskCancelledToast": "“{{name}}”已取消" + }, "teamInbox": { "title": "团队收件箱", "listLabel": "团队收件箱列表", diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index f685f82821..0966538ce6 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -707,7 +707,9 @@ "sent": "测试通知已发送", "permissionWarning": "测试通知发送失败,请检查权限。", "sendFailed": "无法发送测试通知。请检查通知权限后重试。" - } + }, + "teamInbox": "团队收件箱", + "teamInboxDesc": "来自队友的分配、提及和交接" }, "editor": { "tabEditor": "编辑器", diff --git a/src/modules/MainApp/Settings/__tests__/NotificationsSettings.test.ts b/src/modules/MainApp/Settings/__tests__/NotificationsSettings.test.ts new file mode 100644 index 0000000000..9107093a23 --- /dev/null +++ b/src/modules/MainApp/Settings/__tests__/NotificationsSettings.test.ts @@ -0,0 +1,184 @@ +// @vitest-environment jsdom +import React, { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import NotificationsAdvancedBlocks from "../renderer/slots/NotificationsAdvancedBlocks"; +import NotificationsMasterToggleRow from "../renderer/slots/NotificationsMasterToggleRow"; + +const mocks = vi.hoisted(() => ({ + values: new Map(), + setters: new Map>(), + checkPermission: vi.fn(), + requestPermission: vi.fn(), + sendTest: vi.fn(), + playSound: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@src/store/settings", () => ({ + useSetting: (key: string) => [ + mocks.values.get(key), + mocks.setters.get(key) ?? vi.fn(), + ], +})); + +vi.mock("@src/api/services/notification", () => ({ + checkNotificationPermission: mocks.checkPermission, + requestNotificationPermission: mocks.requestPermission, + sendTestNotification: mocks.sendTest, + playCompletionSound: mocks.playSound, +})); + +vi.mock("@/src/modules/shared/layouts/SectionLayout", () => ({ + SectionContainer: ({ children }: { children?: React.ReactNode }) => + createElement("section", null, children), + SectionRow: ({ + children, + label, + }: { + children?: React.ReactNode; + label?: string; + }) => createElement("div", { "data-label": label }, children), +})); + +vi.mock("@src/components/Switch", () => ({ + default: ({ + checked, + disabled, + onChange, + }: { + checked?: boolean; + disabled?: boolean; + onChange?: () => void; + }) => + createElement("button", { + type: "button", + disabled, + "data-checked": String(Boolean(checked)), + onClick: onChange, + }), +})); + +vi.mock("@src/components/Button", () => ({ + default: ({ children }: { children?: React.ReactNode }) => + createElement("button", { type: "button" }, children), +})); + +vi.mock("@src/components/Slider", () => ({ + default: () => createElement("div"), +})); + +vi.mock("@src/components/Message", () => ({ + default: { + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@tauri-apps/plugin-shell", () => ({ + open: vi.fn(), +})); + +vi.mock("@src/util/platform/tauri", () => ({ + isMacOS: () => false, +})); + +describe("notification settings lifecycle", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + mocks.values.clear(); + mocks.setters.clear(); + mocks.checkPermission.mockReset().mockResolvedValue("unknown"); + mocks.requestPermission.mockReset().mockResolvedValue("granted"); + mocks.sendTest.mockReset().mockResolvedValue(true); + mocks.playSound.mockReset().mockResolvedValue(true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = false; + }); + + it("lets the master switch enable sound-only notifications without requesting OS permission", () => { + const setEnabled = vi.fn(); + mocks.values.set("notifications.enabled", false); + mocks.setters.set("notifications.enabled", setEnabled); + + act(() => root.render(createElement(NotificationsMasterToggleRow))); + act(() => container.querySelector("button")?.click()); + + expect(setEnabled).toHaveBeenCalledWith(true); + expect(mocks.requestPermission).not.toHaveBeenCalled(); + }); + + it("keeps categories visible with sound off and requests permission at the system toggle", async () => { + const setSystemEnabled = vi.fn(); + const defaults: Record = { + "notifications.enabled": true, + "notifications.completionSound": false, + "notifications.systemNotificationEnabled": false, + "notifications.dockBadgeEnabled": false, + "notifications.soundVolume": 70, + "notifications.categories.taskCompletion": true, + "notifications.categories.errors": true, + "notifications.categories.teamInbox": true, + }; + for (const [key, value] of Object.entries(defaults)) { + mocks.values.set(key, value); + mocks.setters.set(key, vi.fn()); + } + mocks.setters.set( + "notifications.systemNotificationEnabled", + setSystemEnabled + ); + + await act(async () => { + root.render(createElement(NotificationsAdvancedBlocks)); + }); + + expect( + container.querySelector('[data-label="notifications.teamInbox"]') + ).not.toBeNull(); + const systemRow = container.querySelector( + '[data-label="notifications.enableSystem"]' + ); + await act(async () => { + systemRow?.querySelector("button")?.click(); + }); + + expect(mocks.requestPermission).toHaveBeenCalledTimes(1); + expect(setSystemEnabled).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/modules/MainApp/Settings/renderer/slots/NotificationsAdvancedBlocks.tsx b/src/modules/MainApp/Settings/renderer/slots/NotificationsAdvancedBlocks.tsx index dfdab39be0..11ec6589dd 100644 --- a/src/modules/MainApp/Settings/renderer/slots/NotificationsAdvancedBlocks.tsx +++ b/src/modules/MainApp/Settings/renderer/slots/NotificationsAdvancedBlocks.tsx @@ -2,34 +2,27 @@ import { SectionContainer, SectionRow, } from "@/src/modules/shared/layouts/SectionLayout"; -import { invoke } from "@tauri-apps/api/core"; import { open as shellOpen } from "@tauri-apps/plugin-shell"; import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { + type NotificationPermissionStatus, checkNotificationPermission, playCompletionSound, + requestNotificationPermission, sendTestNotification, } from "@src/api/services/notification"; import Button from "@src/components/Button"; import Message from "@src/components/Message"; import Slider from "@src/components/Slider"; import Switch from "@src/components/Switch"; -import { createLogger } from "@src/hooks/logger"; import { NAV_BUTTON_PROPS } from "@src/modules/MainApp/Settings/config"; import { useSetting } from "@src/store/settings"; import { isMacOS } from "@src/util/platform/tauri"; -const log = createLogger("Notifications"); - interface NotificationCategoryConfig { - key: - | "taskCompletion" - | "agentApproval" - | "errors" - | "sessionStatus" - | "gitOperations"; + key: "taskCompletion" | "errors" | "teamInbox"; labelKey: string; } @@ -38,21 +31,13 @@ const NOTIFICATION_CATEGORIES: NotificationCategoryConfig[] = [ key: "taskCompletion", labelKey: "notifications.taskCompletion", }, - { - key: "agentApproval", - labelKey: "notifications.agentApproval", - }, { key: "errors", labelKey: "notifications.errors", }, { - key: "sessionStatus", - labelKey: "notifications.sessionStatus", - }, - { - key: "gitOperations", - labelKey: "notifications.gitOperations", + key: "teamInbox", + labelKey: "notifications.teamInbox", }, ]; @@ -72,18 +57,14 @@ const NotificationsAdvancedBlocks: React.FC = () => { const [taskCompletion, setTaskCompletion] = useSetting( "notifications.categories.taskCompletion" ); - const [agentApproval, setAgentApproval] = useSetting( - "notifications.categories.agentApproval" - ); const [errors, setErrors] = useSetting("notifications.categories.errors"); - const [sessionStatus, setSessionStatus] = useSetting( - "notifications.categories.sessionStatus" - ); - const [gitOperations, setGitOperations] = useSetting( - "notifications.categories.gitOperations" + const [teamInbox, setTeamInbox] = useSetting( + "notifications.categories.teamInbox" ); - const [permissionStatus, setPermissionStatus] = useState("unknown"); + const [permissionStatus, setPermissionStatus] = + useState("unknown"); + const [isRequestingPermission, setIsRequestingPermission] = useState(false); const [isTesting, setIsTesting] = useState(false); useEffect(() => { @@ -98,22 +79,37 @@ const NotificationsAdvancedBlocks: React.FC = () => { }; }, []); + const ensureSystemPermission = + async (): Promise => { + if (permissionStatus === "granted") return permissionStatus; + setIsRequestingPermission(true); + try { + const result = await requestNotificationPermission(); + setPermissionStatus(result); + if (result !== "granted") { + Message.warning(t("notifications.permissionDenied")); + } + return result; + } finally { + setIsRequestingPermission(false); + } + }; + + const handleToggleSystemNotification = async () => { + if (systemNotificationEnabled) { + setSystemNotificationEnabled(false); + return; + } + if ((await ensureSystemPermission()) === "granted") { + setSystemNotificationEnabled(true); + } + }; + const handleTestNotification = async () => { setIsTesting(true); try { - const success = await sendTestNotification({ - enabled, - systemNotificationEnabled, - completionSound, - soundVolume, - categories: { - taskCompletion, - agentApproval, - errors, - sessionStatus, - gitOperations, - }, - }); + if ((await ensureSystemPermission()) !== "granted") return; + const success = await sendTestNotification(); if (success) { Message.success(t("notifications.test.sent")); } else { @@ -126,18 +122,6 @@ const NotificationsAdvancedBlocks: React.FC = () => { } }; - const handleToggleDockBadge = async () => { - const newEnabled = !dockBadgeEnabled; - setDockBadgeEnabled(newEnabled); - if (!newEnabled) { - try { - await invoke("clear_dock_badge"); - } catch (error) { - log.error("[Notifications] Failed to clear badge:", error); - } - } - }; - const handleVolumeChange: (value: number | [number, number]) => void = ( value ) => { @@ -147,18 +131,14 @@ const NotificationsAdvancedBlocks: React.FC = () => { const categoryValues = { taskCompletion, - agentApproval, errors, - sessionStatus, - gitOperations, + teamInbox, }; const categorySetters = { taskCompletion: setTaskCompletion, - agentApproval: setAgentApproval, errors: setErrors, - sessionStatus: setSessionStatus, - gitOperations: setGitOperations, + teamInbox: setTeamInbox, } as const; if (!enabled) { @@ -191,31 +171,28 @@ const NotificationsAdvancedBlocks: React.FC = () => { )} - {completionSound && ( - - {NOTIFICATION_CATEGORIES.map((category) => ( - - - categorySetters[category.key](!categoryValues[category.key]) - } - /> - - ))} - - )} + + {NOTIFICATION_CATEGORIES.map((category) => ( + + + categorySetters[category.key](!categoryValues[category.key]) + } + /> + + ))} + - setSystemNotificationEnabled(!systemNotificationEnabled) - } + disabled={isRequestingPermission} + onChange={() => void handleToggleSystemNotification()} /> - {systemNotificationEnabled && ( + {(systemNotificationEnabled || permissionStatus !== "unknown") && ( { - + setDockBadgeEnabled(!dockBadgeEnabled)} + /> @@ -265,13 +245,13 @@ const NotificationsAdvancedBlocks: React.FC = () => { size="default" onClick={handleTestNotification} loading={isTesting} - disabled={permissionStatus !== "granted"} + disabled={isRequestingPermission} > {t("notifications.notification")}