feat(chat): notify when Hermes finishes a prompt in the background (#64)

Sending a long prompt and switching to other work — the canonical
async-agent flow — required polling the chat to know when the
response landed. Wire a local UNUserNotificationCenter notification
to fire when an ACP prompt completes while Scarf isn't the
foreground app.

- New `ChatNotificationService` (Core/Services) handles lazy
  authorization, foreground gating, and post.
- `ChatViewModel.sendViaACP` calls it on successful prompt
  completion with the assistant's first-line preview and the
  active session title.
- Settings → Display → Feedback adds a "Notify when Hermes finishes"
  toggle, default on. Skipped for `/steer`-style mid-run sends —
  those don't end a turn.

Dock badges and per-session unread state from the issue are
worthwhile follow-ups but out of scope for v2.6.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-01 15:35:55 +02:00
parent ec47d191a1
commit 596c844da5
3 changed files with 125 additions and 0 deletions
@@ -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 + ""
}
}
@@ -415,6 +415,21 @@ final class ChatViewModel {
) )
// Re-fetch session from DB to pick up cost/token data Hermes may have written // Re-fetch session from DB to pick up cost/token data Hermes may have written
await richChatViewModel.refreshSessionFromDB() 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 { } catch is CancellationError {
acpStatus = "Cancelled" acpStatus = "Cancelled"
} catch { } catch {
@@ -22,6 +22,10 @@ struct DisplayTab: View {
private var showSessionsList: Bool = true private var showSessionsList: Bool = true
@AppStorage(ChatDensityKeys.showInspector) @AppStorage(ChatDensityKeys.showInspector)
private var showInspector: Bool = true 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 { var body: some View {
SettingsSection(title: "Chat density", icon: "rectangle.compress.vertical") { SettingsSection(title: "Chat density", icon: "rectangle.compress.vertical") {
@@ -64,6 +68,7 @@ struct DisplayTab: View {
SettingsSection(title: "Feedback", icon: "bell") { SettingsSection(title: "Feedback", icon: "bell") {
ToggleRow(label: "Bell on Complete", isOn: viewModel.config.display.bellOnComplete) { viewModel.setBellOnComplete($0) } ToggleRow(label: "Bell on Complete", isOn: viewModel.config.display.bellOnComplete) { viewModel.setBellOnComplete($0) }
ToggleRow(label: "Notify when Hermes finishes", isOn: notifyOnComplete) { notifyOnComplete = $0 }
} }
} }
} }