diff --git a/assistant/src/calls/guardian-dispatch.ts b/assistant/src/calls/guardian-dispatch.ts index f81132d44a3..6cd1ac31e61 100644 --- a/assistant/src/calls/guardian-dispatch.ts +++ b/assistant/src/calls/guardian-dispatch.ts @@ -146,6 +146,7 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams): requestId: request.id, callSessionId, title: guardianCopy.threadTitle, + questionText: request.questionText, } as ServerMessage); } updateDeliveryStatus(delivery.id, 'sent'); diff --git a/assistant/src/daemon/ipc-contract/work-items.ts b/assistant/src/daemon/ipc-contract/work-items.ts index d5151e5aaa8..96dc12c01d8 100644 --- a/assistant/src/daemon/ipc-contract/work-items.ts +++ b/assistant/src/daemon/ipc-contract/work-items.ts @@ -221,4 +221,5 @@ export interface GuardianRequestThreadCreated { requestId: string; callSessionId: string; title: string; + questionText: string; } diff --git a/clients/macos/vellum-assistant/App/AppDelegate+Notifications.swift b/clients/macos/vellum-assistant/App/AppDelegate+Notifications.swift index f3e0e7b2bc2..bcb2e482b49 100644 --- a/clients/macos/vellum-assistant/App/AppDelegate+Notifications.swift +++ b/clients/macos/vellum-assistant/App/AppDelegate+Notifications.swift @@ -84,7 +84,19 @@ extension AppDelegate { options: [] ) - center.setNotificationCategories([activityCategory, toolConfirmationCategory, rideShotgunCategory, voiceResponseCategory, quickChatCategory]) + let viewGuardianAction = UNNotificationAction( + identifier: "VIEW_GUARDIAN", + title: "View", + options: [.foreground] + ) + let guardianRequestCategory = UNNotificationCategory( + identifier: "GUARDIAN_REQUEST", + actions: [viewGuardianAction], + intentIdentifiers: [], + options: [] + ) + + center.setNotificationCategories([activityCategory, toolConfirmationCategory, rideShotgunCategory, voiceResponseCategory, quickChatCategory, guardianRequestCategory]) } func registerBundledFonts() { @@ -165,7 +177,17 @@ extension AppDelegate: UNUserNotificationCenterDelegate { let conversationId = response.notification.request.content.userInfo["conversationId"] as? String await MainActor.run { guard !self.isAwaitingFirstLaunchReady else { return } - self.openQuickChatThread(conversationId: conversationId) + self.openConversationThread(conversationId: conversationId) + } + return + } + + // Handle guardian request notifications — open the guardian thread in the main window + if categoryId == "GUARDIAN_REQUEST" { + let conversationId = response.notification.request.content.userInfo["conversationId"] as? String + await MainActor.run { + guard !self.isAwaitingFirstLaunchReady else { return } + self.openConversationThread(conversationId: conversationId) } return } diff --git a/clients/macos/vellum-assistant/App/AppDelegate+Sessions.swift b/clients/macos/vellum-assistant/App/AppDelegate+Sessions.swift index 6cb6f416762..195b95f24be 100644 --- a/clients/macos/vellum-assistant/App/AppDelegate+Sessions.swift +++ b/clients/macos/vellum-assistant/App/AppDelegate+Sessions.swift @@ -402,9 +402,30 @@ extension AppDelegate { } } + func deliverGuardianRequestNotification(title: String, questionText: String, conversationId: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = String(questionText.prefix(200)) + content.sound = .default + content.categoryIdentifier = "GUARDIAN_REQUEST" + content.userInfo = ["conversationId": conversationId] + + let request = UNNotificationRequest( + identifier: "guardian-request-\(conversationId)", + content: content, + trigger: nil + ) + UNUserNotificationCenter.current().add(request) { error in + if let error { + log.error("Failed to post guardian request notification: \(error.localizedDescription)") + } + } + } + /// Opens the main window and navigates to the thread for the given conversation ID. /// Retries if the thread isn't populated yet (e.g., ThreadManager hasn't loaded it). - func openQuickChatThread(conversationId: String?) { + /// Used by Quick Chat, Guardian Request, and other notification deep links. + func openConversationThread(conversationId: String?) { showMainWindow() guard let conversationId else { return } diff --git a/clients/macos/vellum-assistant/App/AppDelegate.swift b/clients/macos/vellum-assistant/App/AppDelegate.swift index 3c3a15a6030..703d4ec3542 100644 --- a/clients/macos/vellum-assistant/App/AppDelegate.swift +++ b/clients/macos/vellum-assistant/App/AppDelegate.swift @@ -685,10 +685,20 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { callSessionId: msg.callSessionId, title: msg.title ) - if let thread = self.mainWindow?.threadManager.threads.first(where: { $0.sessionId == msg.conversationId }) { - self.mainWindow?.threadManager.activeThreadId = thread.id + if NSApp.isActive { + // App is in foreground — select thread and show window immediately + if let thread = self.mainWindow?.threadManager.threads.first(where: { $0.sessionId == msg.conversationId }) { + self.mainWindow?.threadManager.activeThreadId = thread.id + } + self.showMainWindow() + } else { + // App is backgrounded — post native notification + self.deliverGuardianRequestNotification( + title: msg.title, + questionText: msg.questionText, + conversationId: msg.conversationId + ) } - self.showMainWindow() } // Handle escalation: text_qa -> computer_use via computer_use_request_control diff --git a/clients/shared/IPC/Generated/IPCContractGenerated.swift b/clients/shared/IPC/Generated/IPCContractGenerated.swift index e598c74ab0d..62374838bdc 100644 --- a/clients/shared/IPC/Generated/IPCContractGenerated.swift +++ b/clients/shared/IPC/Generated/IPCContractGenerated.swift @@ -1853,13 +1853,15 @@ public struct IPCGuardianRequestThreadCreated: Codable, Sendable { public let requestId: String public let callSessionId: String public let title: String + public let questionText: String - public init(type: String, conversationId: String, requestId: String, callSessionId: String, title: String) { + public init(type: String, conversationId: String, requestId: String, callSessionId: String, title: String, questionText: String) { self.type = type self.conversationId = conversationId self.requestId = requestId self.callSessionId = callSessionId self.title = title + self.questionText = questionText } }