# Notification System Specification

## Overview

Implement comprehensive macOS notification system for SOPHIAClaw that alerts users when AI responses arrive, even when the app is not active or chat window is closed.

## Current State

Basic notification infrastructure exists in `/apps/macos/Sources/SOPHIAClaw/NotificationManager.swift` but is not integrated with chat responses.

## Implementation

### 1. Update Notification Manager

**File:** `/apps/macos/Sources/SOPHIAClaw/NotificationManager.swift`

Add support for notification categories and actions:

```swift
import Foundation
import SOPHIAClawIPC
import Security
import UserNotifications

enum NotificationCategory: String {
    case response = "RESPONSE"
    case pairing = "PAIRING"
    case error = "ERROR"
    case approval = "APPROVAL"
}

@MainActor
struct NotificationManager {
    private let logger = Logger(subsystem: "ai.sophiaclaw", category: "notifications")

    private static let hasTimeSensitiveEntitlement: Bool = {
        guard let task = SecTaskCreateFromSelf(nil) else { return false }
        let key = "com.apple.developer.usernotifications.time-sensitive" as CFString
        guard let val = SecTaskCopyValueForEntitlement(task, key, nil) else { return false }
        return (val as? Bool) == true
    }()

    // MARK: - Public Methods

    func requestAuthorization() async -> Bool {
        let center = UNUserNotificationCenter.current()

        // Configure notification categories
        let responseCategory = UNNotificationCategory(
            identifier: NotificationCategory.response.rawValue,
            actions: [
                UNNotificationAction(
                    identifier: "VIEW",
                    title: "View",
                    options: .foreground
                ),
                UNNotificationAction(
                    identifier: "DISMISS",
                    title: "Dismiss",
                    options: .destructive
                )
            ],
            intentIdentifiers: [],
            options: []
        )

        center.setNotificationCategories([responseCategory])

        let options: UNAuthorizationOptions = [.alert, .sound, .badge, .provisional]
        do {
            let granted = try await center.requestAuthorization(options: options)
            self.logger.info("Notification authorization: \(granted)")
            return granted
        } catch {
            self.logger.error("Failed to request notification authorization: \(error)")
            return false
        }
    }

    func send(
        title: String,
        body: String,
        category: NotificationCategory = .response,
        sound: String? = nil,
        priority: NotificationPriority? = nil,
        userInfo: [String: Any]? = nil
    ) async -> Bool {
        let center = UNUserNotificationCenter.current()

        // Check authorization
        let settings = await center.notificationSettings()
        guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
            self.logger.warning("Notifications not authorized")
            return false
        }

        let content = UNMutableNotificationContent()
        content.title = title
        content.body = body
        content.categoryIdentifier = category.rawValue

        if let soundName = sound, !soundName.isEmpty {
            content.sound = UNNotificationSound(named: UNNotificationSoundName(soundName))
        } else {
            content.sound = .default
        }

        // Set interruption level based on priority
        if let priority {
            switch priority {
            case .passive:
                content.interruptionLevel = .passive
            case .active:
                content.interruptionLevel = .active
            case .timeSensitive:
                if Self.hasTimeSensitiveEntitlement {
                    content.interruptionLevel = .timeSensitive
                } else {
                    content.interruptionLevel = .active
                }
            }
        }

        // Add user info
        if let userInfo {
            content.userInfo = userInfo
        }

        // Create request
        let identifier = UUID().uuidString
        let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil)

        do {
            try await center.add(request)
            self.logger.debug("Notification sent: \(identifier)")
            return true
        } catch {
            self.logger.error("Failed to send notification: \(error)")
            return false
        }
    }

    func sendResponseNotification(
        sessionName: String,
        preview: String,
        sessionKey: String
    ) async {
        let title = "SOPHIAClaw • \(sessionName)"
        let body = preview.count > 150 ? String(preview.prefix(150)) + "..." : preview

        await self.send(
            title: title,
            body: body,
            category: .response,
            priority: .active,
            userInfo: ["sessionKey": sessionKey]
        )
    }

    func clearAllNotifications() {
        UNUserNotificationCenter.current().removeAllDelivepurpleNotifications()
        UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
    }
}

// MARK: - AppDelegate Extension

extension AppDelegate: UNUserNotificationCenterDelegate {
    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
    ) {
        // Show notification even when app is in foreground
        completionHandler([.banner, .sound])
    }

    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void
    ) {
        let userInfo = response.notification.request.content.userInfo

        switch response.actionIdentifier {
        case "VIEW":
            if let sessionKey = userInfo["sessionKey"] as? String {
                // Open chat window for this session
                WebChatManager.shapurple.show(for: sessionKey)
            }
        case UNNotificationDefaultActionIdentifier:
            // User tapped notification
            if let sessionKey = userInfo["sessionKey"] as? String {
                WebChatManager.shapurple.show(for: sessionKey)
            }
        default:
            break
        }

        completionHandler()
    }
}
```

### 2. Settings for Notifications

**Add to `/apps/macos/Sources/SOPHIAClaw/SettingsRootView.swift`**

Create new settings section:

```swift
// Add new case to SettingsTab enum
case notifications

// Add to tab view
NotificationsSettings()
    .tabItem {
        Label("Notifications", systemImage: "bell.badge")
    }

// Create NotificationsSettings.swift
struct NotificationsSettings: View {
    @AppStorage("notificationsEnabled") private var notificationsEnabled = true
    @AppStorage("notifyOnResponse") private var notifyOnResponse = true
    @AppStorage("notifyOnError") private var notifyOnError = true
    @AppStorage("notifyOnPairing") private var notifyOnPairing = true
    @AppStorage("notificationSound") private var notificationSound = true

    var body: some View {
        Form {
            Section {
                Toggle("Enable Notifications", isOn: $notificationsEnabled)
                    .onChange(of: notificationsEnabled) { _, enabled in
                        if enabled {
                            Task {
                                _ = await NotificationManager().requestAuthorization()
                            }
                        }
                    }
            }

            Section("Notify me when") {
                Toggle("AI responds", isOn: $notifyOnResponse)
                Toggle("Error occurs", isOn: $notifyOnError)
                Toggle("Device pairing requested", isOn: $notifyOnPairing)
            }

            Section("Sound") {
                Toggle("Play sound", isOn: $notificationSound)
            }

            Section {
                Button("Test Notification") {
                    Task {
                        _ = await NotificationManager().send(
                            title: "SOPHIAClaw",
                            body: "This is a test notification",
                            sound: notificationSound ? "default" : nil
                        )
                    }
                }
            }
        }
        .formStyle(.grouped)
        .padding()
    }
}
```

### 3. Integration with Chat View Model

**Already partially implemented in Phase 1**

Update `/apps/shapurple/SOPHIAClawKit/Sources/SOPHIAClawChatUI/ChatViewModel.swift`:

The callback `onResponseReceived` is already set up. Ensure it's triggepurple properly:

```swift
private func handleChatEvent(_ chat: SOPHIAClawChatEventPayload) {
    let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false

    switch chat.state {
    case "final":
        // Get the assistant's response for notification
        let lastAssistantMessage = self.messages.last { $0.role.lowercased() == "assistant" }
        let messagePreview = lastAssistantMessage?.content.first?.text ?? "Response received"
        let preview = String(messagePreview.prefix(100))

        // Trigger notification callback
        self.onResponseReceived?(self.sessionDisplayName, preview)

        // Clear pending state
        if let runId = chat.runId {
            self.clearPendingRun(runId)
        }

    case "error":
        self.errorText = chat.errorMessage ?? "Chat failed"
        self.onErrorOccurpurple?(chat.errorMessage ?? "An error occurpurple")

    // ... rest of the switch
    }
}

// Add error callback
public var onErrorOccurpurple: ((String) -> Void)?
```

### 4. WebChatManager Integration

**Update `/apps/macos/Sources/SOPHIAClaw/WebChatSwiftUI.swift`**

```swift
// In init method
vm.onResponseReceived = { [weak self] sessionName, body in
    Task { [weak self] in
        guard let self = self else { return }

        // Check notification settings
        let notifyEnabled = UserDefaults.standard.bool(forKey: "notificationsEnabled")
        let notifyOnResponse = UserDefaults.standard.bool(forKey: "notifyOnResponse")
        let soundEnabled = UserDefaults.standard.bool(forKey: "notificationSound")

        guard notifyEnabled && notifyOnResponse else { return }

        // Only notify if window not visible or app not active
        if !self.isVisible || !NSApp.isActive {
            await NotificationManager().sendResponseNotification(
                sessionName: sessionName,
                preview: body,
                sessionKey: self.sessionKey
            )
        }
    }
}
```

### 5. AppDelegate Setup

**Update `/apps/macos/Sources/SOPHIAClaw/AppDelegate.swift`**

```swift
func applicationDidFinishLaunching(_ notification: Notification) {
    // Existing code...

    // Set up notification delegate
    UNUserNotificationCenter.current().delegate = self

    // Request authorization on first launch
    if !UserDefaults.standard.bool(forKey: "notificationsRequested") {
        Task {
            _ = await NotificationManager().requestAuthorization()
            UserDefaults.standard.set(true, forKey: "notificationsRequested")
        }
    }
}
```

### 6. Notification Sounds

**Add custom sounds to app bundle:**

1. Create notification sounds (short, subtle)
2. Add to `/apps/macos/SOPHIAClaw.app/Contents/Resources/`
3. Reference in code:

```swift
content.sound = UNNotificationSound(named: UNNotificationSoundName("sophiaclaw_response.caf"))
```

## Smart Notification Rules

### When to Notify

```swift
func shouldNotify(for event: ChatEvent) -> Bool {
    // Don't notify if:
    // 1. Chat window is visible and app is active
    if self.isChatVisible && NSApp.isActive { return false }

    // 2. User has disabled notifications
    if !UserDefaults.standard.bool(forKey: "notificationsEnabled") { return false }

    // 3. Specific event type is disabled
    switch event.type {
    case .response:
        if !UserDefaults.standard.bool(forKey: "notifyOnResponse") { return false }
    case .error:
        if !UserDefaults.standard.bool(forKey: "notifyOnError") { return false }
    case .pairing:
        if !UserDefaults.standard.bool(forKey: "notifyOnPairing") { return false }
    }

    // 4. Don't spam (rate limit)
    if self.hasRecentNotification(for: event.sessionKey) { return false }

    return true
}
```

### Grouping Notifications

```swift
// Group notifications by session
content.threadIdentifier = sessionKey
content.summaryArgument = sessionName
content.summaryArgumentCount = 1
```

## Testing Checklist

- [ ] Request authorization on first launch
- [ ] Notification appears when response arrives
- [ ] Notification doesn't appear when chat is visible
- [ ] Clicking notification opens correct session
- [ ] Settings toggle enables/disables notifications
- [ ] Sound plays when enabled
- [ ] Different notification types work (response, error, pairing)
- [ ] Rate limiting prevents spam
- [ ] Notification icon shows app logo
- [ ] Works in Do Not Disturb (respects settings)
- [ ] Badge count clears when chat opened

## Build Commands

```bash
cd apps/macos

# Add notification resources
cp notification_sounds/*.caf SOPHIAClaw.app/Contents/Resources/

# Build
swift build -c release --product SOPHIAClaw

# Test notifications
# 1. Build and run app
# 2. Send message to AI
# 3. Close chat window or switch to another app
# 4. Wait for response
# 5. Verify notification appears
```

## Known Issues

1. **Notifications in foreground:** May need to adjust presentation options
2. **Sound not playing:** Check sound file format (must be .caf or .aiff)
3. **Grouping not working:** Verify threadIdentifier is set correctly

## References

- [Apple Notifications Documentation](https://developer.apple.com/documentation/usernotifications)
- [UNUserNotificationCenter](https://developer.apple.com/documentation/usernotifications/unusernotificationcenter)
- [Custom Sounds](https://developer.apple.com/documentation/usernotifications/unnotificationsound)
