Show event label and first paragraph in notifications
This commit is contained in:
+1
-1
@@ -145,7 +145,7 @@ Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/ima
|
||||
|
||||
## Notifications
|
||||
|
||||
`Notifications.swift` posts through `UNUserNotificationCenter`: the preferences from `GET /api/me`, the events (`inbox.created` by kind, `turn.ended` with `summary: true`), title `byre · <task title>`, body the first line cut at 200 characters, `task_id` and `origin` (the server's) in its user info, and the item id, `turn-<seq>@<origin>` or `error-<seq>@<origin>` as the request identifier, so a repeat replaces the earlier one and two servers' event seqs don't collide. Every connected server notifies, its window open or not. They show as banners even while byre is the active app (`Notifier`, the centre's delegate, set at launch). Clicking one activates the app, opens the window on its server and focuses the task's Transcript. A notification left in Notification Center after quitting relaunches the app when clicked; `Notifier` holds its server and task until that server's startup has a store, then focuses it. Settings shows the authorization state (`This Mac shows notifications while byre is running.`, `This Mac doesn't show notifications from byre.` with a **Turn on for this Mac** button, which asks macOS for permission, or `Notifications are blocked for byre in System Settings.`). Nothing notifies while the app isn't running.
|
||||
`Notifications.swift` posts through `UNUserNotificationCenter`: the preferences from `GET /api/me`, the events (`inbox.created` by kind, `turn.ended` with `summary: true`), title `byre · <task title>`, subtitle what happened (`Question`, `Ready to merge`, `Turn finished`, …), body the text's first paragraph (`preview`: the first run of lines that aren't blank, Markdown headings, fences or HTML tags such as `<details>`, list and quote markers dropped and inline Markdown rendered to plain text, joined with spaces) cut at 200 characters, `task_id` and `origin` (the server's) in its user info, and the item id, `turn-<seq>@<origin>` or `error-<seq>@<origin>` as the request identifier, so a repeat replaces the earlier one and two servers' event seqs don't collide. Every connected server notifies, its window open or not. They show as banners even while byre is the active app (`Notifier`, the centre's delegate, set at launch). Clicking one activates the app, opens the window on its server and focuses the task's Transcript. A notification left in Notification Center after quitting relaunches the app when clicked; `Notifier` holds its server and task until that server's startup has a store, then focuses it. Settings shows the authorization state (`This Mac shows notifications while byre is running.`, `This Mac doesn't show notifications from byre.` with a **Turn on for this Mac** button, which asks macOS for permission, or `Notifications are blocked for byre in System Settings.`). Nothing notifies while the app isn't running.
|
||||
|
||||
## Pane focus
|
||||
|
||||
|
||||
+1
-1
@@ -352,7 +352,7 @@ A user who is away has byre answer their agents' questions instead of waiting (`
|
||||
The running macOS app posts notifications for events on its SSE stream, raised by the app itself (`notifyInbox`, `notifyError`, `notifyTurn` and `notifyTask` in `mac/Sources/Byre/Notifications.swift`, through `UNUserNotificationCenter`). With the app not running, nothing notifies; closing its window leaves it running.
|
||||
|
||||
- **Preferences.** The `users.notify_*` boolean columns hold what notifies the user, returned as `notification_prefs` on `GET /api/me` and set with `PUT /api/me/notifications`: `questions` (`inbox.created`, on by default), `reports` (task turns that end with `summary: true` on `turn.ended`, labelled "Turn summaries"), `ready` (`task.ready`), `discard` (`task.discard_suggested`), `merge_problems` (`task.merge_stopped` and `task.merge_after_review_dropped`), `workflows` (`task.workflow_finished`), `errors` (`session.error`) and `rate_limits` (`session.rate_limited`). Migration `080-normalize-notification-preferences` moves existing JSON preferences into those columns and drops the JSON column; a missing key becomes off. New users have only questions enabled. The app checks them against each event as it arrives.
|
||||
- **Content.** The title is `byre · <task title>` and the body the first line of the question, turn summary, ready-to-merge overview, discard or merge-problem reason, workflow output, or error (or "Rate limited"), cut at 200 characters. The notification's identifier is the question's ID, `turn-<seq>` for a turn summary, `workflow-<seq>` for a finished workflow, `error-<seq>` for an error or rate limit, and `task-<task id>` for ready to merge, suggests discard and merge problems, so each task shows at most one of those three at a time.
|
||||
- **Content.** The title is `byre · <task title>`, the subtitle what happened (`Question`, `Turn finished`, `Ready to merge`, `Suggests discard`, `Merge stopped`, `Didn't merge after the review`, `Finished <workflow>`, `Error` or `Rate limited`), and the body the first paragraph of the question, turn summary, ready-to-merge overview, discard or merge-problem reason, workflow output, or error (empty for a rate limit): the first run of lines that aren't blank, Markdown headings, fences or HTML tags, with list and quote markers dropped and inline Markdown rendered to plain text, joined with spaces and cut at 200 characters. The notification's identifier is the question's ID, `turn-<seq>` for a turn summary, `workflow-<seq>` for a finished workflow, `error-<seq>` for an error or rate limit, and `task-<task id>` for ready to merge, suggests discard and merge problems, so each task shows at most one of those three at a time.
|
||||
- **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`), a `summary.read` event removes that task's `turn-<seq>` notifications up to its `seq` (`clearTurns`), and `task.merged`, `task.discarded`, `snapshot.created` (unless its `trigger` is `answer`) and `inbox.created` remove the task's `task-<task id>` notification (`clearTask`); the last two are the events that clear the task's ready state on the server.
|
||||
- **Clicking** a notification activates the app, shows its window and points the right pane at the item's task.
|
||||
|
||||
|
||||
@@ -54,11 +54,31 @@ private func enqueue(_ op: @escaping @MainActor () async -> Void) {
|
||||
}
|
||||
|
||||
@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) ?? ""
|
||||
private func preview(_ text: String) -> String {
|
||||
var lines: [String] = []
|
||||
for raw in text.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||
var line = trimmed(String(raw))
|
||||
if line.isEmpty || line.hasPrefix("#") || line.hasPrefix("<") || line.hasPrefix("```") {
|
||||
if lines.isEmpty {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
for marker in ["- ", "* ", "> "] where line.hasPrefix(marker) {
|
||||
line = trimmed(String(line.dropFirst(marker.count)))
|
||||
}
|
||||
lines.append((try? AttributedString(markdown: line)).map { String($0.characters) } ?? line)
|
||||
}
|
||||
let body = lines.joined(separator: " ")
|
||||
return body.count > 200 ? "\(body.prefix(199))…" : body
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func show(_ store: Store, _ taskId: String, _ tag: String, _ kind: String, _ text: String) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = store.findTask(taskId).map { "byre · \($0.name)" } ?? "byre"
|
||||
content.body = line.count > 200 ? "\(line.prefix(199))…" : line
|
||||
content.subtitle = kind
|
||||
content.body = preview(text)
|
||||
content.sound = .default
|
||||
content.userInfo = ["task_id": taskId, "origin": store.api.origin.absoluteString]
|
||||
let request = UNNotificationRequest(identifier: tag, content: content, trigger: nil)
|
||||
@@ -80,7 +100,7 @@ func notifyInbox(_ store: Store, _ item: InboxItem) {
|
||||
guard let me = store.me, me.notificationPrefs.questions, item.state == .open else {
|
||||
return
|
||||
}
|
||||
show(store, item.taskId, item.id, str(item.payload.value, "question") ?? "")
|
||||
show(store, item.taskId, item.id, "Question", str(item.payload.value, "question") ?? "")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -92,7 +112,7 @@ func notifyError(_ store: Store, _ ev: ByreEvent) {
|
||||
if !(limited ? me.notificationPrefs.rateLimits : me.notificationPrefs.errors) {
|
||||
return
|
||||
}
|
||||
show(store, taskId, "error-\(ev.seq)@\(store.api.origin.absoluteString)", limited ? "Rate limited" : "Error: \(str(ev.payload.value, "error") ?? "")")
|
||||
show(store, taskId, "error-\(ev.seq)@\(store.api.origin.absoluteString)", limited ? "Rate limited" : "Error", limited ? "" : str(ev.payload.value, "error") ?? "")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -128,15 +148,15 @@ func notifyTask(_ store: Store, _ ev: ByreEvent) {
|
||||
let background = store.findTask(taskId)?.background == true
|
||||
switch ev.kind {
|
||||
case "task.ready" where prefs.ready:
|
||||
show(store, taskId, "task-\(taskId)@\(origin)", "Ready to merge: \(str(ev.payload.value, "overview") ?? "")")
|
||||
show(store, taskId, "task-\(taskId)@\(origin)", "Ready to merge", str(ev.payload.value, "overview") ?? "")
|
||||
case "task.discard_suggested" where prefs.discard && !background:
|
||||
show(store, taskId, "task-\(taskId)@\(origin)", "Suggests discard: \(str(ev.payload.value, "reason") ?? "")")
|
||||
show(store, taskId, "task-\(taskId)@\(origin)", "Suggests discard", str(ev.payload.value, "reason") ?? "")
|
||||
case "task.merge_stopped" where prefs.mergeProblems:
|
||||
show(store, taskId, "task-\(taskId)@\(origin)", "Merge stopped: \(str(ev.payload.value, "reason") ?? "")")
|
||||
show(store, taskId, "task-\(taskId)@\(origin)", "Merge stopped", str(ev.payload.value, "reason") ?? "")
|
||||
case "task.merge_after_review_dropped" where prefs.mergeProblems:
|
||||
show(store, taskId, "task-\(taskId)@\(origin)", "Didn't merge after the review: \(str(ev.payload.value, "reason") ?? "")")
|
||||
show(store, taskId, "task-\(taskId)@\(origin)", "Didn't merge after the review", str(ev.payload.value, "reason") ?? "")
|
||||
case "task.workflow_finished" where prefs.workflows && !background:
|
||||
show(store, taskId, "workflow-\(ev.seq)@\(origin)", "Finished \(str(ev.payload.value, "workflow") ?? ""): \(str(ev.payload.value, "output") ?? "")")
|
||||
show(store, taskId, "workflow-\(ev.seq)@\(origin)", "Finished \(str(ev.payload.value, "workflow") ?? "")", str(ev.payload.value, "output") ?? "")
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -155,5 +175,5 @@ 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, store.findTask(taskId)?.background != true else {
|
||||
return
|
||||
}
|
||||
show(store, taskId, "turn-\(ev.seq)@\(store.api.origin.absoluteString)", str(ev.payload.value, "result") ?? "")
|
||||
show(store, taskId, "turn-\(ev.seq)@\(store.api.origin.absoluteString)", "Turn finished", str(ev.payload.value, "result") ?? "")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user