Move the following audited-and-implemented changes into openspec/changes/archive/2026-06-12-completed-changes-archive/: - backend-frontend-refactoring - config-profile-git-mounts - config-profile-includes-ui - config-profile-multi-repo-mounts - container-monitoring-notifications - git-mount-url-validation - home-path-expansion - mobile-terminal-ux - mount-specificity-ordering - notification-center - persistent-terminal-sessions - session-list-overhaul - ssh-key-mounting - terminal-fullscreen-unified-header - tool-session-progress-and-updates Also regenerated .pi-map*.md files for openspec/changes so the remaining active changes (multi-session-terminal-ux, reorganize-long-files, working-copies, workspace-first-ui) reflect the new layout.
17 KiB
SDD Proposal — Notification Center
Change ID: notification-center
Status: Draft
Date: 2026-05-29
1. Problem Statement
The current notification surface is limited to ephemeral toasts driven by an unfiltered SSE stream. Users face three critical gaps:
- No persistence — If a user is offline, reloads the page, or dismisses a toast, the event is gone forever. There is no way to review what happened while they were away.
- No scoping — The SSE endpoint broadcasts all instance events to every authenticated user. Users receive toasts for containers they do not own, creating noise and potential information leakage.
- No lifecycle or control — Toasts auto-dismiss with no read/unread state, no dismissal history, and no user preferences to mute categories or suppress toast pop-ups.
These gaps make the system unsuitable for any asynchronous, user-specific, or high-signal communication such as health alerts, system maintenance notices, or future billing events.
2. Goals
| # | Goal | Success Measure |
|---|---|---|
| G1 | Persistent, per-user notification store backed by a new database table. Notifications survive page reloads, browser restarts, and session changes. | 100 % of notifications created for a user are retrievable after a full browser close + reopen. |
| G2 | Per-user filtering — Users only see notifications scoped to their user_id. | Zero cross-user notification leakage in API responses. |
| G3 | Read/unread/dismiss lifecycle with REST endpoints and optimistic UI updates. | Users can mark individual or all notifications read, and dismiss unwanted entries; state persists on refresh. |
| G4 | Notification center UI — Bell icon in the top-right AppShell header with a dropdown panel listing recent notifications. | Bell is visible on desktop; dropdown renders within 200 ms of click; accessible via keyboard. |
| G5 | Unread count badge — Red badge on the bell icon reflecting the real-time unread count. | Badge count matches GET /notifications/unread within one polling interval. |
| G6 | Modular notification sources — Any backend module can call a central NotificationService to create user-scoped notifications without touching instance events directly. | A new source (e.g., a future billing module) can emit notifications by adding a single service call. |
| G7 | Toast coordination — The existing toast system respects user preferences and avoids duplicate surfacing when a notification is already in the center. | No user sees both a toast and a center entry for the same backend event unless they explicitly re-open the center. |
| G8 | User preferences — Mute categories and toast-level settings stored in UserConfig. | Preference changes take effect immediately without a server restart. |
3. Non-Goals
| # | Non-Goal | Rationale |
|---|---|---|
| NG1 | Real-time SSE for notifications in Phase 1 | Will use REST polling (30 s list / 15 s unread) to ship faster. A dedicated notifications/stream SSE is a fast-follow (Phase 3). |
| NG2 | Push notifications / WebHooks / Email | Out of scope for this change. The architecture must not block these later, but no transport work is included now. |
| NG3 | Multi-worker event-bus scaling | InstanceEventBus remains an in-memory singleton. The NotificationService interface is designed so a future Redis-backed queue can slot in without consumer changes. |
| NG4 | Team / group-scoped notifications | Notifications are 1-to-1 user_id only. Mentioning or broadcasting to teams is future work. |
| NG5 | Mobile-specific notification UI (bottom sheet) | The bell will be hidden on isMobileTerminal. A mobile-native bottom-sheet variant is a future polish item. |
| NG6 | Rich-text or markdown bodies | title and message are plain strings. No formatting engine is introduced. |
4. User Stories
| ID | Story | Acceptance Criteria |
|---|---|---|
| US-1 | As a user, I want to see a bell icon with an unread count in the header so that I know when something needs my attention. | Bell renders in header-actions; badge shows unread count; count updates on poll. |
| US-2 | As a user, I want to click the bell and see a list of recent notifications so that I can catch up on events I missed. | Dropdown opens; lists last 20 notifications; shows title, relative time, severity icon; empty state when none exist. |
| US-3 | As a user, I want to mark a notification as read so that the badge count decreases and the UI reflects my attention. | Clicking a row or its Mark read action updates read_at; badge decrements; row styling changes. |
| US-4 | As a user, I want to dismiss a notification so that it no longer appears in my list. | Dismiss removes the row from the list and sets dismissed_at; does not affect other users. |
| US-5 | As a user, I want to Mark all as read so that I can clear my inbox quickly. | Footer button marks all unread notifications read; badge resets to zero; list styling updates. |
| US-6 | As a user, I want notification preferences (mute categories, toast level) so that I control noise. | Settings panel or modal exposes checkboxes / select for mute categories and toast level; saves to UserConfig. |
| US-7 | As a backend developer, I want to emit a notification from any module with one function call so that I do not rebuild plumbing each time. | NotificationService.create_notification(...) is importable anywhere; auto-scopes to user_id. |
| US-8 | As a user, I want container error events to appear as notifications so that I can review them later even if I missed the toast. | instance.error events from HealthMonitor / lifecycle_hooks generate a Notification row for the owner. |
5. Proposed Solution
5.1 Backend
New Data Model
# apps/api/src/models/notification.py
class Notification(Base):
__tablename__ = "notifications"
id: Mapped[UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid4)
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
category: Mapped[str] = mapped_column(String(32), nullable=False)
severity: Mapped[str] = mapped_column(String(16), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
message: Mapped[str] = mapped_column(Text, nullable=True)
source_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
source_id: Mapped[UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
metadata: Mapped[dict] = mapped_column(JSON, default=dict)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
dismissed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True, nullable=False
)
Indexes:
- (user_id, created_at DESC) — fast list queries
- (user_id, read_at) WHERE read_at IS NULL — fast unread count (partial index)
New Service
# apps/api/src/services/notification_service.py
class NotificationService:
async def create_notification(
self, user_id: UUID, category: str, severity: str,
title: str, message: str | None = None,
source_type: str | None = None, source_id: UUID | None = None,
metadata: dict | None = None
) -> Notification: ...
async def list_notifications(
self, user_id: UUID, *, limit: int = 20, offset: int = 0,
unread_only: bool = False
) -> list[Notification]: ...
async def get_unread_count(self, user_id: UUID) -> int: ...
async def mark_read(self, notification_id: UUID, user_id: UUID) -> Notification: ...
async def mark_all_read(self, user_id: UUID) -> int: ...
async def dismiss(self, notification_id: UUID, user_id: UUID) -> None: ...
The service is instantiated as a module-level singleton and imported by event producers.
New API Router
- GET /notifications — list (paginated, supports ?unread_only=true)
- GET /notifications/unread — returns { "count": int }
- PATCH /notifications/{id}/read — mark single read
- POST /notifications/mark-all-read — mark all read
- DELETE /notifications/{id} — dismiss (soft-delete by setting dismissed_at)
All endpoints enforce user_id == current_user.id at the service layer.
Event-Bus Integration
- lifecycle_hooks.py and health_monitor.py call notification_service.create_notification(...) with user_id=tool_instance.owner_id after publishing the raw event.
- No changes to InstanceEventBus itself; the notification service is a consumer, not bus middleware.
Preferences Extension
Extend UserConfig.config JSON schema with two new keys:
- notification_mute_categories: string[] — categories the user does not want to see at all.
- notification_toast_level: "all" | "errors" | "none" — default is "all".
5.2 Frontend
New / Modified Components
| Component | Purpose |
|---|---|
| notification-center.tsx | Bell icon + dropdown panel. Manages open/close state, outside-click close, keyboard Escape. |
| notification-item.tsx | Single row: severity icon, title, relative time, mark-read/dismiss actions. |
| notification-provider.tsx | React context: holds list, unread count, polling logic (30 s / 15 s), mutations with optimistic updates. |
| use-notifications.ts | Hook exposing notifications, unreadCount, markRead, markAllRead, dismiss, isLoading. |
| app-shell.tsx | Mount NotificationCenter inside header-actions; hide when isMobileTerminal. |
| event-toast-bridge.tsx | Read userConfig.notification_toast_level before emitting a toast. Skip toast if level is "none" or event severity is below threshold. |
| icons.ts | Register "bell" pointing to PhosphorIcons.Bell. |
| styles.css | Add .notification-dropdown, .notification-item, .notification-badge utilities. |
Toast Coordination Logic
- Backend event triggers NotificationService.create_notification() (always happens).
- EventToastBridge receives the SSE event.
- Bridge checks userConfig.notification_toast_level:
- If "none": never toast.
- If "errors": only toast when severity is "error".
- If "all": toast as before.
- Bridge also checks if the event category is in notification_mute_categories; if so, skip toast.
- The notification row is always created on the backend regardless of frontend preferences; filtering happens at read time and in the bridge.
Polling Strategy
- Notification list: GET /notifications every 30 seconds while the dropdown is closed; refresh immediately when opened.
- Unread count: GET /notifications/unread every 15 seconds.
- Intervals are configurable constants in the provider.
6. Key Decisions
| Decision | Rationale |
|---|---|
| Soft-delete via dismissed_at instead of hard DELETE | Preserves audit history and allows future features such as "Recently dismissed" or admin analytics. |
| Partial index on read_at IS NULL | Unread count is queried frequently; a partial index keeps it small and fast even as the table grows. |
| Poll instead of SSE for Phase 1 | Avoids redesigning the SSE multiplexing logic and lets us ship the full UI and backend in one PR. SSE follow-up is isolated. |
| Plain-text title/message | Avoids introducing a markdown parser or HTML sanitization dependency. Rich content can be a future enhancement. |
| UserConfig JSON blob for preferences | Matches existing pattern (theme, editor, git identity). No schema migration needed when adding keys. |
| No middleware in InstanceEventBus | Producers (lifecycle_hooks, health_monitor) explicitly call the notification service. This makes the dependency visible and avoids hidden side effects in the bus. |
| Category + severity enums stored as strings | Simple, human-readable, and extensible without Alembic migrations when a new source introduces a category. |
7. Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| High write volume on notifications table | Medium | High | Add partial indexes from day one; monitor write throughput; shard or archive old rows (e.g., auto-dismiss after 90 days) if volume becomes problematic. |
| Cross-user data leakage in API | Low | Critical | Enforce user_id filter in every service method; add integration tests that attempt to read another user’s notification and assert 404. |
| Polling overhead at scale | Medium | Medium | Poll intervals are conservative; unread count endpoint is a single COUNT query with a partial index. SSE fast-follow eliminates polling. |
| Mobile layout absence | Low | Low | Bell is hidden on isMobileTerminal. Mobile bottom-sheet is a future non-goal. |
| No reusable dropdown component | Medium | Medium | Build a minimal positioned panel inside notification-center.tsx using a ref + useEffect for outside click; extract to a design-system component only after it stabilizes. |
| Notification service called before DB commit | Medium | Medium | Ensure lifecycle_hooks commits the parent transaction (instance_events insert) before calling the notification service, or wrap both in the same unit of work. |
8. Acceptance Criteria
Backend
- Alembic migration creates the notifications table with correct columns, FK, and indexes.
- GET /notifications returns only rows where user_id matches the authenticated user, ordered by created_at DESC.
- GET /notifications/unread returns the exact count of rows where read_at IS NULL for the authenticated user.
- PATCH /notifications/{id}/read sets read_at and returns the updated row; 404 if not owned by caller.
- POST /notifications/mark-all-read sets read_at on all unread rows for the caller; returns count affected.
- DELETE /notifications/{id} sets dismissed_at; row no longer appears in list queries.
- HealthMonitor and lifecycle_hooks generate notifications scoped to the tool instance owner.
Frontend
- Bell icon renders in AppShell header-actions on desktop.
- Unread count badge updates within 15 seconds of a new notification.
- Dropdown opens on bell click, closes on outside click or Escape.
- Notification list shows title, relative time, severity icon; unread rows are visually distinct.
- Mark read and Dismiss actions update UI optimistically and persist after refresh.
- Mark all as read clears the badge and updates all visible rows.
- Empty state message shown when no notifications exist.
- Toast bridge respects notification_toast_level and notification_mute_categories.
Integration
- End-to-end test: trigger an instance.error event → verify notification row created → verify badge increments → verify toast appears (or not) based on preference → mark read → verify badge clears.
9. Effort Estimate + PR Breakdown
PR 1 — Backend Core (~2 days)
Scope: Migration, model, service, API router, registration in main.py. Files:
- alembic/versions/..._add_notifications.py
- apps/api/src/models/notification.py
- apps/api/src/services/notification_service.py
- apps/api/src/api/notifications.py
- apps/api/src/main.py Tests: Service unit tests, API integration tests (ownership, pagination, mark-all-read).
PR 2 — Backend Integration (~1 day)
Scope: Wire lifecycle_hooks and HealthMonitor to call NotificationService; add preferences to UserConfig schema. Files:
- apps/api/src/services/lifecycle_hooks.py
- apps/api/src/services/health_monitor.py
- apps/api/src/models/user_config.py (schema docs / validation) Tests: End-to-end event-to-notification creation tests.
PR 3 — Frontend Core (~2 days)
Scope: Icon, provider, hook, notification-center component, item component, styles, app-shell integration. Files:
- apps/web/src/utils/icons.ts
- apps/web/src/state/notifications.tsx
- apps/web/src/hooks/use-notifications.ts
- apps/web/src/components/notification-center.tsx
- apps/web/src/components/notification-item.tsx
- apps/web/src/components/app-shell.tsx
- apps/web/src/styles.css Tests: Component render tests, hook behavior tests, optimistic update tests.
PR 4 — Toast Coordination + Preferences UI (~1 day)
Scope: Update EventToastBridge; add preference controls (inside existing settings modal or new section); connect to UserConfig API. Files:
- apps/web/src/components/event-toast-bridge.tsx
- apps/web/src/components/toast-rules.ts (if toast level logic lives here)
- Settings / preferences component (TBD based on existing UI) Tests: Bridge logic tests, preference persistence tests.
Total Estimated Effort: ~6 engineering days
Sequence: PR 1 and PR 2 can be stacked (2 before 3). PR 3 depends on PR 1/2. PR 4 depends on PR 3.
10. Rollback Plan
- Database: The migration is additive (new table + indexes). Rolling back requires a single Alembic downgrade that drops the notifications table. No existing tables are modified.
- Frontend: If the UI causes performance or layout issues, remove the NotificationCenter mount from app-shell.tsx. The rest of the codebase is unaffected.
- Backend API: If the router causes issues, unregister it in main.py. The underlying service and table can remain safely.
- Event producers: If notification creation causes errors, the explicit service call in lifecycle_hooks and health_monitor can be wrapped in a try/except log-and-continue block so that event publishing is never blocked.