From a7409fc367d11f833fb9dfe9bb33687532a2e6ed Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Fri, 8 May 2026 16:55:16 +0000 Subject: [PATCH 01/16] docs: design notification system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add design doc for a centralized notification system: bell + panel on the navbar, durable per-user inbox, three notification types in v1 (mention, session_returned, global_admin), and a toast-vs-notification rule. Takes positions on the open questions in the brief — collapse session-returned per (user, session, type), interaction-based default subscription, click marks read but doesn't dismiss, navbar message ticker cut from v1, sticky admin banner in the navbar center slot. No code yet — design + open questions + phased plan + effort estimate. Co-Authored-By: Claude Opus 4.7 --- docs/notification-system-design.md | 664 +++++++++++++++++++++++++++++ 1 file changed, 664 insertions(+) create mode 100644 docs/notification-system-design.md diff --git a/docs/notification-system-design.md b/docs/notification-system-design.md new file mode 100644 index 0000000000..137f352afe --- /dev/null +++ b/docs/notification-system-design.md @@ -0,0 +1,664 @@ +# Agor Notification System — Design Doc (DRAFT) + +**Status:** Design / awaiting Max's review before implementation +**Author:** Claude (design pass) +**Date:** 2026-05-08 +**Branch:** `design-notification-system` + +--- + +## TL;DR + +Agor has good *toasts* (Ant Design `message` API at `apps/agor-ui/src/utils/message.tsx`) and a strong real-time push pipe (`app.io.to(userRoomName(userId))` in `apps/agor-daemon/src/setup/socketio.ts:193,379`), but **no durable inbox**. Today, when an agent completes a task while you're on another board — or a teammate `@`-mentions you in a comment — there's no centralized record of "things that happened while you were away." The `unreadCommentsCount` badge on the navbar's comments button (`AppHeader.tsx:277-293`) only reflects the *current* board. + +This doc proposes a `notifications` table, a per-user real-time push, a bell-icon panel in the navbar, and a clear toast-vs-notification rule. It takes positions on the open questions Max raised. It explicitly **scopes out** the navbar message ticker for v1 (and defends that cut), recommends **server-driven unread counts** (not the client-derived pattern used for comments today), and proposes a **collapse-per-(session,type)** model so a chatty agent doesn't fan out into a 50-row panel. + +**Not in this doc:** code. This is design + open questions. Implementation lands in a follow-up. + +--- + +## 1. Goals + Non-goals + +### 1.1 Goals + +- **One bell, one panel, one counter** — every "something you should know" surfaces here. +- **Durable across sessions and devices** — notifications survive page reload and follow the user, not the tab. +- **Real-time delivery** — uses the existing socket pipe; new notifs animate in without a refresh. +- **Clear rule for toast vs notification** — so future contributors don't have to ask. +- **Cheap to extend** — adding a new notification type later is a schema enum + a producer call site. + +### 1.2 Non-goals (v1) + +- **Email / push / SMS** — out of scope; web-only delivery. +- **Digest / batching** — no "you have 7 unread" emails. +- **Per-event subscription UI** — opt-out lives at the *session* level (archive = unsub). No per-type granular subscription panel. +- **Rich-content notifs** — no embedded video, no rendered code blocks. Title + 1-line preview + link. +- **Slack/Discord parity** — gateway integrations stay in the gateway layer (`apps/agor-daemon/src/services/gateway-channels.ts`); the in-app notification system doesn't try to be a chat app. +- **Navbar message ticker** — see §3.5; explicit scope cut. + +--- + +## 2. Current state inventory + +What exists today, citation-heavy so the implementer can plug in: + +| Capability | Today | File pointer | +|---|---|---| +| **Toast / message API** | Ant Design `message`, wrapped via `useThemedMessage()` (`showSuccess`/`showError`/etc.) | `apps/agor-ui/src/utils/message.tsx` | +| **Per-user socket push** | `app.io.to(userRoomName(userId)).emit(event, payload)`. Sockets join `user/{user_id}` on auth | `apps/agor-daemon/src/setup/socketio.ts:193, 379` | +| **Authenticated broadcast** | `app.channel('authenticated')` — joined on login, left on logout | `apps/agor-daemon/src/setup/socketio.ts:735-765` | +| **Service-event push** | `app.service(name).emit(event, data)` — Feathers fans out to subscribed clients | `register-routes.ts` (many sites, e.g. `:585, :2271, :2297`) | +| **Comments unread badge** | Client-side derived from `commentById` map filtered to current board. **Not durable, single-board only.** | `apps/agor-ui/src/components/App/App.tsx:672-689` | +| **Mentions** | `comment.data.mentions: string[]` (Phase 4). Detection is **substring match on `@username` / `@email`** in comment content | `apps/agor-ui/src/components/App/App.tsx:678-689`; schema at `schema.sqlite.ts:1260-1262` | +| **"Session needs attention"** | `session.ready_for_prompt: bool`. Set true on task COMPLETED/FAILED; cleared when user opens conversation drawer | `packages/core/src/types/session.ts:314`; `apps/agor-daemon/src/services/tasks.ts` | +| **"Worktree needs attention"** | `worktree.needs_attention: bool` — cumulative over sessions in the worktree | `packages/core/src/types/worktree.ts:252` | +| **Archive state** | `session.archived` + `worktree.archived` + `worktree.archived_at` | `schema.sqlite.ts:97, worktree fields` | +| **Parent-callback queueing** | When a *child* session completes, parent gets a queued system message. **This is agent↔agent, not user↔agent** | `context/explorations/parent-session-callbacks.md` | +| **Favicon unread indicator** | `useFaviconStatus.ts` already swaps the favicon based on `ready_for_prompt` aggregate | `apps/agor-ui/src/hooks/useFaviconStatus.ts` | +| **Audio chime** | `user.preferences.audio.enabled` — already plays on session ready | `packages/core/src/types/user.ts:329-337` | + +**Key takeaway:** the building blocks are already there. The missing piece is a *recipient-addressed durable record* — i.e., a row that says "user X should be told about event Y, here's the link, marked unread." Today the system has plenty of *events*, no *inbox*. + +--- + +## 3. User-facing experience + +### 3.1 Bell + counter + +A new bell button on `AppHeader.tsx`, on the **right side**, between the Facepile/divider and the Live Event Stream button. It uses the same `` pattern as the existing comments button: + +```tsx + + + + +
+ {visible.length === 0 ? ( + + } + imageStyle={{ height: 60 }} + description={ +
+ You're all caught up. +
+ + Notifications about sessions you're working on, mentions, and + announcements appear here. + +
+ } + /> + ) : ( + + {visible.map((n) => ( + onMarkRead(n.notification_id)} + onDismiss={() => onDismiss(n.notification_id)} + onClick={() => { + onMarkRead(n.notification_id); + onOpen?.(n); + setOpen(false); + }} + /> + ))} + + )} +
+ + ); + + return ( + + + + + + + + Active + + + rowKey="broadcast_group_id" + dataSource={active} + loading={loading} + pagination={false} + locale={{ emptyText: 'No active announcements.' }} + columns={[ + { + title: 'Title', + dataIndex: 'title', + render: (title: string, record) => ( + + {record.banner && } + {title} + + ), + }, + { + title: 'Preview', + dataIndex: 'preview', + render: (preview?: string) => + preview ? {preview} : '—', + }, + { + title: 'Banner', + dataIndex: 'banner', + width: 80, + render: (banner: boolean) => + banner ? Banner : Panel only, + }, + { + title: 'Recipients', + dataIndex: 'recipient_count', + width: 110, + }, + { + title: 'Sent', + dataIndex: 'created_at', + width: 130, + render: (v: string) => ( + {formatRelativeTime(v)} + ), + }, + { + title: 'Expires', + dataIndex: 'expires_at', + width: 140, + render: (v?: string) => + v ? {formatRelativeTime(v)} : 'Never', + }, + { + title: '', + key: 'actions', + width: 100, + render: (_: unknown, record) => ( + + ), + }, + ]} + /> + + {past.length > 0 && ( + <> + + Past + + + rowKey="broadcast_group_id" + dataSource={past} + pagination={{ pageSize: 10 }} + columns={[ + { title: 'Title', dataIndex: 'title' }, + { + title: 'Sent', + dataIndex: 'created_at', + width: 160, + render: (v: string) => ( + {formatRelativeTime(v)} + ), + }, + { + title: 'Expired', + dataIndex: 'expires_at', + width: 160, + render: (v?: string) => + v ? {formatRelativeTime(v)} : '—', + }, + { title: 'Recipients', dataIndex: 'recipient_count', width: 110 }, + ]} + /> + + )} + + setComposeOpen(false)} + onSent={() => { + setComposeOpen(false); + refetch(); + }} + /> + + ); +}; + +// ============================================================================ +// Compose modal +// ============================================================================ + +interface ComposeAnnouncementModalProps { + open: boolean; + client: AgorClient | null; + onClose: () => void; + onSent: () => void; +} + +interface ComposeForm { + title: string; + preview?: string; + banner: boolean; + expireValue?: number; + expireUnit?: 'minutes' | 'hours' | 'days'; +} + +const ComposeAnnouncementModal: React.FC = ({ + open, + client, + onClose, + onSent, +}) => { + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async () => { + if (!client) return; + try { + const values = await form.validateFields(); + setSubmitting(true); + + const expires_at = computeExpiresAt(values); + // biome-ignore lint/suspicious/noExplicitAny: dynamic feathers service typing + await (client.service('notifications') as any).broadcast({ + title: values.title, + preview: values.preview, + banner: values.banner ?? false, + expires_at: expires_at?.toISOString(), + }); + message.success('Announcement sent'); + form.resetFields(); + onSent(); + } catch (err) { + if (err && typeof err === 'object' && 'errorFields' in err) { + // Form validation failed — antd already surfaces field errors. + return; + } + message.error( + `Failed to send: ${err instanceof Error ? err.message : String(err)}` + ); + } finally { + setSubmitting(false); + } + }; + + return ( + +
+ + + + + +