From 1343d48ce371231902caf3e75c5e48238dc086d8 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Sun, 19 Jul 2026 07:28:06 +0100 Subject: [PATCH] feat(attention): add reminders and clearer GitHub handoff [roadmap:v0.1.2] --- App/Info.plist | 2 +- README.md | 6 + Sources/Meanwhile/MeanwhileApp.swift | 162 ++++++- .../NeedsYouNotificationController.swift | 287 ++++++++++++ .../NeedsYouNotificationSettingsSection.swift | 123 ++++++ .../Meanwhile/RepositorySettingsModel.swift | 57 ++- .../Meanwhile/RepositorySettingsView.swift | 65 +++ Sources/Meanwhile/SettingsSupport.swift | 1 + .../Meanwhile/StatusItemSettingsSection.swift | 6 +- Sources/MeanwhileCore/MeanwhileEngine.swift | 2 +- Sources/MeanwhileCore/MenuBarPresenter.swift | 132 +++++- .../NeedsYouNotificationPreferences.swift | 103 +++++ .../NeedsYouNotificationState.swift | 138 ++++++ .../MeanwhileCore/StatusItemBloomState.swift | 11 +- Sources/MeanwhileCore/WorkItem.swift | 4 + .../MenuBarPresenterTests.swift | 166 ++++++- ...NeedsYouNotificationPreferencesTests.swift | 76 ++++ .../NeedsYouNotificationStateTests.swift | 408 ++++++++++++++++++ .../StatusItemBloomStateTests.swift | 86 +++- docs/v0.1.2-always-ready.md | 39 +- 20 files changed, 1816 insertions(+), 58 deletions(-) create mode 100644 Sources/Meanwhile/NeedsYouNotificationController.swift create mode 100644 Sources/Meanwhile/NeedsYouNotificationSettingsSection.swift create mode 100644 Sources/MeanwhileCore/NeedsYouNotificationPreferences.swift create mode 100644 Sources/MeanwhileCore/NeedsYouNotificationState.swift create mode 100644 Tests/MeanwhileCoreTests/NeedsYouNotificationPreferencesTests.swift create mode 100644 Tests/MeanwhileCoreTests/NeedsYouNotificationStateTests.swift 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).