diff --git a/docs/macos.md b/docs/macos.md index 08c4e7c..5a301ce 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -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 · `, body the first line cut at 200 characters, `task_id` and `origin` (the server's) in its user info, and the item id, `turn-@` or `error-@` 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 · `, 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 `
`, 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-@` or `error-@` 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 diff --git a/docs/operations.md b/docs/operations.md index 0498d1e..010aa50 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 · ` 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-` for a turn summary, `workflow-` for a finished workflow, `error-` for an error or rate limit, and `task-` 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 · `, the subtitle what happened (`Question`, `Turn finished`, `Ready to merge`, `Suggests discard`, `Merge stopped`, `Didn't merge after the review`, `Finished `, `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-` for a turn summary, `workflow-` for a finished workflow, `error-` for an error or rate limit, and `task-` 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-` 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-` 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. diff --git a/mac/Sources/Byre/Notifications.swift b/mac/Sources/Byre/Notifications.swift index b44d0d8..3319e43 100644 --- a/mac/Sources/Byre/Notifications.swift +++ b/mac/Sources/Byre/Notifications.swift @@ -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") ?? "") }