From e4d6315157035813e2c9c2def8bf21be04b822ea Mon Sep 17 00:00:00 2001 From: Lukas Lipka Date: Sat, 31 Jan 2026 11:14:51 +0100 Subject: [PATCH] feat: allow system notifications for background agent completions --- package-lock.json | 10 + package.json | 1 + src-tauri/Cargo.lock | 69 ++++- src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 1 + src-tauri/src/lib.rs | 1 + src-tauri/src/types.rs | 11 + src/App.tsx | 9 + .../app/hooks/useUpdaterController.ts | 18 ++ .../hooks/useAgentSystemNotifications.ts | 248 ++++++++++++++++++ .../settings/components/SettingsView.test.tsx | 5 + .../settings/components/SettingsView.tsx | 30 +++ src/features/settings/hooks/useAppSettings.ts | 1 + src/services/tauri.ts | 11 + src/types.ts | 1 + 15 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 src/features/notifications/hooks/useAgentSystemNotifications.ts diff --git a/package-lock.json b/package-lock.json index a809640f..3f3ba811 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@tanstack/react-virtual": "^3.13.18", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.9.0", @@ -2012,6 +2013,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-notification": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz", + "integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", diff --git a/package.json b/package.json index 1fb07dc1..094d9294 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@tanstack/react-virtual": "^3.13.18", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.9.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9c9670a0..52159b44 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -623,6 +623,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-liquid-glass", + "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-updater", @@ -2389,6 +2390,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "mach2" version = "0.4.3" @@ -2611,6 +2624,20 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify-rust" +version = "4.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -3310,7 +3337,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -3471,6 +3498,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -4730,6 +4766,25 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.2", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.3" @@ -4910,6 +4965,18 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.18", + "windows 0.61.3", + "windows-version", +] + [[package]] name = "tempfile" version = "3.24.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c8f99b09..809ce302 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,6 +24,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["protocol-asset", "macos-private-api"] } tauri-plugin-liquid-glass = "0.1" +tauri-plugin-notification = "2" tauri-plugin-opener = "2" tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 97284105..b5a2e19d 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -12,6 +12,7 @@ "updater:default", "window-state:default", "liquid-glass:default", + "notification:default", "core:window:allow-set-effects", "core:window:allow-start-dragging" ] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 92791a6f..d59bd4db 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -68,6 +68,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_notification::init()) .invoke_handler(tauri::generate_handler![ settings::get_app_settings, settings::update_app_settings, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 5e3f421f..3e77a49c 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -408,6 +408,11 @@ pub(crate) struct AppSettings { pub(crate) notification_sounds_enabled: bool, #[serde(default = "default_preload_git_diffs", rename = "preloadGitDiffs")] pub(crate) preload_git_diffs: bool, + #[serde( + default = "default_system_notifications_enabled", + rename = "systemNotificationsEnabled" + )] + pub(crate) system_notifications_enabled: bool, #[serde( default = "default_experimental_collab_enabled", rename = "experimentalCollabEnabled" @@ -594,6 +599,10 @@ fn default_notification_sounds_enabled() -> bool { true } +fn default_system_notifications_enabled() -> bool { + true +} + fn default_preload_git_diffs() -> bool { true } @@ -758,6 +767,7 @@ impl Default for AppSettings { code_font_family: default_code_font_family(), code_font_size: default_code_font_size(), notification_sounds_enabled: true, + system_notifications_enabled: true, preload_git_diffs: default_preload_git_diffs(), experimental_collab_enabled: false, experimental_collaboration_modes_enabled: false, @@ -856,6 +866,7 @@ mod tests { assert!(settings.code_font_family.contains("SF Mono")); assert_eq!(settings.code_font_size, 11); assert!(settings.notification_sounds_enabled); + assert!(settings.system_notifications_enabled); assert!(settings.preload_git_diffs); assert!(!settings.experimental_steer_enabled); assert!(!settings.dictation_enabled); diff --git a/src/App.tsx b/src/App.tsx index 85490fbf..3ec70fd0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -254,13 +254,21 @@ function MainApp() { } = useSettingsModalState(); const composerInputRef = useRef(null); + const getWorkspaceName = useCallback( + (workspaceId: string) => workspacesById.get(workspaceId)?.name, + [workspacesById], + ); + const { updaterState, startUpdate, dismissUpdate, handleTestNotificationSound, + handleTestSystemNotification, } = useUpdaterController({ notificationSoundsEnabled: appSettings.notificationSoundsEnabled, + systemNotificationsEnabled: appSettings.systemNotificationsEnabled, + getWorkspaceName, onDebug: addDebugEntry, successSoundUrl, errorSoundUrl, @@ -2141,6 +2149,7 @@ function MainApp() { scaleShortcutTitle, scaleShortcutText, onTestNotificationSound: handleTestNotificationSound, + onTestSystemNotification: handleTestSystemNotification, dictationModelStatus: dictationModel.status, onDownloadDictationModel: dictationModel.download, onCancelDictationDownload: dictationModel.cancel, diff --git a/src/features/app/hooks/useUpdaterController.ts b/src/features/app/hooks/useUpdaterController.ts index d2ec40b2..7c1549a6 100644 --- a/src/features/app/hooks/useUpdaterController.ts +++ b/src/features/app/hooks/useUpdaterController.ts @@ -1,14 +1,18 @@ import { useCallback, useRef } from "react"; import { useUpdater } from "../../update/hooks/useUpdater"; import { useAgentSoundNotifications } from "../../notifications/hooks/useAgentSoundNotifications"; +import { useAgentSystemNotifications } from "../../notifications/hooks/useAgentSystemNotifications"; import { useWindowFocusState } from "../../layout/hooks/useWindowFocusState"; import { useTauriEvent } from "./useTauriEvent"; import { playNotificationSound } from "../../../utils/notificationSounds"; import { subscribeUpdaterCheck } from "../../../services/events"; +import { sendNotification } from "../../../services/tauri"; import type { DebugEntry } from "../../../types"; type Params = { notificationSoundsEnabled: boolean; + systemNotificationsEnabled: boolean; + getWorkspaceName?: (workspaceId: string) => string | undefined; onDebug: (entry: DebugEntry) => void; successSoundUrl: string; errorSoundUrl: string; @@ -16,6 +20,8 @@ type Params = { export function useUpdaterController({ notificationSoundsEnabled, + systemNotificationsEnabled, + getWorkspaceName, onDebug, successSoundUrl, errorSoundUrl, @@ -52,6 +58,13 @@ export function useUpdaterController({ onDebug, }); + useAgentSystemNotifications({ + enabled: systemNotificationsEnabled, + isWindowFocused, + getWorkspaceName, + onDebug, + }); + const handleTestNotificationSound = useCallback(() => { const useError = nextTestSoundIsError.current; nextTestSoundIsError.current = !useError; @@ -60,11 +73,16 @@ export function useUpdaterController({ playNotificationSound(url, type, onDebug); }, [errorSoundUrl, onDebug, successSoundUrl]); + const handleTestSystemNotification = useCallback(() => { + void sendNotification("Test Notification", "This is a test notification from CodexMonitor."); + }, []); + return { updaterState, startUpdate, checkForUpdates, dismissUpdate: dismiss, handleTestNotificationSound, + handleTestSystemNotification, }; } diff --git a/src/features/notifications/hooks/useAgentSystemNotifications.ts b/src/features/notifications/hooks/useAgentSystemNotifications.ts new file mode 100644 index 00000000..c3107f7a --- /dev/null +++ b/src/features/notifications/hooks/useAgentSystemNotifications.ts @@ -0,0 +1,248 @@ +import { useCallback, useMemo, useRef } from "react"; +import type { DebugEntry } from "../../../types"; +import { sendNotification } from "../../../services/tauri"; +import { useAppServerEvents } from "../../app/hooks/useAppServerEvents"; + +const DEFAULT_MIN_DURATION_MS = 60_000; // 1 minute +const MAX_BODY_LENGTH = 200; + +type SystemNotificationOptions = { + enabled: boolean; + isWindowFocused: boolean; + minDurationMs?: number; + getWorkspaceName?: (workspaceId: string) => string | undefined; + onDebug?: (entry: DebugEntry) => void; +}; + +function buildThreadKey(workspaceId: string, threadId: string) { + return `${workspaceId}:${threadId}`; +} + +function buildTurnKey(workspaceId: string, turnId: string) { + return `${workspaceId}:${turnId}`; +} + +function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + return text.slice(0, maxLength - 1) + "…"; +} + +export function useAgentSystemNotifications({ + enabled, + isWindowFocused, + minDurationMs = DEFAULT_MIN_DURATION_MS, + getWorkspaceName, + onDebug, +}: SystemNotificationOptions) { + const turnStartById = useRef(new Map()); + const turnStartByThread = useRef(new Map()); + const lastNotifiedAtByThread = useRef(new Map()); + const lastMessageByThread = useRef(new Map()); + + const notify = useCallback( + async (title: string, body: string, label: "success" | "error") => { + try { + await sendNotification(title, body); + onDebug?.({ + id: `${Date.now()}-client-notification-${label}`, + timestamp: Date.now(), + source: "client", + label: `notification/${label}`, + payload: { title, body }, + }); + } catch (error) { + onDebug?.({ + id: `${Date.now()}-client-notification-error`, + timestamp: Date.now(), + source: "error", + label: "notification/error", + payload: error instanceof Error ? error.message : String(error), + }); + } + }, + [onDebug], + ); + + const consumeDuration = useCallback( + (workspaceId: string, threadId: string, turnId: string) => { + const threadKey = buildThreadKey(workspaceId, threadId); + let startedAt: number | undefined; + + if (turnId) { + const turnKey = buildTurnKey(workspaceId, turnId); + startedAt = turnStartById.current.get(turnKey); + turnStartById.current.delete(turnKey); + } + + if (startedAt === undefined) { + startedAt = turnStartByThread.current.get(threadKey); + } + + if (startedAt !== undefined) { + turnStartByThread.current.delete(threadKey); + return Date.now() - startedAt; + } + + return null; + }, + [], + ); + + const recordStartIfMissing = useCallback( + (workspaceId: string, threadId: string) => { + const threadKey = buildThreadKey(workspaceId, threadId); + if (!turnStartByThread.current.has(threadKey)) { + turnStartByThread.current.set(threadKey, Date.now()); + } + }, + [], + ); + + const shouldNotify = useCallback( + (durationMs: number | null, threadKey: string) => { + if (durationMs === null) { + return false; + } + if (!enabled) { + return false; + } + if (durationMs < minDurationMs) { + return false; + } + if (isWindowFocused) { + return false; + } + const lastNotifiedAt = lastNotifiedAtByThread.current.get(threadKey); + if (lastNotifiedAt && Date.now() - lastNotifiedAt < 1500) { + return false; + } + lastNotifiedAtByThread.current.set(threadKey, Date.now()); + return true; + }, + [enabled, isWindowFocused, minDurationMs], + ); + + const getNotificationContent = useCallback( + (workspaceId: string, threadId: string, fallbackBody: string) => { + const title = getWorkspaceName?.(workspaceId) ?? "Agent Complete"; + const threadKey = buildThreadKey(workspaceId, threadId); + const lastMessage = lastMessageByThread.current.get(threadKey); + const body = lastMessage + ? truncateText(lastMessage, MAX_BODY_LENGTH) + : fallbackBody; + return { title, body }; + }, + [getWorkspaceName], + ); + + const handleTurnStarted = useCallback( + (workspaceId: string, threadId: string, turnId: string) => { + const startedAt = Date.now(); + turnStartByThread.current.set( + buildThreadKey(workspaceId, threadId), + startedAt, + ); + if (turnId) { + turnStartById.current.set(buildTurnKey(workspaceId, turnId), startedAt); + } + }, + [], + ); + + const handleTurnCompleted = useCallback( + (workspaceId: string, threadId: string, turnId: string) => { + const durationMs = consumeDuration(workspaceId, threadId, turnId); + const threadKey = buildThreadKey(workspaceId, threadId); + if (!shouldNotify(durationMs, threadKey)) { + return; + } + const { title, body } = getNotificationContent( + workspaceId, + threadId, + "Your agent has finished its task.", + ); + void notify(title, body, "success"); + }, + [consumeDuration, getNotificationContent, notify, shouldNotify], + ); + + const handleTurnError = useCallback( + ( + workspaceId: string, + threadId: string, + turnId: string, + payload: { message: string; willRetry: boolean }, + ) => { + if (payload.willRetry) { + return; + } + const durationMs = consumeDuration(workspaceId, threadId, turnId); + const threadKey = buildThreadKey(workspaceId, threadId); + if (!shouldNotify(durationMs, threadKey)) { + return; + } + const title = getWorkspaceName?.(workspaceId) ?? "Agent Error"; + const body = payload.message || "An error occurred."; + void notify(title, truncateText(body, MAX_BODY_LENGTH), "error"); + }, + [consumeDuration, getWorkspaceName, notify, shouldNotify], + ); + + const handleItemStarted = useCallback( + (workspaceId: string, threadId: string) => { + recordStartIfMissing(workspaceId, threadId); + }, + [recordStartIfMissing], + ); + + const handleAgentMessageDelta = useCallback( + (event: { workspaceId: string; threadId: string }) => { + recordStartIfMissing(event.workspaceId, event.threadId); + }, + [recordStartIfMissing], + ); + + const handleAgentMessageCompleted = useCallback( + (event: { workspaceId: string; threadId: string; text: string }) => { + const threadKey = buildThreadKey(event.workspaceId, event.threadId); + // Store the message text for use in turn completion notification + if (event.text) { + lastMessageByThread.current.set(threadKey, event.text); + } + const durationMs = consumeDuration(event.workspaceId, event.threadId, ""); + if (!shouldNotify(durationMs, threadKey)) { + return; + } + const { title, body } = getNotificationContent( + event.workspaceId, + event.threadId, + "Your agent has finished its task.", + ); + void notify(title, body, "success"); + }, + [consumeDuration, getNotificationContent, notify, shouldNotify], + ); + + const handlers = useMemo( + () => ({ + onTurnStarted: handleTurnStarted, + onTurnCompleted: handleTurnCompleted, + onTurnError: handleTurnError, + onItemStarted: handleItemStarted, + onAgentMessageDelta: handleAgentMessageDelta, + onAgentMessageCompleted: handleAgentMessageCompleted, + }), + [ + handleAgentMessageCompleted, + handleAgentMessageDelta, + handleItemStarted, + handleTurnCompleted, + handleTurnError, + handleTurnStarted, + ], + ); + + useAppServerEvents(handlers); +} diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index e79afc48..02fdb161 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -52,6 +52,7 @@ const baseSettings: AppSettings = { "\"SF Mono\", \"SFMono-Regular\", Menlo, Monaco, monospace", codeFontSize: 11, notificationSoundsEnabled: true, + systemNotificationsEnabled: true, preloadGitDiffs: true, experimentalCollabEnabled: false, experimentalCollaborationModesEnabled: false, @@ -131,6 +132,7 @@ const renderDisplaySection = ( scaleShortcutTitle: "Scale shortcut", scaleShortcutText: "Use Command +/-", onTestNotificationSound: vi.fn(), + onTestSystemNotification: vi.fn(), dictationModelStatus: null, onDownloadDictationModel: vi.fn(), onCancelDictationDownload: vi.fn(), @@ -356,6 +358,7 @@ describe("SettingsView Codex overrides", () => { scaleShortcutTitle="Scale shortcut" scaleShortcutText="Use Command +/-" onTestNotificationSound={vi.fn()} + onTestSystemNotification={vi.fn()} dictationModelStatus={null} onDownloadDictationModel={vi.fn()} onCancelDictationDownload={vi.fn()} @@ -403,6 +406,7 @@ describe("SettingsView Shortcuts", () => { scaleShortcutTitle="Scale shortcut" scaleShortcutText="Use Command +/-" onTestNotificationSound={vi.fn()} + onTestSystemNotification={vi.fn()} dictationModelStatus={null} onDownloadDictationModel={vi.fn()} onCancelDictationDownload={vi.fn()} @@ -443,6 +447,7 @@ describe("SettingsView Shortcuts", () => { scaleShortcutTitle="Scale shortcut" scaleShortcutText="Use Command +/-" onTestNotificationSound={vi.fn()} + onTestSystemNotification={vi.fn()} dictationModelStatus={null} onDownloadDictationModel={vi.fn()} onCancelDictationDownload={vi.fn()} diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index e6df84d6..80c078c0 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -162,6 +162,7 @@ export type SettingsViewProps = { scaleShortcutTitle: string; scaleShortcutText: string; onTestNotificationSound: () => void; + onTestSystemNotification: () => void; dictationModelStatus?: DictationModelStatus | null; onDownloadDictationModel?: () => void; onCancelDictationDownload?: () => void; @@ -273,6 +274,7 @@ export function SettingsView({ scaleShortcutTitle, scaleShortcutText, onTestNotificationSound, + onTestSystemNotification, dictationModelStatus, onDownloadDictationModel, onCancelDictationDownload, @@ -1528,6 +1530,27 @@ export function SettingsView({ +
+
+
System notifications
+
+ Show a macOS notification when a long-running agent finishes while the window is unfocused. +
+
+ +
+
)} diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 1c3ed8c1..ee513dfb 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -52,6 +52,7 @@ const defaultSettings: AppSettings = { codeFontFamily: DEFAULT_CODE_FONT_FAMILY, codeFontSize: CODE_FONT_SIZE_DEFAULT, notificationSoundsEnabled: true, + systemNotificationsEnabled: true, preloadGitDiffs: true, experimentalCollabEnabled: false, experimentalCollaborationModesEnabled: false, diff --git a/src/services/tauri.ts b/src/services/tauri.ts index 16e6438d..1fd377e4 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -738,3 +738,14 @@ export async function generateCommitMessage( ): Promise { return invoke("generate_commit_message", { workspaceId }); } + +export async function sendNotification( + title: string, + body: string, +): Promise { + const notification = await import("@tauri-apps/plugin-notification"); + const permission = await notification.requestPermission(); + if (permission === "granted") { + await notification.sendNotification({ title, body }); + } +} diff --git a/src/types.ts b/src/types.ts index a42ceda8..fa158529 100644 --- a/src/types.ts +++ b/src/types.ts @@ -146,6 +146,7 @@ export type AppSettings = { codeFontFamily: string; codeFontSize: number; notificationSoundsEnabled: boolean; + systemNotificationsEnabled: boolean; preloadGitDiffs: boolean; experimentalCollabEnabled: boolean; experimentalCollaborationModesEnabled: boolean;