diff --git a/App/Info.plist b/App/Info.plist index 41cab1d..1bc0d93 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.1.2 + 0.1.3 CFBundleVersion - 5 + 6 LSMinimumSystemVersion 14.0 LSUIElement diff --git a/README.md b/README.md index e2a4bb5..548343b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Meanwhile is a macOS 14 menu-bar app that turns coding-agent wait time into one small, actionable GitHub task. It is built in Swift 5.10 with no third-party dependencies. -## v0.1.2 behavior +## v0.1.3 behavior - Uses Claude Code and Codex lifecycle hooks—not process or CPU guesses—to track each session as `thinking`, `needs-you`, or `idle`. @@ -29,6 +29,15 @@ dependencies. - 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. +- Lets you run a clearly labelled six-second attention test from Settings, + including a test notification when reminders are available. The test never + creates work or replaces a real interruption. +- Shows independent freshness for review and failing-CI sources, with manual + refresh and in-context recovery for GitHub authentication or agent hooks. +- Keeps agent requests always on while letting you independently enable reviews + and failing CI from Settings, with no relaunch required. +- Keeps failed agent and browser handoffs active, offering Settings or Copy Link + instead of silently losing the route back. - 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 @@ -126,9 +135,9 @@ cask with: GITHUB_REPOSITORY="tcballard/Meanwhile" ./Scripts/release-unsigned.sh ``` -This produces `dist/Meanwhile-0.1.2-unsigned.zip` and `dist/meanwhile.rb`. +This produces `dist/Meanwhile-0.1.3-unsigned.zip` and `dist/meanwhile.rb`. Publish the archive only as a GitHub **pre-release** tagged -`v0.1.2-unsigned`. The generated cask identifies it as unsigned and tells users +`v0.1.3-unsigned`. The generated cask identifies it as unsigned and tells users that Gatekeeper will block the first launch. Users who trust the build must explicitly remove quarantine themselves; the cask does not bypass Gatekeeper. @@ -144,7 +153,7 @@ GITHUB_REPOSITORY="owner/Meanwhile" \ ``` The script builds and signs the app in a temporary local directory, then -produces `dist/Meanwhile-0.1.2.zip` and `dist/meanwhile.rb`. It verifies the +produces `dist/Meanwhile-0.1.3.zip` and `dist/meanwhile.rb`. It verifies the stapled app and a clean extraction of the final archive before returning. Publishing the archive, cask, and GitHub release remains an explicit release-owner action. Set `RELEASE_OUTPUT_DIR` to write the final artifacts diff --git a/Sources/Meanwhile/AttentionSourcesSettingsSection.swift b/Sources/Meanwhile/AttentionSourcesSettingsSection.swift new file mode 100644 index 0000000..df7984c --- /dev/null +++ b/Sources/Meanwhile/AttentionSourcesSettingsSection.swift @@ -0,0 +1,61 @@ +import MeanwhileCore +import SwiftUI + +struct AttentionSourcesSettingsSection: View { + @Binding var selection: AttentionSourceSelection + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + sourceRow( + title: "Agent requests", + detail: "Permission prompts, questions, and other agent handoffs.", + isOn: .constant(true), + isLocked: true + ) + sourceRow( + title: "Failing CI", + detail: "Failed checks on your open pull requests while an agent is thinking.", + isOn: $selection.failingCIEnabled + ) + sourceRow( + title: "Review requests", + detail: "Pull requests waiting for your review while an agent is thinking.", + isOn: $selection.reviewsEnabled + ) + Text("GitHub sources remain wait-gated: enabling them does not show work while every agent is idle.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func sourceRow( + title: String, + detail: String, + isOn: Binding, + isLocked: Bool = false + ) -> some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.callout.weight(.semibold)) + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 12) + if isLocked { + Label("Always on", systemImage: "checkmark.circle.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(.green) + .accessibilityLabel("Agent requests always on") + } else { + Toggle("", isOn: isOn) + .labelsHidden() + .toggleStyle(.switch) + .accessibilityLabel(title) + } + } + } +} diff --git a/Sources/Meanwhile/AttentionTestSettingsSection.swift b/Sources/Meanwhile/AttentionTestSettingsSection.swift new file mode 100644 index 0000000..fefe857 --- /dev/null +++ b/Sources/Meanwhile/AttentionTestSettingsSection.swift @@ -0,0 +1,59 @@ +import SwiftUI + +enum AttentionTestRunResult: Equatable { + case startedMenuBarOnly + case startedWithNotification + case blockedByRealAttention + case failedNotification + + var message: String { + switch self { + case .startedMenuBarOnly: + return "The menu bar will show a test for six seconds. Reminders are off or unavailable." + case .startedWithNotification: + return "The menu bar and a clearly labelled test notification are active." + case .blockedByRealAttention: + return "A real task needs you now, so Meanwhile did not replace it with a test." + case .failedNotification: + return "The menu-bar test worked, but macOS did not accept the test notification." + } + } + + var isError: Bool { + self == .blockedByRealAttention || self == .failedNotification + } +} + +struct AttentionTestSettingsSection: View { + let isRunning: Bool + let result: AttentionTestRunResult? + let run: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Test the attention loop") + .font(.callout.weight(.semibold)) + Text("Preview the menu-bar bloom and, when enabled, a test reminder. No agent or GitHub activity is created.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Button(isRunning ? "Testing…" : "Run Attention Test", action: run) + .disabled(isRunning) + .accessibilityHint("Shows a labelled six-second test without creating work") + } + if let result { + SettingsInlineMessage( + result.message, + systemImage: result.isError + ? "exclamationmark.triangle.fill" + : "checkmark.circle.fill", + tint: result.isError ? .orange : .secondary + ) + } + } + } +} diff --git a/Sources/Meanwhile/ConnectionHealthSection.swift b/Sources/Meanwhile/ConnectionHealthSection.swift index a6f0b0d..868ed9c 100644 --- a/Sources/Meanwhile/ConnectionHealthSection.swift +++ b/Sources/Meanwhile/ConnectionHealthSection.swift @@ -6,12 +6,17 @@ struct ConnectionHealthSection: View { let integrationHealthError: String? let lastAgentEvent: AgentSessionState? let githubAuthenticationStatus: GitHubAuthenticationStatus + let githubLoginCopyMessage: String? let sessionInspection: AgentSessionInspection let sessionRecoveryMessage: String? let sessionRecoveryIsError: Bool let isClearingStuckSessions: Bool + let sourceRefreshSnapshot: SourceRefreshSnapshot let staleAfter: String + let repairIntegrations: () -> Void let clearStuckSessions: () -> Void + let copyGitHubLoginCommand: () -> Void + let refreshGitHubSources: () -> Void let now: Date @State private var isConfirmingClear = false @@ -22,7 +27,10 @@ struct ConnectionHealthSection: View { tint: integrationHealthTint, title: "Agent hooks", value: integrationHealthTitle, - detail: integrationHealthDetail + detail: integrationHealthDetail, + actionTitle: integrationHealth.state == .installed ? nil : "Repair…", + actionAccessibilityLabel: "Repair agent integrations", + action: repairIntegrations ) HealthRow( systemImage: sessionHealthSymbol, @@ -47,7 +55,24 @@ struct ConnectionHealthSection: View { tint: githubHealthTint, title: "GitHub", value: githubHealthTitle, - detail: githubHealthDetail + detail: githubHealthDetail, + actionTitle: githubAuthenticationStatus == .authenticated + ? nil + : "Copy Login Command", + actionAccessibilityLabel: "Copy GitHub login command", + action: copyGitHubLoginCommand + ) + SourceHealthRow( + title: "Review source", + record: sourceRefreshSnapshot.reviews, + now: now, + refresh: refreshGitHubSources + ) + SourceHealthRow( + title: "Failing CI source", + record: sourceRefreshSnapshot.failingCI, + now: now, + refresh: refreshGitHubSources ) } .confirmationDialog( @@ -181,7 +206,8 @@ struct ConnectionHealthSection: View { } private var githubHealthDetail: String { - githubAuthenticationStatus == .authenticated + if let githubLoginCopyMessage { return githubLoginCopyMessage } + return githubAuthenticationStatus == .authenticated ? "Using the GitHub CLI session on this Mac." : "Run gh auth login, then refresh repository sources." } @@ -197,6 +223,64 @@ struct ConnectionHealthSection: View { } } +private struct SourceHealthRow: View { + let title: String + let record: SourceRefreshRecord + let now: Date + let refresh: () -> Void + + var body: some View { + HealthRow( + systemImage: symbol, + tint: tint, + title: title, + value: value, + detail: detail, + actionTitle: record.isEnabled ? (record.lastFailureAt == nil ? "Refresh All" : "Retry All") : nil, + actionAccessibilityLabel: "\(record.lastFailureAt == nil ? "Refresh" : "Retry") all GitHub sources", + action: refresh, + actionDisabled: record.isRefreshing + ) + } + + private var value: String { + guard record.isEnabled else { return "Disabled" } + if record.isRefreshing { return "Refreshing…" } + if let failure = record.lastFailureAt { + guard let success = record.lastSuccessAt else { return "Refresh failed" } + if failure > success { return "Refresh failed" } + } + if let success = record.lastSuccessAt { + return "Updated \(relativeDateString(success, relativeTo: now))" + } + return "Not checked yet" + } + + private var detail: String { + guard record.isEnabled else { return "Disabled in Attention sources." } + if record.isRefreshing { return "Checking GitHub now." } + if record.lastFailureAt != nil { return "Check GitHub CLI authentication, then retry." } + if record.lastSuccessAt != nil { + return "Automatic checks pause while agents are idle. Refresh works at any time." + } + return "Run a manual refresh, or start an agent session to enable automatic checks." + } + + private var symbol: String { + if !record.isEnabled { return "minus.circle" } + if record.isRefreshing { return "arrow.triangle.2.circlepath" } + if record.lastFailureAt != nil { return "exclamationmark.triangle.fill" } + if record.lastSuccessAt != nil { return "checkmark.circle.fill" } + return "clock.badge.questionmark" + } + + private var tint: Color { + if record.lastFailureAt != nil { return .orange } + if record.lastSuccessAt != nil { return .green } + return .secondary + } +} + private struct HealthRow: View { let systemImage: String let tint: Color diff --git a/Sources/Meanwhile/MeanwhileApp.swift b/Sources/Meanwhile/MeanwhileApp.swift index 8541b6f..7672c7a 100644 --- a/Sources/Meanwhile/MeanwhileApp.swift +++ b/Sources/Meanwhile/MeanwhileApp.swift @@ -19,6 +19,12 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { private let recentSignalStore = RecentSignalStore() private let launchAtLoginController = LaunchAtLoginController() private let needsYouNotificationPreferences = NeedsYouNotificationPreferences() + private lazy var attentionSourcePreferences = AttentionSourcePreferences( + defaultSelection: AttentionSourceSelection( + reviewsEnabled: configuration.enableReviews, + failingCIEnabled: configuration.enableFailingCI + ) + ) private lazy var integrationInstaller = AgentIntegrationInstaller(helperURL: helperURL()) private lazy var hotKeyPreferences = HotKeyPreferences(defaultHotKey: configuration.hotKey) private lazy var needsYouNotificationController = NeedsYouNotificationController() @@ -35,6 +41,8 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { private var needsYouNotificationState = NeedsYouNotificationState() private var bloomExpirationWorkItem: DispatchWorkItem? private var pendingBloomSettlement = false + private var attentionTestState = AttentionTestState() + private var attentionTestExpirationWorkItem: DispatchWorkItem? private lazy var agentFocusRouter = AgentFocusRouter( focusTerminal: { [terminalFocuser] session in terminalFocuser.focus(session) @@ -47,6 +55,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { eventStore: eventStore, recentSignalStore: recentSignalStore, notificationPreferences: needsYouNotificationPreferences, + attentionSourcePreferences: attentionSourcePreferences, notificationController: needsYouNotificationController, sessionStaleAfter: configuration.sessionStaleSeconds, appVersion: Bundle.main.object( @@ -92,6 +101,22 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { }, notificationSettingsDidChange: { [weak self] in self?.reconcileNeedsYouNotification() + }, + runAttentionTest: { [weak self] completion in + self?.runAttentionTest(completion: completion) + }, + sourceRefreshSnapshot: { [weak self] in + self?.runtime?.sourceRefreshSnapshot + ?? SourceRefreshSnapshot( + reviewsEnabled: self?.attentionSourcePreferences.selection.reviewsEnabled ?? true, + failingCIEnabled: self?.attentionSourcePreferences.selection.failingCIEnabled ?? true + ) + }, + refreshSources: { [weak self] completion in + self?.runtime?.refreshSources(completion: completion) + }, + sourceSelectionDidChange: { [weak self] selection in + self?.runtime?.sourceSelectionDidChange(selection) } ) @@ -104,6 +129,9 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { needsYouNotificationController.onResponse = { [weak self] identifier in self?.handleNeedsYouNotificationResponse(identifier: identifier) } + needsYouNotificationController.onTestResponse = { [weak self] in + self?.openSettings() + } needsYouNotificationController.start() if !needsYouNotificationPreferences.settings.isEnabled { needsYouNotificationController.cancelAllManaged() @@ -140,7 +168,8 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { eventStore: eventStore, reviewSource: reviewSource, ciSource: ciSource, - configuration: configuration + configuration: configuration, + sourceSelection: attentionSourcePreferences.selection ) { [weak self] presentation in Task { @MainActor [weak self] in self?.present(presentation) } } @@ -155,6 +184,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { bloomExpirationWorkItem?.cancel() + attentionTestExpirationWorkItem?.cancel() runtime?.stop() } @@ -167,6 +197,12 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { currentItem = presentation.item recordPresentationIfNeeded(presentation.item) reconcileNeedsYouNotification(presentation) + if attentionTestState.observeRealAttention(isActive: presentation.item != nil) == .preempted { + attentionTestExpirationWorkItem?.cancel() + attentionTestExpirationWorkItem = nil + settingsModel.attentionTestDidEnd() + } + if attentionTestState.isActive { return } let transition = bloomState.observe( phase: presentation.phase, item: presentation.item @@ -343,6 +379,10 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { } @objc private func openCurrentItem() { + if attentionTestState.isActive { + openSettings() + return + } guard let item = currentItem else { return } settleBloomBeforeAction() cancelNeedsYouNotification(for: item) @@ -355,7 +395,24 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { showFocusFailure(for: item) } } else if let url = MenuBarPresenter.destinationURL(item: item) { - NSWorkspace.shared.open(url) + if !NSWorkspace.shared.open(url) { + showLinkFailure(for: item, url: url) + } + } + } + + private func showLinkFailure(for _: WorkItem, url: URL) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Couldn’t open this GitHub item" + alert.informativeText = "The interruption is still active. Copy the link and open it in a browser when you’re ready." + alert.addButton(withTitle: "Copy Link") + alert.addButton(withTitle: "OK") + NSApp.activate(ignoringOtherApps: true) + if alert.runModal() == .alertFirstButtonReturn { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + _ = pasteboard.setString(url.absoluteString, forType: .string) } } @@ -375,9 +432,12 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { The interruption is still active. """ } + alert.addButton(withTitle: "Open Settings") alert.addButton(withTitle: "OK") NSApp.activate(ignoringOtherApps: true) - alert.runModal() + if alert.runModal() == .alertFirstButtonReturn { + openSettings() + } } private func registerHotKeyFromPreferences() { @@ -600,6 +660,74 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func quitMeanwhile() { NSApplication.shared.terminate(nil) } + + private func runAttentionTest( + completion: @escaping (AttentionTestRunResult) -> Void + ) { + let realAttentionIsActive = currentItem != nil + guard case .started(let generation) = attentionTestState.start( + realAttentionIsActive: realAttentionIsActive + ) else { + completion(.blockedByRealAttention) + return + } + + attentionTestExpirationWorkItem?.cancel() + renderAttentionTest() + menuBar?.announce("Meanwhile attention test") + + let notificationsAvailable = needsYouNotificationPreferences.settings.isEnabled + && needsYouNotificationController.permission == .authorized + if notificationsAvailable { + needsYouNotificationController.deliverTest { outcome in + switch outcome { + case .delivered: completion(.startedWithNotification) + case .cancelled: completion(.startedMenuBarOnly) + case .failed: completion(.failedNotification) + } + } + } else { + completion(.startedMenuBarOnly) + } + + let workItem = DispatchWorkItem { [weak self] in + Task { @MainActor [weak self] in + guard let self, + self.attentionTestState.finish(generation: generation) == .finished else { + return + } + self.attentionTestExpirationWorkItem = nil + self.settingsModel.attentionTestDidEnd() + if let latestPresentation = self.latestPresentation { + self.render(latestPresentation, isBlooming: self.bloomState.isActive) + } + } + } + attentionTestExpirationWorkItem = workItem + DispatchQueue.main.asyncAfter( + deadline: .now() + Self.statusItemBloomDuration, + execute: workItem + ) + } + + private func renderAttentionTest() { + menuBar?.setTitle("Test needs attention") + menuBar?.statusItem.length = NSStatusItem.variableLength + menuBar?.setIcon( + systemName: MenuBarPresenter.iconName(phase: .needsYou), + accessibilityDescription: nil, + tintColor: .systemRed + ) + menuBar?.setAccessibility( + label: "Meanwhile attention test", + help: "Click to return to Settings. This is not a real task." + ) + menuBar?.statusItem.button?.toolTip = "Meanwhile attention test — click to return to Settings" + openItemMenuItem?.title = "Return to Settings" + openItemMenuItem?.isEnabled = true + snoozeMenuItem?.isEnabled = false + hideMenuItem?.isEnabled = false + } } @main diff --git a/Sources/Meanwhile/NeedsYouNotificationController.swift b/Sources/Meanwhile/NeedsYouNotificationController.swift index 583cb6c..3ac9952 100644 --- a/Sources/Meanwhile/NeedsYouNotificationController.swift +++ b/Sources/Meanwhile/NeedsYouNotificationController.swift @@ -12,9 +12,11 @@ enum NeedsYouNotificationDeliveryOutcome: Equatable { @MainActor final class NeedsYouNotificationController: NSObject, UNUserNotificationCenterDelegate { nonisolated static let identifierPrefix = "Meanwhile.needs-you." + nonisolated static let testIdentifier = "Meanwhile.attention-test" var onPermissionChange: ((NeedsYouNotificationPermission) -> Void)? var onResponse: ((String) -> Void)? + var onTestResponse: (() -> Void)? private(set) var permission: NeedsYouNotificationPermission = .unknown { didSet { @@ -105,6 +107,35 @@ final class NeedsYouNotificationController: NSObject, UNUserNotificationCenterDe } } + func deliverTest( + completion: @escaping (NeedsYouNotificationDeliveryOutcome) -> Void + ) { + guard permission == .authorized else { + completion(.cancelled) + return + } + center.removePendingNotificationRequests(withIdentifiers: [Self.testIdentifier]) + center.removeDeliveredNotifications(withIdentifiers: [Self.testIdentifier]) + let content = UNMutableNotificationContent() + content.title = "Meanwhile attention test" + content.body = "This is a test. Click to return to Meanwhile Settings." + let request = UNNotificationRequest( + identifier: Self.testIdentifier, + content: content, + trigger: nil + ) + center.add(request) { [weak self] error in + Task { @MainActor [weak self] in + if error == nil { + completion(.delivered) + } else { + self?.refreshPermission() + completion(.failed) + } + } + } + } + func cancel(itemID: String) { let identifier = Self.identifier(for: itemID) desiredRequests.removeValue(forKey: identifier) @@ -258,7 +289,8 @@ final class NeedsYouNotificationController: NSObject, UNUserNotificationCenterDe willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { - guard notification.request.identifier.hasPrefix(Self.identifierPrefix) else { + guard notification.request.identifier.hasPrefix(Self.identifierPrefix) + || notification.request.identifier == Self.testIdentifier else { completionHandler([]) return } @@ -271,6 +303,13 @@ final class NeedsYouNotificationController: NSObject, UNUserNotificationCenterDe withCompletionHandler completionHandler: @escaping () -> Void ) { let identifier = response.notification.request.identifier + if identifier == Self.testIdentifier, + response.actionIdentifier == UNNotificationDefaultActionIdentifier { + center.removeDeliveredNotifications(withIdentifiers: [identifier]) + Task { @MainActor [weak self] in self?.onTestResponse?() } + completionHandler() + return + } guard identifier.hasPrefix(Self.identifierPrefix), response.actionIdentifier == UNNotificationDefaultActionIdentifier else { completionHandler() diff --git a/Sources/Meanwhile/RepositorySettingsModel.swift b/Sources/Meanwhile/RepositorySettingsModel.swift index 031ca2a..559176c 100644 --- a/Sources/Meanwhile/RepositorySettingsModel.swift +++ b/Sources/Meanwhile/RepositorySettingsModel.swift @@ -37,6 +37,11 @@ final class RepositorySettingsModel: ObservableObject { @Published private(set) var needsYouNotificationSettings: NeedsYouNotificationSettings @Published private(set) var needsYouNotificationPermission: NeedsYouNotificationPermission @Published private(set) var isRequestingNotificationPermission = false + @Published private(set) var attentionTestIsRunning = false + @Published private(set) var attentionTestResult: AttentionTestRunResult? + @Published private(set) var sourceRefreshSnapshot: SourceRefreshSnapshot + @Published private(set) var githubLoginCopyMessage: String? + @Published private(set) var attentionSourceSelection: AttentionSourceSelection let appVersion: String let buildVersion: String @@ -92,6 +97,7 @@ final class RepositorySettingsModel: ObservableObject { private let eventStore: AgentEventStore private let recentSignalStore: RecentSignalStore private let notificationPreferences: NeedsYouNotificationPreferences + private let attentionSourcePreferences: AttentionSourcePreferences private let notificationController: NeedsYouNotificationController private let releaseUpdateChecker: ReleaseUpdateChecker private let sessionStaleAfter: TimeInterval @@ -105,6 +111,10 @@ final class RepositorySettingsModel: ObservableObject { private let hotKeyDidChange: (HotKeyConfiguration?) -> Void private let integrationDidInstall: (AgentIntegrationInstallResult) -> Void private let notificationSettingsDidChange: () -> Void + private let runAttentionTestAction: (@escaping (AttentionTestRunResult) -> Void) -> Void + private let sourceRefreshSnapshotProvider: () -> SourceRefreshSnapshot + private let refreshSourcesAction: (@escaping @Sendable (SourceRefreshSnapshot) -> Void) -> Void + private let sourceSelectionDidChange: (AttentionSourceSelection) -> Void private var hasLoaded = false private var diagnosticsFeedbackID = UUID() @@ -117,6 +127,7 @@ final class RepositorySettingsModel: ObservableObject { eventStore: AgentEventStore, recentSignalStore: RecentSignalStore, notificationPreferences: NeedsYouNotificationPreferences, + attentionSourcePreferences: AttentionSourcePreferences, notificationController: NeedsYouNotificationController, releaseUpdateChecker: ReleaseUpdateChecker = ReleaseUpdateChecker(), sessionStaleAfter: TimeInterval, @@ -131,7 +142,11 @@ final class RepositorySettingsModel: ObservableObject { selectionDidChange: @escaping () -> Void, hotKeyDidChange: @escaping (HotKeyConfiguration?) -> Void, integrationDidInstall: @escaping (AgentIntegrationInstallResult) -> Void, - notificationSettingsDidChange: @escaping () -> Void + notificationSettingsDidChange: @escaping () -> Void, + runAttentionTest: @escaping (@escaping (AttentionTestRunResult) -> Void) -> Void, + sourceRefreshSnapshot: @escaping () -> SourceRefreshSnapshot, + refreshSources: @escaping (@escaping @Sendable (SourceRefreshSnapshot) -> Void) -> Void, + sourceSelectionDidChange: @escaping (AttentionSourceSelection) -> Void ) { self.preferences = preferences self.hotKeyPreferences = hotKeyPreferences @@ -141,6 +156,7 @@ final class RepositorySettingsModel: ObservableObject { self.eventStore = eventStore self.recentSignalStore = recentSignalStore self.notificationPreferences = notificationPreferences + self.attentionSourcePreferences = attentionSourcePreferences self.notificationController = notificationController self.releaseUpdateChecker = releaseUpdateChecker self.sessionStaleAfter = sessionStaleAfter @@ -156,6 +172,10 @@ final class RepositorySettingsModel: ObservableObject { self.hotKeyDidChange = hotKeyDidChange self.integrationDidInstall = integrationDidInstall self.notificationSettingsDidChange = notificationSettingsDidChange + runAttentionTestAction = runAttentionTest + sourceRefreshSnapshotProvider = sourceRefreshSnapshot + refreshSourcesAction = refreshSources + self.sourceSelectionDidChange = sourceSelectionDidChange let snapshot = preferences.snapshot includesAllRepositories = snapshot.includesAllRepositories selectedRepositories = snapshot.selectedRepositories @@ -163,6 +183,8 @@ final class RepositorySettingsModel: ObservableObject { self.launchAtLoginStatus = launchAtLoginStatus() needsYouNotificationSettings = notificationPreferences.settings needsYouNotificationPermission = notificationController.permission + self.sourceRefreshSnapshot = sourceRefreshSnapshot() + attentionSourceSelection = attentionSourcePreferences.selection } func loadRepositories(force: Bool = false) { @@ -231,11 +253,78 @@ final class RepositorySettingsModel: ObservableObject { } self.sessionInspection = sessionInspection launchAtLoginStatus = launchAtLoginStatusProvider() + sourceRefreshSnapshot = sourceRefreshSnapshotProvider() isCheckingHealth = false } } } + func runAttentionTest() { + guard !attentionTestIsRunning else { return } + attentionTestIsRunning = true + attentionTestResult = nil + runAttentionTestAction { [weak self] result in + Task { @MainActor [weak self] in + self?.attentionTestResult = result + if result == .blockedByRealAttention { + self?.attentionTestIsRunning = false + } + } + } + Task { [weak self] in + try? await Task.sleep(nanoseconds: 6_500_000_000) + self?.attentionTestIsRunning = false + } + } + + func attentionTestDidEnd() { + attentionTestIsRunning = false + } + + func refreshGitHubSources() { + let now = Date() + sourceRefreshSnapshot = sourceRefreshSnapshotProvider() + sourceRefreshSnapshot.reviews.begin(at: now) + sourceRefreshSnapshot.failingCI.begin(at: now) + refreshSourcesAction { [weak self] snapshot in + Task { @MainActor [weak self] in + self?.sourceRefreshSnapshot = snapshot + self?.refreshStatus() + } + } + } + + func setAttentionSourceSelection(_ selection: AttentionSourceSelection) { + if selection.reviewsEnabled != attentionSourceSelection.reviewsEnabled { + attentionSourcePreferences.setReviewsEnabled(selection.reviewsEnabled) + } + if selection.failingCIEnabled != attentionSourceSelection.failingCIEnabled { + attentionSourcePreferences.setFailingCIEnabled(selection.failingCIEnabled) + } + attentionSourceSelection = attentionSourcePreferences.selection + sourceSelectionDidChange(attentionSourceSelection) + sourceRefreshSnapshot.reviews.isEnabled = attentionSourceSelection.reviewsEnabled + sourceRefreshSnapshot.failingCI.isEnabled = attentionSourceSelection.failingCIEnabled + if !attentionSourceSelection.reviewsEnabled { + sourceRefreshSnapshot.reviews.isRefreshing = false + } + if !attentionSourceSelection.failingCIEnabled { + sourceRefreshSnapshot.failingCI.isRefreshing = false + } + } + + func copyGitHubLoginCommand() { + githubLoginCopyMessage = copyText("gh auth login") + ? "Copied `gh auth login` to the clipboard." + : "Meanwhile could not copy the login command." + let message = githubLoginCopyMessage + Task { [weak self] in + try? await Task.sleep(nanoseconds: 3_000_000_000) + guard self?.githubLoginCopyMessage == message else { return } + self?.githubLoginCopyMessage = nil + } + } + func checkForUpdates() { guard updateState != .checking else { return } updateState = .checking @@ -371,6 +460,7 @@ final class RepositorySettingsModel: ObservableObject { repositoryScopeIncludesAll: includesAllRepositories, accessibleRepositoryCount: availableRepositories.count, selectedRepositoryCount: selectedRepositories.count, + attentionSourceSelection: attentionSourceSelection, hotKeyConfigured: hotKey != nil, sessionInspection: sessionInspection, lastAgentEvent: lastAgentEvent, diff --git a/Sources/Meanwhile/RepositorySettingsView.swift b/Sources/Meanwhile/RepositorySettingsView.swift index 731690a..4722715 100644 --- a/Sources/Meanwhile/RepositorySettingsView.swift +++ b/Sources/Meanwhile/RepositorySettingsView.swift @@ -7,6 +7,8 @@ struct RepositorySettingsView: View { @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.attentionTest.expanded") private var isAttentionTestExpanded = true + @AppStorage("settings.section.attentionSources.expanded") private var isAttentionSourcesExpanded = true @AppStorage("settings.section.repositorySources.expanded") private var isRepositorySourcesExpanded = true @AppStorage("settings.section.recentSignals.expanded") private var isRecentSignalsExpanded = true @State private var searchText = "" @@ -82,6 +84,32 @@ struct RepositorySettingsView: View { ) } Divider() + CollapsibleSettingsSection( + title: "Attention test", + trailing: model.attentionTestIsRunning ? "Testing" : nil, + isExpanded: $isAttentionTestExpanded, + showsProgress: model.attentionTestIsRunning + ) { + AttentionTestSettingsSection( + isRunning: model.attentionTestIsRunning, + result: model.attentionTestResult, + run: model.runAttentionTest + ) + } + Divider() + CollapsibleSettingsSection( + title: "Attention sources", + trailing: sourceSelectionTrailing, + isExpanded: $isAttentionSourcesExpanded + ) { + AttentionSourcesSettingsSection( + selection: Binding( + get: { model.attentionSourceSelection }, + set: { model.setAttentionSourceSelection($0) } + ) + ) + } + Divider() ControlsSettingsSection( hotKey: Binding( get: { model.hotKey }, @@ -161,12 +189,17 @@ struct RepositorySettingsView: View { integrationHealthError: model.integrationHealthError, lastAgentEvent: model.lastAgentEvent, githubAuthenticationStatus: model.githubAuthenticationStatus, + githubLoginCopyMessage: model.githubLoginCopyMessage, sessionInspection: model.sessionInspection, sessionRecoveryMessage: model.sessionRecoveryMessage, sessionRecoveryIsError: model.sessionRecoveryIsError, isClearingStuckSessions: model.isClearingStuckSessions, + sourceRefreshSnapshot: model.sourceRefreshSnapshot, staleAfter: model.sessionStaleAfterDescription, + repairIntegrations: model.installIntegrations, clearStuckSessions: model.clearStuckSessions, + copyGitHubLoginCommand: model.copyGitHubLoginCommand, + refreshGitHubSources: model.refreshGitHubSources, now: now ) } @@ -218,6 +251,16 @@ struct RepositorySettingsView: View { } } } + + private var sourceSelectionTrailing: String { + let githubSourceCount = [ + model.attentionSourceSelection.reviewsEnabled, + model.attentionSourceSelection.failingCIEnabled + ].filter { $0 }.count + return githubSourceCount == 0 + ? "Agents only" + : "\(githubSourceCount + 1) enabled" + } } private extension NeedsYouNotificationDelay { diff --git a/Sources/MeanwhileCore/AttentionSourcePreferences.swift b/Sources/MeanwhileCore/AttentionSourcePreferences.swift new file mode 100644 index 0000000..e6773fc --- /dev/null +++ b/Sources/MeanwhileCore/AttentionSourcePreferences.swift @@ -0,0 +1,66 @@ +import Foundation + +public struct AttentionSourceSelection: Equatable, Sendable { + public var reviewsEnabled: Bool + public var failingCIEnabled: Bool + + public init(reviewsEnabled: Bool = true, failingCIEnabled: Bool = true) { + self.reviewsEnabled = reviewsEnabled + self.failingCIEnabled = failingCIEnabled + } + + public func isEnabled(_ source: GitHubSourceKind) -> Bool { + switch source { + case .reviews: return reviewsEnabled + case .failingCI: return failingCIEnabled + } + } +} + +public final class AttentionSourcePreferences: @unchecked Sendable { + private enum Key { + static let reviews = "Meanwhile.sources.reviews.enabled" + static let failingCI = "Meanwhile.sources.failingCI.enabled" + } + + private let lock = NSLock() + private let defaults: UserDefaults + private var state: AttentionSourceSelection + + public init( + defaults: UserDefaults = .standard, + defaultSelection: AttentionSourceSelection = AttentionSourceSelection() + ) { + self.defaults = defaults + state = AttentionSourceSelection( + reviewsEnabled: defaults.object(forKey: Key.reviews) == nil + ? defaultSelection.reviewsEnabled + : defaults.bool(forKey: Key.reviews), + failingCIEnabled: defaults.object(forKey: Key.failingCI) == nil + ? defaultSelection.failingCIEnabled + : defaults.bool(forKey: Key.failingCI) + ) + } + + public var selection: AttentionSourceSelection { + lock.lock() + defer { lock.unlock() } + return state + } + + public func setReviewsEnabled(_ enabled: Bool) { + update { $0.reviewsEnabled = enabled } + } + + public func setFailingCIEnabled(_ enabled: Bool) { + update { $0.failingCIEnabled = enabled } + } + + private func update(_ body: (inout AttentionSourceSelection) -> Void) { + lock.lock() + defer { lock.unlock() } + body(&state) + defaults.set(state.reviewsEnabled, forKey: Key.reviews) + defaults.set(state.failingCIEnabled, forKey: Key.failingCI) + } +} diff --git a/Sources/MeanwhileCore/AttentionTestState.swift b/Sources/MeanwhileCore/AttentionTestState.swift new file mode 100644 index 0000000..bb894ea --- /dev/null +++ b/Sources/MeanwhileCore/AttentionTestState.swift @@ -0,0 +1,40 @@ +import Foundation + +public struct AttentionTestState: Equatable, Sendable { + public enum Transition: Equatable, Sendable { + case none + case started(generation: UInt64) + case finished + case preempted + } + + public private(set) var generation: UInt64 = 0 + public private(set) var isActive = false + + public init() {} + + public mutating func start(realAttentionIsActive: Bool) -> Transition { + guard !realAttentionIsActive, !isActive else { return .none } + generation &+= 1 + isActive = true + return .started(generation: generation) + } + + public mutating func observeRealAttention(isActive: Bool) -> Transition { + guard isActive, self.isActive else { return .none } + self.isActive = false + return .preempted + } + + public mutating func finish(generation: UInt64) -> Transition { + guard isActive, generation == self.generation else { return .none } + isActive = false + return .finished + } + + public mutating func cancel() -> Transition { + guard isActive else { return .none } + isActive = false + return .finished + } +} diff --git a/Sources/MeanwhileCore/DiagnosticsReport.swift b/Sources/MeanwhileCore/DiagnosticsReport.swift index 7322fb5..fd2576a 100644 --- a/Sources/MeanwhileCore/DiagnosticsReport.swift +++ b/Sources/MeanwhileCore/DiagnosticsReport.swift @@ -18,6 +18,7 @@ public struct DiagnosticsSnapshot: Sendable { public var repositoryScopeIncludesAll: Bool public var accessibleRepositoryCount: Int public var selectedRepositoryCount: Int + public var attentionSourceSelection: AttentionSourceSelection public var hotKeyConfigured: Bool public var sessionInspection: AgentSessionInspection public var lastAgentEvent: AgentSessionState? @@ -34,6 +35,7 @@ public struct DiagnosticsSnapshot: Sendable { repositoryScopeIncludesAll: Bool, accessibleRepositoryCount: Int, selectedRepositoryCount: Int, + attentionSourceSelection: AttentionSourceSelection, hotKeyConfigured: Bool, sessionInspection: AgentSessionInspection, lastAgentEvent: AgentSessionState?, @@ -49,6 +51,7 @@ public struct DiagnosticsSnapshot: Sendable { self.repositoryScopeIncludesAll = repositoryScopeIncludesAll self.accessibleRepositoryCount = accessibleRepositoryCount self.selectedRepositoryCount = selectedRepositoryCount + self.attentionSourceSelection = attentionSourceSelection self.hotKeyConfigured = hotKeyConfigured self.sessionInspection = sessionInspection self.lastAgentEvent = lastAgentEvent @@ -63,7 +66,7 @@ public enum MeanwhileDiagnosticsReport { ) -> String { var lines = [ "Meanwhile diagnostics", - "Schema: 1", + "Schema: 2", "Generated: \(timestamp(generatedAt))", "App: \(snapshot.appVersion) (\(snapshot.buildVersion))", "macOS: \(snapshot.operatingSystemVersion)", @@ -75,6 +78,9 @@ public enum MeanwhileDiagnosticsReport { "Claude status line conflict: \(yesNo(snapshot.integrationHealth.claudeStatuslineConflict))", "GitHub CLI: \(snapshot.githubAuthenticationStatus == .authenticated ? "authenticated" : "not authenticated")", "Repository scope: \(repositoryScopeDescription(snapshot))", + "Agent requests: enabled", + "Failing CI source: \(enabledDisabled(snapshot.attentionSourceSelection.failingCIEnabled))", + "Review source: \(enabledDisabled(snapshot.attentionSourceSelection.reviewsEnabled))", "Keyboard shortcut: \(snapshot.hotKeyConfigured ? "configured" : "not configured")", "Active agent sessions: \(snapshot.sessionInspection.activeCount)", "Sessions that may be stuck: \(snapshot.sessionInspection.stuckCount)" @@ -110,6 +116,10 @@ public enum MeanwhileDiagnosticsReport { value ? "yes" : "no" } + private static func enabledDisabled(_ value: Bool) -> String { + value ? "enabled" : "disabled" + } + private static func launchAtLoginDescription(_ status: LaunchAtLoginStatus) -> String { switch status { case .disabled: return "off" diff --git a/Sources/MeanwhileCore/GitHubCISource.swift b/Sources/MeanwhileCore/GitHubCISource.swift index 957eaf8..01d21b5 100644 --- a/Sources/MeanwhileCore/GitHubCISource.swift +++ b/Sources/MeanwhileCore/GitHubCISource.swift @@ -32,7 +32,7 @@ public final class GitHubCISource { private let runner: any CommandRunning private let cacheInterval: TimeInterval private let repositoryIsAllowed: @Sendable (String) -> Bool - private var lastAttemptDate: Date? + public private(set) var lastAttemptDate: Date? private var allCachedItems: [FailingCIItem] = [] public var cachedItems: [FailingCIItem] { @@ -49,14 +49,19 @@ public final class GitHubCISource { self.repositoryIsAllowed = repositoryIsAllowed } + public func isDue(now: Date = Date()) -> Bool { + guard let lastAttemptDate else { return true } + return now.timeIntervalSince(lastAttemptDate) >= cacheInterval + } + @discardableResult public func itemsIfDue( pollingAllowed: Bool, + force: Bool = false, now: Date = Date() ) throws -> [FailingCIItem] { guard pollingAllowed else { return cachedItems } - if let lastAttemptDate, - now.timeIntervalSince(lastAttemptDate) < cacheInterval { + if !force, !isDue(now: now) { return cachedItems } diff --git a/Sources/MeanwhileCore/GitHubReviewSource.swift b/Sources/MeanwhileCore/GitHubReviewSource.swift index a63ee94..3e45ee8 100644 --- a/Sources/MeanwhileCore/GitHubReviewSource.swift +++ b/Sources/MeanwhileCore/GitHubReviewSource.swift @@ -32,7 +32,7 @@ public final class GitHubReviewSource { private let runner: any CommandRunning private let cacheInterval: TimeInterval private let repositoryIsAllowed: @Sendable (String) -> Bool - private var lastAttemptDate: Date? + public private(set) var lastAttemptDate: Date? private var allCachedReviews: [ReviewItem] = [] public var cachedReviews: [ReviewItem] { @@ -49,15 +49,20 @@ public final class GitHubReviewSource { self.repositoryIsAllowed = repositoryIsAllowed } + public func isDue(now: Date = Date()) -> Bool { + guard let lastAttemptDate else { return true } + return now.timeIntervalSince(lastAttemptDate) >= cacheInterval + } + /// Returns cached data unless polling is allowed and the 60-second cache is due. @discardableResult public func reviewsIfDue( pollingAllowed: Bool, + force: Bool = false, now: Date = Date() ) throws -> [ReviewItem] { guard pollingAllowed else { return cachedReviews } - if let lastAttemptDate, - now.timeIntervalSince(lastAttemptDate) < cacheInterval { + if !force, !isDue(now: now) { return cachedReviews } diff --git a/Sources/MeanwhileCore/MeanwhileEngine.swift b/Sources/MeanwhileCore/MeanwhileEngine.swift index 8c8d803..2676119 100644 --- a/Sources/MeanwhileCore/MeanwhileEngine.swift +++ b/Sources/MeanwhileCore/MeanwhileEngine.swift @@ -41,8 +41,13 @@ public final class MeanwhileEngine: @unchecked Sendable { sessions: [AgentSessionState], reviews: [ReviewItem], failingCI: [FailingCIItem], + sourceSelection: AttentionSourceSelection? = nil, now: Date = Date() ) -> MeanwhilePresentation { + let sourceSelection = sourceSelection ?? AttentionSourceSelection( + reviewsEnabled: configuration.enableReviews, + failingCIEnabled: configuration.enableFailingCI + ) let activeSessions = sessions.filter { $0.phase != .idle } let needsYou = sessions.filter { $0.phase == .needsYou } let thinking = sessions.filter { $0.phase == .thinking } @@ -58,7 +63,7 @@ public final class MeanwhileEngine: @unchecked Sendable { session: session ) } - let ciItems = configuration.enableFailingCI ? failingCI.map { item in + let ciItems = sourceSelection.failingCIEnabled ? failingCI.map { item in WorkItem( id: "ci:\(item.repository)#\(item.number)", kind: .failingCI, @@ -68,7 +73,7 @@ public final class MeanwhileEngine: @unchecked Sendable { createdAt: item.createdAt ) } : [] - let reviewItems = configuration.enableReviews ? reviews.map { item in + let reviewItems = sourceSelection.reviewsEnabled ? reviews.map { item in WorkItem( id: "review:\(item.repository)#\(item.number)", kind: .review, diff --git a/Sources/MeanwhileCore/MeanwhileRuntime.swift b/Sources/MeanwhileCore/MeanwhileRuntime.swift index d8bc875..1479a4f 100644 --- a/Sources/MeanwhileCore/MeanwhileRuntime.swift +++ b/Sources/MeanwhileCore/MeanwhileRuntime.swift @@ -4,6 +4,7 @@ import Peripheral public final class MeanwhileRuntime: @unchecked Sendable { public typealias PresentationHandler = @Sendable (MeanwhilePresentation) -> Void + public typealias SourceRefreshHandler = @Sendable (SourceRefreshSnapshot) -> Void private let logger = Logger(subsystem: "Meanwhile", category: "Runtime") private let sessionQueue: DispatchQueue @@ -27,7 +28,19 @@ public final class MeanwhileRuntime: @unchecked Sendable { var currentItem: WorkItem? var lastThinkingDate: Date? var sourcePollInFlight = false + var sourceRefresh: SourceRefreshSnapshot + var sourceSelection: AttentionSourceSelection var stopped = false + + init( + sourceSelection: AttentionSourceSelection = AttentionSourceSelection() + ) { + self.sourceSelection = sourceSelection + sourceRefresh = SourceRefreshSnapshot( + reviewsEnabled: sourceSelection.reviewsEnabled, + failingCIEnabled: sourceSelection.failingCIEnabled + ) + } } public init( @@ -35,6 +48,7 @@ public final class MeanwhileRuntime: @unchecked Sendable { reviewSource: GitHubReviewSource = GitHubReviewSource(), ciSource: GitHubCISource = GitHubCISource(), configuration: MeanwhileConfiguration = MeanwhileConfiguration.load(), + sourceSelection: AttentionSourceSelection? = nil, dispositions: ItemDispositionStore = ItemDispositionStore(), statuslineStore: StatuslineSnapshotStore = StatuslineSnapshotStore(), presentationHandler: @escaping PresentationHandler @@ -50,6 +64,12 @@ public final class MeanwhileRuntime: @unchecked Sendable { engine = MeanwhileEngine(configuration: configuration, dispositions: dispositions) self.statuslineStore = statuslineStore self.presentationHandler = presentationHandler + state = State( + sourceSelection: sourceSelection ?? AttentionSourceSelection( + reviewsEnabled: configuration.enableReviews, + failingCIEnabled: configuration.enableFailingCI + ) + ) sessionTimer = PollTimer(interval: 0.5, queue: sessionQueue) sourceTimer = PollTimer(interval: 60, queue: sourceQueue) } @@ -82,6 +102,37 @@ public final class MeanwhileRuntime: @unchecked Sendable { } } + public var sourceRefreshSnapshot: SourceRefreshSnapshot { + withState { $0.sourceRefresh } + } + + public func refreshSources(completion: SourceRefreshHandler? = nil) { + sourceQueue.async { [weak self] in + self?.requestSourcePoll(force: true, completion: completion) + } + } + + public func sourceSelectionDidChange(_ selection: AttentionSourceSelection) { + sourceQueue.async { [weak self] in + guard let self else { return } + let shouldRefresh = withState { state -> Bool in + guard state.sourceSelection != selection else { return false } + let enabledNewSource = (!state.sourceSelection.reviewsEnabled && selection.reviewsEnabled) + || (!state.sourceSelection.failingCIEnabled && selection.failingCIEnabled) + state.sourceSelection = selection + state.sourceRefresh.reviews.isEnabled = selection.reviewsEnabled + state.sourceRefresh.reviews.isRefreshing = false + state.sourceRefresh.failingCI.isEnabled = selection.failingCIEnabled + state.sourceRefresh.failingCI.isRefreshing = false + if !selection.reviewsEnabled { state.reviews = [] } + if !selection.failingCIEnabled { state.failingCI = [] } + return enabledNewSource + } + present() + if shouldRefresh { requestSourcePoll(force: true) } + } + } + public func snoozeCurrent(now: Date = Date()) { guard let item = withState({ $0.currentItem }) else { return } engine.snooze(item, now: now) @@ -113,41 +164,77 @@ public final class MeanwhileRuntime: @unchecked Sendable { if shouldPoll { requestSourcePoll(now: now) } } - private func requestSourcePoll(now: Date = Date()) { + private func requestSourcePoll( + now: Date = Date(), + force: Bool = false, + completion: SourceRefreshHandler? = nil + ) { let shouldStart = withState { state -> Bool in guard !state.stopped, !state.sourcePollInFlight else { return false } state.sourcePollInFlight = true return true } - guard shouldStart else { return } + guard shouldStart else { + if let completion { completion(sourceRefreshSnapshot) } + return + } sourceQueue.async { [weak self] in - self?.pollSources(now: now) + self?.pollSources(now: now, force: force, completion: completion) } } - private func pollSources(now: Date) { - defer { withState { $0.sourcePollInFlight = false } } - let allowed = withState { state -> Bool in - guard !state.stopped, let lastThinkingDate = state.lastThinkingDate else { - return false + private func pollSources( + now: Date, + force: Bool, + completion: SourceRefreshHandler? + ) { + defer { + let snapshot = withState { state -> SourceRefreshSnapshot in + state.sourcePollInFlight = false + return state.sourceRefresh } + completion?(snapshot) + } + let allowed = withState { state -> Bool in + guard !state.stopped else { return false } + if force { return true } + guard let lastThinkingDate = state.lastThinkingDate else { return false } return now.timeIntervalSince(lastThinkingDate) <= 600 } guard allowed else { return } - if configuration.enableReviews { + let selection = withState { $0.sourceSelection } + if selection.isEnabled(.reviews), force || reviewSource.isDue(now: now) { + withState { $0.sourceRefresh[.reviews].begin(at: now) } do { - let reviews = try reviewSource.reviewsIfDue(pollingAllowed: true, now: now) - withState { $0.reviews = reviews } + let reviews = try reviewSource.reviewsIfDue( + pollingAllowed: true, + force: force, + now: now + ) + withState { + $0.reviews = reviews + $0.sourceRefresh[.reviews].succeed(at: Date()) + } } catch { + withState { $0.sourceRefresh[.reviews].fail(at: Date()) } logger.error("Review poll failed: \(error.localizedDescription, privacy: .public)") } } - if configuration.enableFailingCI { + if selection.isEnabled(.failingCI), force || ciSource.isDue(now: now) { + withState { $0.sourceRefresh[.failingCI].begin(at: now) } do { - let items = try ciSource.itemsIfDue(pollingAllowed: true, now: now) - withState { $0.failingCI = items } + let items = try ciSource.itemsIfDue( + pollingAllowed: true, + force: force, + now: now + ) + withState { + $0.failingCI = items + $0.sourceRefresh[.failingCI].succeed(at: Date()) + } } catch { + withState { $0.sourceRefresh[.failingCI].fail(at: Date()) } logger.error("CI poll failed: \(error.localizedDescription, privacy: .public)") } } @@ -155,12 +242,15 @@ public final class MeanwhileRuntime: @unchecked Sendable { } private func present(now: Date = Date()) { - let snapshot = withState { ($0.stopped, $0.sessions, $0.reviews, $0.failingCI) } + let snapshot = withState { + ($0.stopped, $0.sessions, $0.reviews, $0.failingCI, $0.sourceSelection) + } guard !snapshot.0 else { return } let presentation = engine.presentation( sessions: snapshot.1, reviews: snapshot.2, failingCI: snapshot.3, + sourceSelection: snapshot.4, now: now ) withState { $0.currentItem = presentation.item } diff --git a/Sources/MeanwhileCore/SourceRefreshSnapshot.swift b/Sources/MeanwhileCore/SourceRefreshSnapshot.swift new file mode 100644 index 0000000..3f34cc3 --- /dev/null +++ b/Sources/MeanwhileCore/SourceRefreshSnapshot.swift @@ -0,0 +1,76 @@ +import Foundation + +public enum GitHubSourceKind: String, CaseIterable, Sendable { + case reviews + case failingCI +} + +public struct SourceRefreshRecord: Equatable, Sendable { + public var isEnabled: Bool + public var isRefreshing: Bool + public var lastAttemptAt: Date? + public var lastSuccessAt: Date? + public var lastFailureAt: Date? + + public init( + isEnabled: Bool, + isRefreshing: Bool = false, + lastAttemptAt: Date? = nil, + lastSuccessAt: Date? = nil, + lastFailureAt: Date? = nil + ) { + self.isEnabled = isEnabled + self.isRefreshing = isRefreshing + self.lastAttemptAt = lastAttemptAt + self.lastSuccessAt = lastSuccessAt + self.lastFailureAt = lastFailureAt + } + + public mutating func begin(at date: Date) { + guard isEnabled else { return } + isRefreshing = true + lastAttemptAt = date + } + + public mutating func succeed(at date: Date) { + guard isEnabled else { return } + isRefreshing = false + lastSuccessAt = date + lastFailureAt = nil + } + + public mutating func fail(at date: Date) { + guard isEnabled else { return } + isRefreshing = false + lastFailureAt = date + } +} + +public struct SourceRefreshSnapshot: Equatable, Sendable { + public var reviews: SourceRefreshRecord + public var failingCI: SourceRefreshRecord + + public init(reviewsEnabled: Bool, failingCIEnabled: Bool) { + reviews = SourceRefreshRecord(isEnabled: reviewsEnabled) + failingCI = SourceRefreshRecord(isEnabled: failingCIEnabled) + } + + public subscript(_ source: GitHubSourceKind) -> SourceRefreshRecord { + get { + switch source { + case .reviews: return reviews + case .failingCI: return failingCI + } + } + set { + switch source { + case .reviews: reviews = newValue + case .failingCI: failingCI = newValue + } + } + } + + public var isRefreshing: Bool { + reviews.isRefreshing || failingCI.isRefreshing + } +} diff --git a/Tests/MeanwhileCoreTests/AttentionSourcePreferencesTests.swift b/Tests/MeanwhileCoreTests/AttentionSourcePreferencesTests.swift new file mode 100644 index 0000000..d2d42bd --- /dev/null +++ b/Tests/MeanwhileCoreTests/AttentionSourcePreferencesTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import MeanwhileCore + +final class AttentionSourcePreferencesTests: XCTestCase { + func testSeedsFromConfigurationAndPersistsIndependentChoices() { + let suite = "AttentionSourcePreferencesTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let initial = AttentionSourcePreferences( + defaults: defaults, + defaultSelection: AttentionSourceSelection( + reviewsEnabled: false, + failingCIEnabled: true + ) + ) + + XCTAssertEqual( + initial.selection, + AttentionSourceSelection(reviewsEnabled: false, failingCIEnabled: true) + ) + initial.setReviewsEnabled(true) + initial.setFailingCIEnabled(false) + + let reloaded = AttentionSourcePreferences( + defaults: defaults, + defaultSelection: AttentionSourceSelection( + reviewsEnabled: false, + failingCIEnabled: true + ) + ) + XCTAssertEqual( + reloaded.selection, + AttentionSourceSelection(reviewsEnabled: true, failingCIEnabled: false) + ) + XCTAssertTrue(reloaded.selection.isEnabled(.reviews)) + XCTAssertFalse(reloaded.selection.isEnabled(.failingCI)) + } +} diff --git a/Tests/MeanwhileCoreTests/AttentionTestStateTests.swift b/Tests/MeanwhileCoreTests/AttentionTestStateTests.swift new file mode 100644 index 0000000..cd6fc29 --- /dev/null +++ b/Tests/MeanwhileCoreTests/AttentionTestStateTests.swift @@ -0,0 +1,22 @@ +import XCTest +@testable import MeanwhileCore + +final class AttentionTestStateTests: XCTestCase { + func testStartsOnceAndIgnoresStaleExpiry() { + var state = AttentionTestState() + XCTAssertEqual(state.start(realAttentionIsActive: false), .started(generation: 1)) + XCTAssertEqual(state.start(realAttentionIsActive: false), .none) + XCTAssertEqual(state.finish(generation: 0), .none) + XCTAssertTrue(state.isActive) + XCTAssertEqual(state.finish(generation: 1), .finished) + XCTAssertFalse(state.isActive) + } + + func testRealAttentionBlocksAndPreemptsTest() { + var state = AttentionTestState() + XCTAssertEqual(state.start(realAttentionIsActive: true), .none) + XCTAssertEqual(state.start(realAttentionIsActive: false), .started(generation: 1)) + XCTAssertEqual(state.observeRealAttention(isActive: true), .preempted) + XCTAssertFalse(state.isActive) + } +} diff --git a/Tests/MeanwhileCoreTests/DiagnosticsReportTests.swift b/Tests/MeanwhileCoreTests/DiagnosticsReportTests.swift index 456c154..60d79b9 100644 --- a/Tests/MeanwhileCoreTests/DiagnosticsReportTests.swift +++ b/Tests/MeanwhileCoreTests/DiagnosticsReportTests.swift @@ -29,6 +29,10 @@ final class DiagnosticsReportTests: XCTestCase { repositoryScopeIncludesAll: false, accessibleRepositoryCount: 68, selectedRepositoryCount: 3, + attentionSourceSelection: AttentionSourceSelection( + reviewsEnabled: false, + failingCIEnabled: true + ), hotKeyConfigured: true, sessionInspection: AgentSessionInspection( activeCount: 2, @@ -64,10 +68,13 @@ final class DiagnosticsReportTests: XCTestCase { ) XCTAssertTrue(report.contains("App: 0.1.2 (3)")) - XCTAssertTrue(report.contains("Schema: 1")) + XCTAssertTrue(report.contains("Schema: 2")) XCTAssertTrue(report.contains("Launch at login: needs approval")) XCTAssertTrue(report.contains("Update: available (v0.1.3)")) XCTAssertTrue(report.contains("Repository scope: selected only (3)")) + XCTAssertTrue(report.contains("Agent requests: enabled")) + XCTAssertTrue(report.contains("Failing CI source: enabled")) + XCTAssertTrue(report.contains("Review source: disabled")) XCTAssertTrue(report.contains("Sessions that may be stuck: 1")) XCTAssertTrue(report.contains("Last agent event: codex, needs-you")) XCTAssertTrue(report.contains("Recent ciFailed: 1")) diff --git a/Tests/MeanwhileCoreTests/GitHubCISourceTests.swift b/Tests/MeanwhileCoreTests/GitHubCISourceTests.swift index 723446d..fc5a4ab 100644 --- a/Tests/MeanwhileCoreTests/GitHubCISourceTests.swift +++ b/Tests/MeanwhileCoreTests/GitHubCISourceTests.swift @@ -45,6 +45,24 @@ final class GitHubCISourceTests: XCTestCase { XCTAssertEqual(runner.commands.count, 2) } + func testForceBypassesCache() throws { + let runner = CICommandRunner(result: ShellCommandResult( + exitCode: 0, + stdout: Self.fixture, + stderr: "" + )) + let source = GitHubCISource(runner: runner, cacheInterval: 60) + let start = Date(timeIntervalSince1970: 2_000) + + _ = try source.itemsIfDue(pollingAllowed: true, now: start) + _ = try source.itemsIfDue( + pollingAllowed: true, + force: true, + now: start.addingTimeInterval(1) + ) + XCTAssertEqual(runner.commands.count, 2) + } + private static let fixture = """ { "data": {"viewer": {"pullRequests": {"nodes": [ diff --git a/Tests/MeanwhileCoreTests/GitHubReviewSourceTests.swift b/Tests/MeanwhileCoreTests/GitHubReviewSourceTests.swift index 29610cd..d207097 100644 --- a/Tests/MeanwhileCoreTests/GitHubReviewSourceTests.swift +++ b/Tests/MeanwhileCoreTests/GitHubReviewSourceTests.swift @@ -47,6 +47,25 @@ final class GitHubReviewSourceTests: XCTestCase { ) } + func testForceBypassesCacheWithoutBypassingPollingGate() throws { + let runner = RecordingCommandRunner(results: [successfulResult, successfulResult]) + let source = GitHubReviewSource(runner: runner) + let start = Date(timeIntervalSince1970: 1_000) + + _ = try source.reviewsIfDue(pollingAllowed: true, now: start) + _ = try source.reviewsIfDue( + pollingAllowed: true, + force: true, + now: start.addingTimeInterval(1) + ) + _ = try source.reviewsIfDue( + pollingAllowed: false, + force: true, + now: start.addingTimeInterval(2) + ) + XCTAssertEqual(runner.commands.count, 2) + } + func testFiltersCachedReviewsUsingRepositoryPreferences() throws { let preferences = RepositoryPreferences( defaults: UserDefaults(suiteName: UUID().uuidString)! diff --git a/Tests/MeanwhileCoreTests/MeanwhileEngineTests.swift b/Tests/MeanwhileCoreTests/MeanwhileEngineTests.swift index b8ca50e..ab01926 100644 --- a/Tests/MeanwhileCoreTests/MeanwhileEngineTests.swift +++ b/Tests/MeanwhileCoreTests/MeanwhileEngineTests.swift @@ -66,6 +66,51 @@ final class MeanwhileEngineTests: XCTestCase { XCTAssertEqual(presentation.item?.id, "ci:acme/repo#2") } + func testLiveSourceSelectionFiltersGitHubWorkWithoutDisablingAgents() { + let context = makeEngine() + defer { context.cleanup() } + let now = Date(timeIntervalSince1970: 4_500) + let sessions = [makeSession(phase: .thinking, enteredAt: now.addingTimeInterval(-30))] + let review = makeReview(number: 1, createdAt: now.addingTimeInterval(-300)) + let ci = makeCI(number: 2, createdAt: now.addingTimeInterval(-200)) + + let reviewsOnly = context.engine.presentation( + sessions: sessions, + reviews: [review], + failingCI: [ci], + sourceSelection: AttentionSourceSelection( + reviewsEnabled: true, + failingCIEnabled: false + ), + now: now + ) + XCTAssertEqual(reviewsOnly.item?.kind, .review) + + let agentsOnly = context.engine.presentation( + sessions: sessions, + reviews: [review], + failingCI: [ci], + sourceSelection: AttentionSourceSelection( + reviewsEnabled: false, + failingCIEnabled: false + ), + now: now + ) + XCTAssertNil(agentsOnly.item) + + let needsYou = context.engine.presentation( + sessions: [makeSession(phase: .needsYou, enteredAt: now)], + reviews: [review], + failingCI: [ci], + sourceSelection: AttentionSourceSelection( + reviewsEnabled: false, + failingCIEnabled: false + ), + now: now + ) + XCTAssertEqual(needsYou.item?.kind, .needsYou) + } + func testSnoozeMovesToNextDeterministicItem() { let context = makeEngine() defer { context.cleanup() } diff --git a/Tests/MeanwhileCoreTests/SourceRefreshSnapshotTests.swift b/Tests/MeanwhileCoreTests/SourceRefreshSnapshotTests.swift new file mode 100644 index 0000000..67fd3c9 --- /dev/null +++ b/Tests/MeanwhileCoreTests/SourceRefreshSnapshotTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import MeanwhileCore + +final class SourceRefreshSnapshotTests: XCTestCase { + func testSuccessAndFailureTransitionsRemainSourceSpecific() { + let start = Date(timeIntervalSince1970: 100) + let finish = Date(timeIntervalSince1970: 101) + var snapshot = SourceRefreshSnapshot(reviewsEnabled: true, failingCIEnabled: true) + + snapshot[.reviews].begin(at: start) + XCTAssertTrue(snapshot.isRefreshing) + snapshot[.reviews].succeed(at: finish) + XCTAssertEqual(snapshot.reviews.lastSuccessAt, finish) + XCTAssertNil(snapshot.reviews.lastFailureAt) + + snapshot[.failingCI].begin(at: start) + snapshot[.failingCI].fail(at: finish) + XCTAssertEqual(snapshot.failingCI.lastFailureAt, finish) + XCTAssertNil(snapshot.failingCI.lastSuccessAt) + } + + func testDisabledSourceIgnoresRefreshTransitions() { + var snapshot = SourceRefreshSnapshot(reviewsEnabled: false, failingCIEnabled: true) + snapshot[.reviews].begin(at: Date()) + XCTAssertFalse(snapshot.reviews.isRefreshing) + XCTAssertNil(snapshot.reviews.lastAttemptAt) + } +} diff --git a/docs/v0.1.3-prove-the-loop.md b/docs/v0.1.3-prove-the-loop.md new file mode 100644 index 0000000..f9718d7 --- /dev/null +++ b/docs/v0.1.3-prove-the-loop.md @@ -0,0 +1,78 @@ +# Meanwhile v0.1.3 — Prove the Loop + +v0.1.3 makes Meanwhile’s existing attention loop observable, testable, and +recoverable. It does not broaden the product into a queue, session browser, or +general-purpose notification system. + +## User contract + +### Test the attention loop + +- Settings includes a **Run Attention Test** action. +- The test shows a clearly labelled red menu-bar interruption for six seconds. +- If reminders are enabled and macOS permission is available, it also sends a + clearly labelled test notification. +- Clicking either test surface returns to Settings. +- The test never creates an agent session, GitHub item, recent signal, or + notification receipt. +- Real work always pre-empts the test, and a test cannot replace real work. + +### See and recover source health + +- Connection health reports review and failing-CI freshness independently. +- Each source distinguishes disabled, never checked, refreshing, successful, + and failed states. +- A manual refresh bypasses the idle polling gate and the sixty-second cache. +- Manual refresh does not surface GitHub work while agents are idle; the + existing wait gate remains authoritative. +- Failed refreshes expose a Retry action and GitHub authentication recovery. + +### Choose what can interrupt you + +- Settings keeps agent requests permanently enabled as Meanwhile’s core source. +- Review requests and failing CI can be enabled independently without relaunching. +- Source choices persist across launches and upgrades. +- Disabling a GitHub source immediately removes its work, stops future polling, + and marks it Disabled in Connection health. +- Re-enabling a GitHub source refreshes it immediately, while the waiting-first + presentation gate remains unchanged. + +### Recover without losing the interruption + +- Missing agent integrations can be repaired in Connection health. +- Missing GitHub authentication provides a copyable `gh auth login` command. +- A failed browser handoff keeps the item active and offers Copy Link. +- A failed agent handoff keeps the item active and offers Open Settings. + +### Keyboard and accessibility + +- New controls use native buttons and disclosure groups with explicit labels + and hints. +- The test status item has a distinct accessible label and help text. +- Test-only snooze and hide actions are disabled because no real item exists. + +## Acceptance checks + +- Core state tests prove stale timers cannot end replacement tests and real work + pre-empts a test. +- Source tests prove manual refresh bypasses cache without bypassing the source’s + polling permission contract. +- Preference and engine tests prove source choices persist and disabled GitHub + sources neither surface work nor disable agent requests. +- Full SwiftPM tests pass. +- The packaged app builds, signs, launches, and renders Settings correctly in + light and dark appearance. +- The extracted release candidate passes strict `codesign` verification. + +## Firm exclusions + +- No queue, session roster, history view, or work counts. +- No floating island, detached panel, or Perch-style presence surface. +- No new work sources or repeated reminder policy. +- No automatic updater. +- No change to the waiting-first GitHub gate. + +## Release identity + +- Version: 0.1.3 +- Build: 6