diff --git a/Cargo.lock b/Cargo.lock
index 437213d..9d5349c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1482,6 +1482,7 @@ dependencies = [
  "tauri",
  "tauri-build",
  "tauri-plugin-dialog",
+ "tauri-plugin-notification",
  "tauri-plugin-opener",
  "tauri-plugin-os",
  "tauri-plugin-process",
@@ -2332,6 +2333,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.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3"
+dependencies = [
+ "cc",
+ "objc2",
+ "objc2-foundation",
+ "time",
+]
+
 [[package]]
 name = "markup5ever"
 version = "0.14.1"
@@ -2549,6 +2562,20 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "notify-rust"
+version = "4.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d782ebad628d1a24303e9a0aac4656115ceb482ba56c66c4af3d85530852d9a"
+dependencies = [
+ "futures-lite",
+ "log",
+ "mac-notification-sys",
+ "serde",
+ "tauri-winrt-notification",
+ "zbus",
+]
+
 [[package]]
 name = "nu-ansi-term"
 version = "0.50.3"
@@ -3223,7 +3250,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
 dependencies = [
  "base64 0.22.1",
  "indexmap 2.13.0",
- "quick-xml",
+ "quick-xml 0.38.4",
  "serde",
  "time",
 ]
@@ -3363,6 +3390,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"
@@ -3418,6 +3454,16 @@ dependencies = [
  "rand_core 0.6.4",
 ]
 
+[[package]]
+name = "rand"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
 [[package]]
 name = "rand_chacha"
 version = "0.2.2"
@@ -3438,6 +3484,16 @@ dependencies = [
  "rand_core 0.6.4",
 ]
 
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
 [[package]]
 name = "rand_core"
 version = "0.5.1"
@@ -3456,6 +3512,15 @@ dependencies = [
  "getrandom 0.2.17",
 ]
 
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
 [[package]]
 name = "rand_hc"
 version = "0.2.0"
@@ -4632,6 +4697,25 @@ dependencies = [
  "url",
 ]
 
+[[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"
@@ -4846,6 +4930,18 @@ dependencies = [
  "toml 0.9.12+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",
+ "windows-version",
+]
+
 [[package]]
 name = "tempfile"
 version = "3.27.0"
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 5f27a1b..de2f539 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -19,11 +19,12 @@ vendored-openssl = ["git-sync-lib/vendored-openssl"]
 git-sync-lib    = { path = "../crates/git-sync-lib" }
 
 tauri                 = { version = "2", features = ["tray-icon"] }
-tauri-plugin-dialog   = "2"
-tauri-plugin-os       = "2"
-tauri-plugin-process  = "2"
-tauri-plugin-updater  = "2"
-tauri-plugin-opener   = "2"
+tauri-plugin-dialog       = "2"
+tauri-plugin-notification = "2"
+tauri-plugin-opener       = "2"
+tauri-plugin-os           = "2"
+tauri-plugin-process      = "2"
+tauri-plugin-updater      = "2"
 
 specta              = { version = "=2.0.0-rc.24", features = ["derive", "chrono"] }
 specta-typescript   = "0.0.11"
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index 1e7a6dd..1c6bf77 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -10,6 +10,7 @@
 		"core:window:allow-toggle-maximize",
 		"core:window:allow-close",
 		"dialog:allow-open",
+		"notification:default",
 		"os:default",
 		"process:default",
 		"updater:default",
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 9002c87..de8f5c2 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -16,7 +16,7 @@ use tracing_subscriber::prelude::*;
 
 use config::{load_config, DesktopConfig};
 use log_layer::{FrontendLogEntry, TauriLogLayer};
-use status::AppStatus;
+use status::{AppStatus, SyncErrorPayload};
 use worker::BgCmd;
 
 // ── Bindings export (test-only utility) ───────────────────────────────────────
@@ -138,6 +138,7 @@ pub fn run() {
     let invoke_handler = builder.invoke_handler();
 
     tauri::Builder::default()
+        .plugin(tauri_plugin_notification::init())
         .plugin(tauri_plugin_updater::Builder::new().build())
         .plugin(tauri_plugin_process::init())
         .plugin(tauri_plugin_os::init())
@@ -176,15 +177,42 @@ pub fn run() {
 
             win_builder.build()?;
 
-            // Push status updates to the frontend whenever the worker sends a new snapshot.
+            // Push status updates to the frontend and send desktop notifications
+            // whenever a repository transitions into an error or conflict state.
             let app_handle = app.handle().clone();
             let mut rx = status_rx_forwarder;
             tauri::async_runtime::spawn(async move {
+                let mut prev_error_keys: Vec<Option<String>> =
+                    vec![None; rx.borrow().repos.len()];
                 loop {
                     if rx.changed().await.is_err() {
                         break;
                     }
                     let snapshot = rx.borrow_and_update().clone();
+
+                    // Reset tracking when the repo count changes (e.g. after reconfigure).
+                    if prev_error_keys.len() != snapshot.repos.len() {
+                        prev_error_keys = vec![None; snapshot.repos.len()];
+                    }
+
+                    for (i, repo) in snapshot.repos.iter().enumerate() {
+                        let key = error_key(&repo.error);
+                        if key.is_some() && key.as_deref() != prev_error_keys[i].as_deref() {
+                            if let Some(ref err) = repo.error {
+                                let (title, body) = notification_text(&repo.repo_path, err);
+                                use tauri_plugin_notification::NotificationExt;
+                                app_handle
+                                    .notification()
+                                    .builder()
+                                    .title(&title)
+                                    .body(&body)
+                                    .show()
+                                    .ok();
+                            }
+                        }
+                        prev_error_keys[i] = key;
+                    }
+
                     StatusUpdateEvent(snapshot).emit(&app_handle).ok();
                 }
             });
@@ -259,3 +287,57 @@ pub fn run() {
         .run(tauri::generate_context!())
         .expect("error while running tauri application");
 }
+
+/// Returns a short string key identifying the current error category, used to
+/// detect transitions into a new error state so we don't re-notify on every tick.
+fn error_key(error: &Option<SyncErrorPayload>) -> Option<String> {
+    error.as_ref().map(|e| match e {
+        SyncErrorPayload::Auth { .. } => "auth".to_string(),
+        SyncErrorPayload::Network { .. } => "network".to_string(),
+        SyncErrorPayload::Conflict { .. } => "conflict".to_string(),
+        SyncErrorPayload::ConflictBranch { .. } => "conflict_branch".to_string(),
+        SyncErrorPayload::Config { .. } => "config".to_string(),
+        SyncErrorPayload::State { .. } => "state".to_string(),
+        SyncErrorPayload::Unknown { .. } => "unknown".to_string(),
+    })
+}
+
+/// Builds the notification title and body for a repository error.
+fn notification_text(repo_path: &str, error: &SyncErrorPayload) -> (String, String) {
+    let repo_name = std::path::Path::new(repo_path)
+        .file_name()
+        .and_then(|n| n.to_str())
+        .unwrap_or("Unknown repository")
+        .to_string();
+
+    match error {
+        SyncErrorPayload::Conflict { message } => (
+            format!("{}: Merge conflict", repo_name),
+            message.clone(),
+        ),
+        SyncErrorPayload::ConflictBranch { branch, message } => (
+            format!("{}: Conflicts saved to branch", repo_name),
+            format!("{} — {}", branch, message),
+        ),
+        SyncErrorPayload::Auth { message } => (
+            format!("{}: Authentication failed", repo_name),
+            message.clone(),
+        ),
+        SyncErrorPayload::Network { message } => (
+            format!("{}: Network error", repo_name),
+            message.clone(),
+        ),
+        SyncErrorPayload::Config { message } => (
+            format!("{}: Configuration error", repo_name),
+            message.clone(),
+        ),
+        SyncErrorPayload::State { message } => (
+            format!("{}: Repository needs attention", repo_name),
+            message.clone(),
+        ),
+        SyncErrorPayload::Unknown { message } => (
+            format!("{}: Sync error", repo_name),
+            message.clone(),
+        ),
+    }
+}
