From c9549186b36234704e1e6160a115654d861e02ca Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Tue, 28 Jul 2026 14:11:09 +0800 Subject: [PATCH] feat(notifications): notify background permission requests Add a default-enabled permission request notification preference. Notify for actionable core and ACP approval requests while BitFun is in the background, with request de-duplication and same-round batching. Keep the listener active in toolbar mode and apply preference changes immediately. --- .../assembly/core/src/service/config/types.rs | 22 +++- .../permissionRequestNotifyPolicy.test.ts | 72 +++++++++++ .../hooks/permissionRequestNotifyPolicy.ts | 104 ++++++++++++++++ .../app/hooks/usePermissionRequestNotify.ts | 113 ++++++++++++++++++ src/web-ui/src/app/layout/AppLayout.tsx | 2 + .../AcpPermissionToolCardModule.test.ts | 107 +++++++++++++++++ .../AcpPermissionToolCardModule.ts | 15 ++- .../config/components/BasicsConfig.tsx | 33 ++++- .../src/infrastructure/config/types/index.ts | 2 + .../src/infrastructure/event-bus/index.ts | 1 + .../permissionRequestNotificationEvent.ts | 14 +++ src/web-ui/src/locales/en-US/common.json | 5 +- .../src/locales/en-US/settings/basics.json | 4 + src/web-ui/src/locales/zh-CN/common.json | 5 +- .../src/locales/zh-CN/settings/basics.json | 4 + src/web-ui/src/locales/zh-TW/common.json | 5 +- .../src/locales/zh-TW/settings/basics.json | 4 + 17 files changed, 504 insertions(+), 8 deletions(-) create mode 100644 src/web-ui/src/app/hooks/permissionRequestNotifyPolicy.test.ts create mode 100644 src/web-ui/src/app/hooks/permissionRequestNotifyPolicy.ts create mode 100644 src/web-ui/src/app/hooks/usePermissionRequestNotify.ts create mode 100644 src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.test.ts create mode 100644 src/web-ui/src/infrastructure/event-bus/permissionRequestNotificationEvent.ts diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 01ae0265a0..7f762f4f71 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -413,6 +413,9 @@ pub struct NotificationConfig { /// Whether to show a toast notification when a dialog turn completes while the window is not focused. #[serde(default = "default_true")] pub dialog_completion_notify: bool, + /// Whether to show a toast notification when an approval request arrives while the window is not focused. + #[serde(default = "default_true")] + pub permission_request_notify: bool, /// Whether to show built-in tip cards on startup (can be disabled by the user). #[serde(default = "default_true")] pub enable_startup_tips: bool, @@ -1703,6 +1706,7 @@ impl Default for AppConfig { position: "topRight".to_string(), duration: 5000, dialog_completion_notify: true, + permission_request_notify: true, enable_startup_tips: true, }, flow_chat: AppFlowChatConfig::default(), @@ -1955,6 +1959,7 @@ impl Default for NotificationConfig { position: "topRight".to_string(), duration: 5000, dialog_completion_notify: true, + permission_request_notify: true, enable_startup_tips: true, } } @@ -2106,7 +2111,7 @@ mod tests { use super::{ AIConfig, AIExperienceConfig, AIModelConfig, AgentModelDefaultsConfig, AgentProfileConfig, AgentProfileView, AppConfig, AppLoggingConfig, GlobalConfig, MemoryExternalContextPolicy, - ModelExchangeTracingMode, ReasoningMode, SubagentBatchExecutionPolicy, + ModelExchangeTracingMode, NotificationConfig, ReasoningMode, SubagentBatchExecutionPolicy, SubagentModelSelection, UserSkillGroupsConfig, UserToolGroupsConfig, }; use bitfun_runtime_ports::ToolPermissionConfig; @@ -2120,6 +2125,15 @@ mod tests { assert!(!config.prevent_sleep); } + #[test] + fn permission_request_notifications_default_to_enabled() { + assert!(NotificationConfig::default().permission_request_notify); + + let config: NotificationConfig = serde_json::from_value(serde_json::json!({})) + .expect("empty notification config should default"); + assert!(config.permission_request_notify); + } + #[test] fn agent_profile_defaults_keep_all_collections_empty() { let config = AgentProfileConfig::default(); @@ -2397,6 +2411,7 @@ mod tests { "position": "top-right", "duration": 4000, "dialog_completion_notify": true, + "permission_request_notify": false, "enable_startup_tips": true }, "ai_experience": { @@ -2420,6 +2435,7 @@ mod tests { .expect("minimal app config with quick_actions should deserialize"); let actions = &config.app.ai_experience.quick_actions; + assert!(!config.app.notifications.permission_request_notify); assert_eq!(actions.len(), 1); assert_eq!(actions[0].id, "custom_1"); assert_eq!(actions[0].label, "Run tests"); @@ -2429,6 +2445,10 @@ mod tests { serialized["app"]["ai_experience"]["quick_actions"][0]["id"], "custom_1" ); + assert_eq!( + serialized["app"]["notifications"]["permission_request_notify"], + false + ); } #[test] diff --git a/src/web-ui/src/app/hooks/permissionRequestNotifyPolicy.test.ts b/src/web-ui/src/app/hooks/permissionRequestNotifyPolicy.test.ts new file mode 100644 index 0000000000..5a1b4b1aa1 --- /dev/null +++ b/src/web-ui/src/app/hooks/permissionRequestNotifyPolicy.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + buildPermissionRequestNotificationCopy, + PermissionRequestNotificationBatcher, + PERMISSION_REQUEST_NOTIFY_COALESCE_MS, + shouldSendPermissionRequestNotification, +} from './permissionRequestNotifyPolicy'; + +describe('PermissionRequestNotificationBatcher', () => { + it('de-duplicates requests and aggregates requests from the same session round', () => { + vi.useFakeTimers(); + const onBatchReady = vi.fn(); + const batcher = new PermissionRequestNotificationBatcher(onBatchReady); + + batcher.enqueue({ requestId: 'request-1', sessionId: 'session-1', roundId: 'round-1' }); + batcher.enqueue({ requestId: 'request-2', sessionId: 'session-1', roundId: 'round-1' }); + batcher.enqueue({ requestId: 'request-1', sessionId: 'session-1', roundId: 'round-1' }); + + vi.advanceTimersByTime(PERMISSION_REQUEST_NOTIFY_COALESCE_MS); + + expect(onBatchReady).toHaveBeenCalledTimes(1); + expect(onBatchReady).toHaveBeenCalledWith({ requestCount: 2 }); + batcher.dispose(); + vi.useRealTimers(); + }); + + it('keeps requests from different rounds in separate notifications', () => { + vi.useFakeTimers(); + const onBatchReady = vi.fn(); + const batcher = new PermissionRequestNotificationBatcher(onBatchReady); + + batcher.enqueue({ requestId: 'request-1', sessionId: 'session-1', roundId: 'round-1' }); + batcher.enqueue({ requestId: 'request-2', sessionId: 'session-1', roundId: 'round-2' }); + + vi.advanceTimersByTime(PERMISSION_REQUEST_NOTIFY_COALESCE_MS); + + expect(onBatchReady).toHaveBeenCalledTimes(2); + expect(onBatchReady).toHaveBeenCalledWith({ requestCount: 1 }); + batcher.dispose(); + vi.useRealTimers(); + }); +}); + +describe('permission request notification policy', () => { + it('requires both a background window and an enabled preference', () => { + expect(shouldSendPermissionRequestNotification({ + isBackground: false, + notificationsEnabled: true, + })).toBe(false); + expect(shouldSendPermissionRequestNotification({ + isBackground: true, + notificationsEnabled: false, + })).toBe(false); + expect(shouldSendPermissionRequestNotification({ + isBackground: true, + notificationsEnabled: true, + })).toBe(true); + }); + + it('uses generic copy without accepting request details', () => { + const t = (key: string, options?: Record) => { + if (key === 'notify.permissionRequestTitle') return 'BitFun requires approval'; + if (key === 'notify.permissionRequestBody') return 'An action is waiting for your approval.'; + return `${options?.count} actions are waiting for your approval.`; + }; + + expect(buildPermissionRequestNotificationCopy({ requestCount: 2, t })).toEqual({ + title: 'BitFun requires approval', + body: '2 actions are waiting for your approval.', + }); + }); +}); diff --git a/src/web-ui/src/app/hooks/permissionRequestNotifyPolicy.ts b/src/web-ui/src/app/hooks/permissionRequestNotifyPolicy.ts new file mode 100644 index 0000000000..651acbe584 --- /dev/null +++ b/src/web-ui/src/app/hooks/permissionRequestNotifyPolicy.ts @@ -0,0 +1,104 @@ +import type { PermissionRequestNotificationEvent } from '@/infrastructure/event-bus'; + +export const PERMISSION_REQUEST_NOTIFY_COALESCE_MS = 400; + +const MAX_DEDUPLICATED_REQUESTS = 1_000; + +interface PermissionRequestNotificationInput { + isBackground: boolean; + notificationsEnabled?: boolean; +} + +interface PermissionRequestNotificationCopyInput { + requestCount: number; + t: (key: string, options?: Record) => string; +} + +export interface PermissionRequestNotificationBatch { + requestCount: number; +} + +interface PendingBatch { + requestIds: Set; + timer: ReturnType; +} + +/** + * Keeps duplicate runtime events from creating repeated OS notifications and + * groups approval prompts raised by the same model round into one notification. + */ +export class PermissionRequestNotificationBatcher { + private readonly seenRequestIds = new Set(); + private readonly pendingBatches = new Map(); + + constructor( + private readonly onBatchReady: (batch: PermissionRequestNotificationBatch) => void, + private readonly coalesceMs = PERMISSION_REQUEST_NOTIFY_COALESCE_MS, + ) {} + + enqueue(request: PermissionRequestNotificationEvent): void { + if (!request.requestId || this.seenRequestIds.has(request.requestId)) { + return; + } + + this.rememberRequestId(request.requestId); + + const batchKey = request.roundId + ? JSON.stringify([request.sessionId, request.roundId]) + : `request:${request.requestId}`; + const existingBatch = this.pendingBatches.get(batchKey); + if (existingBatch) { + existingBatch.requestIds.add(request.requestId); + return; + } + + const requestIds = new Set([request.requestId]); + const timer = setTimeout(() => { + const batch = this.pendingBatches.get(batchKey); + if (!batch) { + return; + } + this.pendingBatches.delete(batchKey); + this.onBatchReady({ requestCount: batch.requestIds.size }); + }, this.coalesceMs); + + this.pendingBatches.set(batchKey, { requestIds, timer }); + } + + dispose(): void { + for (const batch of this.pendingBatches.values()) { + clearTimeout(batch.timer); + } + this.pendingBatches.clear(); + } + + private rememberRequestId(requestId: string): void { + this.seenRequestIds.add(requestId); + while (this.seenRequestIds.size > MAX_DEDUPLICATED_REQUESTS) { + const oldestRequestId = this.seenRequestIds.values().next().value; + if (!oldestRequestId) { + return; + } + this.seenRequestIds.delete(oldestRequestId); + } + } +} + +export function shouldSendPermissionRequestNotification({ + isBackground, + notificationsEnabled, +}: PermissionRequestNotificationInput): boolean { + return isBackground && notificationsEnabled !== false; +} + +export function buildPermissionRequestNotificationCopy({ + requestCount, + t, +}: PermissionRequestNotificationCopyInput): { title: string; body: string } { + return { + title: t('notify.permissionRequestTitle'), + body: requestCount === 1 + ? t('notify.permissionRequestBody') + : t('notify.permissionRequestBatchBody', { count: requestCount }), + }; +} diff --git a/src/web-ui/src/app/hooks/usePermissionRequestNotify.ts b/src/web-ui/src/app/hooks/usePermissionRequestNotify.ts new file mode 100644 index 0000000000..71d72918cc --- /dev/null +++ b/src/web-ui/src/app/hooks/usePermissionRequestNotify.ts @@ -0,0 +1,113 @@ +import { useEffect, useRef } from 'react'; +import { agentAPI } from '@/infrastructure/api'; +import { + globalEventBus, + PERMISSION_REQUEST_NOTIFICATION_EVENT, + type PermissionRequestNotificationEvent, +} from '@/infrastructure/event-bus'; +import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; +import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import { useI18n } from '@/infrastructure/i18n'; +import { createLogger } from '@/shared/utils/logger'; +import { + buildPermissionRequestNotificationCopy, + PermissionRequestNotificationBatcher, + shouldSendPermissionRequestNotification, +} from './permissionRequestNotifyPolicy'; + +const log = createLogger('usePermissionRequestNotify'); + +const PERMISSION_REQUEST_NOTIFY_CONFIG_KEY = 'app.notifications.permission_request_notify'; + +/** + * Sends one generic desktop notification for user-actionable permission + * requests received while BitFun is in the background. Core runtime and ACP + * requests share the same policy, de-duplication, and round-level batching. + */ +export const usePermissionRequestNotify = () => { + const { t } = useI18n('common'); + const windowFocusedRef = useRef(true); + + useEffect(() => { + let disposed = false; + const handleFocus = () => { windowFocusedRef.current = true; }; + const handleBlur = () => { windowFocusedRef.current = false; }; + windowFocusedRef.current = document.hasFocus(); + const isBackground = () => document.hidden || !windowFocusedRef.current; + + const sendBatchNotification = async (requestCount: number) => { + let enabled = true; + try { + enabled = await configManager.getConfig(PERMISSION_REQUEST_NOTIFY_CONFIG_KEY); + } catch (error) { + log.warn('Failed to read permission_request_notify config', error); + } + + if (!shouldSendPermissionRequestNotification({ + isBackground: isBackground(), + notificationsEnabled: enabled, + })) { + return; + } + + const notificationCopy = buildPermissionRequestNotificationCopy({ requestCount, t }); + await systemAPI.sendSystemNotification(notificationCopy.title, notificationCopy.body); + }; + + const batcher = new PermissionRequestNotificationBatcher(({ requestCount }) => { + void sendBatchNotification(requestCount); + }); + + const enqueueWhenBackground = (request: PermissionRequestNotificationEvent) => { + if (isBackground()) { + batcher.enqueue(request); + } + }; + + window.addEventListener('focus', handleFocus); + window.addEventListener('blur', handleBlur); + + const unlistenPermissionRequests = agentAPI.onPermissionRequestEvent((event) => { + if (event.event !== 'asked') { + return; + } + enqueueWhenBackground({ + requestId: event.request.requestId, + sessionId: event.request.sessionId, + roundId: event.request.roundId, + }); + }); + const unlistenAcpPermissionRequests = globalEventBus.on( + PERMISSION_REQUEST_NOTIFICATION_EVENT, + enqueueWhenBackground, + ); + + void (async () => { + try { + await agentAPI.subscribePermissionRequests(); + const pendingRequests = await agentAPI.listPendingPermissionRequests(); + if (disposed) { + return; + } + pendingRequests.forEach((request) => { + enqueueWhenBackground({ + requestId: request.requestId, + sessionId: request.sessionId, + roundId: request.roundId, + }); + }); + } catch (error) { + log.warn('Failed to subscribe to permission request events', error); + } + })(); + + return () => { + disposed = true; + window.removeEventListener('focus', handleFocus); + window.removeEventListener('blur', handleBlur); + unlistenPermissionRequests(); + unlistenAcpPermissionRequests(); + batcher.dispose(); + }; + }, [t]); +}; diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index a59d061ab1..855fa7b8a4 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -13,6 +13,7 @@ import { useWorkspaceContext } from '../../infrastructure/contexts/WorkspaceCont import { useWindowControls } from '../hooks/useWindowControls'; import { isWindowFullscreenShortcut } from '../hooks/windowFullscreenShortcut'; import { useAssistantBootstrap } from '../hooks/useAssistantBootstrap'; +import { usePermissionRequestNotify } from '../hooks/usePermissionRequestNotify'; import { useApp } from '../hooks/useApp'; import { useSceneStore } from '../stores/sceneStore'; import { useShortcut } from '@/infrastructure/hooks/useShortcut'; @@ -77,6 +78,7 @@ interface WindowModeHint { const AppLayout: React.FC = ({ className = '' }) => { const { t } = useI18n('components'); const { t: tCommon } = useI18n('common'); + usePermissionRequestNotify(); const { currentWorkspace, hasWorkspace, diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.test.ts new file mode 100644 index 0000000000..eb2a108c90 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + globalEventBus, + PERMISSION_REQUEST_NOTIFICATION_EVENT, + type PermissionRequestNotificationEvent, +} from '@/infrastructure/event-bus'; +import { FlowChatStore } from '../../store/FlowChatStore'; +import type { FlowToolItem, Session } from '../../types/flow-chat'; +import { + applyPendingAcpPermissionForTool, + handleAcpPermissionRequestForToolCard, +} from './AcpPermissionToolCardModule'; + +describe('ACP permission notification bridge', () => { + const received: PermissionRequestNotificationEvent[] = []; + let unlisten: (() => void) | undefined; + + beforeEach(() => { + FlowChatStore.getInstance().setState(() => ({ + sessions: new Map(), + activeSessionId: null, + })); + unlisten = globalEventBus.on( + PERMISSION_REQUEST_NOTIFICATION_EVENT, + (event) => received.push(event), + ); + }); + + afterEach(() => { + received.length = 0; + unlisten?.(); + }); + + it('emits after an ACP request is applied to its matching tool card', () => { + addToolCard('tool-1'); + + expect(handleAcpPermissionRequestForToolCard({ + permissionId: 'permission-1', + sessionId: 'acp-session-1', + toolCall: { toolCallId: 'tool-1' }, + })).toBe(true); + + expect(received).toEqual([{ + requestId: 'permission-1', + sessionId: 'session-1', + roundId: 'round-1', + }]); + }); + + it('waits to emit until a deferred ACP request has a matching tool card', () => { + expect(handleAcpPermissionRequestForToolCard({ + permissionId: 'permission-2', + sessionId: 'acp-session-2', + toolCall: { toolCallId: 'tool-2' }, + })).toBe(true); + expect(received).toEqual([]); + + addToolCard('tool-2'); + applyPendingAcpPermissionForTool(FlowChatStore.getInstance(), 'tool-2'); + + expect(received).toEqual([{ + requestId: 'permission-2', + sessionId: 'session-1', + roundId: 'round-1', + }]); + }); +}); + +function addToolCard(toolId: string): void { + const tool: FlowToolItem = { + id: toolId, + type: 'tool', + toolName: 'Tool', + timestamp: 1000, + status: 'streaming', + toolCall: { id: toolId, input: {} }, + }; + + FlowChatStore.getInstance().setState(() => ({ + sessions: new Map([['session-1', { + sessionId: 'session-1', + title: 'Session', + dialogTurns: [{ + id: 'turn-1', + sessionId: 'session-1', + userMessage: { id: 'user-1', content: 'Request', timestamp: 900 }, + modelRounds: [{ + id: 'round-1', + index: 0, + items: [tool], + isStreaming: true, + isComplete: false, + status: 'streaming', + startTime: 1000, + }], + status: 'processing', + startTime: 900, + }], + status: 'idle', + config: { agentType: 'agentic' }, + createdAt: 800, + lastActiveAt: 1000, + error: null, + } as Session]]), + activeSessionId: 'session-1', + })); +} diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts index b4f658d20f..480efdd6c0 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/AcpPermissionToolCardModule.ts @@ -5,6 +5,10 @@ import { FlowChatStore } from '../../store/FlowChatStore'; import type { FlowToolItem } from '../../types/flow-chat'; import type { AcpPermissionRequestEvent } from '@/infrastructure/api/service-api/ACPClientAPI'; +import { + globalEventBus, + PERMISSION_REQUEST_NOTIFICATION_EVENT, +} from '@/infrastructure/event-bus'; const pendingAcpPermissionRequests = new Map(); @@ -18,7 +22,7 @@ function acpPermissionToolId(event: AcpPermissionRequestEvent): string | null { function findToolContextById( store: FlowChatStore, toolId: string -): { sessionId: string; turnId: string; itemId: string } | null { +): { sessionId: string; turnId: string; roundId: string; itemId: string } | null { const state = store.getState(); for (const [sessionId, session] of state.sessions) { for (const turn of session.dialogTurns) { @@ -29,7 +33,7 @@ function findToolContextById( )) as FlowToolItem | undefined; if (item) { - return { sessionId, turnId: turn.id, itemId: item.id }; + return { sessionId, turnId: turn.id, roundId: round.id, itemId: item.id }; } } } @@ -66,6 +70,13 @@ function applyAcpPermissionRequest( store.setSessionNeedsAttention(toolContext.sessionId, 'tool_confirm'); } + // Emit only after the request is represented by an actionable tool card. + globalEventBus.emit(PERMISSION_REQUEST_NOTIFICATION_EVENT, { + requestId: event.permissionId, + sessionId: toolContext.sessionId, + roundId: toolContext.roundId, + }); + return true; } diff --git a/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx b/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx index f13f47ab89..0833850131 100644 --- a/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/BasicsConfig.tsx @@ -881,8 +881,10 @@ function BasicsWindowBehaviorSection() { ); } -function BasicsNotificationsSection() { const { t } = useTranslation('settings/basics'); +function BasicsNotificationsSection() { + const { t } = useTranslation('settings/basics'); const [dialogNotify, setDialogNotify] = useState(true); + const [permissionRequestNotify, setPermissionRequestNotify] = useState(true); const [startupTips, setStartupTips] = useState(true); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); @@ -890,14 +892,17 @@ function BasicsNotificationsSection() { const { t } = useTranslation('settings/ useEffect(() => { void (async () => { try { - const [notify, tips] = await Promise.all([ + const [notify, permissionNotify, tips] = await Promise.all([ configManager.getConfig('app.notifications.dialog_completion_notify'), + configManager.getConfig('app.notifications.permission_request_notify'), configManager.getConfig('app.notifications.enable_startup_tips'), ]); setDialogNotify(notify !== false); + setPermissionRequestNotify(permissionNotify !== false); setStartupTips(tips !== false); } catch { setDialogNotify(true); + setPermissionRequestNotify(true); setStartupTips(true); } })(); @@ -916,6 +921,19 @@ function BasicsNotificationsSection() { const { t } = useTranslation('settings/ } }; + const handlePermissionRequestNotifyToggle = async (checked: boolean) => { + setSaving(true); + try { + await configManager.setConfig('app.notifications.permission_request_notify', checked); + setPermissionRequestNotify(checked); + setMessage({ type: 'success', text: t('notifications.messages.saveSuccess') }); + } catch { + setMessage({ type: 'error', text: t('notifications.messages.saveFailed') }); + } finally { + setSaving(false); + } + }; + const handleStartupTipsToggle = async (checked: boolean) => { setSaving(true); try { @@ -946,6 +964,17 @@ function BasicsNotificationsSection() { const { t } = useTranslation('settings/ disabled={saving} /> + + { void handlePermissionRequestNotifyToggle(e.target.checked); }} + disabled={saving} + /> +