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