diff --git a/scarf/scarf/Core/Services/ChatNotificationService.swift b/scarf/scarf/Core/Services/ChatNotificationService.swift new file mode 100644 index 0000000..e3d7d47 --- /dev/null +++ b/scarf/scarf/Core/Services/ChatNotificationService.swift @@ -0,0 +1,105 @@ +import Foundation +import UserNotifications +import os +#if canImport(AppKit) +import AppKit +#endif + +/// Posts a "Hermes finished responding" local notification when an +/// agent prompt completes while Scarf is not in the foreground +/// (issue #64). Users can switch to other work and learn when their +/// prompt has landed without polling the chat pane. +/// +/// Authorization is requested lazily on first use. The user's global +/// toggle (`scarf.chat.notifyOnComplete`, default on) gates posting, +/// and notifications are suppressed when `NSApp.isActive` so users +/// who happen to be looking at the chat aren't pinged for nothing. +@MainActor +final class ChatNotificationService { + static let shared = ChatNotificationService() + + private let logger = Logger(subsystem: "com.scarf", category: "ChatNotifications") + private let center = UNUserNotificationCenter.current() + private var hasRequestedAuthorization = false + private var isAuthorized = false + + /// AppStorage-shared key for the "notify on completion" toggle. + /// Default true; the toggle lives under Settings → Display. + static let toggleKey = "scarf.chat.notifyOnComplete" + + private init() {} + + /// Post a local notification announcing prompt completion. Quietly + /// no-ops when: + /// - The user has disabled the toggle. + /// - Scarf is the foreground app (the in-chat status indicator + /// is sufficient). + /// - The system has not yet granted (or has denied) notification + /// authorization. + /// `preview` is the first line of the assistant's reply, truncated + /// to a sensible length for the lock-screen / notification center. + func postPromptCompleted(sessionTitle: String?, preview: String) { + let enabled = UserDefaults.standard.object(forKey: Self.toggleKey) as? Bool ?? true + guard enabled else { return } + + #if canImport(AppKit) + if NSApp?.isActive == true { return } + #endif + + Task { [weak self] in + guard let self else { return } + let granted = await self.ensureAuthorized() + guard granted else { return } + + let content = UNMutableNotificationContent() + content.title = sessionTitle?.isEmpty == false + ? "Hermes finished — \(sessionTitle ?? "")" + : "Hermes finished responding" + content.body = Self.trimmedPreview(preview) + content.sound = .default + + let request = UNNotificationRequest( + identifier: UUID().uuidString, + content: content, + trigger: nil + ) + do { + try await self.center.add(request) + } catch { + self.logger.warning("Notification post failed: \(error.localizedDescription, privacy: .public)") + } + } + } + + private func ensureAuthorized() async -> Bool { + if isAuthorized { return true } + if hasRequestedAuthorization { + // Already asked once this run; respect the current settings. + let settings = await center.notificationSettings() + isAuthorized = settings.authorizationStatus == .authorized + return isAuthorized + } + hasRequestedAuthorization = true + do { + let granted = try await center.requestAuthorization(options: [.alert, .sound]) + isAuthorized = granted + return granted + } catch { + logger.warning("Notification authorization failed: \(error.localizedDescription, privacy: .public)") + return false + } + } + + /// First non-empty line, capped at ~140 chars so the notification + /// surface stays readable on every macOS notification style. + static func trimmedPreview(_ raw: String) -> String { + let firstLine = raw + .split(whereSeparator: \.isNewline) + .first + .map(String.init) ?? raw + let trimmed = firstLine.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.count <= 140 { return trimmed } + let prefix = trimmed.prefix(140).trimmingCharacters(in: .whitespacesAndNewlines) + return prefix + "…" + } +} diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 854f2c2..443fee6 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -415,6 +415,21 @@ final class ChatViewModel { ) // Re-fetch session from DB to pick up cost/token data Hermes may have written await richChatViewModel.refreshSessionFromDB() + // Issue #64 — notify the user that Hermes has + // finished if Scarf isn't the foreground app. The + // notifier handles the foreground/disabled gating; + // we just hand it the latest assistant text and + // session title for the body line. + if !isSteer { + let preview = richChatViewModel.messages + .last(where: { $0.isAssistant })? + .content ?? "" + let title = richChatViewModel.currentSession?.title + ChatNotificationService.shared.postPromptCompleted( + sessionTitle: title, + preview: preview + ) + } } catch is CancellationError { acpStatus = "Cancelled" } catch { diff --git a/scarf/scarf/Features/Settings/Views/Tabs/DisplayTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/DisplayTab.swift index 226feae..34c79a5 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/DisplayTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/DisplayTab.swift @@ -22,6 +22,10 @@ struct DisplayTab: View { private var showSessionsList: Bool = true @AppStorage(ChatDensityKeys.showInspector) private var showInspector: Bool = true + /// Background-completion notifications (issue #64). Default on so + /// users new to Scarf get the async-aware UX out of the box. + @AppStorage(ChatNotificationService.toggleKey) + private var notifyOnComplete: Bool = true var body: some View { SettingsSection(title: "Chat density", icon: "rectangle.compress.vertical") { @@ -64,6 +68,7 @@ struct DisplayTab: View { SettingsSection(title: "Feedback", icon: "bell") { ToggleRow(label: "Bell on Complete", isOn: viewModel.config.display.bellOnComplete) { viewModel.setBellOnComplete($0) } + ToggleRow(label: "Notify when Hermes finishes", isOn: notifyOnComplete) { notifyOnComplete = $0 } } } }