mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -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
|
||||
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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user