Close stale notifications once the item is dealt with

This commit is contained in:
Ian Gulliver
2026-10-17 15:41:54 -07:00
parent 67578352ce
commit e67576ac53
4 changed files with 39 additions and 4 deletions
-3
View File
@@ -31,9 +31,6 @@
# M7 — Inbox triage # 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-<seq>`, 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 # M8 — macOS client
- Phone app - Phone app
+1
View File
@@ -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: <why>"), `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. - **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: <why>"), `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 · <task title>` 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-<seq>` for a turn summary, so a repeat replaces the earlier one. - **Content.** The title is `byre · <task title>` 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-<seq>` 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-<seq>` 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. - **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. The left pane's **Settings** tab asks macOS for notification permission and edits the preferences.
+36 -1
View File
@@ -59,6 +59,18 @@ private func itemText(_ item: InboxItem) -> String {
} }
} }
@MainActor
private var queue: Task<Void, Never>?
@MainActor
private func enqueue(_ op: @escaping @MainActor () async -> Void) {
let prev = queue
queue = Task {
await prev?.value
await op()
}
}
@MainActor @MainActor
private func show(_ store: Store, _ taskId: String, _ tag: String, _ text: String) { private func show(_ store: Store, _ taskId: String, _ tag: String, _ text: String) {
let line = trimmed(text).split(separator: "\n", omittingEmptySubsequences: false).first.map(String.init) ?? "" 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.sound = .default
content.userInfo = ["task_id": taskId] content.userInfo = ["task_id": taskId]
let request = UNNotificationRequest(identifier: tag, content: content, trigger: nil) let request = UNNotificationRequest(identifier: tag, content: content, trigger: nil)
Task { enqueue {
let status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus let status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
if status != .authorized && status != .provisional { if status != .authorized && status != .provisional {
return return
@@ -89,6 +101,29 @@ func notifyInbox(_ store: Store, _ item: InboxItem) {
show(store, item.taskId, item.id, itemText(item)) 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 @MainActor
func notifyTurn(_ store: Store, _ ev: ByreEvent) { 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 { guard let me = store.me, me.notificationPrefs.reports, ev.payload.value["summary"] as? Bool == true, let taskId = ev.taskId, !taskId.isEmpty else {
+2
View File
@@ -551,6 +551,7 @@ final class Store {
try await self.loadInbox() try await self.loadInbox()
} }
case "inbox.updated": case "inbox.updated":
clearInbox(try! decodePayload(ev.payload, as: InboxItem.self))
spawn("load inbox") { spawn("load inbox") {
try await self.loadInbox() try await self.loadInbox()
} }
@@ -566,6 +567,7 @@ final class Store {
case "summary.read": case "summary.read":
if let taskId = ev.payload.value["task_id"] as? String, let seq = ev.payload.value["seq"] as? Int { if let taskId = ev.payload.value["task_id"] as? String, let seq = ev.payload.value["seq"] as? Int {
_ = noteSummaryRead(taskId, Int64(seq)) _ = noteSummaryRead(taskId, Int64(seq))
clearTurns(taskId, through: Int64(seq))
} }
case "focus.updated": case "focus.updated":
if ev.actor != me?.login { if ev.actor != me?.login {