caadd59441
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.
284 lines
17 KiB
Markdown
284 lines
17 KiB
Markdown
# 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:
|
||
|
||
1. **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.
|
||
2. **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.
|
||
3. **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
|
||
|
||
```python
|
||
# 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
|
||
|
||
```python
|
||
# 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
|
||
|
||
1. Backend event triggers NotificationService.create_notification() (always happens).
|
||
2. EventToastBridge receives the SSE event.
|
||
3. Bridge checks userConfig.notification_toast_level:
|
||
- If "none": never toast.
|
||
- If "errors": only toast when severity is "error".
|
||
- If "all": toast as before.
|
||
4. Bridge also checks if the event category is in notification_mute_categories; if so, skip toast.
|
||
5. 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
|
||
|
||
1. **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.
|
||
2. **Frontend:** If the UI causes performance or layout issues, remove the NotificationCenter mount from app-shell.tsx. The rest of the codebase is unaffected.
|
||
3. **Backend API:** If the router causes issues, unregister it in main.py. The underlying service and table can remain safely.
|
||
4. **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.
|