Files

157 lines
6.9 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import TermindDomain
public nonisolated enum AssistantNotificationKind: Hashable, Sendable {
case completed, failed, questions, secret, approval, authentication, passphrase, hostKey, mcpAuthorization
}
public nonisolated struct AssistantNotificationInteraction: Equatable, Sendable {
public let id: UUID
public let kind: AssistantNotificationKind
public init(id: UUID, kind: AssistantNotificationKind) {
self.id = id
self.kind = kind
}
}
public nonisolated enum AssistantNotificationEvent: Sendable {
case finished(runID: UUID, kind: AssistantNotificationKind)
case interactionChanged(AssistantNotificationInteraction?)
case removed
}
extension ConversationModel {
/// Mirrors the transcript's interaction priority, without exposing prompt or credential data.
public var notificationInteraction: AssistantNotificationInteraction? {
guard !runState.isTerminal, !isArchived else { return nil }
if let interaction = pendingUserInteraction {
switch interaction {
case .mcpAuthorization(let prompt):
return .init(id: prompt.id, kind: .mcpAuthorization)
case .questions(let request):
let secret = request.questions.contains { if case .secret = $0.kind { true } else { false } }
return .init(id: request.id, kind: secret ? .secret : .questions)
case .sshAuthentication(let request):
if request.keyboardInteractive?.isInformational == true { return nil }
return .init(id: request.id, kind: request.requiresPassphrase ? .passphrase : .authentication)
case .sshHostKey(let request):
return .init(id: request.id, kind: .hostKey)
}
}
return pendingApproval.map { .init(id: $0.id, kind: .approval) }
}
}
/// App-owned, independent of view mounting, title generation and Memory callbacks.
@MainActor
public final class AssistantNotificationCoordinator {
private let service: any NotificationSending
private let preferences: @MainActor () -> NotificationPreferences
private let isFocused: @MainActor (UUID) -> Bool
private let isVisible: @MainActor (UUID) -> Bool
private let content:
@MainActor (ConversationModel, AssistantNotificationKind, UUID?) -> (title: String, body: String)
private struct Observation {
weak var conversation: ConversationModel?
var seen = Set<String>()
}
private var observations: [UUID: Observation] = [:]
private var stopped = false
private var interactionIDs: [UUID: String] = [:]
private var tasks: [String: Task<Void, Never>] = [:]
public init(
service: any NotificationSending,
preferences: @escaping @MainActor () -> NotificationPreferences,
isFocused: @escaping @MainActor (UUID) -> Bool,
isVisible: (@MainActor (UUID) -> Bool)? = nil,
content:
@escaping @MainActor (ConversationModel, AssistantNotificationKind, UUID?) -> (title: String, body: String)
) {
self.service = service
self.preferences = preferences
self.isFocused = isFocused
self.isVisible = isVisible ?? isFocused
self.content = content
}
public func observe(_ conversation: ConversationModel) {
guard !stopped else { return }
for id in observations.keys.filter({ observations[$0]?.conversation == nil }) { unobserve(id) }
if observations[conversation.id]?.conversation !== conversation {
unobserve(conversation.id)
observations[conversation.id] = Observation(conversation: conversation)
}
conversation.notificationEvent = { [weak self, weak conversation] event in
guard let self, let conversation else { return }
receive(event, from: conversation)
}
}
public func unobserve(_ id: UUID) {
guard let observation = observations.removeValue(forKey: id) else { return }
observation.conversation?.notificationEvent = nil
for identifier in observation.seen { tasks.removeValue(forKey: identifier)?.cancel() }
if let interaction = interactionIDs.removeValue(forKey: id) { service.remove([interaction]) }
}
public func stop() {
stopped = true
for id in Array(observations.keys) { unobserve(id) }
}
private func receive(_ event: AssistantNotificationEvent, from conversation: ConversationModel) {
guard !stopped else { return }
let identifier: String
let kind: AssistantNotificationKind
let requestID: UUID?
let completedRunID: UUID?
switch event {
case .removed:
unobserve(conversation.id)
return
case .finished(let runID, let outcome):
guard outcome == .completed || outcome == .failed else { return }
identifier = "assistant.\(conversation.id).run.\(runID)"
kind = outcome
requestID = nil
completedRunID = runID
case .interactionChanged(let interaction):
let nextID = interaction.map { "assistant.\(conversation.id).interaction.\($0.id)" }
if let previous = interactionIDs[conversation.id], previous != nextID {
tasks.removeValue(forKey: previous)?.cancel()
service.remove([previous])
conversation.removeUnreadNotification(previous)
}
interactionIDs[conversation.id] = nextID
guard let interaction, let nextID else { return }
identifier = nextID
kind = interaction.kind
requestID = interaction.id
completedRunID = nil
}
// Suppressed events are also consumed: changing focus/settings never replays them.
guard observations[conversation.id]?.seen.insert(identifier).inserted == true else { return }
if !isVisible(conversation.id) { conversation.markNotificationUnread(identifier) }
guard preferences().allowsAssistant(isFocused: isFocused(conversation.id)) else { return }
let text = content(conversation, kind, completedRunID)
let notification = AppNotification(
id: identifier, target: .assistant(conversation.id), title: text.title, body: text.body)
tasks[identifier] = Task { [weak self, weak conversation] in
guard let self else { return }
defer { tasks[identifier] = nil }
await service.send(notification) { [weak self, weak conversation] in
guard let self, !stopped, let conversation,
observations[conversation.id]?.conversation === conversation,
self.preferences().allowsAssistant(isFocused: self.isFocused(conversation.id))
else { return false }
return requestID == nil || conversation.notificationInteraction?.id == requestID
}
}
}
}