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.
39 KiB
SDD Design — Notification Center
Change ID: notification-center
Phase: design
Date: 2026-05-29
Owner: Gentle AI
Scope: Cross-cutting (backend + frontend)
Est. Lines: ~1,800 (recommend 4 chained PRs)
Depends on: container-monitoring-notifications (event bus, SSE, lifecycle hooks)
1. Component Architecture
1.1 NotificationService — Backend Singleton
Pattern: Module-level singleton, instantiated once at import time and shared across request handlers and event producers.
Responsibilities:
- Insert per-user
Notificationrows into the database. - Filter all queries by
user_id; enforce strict ownership isolation. - Exclude
dismissed_at IS NOT NULLrows from list queries. - Return paginated results ordered by
created_at DESC. - Apply
notification_mute_categoriesfiltering at the service layer so muted categories are never returned.
Class:
# apps/api/src/services/notification_service.py
class NotificationService:
"""Singleton notification persistence service."""
async def create_notification(
self,
session: AsyncSession,
user_id: uuid.UUID,
*,
category: str,
severity: str,
title: str,
message: str | None = None,
source_type: str | None = None,
source_id: uuid.UUID | None = None,
metadata: dict | None = None,
) -> Notification: ...
async def list_notifications(
self,
session: AsyncSession,
user_id: uuid.UUID,
*,
limit: int = 20,
offset: int = 0,
unread_only: bool = False,
mute_categories: list[str] | None = None,
) -> tuple[list[Notification], int]: ...
async def get_unread_count(
self,
session: AsyncSession,
user_id: uuid.UUID,
) -> int: ...
async def mark_read(
self,
session: AsyncSession,
notification_id: uuid.UUID,
user_id: uuid.UUID,
) -> Notification: ...
async def mark_all_read(
self,
session: AsyncSession,
user_id: uuid.UUID,
) -> int: ...
async def dismiss(
self,
session: AsyncSession,
notification_id: uuid.UUID,
user_id: uuid.UUID,
) -> None: ...
Why pass AsyncSession explicitly:
- Event producers (lifecycle hooks, health monitor) may hold their own DB session. The service performs a single atomic INSERT/UPDATE inside that session rather than opening a nested transaction. The caller is responsible for commit.
1.2 Notification API Router — FastAPI
Pattern: Standard FastAPI APIRouter, mounted in main.py under /notifications.
Responsibilities:
- Derive
current_userfrom the existing auth dependency. - Validate query parameters (
limitcapped at 100). - Delegate to
NotificationService; return Pydantic response models. - Return
404when a notification does not exist or is owned by a different user. - Read
notification_mute_categoriesfromUserConfigand pass it tolist_notifications.
Router:
# apps/api/src/api/notifications.py
router = APIRouter(prefix="/notifications", tags=["notifications"])
@router.get("", response_model=NotificationListResponse)
async def list_notifications(
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
unread_only: bool = Query(False),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_async_session),
) -> NotificationListResponse: ...
@router.get("/unread", response_model=UnreadCountResponse)
async def get_unread_count(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_async_session),
) -> UnreadCountResponse: ...
@router.patch("/{id}/read", response_model=NotificationItem)
async def mark_notification_read(
id: uuid.UUID,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_async_session),
) -> NotificationItem: ...
@router.post("/mark-all-read", response_model=MarkAllReadResponse)
async def mark_all_read(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_async_session),
) -> MarkAllReadResponse: ...
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
async def dismiss_notification(
id: uuid.UUID,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_async_session),
) -> None: ...
1.3 Event Producer Integration
Lifecycle Hooks (apps/api/src/services/lifecycle_hooks.py):
- After
publish_lifecycle_event()publishes the raw event toInstanceEventBus, callnotification_service.create_notification(...)with:user_id = tool_instance.owner_idcategory = "instance"severitymapped from event type (infofor created/started/stopped/restarted/deleted,errorfor error)titlederived from event type (e.g., "Container started")messagefrom payload messagesource_type = "tool_instances",source_id = tool_instance.id
- Wrap the service call in
try/except; on failure log withcorrelation_idand continue.
Health Monitor (apps/api/src/services/health_monitor.py):
- After detecting a state change and publishing
instance.health_changedorinstance.error, callnotification_service.create_notification(...)with:user_id = tool_instance.owner_idcategory = "health"for health changes,"instance"for errorsseveritymapped from new status (errorfor crash,warningfor unhealthy,infofor recovery)
- Wrap the service call in
try/except; on failure log withcorrelation_idand continue.
Important: Notification creation is fire-and-forget from the event producer perspective. The event pipeline (bus publish, audit row insert) must never be blocked by a notification service failure.
1.4 Frontend Components
NotificationProvider
Pattern: React Context + Provider, modeled after EventProvider (apps/web/src/state/events.tsx).
Responsibilities:
- Maintain
notifications: NotificationItem[]andunreadCount: numberin state. - Run two independent polling intervals:
- Unread count: every 15 seconds.
- Notification list: every 30 seconds (suppressed when dropdown is open to avoid double-fetch).
- Provide mutation functions (
markRead,markAllRead,dismiss) with optimistic updates. - Pause all polling when the document is hidden (
document.hidden). - Immediately refresh the list when the dropdown opens.
Location: apps/web/src/state/notifications.tsx
useNotifications Hook
Pattern: Consumer hook, modeled after useEvents() (apps/web/src/hooks/use-events.ts).
Responsibilities:
- Expose
notifications,unreadCount,isLoading,error. - Expose
markRead(id),markAllRead(),dismiss(id),refreshList(). - Handle optimistic state updates before API call resolves.
- Surface errors silently (log to console) without throwing.
Location: apps/web/src/hooks/use-notifications.ts
NotificationCenter Component
Pattern: Controlled dropdown panel mounted in AppShell header-actions.
Responsibilities:
- Render a bell icon (Phosphor
Bell) with an unread count badge. - Manage
isOpenstate; open on bell click, close on outside click orEscapekey. - Render a scrollable dropdown panel positioned below the bell, right-aligned.
- Render a list of
NotificationItemcomponents. - Show empty state when the list is empty.
- Render a footer with "Mark all as read" action.
- Call
refreshList()immediately when opening. - Hide entirely when
isMobileTerminalis true.
Location: apps/web/src/components/notification-center.tsx
NotificationItem Component
Pattern: Presentational row component.
Responsibilities:
- Display severity icon (mapped from
severityto Phosphor icon). - Display
titleand relative timestamp (e.g., "2m ago"). - Render "Mark read" and "Dismiss" action buttons.
- Apply visual distinction for unread rows (e.g., bolder text, accent border, background tint).
- Apply reduced opacity for read rows.
Props interface:
interface NotificationItemProps {
notification: NotificationItem;
onMarkRead: (id: string) => void;
onDismiss: (id: string) => void;
}
Location: apps/web/src/components/notification-item.tsx
1.5 Toast Coordination — EventToastBridge
Pattern: Modify existing EventToastBridge (apps/web/src/components/event-toast-bridge.tsx) to consult notification preferences before emitting toasts.
Responsibilities:
- Read
userConfig.notification_toast_levelanduserConfig.notification_mute_categoriesfrom the existing user config context. - Before showing a toast for an SSE event:
- Skip if
toastLevel === "none". - Skip if
toastLevel === "errors"and event severity is not"error". - Skip if event category is in
muteCategories.
- Skip if
- The toast bridge does not suppress the backend notification creation; it only controls frontend toast surfacing.
- To avoid duplicate perception: if a toast is shown for an event that also generated a notification, the user sees the toast (ephemeral) and later sees the same item in the center (persistent). This is acceptable because the toast provides immediate attention while the center provides history.
Location: apps/web/src/components/event-toast-bridge.tsx
2. File Structure
New Files
| File | Purpose |
|---|---|
apps/api/src/models/notification.py |
SQLAlchemy Notification model with indexes and FK. |
apps/api/src/services/notification_service.py |
NotificationService singleton with create/list/count/read/dismiss methods. |
apps/api/src/api/notifications.py |
FastAPI router: GET /notifications, GET /notifications/unread, PATCH /{id}/read, POST /mark-all-read, DELETE /{id}. |
apps/api/alembic/versions/2026_05_29_add_notifications_table.py |
Alembic migration creating notifications table, FK, and two indexes. |
apps/web/src/components/notification-center.tsx |
Bell icon + dropdown panel with list, empty state, footer actions, outside-click/Escape close. |
apps/web/src/components/notification-item.tsx |
Single notification row: severity icon, title, relative time, mark-read/dismiss actions. |
apps/web/src/state/notifications.tsx |
NotificationProvider: polling logic (15s unread / 30s list), optimistic mutations, visibility pause. |
apps/web/src/hooks/use-notifications.ts |
useNotifications() hook exposing state and mutation callbacks. |
apps/web/src/utils/icons.ts |
Add "bell" to IconName union and iconRegistry mapping to PhosphorIcons.Bell. |
Modified Files
| File | Purpose |
|---|---|
apps/api/src/main.py |
Import and register notifications_router; import Notification model for Alembic autogenerate. |
apps/api/src/models/__init__.py |
Export Notification for Alembic autogenerate. |
apps/api/src/api/__init__.py |
Export notifications_router (if barrel file exists). |
apps/api/src/services/lifecycle_hooks.py |
After publishing raw event, call NotificationService.create_notification with owner_id as user_id. Wrap in try/except. |
apps/api/src/services/health_monitor.py |
After state change detection, call NotificationService.create_notification with owner_id as user_id. Wrap in try/except. |
apps/web/src/components/app-shell.tsx |
Mount <NotificationCenter /> inside header-actions; hide when isMobileTerminal is true. |
apps/web/src/components/event-toast-bridge.tsx |
Read notification_toast_level and notification_mute_categories from user config; skip toasts based on preference hierarchy. |
apps/web/src/styles.css |
Add .notification-dropdown, .notification-item, .notification-item--unread, .notification-badge, .notification-empty utility classes. |
3. Interface Design
3.1 NotificationService Method Signatures
# apps/api/src/services/notification_service.py
from sqlalchemy.ext.asyncio import AsyncSession
import uuid
from datetime import datetime
from typing import Any
class NotificationService:
async def create_notification(
self,
session: AsyncSession,
user_id: uuid.UUID,
*,
category: str,
severity: str,
title: str,
message: str | None = None,
source_type: str | None = None,
source_id: uuid.UUID | None = None,
metadata: dict[str, Any] | None = None,
) -> Notification: ...
async def list_notifications(
self,
session: AsyncSession,
user_id: uuid.UUID,
*,
limit: int = 20,
offset: int = 0,
unread_only: bool = False,
mute_categories: list[str] | None = None,
) -> tuple[list[Notification], int]:
"""Returns (items, total_count)."""
...
async def get_unread_count(
self,
session: AsyncSession,
user_id: uuid.UUID,
) -> int: ...
async def mark_read(
self,
session: AsyncSession,
notification_id: uuid.UUID,
user_id: uuid.UUID,
) -> Notification: ...
async def mark_all_read(
self,
session: AsyncSession,
user_id: uuid.UUID,
) -> int:
"""Returns number of rows updated."""
...
async def dismiss(
self,
session: AsyncSession,
notification_id: uuid.UUID,
user_id: uuid.UUID,
) -> None: ...
3.2 API Endpoint Request/Response Schemas (Pydantic)
# apps/api/src/api/notifications.py
from pydantic import BaseModel, Field
import uuid
from datetime import datetime
class NotificationItem(BaseModel):
id: uuid.UUID
user_id: uuid.UUID
category: str
severity: str
title: str
message: str | None
source_type: str | None
source_id: uuid.UUID | None
metadata: dict
read_at: datetime | None
dismissed_at: datetime | None
created_at: datetime
class Config:
from_attributes = True
class NotificationListResponse(BaseModel):
items: list[NotificationItem]
total: int
limit: int
offset: int
class UnreadCountResponse(BaseModel):
count: int
class MarkAllReadResponse(BaseModel):
marked_count: int
3.3 Frontend: useNotifications() Return Type
// apps/web/src/hooks/use-notifications.ts
export interface NotificationItem {
id: string;
user_id: string;
category: string;
severity: "info" | "warning" | "error" | "success";
title: string;
message: string | null;
source_type: string | null;
source_id: string | null;
metadata: Record<string, unknown>;
read_at: string | null;
dismissed_at: string | null;
created_at: string;
}
export interface UseNotificationsReturn {
notifications: NotificationItem[];
unreadCount: number;
isLoading: boolean;
error: Error | null;
markRead: (id: string) => Promise<void>;
markAllRead: () => Promise<void>;
dismiss: (id: string) => Promise<void>;
refreshList: () => Promise<void>;
}
export function useNotifications(): UseNotificationsReturn {
// Consumes NotificationContext
}
3.4 NotificationItem Props
// apps/web/src/components/notification-item.tsx
export interface NotificationItemProps {
notification: NotificationItem;
onMarkRead: (id: string) => void;
onDismiss: (id: string) => void;
}
3.5 Toast Coordination: Preference Checks Before Showing Toast
// apps/web/src/components/event-toast-bridge.tsx
interface UserNotificationConfig {
notification_toast_level: "all" | "errors" | "none";
notification_mute_categories: string[];
}
function shouldShowToast(
event: InstanceEventPayload,
config: UserNotificationConfig,
): boolean {
if (config.notification_toast_level === "none") return false;
const severity = mapEventToSeverity(event);
if (config.notification_toast_level === "errors" && severity !== "error") {
return false;
}
const category = mapEventToCategory(event);
if (config.notification_mute_categories.includes(category)) {
return false;
}
return true;
}
4. Data Flow
4.1 Event Producer → NotificationService → DB → REST API → Frontend Polling
HealthMonitor / LifecycleHook
│
├──► Publish raw event to InstanceEventBus
│ │
│ ▼
│ SSE Stream ──► Frontend (toast bridge)
│
└──► NotificationService.create_notification(user_id=owner_id, ...)
│
▼
DB INSERT INTO notifications
│
▼
Frontend polling (every 15s unread / 30s list)
│
├──► GET /notifications/unread ──► Badge count update
│
└──► GET /notifications ──► Dropdown list update
Key invariants:
- The notification row is created independent of the SSE stream. A user who is offline will still have notifications waiting when they return.
- The
NotificationServicecall happens after the raw event is published, so event bus delivery is never blocked by DB write latency. - Event producers wrap the service call in
try/except; a notification insert failure is logged but does not break the event pipeline.
4.2 User Action → PATCH/POST/DELETE → Optimistic UI Update → API Call → State Refresh
User clicks "Mark read" on a notification row
│
▼
NotificationItem calls onMarkRead(id)
│
▼
useNotifications.markRead(id)
│
├──► Optimistic: update local state → set read_at, decrement unreadCount
│
├──► API: PATCH /notifications/{id}/read
│ │
│ ├──► 200 OK → no further state change (already optimistic)
│ │
│ └──► 404/500 → rollback optimistic update, log error
│
└──► Next polling cycle refreshes full list as reconciliation
Optimistic update rules:
markRead: Immediately setread_at = now()on the local item and decrementunreadCountby 1.markAllRead: Immediately setread_at = now()on all items and setunreadCount = 0.dismiss: Immediately remove the item from the local array; if it was unread, decrementunreadCount.- On API failure, revert to the pre-mutation state.
4.3 Toast Flow: SSE Event → Preference Check → Toast Decision
InstanceEventBus publishes event
│
▼
EventContext receives payload
│
▼
EventToastBridge.onEvent(payload)
│
├──► Map event type → category, severity
│
├──► Read userConfig.notification_toast_level
│ │
│ ├──► "none" → skip
│ ├──► "errors" and severity != "error" → skip
│ └──► "all" → continue
│
├──► Read userConfig.notification_mute_categories
│ │
│ ├──► category in mute list → skip
│ └──► not muted → continue
│
└──► Show toast via toast system
│
▼
Notification row already exists (created independently)
Duplicate avoidance:
- The toast and the notification center are intentionally independent surfaces. A user may see a toast for an event and also see the same event in the center later.
- The toast is ephemeral (3–10s); the center is persistent. This is by design, not a bug.
- No deduplication between toast and center is required because they serve different purposes (alert vs. history).
5. State Machine
5.1 Notification Lifecycle
+------------------+
│ unread │
│ (read_at IS NULL)
+--------+---------+
│
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
+-----------+ +-----------+ +-----------+
│ mark read │ │ mark all │ │ dismiss │
│ (PATCH) │ │ read │ │ (DELETE) │
+-----+-----+ │ (POST) │ +-----+-----+
│ +-----+-----+ │
│ │ │
▼ ▼ ▼
+-----------+ +-----------+ +-----------+
│ read │ │ read │ │ dismissed │
│(read_at │ │(read_at │ │(dismissed_│
│ set) │ │ set) │ │ at set) │
+-----------+ +-----------+ +-----------+
│ │ │
│ │ │
└──────────────┴──────────────┘
│
▼
+-----------+
│ absent │
│ from list │
│ queries │
+-----------+
Transitions:
| From | Action | To | DB Change |
|---|---|---|---|
unread |
Mark read | read |
read_at = now() |
unread |
Mark all read | read |
read_at = now() on all unread rows |
unread |
Dismiss | dismissed |
dismissed_at = now() |
read |
Dismiss | dismissed |
dismissed_at = now() |
dismissed |
— | absent |
Excluded from all list queries |
5.2 Badge State
The badge state is a derived value from unreadCount, which is polled every 15 seconds.
Badge state
│
├──► unreadCount === 0 → badge hidden
│
└──► unreadCount > 0 → badge visible, text = unreadCount
(cap display at "99+" if count > 99)
Badge color: Use the existing nav-badge CSS class (red background, white text) for consistency with the session-card health badges.
6. Database Schema
6.1 Table: notifications
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category VARCHAR(32) NOT NULL,
severity VARCHAR(16) NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT,
source_type VARCHAR(64),
source_id UUID,
metadata JSONB NOT NULL DEFAULT '{}',
read_at TIMESTAMPTZ,
dismissed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_notifications_user_created_at
ON notifications(user_id, created_at DESC);
CREATE INDEX idx_notifications_user_unread
ON notifications(user_id, read_at)
WHERE read_at IS NULL;
SQLAlchemy model:
# apps/api/src/models/notification.py
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import ForeignKey, String, Text, DateTime, JSON
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from .base import Base, UUIDPrimaryKeyMixin
class Notification(UUIDPrimaryKeyMixin, Base):
__tablename__ = "notifications"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
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 | None] = mapped_column(Text, nullable=True)
source_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
source_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), nullable=True
)
metadata: Mapped[dict[str, Any]] = mapped_column(
JSONB, nullable=False, 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
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
)
6.2 Migration
File: apps/api/alembic/versions/2026_05_29_add_notifications_table.py
Dependency: Depends on the current Alembic head at time of creation.
"""Add notifications table
Revision ID: <generated>
Revises: <current_head>
Create Date: 2026-05-29
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "<generated>"
down_revision = "<current_head>"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"notifications",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("category", sa.String(length=32), nullable=False),
sa.Column("severity", sa.String(length=16), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("message", sa.Text(), nullable=True),
sa.Column("source_type", sa.String(length=64), nullable=True),
sa.Column("source_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'"), nullable=False),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("dismissed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("idx_notifications_user_created_at", "notifications", ["user_id", sa.text("created_at DESC")])
op.create_index(
"idx_notifications_user_unread",
"notifications",
["user_id", "read_at"],
postgresql_where=sa.text("read_at IS NULL"),
)
def downgrade() -> None:
op.drop_index("idx_notifications_user_unread", table_name="notifications")
op.drop_index("idx_notifications_user_created_at", table_name="notifications")
op.drop_table("notifications")
7. Polling Strategy
7.1 Intervals
| Endpoint | Interval | Condition |
|---|---|---|
GET /notifications/unread |
15 seconds | Always while authenticated |
GET /notifications |
30 seconds | Only while dropdown is closed |
GET /notifications |
Immediate | When dropdown is opened |
7.2 Tab Visibility Handling
When document.visibilityState === "hidden" (user switches tabs or minimizes):
- Pause all polling intervals using
clearInterval. - Resume polling when
visibilityState === "visible"usingdocument.addEventListener("visibilitychange", ...). - On resume, immediately fire both polls to catch up, then restart intervals.
Rationale: Prevents unnecessary server load from background tabs and reduces battery usage on mobile.
7.3 Authentication State
- When the user logs out, stop all polling and clear notification state.
- When the user logs in, start polling immediately (do not wait for the first interval).
7.4 Dropdown Open Behavior
User clicks bell
│
├──► clearInterval(listPollInterval)
│
├──► set isOpen = true
│
├──► immediate GET /notifications
│ │
│ └──► update state
│
└──► on dropdown close: restart 30s list poll, immediate GET /notifications/unread
7.5 Error Resilience
- If a poll request fails (network error, 5xx), log to console and schedule the next poll normally.
- Do not display error UI for polling failures; the user should not be interrupted.
- If a poll returns 401, stop all polling (user session expired).
8. Toast Coordination
8.1 Preference Hierarchy
Preferences are stored in UserConfig.config JSON blob:
{
"notification_mute_categories": ["instance"],
"notification_toast_level": "all"
}
Evaluation order (first match wins):
- Mute categories: If the event's mapped
categoryis innotification_mute_categories, suppress the toast entirely. - Toast level:
"none"→ suppress all toasts."errors"→ only show toasts whenseverity === "error"."all"→ show all toasts (default).
8.2 Event-to-Category/Severity Mapping
// apps/web/src/components/toast-rules.ts (extended)
function mapEventToCategory(event: InstanceEventPayload): string {
if (event.event.startsWith("instance.")) return "instance";
if (event.event.startsWith("health.")) return "health";
return "system";
}
function mapEventToSeverity(event: InstanceEventPayload): "info" | "warning" | "error" | "success" {
switch (event.event) {
case "instance.error":
case "health.error":
return "error";
case "instance.health_changed":
return event.status === "unhealthy" ? "warning" : "success";
case "instance.created":
case "instance.started":
case "instance.stopped":
case "instance.restarted":
case "instance.deleted":
return "info";
default:
return "info";
}
}
8.3 Avoiding Duplicate Toast + Center Entry
There is no deduplication requirement between toast and center entry. The two surfaces serve different purposes:
| Surface | Purpose | Lifetime |
|---|---|---|
| Toast | Immediate attention grabber | 3–10 seconds |
| Center | Persistent history and review | Until dismissed |
Both can show the same event. This is acceptable because:
- The toast auto-dismisses quickly.
- The user may miss the toast and rely on the center.
- The user may see the toast and later want to reference details in the center.
Future enhancement (Phase 3): Add notification_id to toast metadata so clicking the toast can open the center and scroll to the corresponding row.
9. Testing Strategy
9.1 Backend: Service Unit Tests
File: tests/unit/test_notification_service.py
| Test | Description |
|---|---|
test_create_notification |
Call create_notification with all fields; assert row inserted with correct values, read_at NULL, dismissed_at NULL. |
test_list_notifications_orders_by_created_at_desc |
Insert 3 rows; assert list returns newest first. |
test_list_notifications_excludes_dismissed |
Insert 2 rows, dismiss 1; assert only 1 returned. |
test_list_notifications_unread_only |
Insert 1 read, 1 unread; assert unread_only=True returns 1. |
test_list_notifications_mute_categories |
Insert rows with categories instance and system; pass mute_categories=["instance"]; assert only system returned. |
test_get_unread_count |
Insert 5 rows, 2 unread; assert count is 2. |
test_get_unread_count_excludes_dismissed |
Insert 1 unread dismissed; assert count is 0. |
test_mark_read_sets_read_at |
Call mark_read; assert read_at is not NULL. |
test_mark_read_wrong_owner_raises |
User A creates notification; User B calls mark_read; assert 404-equivalent exception. |
test_mark_all_read_affects_only_caller |
User A has 3 unread, User B has 2; mark all for A; assert A=0 unread, B=2 unread. |
test_dismiss_sets_dismissed_at |
Call dismiss; assert dismissed_at is not NULL. |
test_dismiss_wrong_owner_raises |
User A creates notification; User B calls dismiss; assert 404-equivalent exception. |
Fixtures needed:
notification_service: freshNotificationService()instance.db_session: async SQLAlchemy session with rollback after each test.user_a,user_b: test user rows inserted in session.
9.2 Backend: API Integration Tests
File: tests/integration/test_notifications_api.py
| Test | Description |
|---|---|
test_list_requires_auth |
GET /notifications without auth → 401. |
test_list_returns_only_own_notifications |
Create notification for user A; user B requests list → not in response. |
test_list_pagination |
Create 25 notifications; limit=10&offset=10 → items length 10, total 25. |
test_unread_count_endpoint |
Create 3 unread; GET /notifications/unread → {count: 3}. |
test_mark_read_endpoint |
Create unread; PATCH /notifications/{id}/read → 200, read_at set. |
test_mark_read_404_for_other_user |
Create notification for user A; user B PATCH → 404. |
test_mark_all_read_endpoint |
Create 4 unread; POST /notifications/mark-all-read → {marked_count: 4}. |
test_dismiss_endpoint |
Create notification; DELETE /notifications/{id} → 204; subsequent list excludes it. |
test_dismiss_404_for_other_user |
User B DELETE user A's notification → 404. |
test_mute_categories_filter_in_list |
Set user config mute_categories=["instance"]; create instance + system notifications; GET /notifications returns only system. |
9.3 Frontend: Component Tests
File: apps/web/src/components/notification-center.test.tsx
| Test | Description |
|---|---|
renders bell icon |
Mount with provider; assert bell icon visible. |
shows badge when unread count > 0 |
Provider state has unreadCount=3; assert badge text is "3". |
hides badge when unread count is 0 |
Provider state has unreadCount=0; assert badge not in document. |
opens dropdown on bell click |
Simulate click; assert dropdown panel visible. |
closes dropdown on outside click |
Open dropdown; click outside; assert panel not visible. |
closes dropdown on Escape |
Open dropdown; fire Escape key; assert panel not visible. |
renders empty state when no notifications |
List empty; assert empty state text visible. |
renders notification items |
List has 2 items; assert 2 NotificationItem components rendered. |
calls markAllRead on footer button click |
Simulate "Mark all as read" click; assert mock called. |
refreshes list immediately on open |
Open dropdown; assert refreshList mock called. |
File: apps/web/src/components/notification-item.test.tsx
| Test | Description |
|---|---|
displays title and relative time |
Render with sample data; assert title and time visible. |
applies unread styling when read_at is null |
read_at=null; assert unread CSS class present. |
applies read styling when read_at is set |
read_at=iso-string; assert read CSS class present. |
calls onMarkRead when mark read clicked |
Simulate click; assert callback with correct id. |
calls onDismiss when dismiss clicked |
Simulate click; assert callback with correct id. |
9.4 Frontend: Hook Tests
File: apps/web/src/hooks/use-notifications.test.ts
| Test | Description |
|---|---|
returns notifications from context |
Mock provider value; assert hook returns same array. |
returns unreadCount from context |
Mock provider value; assert hook returns correct count. |
optimistically updates on markRead |
Call markRead; assert local state updated before API resolves. |
reverts optimistic update on markRead failure |
Mock API rejection; assert state reverted to original. |
optimistically updates on dismiss |
Call dismiss; assert item removed and count decremented. |
reverts optimistic update on dismiss failure |
Mock API rejection; assert item restored and count restored. |
calls refreshList when invoked |
Mock API; assert GET /notifications called. |
9.5 Frontend: Toast Bridge Tests
File: apps/web/src/components/event-toast-bridge.test.tsx
| Test | Description |
|---|---|
shows toast when level is all and category not muted |
Config {level:"all",mute:[]}; assert toast shown. |
suppresses toast when level is none |
Config {level:"none"}; assert no toast. |
suppresses info toast when level is errors |
Config {level:"errors"}; event severity info; assert no toast. |
shows error toast when level is errors |
Config {level:"errors"}; event severity error; assert toast shown. |
suppresses toast when category is muted |
Config {level:"all",mute:["instance"]}; category instance; assert no toast. |
9.6 End-to-End Test
File: tests/e2e/notification-center.spec.ts (Playwright)
| Test | Description |
|---|---|
container error creates notification and increments badge |
Trigger instance.error via API; within 15s assert badge shows 1; open dropdown; assert error notification visible. |
mark read clears badge |
Open dropdown; click mark read; assert badge hidden. |
dismiss removes from list |
Open dropdown; click dismiss; assert notification not in list; refresh page; assert still absent. |
mark all read clears all |
Create 3 unread; click "Mark all as read"; assert badge hidden; assert all rows styled as read. |
toast respects preferences |
Set toast level to "none"; trigger event; assert no toast appears. |
10. Rollout Plan
| PR | Contents | Estimated Lines | Review Risk |
|---|---|---|---|
| PR 1: Backend core | Migration, model, NotificationService, API router, registration in main.py, unit + integration tests |
~600 | Medium |
| PR 2: Backend integration | Wire lifecycle_hooks.py and health_monitor.py; extend UserConfig schema docs; event producer tests |
~250 | Low |
| PR 3: Frontend core | Icon, NotificationProvider, useNotifications, NotificationCenter, NotificationItem, styles, AppShell integration, component + hook tests |
~700 | Medium |
| PR 4: Toast coordination | Update EventToastBridge + toast-rules.ts; preference UI wiring; bridge + preference tests |
~250 | Low |
Dependency order: PR 1 → PR 2 → PR 3 → PR 4. PR 3 depends on PR 1/2 backend APIs. PR 4 depends on PR 3 UI.
11. Decisions
| ID | Decision | Rationale |
|---|---|---|
| D1 | NotificationService accepts AsyncSession instead of managing its own |
Keeps service lightweight and avoids nested transaction issues when called from event producers that already hold a session. |
| D2 | Soft-delete via dismissed_at |
Preserves audit history; allows future "recently dismissed" or admin analytics features. |
| D3 | Partial index on read_at IS NULL |
Unread count is queried every 15 seconds per active user; partial index keeps query small and fast. |
| D4 | Poll instead of SSE for Phase 1 | Avoids redesigning SSE multiplexing for per-user streams. REST polling is simpler, cache-friendly, and sufficient for MVP. |
| D5 | No deduplication between toast and center | The surfaces serve different purposes (alert vs. history). Both showing the same event is acceptable. |
| D6 | UserConfig JSON blob for preferences |
Matches existing pattern (theme, editor, git identity). No schema migration needed when adding keys. |
| D7 | Pause polling on document.hidden |
Reduces server load from background tabs and improves battery life on mobile. |
| D8 | category and severity stored as strings, not enums |
Extensible without Alembic migrations when new sources introduce categories. |
| D9 | Event producers call NotificationService directly, not via EventBus |
Makes the dependency visible and avoids hidden side effects. The bus remains transport for raw events only. |
| D10 | Cap badge display at "99+" | Prevents layout shift if a user accumulates an extreme number of unread notifications. |