diff --git a/App/Info.plist b/App/Info.plist
index 72c914e..41cab1d 100644
--- a/App/Info.plist
+++ b/App/Info.plist
@@ -17,7 +17,7 @@
CFBundleShortVersionString
0.1.2
CFBundleVersion
- 4
+ 5
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/README.md b/README.md
index 0c36144..e2a4bb5 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,9 @@ dependencies.
always show glyph only.
- Surfaces two sources: failing CI on your own open pull requests, then review
requests, oldest first within each source.
+- Briefly expands newly selected reviews and CI failures with repository context,
+ then keeps the settled menu-bar label compact. Failing CI opens directly to
+ the pull request's checks.
- Orders all work deterministically: needs-you, red CI, reviews, oldest first.
- Supports 15-minute snooze and **Hide Until It Changes** from the right-click
menu.
@@ -23,6 +26,9 @@ dependencies.
GitHub sources using the repository settings.
- Offers a native **Launch at Login** switch that reflects macOS's real Login
Items state, including approval when the system requires it.
+- Offers off-by-default, needs-you-only notifications with a selectable delay.
+ Meanwhile confirms the task is still waiting before posting one quiet reminder;
+ reviews and CI never notify.
- Shows the installed version and latest GitHub release in Settings without
downloading or installing anything automatically.
- Identifies agent sessions that may be stuck and lets you clear only those
diff --git a/Sources/Meanwhile/MeanwhileApp.swift b/Sources/Meanwhile/MeanwhileApp.swift
index 275f9bd..8541b6f 100644
--- a/Sources/Meanwhile/MeanwhileApp.swift
+++ b/Sources/Meanwhile/MeanwhileApp.swift
@@ -18,8 +18,10 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
private let eventStore = AgentEventStore()
private let recentSignalStore = RecentSignalStore()
private let launchAtLoginController = LaunchAtLoginController()
+ private let needsYouNotificationPreferences = NeedsYouNotificationPreferences()
private lazy var integrationInstaller = AgentIntegrationInstaller(helperURL: helperURL())
private lazy var hotKeyPreferences = HotKeyPreferences(defaultHotKey: configuration.hotKey)
+ private lazy var needsYouNotificationController = NeedsYouNotificationController()
private var menuBar: MenuBarController?
private var runtime: MeanwhileRuntime?
private var currentItem: WorkItem?
@@ -30,6 +32,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
private var lastRecordedItemID: String?
private var latestPresentation: MeanwhilePresentation?
private var bloomState = StatusItemBloomState()
+ private var needsYouNotificationState = NeedsYouNotificationState()
private var bloomExpirationWorkItem: DispatchWorkItem?
private var pendingBloomSettlement = false
private lazy var agentFocusRouter = AgentFocusRouter(
@@ -43,6 +46,8 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
integrationInstaller: integrationInstaller,
eventStore: eventStore,
recentSignalStore: recentSignalStore,
+ notificationPreferences: needsYouNotificationPreferences,
+ notificationController: needsYouNotificationController,
sessionStaleAfter: configuration.sessionStaleSeconds,
appVersion: Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString"
@@ -84,9 +89,27 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
detail: "Claude and Codex"
)
)
+ },
+ notificationSettingsDidChange: { [weak self] in
+ self?.reconcileNeedsYouNotification()
}
)
+ func applicationWillFinishLaunching(_ notification: Notification) {
+ needsYouNotificationController.onPermissionChange = { [weak self] permission in
+ guard let self else { return }
+ settingsModel.setNeedsYouNotificationPermission(permission)
+ reconcileNeedsYouNotification()
+ }
+ needsYouNotificationController.onResponse = { [weak self] identifier in
+ self?.handleNeedsYouNotificationResponse(identifier: identifier)
+ }
+ needsYouNotificationController.start()
+ if !needsYouNotificationPreferences.settings.isEnabled {
+ needsYouNotificationController.cancelAllManaged()
+ }
+ }
+
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
@@ -135,10 +158,15 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
runtime?.stop()
}
+ func applicationDidBecomeActive(_ notification: Notification) {
+ needsYouNotificationController.refreshPermission()
+ }
+
private func present(_ presentation: MeanwhilePresentation) {
latestPresentation = presentation
currentItem = presentation.item
recordPresentationIfNeeded(presentation.item)
+ reconcileNeedsYouNotification(presentation)
let transition = bloomState.observe(
phase: presentation.phase,
item: presentation.item
@@ -317,11 +345,16 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func openCurrentItem() {
guard let item = currentItem else { return }
settleBloomBeforeAction()
+ cancelNeedsYouNotification(for: item)
+ open(item)
+ }
+
+ private func open(_ item: WorkItem) {
if item.kind == .needsYou, let session = item.session {
if agentFocusRouter.focus(session) == .unavailable {
showFocusFailure(for: item)
}
- } else if let url = item.url {
+ } else if let url = MenuBarPresenter.destinationURL(item: item) {
NSWorkspace.shared.open(url)
}
}
@@ -363,6 +396,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func snoozeCurrentItem() {
settleBloomBeforeAction()
if let item = currentItem {
+ cancelNeedsYouNotification(for: item)
recentSignalStore.record(
RecentSignal(
kind: .snoozed,
@@ -377,6 +411,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func hideCurrentItemUntilChange() {
settleBloomBeforeAction()
if let item = currentItem {
+ cancelNeedsYouNotification(for: item)
recentSignalStore.record(
RecentSignal(
kind: .hiddenUntilChange,
@@ -388,6 +423,131 @@ private final class AppDelegate: NSObject, NSApplicationDelegate {
runtime?.dismissCurrent()
}
+ private func reconcileNeedsYouNotification(
+ _ presentation: MeanwhilePresentation? = nil
+ ) {
+ guard let presentation = presentation ?? latestPresentation else { return }
+ let settings = needsYouNotificationPreferences.settings
+ let permission = needsYouNotificationController.permission
+ if let item = presentation.item, item.kind == .needsYou {
+ let identifier = NeedsYouNotificationController.identifier(for: item.id)
+ if needsYouNotificationPreferences.containsReceipt(identifier: identifier) {
+ needsYouNotificationState.restoreReceipt(itemID: item.id)
+ }
+ }
+ if !settings.isEnabled {
+ needsYouNotificationController.retainOnly(itemID: nil)
+ } else if permission != .unknown {
+ let eligibleItemID = permission == .authorized
+ && presentation.phase == .needsYou
+ && presentation.item?.kind == .needsYou
+ ? presentation.item?.id
+ : nil
+ needsYouNotificationController.retainOnly(itemID: eligibleItemID)
+ }
+ let transition = needsYouNotificationState.observe(
+ settings: settings,
+ permission: permission,
+ phase: presentation.phase,
+ item: presentation.item
+ )
+ switch transition {
+ case .none:
+ break
+ case .deliver(let item):
+ deliverNeedsYouNotification(item)
+ case .replace(let previousItemID, let delivery):
+ needsYouNotificationController.cancel(itemID: previousItemID)
+ if let delivery {
+ deliverNeedsYouNotification(delivery)
+ }
+ case .cancel(let itemID):
+ needsYouNotificationController.cancel(itemID: itemID)
+ }
+ }
+
+ private func cancelNeedsYouNotification(for item: WorkItem) {
+ guard item.kind == .needsYou else { return }
+ needsYouNotificationPreferences.recordReceipt(
+ identifier: NeedsYouNotificationController.identifier(for: item.id)
+ )
+ let transition = needsYouNotificationState.acknowledge(itemID: item.id)
+ if case .cancel(let itemID) = transition {
+ needsYouNotificationController.cancel(itemID: itemID)
+ }
+ }
+
+ private func deliverNeedsYouNotification(_ item: WorkItem) {
+ guard let title = MenuBarPresenter.notificationTitle(item: item) else { return }
+ let identifier = NeedsYouNotificationController.identifier(for: item.id)
+ needsYouNotificationController.deliver(
+ identifier: identifier,
+ title: title
+ ) { [weak self] outcome in
+ switch outcome {
+ case .delivered:
+ self?.needsYouNotificationPreferences.recordReceipt(identifier: identifier)
+ case .failed:
+ self?.needsYouNotificationState.deliveryFailed(
+ itemID: item.id,
+ retryNotBefore: Date().addingTimeInterval(60)
+ )
+ case .cancelled:
+ break
+ }
+ }
+ }
+
+ private func handleNeedsYouNotificationResponse(identifier: String) {
+ let current = currentItem.flatMap { item -> WorkItem? in
+ guard item.kind == .needsYou,
+ NeedsYouNotificationController.identifier(for: item.id) == identifier else {
+ return nil
+ }
+ return item
+ }
+ if let current {
+ settleBloomBeforeAction()
+ cancelNeedsYouNotification(for: current)
+ open(current)
+ return
+ }
+
+ let session = eventStore.sessions(
+ staleAfter: configuration.sessionStaleSeconds,
+ activeStaleAfter: configuration.activeSessionStaleSeconds
+ ).first { session in
+ session.phase == .needsYou
+ && NeedsYouNotificationController.identifier(
+ for: WorkItem.needsYouID(for: session)
+ ) == identifier
+ }
+ guard let session else {
+ showExpiredNotificationMessage()
+ return
+ }
+ let item = WorkItem(
+ id: WorkItem.needsYouID(for: session),
+ kind: .needsYou,
+ title: "\(providerDisplayName(session.provider)) needs you",
+ detail: session.cwd,
+ createdAt: session.enteredAt,
+ session: session
+ )
+ cancelNeedsYouNotification(for: item)
+ open(item)
+ }
+
+ private func showExpiredNotificationMessage() {
+ let alert = NSAlert()
+ alert.alertStyle = .informational
+ alert.messageText = "That task is no longer waiting"
+ alert.informativeText = "Meanwhile did not open a different task."
+ alert.addButton(withTitle: "OK")
+ NSApp.activate(ignoringOtherApps: true)
+ alert.runModal()
+ }
+
private func showFirstRunSettingsIfNeeded() {
guard !UserDefaults.standard.bool(forKey: DefaultsKey.integrationPrompted) else { return }
UserDefaults.standard.set(true, forKey: DefaultsKey.integrationPrompted)
diff --git a/Sources/Meanwhile/NeedsYouNotificationController.swift b/Sources/Meanwhile/NeedsYouNotificationController.swift
new file mode 100644
index 0000000..583cb6c
--- /dev/null
+++ b/Sources/Meanwhile/NeedsYouNotificationController.swift
@@ -0,0 +1,287 @@
+import AppKit
+import CryptoKit
+import MeanwhileCore
+import UserNotifications
+
+enum NeedsYouNotificationDeliveryOutcome: Equatable {
+ case delivered
+ case cancelled
+ case failed
+}
+
+@MainActor
+final class NeedsYouNotificationController: NSObject, UNUserNotificationCenterDelegate {
+ nonisolated static let identifierPrefix = "Meanwhile.needs-you."
+
+ var onPermissionChange: ((NeedsYouNotificationPermission) -> Void)?
+ var onResponse: ((String) -> Void)?
+
+ private(set) var permission: NeedsYouNotificationPermission = .unknown {
+ didSet {
+ guard permission != oldValue else { return }
+ onPermissionChange?(permission)
+ }
+ }
+
+ private struct RequestSpec: Equatable {
+ var identifier: String
+ var title: String
+ }
+
+ private let center: UNUserNotificationCenter
+ private var desiredRequests: [String: RequestSpec] = [:]
+ private var retainedIdentifiers: Set = []
+
+ init(center: UNUserNotificationCenter = .current()) {
+ self.center = center
+ super.init()
+ }
+
+ func start() {
+ center.delegate = self
+ refreshPermission()
+ }
+
+ func refreshPermission() {
+ center.getNotificationSettings { [weak self] settings in
+ let permission = Self.permission(from: settings)
+ Task { @MainActor [weak self] in
+ self?.permission = permission
+ }
+ }
+ }
+
+ func requestPermission() {
+ guard permission == .notDetermined || permission == .unknown else {
+ refreshPermission()
+ return
+ }
+ center.requestAuthorization(options: [.alert]) { [weak self] _, error in
+ guard error == nil else {
+ Task { @MainActor [weak self] in
+ self?.permission = .unavailable
+ }
+ return
+ }
+ Task { @MainActor [weak self] in
+ self?.refreshPermission()
+ }
+ }
+ }
+
+ func deliver(
+ identifier: String,
+ title: String,
+ completion: @escaping (NeedsYouNotificationDeliveryOutcome) -> Void
+ ) {
+ guard permission == .authorized,
+ identifier.hasPrefix(Self.identifierPrefix) else {
+ completion(.cancelled)
+ return
+ }
+ let spec = RequestSpec(
+ identifier: identifier,
+ title: title
+ )
+ desiredRequests = [identifier: spec]
+ retainedIdentifiers = [identifier]
+ removeStaleManagedNotifications()
+
+ center.getDeliveredNotifications { [weak self] notifications in
+ let wasDelivered = notifications.contains {
+ $0.request.identifier == identifier
+ }
+ Task { @MainActor [weak self] in
+ guard let self else {
+ completion(.cancelled)
+ return
+ }
+ finishScheduling(
+ spec,
+ wasDelivered: wasDelivered,
+ completion: completion
+ )
+ }
+ }
+ }
+
+ func cancel(itemID: String) {
+ let identifier = Self.identifier(for: itemID)
+ desiredRequests.removeValue(forKey: identifier)
+ retainedIdentifiers.remove(identifier)
+ center.removePendingNotificationRequests(withIdentifiers: [identifier])
+ center.removeDeliveredNotifications(withIdentifiers: [identifier])
+ }
+
+ func retainOnly(itemID: String?) {
+ let identifiers: Set
+ if let itemID {
+ identifiers = [Self.identifier(for: itemID)]
+ } else {
+ identifiers = []
+ }
+ guard identifiers != retainedIdentifiers else { return }
+ retainedIdentifiers = identifiers
+ desiredRequests = desiredRequests.filter { identifiers.contains($0.key) }
+ removeStaleManagedNotifications()
+ }
+
+ func cancelAllManaged() {
+ desiredRequests.removeAll()
+ retainedIdentifiers.removeAll()
+ removeStaleManagedNotifications()
+ }
+
+ func openSystemSettings() {
+ let bundleIdentifier = Bundle.main.bundleIdentifier ?? "com.meanwhile.Meanwhile"
+ let specific = URL(
+ string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=\(bundleIdentifier)"
+ )
+ if let specific, NSWorkspace.shared.open(specific) { return }
+ if let notifications = URL(
+ string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension"
+ ) {
+ NSWorkspace.shared.open(notifications)
+ }
+ }
+
+ static func identifier(for itemID: String) -> String {
+ let digest = SHA256.hash(data: Data(itemID.utf8))
+ let value = digest.map { String(format: "%02x", $0) }.joined()
+ return identifierPrefix + value
+ }
+
+ private func finishScheduling(
+ _ spec: RequestSpec,
+ wasDelivered: Bool,
+ completion: @escaping (NeedsYouNotificationDeliveryOutcome) -> Void
+ ) {
+ guard permission == .authorized,
+ desiredRequests[spec.identifier] == spec else {
+ completion(.cancelled)
+ return
+ }
+ guard !wasDelivered else {
+ completion(.delivered)
+ return
+ }
+
+ let content = UNMutableNotificationContent()
+ content.title = spec.title
+ content.body = "Click to return to the waiting task."
+ let request = UNNotificationRequest(
+ identifier: spec.identifier,
+ content: content,
+ trigger: nil
+ )
+ center.add(request) { [weak self] error in
+ Task { @MainActor [weak self] in
+ guard let self else {
+ completion(.cancelled)
+ return
+ }
+ guard desiredRequests[spec.identifier] == spec else {
+ center.removePendingNotificationRequests(
+ withIdentifiers: [spec.identifier]
+ )
+ center.removeDeliveredNotifications(
+ withIdentifiers: [spec.identifier]
+ )
+ completion(.cancelled)
+ return
+ }
+ guard error == nil else {
+ desiredRequests.removeValue(forKey: spec.identifier)
+ center.removePendingNotificationRequests(
+ withIdentifiers: [spec.identifier]
+ )
+ center.removeDeliveredNotifications(
+ withIdentifiers: [spec.identifier]
+ )
+ completion(.failed)
+ refreshPermission()
+ return
+ }
+ completion(.delivered)
+ }
+ }
+ }
+
+ private func removeStaleManagedNotifications() {
+ center.getPendingNotificationRequests { [weak self] requests in
+ let managed = requests.map(\.identifier).filter {
+ $0.hasPrefix(Self.identifierPrefix)
+ }
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ let identifiers = managed.filter {
+ self.desiredRequests[$0] == nil
+ && !self.retainedIdentifiers.contains($0)
+ }
+ center.removePendingNotificationRequests(withIdentifiers: identifiers)
+ }
+ }
+ center.getDeliveredNotifications { [weak self] notifications in
+ let managed = notifications.map { $0.request.identifier }.filter {
+ $0.hasPrefix(Self.identifierPrefix)
+ }
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ let identifiers = managed.filter {
+ self.desiredRequests[$0] == nil
+ && !self.retainedIdentifiers.contains($0)
+ }
+ center.removeDeliveredNotifications(withIdentifiers: identifiers)
+ }
+ }
+ }
+
+ nonisolated private static func permission(
+ from settings: UNNotificationSettings
+ ) -> NeedsYouNotificationPermission {
+ switch settings.authorizationStatus {
+ case .notDetermined:
+ return .notDetermined
+ case .denied:
+ return .denied
+ case .authorized, .provisional, .ephemeral:
+ return settings.notificationCenterSetting == .enabled
+ ? .authorized
+ : .limited
+ @unknown default:
+ return .unavailable
+ }
+ }
+
+ nonisolated func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification,
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
+ ) {
+ guard notification.request.identifier.hasPrefix(Self.identifierPrefix) else {
+ completionHandler([])
+ return
+ }
+ completionHandler([.banner, .list])
+ }
+
+ nonisolated func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ didReceive response: UNNotificationResponse,
+ withCompletionHandler completionHandler: @escaping () -> Void
+ ) {
+ let identifier = response.notification.request.identifier
+ guard identifier.hasPrefix(Self.identifierPrefix),
+ response.actionIdentifier == UNNotificationDefaultActionIdentifier else {
+ completionHandler()
+ return
+ }
+ center.removeDeliveredNotifications(withIdentifiers: [identifier])
+ Task { @MainActor [weak self] in
+ self?.desiredRequests.removeValue(forKey: identifier)
+ self?.retainedIdentifiers.remove(identifier)
+ self?.onResponse?(identifier)
+ }
+ completionHandler()
+ }
+}
diff --git a/Sources/Meanwhile/NeedsYouNotificationSettingsSection.swift b/Sources/Meanwhile/NeedsYouNotificationSettingsSection.swift
new file mode 100644
index 0000000..1894730
--- /dev/null
+++ b/Sources/Meanwhile/NeedsYouNotificationSettingsSection.swift
@@ -0,0 +1,123 @@
+import MeanwhileCore
+import SwiftUI
+
+struct NeedsYouNotificationSettingsSection: View {
+ @Binding var settings: NeedsYouNotificationSettings
+ let permission: NeedsYouNotificationPermission
+ let isRequestingPermission: Bool
+ let requestPermission: () -> Void
+ let openSystemSettings: () -> Void
+ let retryStatus: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 11) {
+ HStack(alignment: .top, spacing: 10) {
+ Text("Needs-you reminders")
+ .frame(width: 132, alignment: .leading)
+
+ Toggle("Notify when a task keeps waiting", isOn: $settings.isEnabled)
+ .labelsHidden()
+ .toggleStyle(.switch)
+ .accessibilityHint(
+ "Posts one notification if the same needs-you task is still waiting."
+ )
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(statusTitle)
+ .font(.callout.weight(.medium))
+ Text(statusDetail)
+ .font(.caption)
+ .foregroundStyle(statusNeedsAttention ? .orange : .secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Spacer(minLength: 8)
+
+ if settings.isEnabled {
+ if permission == .denied || permission == .limited {
+ Button("Open Notifications…", action: openSystemSettings)
+ } else if permission == .unavailable {
+ Button("Try Again", action: retryStatus)
+ } else if !isRequestingPermission,
+ permission == .notDetermined || permission == .unknown {
+ Button("Allow Notifications…", action: requestPermission)
+ }
+ }
+ }
+
+ HStack(spacing: 10) {
+ Text("Notify after")
+ .frame(width: 132, alignment: .leading)
+
+ Picker("Notify after", selection: $settings.delay) {
+ ForEach(NeedsYouNotificationDelay.allCases, id: \.self) { delay in
+ Text(delay.label).tag(delay)
+ }
+ }
+ .labelsHidden()
+ .pickerStyle(.menu)
+ .frame(width: 132)
+ .disabled(!settings.isEnabled)
+
+ Text("Measured from when the task first needs you")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Text("One quiet reminder per waiting task. Reviews and failing CI never notify.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .padding(.leading, 142)
+ }
+ }
+
+ private var statusTitle: String {
+ if !settings.isEnabled { return "Off" }
+ if isRequestingPermission { return "Requesting permission" }
+ switch permission {
+ case .authorized: return "One quiet reminder"
+ case .notDetermined, .unknown: return "Permission needed"
+ case .denied: return "Blocked by macOS"
+ case .limited: return "Notification Center is off"
+ case .unavailable: return "Status unavailable"
+ }
+ }
+
+ private var statusDetail: String {
+ if !settings.isEnabled {
+ return "Menu bar only. Turn this on to ask macOS for permission."
+ }
+ if isRequestingPermission { return "Waiting for macOS permission." }
+ switch permission {
+ case .authorized:
+ return "Notifies only while the same needs-you task is still waiting."
+ case .notDetermined, .unknown:
+ return "macOS permission is required before Meanwhile can notify."
+ case .denied:
+ return "Allow Meanwhile under Notifications in System Settings."
+ case .limited:
+ return "Turn on Notification Center delivery for Meanwhile."
+ case .unavailable:
+ return "Meanwhile couldn’t read notification settings."
+ }
+ }
+
+ private var statusNeedsAttention: Bool {
+ settings.isEnabled && (
+ permission == .denied
+ || permission == .limited
+ || permission == .unavailable
+ )
+ }
+}
+
+private extension NeedsYouNotificationDelay {
+ var label: String {
+ switch self {
+ case .oneMinute: return "1 minute"
+ case .fiveMinutes: return "5 minutes"
+ case .fifteenMinutes: return "15 minutes"
+ case .thirtyMinutes: return "30 minutes"
+ }
+ }
+}
diff --git a/Sources/Meanwhile/RepositorySettingsModel.swift b/Sources/Meanwhile/RepositorySettingsModel.swift
index 81b24dc..031ca2a 100644
--- a/Sources/Meanwhile/RepositorySettingsModel.swift
+++ b/Sources/Meanwhile/RepositorySettingsModel.swift
@@ -34,6 +34,9 @@ final class RepositorySettingsModel: ObservableObject {
@Published private(set) var sessionRecoveryMessage: String?
@Published private(set) var sessionRecoveryIsError = false
@Published private(set) var isClearingStuckSessions = false
+ @Published private(set) var needsYouNotificationSettings: NeedsYouNotificationSettings
+ @Published private(set) var needsYouNotificationPermission: NeedsYouNotificationPermission
+ @Published private(set) var isRequestingNotificationPermission = false
let appVersion: String
let buildVersion: String
@@ -88,6 +91,8 @@ final class RepositorySettingsModel: ObservableObject {
private let integrationInstaller: AgentIntegrationInstaller
private let eventStore: AgentEventStore
private let recentSignalStore: RecentSignalStore
+ private let notificationPreferences: NeedsYouNotificationPreferences
+ private let notificationController: NeedsYouNotificationController
private let releaseUpdateChecker: ReleaseUpdateChecker
private let sessionStaleAfter: TimeInterval
private let operatingSystemVersion: String
@@ -99,6 +104,7 @@ final class RepositorySettingsModel: ObservableObject {
private let selectionDidChange: () -> Void
private let hotKeyDidChange: (HotKeyConfiguration?) -> Void
private let integrationDidInstall: (AgentIntegrationInstallResult) -> Void
+ private let notificationSettingsDidChange: () -> Void
private var hasLoaded = false
private var diagnosticsFeedbackID = UUID()
@@ -110,6 +116,8 @@ final class RepositorySettingsModel: ObservableObject {
integrationInstaller: AgentIntegrationInstaller,
eventStore: AgentEventStore,
recentSignalStore: RecentSignalStore,
+ notificationPreferences: NeedsYouNotificationPreferences,
+ notificationController: NeedsYouNotificationController,
releaseUpdateChecker: ReleaseUpdateChecker = ReleaseUpdateChecker(),
sessionStaleAfter: TimeInterval,
appVersion: String,
@@ -122,7 +130,8 @@ final class RepositorySettingsModel: ObservableObject {
openURL: @escaping (URL) -> Void,
selectionDidChange: @escaping () -> Void,
hotKeyDidChange: @escaping (HotKeyConfiguration?) -> Void,
- integrationDidInstall: @escaping (AgentIntegrationInstallResult) -> Void
+ integrationDidInstall: @escaping (AgentIntegrationInstallResult) -> Void,
+ notificationSettingsDidChange: @escaping () -> Void
) {
self.preferences = preferences
self.hotKeyPreferences = hotKeyPreferences
@@ -131,6 +140,8 @@ final class RepositorySettingsModel: ObservableObject {
self.integrationInstaller = integrationInstaller
self.eventStore = eventStore
self.recentSignalStore = recentSignalStore
+ self.notificationPreferences = notificationPreferences
+ self.notificationController = notificationController
self.releaseUpdateChecker = releaseUpdateChecker
self.sessionStaleAfter = sessionStaleAfter
self.appVersion = appVersion
@@ -144,11 +155,14 @@ final class RepositorySettingsModel: ObservableObject {
self.selectionDidChange = selectionDidChange
self.hotKeyDidChange = hotKeyDidChange
self.integrationDidInstall = integrationDidInstall
+ self.notificationSettingsDidChange = notificationSettingsDidChange
let snapshot = preferences.snapshot
includesAllRepositories = snapshot.includesAllRepositories
selectedRepositories = snapshot.selectedRepositories
hotKey = hotKeyPreferences.hotKey
self.launchAtLoginStatus = launchAtLoginStatus()
+ needsYouNotificationSettings = notificationPreferences.settings
+ needsYouNotificationPermission = notificationController.permission
}
func loadRepositories(force: Bool = false) {
@@ -179,6 +193,7 @@ final class RepositorySettingsModel: ObservableObject {
func refreshStatus() {
guard !isCheckingHealth else { return }
+ notificationController.refreshPermission()
isCheckingHealth = true
let authenticationChecker = self.authenticationChecker
let integrationInstaller = self.integrationInstaller
@@ -263,6 +278,46 @@ final class RepositorySettingsModel: ObservableObject {
openLoginItemsSettingsAction()
}
+ func setNeedsYouNotificationsEnabled(_ enabled: Bool) {
+ notificationPreferences.setEnabled(enabled)
+ needsYouNotificationSettings = notificationPreferences.settings
+ notificationSettingsDidChange()
+
+ guard enabled,
+ !isRequestingNotificationPermission,
+ needsYouNotificationPermission == .notDetermined
+ || needsYouNotificationPermission == .unknown else { return }
+ requestNeedsYouNotificationPermission()
+ }
+
+ func setNeedsYouNotificationDelay(_ delay: NeedsYouNotificationDelay) {
+ notificationPreferences.setDelay(delay)
+ needsYouNotificationSettings = notificationPreferences.settings
+ notificationSettingsDidChange()
+ }
+
+ func setNeedsYouNotificationPermission(_ permission: NeedsYouNotificationPermission) {
+ needsYouNotificationPermission = permission
+ isRequestingNotificationPermission = false
+ }
+
+ func openNotificationSettings() {
+ notificationController.openSystemSettings()
+ }
+
+ func requestNeedsYouNotificationPermission() {
+ guard needsYouNotificationSettings.isEnabled,
+ !isRequestingNotificationPermission,
+ needsYouNotificationPermission == .notDetermined
+ || needsYouNotificationPermission == .unknown else { return }
+ isRequestingNotificationPermission = true
+ notificationController.requestPermission()
+ }
+
+ func retryNotificationStatus() {
+ notificationController.refreshPermission()
+ }
+
func openLatestRelease() {
guard let releaseURL = updateState.releaseURL else { return }
openURL(releaseURL)
diff --git a/Sources/Meanwhile/RepositorySettingsView.swift b/Sources/Meanwhile/RepositorySettingsView.swift
index 879cf13..731690a 100644
--- a/Sources/Meanwhile/RepositorySettingsView.swift
+++ b/Sources/Meanwhile/RepositorySettingsView.swift
@@ -6,6 +6,7 @@ struct RepositorySettingsView: View {
@AppStorage("settings.section.statusItem.expanded") private var isStatusItemExpanded = true
@AppStorage("settings.section.connectionHealth.expanded") private var isConnectionHealthExpanded = true
@AppStorage("settings.section.aboutSupport.expanded") private var isAboutSupportExpanded = true
+ @AppStorage("settings.section.notifications.expanded") private var isNotificationsExpanded = true
@AppStorage("settings.section.repositorySources.expanded") private var isRepositorySourcesExpanded = true
@AppStorage("settings.section.recentSignals.expanded") private var isRecentSignalsExpanded = true
@State private var searchText = ""
@@ -42,6 +43,31 @@ struct RepositorySettingsView: View {
}
}
+ private var notificationTrailing: String {
+ let settings = model.needsYouNotificationSettings
+ guard settings.isEnabled else { return "Off" }
+ if model.isRequestingNotificationPermission { return "Requesting" }
+ switch model.needsYouNotificationPermission {
+ case .authorized: return "After \(settings.delay.shortLabel)"
+ case .denied: return "Blocked"
+ case .limited: return "Limited"
+ case .unavailable: return "Unavailable"
+ case .notDetermined, .unknown: return "Permission needed"
+ }
+ }
+
+ private var notificationTrailingTint: Color {
+ guard model.needsYouNotificationSettings.isEnabled else {
+ return Color(nsColor: .tertiaryLabelColor)
+ }
+ switch model.needsYouNotificationPermission {
+ case .denied, .limited, .unavailable:
+ return .orange
+ default:
+ return Color(nsColor: .tertiaryLabelColor)
+ }
+ }
+
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
@@ -73,6 +99,34 @@ struct RepositorySettingsView: View {
openLoginItemsSettings: model.openLoginItemsSettings
)
Divider()
+ CollapsibleSettingsSection(
+ title: "Notifications",
+ trailing: notificationTrailing,
+ trailingTint: notificationTrailingTint,
+ isExpanded: $isNotificationsExpanded,
+ showsProgress: model.isRequestingNotificationPermission
+ ) {
+ NeedsYouNotificationSettingsSection(
+ settings: Binding(
+ get: { model.needsYouNotificationSettings },
+ set: { settings in
+ if settings.isEnabled
+ != model.needsYouNotificationSettings.isEnabled {
+ model.setNeedsYouNotificationsEnabled(settings.isEnabled)
+ }
+ if settings.delay != model.needsYouNotificationSettings.delay {
+ model.setNeedsYouNotificationDelay(settings.delay)
+ }
+ }
+ ),
+ permission: model.needsYouNotificationPermission,
+ isRequestingPermission: model.isRequestingNotificationPermission,
+ requestPermission: model.requestNeedsYouNotificationPermission,
+ openSystemSettings: model.openNotificationSettings,
+ retryStatus: model.retryNotificationStatus
+ )
+ }
+ Divider()
CollapsibleSettingsSection(
title: "About & support",
trailing: aboutSupportTrailing,
@@ -165,3 +219,14 @@ struct RepositorySettingsView: View {
}
}
}
+
+private extension NeedsYouNotificationDelay {
+ var shortLabel: String {
+ switch self {
+ case .oneMinute: return "1 min"
+ case .fiveMinutes: return "5 min"
+ case .fifteenMinutes: return "15 min"
+ case .thirtyMinutes: return "30 min"
+ }
+ }
+}
diff --git a/Sources/Meanwhile/SettingsSupport.swift b/Sources/Meanwhile/SettingsSupport.swift
index 8e78bce..63a10c7 100644
--- a/Sources/Meanwhile/SettingsSupport.swift
+++ b/Sources/Meanwhile/SettingsSupport.swift
@@ -21,6 +21,7 @@ struct SettingsSectionHeader: View {
if showsProgress {
ProgressView()
.controlSize(.small)
+ .accessibilityLabel("\(title) in progress")
}
}
}
diff --git a/Sources/Meanwhile/StatusItemSettingsSection.swift b/Sources/Meanwhile/StatusItemSettingsSection.swift
index 4e8e03e..896d4e6 100644
--- a/Sources/Meanwhile/StatusItemSettingsSection.swift
+++ b/Sources/Meanwhile/StatusItemSettingsSection.swift
@@ -35,13 +35,13 @@ struct StatusItemSettingsSection: View {
systemImage: "rectangle.stack.fill",
tint: .orange,
label: "#78",
- meaning: "A review is ready while an agent works"
+ meaning: "Briefly names the repository; click to open the PR"
)
StatusLanguageRow(
systemImage: "rectangle.stack.fill",
tint: .orange,
- label: "CI!",
- meaning: "Failing CI is ready while an agent works"
+ label: "CI! #42",
+ meaning: "Briefly names the repository; click to inspect checks"
)
StatusLanguageRow(
systemImage: "rectangle.stack",
diff --git a/Sources/MeanwhileCore/MeanwhileEngine.swift b/Sources/MeanwhileCore/MeanwhileEngine.swift
index 85b66f8..8c8d803 100644
--- a/Sources/MeanwhileCore/MeanwhileEngine.swift
+++ b/Sources/MeanwhileCore/MeanwhileEngine.swift
@@ -50,7 +50,7 @@ public final class MeanwhileEngine: @unchecked Sendable {
let urgentItems = needsYou.map { session in
WorkItem(
- id: "needs-you:\(session.id):\(session.enteredAt.timeIntervalSince1970)",
+ id: WorkItem.needsYouID(for: session),
kind: .needsYou,
title: "\(providerName(session.provider)) needs you",
detail: session.cwd,
diff --git a/Sources/MeanwhileCore/MenuBarPresenter.swift b/Sources/MeanwhileCore/MenuBarPresenter.swift
index 7484367..acaef63 100644
--- a/Sources/MeanwhileCore/MenuBarPresenter.swift
+++ b/Sources/MeanwhileCore/MenuBarPresenter.swift
@@ -17,27 +17,28 @@ public enum MenuBarPresenter {
guard let item else { return nil }
switch item.kind {
case .needsYou: return item.title
- case .failingCI: return "CI!"
+ case .failingCI:
+ return itemNumber(item).map { "CI! #\($0)" } ?? "CI!"
case .review:
- let number = item.title.split(separator: "#").last.map(String.init)
- return number.map { "#\($0)" } ?? "Review"
+ return itemNumber(item).map { "#\($0)" } ?? "Review"
}
}
public static func bloomText(item: WorkItem?) -> String? {
- guard let item, item.kind == .needsYou else {
- return statusText(item: item)
+ guard let item else { return nil }
+ switch item.kind {
+ case .needsYou:
+ return contextualTitle(
+ attentionText(item: item),
+ context: projectName(item: item)
+ )
+ case .review:
+ let base = itemNumber(item).map { "Review #\($0)" } ?? "Review ready"
+ return contextualTitle(base, context: repositoryName(item: item))
+ case .failingCI:
+ let base = itemNumber(item).map { "CI failed #\($0)" } ?? "CI failed"
+ return contextualTitle(base, context: repositoryName(item: item))
}
- let attention = attentionText(item: item)
- guard let project = projectName(item: item) else { return attention }
-
- let separator = " — "
- let totalLimit = 46
- let projectLimit = min(20, totalLimit - attention.count - separator.count)
- guard projectLimit >= 8 else { return attention }
- let compactProject = middleTruncated(project, limit: projectLimit)
- let combined = "\(attention)\(separator)\(compactProject)"
- return combined.count <= totalLimit ? combined : attention
}
public static func attentionText(item: WorkItem) -> String {
@@ -70,6 +71,36 @@ public enum MenuBarPresenter {
return middleTruncated(name, limit: 32)
}
+ public static func repositoryName(item: WorkItem) -> String? {
+ guard item.kind == .review || item.kind == .failingCI else { return nil }
+ let repository = item.detail.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !repository.isEmpty,
+ repository.rangeOfCharacter(from: .controlCharacters) == nil else { return nil }
+ let name = repository.split(separator: "/").last.map(String.init) ?? repository
+ guard !name.isEmpty, name != ".", name != ".." else { return nil }
+ return middleTruncated(name, limit: 32)
+ }
+
+ public static func notificationTitle(item: WorkItem) -> String? {
+ guard item.kind == .needsYou else { return nil }
+ let provider = item.session.map { providerName($0.provider) } ?? "Agent"
+ switch item.session?.effectiveAttentionReason ?? .generic {
+ case .approvalRequired:
+ return "\(provider) still needs approval"
+ case .answerRequired:
+ return "\(provider) still needs an answer"
+ case .generic:
+ return "\(provider) still needs attention"
+ }
+ }
+
+ public static func destinationURL(item: WorkItem) -> URL? {
+ guard let url = item.url else { return nil }
+ guard item.kind == .failingCI,
+ url.lastPathComponent != "checks" else { return url }
+ return url.appendingPathComponent("checks")
+ }
+
public static func openActionTitle(item: WorkItem) -> String {
switch item.kind {
case .needsYou:
@@ -83,8 +114,12 @@ public enum MenuBarPresenter {
return "\(action) — \(project)"
}
return action
- case .review, .failingCI:
- return "Open \(item.title) — \(item.detail)"
+ case .review:
+ let base = itemNumber(item).map { "Open Review #\($0)" } ?? "Open Review"
+ return menuActionTitle(base, repository: repositoryName(item: item))
+ case .failingCI:
+ let base = itemNumber(item).map { "Inspect CI #\($0)" } ?? "Inspect CI"
+ return menuActionTitle(base, repository: repositoryName(item: item))
}
}
@@ -97,7 +132,18 @@ public enum MenuBarPresenter {
}
return "\(attention) — click to return"
}
- return "\(item.title): \(item.detail)"
+ let number = itemNumber(item)
+ let repository = middleTruncated(item.detail, limit: 64)
+ switch item.kind {
+ case .review:
+ let identity = number.map { "\(repository) #\($0)" } ?? repository
+ return "Review requested — \(identity) — click to open"
+ case .failingCI:
+ let identity = number.map { "\(repository) #\($0)" } ?? repository
+ return "CI failed — \(identity) — click to inspect checks"
+ case .needsYou:
+ return "\(item.title) — click to return"
+ }
}
switch phase {
case .idle: return "Meanwhile — idle"
@@ -118,8 +164,16 @@ public enum MenuBarPresenter {
return "\(attention) in \(project)"
}
return attention
- case .review, .failingCI:
- return "\(item.title), \(item.detail)"
+ case .review:
+ if let number = itemNumber(item) {
+ return "Review requested for pull request \(number) in \(item.detail)"
+ }
+ return "Review requested in \(item.detail)"
+ case .failingCI:
+ if let number = itemNumber(item) {
+ return "Continuous integration failed for pull request \(number) in \(item.detail)"
+ }
+ return "Continuous integration failed in \(item.detail)"
}
}
switch phase {
@@ -141,8 +195,12 @@ public enum MenuBarPresenter {
return "Returns to the waiting task."
}
return "Returns to the waiting \(providerName(provider)) task."
- case .review, .failingCI:
- return "Opens \(item.title)."
+ case .review:
+ return itemNumber(item).map { "Opens pull request \($0) on GitHub." }
+ ?? "Opens the pull request on GitHub."
+ case .failingCI:
+ return itemNumber(item).map { "Opens the failed checks for pull request \($0) on GitHub." }
+ ?? "Opens the failed checks on GitHub."
}
}
return phase == .needsYou
@@ -153,11 +211,39 @@ public enum MenuBarPresenter {
public static func statuslineText(item: WorkItem) -> String {
switch item.kind {
case .needsYou: return "Meanwhile: \(attentionText(item: item))"
- case .failingCI: return "Meanwhile: CI failed — \(item.detail)"
+ case .failingCI: return "Meanwhile: \(item.title) — \(item.detail)"
case .review: return "Meanwhile: \(item.title) — \(item.detail)"
}
}
+ private static func itemNumber(_ item: WorkItem) -> String? {
+ guard let marker = item.title.lastIndex(of: "#") else { return nil }
+ let suffix = item.title[item.title.index(after: marker)...]
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !suffix.isEmpty, suffix.allSatisfy(\.isNumber) else { return nil }
+ return suffix
+ }
+
+ private static func contextualTitle(_ base: String, context: String?) -> String {
+ guard let context else { return base }
+ let separator = " — "
+ let totalLimit = 46
+ let contextLimit = min(20, totalLimit - base.count - separator.count)
+ guard contextLimit >= 8 else { return base }
+ let compactContext = middleTruncated(context, limit: contextLimit)
+ let combined = "\(base)\(separator)\(compactContext)"
+ return combined.count <= totalLimit ? combined : base
+ }
+
+ private static func menuActionTitle(_ base: String, repository: String?) -> String {
+ guard let repository else { return base }
+ let separator = " — "
+ let totalLimit = 30
+ let repositoryLimit = totalLimit - base.count - separator.count
+ guard repositoryLimit >= 6 else { return base }
+ return "\(base)\(separator)\(middleTruncated(repository, limit: repositoryLimit))"
+ }
+
private static func providerName(_ provider: AgentProvider) -> String {
switch provider {
case .claude: return "Claude"
diff --git a/Sources/MeanwhileCore/NeedsYouNotificationPreferences.swift b/Sources/MeanwhileCore/NeedsYouNotificationPreferences.swift
new file mode 100644
index 0000000..679421c
--- /dev/null
+++ b/Sources/MeanwhileCore/NeedsYouNotificationPreferences.swift
@@ -0,0 +1,103 @@
+import Foundation
+
+public enum NeedsYouNotificationDelay: Int, CaseIterable, Codable, Sendable {
+ case oneMinute = 60
+ case fiveMinutes = 300
+ case fifteenMinutes = 900
+ case thirtyMinutes = 1_800
+
+ public var timeInterval: TimeInterval { TimeInterval(rawValue) }
+}
+
+public enum NeedsYouNotificationPermission: Equatable, Sendable {
+ case unknown
+ case notDetermined
+ case authorized
+ case denied
+ case limited
+ case unavailable
+}
+
+public struct NeedsYouNotificationSettings: Equatable, Sendable {
+ public var isEnabled: Bool
+ public var delay: NeedsYouNotificationDelay
+
+ public init(
+ isEnabled: Bool = false,
+ delay: NeedsYouNotificationDelay = .oneMinute
+ ) {
+ self.isEnabled = isEnabled
+ self.delay = delay
+ }
+}
+
+public final class NeedsYouNotificationPreferences: @unchecked Sendable {
+ private enum Key {
+ static let enabled = "Meanwhile.notifications.needsYou.enabled"
+ static let delay = "Meanwhile.notifications.needsYou.delaySeconds"
+ static let receipts = "Meanwhile.notifications.needsYou.receipts"
+ }
+
+ private static let receiptLimit = 32
+
+ private let defaults: UserDefaults
+ private let lock = NSLock()
+ private var state: NeedsYouNotificationSettings
+ private var receiptIdentifiers: [String]
+
+ public init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ let rawDelay = defaults.integer(forKey: Key.delay)
+ state = NeedsYouNotificationSettings(
+ isEnabled: defaults.bool(forKey: Key.enabled),
+ delay: NeedsYouNotificationDelay(rawValue: rawDelay) ?? .oneMinute
+ )
+ receiptIdentifiers = Array(
+ (defaults.stringArray(forKey: Key.receipts) ?? [])
+ .suffix(Self.receiptLimit)
+ )
+ }
+
+ public var settings: NeedsYouNotificationSettings {
+ lock.lock()
+ defer { lock.unlock() }
+ return state
+ }
+
+ public func setEnabled(_ enabled: Bool) {
+ update {
+ $0.isEnabled = enabled
+ }
+ }
+
+ public func setDelay(_ delay: NeedsYouNotificationDelay) {
+ update {
+ $0.delay = delay
+ }
+ }
+
+ public func containsReceipt(identifier: String) -> Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return receiptIdentifiers.contains(identifier)
+ }
+
+ public func recordReceipt(identifier: String) {
+ lock.lock()
+ defer { lock.unlock() }
+ receiptIdentifiers.removeAll { $0 == identifier }
+ receiptIdentifiers.append(identifier)
+ if receiptIdentifiers.count > Self.receiptLimit {
+ receiptIdentifiers.removeFirst(receiptIdentifiers.count - Self.receiptLimit)
+ }
+ defaults.set(receiptIdentifiers, forKey: Key.receipts)
+ }
+
+ private func update(_ body: (inout NeedsYouNotificationSettings) -> Void) {
+ lock.lock()
+ defer { lock.unlock() }
+ body(&state)
+ defaults.set(state.isEnabled, forKey: Key.enabled)
+ defaults.set(state.delay.rawValue, forKey: Key.delay)
+ }
+}
diff --git a/Sources/MeanwhileCore/NeedsYouNotificationState.swift b/Sources/MeanwhileCore/NeedsYouNotificationState.swift
new file mode 100644
index 0000000..2084f10
--- /dev/null
+++ b/Sources/MeanwhileCore/NeedsYouNotificationState.swift
@@ -0,0 +1,138 @@
+import Foundation
+
+public enum NeedsYouNotificationTransition: Equatable, Sendable {
+ case none
+ case deliver(item: WorkItem)
+ case replace(previousItemID: String, delivery: WorkItem?)
+ case cancel(itemID: String)
+}
+
+public struct NeedsYouNotificationState: Sendable {
+ private enum DeliveryState: Sendable {
+ case armed
+ case attempted
+ case acknowledged
+ }
+
+ private struct Candidate: Sendable {
+ var itemID: String
+ var deliveryState: DeliveryState
+ var retryNotBefore: Date?
+ }
+
+ private var candidate: Candidate?
+ private var suppressedItemIDs: Set = []
+ private var suppressionOrder: [String] = []
+
+ public init() {}
+
+ public mutating func observe(
+ settings: NeedsYouNotificationSettings,
+ permission: NeedsYouNotificationPermission,
+ phase: AgentDisplayPhase,
+ item: WorkItem?,
+ now: Date = Date()
+ ) -> NeedsYouNotificationTransition {
+ let eligibleItem: WorkItem?
+ if settings.isEnabled,
+ permission == .authorized,
+ phase == .needsYou,
+ let item,
+ item.kind == .needsYou {
+ eligibleItem = item
+ } else {
+ eligibleItem = nil
+ }
+
+ guard let eligibleItem else {
+ guard let previous = candidate else { return .none }
+ candidate = nil
+ return .cancel(itemID: previous.itemID)
+ }
+
+ if suppressedItemIDs.contains(eligibleItem.id) {
+ let previousItemID = candidate?.itemID
+ candidate = Candidate(
+ itemID: eligibleItem.id,
+ deliveryState: .attempted,
+ retryNotBefore: nil
+ )
+ if let previousItemID, previousItemID != eligibleItem.id {
+ return .replace(previousItemID: previousItemID, delivery: nil)
+ }
+ return .none
+ }
+
+ let deadline = eligibleItem.createdAt.addingTimeInterval(
+ settings.delay.timeInterval
+ )
+ if var existing = candidate, existing.itemID == eligibleItem.id {
+ let deliveryDate = max(deadline, existing.retryNotBefore ?? .distantPast)
+ guard existing.deliveryState == .armed, now >= deliveryDate else {
+ return .none
+ }
+ existing.deliveryState = .attempted
+ existing.retryNotBefore = nil
+ candidate = existing
+ suppress(itemID: eligibleItem.id)
+ return .deliver(item: eligibleItem)
+ }
+
+ let previousItemID = candidate?.itemID
+ let delayHasElapsed = now >= deadline
+ candidate = Candidate(
+ itemID: eligibleItem.id,
+ deliveryState: delayHasElapsed ? .attempted : .armed,
+ retryNotBefore: nil
+ )
+ let delivery = delayHasElapsed ? eligibleItem : nil
+ if delivery != nil {
+ suppress(itemID: eligibleItem.id)
+ }
+ if let previousItemID {
+ return .replace(previousItemID: previousItemID, delivery: delivery)
+ }
+ return delivery.map(NeedsYouNotificationTransition.deliver(item:)) ?? .none
+ }
+
+ public mutating func acknowledge(itemID: String) -> NeedsYouNotificationTransition {
+ suppress(itemID: itemID)
+ guard var existing = candidate, existing.itemID == itemID else {
+ return .cancel(itemID: itemID)
+ }
+ existing.deliveryState = .acknowledged
+ existing.retryNotBefore = nil
+ candidate = existing
+ return .cancel(itemID: itemID)
+ }
+
+ public mutating func restoreReceipt(itemID: String) {
+ suppress(itemID: itemID)
+ guard var existing = candidate, existing.itemID == itemID else { return }
+ existing.deliveryState = .attempted
+ existing.retryNotBefore = nil
+ candidate = existing
+ }
+
+ public mutating func deliveryFailed(itemID: String, retryNotBefore: Date) {
+ unsuppress(itemID: itemID)
+ guard var existing = candidate, existing.itemID == itemID else { return }
+ existing.deliveryState = .armed
+ existing.retryNotBefore = retryNotBefore
+ candidate = existing
+ }
+
+ private mutating func suppress(itemID: String) {
+ guard suppressedItemIDs.insert(itemID).inserted else { return }
+ suppressionOrder.append(itemID)
+ if suppressionOrder.count > 64 {
+ let removed = suppressionOrder.removeFirst()
+ suppressedItemIDs.remove(removed)
+ }
+ }
+
+ private mutating func unsuppress(itemID: String) {
+ suppressedItemIDs.remove(itemID)
+ suppressionOrder.removeAll { $0 == itemID }
+ }
+}
diff --git a/Sources/MeanwhileCore/StatusItemBloomState.swift b/Sources/MeanwhileCore/StatusItemBloomState.swift
index 5a38825..22abdee 100644
--- a/Sources/MeanwhileCore/StatusItemBloomState.swift
+++ b/Sources/MeanwhileCore/StatusItemBloomState.swift
@@ -9,7 +9,7 @@ public enum StatusItemBloomTransition: Equatable, Sendable {
public struct StatusItemBloomState: Sendable {
private struct Candidate: Equatable, Sendable {
var itemID: String
- var reason: AgentAttentionReason
+ var reason: AgentAttentionReason?
}
private var hasObservedPresentation = false
@@ -27,12 +27,13 @@ public struct StatusItemBloomState: Sendable {
item: WorkItem?
) -> StatusItemBloomTransition {
let candidate: Candidate?
- if phase == .needsYou,
- let item,
- item.kind == .needsYou {
+ if let item,
+ (item.kind == .needsYou ? phase == .needsYou : phase == .thinking) {
candidate = Candidate(
itemID: item.id,
- reason: item.session?.effectiveAttentionReason ?? .generic
+ reason: item.kind == .needsYou
+ ? item.session?.effectiveAttentionReason ?? .generic
+ : nil
)
} else {
candidate = nil
diff --git a/Sources/MeanwhileCore/WorkItem.swift b/Sources/MeanwhileCore/WorkItem.swift
index c0e3882..6cffeec 100644
--- a/Sources/MeanwhileCore/WorkItem.swift
+++ b/Sources/MeanwhileCore/WorkItem.swift
@@ -32,6 +32,10 @@ public struct WorkItem: Equatable, Codable, Sendable, Identifiable {
self.createdAt = createdAt
self.session = session
}
+
+ public static func needsYouID(for session: AgentSessionState) -> String {
+ "needs-you:\(session.id):\(session.enteredAt.timeIntervalSince1970)"
+ }
}
public enum WorkItemOrdering {
diff --git a/Tests/MeanwhileCoreTests/MenuBarPresenterTests.swift b/Tests/MeanwhileCoreTests/MenuBarPresenterTests.swift
index b8a42b2..daede72 100644
--- a/Tests/MeanwhileCoreTests/MenuBarPresenterTests.swift
+++ b/Tests/MeanwhileCoreTests/MenuBarPresenterTests.swift
@@ -19,10 +19,122 @@ final class MenuBarPresenterTests: XCTestCase {
MenuBarPresenter.statusText(item: item(.needsYou, title: "Codex needs you")),
"Codex needs you"
)
- XCTAssertEqual(MenuBarPresenter.statusText(item: item(.failingCI, title: "CI failed on #4")), "CI!")
+ XCTAssertEqual(MenuBarPresenter.statusText(item: item(.failingCI, title: "CI failed on #4")), "CI! #4")
XCTAssertEqual(MenuBarPresenter.statusText(item: item(.review, title: "Review #78")), "#78")
}
+ func testReviewAndCIBloomNameTheSingleActionableItem() {
+ XCTAssertEqual(
+ MenuBarPresenter.bloomText(item: item(.review, title: "Review #78")),
+ "Review #78 — repo"
+ )
+ XCTAssertEqual(
+ MenuBarPresenter.bloomText(item: item(.failingCI, title: "CI failed on #42")),
+ "CI failed #42 — repo"
+ )
+ }
+
+ func testReviewAndCIActionsAreExplicitAndCompact() {
+ let review = item(.review, title: "Review #78")
+ let ci = item(.failingCI, title: "CI failed on #42")
+
+ XCTAssertEqual(MenuBarPresenter.openActionTitle(item: review), "Open Review #78 — repo")
+ XCTAssertEqual(MenuBarPresenter.openActionTitle(item: ci), "Inspect CI #42 — repo")
+ XCTAssertLessThanOrEqual(MenuBarPresenter.openActionTitle(item: review).count, 30)
+ XCTAssertLessThanOrEqual(MenuBarPresenter.openActionTitle(item: ci).count, 30)
+
+ let longRepository = item(
+ .review,
+ title: "Review #78",
+ detail: "acme/a-very-long-repository-name-with-a-readable-tail"
+ )
+ XCTAssertLessThanOrEqual(MenuBarPresenter.openActionTitle(item: longRepository).count, 30)
+ }
+
+ func testReviewAndCITooltipsExplainTheClickDestination() {
+ XCTAssertEqual(
+ MenuBarPresenter.tooltip(
+ phase: .thinking,
+ item: item(.review, title: "Review #78")
+ ),
+ "Review requested — acme/repo #78 — click to open"
+ )
+ XCTAssertEqual(
+ MenuBarPresenter.tooltip(
+ phase: .thinking,
+ item: item(.failingCI, title: "CI failed on #42")
+ ),
+ "CI failed — acme/repo #42 — click to inspect checks"
+ )
+ }
+
+ func testReviewAndCIAccessibilityNamesTheItemAndItsResult() {
+ XCTAssertEqual(
+ MenuBarPresenter.accessibilityLabel(
+ phase: .thinking,
+ item: item(.review, title: "Review #78")
+ ),
+ "Review requested for pull request 78 in acme/repo"
+ )
+ XCTAssertEqual(
+ MenuBarPresenter.accessibilityHelp(
+ phase: .thinking,
+ item: item(.review, title: "Review #78")
+ ),
+ "Opens pull request 78 on GitHub."
+ )
+ XCTAssertEqual(
+ MenuBarPresenter.accessibilityLabel(
+ phase: .thinking,
+ item: item(.failingCI, title: "CI failed on #42")
+ ),
+ "Continuous integration failed for pull request 42 in acme/repo"
+ )
+ XCTAssertEqual(
+ MenuBarPresenter.accessibilityHelp(
+ phase: .thinking,
+ item: item(.failingCI, title: "CI failed on #42")
+ ),
+ "Opens the failed checks for pull request 42 on GitHub."
+ )
+ }
+
+ func testReviewOpensPullRequestAndCIOpensThatPullRequestsChecks() throws {
+ let pullRequestURL = try XCTUnwrap(URL(string: "https://github.com/acme/repo/pull/42"))
+ let checksURL = pullRequestURL.appendingPathComponent("checks")
+ let review = item(.review, title: "Review #42", url: pullRequestURL)
+ let ci = item(.failingCI, title: "CI failed on #42", url: pullRequestURL)
+ let ciAlreadyAtChecks = item(.failingCI, title: "CI failed on #42", url: checksURL)
+
+ XCTAssertEqual(MenuBarPresenter.destinationURL(item: review), pullRequestURL)
+ XCTAssertEqual(MenuBarPresenter.destinationURL(item: ci), checksURL)
+ XCTAssertEqual(MenuBarPresenter.destinationURL(item: ciAlreadyAtChecks), checksURL)
+ XCTAssertNil(MenuBarPresenter.destinationURL(item: item(.failingCI, title: "CI failed on #42")))
+ }
+
+ func testMalformedReviewAndCITitlesDoNotBecomeFakePullRequestNumbers() {
+ let review = item(.review, title: "Review ready")
+ let mixedReview = item(.review, title: "Review #12oops")
+ let ci = item(.failingCI, title: "CI failed on #not-a-number")
+
+ XCTAssertEqual(MenuBarPresenter.statusText(item: review), "Review")
+ XCTAssertEqual(MenuBarPresenter.bloomText(item: review), "Review ready — repo")
+ XCTAssertEqual(MenuBarPresenter.openActionTitle(item: review), "Open Review — repo")
+ XCTAssertEqual(
+ MenuBarPresenter.accessibilityLabel(phase: .thinking, item: review),
+ "Review requested in acme/repo"
+ )
+ XCTAssertEqual(MenuBarPresenter.statusText(item: mixedReview), "Review")
+
+ XCTAssertEqual(MenuBarPresenter.statusText(item: ci), "CI!")
+ XCTAssertEqual(MenuBarPresenter.bloomText(item: ci), "CI failed — repo")
+ XCTAssertEqual(MenuBarPresenter.openActionTitle(item: ci), "Inspect CI — repo")
+ XCTAssertEqual(
+ MenuBarPresenter.accessibilityHelp(phase: .thinking, item: ci),
+ "Opens the failed checks on GitHub."
+ )
+ }
+
func testNeedsYouCopyAddsProjectContextOutsideTheStatusTitle() {
let item = item(
.needsYou,
@@ -124,21 +236,42 @@ final class MenuBarPresenterTests: XCTestCase {
XCTAssertLessThanOrEqual(bloom?.count ?? .max, 46)
}
- func testAccessibilityNamesVisibleReviewAndCIItemsWhileAgentIsThinking() {
- XCTAssertEqual(
- MenuBarPresenter.accessibilityLabel(
- phase: .thinking,
- item: item(.review, title: "Review #78")
- ),
- "Review #78, acme/repo"
+ func testReviewBloomBoundsLongUnicodeRepositoryWithoutLosingIdentity() throws {
+ let item = item(
+ .review,
+ title: "Review #78",
+ detail: "acme/Meanwhile-🚀-repository-with-a-very-readable-tail"
)
- XCTAssertEqual(
- MenuBarPresenter.accessibilityLabel(
- phase: .thinking,
- item: item(.failingCI, title: "CI failed on #4")
- ),
- "CI failed on #4, acme/repo"
+
+ let bloom = try XCTUnwrap(MenuBarPresenter.bloomText(item: item))
+ XCTAssertTrue(bloom.hasPrefix("Review #78 — "))
+ XCTAssertLessThanOrEqual(bloom.count, 46)
+ }
+
+ func testNeedsYouNotificationTitlesStayReasonAwareAndReviewCIRemainSilent() {
+ let approval = item(
+ .needsYou,
+ title: "Codex needs you",
+ session: session(
+ provider: .codex,
+ cwd: "/tmp/Meanwhile",
+ reason: .approvalRequired
+ )
+ )
+ let answer = item(
+ .needsYou,
+ title: "Claude needs you",
+ session: session(
+ provider: .claude,
+ cwd: "/tmp/Meanwhile",
+ reason: .answerRequired
+ )
)
+
+ XCTAssertEqual(MenuBarPresenter.notificationTitle(item: approval), "Codex still needs approval")
+ XCTAssertEqual(MenuBarPresenter.notificationTitle(item: answer), "Claude still needs an answer")
+ XCTAssertNil(MenuBarPresenter.notificationTitle(item: item(.review, title: "Review #78")))
+ XCTAssertNil(MenuBarPresenter.notificationTitle(item: item(.failingCI, title: "CI failed on #42")))
}
func testProjectNameRejectsPrivateOrUnhelpfulContexts() {
@@ -177,13 +310,16 @@ final class MenuBarPresenterTests: XCTestCase {
private func item(
_ kind: WorkItemKind,
title: String,
+ detail: String = "acme/repo",
+ url: URL? = nil,
session: AgentSessionState? = nil
) -> WorkItem {
WorkItem(
id: UUID().uuidString,
kind: kind,
title: title,
- detail: "acme/repo",
+ detail: detail,
+ url: url,
createdAt: Date(timeIntervalSince1970: 1),
session: session
)
diff --git a/Tests/MeanwhileCoreTests/NeedsYouNotificationPreferencesTests.swift b/Tests/MeanwhileCoreTests/NeedsYouNotificationPreferencesTests.swift
new file mode 100644
index 0000000..9164fa4
--- /dev/null
+++ b/Tests/MeanwhileCoreTests/NeedsYouNotificationPreferencesTests.swift
@@ -0,0 +1,76 @@
+import Foundation
+import XCTest
+@testable import MeanwhileCore
+
+final class NeedsYouNotificationPreferencesTests: XCTestCase {
+ func testDefaultsToDisabledWithOneMinuteDelay() throws {
+ let fixture = try makeFixture()
+ defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) }
+
+ let preferences = NeedsYouNotificationPreferences(defaults: fixture.defaults)
+
+ XCTAssertEqual(
+ preferences.settings,
+ NeedsYouNotificationSettings(isEnabled: false, delay: .oneMinute)
+ )
+ }
+
+ func testPersistsEnabledStateAndDelayAcrossInstances() throws {
+ let fixture = try makeFixture()
+ defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) }
+
+ let preferences = NeedsYouNotificationPreferences(defaults: fixture.defaults)
+ preferences.setEnabled(true)
+ preferences.setDelay(.fifteenMinutes)
+
+ XCTAssertEqual(
+ NeedsYouNotificationPreferences(defaults: fixture.defaults).settings,
+ NeedsYouNotificationSettings(isEnabled: true, delay: .fifteenMinutes)
+ )
+ }
+
+ func testInvalidPersistedDelayFallsBackToOneMinuteWithoutLosingEnabledIntent() throws {
+ let fixture = try makeFixture()
+ defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) }
+ fixture.defaults.set(true, forKey: "Meanwhile.notifications.needsYou.enabled")
+ fixture.defaults.set(777, forKey: "Meanwhile.notifications.needsYou.delaySeconds")
+
+ let preferences = NeedsYouNotificationPreferences(defaults: fixture.defaults)
+
+ XCTAssertEqual(
+ preferences.settings,
+ NeedsYouNotificationSettings(isEnabled: true, delay: .oneMinute)
+ )
+ }
+
+ func testSupportedDelaysExposeTheirSchedulingIntervals() {
+ XCTAssertEqual(
+ NeedsYouNotificationDelay.allCases.map(\.timeInterval),
+ [60, 300, 900, 1_800]
+ )
+ }
+
+ func testPersistsAndBoundsOpaqueDeliveryReceipts() throws {
+ let fixture = try makeFixture()
+ defer { fixture.defaults.removePersistentDomain(forName: fixture.suiteName) }
+ let preferences = NeedsYouNotificationPreferences(defaults: fixture.defaults)
+
+ for index in 0..<35 {
+ preferences.recordReceipt(identifier: "opaque-\(index)")
+ }
+ preferences.recordReceipt(identifier: "opaque-34")
+
+ let reloaded = NeedsYouNotificationPreferences(defaults: fixture.defaults)
+ XCTAssertFalse(reloaded.containsReceipt(identifier: "opaque-0"))
+ XCTAssertFalse(reloaded.containsReceipt(identifier: "opaque-2"))
+ XCTAssertTrue(reloaded.containsReceipt(identifier: "opaque-3"))
+ XCTAssertTrue(reloaded.containsReceipt(identifier: "opaque-34"))
+ }
+
+ private func makeFixture() throws -> (suiteName: String, defaults: UserDefaults) {
+ let suiteName = "NeedsYouNotificationPreferencesTests.\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defaults.removePersistentDomain(forName: suiteName)
+ return (suiteName, defaults)
+ }
+}
diff --git a/Tests/MeanwhileCoreTests/NeedsYouNotificationStateTests.swift b/Tests/MeanwhileCoreTests/NeedsYouNotificationStateTests.swift
new file mode 100644
index 0000000..2ffc265
--- /dev/null
+++ b/Tests/MeanwhileCoreTests/NeedsYouNotificationStateTests.swift
@@ -0,0 +1,408 @@
+import Foundation
+import XCTest
+@testable import MeanwhileCore
+
+final class NeedsYouNotificationStateTests: XCTestCase {
+ private let origin = Date(timeIntervalSince1970: 1_000)
+
+ func testDisabledOrUnauthorizedSettingsDoNotArmOrDeliver() {
+ for permission in [
+ NeedsYouNotificationPermission.unknown,
+ .notDetermined,
+ .denied,
+ .limited,
+ .unavailable
+ ] {
+ var state = NeedsYouNotificationState()
+ XCTAssertEqual(
+ state.observe(
+ settings: settings(enabled: true),
+ permission: permission,
+ phase: .needsYou,
+ item: needsYouItem("A"),
+ now: origin.addingTimeInterval(120)
+ ),
+ .none,
+ "Unexpected delivery for \(permission)"
+ )
+ }
+
+ var state = NeedsYouNotificationState()
+ XCTAssertEqual(
+ state.observe(
+ settings: settings(enabled: false),
+ permission: .authorized,
+ phase: .needsYou,
+ item: needsYouItem("A"),
+ now: origin.addingTimeInterval(120)
+ ),
+ .none
+ )
+ }
+
+ func testReviewsAndFailingCIDoNotArmOrDeliver() {
+ for item in [reviewItem(), ciItem()] {
+ var state = NeedsYouNotificationState()
+ XCTAssertEqual(
+ state.observe(
+ settings: settings(enabled: true),
+ permission: .authorized,
+ phase: .thinking,
+ item: item,
+ now: origin.addingTimeInterval(120)
+ ),
+ .none
+ )
+ XCTAssertEqual(
+ state.observe(
+ settings: settings(enabled: true),
+ permission: .authorized,
+ phase: .needsYou,
+ item: item,
+ now: origin.addingTimeInterval(120)
+ ),
+ .none
+ )
+ }
+ }
+
+ func testArmsBeforeDeadlineThenDeliversOnFirstConfirmingPollAtDeadline() {
+ var state = NeedsYouNotificationState()
+ let item = needsYouItem("A", createdAt: origin)
+
+ XCTAssertEqual(observe(item, with: &state, now: origin), .none)
+ XCTAssertEqual(
+ observe(item, with: &state, now: origin.addingTimeInterval(59.999)),
+ .none
+ )
+ XCTAssertEqual(
+ observe(item, with: &state, now: origin.addingTimeInterval(60)),
+ .deliver(item: item)
+ )
+ }
+
+ func testRepeatedPollingDeliversOnlyOnce() {
+ var state = NeedsYouNotificationState()
+ let item = needsYouItem("A", createdAt: origin)
+
+ XCTAssertEqual(observe(item, with: &state, now: origin), .none)
+ XCTAssertEqual(
+ observe(item, with: &state, now: origin.addingTimeInterval(60)),
+ .deliver(item: item)
+ )
+ XCTAssertEqual(
+ observe(item, with: &state, now: origin.addingTimeInterval(61)),
+ .none
+ )
+ XCTAssertEqual(
+ observe(item, with: &state, now: origin.addingTimeInterval(300)),
+ .none
+ )
+ }
+
+ func testAlreadyElapsedDelayDeliversImmediatelyFromFreshPresentation() {
+ var state = NeedsYouNotificationState()
+ let item = needsYouItem("A", createdAt: origin)
+
+ XCTAssertEqual(
+ observe(item, with: &state, now: origin.addingTimeInterval(120)),
+ .deliver(item: item)
+ )
+ XCTAssertEqual(
+ observe(item, with: &state, now: origin.addingTimeInterval(121)),
+ .none
+ )
+ }
+
+ func testChangingSelectedItemCancelsPreviousAndArmsReplacement() {
+ var state = NeedsYouNotificationState()
+ let first = needsYouItem("A", createdAt: origin)
+ let second = needsYouItem("B", createdAt: origin.addingTimeInterval(10))
+ XCTAssertEqual(observe(first, with: &state, now: origin), .none)
+
+ XCTAssertEqual(
+ observe(second, with: &state, now: origin.addingTimeInterval(20)),
+ .replace(previousItemID: first.id, delivery: nil)
+ )
+ XCTAssertEqual(
+ observe(second, with: &state, now: origin.addingTimeInterval(70)),
+ .deliver(item: second)
+ )
+ }
+
+ func testChangingToAlreadyDueItemCancelsPreviousAndDeliversReplacement() {
+ var state = NeedsYouNotificationState()
+ let first = needsYouItem("A", createdAt: origin)
+ let second = needsYouItem("B", createdAt: origin.addingTimeInterval(-120))
+ XCTAssertEqual(observe(first, with: &state, now: origin), .none)
+
+ XCTAssertEqual(
+ observe(second, with: &state, now: origin),
+ .replace(previousItemID: first.id, delivery: second)
+ )
+ XCTAssertEqual(observe(second, with: &state, now: origin), .none)
+ }
+
+ func testReasonChangeForSameItemDoesNotDuplicateDelivery() {
+ var state = NeedsYouNotificationState()
+ let generic = needsYouItem("A", reason: .generic, createdAt: origin)
+ let approval = needsYouItem("A", reason: .approvalRequired, createdAt: origin)
+
+ XCTAssertEqual(observe(generic, with: &state, now: origin), .none)
+ XCTAssertEqual(
+ observe(approval, with: &state, now: origin.addingTimeInterval(60)),
+ .deliver(item: approval)
+ )
+ XCTAssertEqual(
+ observe(generic, with: &state, now: origin.addingTimeInterval(61)),
+ .none
+ )
+ }
+
+ func testShorteningDelayBelowElapsedTimeDeliversOnNextPoll() {
+ var state = NeedsYouNotificationState()
+ let item = needsYouItem("A", createdAt: origin)
+ XCTAssertEqual(
+ observe(
+ item,
+ with: &state,
+ now: origin,
+ delay: .fiveMinutes
+ ),
+ .none
+ )
+
+ XCTAssertEqual(
+ observe(
+ item,
+ with: &state,
+ now: origin.addingTimeInterval(90),
+ delay: .oneMinute
+ ),
+ .deliver(item: item)
+ )
+ }
+
+ func testResolutionDisableAndPermissionRevocationCancelCurrentItem() {
+ let item = needsYouItem("A")
+
+ var resolutionState = NeedsYouNotificationState()
+ XCTAssertEqual(observe(item, with: &resolutionState, now: origin), .none)
+ XCTAssertEqual(
+ resolutionState.observe(
+ settings: settings(enabled: true),
+ permission: .authorized,
+ phase: .thinking,
+ item: nil,
+ now: origin
+ ),
+ .cancel(itemID: item.id)
+ )
+ XCTAssertEqual(
+ resolutionState.observe(
+ settings: settings(enabled: true),
+ permission: .authorized,
+ phase: .idle,
+ item: nil,
+ now: origin
+ ),
+ .none
+ )
+
+ var disabledState = NeedsYouNotificationState()
+ XCTAssertEqual(observe(item, with: &disabledState, now: origin), .none)
+ XCTAssertEqual(
+ disabledState.observe(
+ settings: settings(enabled: false),
+ permission: .authorized,
+ phase: .needsYou,
+ item: item,
+ now: origin
+ ),
+ .cancel(itemID: item.id)
+ )
+
+ var deniedState = NeedsYouNotificationState()
+ XCTAssertEqual(observe(item, with: &deniedState, now: origin), .none)
+ XCTAssertEqual(
+ deniedState.observe(
+ settings: settings(enabled: true),
+ permission: .denied,
+ phase: .needsYou,
+ item: item,
+ now: origin
+ ),
+ .cancel(itemID: item.id)
+ )
+ }
+
+ func testAcknowledgementCancelsAndSuppressesUntilItemChanges() {
+ var state = NeedsYouNotificationState()
+ let first = needsYouItem("A", createdAt: origin)
+ let second = needsYouItem("B", createdAt: origin)
+
+ XCTAssertEqual(observe(first, with: &state, now: origin), .none)
+ XCTAssertEqual(
+ observe(first, with: &state, now: origin.addingTimeInterval(60)),
+ .deliver(item: first)
+ )
+ XCTAssertEqual(state.acknowledge(itemID: first.id), .cancel(itemID: first.id))
+ XCTAssertEqual(
+ observe(first, with: &state, now: origin.addingTimeInterval(300)),
+ .none
+ )
+ XCTAssertEqual(
+ observe(second, with: &state, now: origin.addingTimeInterval(300)),
+ .replace(previousItemID: first.id, delivery: second)
+ )
+ }
+
+ func testAcknowledgingUnknownItemStillRequestsScopedCancellation() {
+ var state = NeedsYouNotificationState()
+
+ XCTAssertEqual(
+ state.acknowledge(itemID: "needs-you:stale"),
+ .cancel(itemID: "needs-you:stale")
+ )
+ }
+
+ func testDeliveredOrAcknowledgedItemStaysSuppressedAfterTemporaryDisappearance() {
+ var deliveredState = NeedsYouNotificationState()
+ let delivered = needsYouItem("delivered", createdAt: origin)
+ XCTAssertEqual(
+ observe(delivered, with: &deliveredState, now: origin.addingTimeInterval(60)),
+ .deliver(item: delivered)
+ )
+ XCTAssertEqual(
+ deliveredState.observe(
+ settings: settings(enabled: true),
+ permission: .authorized,
+ phase: .thinking,
+ item: nil,
+ now: origin.addingTimeInterval(61)
+ ),
+ .cancel(itemID: delivered.id)
+ )
+ XCTAssertEqual(
+ observe(delivered, with: &deliveredState, now: origin.addingTimeInterval(900)),
+ .none
+ )
+
+ var acknowledgedState = NeedsYouNotificationState()
+ let acknowledged = needsYouItem("acknowledged", createdAt: origin)
+ XCTAssertEqual(observe(acknowledged, with: &acknowledgedState, now: origin), .none)
+ XCTAssertEqual(
+ acknowledgedState.acknowledge(itemID: acknowledged.id),
+ .cancel(itemID: acknowledged.id)
+ )
+ _ = acknowledgedState.observe(
+ settings: settings(enabled: true),
+ permission: .authorized,
+ phase: .thinking,
+ item: nil,
+ now: origin.addingTimeInterval(1)
+ )
+ XCTAssertEqual(
+ observe(acknowledged, with: &acknowledgedState, now: origin.addingTimeInterval(900)),
+ .none
+ )
+ }
+
+ func testRestoredReceiptSuppressesDeliveryAcrossStateRecreation() {
+ var state = NeedsYouNotificationState()
+ let item = needsYouItem("restored", createdAt: origin)
+ state.restoreReceipt(itemID: item.id)
+
+ XCTAssertEqual(
+ observe(item, with: &state, now: origin.addingTimeInterval(900)),
+ .none
+ )
+ }
+
+ func testFailedDeliveryRearmsWithBackoffInsteadOfRetryingEveryPoll() {
+ var state = NeedsYouNotificationState()
+ let item = needsYouItem("retry", createdAt: origin)
+ let failedAt = origin.addingTimeInterval(60)
+
+ XCTAssertEqual(observe(item, with: &state, now: failedAt), .deliver(item: item))
+ state.deliveryFailed(
+ itemID: item.id,
+ retryNotBefore: failedAt.addingTimeInterval(60)
+ )
+ XCTAssertEqual(
+ observe(item, with: &state, now: failedAt.addingTimeInterval(59)),
+ .none
+ )
+ XCTAssertEqual(
+ observe(item, with: &state, now: failedAt.addingTimeInterval(60)),
+ .deliver(item: item)
+ )
+ }
+
+ private func observe(
+ _ item: WorkItem,
+ with state: inout NeedsYouNotificationState,
+ now: Date,
+ delay: NeedsYouNotificationDelay = .oneMinute
+ ) -> NeedsYouNotificationTransition {
+ state.observe(
+ settings: settings(enabled: true, delay: delay),
+ permission: .authorized,
+ phase: .needsYou,
+ item: item,
+ now: now
+ )
+ }
+
+ private func settings(
+ enabled: Bool,
+ delay: NeedsYouNotificationDelay = .oneMinute
+ ) -> NeedsYouNotificationSettings {
+ NeedsYouNotificationSettings(isEnabled: enabled, delay: delay)
+ }
+
+ private func needsYouItem(
+ _ id: String,
+ reason: AgentAttentionReason = .generic,
+ createdAt: Date? = nil
+ ) -> WorkItem {
+ let createdAt = createdAt ?? origin
+ let session = AgentSessionState(
+ provider: .codex,
+ sessionID: id,
+ cwd: "/tmp/Meanwhile",
+ phase: .needsYou,
+ attentionReason: reason,
+ enteredAt: createdAt,
+ updatedAt: createdAt
+ )
+ return WorkItem(
+ id: "needs-you:\(id)",
+ kind: .needsYou,
+ title: "Codex needs you",
+ detail: session.cwd,
+ createdAt: createdAt,
+ session: session
+ )
+ }
+
+ private func reviewItem() -> WorkItem {
+ WorkItem(
+ id: "review",
+ kind: .review,
+ title: "Review #78",
+ detail: "acme/repo",
+ createdAt: origin
+ )
+ }
+
+ private func ciItem() -> WorkItem {
+ WorkItem(
+ id: "ci",
+ kind: .failingCI,
+ title: "CI failed on #42",
+ detail: "acme/repo",
+ createdAt: origin
+ )
+ }
+}
diff --git a/Tests/MeanwhileCoreTests/StatusItemBloomStateTests.swift b/Tests/MeanwhileCoreTests/StatusItemBloomStateTests.swift
index b727086..40d097b 100644
--- a/Tests/MeanwhileCoreTests/StatusItemBloomStateTests.swift
+++ b/Tests/MeanwhileCoreTests/StatusItemBloomStateTests.swift
@@ -10,6 +10,16 @@ final class StatusItemBloomStateTests: XCTestCase {
XCTAssertFalse(state.isActive)
}
+ func testInitialReviewOrCIPresentationSeedsWithoutReplayingBloom() {
+ var reviewState = StatusItemBloomState()
+ XCTAssertEqual(reviewState.observe(phase: .thinking, item: review("R")), .none)
+ XCTAssertFalse(reviewState.isActive)
+
+ var ciState = StatusItemBloomState()
+ XCTAssertEqual(ciState.observe(phase: .thinking, item: failingCI("C")), .none)
+ XCTAssertFalse(ciState.isActive)
+ }
+
func testNewWaitingItemStartsOnceAndExpiryDoesNotRestartOnPolling() {
var state = StatusItemBloomState()
XCTAssertEqual(state.observe(phase: .idle, item: nil), .none)
@@ -42,6 +52,39 @@ final class StatusItemBloomStateTests: XCTestCase {
XCTAssertTrue(state.expire(itemID: secondID, generation: secondGeneration))
}
+ func testNewReviewStartsOnceAndDoesNotReplayAfterExpiry() {
+ var state = StatusItemBloomState()
+ XCTAssertEqual(state.observe(phase: .idle, item: nil), .none)
+
+ let transition = state.observe(phase: .thinking, item: review("R"))
+ guard case .start(let itemID, let generation) = transition else {
+ return XCTFail("Expected a review bloom start")
+ }
+ XCTAssertEqual(itemID, "R")
+ XCTAssertEqual(state.observe(phase: .thinking, item: review("R")), .none)
+ XCTAssertTrue(state.expire(itemID: itemID, generation: generation))
+ XCTAssertEqual(state.observe(phase: .thinking, item: review("R")), .none)
+ XCTAssertFalse(state.isActive)
+ }
+
+ func testReviewToCIRestartsAndStaleExpiryCannotSettleReplacement() {
+ var state = StatusItemBloomState()
+ _ = state.observe(phase: .idle, item: nil)
+ let first = state.observe(phase: .thinking, item: review("R"))
+ let second = state.observe(phase: .thinking, item: failingCI("C"))
+ guard case .start(let firstID, let firstGeneration) = first,
+ case .start(let secondID, let secondGeneration) = second else {
+ return XCTFail("Expected review and CI bloom starts")
+ }
+
+ XCTAssertEqual(firstID, "R")
+ XCTAssertEqual(secondID, "C")
+ XCTAssertNotEqual(firstGeneration, secondGeneration)
+ XCTAssertFalse(state.expire(itemID: firstID, generation: firstGeneration))
+ XCTAssertTrue(state.isActive)
+ XCTAssertTrue(state.expire(itemID: secondID, generation: secondGeneration))
+ }
+
func testMoreSpecificReasonForSameItemStartsAReplacementBloom() {
var state = StatusItemBloomState()
_ = state.observe(phase: .idle, item: nil)
@@ -59,15 +102,38 @@ final class StatusItemBloomStateTests: XCTestCase {
)
}
- func testLeavingNeedsYouCancelsAndReselectionMayBloomAgain() {
+ func testReplacingNeedsYouWithReviewStartsTheReviewBloom() {
var state = StatusItemBloomState()
_ = state.observe(phase: .idle, item: nil)
XCTAssertStart(state.observe(phase: .needsYou, item: item("A")))
- XCTAssertEqual(state.observe(phase: .thinking, item: review()), .cancel)
- XCTAssertFalse(state.isActive)
+ XCTAssertStart(state.observe(phase: .thinking, item: review("R")))
+ XCTAssertTrue(state.isActive)
XCTAssertStart(state.observe(phase: .needsYou, item: item("A")))
}
+ func testRemovingReviewOrCISelectionCancelsTheActiveBloom() {
+ var reviewState = StatusItemBloomState()
+ _ = reviewState.observe(phase: .idle, item: nil)
+ XCTAssertStart(reviewState.observe(phase: .thinking, item: review("R")))
+ XCTAssertEqual(reviewState.observe(phase: .thinking, item: nil), .cancel)
+ XCTAssertFalse(reviewState.isActive)
+
+ var ciState = StatusItemBloomState()
+ _ = ciState.observe(phase: .idle, item: nil)
+ XCTAssertStart(ciState.observe(phase: .thinking, item: failingCI("C")))
+ XCTAssertEqual(ciState.observe(phase: .idle, item: nil), .cancel)
+ XCTAssertFalse(ciState.isActive)
+ }
+
+ func testReviewAndCIRequireThinkingPhase() {
+ var state = StatusItemBloomState()
+ _ = state.observe(phase: .idle, item: nil)
+
+ XCTAssertEqual(state.observe(phase: .needsYou, item: review("R")), .none)
+ XCTAssertStart(state.observe(phase: .thinking, item: review("R")))
+ XCTAssertEqual(state.observe(phase: .needsYou, item: failingCI("C")), .cancel)
+ }
+
func testExplicitSettlementCancelsOnlyCurrentBloom() {
var state = StatusItemBloomState()
_ = state.observe(phase: .idle, item: nil)
@@ -104,9 +170,9 @@ final class StatusItemBloomStateTests: XCTestCase {
)
}
- private func review() -> WorkItem {
+ private func review(_ id: String) -> WorkItem {
WorkItem(
- id: "review",
+ id: id,
kind: .review,
title: "Review #1",
detail: "acme/repo",
@@ -114,6 +180,16 @@ final class StatusItemBloomStateTests: XCTestCase {
)
}
+ private func failingCI(_ id: String) -> WorkItem {
+ WorkItem(
+ id: id,
+ kind: .failingCI,
+ title: "CI failed on #1",
+ detail: "acme/repo",
+ createdAt: Date(timeIntervalSince1970: 1)
+ )
+ }
+
private func XCTAssertStart(
_ transition: StatusItemBloomTransition,
file: StaticString = #filePath,
diff --git a/docs/v0.1.2-always-ready.md b/docs/v0.1.2-always-ready.md
index 39f999f..08d8ccf 100644
--- a/docs/v0.1.2-always-ready.md
+++ b/docs/v0.1.2-always-ready.md
@@ -33,7 +33,40 @@ product.
supported terminal-backed sessions retain their exact TTY route, while stale
or unsupported focus targets fail visibly instead of activating an arbitrary
terminal or terminal window.
-- Idle remains icon-only, and review and failing-CI labels remain compact.
+- Newly selected GitHub work uses the same six-second native bloom: for example,
+ `Review #78 — WayfinderRouter` or `CI failed #42 — Meanwhile`. It then
+ settles to `#78` or `CI! #42`.
+- Review and CI hover text, accessibility copy, and menu actions explain the
+ handoff before the user clicks. Reviews open the pull request; failing CI
+ opens that pull request's checks page.
+- Idle remains icon-only. Meanwhile still selects and presents only one current
+ item; the clearer handoff does not add a queue, count, or second surface.
+
+### Optional needs-you notifications
+
+- Settings contains an off-by-default **Needs-you notifications** switch and a
+ 1, 5, 15, or 30 minute delay.
+- Enabling the switch is the only action that asks macOS for notification
+ permission. A denied or limited system state remains visible in Settings with
+ a route to Notification settings.
+- While Meanwhile is running, a fresh poll must confirm that the same selected
+ needs-you task is still waiting at the chosen threshold. Only then does
+ Meanwhile post one quiet Notification Center reminder.
+- The delay is measured from when the task entered needs-you, not from when
+ Settings opened or the next poll happened.
+- Opening, snoozing, or hiding the item; changing or resolving the task;
+ disabling notifications; or losing system permission clears the reminder.
+- Notification copy contains only the agent and coarse attention reason. It
+ never contains a project or repository name, working directory, prompt,
+ command, tool input, session identifier, or terminal metadata.
+- A bounded set of opaque, one-way delivery receipts prevents the same task
+ from notifying again after snooze, dismissal, or relaunch. Raw session and
+ work-item identifiers are not stored in those receipts.
+- Clicking a reminder returns only to the exact task that produced it. If that
+ task is no longer waiting, Meanwhile says so instead of opening a different
+ task.
+- Review and CI items never produce notifications, and Meanwhile does not add
+ sounds, badges, repeated reminders, or notification history.
### Launch at login
@@ -74,7 +107,7 @@ product.
- Remote diagnostics, analytics, or crash uploads.
- Session history, per-session management, or new work sources.
- A multi-session roster, waiting count, or attention inbox.
-- An island panel, notification escalation, prompt preview, or queued blooms.
+- An island panel, prompt preview, repeated notifications, or queued blooms.
- Any change to the needs-you, CI, review, snooze, or hide ordering contract.
## Acceptance
@@ -83,4 +116,4 @@ product.
coverage.
- The packaged macOS app builds, signs, launches, and exposes the complete flow
through Settings.
-- The version is 0.1.2 (build 4).
+- The version is 0.1.2 (build 5).