From e67576ac537df45afa0a9e80049a205e64c1e9e7 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Sat, 17 Oct 2026 15:41:54 -0700 Subject: [PATCH] Close stale notifications once the item is dealt with --- TODO.md | 3 --- docs/operations.md | 1 + mac/Sources/Byre/Notifications.swift | 37 +++++++++++++++++++++++++++- mac/Sources/Byre/Store.swift | 2 ++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index df04727..5e6d233 100644 --- a/TODO.md +++ b/TODO.md @@ -31,9 +31,6 @@ # M7 — Inbox triage -- Close stale notifications once the item is dealt with - `Notifications.swift` removes each delivered notification by its identifier when `inbox.updated` leaves the item no longer open, or `summary.read` covers its `turn-`, so a question answered on another Mac or by the lead leaves Notification Center. Factory clears notifications when the session is focused. - # M8 — macOS client - Phone app diff --git a/docs/operations.md b/docs/operations.md index ac44789..d52e441 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -256,6 +256,7 @@ The running macOS app posts a notification for each new inbox item, raised by th - **Preferences.** `users.notification_prefs` holds which item kinds notify the user, returned as `notification_prefs` on `GET /api/me` and set with `PUT /api/me/notifications`: `questions` (on by default; it also covers `client_run` items, whose body reads "Run on your Mac: "), `reports` (task turns that end with `summary: true` on `turn.ended`, labelled "Turn summaries"), `errors` (`session_error`) and `rate_limits`. The app checks them against each item or `turn.ended` event as it arrives. - **Content.** The title is `byre · ` and the body the question (or the turn summary's first line, the error, or "Rate limited"), cut at 200 characters. The notification's identifier is the item's ID, or `turn-` for a turn summary, so a repeat replaces the earlier one. +- **Clearing.** A delivered notification leaves Notification Center once it is dealt with anywhere, on this Mac, another one or by the lead: an `inbox.updated` event whose item is no longer `open` removes the item's notification (`clearInbox`), and a `summary.read` event removes that task's `turn-` notifications up to its `seq` (`clearTurns`). - **Clicking** a notification activates the app, shows its window and points the right pane at the item's task. The left pane's **Settings** tab asks macOS for notification permission and edits the preferences. diff --git a/mac/Sources/Byre/Notifications.swift b/mac/Sources/Byre/Notifications.swift index 76bd4fb..11159b6 100644 --- a/mac/Sources/Byre/Notifications.swift +++ b/mac/Sources/Byre/Notifications.swift @@ -59,6 +59,18 @@ private func itemText(_ item: InboxItem) -> String { } } +@MainActor +private var queue: Task? + +@MainActor +private func enqueue(_ op: @escaping @MainActor () async -> Void) { + let prev = queue + queue = Task { + await prev?.value + await op() + } +} + @MainActor private func show(_ store: Store, _ taskId: String, _ tag: String, _ text: String) { let line = trimmed(text).split(separator: "\n", omittingEmptySubsequences: false).first.map(String.init) ?? "" @@ -68,7 +80,7 @@ private func show(_ store: Store, _ taskId: String, _ tag: String, _ text: Strin content.sound = .default content.userInfo = ["task_id": taskId] let request = UNNotificationRequest(identifier: tag, content: content, trigger: nil) - Task { + enqueue { let status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus if status != .authorized && status != .provisional { return @@ -89,6 +101,29 @@ func notifyInbox(_ store: Store, _ item: InboxItem) { show(store, item.taskId, item.id, itemText(item)) } +@MainActor +func clearInbox(_ item: InboxItem) { + if item.state == .open { + return + } + enqueue { + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [item.id]) + } +} + +@MainActor +func clearTurns(_ taskId: String, through seq: Int64) { + enqueue { + let ids = await UNUserNotificationCenter.current().deliveredNotifications().map(\.request).filter { r in + guard r.content.userInfo["task_id"] as? String == taskId, r.identifier.hasPrefix("turn-"), let s = Int64(r.identifier.dropFirst(5)) else { + return false + } + return s <= seq + }.map(\.identifier) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids) + } +} + @MainActor func notifyTurn(_ store: Store, _ ev: ByreEvent) { guard let me = store.me, me.notificationPrefs.reports, ev.payload.value["summary"] as? Bool == true, let taskId = ev.taskId, !taskId.isEmpty else { diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index 42b9253..f45c335 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -551,6 +551,7 @@ final class Store { try await self.loadInbox() } case "inbox.updated": + clearInbox(try! decodePayload(ev.payload, as: InboxItem.self)) spawn("load inbox") { try await self.loadInbox() } @@ -566,6 +567,7 @@ final class Store { case "summary.read": if let taskId = ev.payload.value["task_id"] as? String, let seq = ev.payload.value["seq"] as? Int { _ = noteSummaryRead(taskId, Int64(seq)) + clearTurns(taskId, through: Int64(seq)) } case "focus.updated": if ev.actor != me?.login {