From 97a62265082a3ed236b38631cd1d738ce5ac2420 Mon Sep 17 00:00:00 2001
From: Matyas Fenyves <16389204+uhhgoat@users.noreply.github.com>
Date: Sun, 5 Jul 2026 09:58:53 +0200
Subject: [PATCH 1/2] feat(notifications): add durable inbox
---
app.py | 3 +
core/database.py | 59 +++++
routes/notification_routes.py | 87 ++++++
src/database.py | 2 +
src/notifications.py | 438 +++++++++++++++++++++++++++++++
src/task_scheduler.py | 39 ++-
static/app.js | 4 +
static/js/notifications.js | 428 ++++++++++++++++++++++++++++++
static/js/tasks.js | 3 +
static/style.css | 199 ++++++++++++++
tests/test_notification_inbox.py | 145 ++++++++++
11 files changed, 1405 insertions(+), 2 deletions(-)
create mode 100644 routes/notification_routes.py
create mode 100644 src/notifications.py
create mode 100644 static/js/notifications.js
create mode 100644 tests/test_notification_inbox.py
diff --git a/app.py b/app.py
index 2ae5ec761..8ddfd75ee 100644
--- a/app.py
+++ b/app.py
@@ -763,6 +763,9 @@ set_task_scheduler(task_scheduler)
from routes.task_routes import setup_task_routes
app.include_router(setup_task_routes(task_scheduler))
+from routes.notification_routes import setup_notification_routes
+app.include_router(setup_notification_routes())
+
from routes.assistant_routes import setup_assistant_routes
app.include_router(setup_assistant_routes(task_scheduler))
diff --git a/core/database.py b/core/database.py
index a9ad90b8b..495c0f558 100644
--- a/core/database.py
+++ b/core/database.py
@@ -743,6 +743,65 @@ class TaskRun(Base):
)
+class NotificationEvent(Base):
+ """Durable notification/audit event.
+
+ System events are logged here even when they do not deserve a visible UI
+ entry. Events that should appear in the notification inbox are paired with
+ one or more NotificationInboxItem rows.
+ """
+ __tablename__ = "notification_events"
+
+ id = Column(String, primary_key=True, index=True)
+ owner = Column(String, nullable=True, index=True)
+ event_class = Column(String, nullable=False, default="system_event", index=True)
+ title = Column(String, nullable=False, default="")
+ body = Column(Text, nullable=True)
+ source_type = Column(String, nullable=True, index=True)
+ source_id = Column(String, nullable=True, index=True)
+ source_url = Column(Text, nullable=True)
+ severity = Column(String, nullable=False, default="info", index=True)
+ category = Column(String, nullable=True, index=True)
+ dedupe_key = Column(String, nullable=True, index=True)
+ metadata_json = Column(JSON, nullable=True)
+ retention_expires_at = Column(DateTime, nullable=True, index=True)
+ created_at = Column(DateTime, nullable=False, default=utcnow_naive, index=True)
+
+ __table_args__ = (
+ Index('ix_notification_events_owner_created', 'owner', 'created_at'),
+ Index('ix_notification_events_owner_class_created', 'owner', 'event_class', 'created_at'),
+ Index('ix_notification_events_owner_dedupe', 'owner', 'dedupe_key'),
+ )
+
+
+class NotificationInboxItem(Base):
+ """Visible/dismissible notification inbox entry derived from an event."""
+ __tablename__ = "notification_inbox_items"
+
+ id = Column(String, primary_key=True, index=True)
+ owner = Column(String, nullable=True, index=True)
+ event_id = Column(String, ForeignKey("notification_events.id", ondelete="CASCADE"), nullable=False, index=True)
+ notification_kind = Column(String, nullable=False, default="inbox_record", index=True)
+ primary_action = Column(String, nullable=True)
+ action_url = Column(Text, nullable=True)
+ is_read = Column(Boolean, default=False, nullable=False, index=True)
+ read_at = Column(DateTime, nullable=True)
+ dismissed_at = Column(DateTime, nullable=True, index=True)
+ archived_at = Column(DateTime, nullable=True, index=True)
+ created_at = Column(DateTime, nullable=False, default=utcnow_naive, index=True)
+
+ event = relationship(
+ "NotificationEvent",
+ backref=backref("inbox_items", cascade="all, delete-orphan"),
+ )
+
+ __table_args__ = (
+ Index('ix_notification_inbox_owner_created', 'owner', 'created_at'),
+ Index('ix_notification_inbox_owner_unread', 'owner', 'is_read', 'created_at'),
+ Index('ix_notification_inbox_owner_visible', 'owner', 'archived_at', 'dismissed_at', 'created_at'),
+ )
+
+
class Memory(Base):
"""
SQLAlchemy model for Memory table.
diff --git a/routes/notification_routes.py b/routes/notification_routes.py
new file mode 100644
index 000000000..5c550dd3b
--- /dev/null
+++ b/routes/notification_routes.py
@@ -0,0 +1,87 @@
+"""Routes for durable notification events and inbox items."""
+
+from typing import Optional
+
+from fastapi import APIRouter, HTTPException, Request
+from pydantic import BaseModel
+
+from src.auth_helpers import require_user
+from src.notifications import (
+ archive_notification,
+ count_unread_notifications,
+ dismiss_notification,
+ list_inbox_notifications,
+ list_notification_events,
+ mark_notification_read,
+)
+
+
+class NotificationReadRequest(BaseModel):
+ read: bool = True
+
+
+def setup_notification_routes() -> APIRouter:
+ router = APIRouter(prefix="/api/notifications", tags=["notifications"])
+
+ @router.get("")
+ async def list_notifications(
+ request: Request,
+ limit: int = 50,
+ include_archived: bool = False,
+ include_dismissed: bool = False,
+ ):
+ owner = require_user(request)
+ return {
+ "notifications": list_inbox_notifications(
+ owner=owner,
+ limit=limit,
+ include_archived=include_archived,
+ include_dismissed=include_dismissed,
+ )
+ }
+
+ @router.get("/count")
+ async def notification_count(request: Request):
+ owner = require_user(request)
+ return {"unread": count_unread_notifications(owner=owner)}
+
+ @router.get("/events")
+ async def notification_events(
+ request: Request,
+ limit: int = 100,
+ event_class: Optional[str] = None,
+ ):
+ owner = require_user(request)
+ return {
+ "events": list_notification_events(
+ owner=owner,
+ limit=limit,
+ event_class=event_class,
+ )
+ }
+
+ @router.post("/{item_id}/read")
+ async def read_notification(request: Request, item_id: str, body: NotificationReadRequest):
+ owner = require_user(request)
+ item = mark_notification_read(item_id=item_id, owner=owner, read=body.read)
+ if not item:
+ raise HTTPException(404, "Notification not found")
+ return item
+
+ @router.post("/{item_id}/dismiss")
+ async def dismiss_inbox_notification(request: Request, item_id: str):
+ owner = require_user(request)
+ item = dismiss_notification(item_id=item_id, owner=owner)
+ if not item:
+ raise HTTPException(404, "Notification not found")
+ return item
+
+ @router.post("/{item_id}/archive")
+ async def archive_inbox_notification(request: Request, item_id: str):
+ owner = require_user(request)
+ item = archive_notification(item_id=item_id, owner=owner)
+ if not item:
+ raise HTTPException(404, "Notification not found")
+ return item
+
+ return router
diff --git a/src/database.py b/src/database.py
index 8f075a564..278be42c1 100644
--- a/src/database.py
+++ b/src/database.py
@@ -23,6 +23,8 @@ from core.database import ( # explicit re-exports for IDE/type-checker visibili
CrewMember,
ScheduledTask,
TaskRun,
+ NotificationEvent,
+ NotificationInboxItem,
Memory,
init_db,
get_db,
diff --git a/src/notifications.py b/src/notifications.py
new file mode 100644
index 000000000..67371f97b
--- /dev/null
+++ b/src/notifications.py
@@ -0,0 +1,438 @@
+"""Durable notification event and inbox helpers."""
+
+from __future__ import annotations
+
+import uuid
+from datetime import datetime, timezone
+from typing import Any
+
+from sqlalchemy.orm import Session as OrmSession
+
+from core.database import (
+ NotificationEvent,
+ NotificationInboxItem,
+ SessionLocal,
+ utcnow_naive,
+)
+
+SYSTEM_EVENT = "system_event"
+INBOX_RECORD = "inbox_record"
+ACTIONABLE = "actionable"
+
+EVENT_CLASSES = {SYSTEM_EVENT, INBOX_RECORD, ACTIONABLE}
+INBOX_CLASSES = {INBOX_RECORD, ACTIONABLE}
+SEVERITIES = {"info", "attention", "urgent", "error"}
+
+
+def _normalize_owner(owner: str | None) -> str | None:
+ owner = (owner or "").strip()
+ return owner or None
+
+
+def _clamp_text(value: str | None, limit: int) -> str | None:
+ if value is None:
+ return None
+ text = str(value)
+ return text if len(text) <= limit else text[: limit - 1] + "..."
+
+
+def _safe_limit(limit: int | None, default: int = 50, maximum: int = 200) -> int:
+ try:
+ n = int(limit or default)
+ except (TypeError, ValueError):
+ n = default
+ return max(1, min(maximum, n))
+
+
+def _iso(dt: datetime | None) -> str | None:
+ if not dt:
+ return None
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
+
+
+def _owner_query(query, model, owner: str | None):
+ owner = _normalize_owner(owner)
+ if owner is None:
+ return query.filter(model.owner == None) # noqa: E711
+ return query.filter(model.owner == owner)
+
+
+def _validate_event_class(event_class: str) -> str:
+ value = (event_class or SYSTEM_EVENT).strip()
+ if value not in EVENT_CLASSES:
+ raise ValueError(f"Unknown notification event_class: {value}")
+ return value
+
+
+def _validate_severity(severity: str) -> str:
+ value = (severity or "info").strip()
+ if value not in SEVERITIES:
+ raise ValueError(f"Unknown notification severity: {value}")
+ return value
+
+
+def _serialize_event(event: NotificationEvent) -> dict[str, Any]:
+ return {
+ "id": event.id,
+ "owner": event.owner,
+ "event_class": event.event_class,
+ "title": event.title,
+ "body": event.body,
+ "source_type": event.source_type,
+ "source_id": event.source_id,
+ "source_url": event.source_url,
+ "severity": event.severity,
+ "category": event.category,
+ "dedupe_key": event.dedupe_key,
+ "metadata": event.metadata_json or {},
+ "retention_expires_at": _iso(event.retention_expires_at),
+ "created_at": _iso(event.created_at),
+ }
+
+
+def _serialize_item(item: NotificationInboxItem) -> dict[str, Any]:
+ event = item.event
+ return {
+ "id": item.id,
+ "owner": item.owner,
+ "event_id": item.event_id,
+ "notification_kind": item.notification_kind,
+ "primary_action": item.primary_action,
+ "action_url": item.action_url,
+ "is_read": bool(item.is_read),
+ "read_at": _iso(item.read_at),
+ "dismissed_at": _iso(item.dismissed_at),
+ "archived_at": _iso(item.archived_at),
+ "created_at": _iso(item.created_at),
+ "event": _serialize_event(event) if event else None,
+ "event_class": event.event_class if event else None,
+ "title": event.title if event else "",
+ "body": event.body if event else None,
+ "source_type": event.source_type if event else None,
+ "source_id": event.source_id if event else None,
+ "source_url": event.source_url if event else None,
+ "severity": event.severity if event else "info",
+ "category": event.category if event else None,
+ "metadata": event.metadata_json if event and event.metadata_json else {},
+ }
+
+
+def _upsert_event(
+ db: OrmSession,
+ *,
+ owner: str | None,
+ event_class: str,
+ title: str,
+ body: str | None = None,
+ source_type: str | None = None,
+ source_id: str | None = None,
+ source_url: str | None = None,
+ severity: str = "info",
+ category: str | None = None,
+ dedupe_key: str | None = None,
+ metadata: dict[str, Any] | None = None,
+ retention_expires_at: datetime | None = None,
+) -> NotificationEvent:
+ owner = _normalize_owner(owner)
+ event_class = _validate_event_class(event_class)
+ severity = _validate_severity(severity)
+ event = None
+ if dedupe_key:
+ event = _owner_query(db.query(NotificationEvent), NotificationEvent, owner).filter(
+ NotificationEvent.dedupe_key == dedupe_key
+ ).first()
+ if event is None:
+ event = NotificationEvent(
+ id=str(uuid.uuid4()),
+ owner=owner,
+ dedupe_key=dedupe_key,
+ created_at=utcnow_naive(),
+ )
+ db.add(event)
+
+ event.event_class = event_class
+ event.title = _clamp_text(title or "Notification", 240) or "Notification"
+ event.body = _clamp_text(body, 20000)
+ event.source_type = _clamp_text(source_type, 80)
+ event.source_id = _clamp_text(source_id, 240)
+ event.source_url = _clamp_text(source_url, 2000)
+ event.severity = severity
+ event.category = _clamp_text(category, 80)
+ event.metadata_json = metadata or {}
+ event.retention_expires_at = retention_expires_at
+ return event
+
+
+def record_notification_event(
+ *,
+ owner: str | None,
+ event_class: str = SYSTEM_EVENT,
+ title: str,
+ body: str | None = None,
+ source_type: str | None = None,
+ source_id: str | None = None,
+ source_url: str | None = None,
+ severity: str = "info",
+ category: str | None = None,
+ dedupe_key: str | None = None,
+ metadata: dict[str, Any] | None = None,
+ retention_expires_at: datetime | None = None,
+) -> dict[str, Any]:
+ db = SessionLocal()
+ try:
+ event = _upsert_event(
+ db,
+ owner=owner,
+ event_class=event_class,
+ title=title,
+ body=body,
+ source_type=source_type,
+ source_id=source_id,
+ source_url=source_url,
+ severity=severity,
+ category=category,
+ dedupe_key=dedupe_key,
+ metadata=metadata,
+ retention_expires_at=retention_expires_at,
+ )
+ db.commit()
+ db.refresh(event)
+ return _serialize_event(event)
+ finally:
+ db.close()
+
+
+def create_inbox_notification(
+ *,
+ owner: str | None,
+ notification_kind: str = INBOX_RECORD,
+ title: str,
+ body: str | None = None,
+ source_type: str | None = None,
+ source_id: str | None = None,
+ source_url: str | None = None,
+ severity: str = "info",
+ category: str | None = None,
+ dedupe_key: str | None = None,
+ metadata: dict[str, Any] | None = None,
+ primary_action: str | None = None,
+ action_url: str | None = None,
+ retention_expires_at: datetime | None = None,
+) -> dict[str, Any]:
+ if notification_kind not in INBOX_CLASSES:
+ raise ValueError(f"Unknown inbox notification_kind: {notification_kind}")
+ db = SessionLocal()
+ try:
+ event = _upsert_event(
+ db,
+ owner=owner,
+ event_class=notification_kind,
+ title=title,
+ body=body,
+ source_type=source_type,
+ source_id=source_id,
+ source_url=source_url,
+ severity=severity,
+ category=category,
+ dedupe_key=dedupe_key,
+ metadata=metadata,
+ retention_expires_at=retention_expires_at,
+ )
+ db.flush()
+ item = db.query(NotificationInboxItem).filter(
+ NotificationInboxItem.event_id == event.id
+ ).first()
+ if item is None:
+ item = NotificationInboxItem(
+ id=str(uuid.uuid4()),
+ owner=_normalize_owner(owner),
+ event_id=event.id,
+ notification_kind=notification_kind,
+ created_at=utcnow_naive(),
+ )
+ db.add(item)
+ item.notification_kind = notification_kind
+ item.primary_action = _clamp_text(primary_action, 80)
+ item.action_url = _clamp_text(action_url, 2000)
+ db.commit()
+ db.refresh(item)
+ return _serialize_item(item)
+ finally:
+ db.close()
+
+
+def record_task_notification(
+ *,
+ task_name: str,
+ status: str,
+ task_id: str | None = None,
+ owner: str | None = None,
+ body: str | None = None,
+ run_id: str | None = None,
+ output_target: str | None = None,
+) -> dict[str, Any]:
+ """Persist the durable counterpart to the transient task notification."""
+ clean_status = (status or "").strip().lower() or "unknown"
+ ok = clean_status == "success"
+ clean_name = (task_name or "Task").strip() or "Task"
+ metadata = {
+ "task_id": task_id,
+ "run_id": run_id,
+ "status": clean_status,
+ "output_target": output_target or "session",
+ }
+ source_id = run_id or task_id
+ dedupe_key = f"task-run:{run_id}:{clean_status}" if run_id else None
+
+ if ok and body:
+ return create_inbox_notification(
+ owner=owner,
+ notification_kind=INBOX_RECORD,
+ title=clean_name,
+ body=body,
+ source_type="task_run",
+ source_id=source_id,
+ source_url="#tasks",
+ severity="info",
+ category="task",
+ dedupe_key=dedupe_key,
+ metadata=metadata,
+ primary_action="open_task",
+ action_url=f"odysseus://tasks/{task_id or ''}",
+ )
+
+ if not ok:
+ return create_inbox_notification(
+ owner=owner,
+ notification_kind=ACTIONABLE,
+ title=f"Task failed: {clean_name}",
+ body=body,
+ source_type="task_run",
+ source_id=source_id,
+ source_url="#tasks",
+ severity="error",
+ category="task",
+ dedupe_key=dedupe_key,
+ metadata=metadata,
+ primary_action="open_task",
+ action_url=f"odysseus://tasks/{task_id or ''}",
+ )
+
+ return record_notification_event(
+ owner=owner,
+ event_class=SYSTEM_EVENT,
+ title=f"Task finished: {clean_name}",
+ body=None,
+ source_type="task_run",
+ source_id=source_id,
+ source_url="#tasks",
+ severity="info",
+ category="task",
+ dedupe_key=dedupe_key,
+ metadata=metadata,
+ )
+
+
+def list_inbox_notifications(
+ *,
+ owner: str | None,
+ limit: int = 50,
+ include_archived: bool = False,
+ include_dismissed: bool = False,
+) -> list[dict[str, Any]]:
+ db = SessionLocal()
+ try:
+ q = _owner_query(db.query(NotificationInboxItem), NotificationInboxItem, owner)
+ if not include_archived:
+ q = q.filter(NotificationInboxItem.archived_at == None) # noqa: E711
+ if not include_dismissed:
+ q = q.filter(NotificationInboxItem.dismissed_at == None) # noqa: E711
+ items = q.order_by(NotificationInboxItem.created_at.desc()).limit(_safe_limit(limit)).all()
+ return [_serialize_item(item) for item in items]
+ finally:
+ db.close()
+
+
+def list_notification_events(
+ *,
+ owner: str | None,
+ limit: int = 100,
+ event_class: str | None = None,
+) -> list[dict[str, Any]]:
+ db = SessionLocal()
+ try:
+ q = _owner_query(db.query(NotificationEvent), NotificationEvent, owner)
+ if event_class:
+ q = q.filter(NotificationEvent.event_class == event_class)
+ events = q.order_by(NotificationEvent.created_at.desc()).limit(_safe_limit(limit, default=100)).all()
+ return [_serialize_event(event) for event in events]
+ finally:
+ db.close()
+
+
+def count_unread_notifications(*, owner: str | None) -> int:
+ db = SessionLocal()
+ try:
+ q = _owner_query(db.query(NotificationInboxItem), NotificationInboxItem, owner).filter(
+ NotificationInboxItem.is_read == False, # noqa: E712
+ NotificationInboxItem.dismissed_at == None, # noqa: E711
+ NotificationInboxItem.archived_at == None, # noqa: E711
+ )
+ return int(q.count())
+ finally:
+ db.close()
+
+
+def mark_notification_read(*, item_id: str, owner: str | None, read: bool = True) -> dict[str, Any] | None:
+ db = SessionLocal()
+ try:
+ item = _owner_query(db.query(NotificationInboxItem), NotificationInboxItem, owner).filter(
+ NotificationInboxItem.id == item_id
+ ).first()
+ if not item:
+ return None
+ item.is_read = bool(read)
+ item.read_at = utcnow_naive() if read else None
+ db.commit()
+ db.refresh(item)
+ return _serialize_item(item)
+ finally:
+ db.close()
+
+
+def dismiss_notification(*, item_id: str, owner: str | None) -> dict[str, Any] | None:
+ db = SessionLocal()
+ try:
+ item = _owner_query(db.query(NotificationInboxItem), NotificationInboxItem, owner).filter(
+ NotificationInboxItem.id == item_id
+ ).first()
+ if not item:
+ return None
+ item.is_read = True
+ item.read_at = item.read_at or utcnow_naive()
+ item.dismissed_at = utcnow_naive()
+ db.commit()
+ db.refresh(item)
+ return _serialize_item(item)
+ finally:
+ db.close()
+
+
+def archive_notification(*, item_id: str, owner: str | None) -> dict[str, Any] | None:
+ db = SessionLocal()
+ try:
+ item = _owner_query(db.query(NotificationInboxItem), NotificationInboxItem, owner).filter(
+ NotificationInboxItem.id == item_id
+ ).first()
+ if not item:
+ return None
+ item.is_read = True
+ item.read_at = item.read_at or utcnow_naive()
+ item.archived_at = utcnow_naive()
+ db.commit()
+ db.refresh(item)
+ return _serialize_item(item)
+ finally:
+ db.close()
diff --git a/src/task_scheduler.py b/src/task_scheduler.py
index d5b1dad62..2be16e5f2 100644
--- a/src/task_scheduler.py
+++ b/src/task_scheduler.py
@@ -342,6 +342,7 @@ class TaskScheduler:
# tasks could be double-dispatched.
self._executing_lock = asyncio.Lock()
self._pending_notifications = [] # completed task notifications
+ self._durable_notifications_enabled = True
self._task_defer_counts = {}
# Strict serial execution — exactly one task runs at a time. Anything
# else (manual trigger, scheduled dispatch, task chain) waits behind
@@ -397,12 +398,35 @@ class TaskScheduler:
logger.debug("Task abort marker failed for %s", task_id, exc_info=True)
return False
- def add_notification(self, task_name: str, status: str, task_id: str = None, owner: str = None, body: str = None):
+ def add_notification(
+ self,
+ task_name: str,
+ status: str,
+ task_id: str = None,
+ owner: str = None,
+ body: str = None,
+ run_id: str = None,
+ output_target: str = None,
+ ):
"""Store a notification about a completed task run. Tagged with the
task's owner so `pop_notifications` can return only that user's
notifications and prevent cross-tenant drain. `body` is the result
text — populated when output_target='notification' so the client can
show a rich browser Notification, not just a toast."""
+ if getattr(self, "_durable_notifications_enabled", False):
+ try:
+ from src.notifications import record_task_notification
+ record_task_notification(
+ task_name=task_name,
+ status=status,
+ task_id=task_id,
+ owner=owner,
+ body=body,
+ run_id=run_id,
+ output_target=output_target,
+ )
+ except Exception:
+ logger.debug("Durable task notification write failed", exc_info=True)
self._pending_notifications.append({
"task_name": task_name,
"status": status,
@@ -1042,6 +1066,8 @@ class TaskScheduler:
task_id,
owner=task.owner,
body=run.result if output == "notification" else None,
+ run_id=run_id,
+ output_target=output,
)
elif run.status == "error":
self.add_notification(
@@ -1050,6 +1076,8 @@ class TaskScheduler:
task_id,
owner=task.owner,
body=run.error or run.result,
+ run_id=run_id,
+ output_target=output,
)
# Log result to the assistant chat so all task activity is visible.
@@ -1095,7 +1123,14 @@ class TaskScheduler:
except Exception:
_should_notify_error = False
if _should_notify_error:
- self.add_notification(f"Task {task_id}", "error", task_id, owner=_owner)
+ self.add_notification(
+ f"Task {task_id}",
+ "error",
+ task_id,
+ owner=_owner,
+ body=f"{type(exec_exc).__name__}: {exec_exc}",
+ run_id=run_id,
+ )
try:
# Persist the actual exception message so the UI can show it
err_text = f"{type(exec_exc).__name__}: {exec_exc}"
diff --git a/static/app.js b/static/app.js
index 97f0ae77e..84402aa31 100644
--- a/static/app.js
+++ b/static/app.js
@@ -23,6 +23,7 @@ import voiceRecorderModule from './js/voiceRecorder.js';
import censorModule from './js/censor.js';
import galleryModule from './js/gallery.js';
import tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1';
+import notificationsModule from './js/notifications.js';
import calendarModule from './js/calendar.js';
import notesModule from './js/notes.js';
import adminModule from './js/admin.js?v=20260716openrouter3';
@@ -3737,6 +3738,9 @@ function startOdysseusApp() {
if (compareModule) {
compareModule.init(API_BASE);
}
+ if (notificationsModule) {
+ notificationsModule.init(API_BASE);
+ }
researchPanelModule.init(API_BASE, markdownModule, sessionModule);
// Initialize document editor module
if (documentModule) {
diff --git a/static/js/notifications.js b/static/js/notifications.js
new file mode 100644
index 000000000..62f050b93
--- /dev/null
+++ b/static/js/notifications.js
@@ -0,0 +1,428 @@
+import uiModule from './ui.js';
+
+let API_BASE = window.location.origin;
+let _panelOpen = false;
+let _mode = 'inbox';
+let _countTimer = null;
+let _bound = false;
+let _anchorButton = null;
+
+const ICONS = {
+ bell: '